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
}
@@ -0,0 +1,39 @@
package stacks
import (
"strings"
"testing"
)
// R-645 (09 §3 decision 142) — the night backup asks "is this app running its pinned version?".
// These pin the answer; r645_version_skip_test.go (backup) pins what the backup does with it.
// The measured shape (9202, 2026-09-23): a failed update leaves the pin on the new version and the
// running record on the old one. Read from the app.yaml ON DISK, not the in-memory copy.
func TestR645_PinNotRunning_FailedUpdateShapeIsAMismatch(t *testing.T) {
appYAML := "deployed: true\npinned_images:\n web: nextcloud:34.0.1-apache\ninstalled_images:\n web:\n ref: nextcloud:31.0.14-apache\n"
m, _ := newPinManager(t, pinTplNew, "", appYAML)
m.stacks["nextcloud"].AppConfig = nil // the in-memory view must not be what answers
why, skip := m.PinNotRunning("nextcloud")
if !skip || !strings.Contains(why, "web runs nextcloud:31.0.14-apache, pinned nextcloud:34.0.1-apache") {
t.Fatalf("PinNotRunning = (%q, %v), want a mismatch naming both versions", why, skip)
}
}
func TestR645_PinNotRunning_AgreementAndUnknownAreNotAMismatch(t *testing.T) {
for name, appYAML := range map[string]string{
"agree": "deployed: true\npinned_images:\n web: nextcloud:31.0.14-apache\ninstalled_images:\n web:\n ref: nextcloud:31.0.14-apache\n",
"unpinned": "deployed: true\ninstalled_images:\n web:\n ref: nextcloud:31.0.14-apache\n",
"no running record": "deployed: true\npinned_images:\n web: nextcloud:34.0.1-apache\n",
"service unobserved": "deployed: true\npinned_images:\n web: nextcloud:34.0.1-apache\ninstalled_images:\n db:\n ref: postgres:16\n",
} {
m, _ := newPinManager(t, pinTplOld, "", appYAML)
if why, skip := m.PinNotRunning("nextcloud"); skip {
t.Errorf("%s: unknown or agreeing must never skip a backup, got (%q, true)", name, why)
}
}
m, _ := newPinManager(t, pinTplOld, "", "deployed: true\n")
if _, skip := m.PinNotRunning("no-such-app"); skip {
t.Error("an unknown app is not a mismatch")
}
}