R-101 + F-DIAG: the restore dialog names the last SUCCESSFUL copy (v0.182.0)
Tier2LastRun is the attempt clock and was rendered as 'Legutóbbi másolat' in the restore confirm dialog. New LastSuccess + SuccessTracked anchor; tier2Update makes the three rebuild sites safe by construction. F-DIAG: six distinct causes, target-aware redaction.
This commit is contained in:
@@ -530,67 +530,97 @@ func (m *Manager) Tier2Info(stackName string) Tier2Info {
|
||||
|
||||
// --- status persistence (drives the "2. mentés" UI card) ---
|
||||
|
||||
// withTier2Prefs carries the customer-preference fields (UserDisabled/PreferredTarget) from any
|
||||
// existing config into a freshly-built status struct, so a runner status write never clobbers them.
|
||||
func (m *Manager) withTier2Prefs(stackName string, cfg *settings.CrossDriveBackup) *settings.CrossDriveBackup {
|
||||
if m.settings != nil {
|
||||
if existing := m.settings.GetCrossDriveConfig(stackName); existing != nil {
|
||||
cfg.UserDisabled = existing.UserDisabled
|
||||
cfg.PreferredTarget = existing.PreferredTarget
|
||||
}
|
||||
// tier2Update applies a run outcome onto a COPY OF THE EXISTING ROW, then persists it.
|
||||
//
|
||||
// R-101 Part 2 — SAFE BY CONSTRUCTION, and this replaced a real hazard rather than tidying one. The
|
||||
// three record* helpers each used to build a WHOLE `CrossDriveBackup` literal, with `withTier2Prefs`
|
||||
// re-applying exactly two fields (UserDisabled, PreferredTarget). Every other field not named in the
|
||||
// literal was silently zeroed on every status write. That is fine while the struct is stable and
|
||||
// catastrophic the moment a field is added: R-101 adds `LastSuccess`, and under the old shape
|
||||
// `recordTier2Failure` would have CLEARED it — the mirror-image of the defect being fixed, firing on
|
||||
// the first failure instead of lying dormant.
|
||||
//
|
||||
// Starting from the existing row inverts the default: a new field carries over unless a caller
|
||||
// deliberately overwrites it. The compile-safe form the R-100 review asked for; nothing is preserved
|
||||
// by a list that can fall out of date.
|
||||
//
|
||||
// Callers must therefore CLEAR explicitly what a run invalidates (a stale LastError on success, a
|
||||
// stale size on failure) — the old behaviour those clears reproduce is preserved exactly.
|
||||
func (m *Manager) tier2Update(stackName string, mutate func(*settings.CrossDriveBackup)) {
|
||||
if m.settings == nil {
|
||||
return
|
||||
}
|
||||
var cfg settings.CrossDriveBackup
|
||||
if existing := m.settings.GetCrossDriveConfig(stackName); existing != nil {
|
||||
cfg = *existing // value copy — EVERY field carries over by default
|
||||
}
|
||||
// One-time migration of a pre-anchor row. Under the old code `LastStatus=="ok"` with a LastRun
|
||||
// means that run DID succeed, so adopting it as the initial anchor is truthful — and it is what
|
||||
// keeps the deploy quiet: without it every existing row would flip to "never succeeded" at once
|
||||
// (all 7 rows on the fleet were pre-anchor). A row whose last known state was an ERROR seeds
|
||||
// nothing, because nothing in the old data evidences a success.
|
||||
if !cfg.SuccessTracked {
|
||||
if cfg.LastStatus == "ok" && cfg.LastRun != "" {
|
||||
cfg.LastSuccess = cfg.LastRun
|
||||
}
|
||||
cfg.SuccessTracked = true
|
||||
}
|
||||
mutate(&cfg)
|
||||
if err := m.settings.SetCrossDriveConfig(stackName, &cfg); err != nil {
|
||||
m.logger.Printf("[WARN] [backup] Tier 2 status persist for %s failed: %v", stackName, err)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func (m *Manager) recordTier2Success(stackName string, target *Tier2Target, sizeBytes int64, warning string, dur time.Duration) {
|
||||
if m.settings == nil {
|
||||
return
|
||||
}
|
||||
if err := m.settings.SetCrossDriveConfig(stackName, m.withTier2Prefs(stackName, &settings.CrossDriveBackup{
|
||||
Enabled: true,
|
||||
Method: "rsync",
|
||||
DestinationPath: target.NamespaceRoot,
|
||||
Schedule: "daily",
|
||||
LastRun: time.Now().Format(time.RFC3339),
|
||||
LastStatus: "ok",
|
||||
LastWarning: strings.TrimSpace(warning),
|
||||
LastDuration: dur.Round(time.Second).String(),
|
||||
LastSizeHuman: humanizeBytes(sizeBytes),
|
||||
})); err != nil {
|
||||
m.logger.Printf("[WARN] [backup] Tier 2 status persist (ok) for %s failed: %v", stackName, err)
|
||||
}
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
m.tier2Update(stackName, func(c *settings.CrossDriveBackup) {
|
||||
c.Enabled = true
|
||||
c.Method = "rsync"
|
||||
c.DestinationPath = target.NamespaceRoot
|
||||
c.Schedule = "daily"
|
||||
c.LastRun = now
|
||||
// R-101: the anchor. Only this branch advances it; no failure branch clears it.
|
||||
c.LastSuccess = now
|
||||
c.LastStatus = "ok"
|
||||
c.LastWarning = strings.TrimSpace(warning)
|
||||
c.LastDuration = dur.Round(time.Second).String()
|
||||
c.LastSizeHuman = humanizeBytes(sizeBytes)
|
||||
c.LastError = "" // a success invalidates the previous error
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Manager) recordTier2Failure(stackName string, target *Tier2Target, cause error) {
|
||||
if m.settings == nil {
|
||||
return
|
||||
}
|
||||
if err := m.settings.SetCrossDriveConfig(stackName, m.withTier2Prefs(stackName, &settings.CrossDriveBackup{
|
||||
Enabled: true,
|
||||
Method: "rsync",
|
||||
DestinationPath: target.NamespaceRoot,
|
||||
Schedule: "daily",
|
||||
LastRun: time.Now().Format(time.RFC3339),
|
||||
LastStatus: "error",
|
||||
LastError: cause.Error(),
|
||||
})); err != nil {
|
||||
m.logger.Printf("[WARN] [backup] Tier 2 status persist (error) for %s failed: %v", stackName, err)
|
||||
}
|
||||
m.tier2Update(stackName, func(c *settings.CrossDriveBackup) {
|
||||
c.Enabled = true
|
||||
c.Method = "rsync"
|
||||
c.DestinationPath = target.NamespaceRoot
|
||||
c.Schedule = "daily"
|
||||
c.LastRun = time.Now().Format(time.RFC3339) // the ATTEMPT clock — advances on failure, by design
|
||||
c.LastStatus = "error"
|
||||
c.LastError = cause.Error()
|
||||
// LastSuccess is deliberately UNTOUCHED: a failure neither advances nor clears the anchor.
|
||||
// Clearing it would make one bad night read as "no copy has ever succeeded".
|
||||
c.LastWarning = "" // a warning from the last successful run does not describe this one
|
||||
c.LastDuration = "" // preserving the old literal's clears exactly
|
||||
c.LastSizeHuman = "" // ditto — a stale size would describe a copy this run did not make
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Manager) recordTier2NoTarget(stackName, reason string) {
|
||||
if m.settings == nil {
|
||||
return
|
||||
}
|
||||
if err := m.settings.SetCrossDriveConfig(stackName, m.withTier2Prefs(stackName, &settings.CrossDriveBackup{
|
||||
Enabled: false,
|
||||
Method: "rsync",
|
||||
Schedule: "daily",
|
||||
LastStatus: "no_target",
|
||||
LastError: reason,
|
||||
})); err != nil {
|
||||
m.logger.Printf("[WARN] [backup] Tier 2 status persist (no_target) for %s failed: %v", stackName, err)
|
||||
}
|
||||
m.tier2Update(stackName, func(c *settings.CrossDriveBackup) {
|
||||
c.Enabled = false
|
||||
c.Method = "rsync"
|
||||
c.Schedule = "daily"
|
||||
c.DestinationPath = ""
|
||||
c.LastStatus = "no_target"
|
||||
c.LastError = reason
|
||||
c.LastRun = ""
|
||||
// LastSuccess survives: "there is no destination drive right now" is not evidence that the
|
||||
// last successful copy never happened. The UI gates on LastRun here, so nothing is rendered.
|
||||
c.LastWarning = ""
|
||||
c.LastDuration = ""
|
||||
c.LastSizeHuman = ""
|
||||
})
|
||||
}
|
||||
|
||||
func tier2NoTargetReason(err error) string {
|
||||
|
||||
Reference in New Issue
Block a user