Files
felhom-controller/controller/internal/stacks/pin_r645_test.go
T
admin 2d63714eca 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
2026-10-06 11:39:25 +02:00

40 lines
2.0 KiB
Go

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")
}
}