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:
2026-10-06 11:30:01 +02:00
parent 13bda270c3
commit 2d63714eca
15 changed files with 356 additions and 1 deletions
+56
View File
@@ -373,3 +373,59 @@ func (m *Manager) advancePinTo(name, stackDir, src, metaSrc string) error {
m.logger.Printf("[INFO] [stacks] update %s: pin advanced to %s (%s)", name, src, summarisePin(pin))
return nil
}
// PinNotRunning (R-645, 09 §3 decision 142) answers the night backup's question: is this app RUNNING
// the version it is pinned to? It returns a one-line description of every service whose recorded
// running reference (installed_images, an OBSERVATION) differs from its pin (pinned_images, the
// DECISION), and true when at least one differs.
//
// THE CASE IT EXISTS FOR, measured 2026-09-23 on 9202: a failed update leaves the pin on the new
// version and the running record on the old one (the record is written only after a healthy start).
// An operator lifting that hold by hand let the capture write the FAILED definition over the recovery
// unit the hold sentence pointed the household to, within seconds. The backup leaves such an app alone.
//
// UNKNOWN IS NEVER A MISMATCH: no app.yaml, no pin, no running record, or a pinned service with no
// running entry all answer false. Skipping a household's backup on a guess would trade a missing
// backup for a bookkeeping gap — the same reason a failed installed_images write never refuses a start.
// It reads app.yaml from disk, so a pin or record written a moment ago is seen.
// Pinned by TestR645_PinNotRunning_* (pin_r645_test.go).
func (m *Manager) PinNotRunning(name string) (string, bool) {
m.mu.RLock()
s, ok := m.stacks[name]
var composePath string
if ok {
composePath = s.ComposePath
}
m.mu.RUnlock()
if !ok || composePath == "" {
return "", false
}
cfg := LoadAppConfig(filepath.Dir(composePath))
if cfg == nil {
return "", false
}
return pinRunDiff(cfg.PinnedImages, cfg.InstalledImages)
}
// pinRunDiff is PinNotRunning's pure comparison, in deterministic service order.
func pinRunDiff(pinned map[string]string, running map[string]InstalledImage) (string, bool) {
if len(pinned) == 0 || len(running) == 0 {
return "", false
}
svcs := make([]string, 0, len(pinned))
for svc := range pinned {
svcs = append(svcs, svc)
}
sort.Strings(svcs)
var diffs []string
for _, svc := range svcs {
got, ok := running[svc]
if !ok || got.Ref == "" {
continue // not observed: unknown, never a mismatch
}
if got.Ref != pinned[svc] {
diffs = append(diffs, fmt.Sprintf("%s runs %s, pinned %s", svc, got.Ref, pinned[svc]))
}
}
return strings.Join(diffs, "; "), len(diffs) > 0
}