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:
2026-07-28 16:36:47 +02:00
parent 3db8bfb953
commit 3f048e042b
12 changed files with 829 additions and 72 deletions
+81 -51
View File
@@ -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 {