From 44ae4dea70a276ac1ea42a104b6662e18ef85f56 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Fri, 25 Sep 2026 11:19:27 +0200 Subject: [PATCH] controller v0.272.0: the backup page says when a whole-box backup does not fit (R-685); R-671, R-670, R-677 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS --- CHANGELOG.md | 19 +++++ controller/README.md | 5 ++ controller/cmd/controller/main.go | 2 + .../cmd/controller/update_leg_wiring_test.go | 9 +++ controller/internal/backup/backup.go | 3 + .../internal/backup/r671_undo_copies_test.go | 39 +++++++++ controller/internal/backup/update_guard.go | 15 ++++ controller/internal/i18n/locales/en.json | 4 +- controller/internal/i18n/locales/hu.json | 4 +- controller/internal/stacks/ladder.go | 2 +- controller/internal/stacks/metadata.go | 18 ++++- .../internal/stacks/r670_probe_meta_test.go | 66 +++++++++++++++ controller/internal/stacks/undo.go | 2 +- controller/internal/stacks/updateorder.go | 32 ++++++++ controller/internal/web/backup_handlers.go | 24 ++++++ controller/internal/web/i18n_web.go | 2 +- .../internal/web/r677_badge_age_test.go | 51 ++++++++++++ controller/internal/web/r685_no_space_test.go | 81 +++++++++++++++++++ .../internal/web/templates/backups.html | 2 +- controller/internal/web/updatebadge.go | 2 +- controller/scripts/i18n_go_keys.json | 4 +- 21 files changed, 376 insertions(+), 10 deletions(-) create mode 100644 controller/internal/backup/r671_undo_copies_test.go create mode 100644 controller/internal/stacks/r670_probe_meta_test.go create mode 100644 controller/internal/web/r677_badge_age_test.go create mode 100644 controller/internal/web/r685_no_space_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f52147..a87e290 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,22 @@ +## v0.272.0 — the backup page says when a whole-box backup does not fit; three leftovers (2026-09-25, R-685, R-671, R-670, R-677) + +**MinAgent: 0.131.0** (unchanged; the new sentence needs agent v0.134.0+ to have anything to say). Needs hub +v0.123.0 (unchanged). New strings: yes (hu + en). + +- **R-685 (page half):** when the agent SKIPPED a whole-box backup because it cannot fit (agent v0.134.0, + `skipped: not enough space: …`), the tier row on the backup page says „A teljes rendszermentés nem fér el: %s + kell, %s szabad. Kevesebb régi mentés megtartása vagy nagyobb lemez segít." / "The full system backup does not + fit: it needs %s and %s is free. Keeping fewer old backups, or a bigger disk, fixes it." — the numbers read from + the agent's own sentence; a skip whose numbers cannot be read gets the sentence without them. +- **R-671:** a restore that lifts an update hold removes the undo copies that hold kept (never for a restore hold, + never while an update moves the app) — `backup.SetUndoCopyRemover` wired to `stacks.RemoveUndoCopies`. +- **R-670:** the undo and a step's `.felhom.yml` are read with `stacks.LoadProbeMetadata` (health check and + resources only), so no false `[ERROR] … backup block rejected … docker-compose.yml unreadable` on every undo. +- **R-677:** for a floating tag re-tested at a new digest, the badge's age counts from when that digest was tested + (`stacks.BehindSinceAge`, used by both badge producers). +- Also: the unprompted-work rule names DooPlex and ep0 only (Peti's box retired 2026-09-25). +- Red-proofs: five (`felhom.eu/documentation/audits/retire-peti-2026-09-25/B/`). + ## v0.271.0 — automatic app updates: the update leg, the failed step remembered, fresh badges (2026-09-25 night, `09` §6.4 part 7, R-680, R-678, R-643) **MinAgent: 0.131.0** (unchanged). Needs hub v0.123.0 (unchanged; the report's new `update_leg` is additive — diff --git a/controller/README.md b/controller/README.md index 0f99581..5d21b54 100644 --- a/controller/README.md +++ b/controller/README.md @@ -740,6 +740,11 @@ One summary line per night (`[update-leg] update leg (after-offsite): done=… u skipped=… [skipped: app=reason, …]`) in the log and in the hub report's `update_leg`. After `done`/`undone` the app's steps-left and badge are fresh at once (R-678). `stacks.update_window` is removed (it was never read). +**Leftovers (v0.272.0).** The backup page says in plain words when a whole-box backup was skipped because it +cannot fit (the agent's numbers; R-685). A restore that lifts an update hold removes that hold's undo copies +(R-671). Probe-only copies of `.felhom.yml` load without the backup-block check (no false ERROR on an undo, R-670). +A re-tested floating tag's badge age counts from its test (R-677). + **Start/restart never answer "completed" (v0.263.0, R-642)** — they answer what was requested and the state the containers are in at that moment; whether the app works is the health probe's to say. diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go index 77361e3..6288de6 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -589,6 +589,8 @@ func main() { // updated, not only once it is held — found live in Scenario F, see backup.Manager.isHeld. if backupMgr != nil { backupMgr.SetUpdatingCheck(stackMgr.IsUpdating) + // R-671 (v0.272.0): a restore that lifts an update hold removes the undo copies that hold kept. + backupMgr.SetUndoCopyRemover(stackMgr.RemoveUndoCopies) } if n := stackMgr.ResumeInterruptedUpdates(ctx); n > 0 { logger.Printf("[WARN] [update] resumed %d interrupted update(s)", n) diff --git a/controller/cmd/controller/update_leg_wiring_test.go b/controller/cmd/controller/update_leg_wiring_test.go index fc93bfa..caf0f40 100644 --- a/controller/cmd/controller/update_leg_wiring_test.go +++ b/controller/cmd/controller/update_leg_wiring_test.go @@ -71,3 +71,12 @@ func TestUpdateLegIsWiredAtStartup(t *testing.T) { t.Error("the offbox-backup job must run through chainUpdateLeg") } } + +// TestUndoCopyRemoverIsWiredAtStartup — R-671's seam is CALLED from main.go (AST walk). +// COMPANION RED-PROOF (REPORT.md): drop the SetUndoCopyRemover call — this fails. +func TestUndoCopyRemoverIsWiredAtStartup(t *testing.T) { + lines, _, _ := slice4CallLines(t) + if len(lines["SetUndoCopyRemover"]) == 0 { + t.Fatal("main.go never calls SetUndoCopyRemover — a lifted hold's undo copies would stay for ever") + } +} diff --git a/controller/internal/backup/backup.go b/controller/internal/backup/backup.go index 0ba603a..d37ab9b 100644 --- a/controller/internal/backup/backup.go +++ b/controller/internal/backup/backup.go @@ -174,6 +174,9 @@ type Manager struct { // updatingCheck (slice 4) — nil-safe; see isHeld / SetUpdatingCheck. updatingCheck func(stackName string) 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 // R-475 update-precondition seams, one per tier. Nil → the real Tier2UnitRestorePoint / // ListRestorePoints / OffsiteSnapshotTimes. They let a test reach Tier 1 and Tier 3 without a drive diff --git a/controller/internal/backup/r671_undo_copies_test.go b/controller/internal/backup/r671_undo_copies_test.go new file mode 100644 index 0000000..b0a72f0 --- /dev/null +++ b/controller/internal/backup/r671_undo_copies_test.go @@ -0,0 +1,39 @@ +package backup + +import ( + "io" + "log" + "testing" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" +) + +// R-671 (v0.272.0) — the undo copies kept by an update hold are removed when a restore lifts that hold; a +// RESTORE hold (R-379) lifts nothing and removes nothing; an app a guarded update is moving is never touched. +// +// COMPANION RED-PROOF (REPORT.md): drop the undoCopyRemover call from clearUpdateHoldAfterRestore — this test +// fails at "the lifted update hold's undo copies were left behind". +func TestR671_RestoreThatClearsAnUpdateHoldRemovesItsUndoCopies(t *testing.T) { + sett := slice4Settings(t) + m := &Manager{logger: log.New(io.Discard, "", 0), settings: sett} + removed := map[string]int{} + m.SetUndoCopyRemover(func(s string) int { removed[s]++; return 3 }) + _ = m.HoldAfterFailedUpdate("nextcloud", time.Now(), time.Now(), UpdateTierLocal) + _ = sett.SetRestoreHold(settings.RestoreHold{Stack: "rst", At: "2026-08-22T14:00:00Z"}) + m.clearUpdateHoldAfterRestore("nextcloud") + m.clearUpdateHoldAfterRestore("rst") + if removed["nextcloud"] != 1 { + t.Fatalf("the lifted update hold's undo copies were left behind (remover calls: %v)", removed) + } + if removed["rst"] != 0 { + t.Fatal("a restore hold is not lifted by a restore, so nothing of it may be removed") + } + // an app mid-update: its copies are the live undo — never removed + _ = m.HoldAfterFailedUpdate("moving", time.Now(), time.Now(), UpdateTierLocal) + m.SetUpdatingCheck(func(s string) bool { return s == "moving" }) + m.clearUpdateHoldAfterRestore("moving") + if removed["moving"] != 0 { + t.Fatal("the undo copies of an app a guarded update is moving must never be removed") + } +} diff --git a/controller/internal/backup/update_guard.go b/controller/internal/backup/update_guard.go index aff1735..c32ffba 100644 --- a/controller/internal/backup/update_guard.go +++ b/controller/internal/backup/update_guard.go @@ -607,6 +607,21 @@ func (m *Manager) clearUpdateHoldAfterRestore(stackName string) { return } m.logger.Printf("[INFO] [backup] %s: restore completed — the update hold (set %s) is CLEARED", stackName, h.At) + // R-671 (v0.272.0): the undo copies the hold kept describe the state this restore just replaced. They + // were kept so the hold's data stayed recoverable; once the app is restored whole they are dead weight + // (measured 2026-09-24 on 9202: three nextcloud copies, ~0.9 GiB, outlived the hold and nothing named + // them). Removed here, and only here — never for a restore hold (R-379), never while an update moves the + // app. Pinned by TestR671_RestoreThatClearsAnUpdateHoldRemovesItsUndoCopies. + if m.undoCopyRemover != nil && (m.updatingCheck == nil || !m.updatingCheck(stackName)) { + n := m.undoCopyRemover(stackName) + m.logger.Printf("[INFO] [backup] %s: removed %d undo cop(y/ies) the lifted update hold had kept (R-671)", stackName, n) + } +} + +// SetUndoCopyRemover wires the undo-copy cleanup (R-671): stacks.Manager.RemoveUndoCopies. INIT-ONLY, main.go — +// pinned by TestUndoCopyRemoverIsWiredAtStartup (an AST walk). The backup package cannot import stacks. +func (m *Manager) SetUndoCopyRemover(fn func(stackName string) int) { + m.undoCopyRemover = fn } // UpdateHeldStacks is the set of apps held stopped after a failed update (R-660, v0.268.0) — the diff --git a/controller/internal/i18n/locales/en.json b/controller/internal/i18n/locales/en.json index b8ed076..491a111 100644 --- a/controller/internal/i18n/locales/en.json +++ b/controller/internal/i18n/locales/en.json @@ -2412,5 +2412,7 @@ "settings.app_update_on": "Automatic app updates are on. This applies from the next night.", "settings.app_update_off": "Automatic app updates are off. From the next night no app updates by itself; you can still update them by hand.", "settings.app_update_save_error": "The setting could not be saved. Try again.", - "app_info.auto_update_done": "Automatic update at %s — done." + "app_info.auto_update_done": "Automatic update at %s — done.", + "backup.tier.no_space": "The full system backup does not fit: it needs %s and %s is free. Keeping fewer old backups, or a bigger disk, fixes it.", + "backup.tier.no_space_unknown": "The full system backup does not fit on the disk. Keeping fewer old backups, or a bigger disk, fixes it." } diff --git a/controller/internal/i18n/locales/hu.json b/controller/internal/i18n/locales/hu.json index 992f752..91f78b8 100644 --- a/controller/internal/i18n/locales/hu.json +++ b/controller/internal/i18n/locales/hu.json @@ -2400,5 +2400,7 @@ "settings.app_update_on": "Az alkalmazások automatikus frissítése bekapcsolva. A következő éjjel már érvényes.", "settings.app_update_off": "Az alkalmazások automatikus frissítése kikapcsolva. A következő éjjel már egyetlen alkalmazás sem frissül magától; kézzel továbbra is frissítheted őket.", "settings.app_update_save_error": "A beállítást nem sikerült elmenteni. Próbáld újra.", - "app_info.auto_update_done": "Automatikus frissítés %s-kor — sikeres." + "app_info.auto_update_done": "Automatikus frissítés %s-kor — sikeres.", + "backup.tier.no_space": "A teljes rendszermentés nem fér el: %s kell, %s szabad. Kevesebb régi mentés megtartása vagy nagyobb lemez segít.", + "backup.tier.no_space_unknown": "A teljes rendszermentés nem fér el a lemezen. Kevesebb régi mentés megtartása vagy nagyobb lemez segít." } diff --git a/controller/internal/stacks/ladder.go b/controller/internal/stacks/ladder.go index 3e59d2c..83486a2 100644 --- a/controller/internal/stacks/ladder.go +++ b/controller/internal/stacks/ladder.go @@ -225,5 +225,5 @@ func loadMetadataFile(path string) (Metadata, error) { if err := os.WriteFile(filepath.Join(tmp, ".felhom.yml"), data, 0o600); err != nil { return Metadata{}, err } - return LoadMetadata(tmp), nil + return LoadProbeMetadata(tmp), nil // R-670 } diff --git a/controller/internal/stacks/metadata.go b/controller/internal/stacks/metadata.go index 0da856e..c7fa143 100644 --- a/controller/internal/stacks/metadata.go +++ b/controller/internal/stacks/metadata.go @@ -316,7 +316,21 @@ func (m Metadata) CatalogSinceAge(now time.Time) (int, bool) { // LoadMetadata reads .felhom.yml from a stack directory. // Returns default metadata if the file doesn't exist. -func LoadMetadata(stackDir string) Metadata { +func LoadMetadata(stackDir string) Metadata { return loadMetadata(stackDir, true) } + +// LoadProbeMetadata reads a `.felhom.yml` for its health check and resources ONLY (R-670, v0.272.0): the +// backup and data_paths blocks are dropped instead of validated. For the copies that live without their +// compose file — the undo's `pre-update-meta/` and a step's `steps/.felhom.yml` — the validation could +// only fail ("docker-compose.yml unreadable") and logged a false ERROR on every undo. Nothing read through +// this loader may consult Backup or DataPaths; they are always nil here. +// Pinned by TestR670_ProbeLoaderLogsNoFalseError. +func LoadProbeMetadata(stackDir string) Metadata { + m := loadMetadata(stackDir, false) + m.Backup, m.DataPaths = nil, nil + return m +} + +func loadMetadata(stackDir string, validateBackup bool) Metadata { meta := Metadata{} path := filepath.Join(stackDir, ".felhom.yml") @@ -401,7 +415,7 @@ func LoadMetadata(stackDir string) Metadata { // block exists) the WHOLE block is rejected — meta.Backup = nil, one ERROR — so the app degrades // to legacy (today's behavior) rather than partially classifying. INERT: nothing consumes // meta.Backup yet (Task 3/4). - if meta.Backup != nil || len(meta.DataPaths) > 0 { + if validateBackup && (meta.Backup != nil || len(meta.DataPaths) > 0) { composePath := filepath.Join(stackDir, "docker-compose.yml") binds := ParseComposeClassifiableBinds(composePath) _, composeErr := os.Stat(composePath) diff --git a/controller/internal/stacks/r670_probe_meta_test.go b/controller/internal/stacks/r670_probe_meta_test.go new file mode 100644 index 0000000..8a44e38 --- /dev/null +++ b/controller/internal/stacks/r670_probe_meta_test.go @@ -0,0 +1,66 @@ +package stacks + +import ( + "bytes" + "context" + "log" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// R-670 (v0.272.0) — every undo logged `[ERROR] .felhom.yml backup block rejected … docker-compose.yml +// unreadable`, because the undo read the previous version's `.felhom.yml` from `pre-update-meta/`, which has +// no compose beside it, through the validating loader. The consequence asserted: a REAL undo of an app whose +// `.felhom.yml` carries a backup block writes no such line to the process log. +// +// COMPANION RED-PROOF (REPORT.md): read PrevMeta with LoadMetadata again — this test fails at "the undo logged +// a false backup-block ERROR". +func TestR670_ProbeLoaderLogsNoFalseError(t *testing.T) { + var buf bytes.Buffer + prev := log.Writer() + log.SetOutput(&buf) + defer log.SetOutput(prev) + + m, dir, _, _, _ := ladderManager(t, true) + withBlock := "display_name: Nextcloud\nbackup:\n userdata:\n - path: data\n class: excluded\n" + mustWrite(t, filepath.Join(dir, ".felhom.yml"), withBlock) + if err := os.MkdirAll(filepath.Join(dir, appliedMetaDir), 0o755); err != nil { + t.Fatal(err) + } + mustWrite(t, filepath.Join(dir, appliedMetaDir, ".felhom.yml"), withBlock) + m.undoCopier = newFakeCopier(map[string]string{undoVol: "OLD"}) + m.updateHealthFn = func(context.Context, string, time.Duration) (bool, string) { return false, "B unhealthy" } + m.updateUndoHealthFn = func(context.Context, string, time.Duration, *Metadata) (bool, string) { return true, "A answers" } + if err := m.StartGuardedUpdate("nextcloud"); err != nil { + t.Fatal(err) + } + if st := waitUpdateDone(t, m, "nextcloud"); st.UpdatePhase != UpdatePhaseUndone { + t.Fatalf("the scenario must reach an undo; ended %q", st.UpdatePhase) + } + if strings.Contains(buf.String(), "docker-compose.yml unreadable") { + t.Fatalf("the undo logged a false backup-block ERROR:\n%s", buf.String()) + } + // control: the validating loader on the same compose-less copy DOES log it (the line is real elsewhere) + buf.Reset() + LoadMetadata(filepath.Join(dir, preUpdateMetaDirForTest(t, dir, withBlock))) + if !strings.Contains(buf.String(), "docker-compose.yml unreadable") { + t.Fatal("control failed: LoadMetadata on a compose-less copy must still reject the block") + } + if pm := LoadProbeMetadata(filepath.Join(dir, "probe-copy")); pm.Backup != nil || pm.DataPaths != nil { + t.Fatal("the probe loader must never hand out a backup block") + } +} + +// preUpdateMetaDirForTest writes a compose-less copy and returns its dir name (relative to dir). +func preUpdateMetaDirForTest(t *testing.T, dir, body string) string { + t.Helper() + d := filepath.Join(dir, "probe-copy") + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + mustWrite(t, filepath.Join(d, ".felhom.yml"), body) + return "probe-copy" +} diff --git a/controller/internal/stacks/undo.go b/controller/internal/stacks/undo.go index 81cb64c..6e96e52 100644 --- a/controller/internal/stacks/undo.go +++ b/controller/internal/stacks/undo.go @@ -537,7 +537,7 @@ func (m *Manager) tryUndo(ctx context.Context, name, dir, why string, entry *upd meta := LoadMetadata(dir) if entry.PrevMeta != "" { if _, err := os.Stat(filepath.Join(entry.PrevMeta, ".felhom.yml")); err == nil { - meta = LoadMetadata(entry.PrevMeta) + meta = LoadProbeMetadata(entry.PrevMeta) // R-670: a probe copy, no compose beside it } else { m.logger.Printf("[WARN] [stacks] update %s: the previous .felhom.yml is missing (%v) — checking with the current one", name, err) } diff --git a/controller/internal/stacks/updateorder.go b/controller/internal/stacks/updateorder.go index d3049f1..a03cea5 100644 --- a/controller/internal/stacks/updateorder.go +++ b/controller/internal/stacks/updateorder.go @@ -210,3 +210,35 @@ func parseImageTag(tag string) (util.Version, string, bool) { } return v, suffix, true } + +// BehindSinceAge is the "Frissítés elérhető — N napja" age, in calendar days (R-677, v0.272.0). For a +// DIGEST-ONLY move — every installed ref equals the catalog's and only a newer TESTED digest makes the app +// behind (a floating tag re-tested) — the age counts from when that digest was tested (the ladder entry's +// tested_at), not from the template's `catalog_since`, which dates the TAG and read „1 napja" for a digest +// tested minutes earlier (seen 2026-09-24). Every other case keeps CatalogSinceAge. Both badge producers +// call this, so they cannot disagree. Pinned by TestR677_DigestOnlyAgeFromTestedAt. +func BehindSinceAge(s Stack, now time.Time) (int, bool) { + if digestOnlyBehind(s) && !s.CatalogTestedAt.IsZero() { + t := s.CatalogTestedAt.UTC() + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) + day := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC) + if d := int(today.Sub(day).Hours() / 24); d >= 0 { + return d, true + } + return 0, false + } + return s.Meta.CatalogSinceAge(now) +} + +// digestOnlyBehind: every catalog service's ref equals the installed ref, and a newer tested digest exists. +func digestOnlyBehind(s Stack) bool { + if s.AppConfig == nil || len(s.CatalogImages) == 0 || len(s.AppConfig.InstalledImages) != len(s.CatalogImages) { + return false + } + for svc, want := range s.CatalogImages { + if got, ok := s.AppConfig.InstalledImages[svc]; !ok || got.Ref != want { + return false + } + } + return digestBehind(s) +} diff --git a/controller/internal/web/backup_handlers.go b/controller/internal/web/backup_handlers.go index 2c7d148..552b90a 100644 --- a/controller/internal/web/backup_handlers.go +++ b/controller/internal/web/backup_handlers.go @@ -4,6 +4,7 @@ import ( "context" "errors" "net/http" + "regexp" "strings" "time" @@ -136,6 +137,28 @@ type guestTierView struct { Current bool // newest success is inside the tier's window FailedAfter bool // the last attempt failed and is newer than the newest success FailedAt time.Time + // NoSpaceLine (v0.272.0, R-685 page half) is the plain sentence under a failed tier when the agent + // SKIPPED the backup because it cannot fit (agent v0.134.0+), in the reader's language. + NoSpaceLine string +} + +// agentNoSpacePrefix is the agent's `backup.BackupSkipNoSpacePrefix` (felhom-agent v0.134.0, +// internal/backup/runner.go) — the stable start of a tier attempt's Error when the space preflight refused. +// A wire contract between two repos: change it in both or in neither. +const agentNoSpacePrefix = "skipped: not enough space: " + +// agentNoSpaceRe reads the agent's sentence: " has X GiB free; … needs about Z GiB (…)". +var agentNoSpaceRe = regexp.MustCompile(`has ([0-9.]+ GiB) free;.*needs about ([0-9.]+ GiB)`) + +// noSpaceLine renders the household's sentence for a space skip, "" when the attempt is not one. +func noSpaceLine(errText string, msg func(key string, a ...interface{}) string) string { + if !strings.HasPrefix(errText, agentNoSpacePrefix) { + return "" + } + if m := agentNoSpaceRe.FindStringSubmatch(errText); m != nil { + return msg("backup.tier.no_space", m[2], m[1]) + } + return msg("backup.tier.no_space_unknown") } // tierWindow is how old a tier's newest success may be and still count as current: its cadence plus @@ -174,6 +197,7 @@ func buildTierViews(v *guestBackupView, tiers []agentapi.TierBackupState, cadenc if a := t.LastAttempt; a != nil && !a.Success { if ts, err := time.Parse(time.RFC3339, a.StartedAt); err == nil && (!tv.HasSuccess || ts.After(tv.SuccessAt)) { tv.FailedAfter, tv.FailedAt = true, ts + tv.NoSpaceLine = noSpaceLine(a.Error, msg) } } if !tv.NotSetUp && !tv.Current { diff --git a/controller/internal/web/i18n_web.go b/controller/internal/web/i18n_web.go index e480b10..42a2650 100644 --- a/controller/internal/web/i18n_web.go +++ b/controller/internal/web/i18n_web.go @@ -399,7 +399,7 @@ func (s *Server) localeFuncs(lang string) template.FuncMap { } case updateBehind: label := b.Msg(lang, "badge.update.behind") - if days, ok := st.Meta.CatalogSinceAge(time.Now().UTC()); ok { + if days, ok := stacks.BehindSinceAge(st, time.Now().UTC()); ok { // R-677 if days == 0 { label += b.Msg(lang, "badge.update.behind.today") } else { diff --git a/controller/internal/web/r677_badge_age_test.go b/controller/internal/web/r677_badge_age_test.go new file mode 100644 index 0000000..3fb5375 --- /dev/null +++ b/controller/internal/web/r677_badge_age_test.go @@ -0,0 +1,51 @@ +package web + +import ( + "strings" + "testing" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" +) + +// R-677 (v0.272.0) — a floating tag re-tested at a new digest: the badge's age must count from when that +// digest was TESTED (today), not from the template's catalog_since (the tag's date, 3 days ago). Both badge +// producers — the Hungarian literal one and the per-language one — are asserted, so they cannot drift. +// +// COMPANION RED-PROOF (REPORT.md): make stacks.BehindSinceAge return CatalogSinceAge always — this test fails +// at "the badge dates the TAG, not the tested digest". +func TestR677_DigestOnlyAgeFromTestedAt(t *testing.T) { + now := time.Date(2026, 9, 25, 10, 0, 0, 0, time.UTC) + dA := "sha256:" + strings.Repeat("a", 64) + dB := "sha256:" + strings.Repeat("b", 64) + st := stacks.Stack{Name: "redisapp", Deployed: true, + Meta: stacks.Metadata{CatalogSince: "2026-09-22"}, + CatalogImages: map[string]string{"web": "redis:7-alpine"}, + CatalogDigests: map[string]string{"web": dB}, + CatalogTestedAt: now.Add(-30 * time.Minute), + AppConfig: &stacks.AppConfig{InstalledImages: map[string]stacks.InstalledImage{ + "web": {Ref: "redis:7-alpine", Digest: dA, At: "2026-09-21T00:00:00Z"}}}} + if stacks.CatalogOrder(st) != stacks.UpdateOrderBehind { + t.Fatal("fixture must read Behind (a newer tested digest)") + } + if d, ok := stacks.BehindSinceAge(st, now); !ok || d != 0 { + t.Fatalf("the badge dates the TAG, not the tested digest: got %d days (ok=%v), want 0", d, ok) + } + if b := updateBadgeAt(st, now); b == nil || !strings.HasSuffix(b.Label, "— ma") { + t.Fatalf("Hungarian producer: %+v, want „… — ma\"", b) + } + // the per-language producer, on the real clock + live := st + live.CatalogTestedAt = time.Now().UTC().Add(-time.Minute) + live.Meta.CatalogSince = time.Now().UTC().AddDate(0, 0, -3).Format("2006-01-02") + en := testPageServer(t).localeFuncs("en")["updateBadge"].(func(stacks.Stack) *MetaBadge)(live) + if en == nil || !strings.HasSuffix(en.Label, "today") { + t.Fatalf("English producer: %+v, want \"… — today\"", en) + } + // a REAL version move keeps the template's date + mv := st + mv.CatalogImages = map[string]string{"web": "redis:7.4-alpine"} + if d, ok := stacks.BehindSinceAge(mv, now); !ok || d != 3 { + t.Fatalf("a version move must keep catalog_since's age (3), got %d (ok=%v)", d, ok) + } +} diff --git a/controller/internal/web/r685_no_space_test.go b/controller/internal/web/r685_no_space_test.go new file mode 100644 index 0000000..9b56d7a --- /dev/null +++ b/controller/internal/web/r685_no_space_test.go @@ -0,0 +1,81 @@ +package web + +import ( + "bytes" + "html" + "strings" + "testing" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/agentapi" + "gitea.dooplex.hu/admin/felhom-controller/internal/i18n" +) + +// R-685 page half (v0.272.0) — when the agent (v0.134.0+) SKIPPED a whole-box backup because it cannot fit, +// the backup page says so in plain words, in the reader's language, with the numbers the agent measured. +// Per state: a space skip (the agent's exact sentence, taken from the live proof 2026-09-24), a space skip +// whose numbers cannot be read, an ordinary failure (no line), and a success (no line); rendered through the +// REAL backups template. +// +// COMPANION RED-PROOF (REPORT.md): drop `tv.NoSpaceLine = noSpaceLine(...)` — this test fails at "a space skip +// must be said on the page". +func TestR685_BackupPageSaysTheBackupDoesNotFit(t *testing.T) { + b, err := i18n.Shared() + if err != nil { + t.Fatal(err) + } + now := time.Date(2026, 9, 25, 9, 0, 0, 0, time.UTC) + live := "skipped: not enough space: local has 14.9 GiB free; the last archive of guest 9201 was 7.6 GiB, so a new one needs about 11.3 GiB (old archives are removed only after a successful backup)" + tiers := func(errText string, ok bool) []agentapi.TierBackupState { + return []agentapi.TierBackupState{{Target: "local", Primary: true, Storage: "present", + LastSuccess: &agentapi.BackupRecord{StartedAt: now.Add(-50 * time.Hour).Format(time.RFC3339), SizeBytes: 8 << 30}, + LastAttempt: &agentapi.TierAttempt{StartedAt: now.Add(-2 * time.Hour).Format(time.RFC3339), Success: ok, Error: errText}}} + } + cases := []struct { + name, err string + ok bool + want map[string]string // lang → substring; "" = no line + }{ + {"space skip", live, false, map[string]string{ + "hu": "A teljes rendszermentés nem fér el: 11.3 GiB kell, 14.9 GiB szabad.", + "en": "The full system backup does not fit: it needs 11.3 GiB and 14.9 GiB is free."}}, + {"space skip, unreadable numbers", "skipped: not enough space: something new", false, map[string]string{ + "hu": "A teljes rendszermentés nem fér el a lemezen.", "en": "The full system backup does not fit on the disk."}}, + {"ordinary failure", "vzdump failed: job errors", false, map[string]string{"hu": "", "en": ""}}, + {"success", "", true, map[string]string{"hu": "", "en": ""}}, + } + s := securityHarness(t) + s.loadTemplates() + for _, c := range cases { + for _, lang := range []string{"hu", "en"} { + msg := func(key string, a ...interface{}) string { + if len(a) == 0 { + return b.Msg(lang, key) + } + return b.Msgf(lang, key, a...) + } + v := &guestBackupView{Available: true} + buildTierViews(v, tiers(c.err, c.ok), map[string]int64{"local": 86400}, now, msg) + got := v.Tiers[0].NoSpaceLine + if c.want[lang] == "" { + if got != "" { + t.Errorf("%s [%s]: no sentence expected, got %q", c.name, lang, got) + } + continue + } + if !strings.Contains(got, c.want[lang]) { + t.Fatalf("%s [%s]: a space skip must be said on the page — got %q, want %q", c.name, lang, got, c.want[lang]) + } + if lang == "hu" { + var buf bytes.Buffer + if err := s.tmpl.ExecuteTemplate(&buf, "backups", map[string]interface{}{"Page": "backups", "Title": "t", "GuestBackup": v, "Backup": map[string]interface{}{"Enabled": true, "DumpFiles": []interface{}{}}}); err != nil { + t.Fatalf("the backups page did not render: %v", err) + } + out := html.UnescapeString(buf.String()) + if !strings.Contains(out, c.want[lang]) || !strings.Contains(out, `data-no-space="true"`) { + i := strings.Index(out, "backup-tier-table"); t.Fatalf("%s: the rendered page does not carry the sentence; near table: %q", c.name, out[max(0, i):min(len(out), i+1500)]) + } + } + } + } +} diff --git a/controller/internal/web/templates/backups.html b/controller/internal/web/templates/backups.html index cd999c5..b8496b6 100644 --- a/controller/internal/web/templates/backups.html +++ b/controller/internal/web/templates/backups.html @@ -125,7 +125,7 @@
{{T "backups.meg_nincs_sikeres_mentes"}} {{T "backups.esedekes"}}
{{end}} {{if .FailedAfter}} -
{{T "backups.sikertelen"}}
+
{{T "backups.sikertelen"}}{{if .NoSpaceLine}} — {{.NoSpaceLine}}{{end}}
{{end}} {{end}} diff --git a/controller/internal/web/updatebadge.go b/controller/internal/web/updatebadge.go index 0b7b0b2..c2b4ad8 100644 --- a/controller/internal/web/updatebadge.go +++ b/controller/internal/web/updatebadge.go @@ -107,7 +107,7 @@ func updateBadgeAt(s stacks.Stack, now time.Time) *MetaBadge { } case updateBehind: label := "Frissítés elérhető" - if days, ok := s.Meta.CatalogSinceAge(now); ok { + if days, ok := stacks.BehindSinceAge(s, now); ok { // R-677 if days == 0 { label += " — ma" } else { diff --git a/controller/scripts/i18n_go_keys.json b/controller/scripts/i18n_go_keys.json index d3bfe53..657ed65 100644 --- a/controller/scripts/i18n_go_keys.json +++ b/controller/scripts/i18n_go_keys.json @@ -79,7 +79,9 @@ "app_info.auto_update_done": "BORN AS A KEY, v0.271.0 (09 6.4 part 7, the automatic update leg) -- a NEW sentence, never a Go literal. Pinned by internal/web/app_update_switch_test.go.", "settings.app_update_on": "BORN AS A KEY, v0.271.0 (09 6.4 part 7, the automatic update leg) -- a NEW sentence, never a Go literal. Pinned by internal/web/app_update_switch_test.go.", "settings.app_update_off": "BORN AS A KEY, v0.271.0 (09 6.4 part 7, the automatic update leg) -- a NEW sentence, never a Go literal. Pinned by internal/web/app_update_switch_test.go.", - "settings.app_update_save_error": "BORN AS A KEY, v0.271.0 (09 6.4 part 7, the automatic update leg) -- a NEW sentence, never a Go literal. Pinned by internal/web/app_update_switch_test.go." + "settings.app_update_save_error": "BORN AS A KEY, v0.271.0 (09 6.4 part 7, the automatic update leg) -- a NEW sentence, never a Go literal. Pinned by internal/web/app_update_switch_test.go.", + "backup.tier.no_space": "BORN AS A KEY, v0.272.0 (R-685 page half) -- a NEW sentence, never a Go literal. Pinned by internal/web/r685_no_space_test.go.", + "backup.tier.no_space_unknown": "BORN AS A KEY, v0.272.0 (R-685 page half) -- a NEW sentence, never a Go literal. Pinned by internal/web/r685_no_space_test.go." }, "flash.share.already_on": "A megosztás már be van kapcsolva.", "flash.share.enable_failed": "A megosztás bekapcsolása nem sikerült.",