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
+6
View File
@@ -1638,6 +1638,12 @@ func (s *Server) buildAppBackupRows(status *backup.FullBackupStatus, lang string
// the customer's data may not be intact. Measured 2026-08-22: after a failed MariaDB replay
// the app reported `health=healthy, running=true, restarts=0` while its schema-version table
// held zero rows.
// R-645 (09 §3 decision 142): an app the night backup leaves alone because it is not running its
// pinned version says so — amber, a deviation, not a failure. Before the hold check, which wins.
if skip, why := s.backupMgr.VersionSkipFor(app.StackName, lang); skip {
row.Status = "yellow"
row.StatusText = why
}
if held, why := s.backupMgr.RestoreHoldForLang(app.StackName, lang); held {
row.Status = "red"
row.StatusText = why
@@ -0,0 +1,34 @@
package web
import (
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
)
// R-645 (09 §3 decision 142): an app the night backup leaves alone because it is not running its pinned
// version says so on the backups page — amber, in the reader's language — and an app running its pin
// keeps its ordinary row.
//
// Red-proof: delete the VersionSkipFor block in buildAppBackupRows — the docmost row reads green and
// this test fails.
func TestR645_BackupRowSaysTheNightBackupSkipsIt(t *testing.T) {
s, _, m := newOffboxWebServer(t)
m.SetStackProvider(&blockProvider{hdd: t.TempDir()})
m.SetVersionCheck(func(name string) (string, bool) { return "x", name == "docmost" })
status := &backup.FullBackupStatus{AppDataInfo: []backup.AppBackupInfo{
{StackName: "docmost", DisplayName: "Docmost", HasVolumeData: true},
{StackName: "privatebin", DisplayName: "PrivateBin", HasVolumeData: true},
}}
for lang, want := range map[string]string{"hu": "jszakai ment", "en": "night backup leaves it out"} {
rows := s.buildAppBackupRows(status, lang)
d := findRow(rows, "docmost")
if d == nil || d.Status != "yellow" || !strings.Contains(d.StatusText, want) {
t.Errorf("%s: docmost row = %+v, want amber with %q", lang, d, want)
}
if p := findRow(rows, "privatebin"); p == nil || p.Status != "green" {
t.Errorf("%s: positive control: privatebin must stay green, got %+v", lang, p)
}
}
}