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:
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user