R-645: the night backup skips an app whose pinned version is not the one it runs (09 decision 142)
Every night leg (DB dump, volume dump, recovery-unit capture, Tier-2 mirror) now leaves alone an app whose app.yaml pin (pinned_images) differs from its running record (installed_images) - the state a failed update leaves behind. A hold lifted by hand (--clear-restore-hold + restart) no longer lets the capture write the just-failed definition over the good unit. Unknown (no pin, no record, a service not observed) never skips. The log says it per leg; the backups page shows one amber line, hu + en (backup.status.version_skip). Seam: backup.Manager.SetVersionCheck <- stacks.Manager.PinNotRunning. Tests: TestR645_HandLiftedHoldKeepsTheGoodUnit (whole night run + Tier 2, unit tree fingerprint), TestR645_VersionSkipSentence, TestR645_PinNotRunning_*, TestR645_BackupRowSaysTheNightBackupSkipsIt, TestR645_VersionCheckIsWiredAtStartup. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -600,6 +600,39 @@ func (m *Manager) isHeld(stackName string) bool {
|
||||
return m.updatingCheck != nil && m.updatingCheck(stackName)
|
||||
}
|
||||
|
||||
// versionSkip (R-645, 09 §3 decision 142) reports whether the night backup must leave an app alone
|
||||
// because the version it is pinned to is NOT the version it runs, and names the difference.
|
||||
//
|
||||
// THE MEASURED CASE (9202, 2026-09-23): a failed update left docmost pinned to 0.96.0 while its running
|
||||
// record said 0.95.0. Lifting the hold by hand (`--clear-restore-hold` + the restart) let the capture
|
||||
// write the 0.96.0 definition — the version that had just failed — over the recovery unit the hold
|
||||
// sentence named, within seconds. The hold is what kept the legs off the app (isHeld); once it is gone,
|
||||
// this is what does. Where a leg also asks isHeld it asks this AFTER it, so a held app keeps its own line.
|
||||
//
|
||||
// Nil check or unknown ⇒ false (back the app up as before): a missing backup is the worse failure.
|
||||
// Pinned by TestR645_HandLiftedHoldKeepsTheGoodUnit (r645_version_skip_test.go).
|
||||
func (m *Manager) versionSkip(stackName string) (string, bool) {
|
||||
if m == nil || m.versionCheck == nil {
|
||||
return "", false
|
||||
}
|
||||
return m.versionCheck(stackName)
|
||||
}
|
||||
|
||||
// VersionSkipFor is versionSkip for the backups page: the household's sentence in lang, or false.
|
||||
func (m *Manager) VersionSkipFor(stackName, lang string) (bool, string) {
|
||||
if _, skip := m.versionSkip(stackName); !skip {
|
||||
return false, ""
|
||||
}
|
||||
return true, util.Text(lang, "backup.status.version_skip", stackName)
|
||||
}
|
||||
|
||||
// SetVersionCheck wires the "is this app running its pinned version" question (stacks.Manager.PinNotRunning).
|
||||
// INIT-ONLY, in main.go — pinned by TestR645_VersionCheckIsWiredAtStartup. The backup package cannot
|
||||
// import stacks, which is why it is a seam.
|
||||
func (m *Manager) SetVersionCheck(fn func(stackName string) (string, bool)) {
|
||||
m.versionCheck = fn
|
||||
}
|
||||
|
||||
// SetUpdatingCheck wires the "is a guarded update moving this app" question (stacks.Manager.IsUpdating).
|
||||
// INIT-ONLY, in main.go — pinned by TestSlice4_UpdatingCheckIsWiredAtStartup. The backup package cannot
|
||||
// import stacks, which is why it is a seam.
|
||||
|
||||
Reference in New Issue
Block a user