diff --git a/CHANGELOG.md b/CHANGELOG.md index 81eb641..44a5409 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,25 @@ +## v0.268.0 — the undo finds the storage after a restore; a held app's page tells the truth; one press = one tested step (2026-09-24, R-658, R-659, R-660, R-651; `09` §6.4 part 5) + +**MinAgent: 0.131.0** (unchanged). Needs hub v0.122.0 for `app_hold_no_whole_copy` (older hubs answer 400 and +the event is lost; the household's mail is unaffected). New strings: yes (hu + en). + +- **R-658 — the undo selects volumes from the app's definition, not from a label.** `DeclaredVolumeNames` + resolves the compose file's named volumes (`_` or `name:`), each checked to exist; the + `com.docker.compose.project` label is a logged cross-check. The unit restore now creates volumes WITH + compose's project, volume and version labels (never a guessed config-hash). The remove's report counts + unlabelled declared volumes (it said `volumes_removed: []` over volumes it did remove). +- **R-659 — operator ruling 2026-09-24, option A.** The hold names the newest WHOLE copy on any tier — for an + app with declared drive files only off-site, because both unit restores refuse it (measured from source: + R-538's guard sits in `RestoreFromRecoveryUnitAtWith`, which the second-drive unit restore calls too). + With none: `hold.update.no_whole_copy` (the operator's copy; English without „please", the house rule), no + Mentések button, and `app_hold_no_whole_copy` (critical) to the operator. +- **R-660 — a held app is not "down".** `classifyRunStates` gains a fourth suppression, the update-held set. +- **R-651 — remove deletes `applied-compose.yml` and `applied-meta/`.** +- **The ladder (`09` §3 decision 14, §6.4 part 5).** One press applies exactly one tested step with its own + definition (`steps/.yml` from the catalog clone; the newest step = the template). The app page + says how many steps remain. An installed version older than the ladder jumps as before, logged by name. +- Red-proofs: eleven, each seen failing (REPORT). + ## v0.267.0 — tests never touch DooPlex's Docker, a cut-off copy is never loaded, two pages tell the truth (2026-09-23, R-650, R-640, R-499, R-518; R-626 measured) **MinAgent: 0.131.0** (unchanged). No hub change. New strings: yes (hu + en). diff --git a/REUSE.md b/REUSE.md index f6dcad6..b97ae69 100644 --- a/REUSE.md +++ b/REUSE.md @@ -116,6 +116,8 @@ | Symbol | File | Short signature | Use for | Gotchas | |---|---|---|---|---| | `Manager.DeployStack` | controller/internal/stacks/deploy.go | `(req DeployRequest) (string, error)` | Full deploy flow | Sets in-memory `Deployed` BEFORE compose up (slow-pull race), reverts on failure | +| `DeclaredVolumeNames` (R-658, v0.268.0) | controller/internal/stacks/undo.go | `(composePath) (own, external []string, err)` | THE app's named volumes as Docker names them: `name:` else `_`, project = top-level `name:` else the stack dir | **Never select an app's volumes by the `com.docker.compose.project` label** — a restore before v0.268.0 made volumes without it, and the undo then copied nothing (R-658). The label is a cross-check only | +| `nextLadderStep` / `StepKey` / `StepFile` (v0.268.0) | controller/internal/stacks/ladder.go | `(templateDir, pinned) (LadderStep, error)` | which definition ONE guarded-update press pins (`09` §3 decision 14) | `StepKey` must equal the catalog's `ladder.step_key` (TestLadder_StepKeyMatchesTheCatalog). A missing/wrong step file is an ERROR, never a jump | | `Manager.RedeployFromEnv` | controller/internal/stacks/deploy.go | `(name, env map[string]string) error` | Re-up with changed env (migration flip, config edits) | `compose up -d`, never `restart` (restart won't pick up images/env) | | `Manager.PersistUnitRedeployConfig` (R-47, v0.153.0) | controller/internal/stacks/deploy.go | `(name, env map[string]string) error` | the PERSIST half of `RedeployFromEnv` — app.yaml + locked fields + in-memory flags, **starts nothing** | **TRAP: the restore paths must use THIS, never `RedeployFromEnv`.** RedeployFromEnv ends in a full `up -d`, which before the replay IS the H4 race. RedeployFromEnv is now literally this + the unchanged up-and-report tail | | `Manager.StartStackServices` (R-47, v0.153.0) | controller/internal/stacks/manager.go | `(name string, services []string) error` | scoped `compose up -d ...` — the DB-only window a dump is replayed in | **REFUSES an empty list** (argument-less `up -d` is a FULL start — the one silent fall-through that would reintroduce the race). No `logPostStartStatus`: the app containers are absent on purpose. Never `RestartStack` here — it is a full up in disguise | @@ -286,6 +288,7 @@ | `config.LoadPermissive` | Skips validation — setup-mode only (customer.id/domain may be unset) | `config.Load` everywhere else | | `ExportDataMounts` / `ParseComposeHDDMounts` as **backup-classification** input | `ExportDataMounts` unions the `${USERDATA_PATH}` ROOT (export-capture logic, not per-bind); `ParseComposeHDDMounts` resolves absolutes AND drops the `:ro` flag — classification needs `${VAR}`-relative paths + read-only awareness | `ParseComposeClassifiableBinds` (controller/internal/stacks/classify_binds.go) | | `docker compose restart` (any wrapper) | Does not pick up new images or env | `RedeployFromEnv` / composeExec `up -d` | +| `backup.WholeOnTier` vs `UpdateCopyHolds` (R-659) | `UpdateCopyHolds` says what a copy HOLDS; it does not say the restore will ACCEPT it — a file app's second-drive copy holds the files and its unit restore still refuses | `WholeOnTier` (asks `DeclaredDriveFileLegs`, the refusal's own predicate) before a sentence names a copy as a way back | ## 4. Seams & interfaces (testing + cross-repo) diff --git a/controller/README.md b/controller/README.md index a32ff70..4f3e437 100644 --- a/controller/README.md +++ b/controller/README.md @@ -677,6 +677,33 @@ sentence and its prefix — is stored as a key + args and rendered in the READER the Hungarian stored text is unchanged. At startup, an app already CURRENT with the catalog gets its `applied-meta/` record (R-646); a behind app is skipped by name, never guessed. +**One press = one tested step (v0.268.0, `09` §3 decision 14, §6.4 part 5).** The catalog's +`update_ladder:` in `.felhom.yml` lists every tested step; every step but the newest carries its own compose +file at `templates//steps/.yml` (sha256 of `to` as canonical JSON, 16 hex — the catalog +computes the same). The guarded update finds the NEWEST entry whose `from` is the app's pin and pins exactly +that step's definition (the newest step: the template's `docker-compose.yml`), read straight from the +catalog clone. A step the catalog promises and does not carry refuses before anything moves. A pin that +matches no entry (an app older than the ladder) takes the catalog's current definition, logged by name. The +app page shows „Hátralévő frissítési lépések: N" / "Update steps remaining: N" while steps remain. A failed +step is undone as any update and the next press starts from the same step again. **Limitation:** a step +has no `.felhom.yml` of its own — the health probe and memory check read the catalog's current one (R-664). + +**The undo finds the app's volumes by its definition (v0.268.0, R-658).** The undo copies the named volumes +the rendered compose file DECLARES (`_`, or the volume's own `name:`), each checked to exist; +the compose label is a logged cross-check. Until v0.267.0 it selected by the label, and a restore recreated +volumes without it — so after any restore the undo copied nothing. The restore now creates volumes WITH +compose's project/volume/version labels too, and the remove counts unlabelled declared volumes. + +**A held app's page names only a way back that works (v0.268.0, R-659, operator ruling 2026-09-24, option +A).** The hold names the newest copy on any tier that brings the app back WHOLE — for an app with declared +drive files (`DeclaredDriveFileLegs`) only the off-site copy, because the unit restore and the second-drive +unit restore both refuse it (R-538) — with what it holds. With none, the sentence is +`hold.update.no_whole_copy` („… Ezen a dobozon nincs olyan másolat … A Felhom ügyfélszolgálatát +értesítettük …"), no Mentések button is shown beside it, and the operator gets `app_hold_no_whole_copy` +(critical, operator-only on hub v0.122.0+, details `{app, from, to, at, copies_seen, undo_state}`). A held +app raises no `app_start_failed` (R-660). A removed app leaves no `applied-compose.yml` / `applied-meta/` +(R-651). + **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 b4ce05c..4a73b64 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -622,6 +622,17 @@ func main() { if backupMgr != nil && ev.CopyTier > 0 { d.CopyHolds = backupMgr.UpdateCopyHoldsKey(ev.App, ev.CopyTier) // R-647: the key, never the Hungarian phrase } + var hold settings.RestoreHold + haveHold := false + if ev.HoldRecorded && backupMgr != nil { + hold, haveHold = backupMgr.UpdateHold(ev.App) + } + if haveHold { // R-659: the details name the copy the SENTENCE names, not the precondition's + d.CopyTier, d.CopyDate, d.CopyHolds = hold.CopyTier, hold.CopyDate, "" + if hold.CopyTier > 0 { + d.CopyHolds = backupMgr.UpdateCopyHoldsKey(ev.App, hold.CopyTier) + } + } notifier.NotifyAppUpdateHeld(d, func(lang string) string { if ev.HoldRecorded && backupMgr != nil { if held, why := backupMgr.RestoreHoldForLang(ev.App, lang); held { @@ -630,6 +641,12 @@ func main() { } return util.Text(lang, "update.error.hold_unsaved") }) + // R-659 (v0.268.0; operator ruling 2026-09-24, option A): no copy on this box brings the app + // back whole — the household was just told support is informed; this is that information. + if haveHold && hold.NoWholeCopy { + notifier.NotifyAppHoldNoWholeCopy(notify.AppHoldNoWholeCopyDetails{App: ev.App, StackName: ev.App, + From: ev.From, To: ev.To, At: ev.At.UTC().Format(time.RFC3339), CopiesSeen: hold.CopiesSeen, UndoState: hold.UndoState}) + } } }) @@ -843,7 +860,7 @@ func main() { if time.Since(startTime) < deadAppBootGrace { return nil // still inside the startup settle window } - dead, states := scanDeployedAppRunStates(stackMgr, quiesceLoop, appStopGuard) + dead, states := scanDeployedAppRunStates(stackMgr, quiesceLoop, appStopGuard, backupMgr) alertMgr.SetDeadAppAlerts(dead) notifier.NotifyAppStartFailures(states) // R-514: a worker OOM-killed inside a running container leaves the app „Fut". Surface it. @@ -2353,7 +2370,7 @@ func recordLateRecovery(logger *log.Logger, started time.Time, res bootrecon.Res // state-based dashboard banner) and EVERY deployed app's run state (for the notifier's one-event-per- // transition tracking). Deploying apps are skipped (mid-deploy is not a fault). Pure over GetStacks() // — the derivation itself lives in classifyRunStates so it is testable without a live Manager. -func scanDeployedAppRunStates(mgr *stacks.Manager, q *quiesce.Loop, g *backup.AppStopGuard) ([]web.DeadApp, []notify.AppRunState) { +func scanDeployedAppRunStates(mgr *stacks.Manager, q *quiesce.Loop, g *backup.AppStopGuard, held updateHeldLister) ([]web.DeadApp, []notify.AppRunState) { // R-97b: a stack THIS controller stopped for a backup is not a fault. q may be nil (unprovisioned // guest) — SuppressedStacks is nil-safe and returns nothing, i.e. suppress nothing. // @@ -2366,9 +2383,26 @@ func scanDeployedAppRunStates(mgr *stacks.Manager, q *quiesce.Loop, g *backup.Ap // for health, and it ends in healthy or HELD. Counting it as dead mid-update would be R-330's false // alarm one mechanism over. suppressed := unionSuppressed(unionSuppressed(q.SuppressedStacks(), g.SuppressedStacks()), mgr.UpdatingStacks()) + // R-660 (v0.268.0): a FOURTH — an app HELD after a failed update is stopped by the product and has + // its own event (`app_update_held`). Without this each hold was followed by an `app_start_failed` + // for the same app. Pinned by TestR660_UpdateHeldAppIsNotReportedDown + the wiring test beside it. + suppressed = unionSuppressed(suppressed, updateHeldSet(held)) return classifyRunStates(mgr.GetStacks(), suppressed, q.FailedRestarts(), time.Now()) } +// updateHeldLister is the backup manager's UpdateHeldStacks, as a seam for the test. +type updateHeldLister interface { + UpdateHeldStacks() map[string]bool +} + +// updateHeldSet is nil-safe over a nil interface (a box with backup disabled has no holds). +func updateHeldSet(l updateHeldLister) map[string]bool { + if l == nil { + return nil + } + return l.UpdateHeldStacks() +} + // unionSuppressed merges the suppression sets of the two mechanisms that stop apps on purpose. // Returns nil when both are empty so the common case allocates nothing. func unionSuppressed(a, b map[string]bool) map[string]bool { @@ -3545,6 +3579,17 @@ func (a *updateGuardsAdapter) HoldAfterFailedUpdate(name string, at time.Time, r if a.b == nil { return fmt.Errorf("backup is not enabled on this box — the hold cannot be recorded") } - // R-479: the sentence names what the chosen copy holds, decided by the app's data layout NOW. - return a.b.HoldAfterFailedUpdateHolding(name, at, rp.ProvenAt, rp.Tier, a.b.UpdateCopyHolds(name, rp.Tier), undoState) + // R-659 (v0.268.0): the hold names the newest copy that brings the app back WHOLE — not the + // precondition copy `rp`, which may be one the restore refuses — and none when there is none. + // R-479 stands inside it: the sentence still says what that copy holds. + _, err := a.b.HoldAfterFailedUpdateWhole(context.Background(), name, at, undoState) + return err +} + +// HoldNoWholeCopy is the page's half of R-659: a hold naming no copy gets no restore button. +func (a *updateGuardsAdapter) HoldNoWholeCopy(name string) bool { + if a.b == nil { + return false + } + return a.b.HoldNoWholeCopy(name) } diff --git a/controller/cmd/controller/r475_wiring_test.go b/controller/cmd/controller/r475_wiring_test.go index a8ba103..efcf607 100644 --- a/controller/cmd/controller/r475_wiring_test.go +++ b/controller/cmd/controller/r475_wiring_test.go @@ -65,12 +65,12 @@ func TestR475_AdapterReadsEveryTier(t *testing.T) { if cb := adapterMethodSelectors(t, "CanBackUp"); !strings.Contains(cb, " CanBackUpApp ") { t.Errorf("CanBackUp must ask backup.CanBackUpApp; selectors:%s", cb) } - if h := adapterMethodSelectors(t, "HoldAfterFailedUpdate"); !strings.Contains(h, " Tier ") { - t.Errorf("the hold must be told the chosen TIER, or it cannot name it; selectors:%s", h) - } - // R-479: and WHAT the copy holds, computed from the app's data layout at hold time. - if h := adapterMethodSelectors(t, "HoldAfterFailedUpdate"); !strings.Contains(h, " UpdateCopyHolds ") || !strings.Contains(h, " HoldAfterFailedUpdateHolding ") { - t.Errorf("the hold must carry UpdateCopyHolds through HoldAfterFailedUpdateHolding (R-479); selectors:%s", h) + // R-659 (v0.268.0) CHANGED THIS ON PURPOSE: the hold no longer names the precondition copy the + // adapter is handed (round 11: that copy was one the restore refuses). The tier, and R-479's + // "what the copy holds", are now chosen inside backup.HoldAfterFailedUpdateWhole from every tier — + // pinned there by TestR659_TheHoldNamesOnlyAWholeCopy (which asserts the holds-phrase too). + if h := adapterMethodSelectors(t, "HoldAfterFailedUpdate"); !strings.Contains(h, " HoldAfterFailedUpdateWhole ") { + t.Errorf("the hold must be recorded through HoldAfterFailedUpdateWhole (R-659); selectors:%s", h) } } diff --git a/controller/cmd/controller/r659_wiring_test.go b/controller/cmd/controller/r659_wiring_test.go new file mode 100644 index 0000000..69db62e --- /dev/null +++ b/controller/cmd/controller/r659_wiring_test.go @@ -0,0 +1,47 @@ +package main + +import ( + "go/ast" + "strings" + "testing" +) + +// R-659 (v0.268.0) — the pieces are wired: the adapter records the hold through the WHOLE-copy walk +// (never the precondition copy again), and the held-event sink sends app_hold_no_whole_copy. +// An AST walk, not a substring (a commented-out call satisfies strings.Contains). +func TestR659_HoldAndEventAreWired(t *testing.T) { + _, f, _ := slice4CallLines(t) + var calls []string + for _, d := range f.Decls { + fn, ok := d.(*ast.FuncDecl) + if !ok || fn.Name.Name != "HoldAfterFailedUpdate" || fn.Recv == nil || fn.Body == nil { + continue + } + if st, ok := fn.Recv.List[0].Type.(*ast.StarExpr); !ok || st.X.(*ast.Ident).Name != "updateGuardsAdapter" { + continue + } + ast.Inspect(fn.Body, func(n ast.Node) bool { + if sel, ok := n.(*ast.SelectorExpr); ok { + calls = append(calls, sel.Sel.Name) + } + return true + }) + } + body := strings.Join(calls, " ") + if !strings.Contains(body, "HoldAfterFailedUpdateWhole") { + t.Fatal("the adapter does not record the hold through HoldAfterFailedUpdateWhole — the precondition copy would be named again (R-659)") + } + if strings.Contains(body, "HoldAfterFailedUpdateHolding") { + t.Fatal("the adapter still calls HoldAfterFailedUpdateHolding with the precondition copy") + } + sends := false + ast.Inspect(mainBody(t), func(n ast.Node) bool { + if sel, ok := n.(*ast.SelectorExpr); ok && sel.Sel.Name == "NotifyAppHoldNoWholeCopy" { + sends = true + } + return true + }) + if !sends { + t.Fatal("main never calls NotifyAppHoldNoWholeCopy — support would never be told") + } +} diff --git a/controller/cmd/controller/r660_held_not_down_test.go b/controller/cmd/controller/r660_held_not_down_test.go new file mode 100644 index 0000000..fc1807b --- /dev/null +++ b/controller/cmd/controller/r660_held_not_down_test.go @@ -0,0 +1,87 @@ +package main + +import ( + "go/ast" + "testing" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" +) + +// R-660 (v0.268.0) — an app HELD after a failed update is stopped by the product and has its own +// event. Measured on 9202 2026-09-23 night (chaos rounds 8 and 11): `app_update_held` at 20:56:04 and +// 21:42:39, then `app_start_failed` 11 s and 13 s later for the same moment — two alarms, one fact. + +type fakeHeld map[string]bool + +func (f fakeHeld) UpdateHeldStacks() map[string]bool { return f } + +// TestR660_UpdateHeldAppIsNotReportedDown — the consequence, through the suppression the scan builds. +// A genuinely exited app beside it must still alarm (the over-correction guard). +// +// COMPANION RED-PROOF (REPORT.md): make updateHeldSet return nil. This test then fails at "a held app +// was reported DOWN". +func TestR660_UpdateHeldAppIsNotReportedDown(t *testing.T) { + sts := []stacks.Stack{ + stack("nextcloud", stacks.StateStopped, true, false), // held after a failed update + stack("romm", stacks.StateExited, true, false), // genuinely broken + } + // A held app's intent is "running" — the household never asked for the stop. + for i := range sts { + sts[i].AppConfig = &stacks.AppConfig{DesiredState: stacks.DesiredStateRunning} + } + dead, states := classifyRunStates(sts, unionSuppressed(nil, updateHeldSet(fakeHeld{"nextcloud": true})), nil, time.Now()) + down := downByName(states) + if down["nextcloud"] || deadNames(dead)["nextcloud"] { + t.Fatal("a held app was reported DOWN — the second, false alarm after app_update_held (R-660)") + } + if !down["romm"] { + t.Fatal("a genuinely exited app stopped alarming — the fix silenced a real fault") + } + // Control: WITHOUT the held set the same app does alarm — so the suppression is what decides. + _, states = classifyRunStates(sts, nil, nil, time.Now()) + if !downByName(states)["nextcloud"] { + t.Fatal("control failed: the fixture's app does not alarm even without the suppression") + } +} + +// The wiring: main passes the backup manager, and the scan unions its held set. +func TestR660_ScanIsGivenTheHeldSet(t *testing.T) { + _, f, _ := slice4CallLines(t) + reads := false + for _, d := range f.Decls { + fn, ok := d.(*ast.FuncDecl) + if !ok || fn.Name.Name != "scanDeployedAppRunStates" || fn.Body == nil { + continue + } + ast.Inspect(fn.Body, func(n ast.Node) bool { + if call, ok := n.(*ast.CallExpr); ok { + if id, ok := call.Fun.(*ast.Ident); ok && id.Name == "updateHeldSet" { + reads = true + } + } + return true + }) + } + if !reads { + t.Fatal("scanDeployedAppRunStates does not read the update-held set (R-660)") + } + withB := false + ast.Inspect(mainBody(t), func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + if id, ok := call.Fun.(*ast.Ident); ok && id.Name == "scanDeployedAppRunStates" { + for _, a := range call.Args { + if x, ok := a.(*ast.Ident); ok && x.Name == "backupMgr" { + withB = true + } + } + } + return true + }) + if !withB { + t.Fatal("main calls scanDeployedAppRunStates without backupMgr — the held set is built and never read") + } +} diff --git a/controller/internal/backup/offbox_reconstitute.go b/controller/internal/backup/offbox_reconstitute.go index c706813..a1596d7 100644 --- a/controller/internal/backup/offbox_reconstitute.go +++ b/controller/internal/backup/offbox_reconstitute.go @@ -347,6 +347,9 @@ func (m *Manager) RestoreHoldForLang(stack, lang string) (bool, string) { // Slice 4: one storage, two reasons. An update hold names the copy it can be restored from; a // restore hold names nothing, because the restore it refers to already consumed the copy. if h.Reason == settings.HoldReasonUpdateFailed { + if h.NoWholeCopy { // R-659: the sentence carries the undo's failure itself and names no copy + return true, util.Text(lang, "hold.update.no_whole_copy", stack) + } return true, m.undoHoldPrefix(lang, h.UndoState) + updateHoldSentence(lang, stack, h) } when := h.At diff --git a/controller/internal/backup/r658_restore_labels_test.go b/controller/internal/backup/r658_restore_labels_test.go new file mode 100644 index 0000000..b69ea87 --- /dev/null +++ b/controller/internal/backup/r658_restore_labels_test.go @@ -0,0 +1,85 @@ +package backup + +import ( + "io" + "log" + "os" + "path/filepath" + "strings" + "testing" +) + +// R-658 (v0.268.0) — the unit restore recreated each named volume with a bare `docker volume create`, +// so the volume carried no compose label; the undo (until v0.267.0) selected volumes by that label and +// copied nothing after any restore. The restore now creates the volume WITH compose's labels. +// +// The docker here is a STUB script in t.TempDir() on PATH (R-650's seam) that records every call. +// +// COMPANION RED-PROOF (REPORT.md): drop composeVolumeLabelArgs from the create call. This test then +// fails at "the restore created vikunja_files WITHOUT the project label". +func TestR658_RestoreCreatesLabelledVolumes(t *testing.T) { + bin := t.TempDir() + calls := filepath.Join(bin, "calls.log") + stub := "#!/bin/sh\necho \"$@\" >> " + calls + "\n" + + "case \"$*\" in 'compose version --short') echo v2.29.7;; esac\nexit 0\n" + if err := os.WriteFile(filepath.Join(bin, "docker"), []byte(stub), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + + dump := t.TempDir() + for _, n := range []string{"vikunja_files.tar", "vikunja_db.tar"} { + if err := os.WriteFile(filepath.Join(dump, n), []byte("tar"), 0o644); err != nil { + t.Fatal(err) + } + } + m := &Manager{logger: log.New(io.Discard, "", 0)} + n, err := m.restoreDockerVolumesFrom("vikunja", dump) + if err != nil || n != 2 { + t.Fatalf("restored %d, err %v — want 2, nil", n, err) + } + b, _ := os.ReadFile(calls) + var creates []string + for _, l := range strings.Split(string(b), "\n") { + if strings.HasPrefix(l, "volume create") { + creates = append(creates, l) + } + } + if len(creates) != 2 { + t.Fatalf("want 2 volume creates, got %q", creates) + } + for _, want := range []struct{ vol, key string }{{"vikunja_files", "files"}, {"vikunja_db", "db"}} { + found := false + for _, c := range creates { + if !strings.HasSuffix(c, " "+want.vol) { + continue + } + found = true + if !strings.Contains(c, "--label com.docker.compose.project=vikunja") { + t.Fatalf("the restore created %s WITHOUT the project label: %q", want.vol, c) + } + if !strings.Contains(c, "--label com.docker.compose.volume="+want.key) { + t.Fatalf("the restore created %s without its volume key %q: %q", want.vol, want.key, c) + } + if !strings.Contains(c, "--label com.docker.compose.version=2.29.7") { + t.Fatalf("the restore created %s without the compose version: %q", want.vol, c) + } + if strings.Contains(c, "config-hash") { + t.Fatalf("a guessed config-hash label would make compose offer to recreate the volume: %q", c) + } + } + if !found { + t.Fatalf("no create for %s in %q", want.vol, creates) + } + } +} + +// A volume without the `_` prefix is created without labels — never with a guessed key. +func TestR658_LabelArgsNeverGuessAKey(t *testing.T) { + if got := composeVolumeLabelArgs("vikunja", "custom_name", "2.29.7"); got != nil { + t.Fatalf("got %v, want no labels", got) + } + if got := composeVolumeLabelArgs("vikunja", "vikunja_db", ""); len(got) != 4 { + t.Fatalf("an unreadable version drops only the version label; got %v", got) + } +} diff --git a/controller/internal/backup/r659_whole_copy_test.go b/controller/internal/backup/r659_whole_copy_test.go new file mode 100644 index 0000000..bbb3265 --- /dev/null +++ b/controller/internal/backup/r659_whole_copy_test.go @@ -0,0 +1,160 @@ +package backup + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/util" +) + +// R-659 (v0.268.0; operator ruling 2026-09-24, `09` §3 decision 25, option A) — a held app's sentence +// names only a copy that brings the app back WHOLE; with none, it says so and names nothing. +// +// Measured 2026-09-24 00:00 on 9202, chaos round 11: nextcloud (drive files declared), own unit the only +// copy; the hold named „saját meghajtó"; the restore of exactly that copy REFUSED (R-538). + +var r659At = time.Date(2026, 9, 23, 21, 42, 39, 0, time.UTC) + +// r659Manager: one app; `files` declares a mandatory drive leg (nextcloud's class). `tiers` are the +// copies present, each at its own time. +func r659Manager(t *testing.T, files bool, tiers map[int]time.Time) *Manager { + t.Helper() + drive := t.TempDir() + m, _, prov := classifiedOffboxManager(t, drive) + prov.hdd["nextcloud"] = drive + if files { + prov.binds["nextcloud"] = []ClassifiedBind{mandatoryHDD("appdata/nextcloud")} + prov.has["nextcloud"] = true + } + m.updateTier2PointFn = func(string) (Tier2RestorePoint, error) { + at, ok := tiers[UpdateTierSecondDrive] + if !ok { + return Tier2RestorePoint{}, errors.New("no Tier-2 copy") + } + return Tier2RestorePoint{Restorable: true, CopyDateProven: true, CopyLastSuccess: at.Format(time.RFC3339), CopyDate: at.Format(time.RFC3339)}, nil + } + m.updateTier1PointsFn = func(string) ([]RestorePoint, bool) { + at, ok := tiers[UpdateTierLocal] + if !ok { + return nil, false + } + return []RestorePoint{{Time: at.Format(time.RFC3339), Tier: 1}}, true + } + m.updateOffsiteTimesFn = func(context.Context) (map[string]time.Time, error) { + at, ok := tiers[UpdateTierOffsite] + if !ok { + return map[string]time.Time{}, nil + } + return map[string]time.Time{"nextcloud": at}, nil + } + return m +} + +// TestR659_TheHoldNamesOnlyAWholeCopy — app class × copies present. +// +// COMPANION RED-PROOF (REPORT.md): make WholeOnTier answer true for every tier (the v0.267.0 reading: +// any copy is a route back). The round-11 case then fails at "names „saját meghajtó"". +func TestR659_TheHoldNamesOnlyAWholeCopy(t *testing.T) { + unit := r659At.Add(-2 * time.Hour) + second := r659At.Add(-20 * time.Hour) + off := r659At.Add(-5 * time.Hour) + noWhole := util.Text("hu", "hold.update.no_whole_copy", "nextcloud") + cases := []struct { + name string + files bool + tiers map[int]time.Time + wantNone bool + wantLabel string // the tier label the sentence must name (hu) + }{ + {"files / unit only (round 11)", true, map[int]time.Time{UpdateTierLocal: unit}, true, ""}, + {"files / second drive only", true, map[int]time.Time{UpdateTierSecondDrive: second}, true, ""}, + {"files / unit + second drive", true, map[int]time.Time{UpdateTierLocal: unit, UpdateTierSecondDrive: second}, true, ""}, + {"files / off-site + unit", true, map[int]time.Time{UpdateTierLocal: unit, UpdateTierOffsite: off}, false, "távoli mentés"}, + {"files / none", true, map[int]time.Time{}, true, ""}, + {"volumes / unit only", false, map[int]time.Time{UpdateTierLocal: unit}, false, "saját meghajtó"}, + {"volumes / second drive older than unit", false, map[int]time.Time{UpdateTierLocal: unit, UpdateTierSecondDrive: second}, false, "saját meghajtó"}, + {"volumes / off-site newest", false, map[int]time.Time{UpdateTierSecondDrive: second, UpdateTierOffsite: off}, false, "távoli mentés"}, + {"volumes / none", false, map[int]time.Time{}, true, ""}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + m := r659Manager(t, c.files, c.tiers) + none, err := m.HoldAfterFailedUpdateWhole(context.Background(), "nextcloud", r659At, "untouched") + if err != nil { + t.Fatal(err) + } + held, why := m.RestoreHoldForLang("nextcloud", "hu") + if !held { + t.Fatal("not held") + } + if c.wantNone { + if !none || why != noWhole { + t.Fatalf("no whole copy exists, yet the hold names %q (none=%v)", why, none) + } + if !m.HoldNoWholeCopy("nextcloud") { + t.Fatal("the page flag is not set — the restore button would stay") + } + h, _ := m.UpdateHold("nextcloud") + if len(h.CopiesSeen) != len(c.tiers) { + t.Fatalf("copies seen %v, want %d recorded for support", h.CopiesSeen, len(c.tiers)) + } + return + } + if none || strings.Contains(why, "nincs olyan másolat") { + t.Fatalf("a whole copy exists, yet the hold says none: %q", why) + } + if !strings.Contains(why, c.wantLabel) { + t.Fatalf("the hold names %q, want the copy %q", why, c.wantLabel) + } + if m.HoldNoWholeCopy("nextcloud") { + t.Fatal("the page flag is set over a whole copy") + } + }) + } +} + +// The English sentence is the operator's copy, verbatim, and the Hungarian likewise. +func TestR659_SentenceBothLanguages(t *testing.T) { + m := r659Manager(t, true, map[int]time.Time{UpdateTierLocal: r659At.Add(-time.Hour)}) + if _, err := m.HoldAfterFailedUpdateWhole(context.Background(), "nextcloud", r659At, "untouched"); err != nil { + t.Fatal(err) + } + _, en := m.RestoreHoldForLang("nextcloud", "en") + if en != "The update of nextcloud did not work, and neither did the automatic undo. This box has no copy that can bring the app back together with its files. Felhom support has been told — until then, do not restart or remove the app." { + t.Fatalf("en = %q", en) + } + _, hu := m.RestoreHoldForLang("nextcloud", "hu") + if hu != "A(z) nextcloud frissítése nem sikerült, és az automatikus visszaállítás sem. Ezen a dobozon nincs olyan másolat, amely az alkalmazást a fájljaival együtt vissza tudná hozni. A Felhom ügyfélszolgálatát értesítettük — kérjük, addig ne indítsa újra és ne törölje az alkalmazást." { + t.Fatalf("hu = %q", hu) + } +} + +// TestR659_TruthTableAgreesWithTheRestoresRefusal — WholeOnTier for the unit tiers must be exactly +// "the unit restore does not refuse for missing files". The two predicates cannot drift. +func TestR659_TruthTableAgreesWithTheRestoresRefusal(t *testing.T) { + for _, files := range []bool{true, false} { + drive := t.TempDir() + m, _, prov := classifiedOffboxManager(t, drive) + prov.hdd["nextcloud"] = drive + if files { + prov.binds["nextcloud"] = []ClassifiedBind{mandatoryHDD("appdata/nextcloud")} + prov.has["nextcloud"] = true + } + mkUnit(t, drive, "nextcloud") + _, err := m.RestoreFromRecoveryUnitAt("nextcloud", RecoveryUnitPath(drive, "nextcloud")) + var refusal *ErrUnitLacksFileLegs + refused := errors.As(err, &refusal) + for _, tier := range []int{UpdateTierLocal, UpdateTierSecondDrive} { + if m.WholeOnTier("nextcloud", tier) == refused { + t.Fatalf("files=%v tier %d: WholeOnTier=%v but the unit restore refused=%v — the page would send a household to a refusal", + files, tier, m.WholeOnTier("nextcloud", tier), refused) + } + } + if !m.WholeOnTier("nextcloud", UpdateTierOffsite) { + t.Fatal("the off-site full restore brings files and database back — it is whole") + } + } +} diff --git a/controller/internal/backup/r660_update_held_test.go b/controller/internal/backup/r660_update_held_test.go new file mode 100644 index 0000000..7299b37 --- /dev/null +++ b/controller/internal/backup/r660_update_held_test.go @@ -0,0 +1,29 @@ +package backup + +import ( + "io" + "log" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" +) + +// R-660 — UpdateHeldStacks names the apps an UPDATE hold stops, and never an R-379 restore hold (that +// hold has no event of its own; the app-down alarm stays its voice). +func TestR660_UpdateHeldStacksIsUpdateHoldsOnly(t *testing.T) { + sett := slice4Settings(t) + m := &Manager{logger: log.New(io.Discard, "", 0), settings: sett} + if got := m.UpdateHeldStacks(); len(got) != 0 { + t.Fatalf("no holds → %v", got) + } + _ = sett.SetRestoreHold(settings.RestoreHold{Stack: "nextcloud", At: "2026-09-23T21:42:39Z", Reason: settings.HoldReasonUpdateFailed}) + _ = sett.SetRestoreHold(settings.RestoreHold{Stack: "docmost", At: "2026-08-22T14:00:00Z"}) + got := m.UpdateHeldStacks() + if !got["nextcloud"] || got["docmost"] || len(got) != 1 { + t.Fatalf("got %v, want exactly {nextcloud}", got) + } + var nilM *Manager + if nilM.UpdateHeldStacks() != nil { + t.Fatal("a nil manager must answer nil") + } +} diff --git a/controller/internal/backup/restore.go b/controller/internal/backup/restore.go index 1aa3603..e81fa75 100644 --- a/controller/internal/backup/restore.go +++ b/controller/internal/backup/restore.go @@ -139,19 +139,32 @@ func (m *Manager) restoreDockerVolumesFrom(stackName, dumpDir string) (int, erro var restored int var failed []string + composeVer := "" for _, entry := range entries { if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".tar") { continue } volName := strings.TrimSuffix(entry.Name(), ".tar") + if composeVer == "" { + composeVer = composeVersionShort() + } m.logger.Printf("[INFO] [backup] Restoring Docker volume %s for %s", volName, stackName) // Remove existing volume (ignore errors — may not exist) dockerexec.Command("docker", "volume", "rm", "-f", volName).Run() - // Create fresh volume - if out, err := dockerexec.Command("docker", "volume", "create", volName).CombinedOutput(); err != nil { + // Create fresh volume — WITH the labels compose gives its own volumes (R-658, v0.268.0). A bare + // `volume create` made a volume the update's undo (until v0.267.0) could not find, and one + // compose warns it "was not created by Docker Compose". The config-hash label is deliberately + // NOT set: compose only compares it when present, and a guessed hash would make it offer to + // recreate (empty) the volume. + args := append([]string{"volume", "create"}, composeVolumeLabelArgs(stackName, volName, composeVer)...) + if len(args) == 2 { + m.logger.Printf("[WARN] [backup] volume %s does not carry the %s_ prefix — created WITHOUT compose labels (the undo finds it by the app's definition anyway)", volName, stackName) + } + args = append(args, volName) + if out, err := dockerexec.Command("docker", args...).CombinedOutput(); err != nil { m.logger.Printf("[ERROR] [backup] Failed to create volume %s: %s — %v", volName, strings.TrimSpace(string(out)), err) failed = append(failed, volName) continue @@ -215,3 +228,29 @@ func (m *Manager) waitForHealthy(stackName string, timeout time.Duration) error } return fmt.Errorf("stack %s did not reach running state within %s after restore", stackName, timeout) } + +// composeVolumeLabelArgs returns the `--label` arguments that make a restored volume look like one +// compose created for this project: project, volume key and compose version. The key is the part of +// the Docker name after `_`, which is how compose names a volume without its own `name:` — +// every catalog template today (R-658, measured 2026-09-24). A name without that prefix gets no labels +// rather than a guessed key. The version label is left out when the version could not be read. +func composeVolumeLabelArgs(project, volName, composeVersion string) []string { + key := strings.TrimPrefix(volName, project+"_") + if key == volName || key == "" { + return nil + } + args := []string{"--label", "com.docker.compose.project=" + project, "--label", "com.docker.compose.volume=" + key} + if composeVersion != "" { + args = append(args, "--label", "com.docker.compose.version="+composeVersion) + } + return args +} + +// composeVersionShort is `docker compose version --short`, "" when it cannot be read. +func composeVersionShort() string { + out, err := dockerexec.Command("docker", "compose", "version", "--short").Output() + if err != nil { + return "" + } + return strings.TrimPrefix(strings.TrimSpace(string(out)), "v") +} diff --git a/controller/internal/backup/update_guard.go b/controller/internal/backup/update_guard.go index 5e965ac..f6e5677 100644 --- a/controller/internal/backup/update_guard.go +++ b/controller/internal/backup/update_guard.go @@ -608,3 +608,132 @@ func (m *Manager) clearUpdateHoldAfterRestore(stackName string) { } m.logger.Printf("[INFO] [backup] %s: restore completed — the update hold (set %s) is CLEARED", stackName, h.At) } + +// UpdateHeldStacks is the set of apps held stopped after a failed update (R-660, v0.268.0) — the +// FOURTH way the product stops an app on purpose, and until v0.268.0 the one `classifyRunStates` did +// not know: each hold's `app_update_held` was followed ~11 s later by an `app_start_failed` for the +// same app (chaos rounds 8 and 11, 2026-09-23 night). A RESTORE hold (R-379) is not in the set: it +// has no event of its own, so the app-down alarm stays its only voice. Nil-safe. +func (m *Manager) UpdateHeldStacks() map[string]bool { + if m == nil || m.settings == nil { + return nil + } + var out map[string]bool + for _, h := range m.settings.ListRestoreHolds() { + if h.Reason != settings.HoldReasonUpdateFailed { + continue + } + if out == nil { + out = map[string]bool{} + } + out[h.Stack] = true + } + return out +} + +// ── R-659 (v0.268.0): the hold names only a copy that can bring the app back WHOLE ──────────────── +// +// MEASURED 2026-09-24 00:00 on 9202 (chaos round 11): nextcloud's update and its undo both failed; the +// hold named „saját meghajtó" (the precondition copy — decision 8 lets an update lean on any tier); +// the household pressed exactly that restore and was REFUSED, because the unit holds no copy of the +// app's files on the drive (R-538). The box had no other copy, so nothing on any page brought the app +// back. Operator ruling 2026-09-24 (`09` §3 decision 25, option A): the hold names only a copy that +// brings the app back whole; with none, it says so, says support is informed, and support is told. +// +// THE TRUTH TABLE, read from the restores' OWN refusals (measured from source, v0.267.0), not from +// what each tier stores: +// +// app own unit (1) second drive (2) off-site (3) +// no declared drive files whole (unit restore) whole („Teljes visszaállítás") whole (full restore) +// declared drive files NOT — refused (R-538) NOT — its unit restore is refused whole („Teljes +// (DeclaredDriveFileLegs) by the same guard; its file restore visszaállítás (fájlok +// only ADDS missing files, no database + adatbázis)") +// +// So the question is asked of the SAME predicate the refusal uses (DeclaredDriveFileLegs), and a test +// pins that the two cannot drift (TestR659_TruthTableAgreesWithTheRestoresRefusal). Tier 2 holds a +// file app's files AND its unit, but no single action brings the app back whole from it — R-661. + +// WholeOnTier reports whether a copy on `tier` can bring this app back WHOLE through the restore the +// Mentések page offers for that tier. +func (m *Manager) WholeOnTier(stackName string, tier int) bool { + switch tier { + case UpdateTierOffsite: + return true + case UpdateTierLocal, UpdateTierSecondDrive: + return !m.HasDriveFileLegs(stackName) + } + return false +} + +// HoldCopies walks EVERY tier (not only until the first acceptable one, as the update does) and +// returns the newest copy that brings the app back whole, whether there is one, and every copy seen. +func (m *Manager) HoldCopies(ctx context.Context, stackName string) (UpdateTierPoint, bool, []UpdateTierPoint) { + var seen []UpdateTierPoint + var best UpdateTierPoint + found := false + for _, tier := range []int{UpdateTierSecondDrive, UpdateTierLocal, UpdateTierOffsite} { + p, ok := m.updateTierPoint(ctx, stackName, tier) + if !ok { + continue + } + seen = append(seen, p) + if m.WholeOnTier(stackName, tier) && (!found || p.At.After(best.At)) { + best, found = p, true + } + } + return best, found, seen +} + +// HoldAfterFailedUpdateWhole records the update hold naming the newest WHOLE copy, or — with none — +// a hold that names nothing and says support is informed (NoWholeCopy). The copies seen are recorded +// either way. Returns whether no whole copy exists. +func (m *Manager) HoldAfterFailedUpdateWhole(ctx context.Context, stackName string, at time.Time, undoState string) (bool, error) { + best, found, seen := m.HoldCopies(ctx, stackName) + var seenS []string + for _, p := range seen { + seenS = append(seenS, fmt.Sprintf("tier %d at %s", p.Tier, p.At.UTC().Format(time.RFC3339))) + } + if !found { + if m == nil || m.settings == nil { + return true, fmt.Errorf("no settings wired — the update hold for %s cannot be persisted", stackName) + } + h := settings.RestoreHold{Stack: stackName, At: at.UTC().Format(time.RFC3339), Reason: settings.HoldReasonUpdateFailed, + UndoState: undoState, NoWholeCopy: true, CopiesSeen: seenS} + if err := m.settings.SetRestoreHold(h); err != nil { + return true, fmt.Errorf("persisting the update hold for %s: %w", stackName, err) + } + m.logger.Printf("[ERROR] [backup] %s is HELD STOPPED after a failed update and NO copy on this box brings it back whole (seen: %v; drive files declared: %v; undo: %q) — support must act (R-659)", + stackName, seenS, m.HasDriveFileLegs(stackName), undoState) + return true, nil + } + if err := m.HoldAfterFailedUpdateHolding(stackName, at, best.At, best.Tier, m.UpdateCopyHolds(stackName, best.Tier), undoState); err != nil { + return false, err + } + if h, ok := m.settings.GetRestoreHold(stackName); ok { + h.CopiesSeen = seenS + _ = m.settings.SetRestoreHold(h) + } + return false, nil +} + +// HoldNoWholeCopy reports whether the app's hold names no copy (R-659) — the page then offers no +// restore button for it. +func (m *Manager) HoldNoWholeCopy(stackName string) bool { + if m == nil || m.settings == nil { + return false + } + h, ok := m.settings.GetRestoreHold(stackName) + return ok && h.Reason == settings.HoldReasonUpdateFailed && h.NoWholeCopy +} + +// UpdateHold returns the stored update hold, for the operator event (R-659). +func (m *Manager) UpdateHold(stackName string) (settings.RestoreHold, bool) { + if m == nil || m.settings == nil { + return settings.RestoreHold{}, false + } + h, ok := m.settings.GetRestoreHold(stackName) + if !ok || h.Reason != settings.HoldReasonUpdateFailed { + return settings.RestoreHold{}, false + } + return h, true +} diff --git a/controller/internal/i18n/locales/en.json b/controller/internal/i18n/locales/en.json index 2fe3216..d324baf 100644 --- a/controller/internal/i18n/locales/en.json +++ b/controller/internal/i18n/locales/en.json @@ -149,6 +149,7 @@ "app_info.megjelenites": "Show", "app_info.megnyitas": "Open ↗", "app_info.megszakadt": "Stopped:", + "app_info.ladder_steps_left": "Update steps remaining:", "app_info.mentesek": "Backups", "app_info.mire_hasznalhato": "What is it for?", "app_info.naplo": "Log", @@ -1484,6 +1485,7 @@ "hold.copy_holds.db_only": "holds only the settings and the database, not the files", "hold.copy_holds.files": "holds the settings, the database and the files", "hold.copy_holds.volumes": "holds the settings, the database and the data volumes", + "hold.update.no_whole_copy": "The update of %s did not work, and neither did the automatic undo. This box has no copy that can bring the app back together with its files. Felhom support has been told — until then, do not restart or remove the app.", "hold.update.sentence": "The update of %s at %s did not succeed, and the app did not start on the new version. The app stays stopped for safety, so that its data is not damaged. It can be restored on the Backups page from this backup: %s, %s — this copy %s.", "hold.update.sentence_legacy": "The update of %s at %s did not succeed, and the app did not start on the new version. The app stays stopped for safety, so that its data is not damaged. It can be restored on the Backups page from the backup of %s.", "hold.update.sentence_tier": "The update of %s at %s did not succeed, and the app did not start on the new version. The app stays stopped for safety, so that its data is not damaged. It can be restored on the Backups page from this backup: %s, %s.", diff --git a/controller/internal/i18n/locales/hu.json b/controller/internal/i18n/locales/hu.json index c94ea3e..01ba69b 100644 --- a/controller/internal/i18n/locales/hu.json +++ b/controller/internal/i18n/locales/hu.json @@ -145,6 +145,7 @@ "app_info.megjelenites": "Megjelenítés", "app_info.megnyitas": "Megnyitás ↗", "app_info.megszakadt": "Megszakadt:", + "app_info.ladder_steps_left": "Hátralévő frissítési lépések:", "app_info.mentesek": "Mentések", "app_info.mire_hasznalhato": "Mire használható?", "app_info.naplo": "Napló", @@ -1472,6 +1473,7 @@ "hold.copy_holds.db_only": "csak a beállításokat és az adatbázist tartalmazza, a fájlokat nem", "hold.copy_holds.files": "a beállításokat, az adatbázist és a fájlokat tartalmazza", "hold.copy_holds.volumes": "a beállításokat, az adatbázist és az adatköteteket tartalmazza", + "hold.update.no_whole_copy": "A(z) %s frissítése nem sikerült, és az automatikus visszaállítás sem. Ezen a dobozon nincs olyan másolat, amely az alkalmazást a fájljaival együtt vissza tudná hozni. A Felhom ügyfélszolgálatát értesítettük — kérjük, addig ne indítsa újra és ne törölje az alkalmazást.", "hold.update.sentence": "A(z) %s frissítése %s-kor nem sikerült, és az alkalmazás nem indult el az új verzióval. Az alkalmazás biztonsági okból leállítva marad, hogy az adatai ne sérüljenek. Visszaállítható a Mentések oldalon ebből a biztonsági mentésből: %s, %s — ez a másolat %s.", "hold.update.sentence_legacy": "A(z) %s frissítése %s-kor nem sikerült, és az alkalmazás nem indult el az új verzióval. Az alkalmazás biztonsági okból leállítva marad, hogy az adatai ne sérüljenek. Visszaállítható a(z) %s-i biztonsági mentésből a Mentések oldalon.", "hold.update.sentence_tier": "A(z) %s frissítése %s-kor nem sikerült, és az alkalmazás nem indult el az új verzióval. Az alkalmazás biztonsági okból leállítva marad, hogy az adatai ne sérüljenek. Visszaállítható a Mentések oldalon ebből a biztonsági mentésből: %s, %s.", diff --git a/controller/internal/notify/notifier.go b/controller/internal/notify/notifier.go index 68034a2..072040e 100644 --- a/controller/internal/notify/notifier.go +++ b/controller/internal/notify/notifier.go @@ -1304,6 +1304,31 @@ func (n *Notifier) NotifyAppUpdateHeld(d AppUpdateDetails, sentence func(lang st n.pushEventBoth("app_update_held", "error", hu, household, d) } +// AppHoldNoWholeCopyDetails is the payload of app_hold_no_whole_copy (v0.268.0, R-659). +type AppHoldNoWholeCopyDetails struct { + App string `json:"app"` + StackName string `json:"stack_name"` + From map[string]string `json:"from,omitempty"` + To map[string]string `json:"to,omitempty"` + At string `json:"at"` + CopiesSeen []string `json:"copies_seen"` + UndoState string `json:"undo_state,omitempty"` +} + +// NotifyAppHoldNoWholeCopy (v0.268.0, R-659; operator ruling 2026-09-24, option A): an app is held +// after a failed update AND a failed undo, and no copy on its box brings it back whole. OPERATOR-ONLY +// on the hub (operatorOnlyEvents) — the household's side is its app_update_held mail, which now says +// support is informed; this event is that information. Severity critical: a household's app is +// stopped and only support can bring it back. +func (n *Notifier) NotifyAppHoldNoWholeCopy(d AppHoldNoWholeCopyDetails) { + if d.CopiesSeen == nil { + d.CopiesSeen = []string{} // the JSON reads [] — "none seen" is a finding, not a missing field + } + msg := fmt.Sprintf("%s is HELD after a failed update and a failed undo (%s), and NO copy on this box brings it back whole. Copies seen: %v. Support must act.", + d.App, d.UndoState, d.CopiesSeen) + n.pushEventBoth("app_hold_no_whole_copy", "critical", msg, "", d) +} + // healthSeverity is the event severity a health status would be sent at (R-647): the disabled path // names what it drops, and "warn" is a health STATUS, not a severity the hub knows. func healthSeverity(status string) string { diff --git a/controller/internal/notify/r659_no_whole_copy_test.go b/controller/internal/notify/r659_no_whole_copy_test.go new file mode 100644 index 0000000..48edf46 --- /dev/null +++ b/controller/internal/notify/r659_no_whole_copy_test.go @@ -0,0 +1,34 @@ +package notify + +import ( + "encoding/json" + "strings" + "testing" +) + +// R-659 (v0.268.0) — app_hold_no_whole_copy: severity critical (the hub's vocabulary), the operator +// message only (no household sentence — the hub bars the type from customers too), and the details +// carry what support needs: app, from, to, at, copies_seen (`[]`, never null, when none were seen). +func TestR659_NoWholeCopyEvent(t *testing.T) { + n := &Notifier{} + var et, sev, msg, cust string + var det []byte + n.pushFn = func(eventType, severity, message, customer string, details interface{}) { + et, sev, msg, cust = eventType, severity, message, customer + det, _ = json.Marshal(details) + } + n.NotifyAppHoldNoWholeCopy(AppHoldNoWholeCopyDetails{App: "nextcloud", StackName: "nextcloud", + From: map[string]string{"nextcloud": "nextcloud:34.0.1"}, To: map[string]string{"nextcloud": "nextcloud:34.0.4"}, + At: "2026-09-23T21:42:39Z", UndoState: "untouched"}) + if et != "app_hold_no_whole_copy" || sev != "critical" || cust != "" { + t.Fatalf("type=%q severity=%q customer=%q", et, sev, cust) + } + if !strings.Contains(msg, "nextcloud") || !strings.Contains(msg, "Support must act") { + t.Fatalf("message = %q", msg) + } + for _, want := range []string{`"app":"nextcloud"`, `"stack_name":"nextcloud"`, `"copies_seen":[]`, `"at":"2026-09-23T21:42:39Z"`, `"from":{`, `"to":{`} { + if !strings.Contains(string(det), want) { + t.Fatalf("details %s lack %s", det, want) + } + } +} diff --git a/controller/internal/settings/settings.go b/controller/internal/settings/settings.go index 6e93b82..f65212f 100644 --- a/controller/internal/settings/settings.go +++ b/controller/internal/settings/settings.go @@ -1753,6 +1753,11 @@ type RestoreHold struct { // "not_started" (stacks.UndoState*). Empty: no undo was attempted (every hold written before the // field existed). Only set for HoldReasonUpdateFailed. UndoState string `json:"undo_state,omitempty"` + // NoWholeCopy (v0.268.0, R-659; operator ruling 2026-09-24, option A) — at hold time NO copy on + // this box could bring the app back whole (backup.WholeOnTier), so the sentence names none and + // says support is informed. CopiesSeen lists what WAS there ("tier N at RFC3339"), for support. + NoWholeCopy bool `json:"no_whole_copy,omitempty"` + CopiesSeen []string `json:"copies_seen,omitempty"` } // Hold reasons. See RestoreHold.Reason. diff --git a/controller/internal/stacks/delete.go b/controller/internal/stacks/delete.go index 8a133b2..9833ba1 100644 --- a/controller/internal/stacks/delete.go +++ b/controller/internal/stacks/delete.go @@ -8,6 +8,7 @@ import ( "os" "os/exec" "path/filepath" + "sort" "strings" "time" @@ -601,7 +602,7 @@ func (m *Manager) RemoveStack(name string, removeHDDData bool, backupPathsToRemo // R-489 (v0.242.0): the volumes are listed BEFORE and AFTER; the difference is what was removed. // Parsing compose's progress output reported `null` over volumes it did remove — measured five // times on demo-hp 2026-09-13 — because compose prints that progress to a TTY it does not have here. - volsBefore := m.projectVolumes(name) + volsBefore := m.appVolumeSet(name, stackDir) output, err := m.composeExecCustomEnv(stackDir, env, "down", "--volumes") if m.isDebug() { m.logger.Printf("[DEBUG] [stacks] RemoveStack %s: compose down output: %s", name, truncateStr(output, 500)) @@ -629,7 +630,7 @@ func (m *Manager) RemoveStack(name string, removeHDDData bool, backupPathsToRemo } // Step 3: the volumes that are gone now — `[]` when none, never null (R-489). - resp.VolumesRemoved = removedVolumes(volsBefore, m.projectVolumes(name)) + resp.VolumesRemoved = removedVolumes(volsBefore, m.appVolumeSet(name, stackDir)) if len(resp.VolumesRemoved) > 0 { m.logger.Printf("[INFO] [stacks] RemoveStack %s: removed volume(s) %v", name, resp.VolumesRemoved) } @@ -719,6 +720,16 @@ func (m *Manager) RemoveStack(name string, removeHDDData bool, backupPathsToRemo m.logger.Printf("[ERROR] Failed to remove %s: %v", appYAMLPath, err) return resp, fmt.Errorf("failed to remove app.yaml: %w", err) } + // R-651 (v0.268.0): the pinned definition and the pinned version's .felhom.yml go with the app. + // MEASURED 2026-09-23 night on 9202: every remove left `applied-compose.yml` and `applied-meta/` + // behind, so a reinstall of the same name started beside the removed install's record — which the + // undo reads as "the version to go back to". The rest of the directory is the catalog mirror and + // stays. Pinned by TestR651_RemoveDeletesTheAppliedRecord. + for _, p := range []string{AppliedComposePath(stackDir), filepath.Join(stackDir, appliedMetaDir)} { + if err := os.RemoveAll(p); err != nil { + m.logger.Printf("[WARN] [stacks] RemoveStack %s: could not remove %s: %v", name, p, err) + } + } m.logger.Printf("[INFO] Stack %s removed successfully (took %.1fs)", name, time.Since(start).Seconds()) @@ -1004,6 +1015,37 @@ func (m *Manager) projectVolumes(project string) []string { return vols } +// appVolumeSet is every volume the removal accounts for: the ones carrying the project label AND the +// ones the app's definition declares that Docker holds by name (R-658, v0.268.0). A restore before +// v0.268.0 recreated volumes without the label, and the remove then answered `volumes_removed: []` +// over volumes compose's `down --volumes` did remove (measured at the 2026-09-23 night's teardown). +// A listing failure reads as nothing, so a removal never fails on bookkeeping. +func (m *Manager) appVolumeSet(project, stackDir string) []string { + set := map[string]bool{} + for _, v := range m.projectVolumes(project) { + set[v] = true + } + if declared, _, err := DeclaredVolumeNames(ComposePathIn(stackDir)); err == nil && len(declared) > 0 { + if out, lerr := m.execCommand("docker", "volume", "ls", "-q"); lerr == nil { + have := map[string]bool{} + for _, l := range strings.Split(out, "\n") { + have[strings.TrimSpace(l)] = true + } + for _, d := range declared { + if have[d] { + set[d] = true + } + } + } + } + out := make([]string, 0, len(set)) + for v := range set { + out = append(out, v) + } + sort.Strings(out) + return out +} + // removedVolumes is before minus after, as a non-nil slice (the JSON must read `[]`, not `null`). func removedVolumes(before, after []string) []string { still := map[string]bool{} diff --git a/controller/internal/stacks/ladder.go b/controller/internal/stacks/ladder.go new file mode 100644 index 0000000..e28a9aa --- /dev/null +++ b/controller/internal/stacks/ladder.go @@ -0,0 +1,163 @@ +package stacks + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +// ── The update ladder on the box (`09` §3 decision 14, §6.4 part 5; controller v0.268.0) ───────── +// +// WHAT IT REPLACES. Until v0.267.0 one press moved an app straight to the catalog's CURRENT +// definition, however many tested steps lay between. Measured 2026-09-23: vikunja installed at 2.3.0, +// the drill catalog then took 2.4.0 and 2.5.0, one press → 2.5.0 in 9.5 s and 2.4.0 never ran; and on +// demo-hp's 9201 romm took its app step AND its engine step in one press — each tested alone, never +// together. Decision 14: a box behind climbs ONE TESTED STEP at a time, in order, and never jumps. +// +// WHERE A STEP'S DEFINITION LIVES. Not in git history — the box's catalog clone is `--depth 1` +// (`sync.go`), and the commit that moved an image is not always the definition that works (romm's +// 15f9ebf OOM-looped; the working step is its images under the later f4eb94f template). So the catalog +// keeps, for every step but the newest, the step's OWN complete compose file at +// `templates//steps/.yml`, written by `upgrade-test.py --write-ladder` and refused +// absent by `check-test-record.py`. The newest step's definition is the template's docker-compose.yml. +// The box reads both straight from its clone; the syncer copies nothing new into the stack dir. +// +// ONE PRESS = ONE STEP. The guarded update (§6.1) runs unchanged around it: same precondition, same +// safety dump, same undo copy, same health wait, same undo. Only the definition it pins differs. +// +// AN APP OLDER THAN THE LADDER. An installed version that matches no entry's `from` has no record to +// climb — today's behaviour (the catalog's current definition) applies, logged by name. Pinned by +// TestLadder_UnknownInstalledJumpsAndSaysSo. + +// LadderEntry is one line of `.felhom.yml`'s `update_ladder:` — the fields the box reads. The catalog's +// `scripts/ladder.py` documents the whole record. +type LadderEntry struct { + From map[string]string `yaml:"from" json:"from"` + To map[string]string `yaml:"to" json:"to"` + Digest map[string]string `yaml:"digest" json:"digest"` + Verdict string `yaml:"verdict" json:"verdict"` +} + +type ladderDoc struct { + UpdateLadder []LadderEntry `yaml:"update_ladder"` +} + +// LoadLadder reads the ladder from a `.felhom.yml`. No key → (nil, nil). The entries are JSON flow +// mappings, which YAML reads as ordinary maps. +func LoadLadder(felhomPath string) ([]LadderEntry, error) { + data, err := os.ReadFile(felhomPath) + if err != nil { + return nil, err + } + var d ladderDoc + if err := yaml.Unmarshal(data, &d); err != nil { + return nil, fmt.Errorf("parsing update_ladder in %s: %w", felhomPath, err) + } + return d.UpdateLadder, nil +} + +// StepKey names a step's definition file: the first 16 hex of the sha256 of `to` as canonical JSON — +// keys sorted, no spaces, no HTML escaping. The catalog computes the SAME string in Python +// (`json.dumps(to, sort_keys=True, separators=(",", ":"))`); TestLadder_StepKeyMatchesTheCatalog pins +// one real value both sides print. +func StepKey(to map[string]string) string { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + _ = enc.Encode(to) // a map[string]string always encodes; Go sorts map keys + sum := sha256.Sum256(bytes.TrimRight(buf.Bytes(), "\n")) + return hex.EncodeToString(sum[:])[:16] +} + +// StepFile is the step's definition path inside a template directory. +func StepFile(templateDir string, to map[string]string) string { + return filepath.Join(templateDir, "steps", StepKey(to)+".yml") +} + +func sameRefs(a, b map[string]string) bool { + if len(a) != len(b) { + return false + } + for k, v := range a { + if b[k] != v { + return false + } + } + return true +} + +// LadderStep is the one step the next press applies. +type LadderStep struct { + // Index of the entry in the ladder, -1 when the installed version matches no entry. + Index int + // Left counts the steps from this one to the head, this one included. 0 when unknown. + Left int + // Source is the compose file the step pins: a steps/ file, or the template's docker-compose.yml. + Source string + // Why is one operator-English sentence for the log. + Why string +} + +// nextLadderStep decides WHICH definition the next press pins, from the catalog template directory +// and the app's current pin. It never guesses: a missing or wrong step file is an error, and the +// update refuses before anything moves (a jump past a tested step is the thing this exists to stop). +func nextLadderStep(templateDir string, pinned map[string]string) (LadderStep, error) { + current := filepath.Join(templateDir, "docker-compose.yml") + ladder, err := LoadLadder(filepath.Join(templateDir, ".felhom.yml")) + if err != nil && !os.IsNotExist(err) { + return LadderStep{}, err + } + if len(ladder) == 0 { + return LadderStep{Index: -1, Source: current, Why: "the template carries no update_ladder — the catalog's current definition"}, nil + } + idx := -1 + for i := len(ladder) - 1; i >= 0; i-- { // the NEWEST entry whose `from` is what runs + if sameRefs(ladder[i].From, pinned) { + idx = i + break + } + } + if idx < 0 { + return LadderStep{Index: -1, Source: current, + Why: fmt.Sprintf("the installed version %s matches no update_ladder entry (%d entries) — an app older than the ladder has no record to climb; the catalog's current definition", summarisePin(pinned), len(ladder))}, nil + } + left := len(ladder) - idx + if idx == len(ladder)-1 { + return LadderStep{Index: idx, Left: left, Source: current, + Why: fmt.Sprintf("the last step (%d of %d) — the catalog's current definition", idx+1, len(ladder))}, nil + } + src := StepFile(templateDir, ladder[idx].To) + imgs, perr := ParseComposeImages(src) + if perr != nil { + return LadderStep{}, fmt.Errorf("step %d of %d (%s) has no definition at %s: %w", idx+1, len(ladder), summarisePin(ladder[idx].To), src, perr) + } + if !sameRefs(imgs, ladder[idx].To) { + return LadderStep{}, fmt.Errorf("step %d of %d: %s names %s, the ladder says %s", idx+1, len(ladder), src, summarisePin(imgs), summarisePin(ladder[idx].To)) + } + return LadderStep{Index: idx, Left: left, Source: src, + Why: fmt.Sprintf("step %d of %d: %s → %s, from %s", idx+1, len(ladder), summarisePin(ladder[idx].From), summarisePin(ladder[idx].To), filepath.Base(src))}, nil +} + +// ladderStepsLeft is the page's count: how many tested steps separate this pin from the catalog's +// head. 0 = unknown or none (no ladder, no match, or already at the head). +func ladderStepsLeft(templateDir string, pinned map[string]string) int { + if len(pinned) == 0 { + return 0 + } + ladder, err := LoadLadder(filepath.Join(templateDir, ".felhom.yml")) + if err != nil || len(ladder) == 0 { + return 0 + } + for i := len(ladder) - 1; i >= 0; i-- { + if sameRefs(ladder[i].From, pinned) { + return len(ladder) - i + } + } + return 0 +} diff --git a/controller/internal/stacks/ladder_test.go b/controller/internal/stacks/ladder_test.go new file mode 100644 index 0000000..792b56d --- /dev/null +++ b/controller/internal/stacks/ladder_test.go @@ -0,0 +1,214 @@ +package stacks + +import ( + "bytes" + "context" + "log" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +// v0.268.0 — the update ladder on the box (`09` §3 decision 14, §6.4 part 5). Every test runs the REAL +// guarded update job with the process boundaries faked, and reads the EFFECT back: the pin in +// app.yaml, the live compose file's bytes, and every definition `up` was ever run on. + +const ( + ladderA = "nextcloud:31.0.14-apache" + ladderB = "nextcloud:33.0.0-apache" + ladderC = "nextcloud:34.0.1-apache" // = pinTplNew, the catalog's current definition + // STEP B's OWN definition carries a line the template does not — the thing that proves the press + // rendered the step's definition and not the new template with B's image substituted in. + ladderBDef = "services:\n web:\n image: " + ladderB + "\n environment:\n - STEP_B_OWN_DEFINITION=1\nvolumes:\n db:\n" +) + +func ladderLine(from, to string) string { + return ` - {"from": {"web": "` + from + `"}, "to": {"web": "` + to + `"}, "digest": {"web": "sha256:` + + strings.Repeat("a", 64) + `"}, "verdict": "proven"}` + "\n" +} + +// ladderManager: slice 4's manager, pinned at A, the catalog at C with a two-step ladder A→B→C and +// B's own definition in steps/. `ups` records the image of the live compose file at every `up`. +func ladderManager(t *testing.T, withStepFile bool) (*Manager, string, *fakeGuards, *[]string, *bytes.Buffer) { + t.Helper() + m, dir, g, c := newSlice4Manager(t) + logBuf := &bytes.Buffer{} + var logMu sync.Mutex + m.logger = log.New(writerFunc(func(p []byte) (int, error) { logMu.Lock(); defer logMu.Unlock(); return logBuf.Write(p) }), "", 0) + catDir := filepath.Dir(m.CatalogTemplatePath("nextcloud", "docker-compose.yml")) + mustWrite(t, filepath.Join(catDir, ".felhom.yml"), "display_name: Nextcloud\nupdate_ladder:\n"+ladderLine(ladderA, ladderB)+ladderLine(ladderB, ladderC)) + if withStepFile { + mustWriteMk(t, StepFile(catDir, map[string]string{"web": ladderB}), ladderBDef) + } + ups := &[]string{} + var mu sync.Mutex + m.updateComposeFn = func(d string, env []string, args ...string) (string, error) { + if args[0] == "up" { + imgs, _ := ParseComposeImages(ComposePathIn(d)) + mu.Lock() + *ups = append(*ups, imgs["web"]) + mu.Unlock() + } + return c.fn(d, env, args...) + } + return m, dir, g, ups, logBuf +} + +type writerFunc func([]byte) (int, error) + +func (f writerFunc) Write(p []byte) (int, error) { return f(p) } + +// TestLadder_TwoPressesTwoSteps — A→B→C in TWO presses: the first renders B's OWN definition, the +// second the catalog's current one. The badge's count follows. +// +// COMPANION RED-PROOF (REPORT.md): make nextLadderStep always return the template (v0.267.0's jump). +// This test then fails at "press 1 pinned nextcloud:34.0.1-apache, want the tested step B". +func TestLadder_TwoPressesTwoSteps(t *testing.T) { + m, dir, _, ups, _ := ladderManager(t, true) + catDir := filepath.Dir(m.CatalogTemplatePath("nextcloud", "docker-compose.yml")) + if n := ladderStepsLeft(catDir, map[string]string{"web": ladderA}); n != 2 { + t.Fatalf("steps left from A = %d, want 2", n) + } + if err := m.StartGuardedUpdate("nextcloud"); err != nil { + t.Fatal(err) + } + st := waitUpdateDone(t, m, "nextcloud") + if st.UpdatePhase != UpdatePhaseDone { + t.Fatalf("press 1 ended %q (%s)", st.UpdatePhase, st.UpdateError) + } + if got := pinOf(t, dir); got != ladderB { + t.Fatalf("press 1 pinned %s, want the tested step B %s — one press must be one step", got, ladderB) + } + if body := fileBody(t, ComposePathIn(dir)); body != ladderBDef { + t.Fatalf("press 1 rendered\n%s\nwant step B's OWN definition (with STEP_B_OWN_DEFINITION)", body) + } + if body := fileBody(t, AppliedComposePath(dir)); body != ladderBDef { + t.Fatal("the stored applied definition is not step B's — the sync would render the wrong file while the app sits at B") + } + if n := ladderStepsLeft(catDir, map[string]string{"web": ladderB}); n != 1 { + t.Fatalf("steps left from B = %d, want 1", n) + } + + if err := m.StartGuardedUpdate("nextcloud"); err != nil { + t.Fatal(err) + } + st = waitUpdateDone(t, m, "nextcloud") + if st.UpdatePhase != UpdatePhaseDone || pinOf(t, dir) != ladderC { + t.Fatalf("press 2 ended %q on %s, want done on C", st.UpdatePhase, pinOf(t, dir)) + } + if body := fileBody(t, ComposePathIn(dir)); body != pinTplNew { + t.Fatalf("press 2 must render the catalog's current definition; got\n%s", body) + } + if strings.Join(*ups, ",") != ladderB+","+ladderC { + t.Fatalf("up ran on %v, want exactly [B, C] — never C first", *ups) + } + if n := ladderStepsLeft(catDir, map[string]string{"web": ladderC}); n != 0 { + t.Fatalf("steps left at the head = %d, want 0", n) + } +} + +// TestLadder_UnknownInstalledJumpsAndSaysSo — an installed version older than the ladder has no record +// to climb: today's behaviour (the catalog's current definition), named in the log. +func TestLadder_UnknownInstalledJumpsAndSaysSo(t *testing.T) { + m, dir, _, _, logBuf := ladderManager(t, true) + old := "services:\n web:\n image: nextcloud:30.0.0-apache\nvolumes:\n db:\n" + mustWrite(t, ComposePathIn(dir), old) + mustWrite(t, AppliedComposePath(dir), old) + mustWrite(t, filepath.Join(dir, "app.yaml"), "deployed: true\nenv: {}\npinned_images:\n web: nextcloud:30.0.0-apache\n") + if err := m.ScanStacks(); err != nil { + t.Fatal(err) + } + if err := m.StartGuardedUpdate("nextcloud"); err != nil { + t.Fatal(err) + } + if st := waitUpdateDone(t, m, "nextcloud"); st.UpdatePhase != UpdatePhaseDone || pinOf(t, dir) != ladderC { + t.Fatalf("ended %q on %s, want done on the catalog's current C", st.UpdatePhase, pinOf(t, dir)) + } + if !strings.Contains(logBuf.String(), "matches no update_ladder entry") || !strings.Contains(logBuf.String(), "nextcloud:30.0.0-apache") { + t.Fatalf("the jump must be logged by name; log:\n%s", logBuf.String()) + } +} + +// TestLadder_FailedStepStopsTheLadder — B fails its health check: the undo puts A back, and C is never +// attempted (no definition naming C is ever brought up). +func TestLadder_FailedStepStopsTheLadder(t *testing.T) { + m, dir, _, ups, _ := ladderManager(t, true) + fc := newFakeCopier(map[string]string{undoVol: "OLD"}) + m.undoCopier = fc + 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) + } + st := waitUpdateDone(t, m, "nextcloud") + if st.UpdatePhase != UpdatePhaseUndone || pinOf(t, dir) != ladderA { + t.Fatalf("ended %q on %s, want undone back on A", st.UpdatePhase, pinOf(t, dir)) + } + for _, u := range *ups { + if u == ladderC { + t.Fatalf("C was brought up after B failed: ups=%v", *ups) + } + } + if cfg := LoadAppConfig(dir); cfg == nil || cfg.LastUpdateUndone == nil || cfg.LastUpdateUndone.To["web"] != ladderB { + t.Fatal("last_update_undone must name step B — the record the automatic caller will read to stop the ladder") + } +} + +// TestLadder_MissingStepFileRefusesBeforeAnythingMoves — the catalog promises step B and does not +// carry its definition: the press refuses; it never jumps past B. +func TestLadder_MissingStepFileRefusesBeforeAnythingMoves(t *testing.T) { + m, dir, g, ups, _ := ladderManager(t, false) + if err := m.StartGuardedUpdate("nextcloud"); err != nil { + t.Fatal(err) + } + st := waitUpdateDone(t, m, "nextcloud") + if st.UpdatePhase != UpdatePhaseFailed || st.UpdateErrorKey != "update.error.pin_failed" { + t.Fatalf("phase=%q key=%q, want failed/pin_failed", st.UpdatePhase, st.UpdateErrorKey) + } + if pinOf(t, dir) != ladderA || len(*ups) != 0 { + t.Fatalf("pin=%s ups=%v — nothing may move", pinOf(t, dir), *ups) + } + for _, c := range g.callList() { + if c == "SafetyDump" || c == "BackupNow" || c == "RestorePoints" { + t.Fatalf("the refusal must come before the precondition, the backup and the safety dump; calls=%v", g.callList()) + } + } +} + +// The step key is the catalog's: one value printed by Python's +// hashlib.sha256(json.dumps(to, sort_keys=True, separators=(",", ":")).encode()).hexdigest()[:16]. +func TestLadder_StepKeyMatchesTheCatalog(t *testing.T) { + if got := StepKey(map[string]string{"romm": "rommapp/romm:5.3.1", "romm-db": "mariadb:11.4", "romm-redis": "redis:7-alpine"}); got != "90dd9d68258286ef" { + t.Fatalf("StepKey = %s, the catalog computes 90dd9d68258286ef", got) + } + if got := StepKey(map[string]string{"web": ladderB}); got != "e5a8dc3d17e505bc" { + t.Fatalf("StepKey = %s, the catalog computes e5a8dc3d17e505bc", got) + } +} + +// A step file whose images disagree with its ladder entry is refused, never rendered. +func TestLadder_StepFileMustNameTheStepsImages(t *testing.T) { + d := t.TempDir() + mustWrite(t, filepath.Join(d, ".felhom.yml"), "update_ladder:\n"+ladderLine(ladderA, ladderB)+ladderLine(ladderB, ladderC)) + mustWriteMk(t, StepFile(d, map[string]string{"web": ladderB}), "services:\n web:\n image: "+ladderC+"\n") + if _, err := nextLadderStep(d, map[string]string{"web": ladderA}); err == nil { + t.Fatal("a step file naming C under B's key was accepted") + } + if err := os.Remove(StepFile(d, map[string]string{"web": ladderB})); err != nil { + t.Fatal(err) + } + if _, err := nextLadderStep(d, map[string]string{"web": ladderA}); err == nil { + t.Fatal("a missing step file was accepted") + } +} + +func mustWriteMk(t *testing.T, p, body string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + mustWrite(t, p, body) +} diff --git a/controller/internal/stacks/manager.go b/controller/internal/stacks/manager.go index 9ccda6b..9215c92 100644 --- a/controller/internal/stacks/manager.go +++ b/controller/internal/stacks/manager.go @@ -167,9 +167,12 @@ type Stack struct { // HoldReason is the customer sentence of a hold in force on this app (a failed update or a failed // restore), "" when none. Filled on every read from the ONE hold store, never cached, so the page // and the API cannot show a hold the gate has already lifted — or miss one it enforces. - HoldReason string `json:"hold_reason,omitempty"` - HealthProbe *HealthProbeResult `json:"health_probe,omitempty"` // controller-side probe result - LastUpdated time.Time `json:"last_updated"` + HoldReason string `json:"hold_reason,omitempty"` + // HoldNoWholeCopy (v0.268.0, R-659): the hold names NO copy — none on this box brings the app back + // whole — so the page offers no restore button beside the sentence (the restore would refuse). + HoldNoWholeCopy bool `json:"hold_no_whole_copy,omitempty"` + HealthProbe *HealthProbeResult `json:"health_probe,omitempty"` // controller-side probe result + LastUpdated time.Time `json:"last_updated"` // RestartingSince (C9-F2) is when this stack was FIRST observed in StateRestarting during the // current restarting run; zero whenever the stack is in any other state. It is what turns a brief // restart (normal: deploy, update, quiesce restart) into a distinguishable crash loop — see @@ -197,6 +200,11 @@ type Stack struct { // Nil means CANNOT-TELL — the cache is missing, unreadable, or the app is not in the catalog — // and the badge then renders NOTHING. Absent is unknown; it is never „Naprakész". CatalogImages map[string]string `json:"catalog_images,omitempty"` + + // LadderStepsLeft (v0.268.0, `09` §3 decision 14) — how many tested steps lie between the app's + // pin and the catalog's head; one press climbs one. 0 = unknown or none. The page shows it while + // the badge says „Frissítés elérhető". + LadderStepsLeft int `json:"ladder_steps_left,omitempty"` } // Manager handles all docker compose stack operations. @@ -602,8 +610,12 @@ func (m *Manager) ScanStacks() error { // one above (v0.235.0). A missing catalog entry is silent at INFO: an orphaned app has no // catalog template by definition, and warning once per app per scan would be noise. var catImages map[string]string + stepsLeft := 0 if deployed && !m.cfg.IsProtectedStack(name) { catPath := m.CatalogTemplatePath(name, "docker-compose.yml") + if appCfg != nil { + stepsLeft = ladderStepsLeft(filepath.Dir(catPath), appCfg.PinnedImages) + } if imgs, cerr := ParseComposeImages(catPath); cerr == nil { catImages = imgs } else if m.isDebug() { @@ -622,18 +634,20 @@ func (m *Manager) ScanStacks() error { existing.AppConfig = appCfg existing.TemplateImages = tplImages existing.CatalogImages = catImages + existing.LadderStepsLeft = stepsLeft } } else { m.stacks[name] = &Stack{ - Name: name, - Meta: meta, - ComposePath: composePath, - State: StateNotDeployed, - Deployed: deployed, - Protected: m.cfg.IsProtectedStack(name), - AppConfig: appCfg, - TemplateImages: tplImages, - CatalogImages: catImages, + Name: name, + Meta: meta, + ComposePath: composePath, + State: StateNotDeployed, + Deployed: deployed, + Protected: m.cfg.IsProtectedStack(name), + AppConfig: appCfg, + TemplateImages: tplImages, + CatalogImages: catImages, + LadderStepsLeft: stepsLeft, } } } diff --git a/controller/internal/stacks/pin.go b/controller/internal/stacks/pin.go index d0517fa..76cbe87 100644 --- a/controller/internal/stacks/pin.go +++ b/controller/internal/stacks/pin.go @@ -328,12 +328,17 @@ func (m *Manager) CatalogTemplatePath(appName, filename string) string { // the syncer has already copied the catalog verbatim into its stack dir, so `pull` + `up -d` do // today's job with no help from here. func (m *Manager) advancePinToCatalog(name, stackDir string) error { + return m.advancePinTo(name, stackDir, m.CatalogTemplatePath(name, "docker-compose.yml")) +} + +// advancePinTo is advancePinToCatalog with the definition named: the catalog's current compose file, +// or — on a ladder (v0.268.0, `09` §3 decision 14) — one step's own definition from `steps/`. +func (m *Manager) advancePinTo(name, stackDir, src string) error { cfg := LoadAppConfig(stackDir) if cfg == nil || len(cfg.PinnedImages) == 0 { return nil // unpinned — today's behaviour, unchanged } - src := m.CatalogTemplatePath(name, "docker-compose.yml") pin, data, err := PinFromCompose(src) if err != nil { // REFUSE rather than silently update to the frozen definition (which would be a no-op diff --git a/controller/internal/stacks/pin_test.go b/controller/internal/stacks/pin_test.go index 0d03a01..6eb588b 100644 --- a/controller/internal/stacks/pin_test.go +++ b/controller/internal/stacks/pin_test.go @@ -18,8 +18,10 @@ import ( // Slice 3 (v0.235.0) — the pin, the stored definition, and adoption. -const pinTplOld = "services:\n web:\n image: nextcloud:31.0.14-apache\n" -const pinTplNew = "services:\n web:\n image: nextcloud:34.0.1-apache\n" +// The volumes block (v0.268.0, R-658): the undo selects the volumes it copies from the DEFINITION, +// so the fixture declares the volume the undo tests hold ("nextcloud_db"). +const pinTplOld = "services:\n web:\n image: nextcloud:31.0.14-apache\nvolumes:\n db:\n" +const pinTplNew = "services:\n web:\n image: nextcloud:34.0.1-apache\nvolumes:\n db:\n" // newPinManager builds a Manager with one deployed stack and a catalog cache. func newPinManager(t *testing.T, liveCompose, catalogCompose, appYAML string) (*Manager, string) { diff --git a/controller/internal/stacks/r651_r658_remove_test.go b/controller/internal/stacks/r651_r658_remove_test.go new file mode 100644 index 0000000..06cc4e2 --- /dev/null +++ b/controller/internal/stacks/r651_r658_remove_test.go @@ -0,0 +1,85 @@ +package stacks + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +// R-651 (v0.268.0) — a removed app left `applied-compose.yml` and `applied-meta/` in its stack dir. +// The CONSEQUENCE asserted: after remove + reinstall, the probe the undo would judge the old version +// by (savePreUpdateMeta) is the REINSTALL's .felhom.yml — not the removed install's record. (A +// reinstall whose pin step fails writes no record of its own, which is when the stale one was read.) +// +// COMPANION RED-PROOF (REPORT.md): delete the R-651 block in RemoveStack. This test then fails at +// "the undo would probe with the REMOVED install's .felhom.yml". +func TestR651_RemoveDeletesTheAppliedRecord(t *testing.T) { + drive := t.TempDir() + m, dir, _ := newR442Manager(t, "app", ssdCompose, driveAppYAML(drive), drive) + mustWrite(t, AppliedComposePath(dir), ssdCompose) + if err := storeAppliedMeta(dir, []byte("# the REMOVED install's probe\n")); err != nil { + t.Fatal(err) + } + if _, err := m.RemoveStack("app", false, nil); err != nil { + t.Fatalf("RemoveStack: %v", err) + } + for _, p := range []string{AppliedComposePath(dir), filepath.Join(dir, appliedMetaDir)} { + if _, err := os.Stat(p); err == nil { + t.Errorf("%s survived the remove (R-651)", p) + } + } + // The catalog mirror stays: it is the template, not the install. + if _, err := os.Stat(filepath.Join(dir, "docker-compose.yml")); err != nil { + t.Fatalf("the catalog mirror's compose file must stay: %v", err) + } + // Reinstall (its pin step wrote no record) → the undo's probe source. + mustWrite(t, filepath.Join(dir, ".felhom.yml"), "# the REINSTALL's probe\n") + md, _ := savePreUpdateMeta(dir) + b, _ := os.ReadFile(filepath.Join(md, ".felhom.yml")) + if !strings.Contains(string(b), "REINSTALL") { + t.Fatalf("the undo would probe with the REMOVED install's .felhom.yml: %q", b) + } +} + +// R-658 — the removal's report counts a declared volume that carries no label (a restored app). +// Measured at the 2026-09-23 night's teardown: `volumes_removed: []` over volumes that did go. +// +// COMPANION RED-PROOF (REPORT.md): make appVolumeSet return projectVolumes alone — the report reads +// `[]` again and this test fails. +func TestR658_RemoveReportsUnlabelledVolumes(t *testing.T) { + drive := t.TempDir() + m, _, _ := newR442Manager(t, "app", ssdCompose, driveAppYAML(drive), drive) + downDone := false + m.execFn = func(name string, args ...string) (string, error) { + a := strings.Join(args, " ") + switch { + case strings.Contains(a, "label=com.docker.compose.project="): + return "", nil // restored: no label + case a == "volume ls -q": + if downDone { + return "other_vol\n", nil + } + return "app_app_cfg\nother_vol\n", nil + } + return "", nil + } + // the stub compose (newR442Manager) is the `down`; flip the listing after the first before-read. + calls := 0 + inner := m.execFn + m.execFn = func(name string, args ...string) (string, error) { + if strings.Join(args, " ") == "volume ls -q" { + calls++ + downDone = calls > 1 + } + return inner(name, args...) + } + resp, err := m.RemoveStack("app", false, nil) + if err != nil { + t.Fatalf("RemoveStack: %v", err) + } + if !reflect.DeepEqual(resp.VolumesRemoved, []string{"app_app_cfg"}) { + t.Fatalf("volumes_removed = %v, want [app_app_cfg] — an unlabelled volume the remove did delete", resp.VolumesRemoved) + } +} diff --git a/controller/internal/stacks/r658_undo_volumes_test.go b/controller/internal/stacks/r658_undo_volumes_test.go new file mode 100644 index 0000000..03f213b --- /dev/null +++ b/controller/internal/stacks/r658_undo_volumes_test.go @@ -0,0 +1,98 @@ +package stacks + +import ( + "os" + "path/filepath" + "reflect" + "testing" +) + +// R-658 (v0.268.0) — after a restore, an app's volumes carry no compose label, and until v0.267.0 the +// undo chose the volumes it copies BY THAT LABEL. Measured on 9202 2026-09-23 night, chaos round 9: +// `the undo copy will hold 0 named volume(s)`, and the failed update was reported "undone" with the old +// binary on the migrated data. + +// TestR658_UndoCopiesUnlabelledVolumes: both of the app's volumes are UNLABELLED (a restore made them); +// the new version migrates both and fails; the undo must copy both and put both back. +// +// COMPANION RED-PROOF (REPORT.md): make appVolumes return copier().ProjectVolumes (the v0.267.0 +// selector). This test then fails at "copied 0 of 2 declared volumes". +func TestR658_UndoCopiesUnlabelledVolumes(t *testing.T) { + m, dir, g, _, fc := newUndoManager(t) + two := "services:\n web:\n image: nextcloud:31.0.14-apache\nvolumes:\n db:\n data:\n" + mustWrite(t, filepath.Join(dir, "docker-compose.yml"), two) + fc.vols["nextcloud_data"] = "OLD-FILES" + fc.unlabeled = map[string]bool{"nextcloud_db": true, "nextcloud_data": true} + inner := m.updateComposeFn + m.updateComposeFn = func(d string, env []string, args ...string) (string, error) { + out, err := inner(d, env, args...) + if args[0] == "up" && fc.vol(undoVol) == "MIGRATED" && fc.vol("nextcloud_data") == "OLD-FILES" { + fc.setVol("nextcloud_data", "MIGRATED-FILES") + } + return out, err + } + if err := m.StartGuardedUpdate("nextcloud"); err != nil { + t.Fatal(err) + } + st := waitUpdateDone(t, m, "nextcloud") + copied := 0 + for _, v := range []string{"nextcloud_db", "nextcloud_data"} { + if fc.callsHave("copy " + v) { + copied++ + } + } + if copied != 2 { + t.Fatalf("copied %d of 2 declared volumes — the undo selected by label, and a restored app has none (R-658)", copied) + } + if got := fc.vol(undoVol); got != "OLD" { + t.Fatalf("database volume after the undo = %q, want the pre-update OLD", got) + } + if got := fc.vol("nextcloud_data"); got != "OLD-FILES" { + t.Fatalf("second volume after the undo = %q, want OLD-FILES", got) + } + if st.UpdatePhase != UpdatePhaseUndone || g.held { + t.Fatalf("phase=%q held=%v, want undone and no hold", st.UpdatePhase, g.held) + } +} + +// TestR658_DeclaredVolumeNames — the resolver names volumes the way compose does. +func TestR658_DeclaredVolumeNames(t *testing.T) { + dir := filepath.Join(t.TempDir(), "vikunja") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + cases := []struct { + body string + own, external []string + }{ + {"services: {}\nvolumes:\n files:\n db:\n", []string{"vikunja_db", "vikunja_files"}, nil}, + {"services: {}\nvolumes:\n db:\n name: custom_db\n", []string{"custom_db"}, nil}, + {"name: other\nservices: {}\nvolumes:\n db:\n", []string{"other_db"}, nil}, + {"services: {}\nvolumes:\n db:\n shared:\n external: true\n", []string{"vikunja_db"}, []string{"vikunja_shared"}}, + {"services: {}\n", nil, nil}, + } + for i, c := range cases { + mustWrite(t, filepath.Join(dir, "docker-compose.yml"), c.body) + own, ext, err := DeclaredVolumeNames(filepath.Join(dir, "docker-compose.yml")) + if err != nil { + t.Fatalf("case %d: %v", i, err) + } + if !reflect.DeepEqual(own, c.own) || !reflect.DeepEqual(ext, c.external) { + t.Fatalf("case %d: own=%v ext=%v, want %v %v", i, own, ext, c.own, c.external) + } + } +} + +// TestR658_MissingDeclaredVolumeIsSkippedNotInvented — a declared volume Docker does not hold is not +// copied (there is nothing to copy) and does not fail the update. +func TestR658_MissingDeclaredVolumeIsSkippedNotInvented(t *testing.T) { + m, dir, _, _, _ := newUndoManager(t) + mustWrite(t, filepath.Join(dir, "docker-compose.yml"), "services:\n web:\n image: nextcloud:31.0.14-apache\nvolumes:\n db:\n never_created:\n") + vols, err := m.planUndoCopies("nextcloud", dir) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(vols, []string{"nextcloud_db"}) { + t.Fatalf("planned %v, want only the existing nextcloud_db", vols) + } +} diff --git a/controller/internal/stacks/undo.go b/controller/internal/stacks/undo.go index b74a67c..17ef456 100644 --- a/controller/internal/stacks/undo.go +++ b/controller/internal/stacks/undo.go @@ -5,11 +5,13 @@ import ( "fmt" "os" "path/filepath" + "sort" "strconv" "strings" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/util" + "gopkg.in/yaml.v3" ) // ── The undo (09 §3 decision 15, v0.263.0) ───────────────────────────────────────────────────────── @@ -134,7 +136,11 @@ type UpdateUndone struct { // volumeCopier is the process boundary of the undo's copy. Production is dockerVolumeCopier; tests // inject a fake and never touch docker. type volumeCopier interface { + // ProjectVolumes lists the volumes carrying the compose project label. Since v0.268.0 (R-658) it + // is a CROSS-CHECK only, logged — never the selector: a restore recreated volumes without it. ProjectVolumes(project string) ([]string, error) + // VolumeExists reports whether Docker holds a volume by exactly this name. + VolumeExists(vol string) (bool, error) VolumeBytes(vol string) (int64, error) Copy(src, dst, app string) error Complete(copyVol string) bool @@ -166,6 +172,21 @@ func (d dockerVolumeCopier) ProjectVolumes(project string) ([]string, error) { return vols, nil } +func (d dockerVolumeCopier) VolumeExists(vol string) (bool, error) { + // `--filter name=` matches a SUBSTRING, so the listing is only narrowed by it; the exact compare + // below is the test. + out, err := d.m.execCommand("docker", "volume", "ls", "-q", "--filter", "name="+vol) + if err != nil { + return false, err + } + for _, l := range strings.Split(out, "\n") { + if strings.TrimSpace(l) == vol { + return true, nil + } + } + return false, nil +} + func (d dockerVolumeCopier) VolumeBytes(vol string) (int64, error) { out, err := d.m.execCommand("docker", "run", "--rm", "-v", vol+":/v:ro", undoHelperImage, "du", "-sb", "/v") if err != nil { @@ -232,8 +253,8 @@ func undoMsg(key string, args ...interface{}) string { return util.Text("hu", ke // planUndoCopies lists the app's named volumes and refuses — before anything moves — when their copy // would breach the disk floor the update already keeps (decision 19's disk limit). -func (m *Manager) planUndoCopies(name string) ([]string, error) { - vols, err := m.copier().ProjectVolumes(name) +func (m *Manager) planUndoCopies(name, dir string) ([]string, error) { + vols, err := m.appVolumes(name, dir) if err != nil { return nil, fmt.Errorf("listing the app's volumes: %w", err) } @@ -253,6 +274,129 @@ func (m *Manager) planUndoCopies(name string) ([]string, error) { return vols, nil } +// ── Which volumes are the app's (R-658, v0.268.0) ───────────────────────────────────────────────── +// +// FOUND 2026-09-23 night by the chaos hour on 9202: the unit restore recreates each named volume with +// a bare `docker volume create `, which carries no compose label, and until v0.267.0 the undo +// selected the volumes it copies BY THAT LABEL. So after any restore the undo copied NOTHING and +// reported the failed update "undone" — the old binary on the new version's migrated data (round 9, +// vikunja: `the undo copy will hold 0 named volume(s)`). Measured with a control: the three restored +// apps had 0 of 3 / 2 / 2 volumes labelled, the three never restored had all of theirs. +// +// So the selector is now the app's OWN DEFINITION: the named volumes its compose file declares, +// resolved to the names compose gives them, each checked to exist. The label is a cross-check that +// is logged and decides nothing. Pinned by TestR658_UndoCopiesUnlabelledVolumes (red-proof: the old +// label selector copies 0 of 2). + +// composeVolumesDoc is the part of a compose file that names the app's volumes. +type composeVolumesDoc struct { + Name string `yaml:"name"` + Volumes map[string]*composeVolDef `yaml:"volumes"` +} + +type composeVolDef struct { + Name string `yaml:"name"` + External interface{} `yaml:"external"` +} + +func (d *composeVolDef) external() bool { + if d == nil { + return false + } + switch v := d.External.(type) { + case bool: + return v + case map[string]interface{}: + return true // the legacy `external: {name: …}` form + } + return false +} + +// DeclaredVolumeNames returns the Docker names of the named volumes a compose file declares, the way +// compose names them: the volume's own `name:` when set, else `_`, where the project is +// the file's top-level `name:` or, as the manager runs compose, the stack directory's name. External +// volumes are not the app's and are left out (returned second, for the log). Sorted. +func DeclaredVolumeNames(composePath string) (own, external []string, err error) { + data, err := os.ReadFile(composePath) + if err != nil { + return nil, nil, fmt.Errorf("reading compose file: %w", err) + } + var doc composeVolumesDoc + if err := yaml.Unmarshal(data, &doc); err != nil { + return nil, nil, fmt.Errorf("parsing compose file %s: %w", composePath, err) + } + project := strings.TrimSpace(doc.Name) + if project == "" { + project = filepath.Base(filepath.Dir(composePath)) + } + for key, def := range doc.Volumes { + full := project + "_" + key + if def != nil && strings.TrimSpace(def.Name) != "" { + full = strings.TrimSpace(def.Name) + } + if def.external() { + external = append(external, full) + continue + } + own = append(own, full) + } + sort.Strings(own) + sort.Strings(external) + return own, external, nil +} + +// appVolumes is the undo's selector: the declared volumes that exist, with the label cross-checked +// and every disagreement logged by name. +func (m *Manager) appVolumes(name, dir string) ([]string, error) { + declared, external, err := DeclaredVolumeNames(ComposePathIn(dir)) + if err != nil { + return nil, err + } + if len(external) > 0 { + m.logger.Printf("[INFO] [stacks] update %s: external volume(s) %v are not the app's — never copied", name, external) + } + var vols []string + for _, v := range declared { + ok, err := m.copier().VolumeExists(v) + if err != nil { + return nil, fmt.Errorf("checking volume %s: %w", v, err) + } + if !ok { + m.logger.Printf("[WARN] [stacks] update %s: the definition declares volume %s but Docker holds none by that name — nothing to copy for it", name, v) + continue + } + vols = append(vols, v) + } + labeled, lerr := m.copier().ProjectVolumes(filepath.Base(dir)) + if lerr != nil { + m.logger.Printf("[WARN] [stacks] update %s: the label cross-check could not list volumes (%v) — the definition decides anyway", name, lerr) + return vols, nil + } + has := map[string]bool{} + for _, v := range labeled { + has[v] = true + } + var unlabeled []string + for _, v := range vols { + if !has[v] { + unlabeled = append(unlabeled, v) + } + delete(has, v) + } + if len(unlabeled) > 0 { + m.logger.Printf("[WARN] [stacks] update %s: volume(s) %v carry no compose label (recreated by a restore before v0.268.0 — R-658) — copied by name", name, unlabeled) + } + if len(has) > 0 { + var extra []string + for v := range has { + extra = append(extra, v) + } + sort.Strings(extra) + m.logger.Printf("[INFO] [stacks] update %s: volume(s) %v carry the app's label but the definition does not declare them — not copied", name, extra) + } + return vols, nil +} + type undoSpaceError struct{ need, free float64 } func (e *undoSpaceError) Error() string { diff --git a/controller/internal/stacks/undo_test.go b/controller/internal/stacks/undo_test.go index 4b4da74..00c00fa 100644 --- a/controller/internal/stacks/undo_test.go +++ b/controller/internal/stacks/undo_test.go @@ -34,6 +34,7 @@ type fakeCopier struct { cutOff bool // every copy is made WITHOUT its finished-marker (a copy container killed mid-way) restoreErr error bytes int64 + unlabeled map[string]bool // volumes Docker holds WITHOUT the compose label (a pre-v0.268.0 restore, R-658) } func newFakeCopier(vols map[string]string) *fakeCopier { @@ -46,11 +47,19 @@ func (f *fakeCopier) ProjectVolumes(string) ([]string, error) { defer f.mu.Unlock() var out []string for v := range f.vols { - out = append(out, v) + if !f.unlabeled[v] { + out = append(out, v) + } } sort.Strings(out) return out, nil } +func (f *fakeCopier) VolumeExists(v string) (bool, error) { + f.mu.Lock() + defer f.mu.Unlock() + _, ok := f.vols[v] + return ok, nil +} func (f *fakeCopier) VolumeBytes(string) (int64, error) { return f.bytes, nil } func (f *fakeCopier) Copy(src, dst, _ string) error { f.mu.Lock() @@ -542,8 +551,13 @@ func TestUndo_EveryPinWriterRecordsTheProbe(t *testing.T) { t.Errorf("%s.%s must call storeAppliedMetaFrom", file, fn) } } - if !funcCalls(t, "pin.go", "advancePinToCatalog", "storeAppliedMetaFrom") { - t.Error("advancePinToCatalog must call storeAppliedMetaFrom") + // v0.268.0: the guarded update pins through advancePinTo (the ladder names the definition); + // advancePinToCatalog is its catalog-head wrapper. The writer is advancePinTo. + if !funcCalls(t, "pin.go", "advancePinTo", "storeAppliedMetaFrom") { + t.Error("advancePinTo must call storeAppliedMetaFrom") + } + if !funcCalls(t, "update.go", "runGuardedUpdate", "advancePinTo") { + t.Error("runGuardedUpdate must pin through advancePinTo") } } diff --git a/controller/internal/stacks/update.go b/controller/internal/stacks/update.go index 8e45992..6bdd660 100644 --- a/controller/internal/stacks/update.go +++ b/controller/internal/stacks/update.go @@ -292,6 +292,9 @@ func fillHoldReason(g UpdateGuards, st *Stack) { if g != nil && st.Deployed { if h, why := g.HoldFor(st.Name); h { st.HoldReason, held = why, true + if nw, ok := g.(holdWholeCopy); ok { + st.HoldNoWholeCopy = nw.HoldNoWholeCopy(st.Name) + } } } // R-480: an update that ended HELD carries the hold's sentence as its UpdateError. Once that hold @@ -304,6 +307,11 @@ func fillHoldReason(g UpdateGuards, st *Stack) { } } +// holdWholeCopy is the OPTIONAL half of UpdateGuards that says a hold names no copy (R-659). +type holdWholeCopy interface { + HoldNoWholeCopy(name string) bool +} + // UpdateRefusal is a refusal taken before anything moved. Reason is a stable key for logs and tests; // Message is the customer sentence. type UpdateRefusal struct { @@ -700,6 +708,19 @@ func (m *Manager) runGuardedUpdate(ctx context.Context, name string) { fail("update.error.no_guards", "no UpdateGuards wired") return } + // v0.268.0 — THE LADDER (`09` §3 decision 14): which definition this ONE press pins. Decided + // first, before anything moves, so a step the catalog promises and does not carry refuses here + // rather than jumping past it. An unpinned app is left to today's behaviour (advancePinTo no-ops). + stepSrc := m.CatalogTemplatePath(name, "docker-compose.yml") + if cfg := LoadAppConfig(dir); cfg != nil && len(cfg.PinnedImages) > 0 { + step, serr := nextLadderStep(filepath.Dir(stepSrc), cfg.PinnedImages) + if serr != nil { + fail("update.error.pin_failed", "update ladder: "+serr.Error()) + return + } + stepSrc = step.Source + m.logger.Printf("[INFO] [stacks] update %s: ladder — %s", name, step.Why) + } // R-475: the precondition is a copy on ANY tier, chosen in the order 2, 1, 3, and the age rule // applies to whichever tier is chosen. The first FRESH copy wins — not merely the first copy — so a // stale second-drive mirror never forces a backup while the app's own unit is minutes old. @@ -743,7 +764,7 @@ func (m *Manager) runGuardedUpdate(ctx context.Context, name string) { // v0.263.0 — the undo's copy is PLANNED here, before anything moves: its size against the disk // floor (decision 19's limit). The copy itself is taken after the pull, where the app stops anyway. - undoVols, perr := m.planUndoCopies(name) + undoVols, perr := m.planUndoCopies(name, dir) if perr != nil { if se, ok := perr.(*undoSpaceError); ok { fail("err.stacks.update_undo_space", "undo copy: "+perr.Error(), se.need, se.free, updateDiskFloorGiB) @@ -792,7 +813,7 @@ func (m *Manager) runGuardedUpdate(ctx context.Context, name string) { fail("update.error.journal_failed", "journal write failed") return } - if err := m.advancePinToCatalog(name, dir); err != nil { + if err := m.advancePinTo(name, dir, stepSrc); err != nil { m.pinBack(name, dir, entry) fail("update.error.pin_failed", "advancing the pin: "+err.Error()) return diff --git a/controller/internal/web/i18n_parity_test.go b/controller/internal/web/i18n_parity_test.go index dcd3a0c..d980be5 100644 --- a/controller/internal/web/i18n_parity_test.go +++ b/controller/internal/web/i18n_parity_test.go @@ -286,6 +286,16 @@ func i18nCases() []i18nCase { base = append(base, i18nCasesA()...) base = append(base, i18nCasesB()...) base = append(base, i18nCasesC()...) + // v0.268.0 (`09` §3 decision 14): the ladder's "steps remaining" line, captured when it was born. + base = append(base, i18nCase{"app_info_ladder_steps", "app_info", func() map[string]interface{} { + d := i18nLayoutData("stacks", "RomM") + st := stacks.Stack{Name: "romm", Deployed: true, State: stacks.StateRunning, LadderStepsLeft: 2} + st.Meta = stacks.Metadata{DisplayName: "RomM", Slug: "romm"} + d["Stack"] = st + d["Meta"] = st.Meta + d["AppInfo"] = st.Meta.AppInfo + return d + }}) return base } diff --git a/controller/internal/web/r659_held_page_test.go b/controller/internal/web/r659_held_page_test.go new file mode 100644 index 0000000..f42c147 --- /dev/null +++ b/controller/internal/web/r659_held_page_test.go @@ -0,0 +1,40 @@ +package web + +import ( + "regexp" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" +) + +// R-659 (v0.268.0) — a hold that names NO whole copy gets no restore button beside its sentence, on +// either page; a hold that names one keeps it. A render test PER BRANCH of the gate (seam-wiring rule). +// +// COMPANION RED-PROOF (REPORT.md): drop `{{if not .HoldNoWholeCopy}}` from stacks.html — the +// "no whole copy" branch fails with the button present. + +var heldButton = regexp.MustCompile(`data-held="true">[^<]*{{stateLabel .Stack.State}} {{if .Stack.Orphaned}}{{T "common.elavult"}}{{end}} {{template "meta_badge" (lifecycleBadge .Meta)}} - {{template "meta_badge" (updateBadge .Stack)}} + {{template "meta_badge" (updateBadge .Stack)}}{{if and (gt .Stack.LadderStepsLeft 0) (not .Stack.HoldReason)}}{{T "app_info.ladder_steps_left"}} {{.Stack.LadderStepsLeft}}{{end}} {{if .EffectiveSubdomain}}{{T "app_info.megnyitas"}}{{end}} {{T "app_info.naplo"}} {{if .Stack.Orphaned}} @@ -31,7 +31,7 @@ {{if .Stack.Updating}}
{{updatePhaseText .Stack}}
{{else if .Stack.HoldReason}} -
{{holdText .Stack}} {{T "app_info.mentesek"}}
+
{{holdText .Stack}}{{if not .Stack.HoldNoWholeCopy}} {{T "app_info.mentesek"}}{{end}}
{{else if .Stack.UpdateError}}
{{updateErrorText .Stack}}
{{else if .UpdateUndoneLine}} diff --git a/controller/internal/web/templates/stacks.html b/controller/internal/web/templates/stacks.html index 615f795..5c14a3e 100644 --- a/controller/internal/web/templates/stacks.html +++ b/controller/internal/web/templates/stacks.html @@ -93,7 +93,7 @@ {{if .Updating}} {{updatePhaseText .}} {{else if .HoldReason}} - +
{{holdText .}}{{if not .HoldNoWholeCopy}} {{T "stacks.mentesek"}}{{end}}
{{if not .Orphaned}}{{end}} {{else if isOperational .State}} {{if .UpdateError}}
{{updateErrorText .}}
{{end}} diff --git a/controller/internal/web/testdata/i18n_parity/app_info_ladder_steps.html b/controller/internal/web/testdata/i18n_parity/app_info_ladder_steps.html new file mode 100644 index 0000000..a6d29a3 --- /dev/null +++ b/controller/internal/web/testdata/i18n_parity/app_info_ladder_steps.html @@ -0,0 +1,561 @@ + + + + + + + + RomM — Felhom.eu + + + + + + + + +
+ + +
+ + +
+ + + + + + + + + + + + + + + +
+ +
+ +

