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
+16
View File
@@ -202,6 +202,8 @@ type Manager struct {
// updatingCheck (slice 4) — nil-safe; see isHeld / SetUpdatingCheck.
updatingCheck func(stackName string) bool
// versionCheck (R-645) — nil-safe; see versionSkip / SetVersionCheck.
versionCheck func(stackName string) (detail string, mismatch bool)
// undoCopyRemover (R-671, v0.272.0) deletes an app's leftover undo copies — stacks.Manager.RemoveUndoCopies,
// wired in main.go (SetUndoCopyRemover). nil-safe: without it the copies stay, as before.
undoCopyRemover func(stackName string) int
@@ -620,6 +622,14 @@ func (m *Manager) runDBDumpsInternal(ctx context.Context) error {
continue
}
// R-645 (09 §3 decision 142): an app not running its pinned version is left alone — its dump
// would land in the unit beside the good definition and become the restore point. A SKIP: the
// unit keeps the last good copy, and the backups page says why.
if why, skip := m.versionSkip(db.StackName); skip {
m.logger.Printf("[WARN] [backup] Skipping DB dump for %s — it is not running its pinned version (%s); the last good backup is kept (R-645)", db.StackName, why)
summary = append(summary, fmt.Sprintf("SKIP %s (not running its pinned version)", db.ContainerName))
continue
}
// R-181: the reserve, BEFORE the first byte of this app's backup is written. This is usually
// where an app's verdict is taken, because the DB leg runs first; the volume leg and the
// capture then read the same memo. SKIP, not FAIL — a deliberate hold is not a broken dump,
@@ -761,6 +771,12 @@ func (m *Manager) runVolumeDumps() (summary []string, dumped int, allOK bool) {
summary = append(summary, fmt.Sprintf("SKIP %s volumes (held)", stack.Name))
continue
}
// R-645: never stop, dump or restart an app that is not running its pinned version.
if why, skip := m.versionSkip(stack.Name); skip {
m.logger.Printf("[WARN] [backup] Skipping volume dump for %s — it is not running its pinned version (%s); the last good backup is kept (R-645)", stack.Name, why)
summary = append(summary, fmt.Sprintf("SKIP %s volumes (not running its pinned version)", stack.Name))
continue
}
// Volume check FIRST — a volume-less stack must not be stopped at all (see gate-order note).
if len(m.stackProvider.GetDockerVolumes(stack.Name)) == 0 {
if m.isDebug() {