+ +
+ ~ RAM + + + Csak x86 + +
+ +
+
+ + +
+ + + +
+ + + + + + + + + + +
+ + + + diff --git a/controller/scripts/i18n_go_keys.json b/controller/scripts/i18n_go_keys.json index c78790b..e4c2ba1 100644 --- a/controller/scripts/i18n_go_keys.json +++ b/controller/scripts/i18n_go_keys.json @@ -15,6 +15,7 @@ "app_info.update_undone": "BORN AS A KEY, v0.263.0 (R-637, the undo) -- a NEW sentence, never a Go literal. Pinned by internal/stacks/undo_test.go and internal/web/undo_page_test.go.", "err.stacks.update_undo_copy_failed": "BORN AS A KEY, v0.263.0 (R-637, the undo) -- a NEW sentence, never a Go literal. Pinned by internal/stacks/undo_test.go and internal/web/undo_page_test.go.", "err.stacks.update_undo_space": "BORN AS A KEY, v0.263.0 (R-637, the undo) -- a NEW sentence, never a Go literal. Pinned by internal/stacks/undo_test.go and internal/web/undo_page_test.go.", + "hold.update.no_whole_copy": "BORN AS A KEY, v0.268.0 (R-659; operator ruling 2026-09-24, option A, the operator's own copy) -- a NEW sentence, never a Go literal. Pinned by internal/backup/r659_whole_copy_test.go.", "hold.update.undo_failed": "BORN AS A KEY, v0.263.0 (R-637, the undo) -- a NEW sentence, never a Go literal. Pinned by internal/stacks/undo_test.go and internal/web/undo_page_test.go.", "hold.update.undo_state.half": "BORN AS A KEY, v0.263.0 (R-637, the undo) -- a NEW sentence, never a Go literal. Pinned by internal/stacks/undo_test.go and internal/web/undo_page_test.go.", "hold.update.undo_state.not_started": "BORN AS A KEY, v0.263.0 (R-637, the undo) -- a NEW sentence, never a Go literal. Pinned by internal/stacks/undo_test.go and internal/web/undo_page_test.go.", diff --git a/controller/scripts/i18n_missing_gate.py b/controller/scripts/i18n_missing_gate.py index a3bfbb0..abdcc5c 100644 --- a/controller/scripts/i18n_missing_gate.py +++ b/controller/scripts/i18n_missing_gate.py @@ -38,12 +38,15 @@ TEMPLATE_ROOTS = [os.path.join(CTRL, "internal", "web", "templates")] MARKER = re.compile(r'\{\{\s*T\s+"([A-Za-z0-9_.\-]+)"\s*\}\}') EN_MISSING_CEILING = 0 -HU_FORMAL_CEILING = 18 # 6 -> 10 -> 12 -> 16 (slice 1) -> 18 (slice 2 release A): the count follows the +HU_FORMAL_CEILING = 19 # 6 -> 10 -> 12 -> 16 (slice 1) -> 18 (slice 2 release A) -> 19 (v0.268.0): the count follows the # conversion, it is not a judgement. Moving a Go literal into hu.json makes an „ön" form the gate could # not see before VISIBLE to it -- the two that arrived are `flash.login.password_changed` („kérjük") # and `alert.deadapp.group` („nézze meg"), both word-for-word what the Go code already said. Slice 2 may # not reword a Hungarian sentence (parity, 10-localisation.md s1), so the ceiling rises with the # measurement and R-516 still owns the words. +# 19 (v0.268.0, R-659): `hold.update.no_whole_copy` („kérjük … ne indítsa újra és ne törölje") is the +# OPERATOR's copy, given word for word in the brief of 2026-09-24 with its ruling (option A) — not reworded +# here. The register question (ön vs te) stays R-516's. EN_FORBIDDEN = re.compile(r"\b(please|kindly)\b", re.I)