diff --git a/CHANGELOG.md b/CHANGELOG.md index 244e434..93d61c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,23 @@ +## v0.263.2 — the undo keeps the probe of the PINNED version, recorded when it was pinned (2026-09-23, R-637) + +**MinAgent: 0.131.0** (unchanged). No new strings. + +**The second thing the live proof on 9202 caught.** v0.263.1 still judged romm's serving old version +"did not start" — this time `last: health check failing` on port 8999. The undo's "old `.felhom.yml`" +was saved AT UPDATE TIME, from the stack dir — but `.felhom.yml` flows into the stack dir on every +catalog SYNC (`09` §5.4: it is never frozen), so by the time anyone presses Update the stack dir already +holds the NEW version's file. v0.263.0's unit tests wrote the new file at PULL time — the wrong moment +— and passed. + +**Fix:** a new record, `/applied-meta/.felhom.yml` — the `.felhom.yml` of the PINNED version, +written when a version is pinned (deploy, pin adoption, the guarded update's pin advance) and put back +by the undo's pin-back, exactly as `applied-compose.yml` is for the definition. The update keeps THAT +file for the undo. **An app pinned before v0.263.2 has no record yet**: its undo probes with the +current file, and the job says so in the log; the record appears at its next pin (deploy or update). +The test fixture now puts the new `.felhom.yml` in place at SYNC time, before the press; red-proofs: +v0.263.1's read of the stack dir fails `TestUndo_UsesTheOldProbe`; a pin advance that records nothing +fails `TestUndo_PinningRecordsThatVersionsProbe`. + ## v0.263.1 — the undo asks the OLD probe even when the new one has marked the app unhealthy (2026-09-23, R-637) **MinAgent: 0.131.0** (unchanged). No new strings. diff --git a/controller/README.md b/controller/README.md index 4db4a89..1df05fb 100644 --- a/controller/README.md +++ b/controller/README.md @@ -640,7 +640,7 @@ finished-marker last. **Bind-mounted folders (photos, documents, the drive) are touched.** On failure: every copy is validated (marker present) before anything is put back; the volumes are emptied and refilled from the copies; the previous compose, applied definition and pin come from the job's own pre-update copies (never the recovery unit); the old version is checked with -the OLD `.felhom.yml` probe (kept in `pre-update-meta/`). Success → phase `undone`, the copies go, and +the OLD `.felhom.yml` probe — the pinned version's own file, recorded in `applied-meta/` whenever a version is pinned (deploy, adoption, update) because `.felhom.yml` flows in on every catalog sync (v0.263.2), and copied to `pre-update-meta/` for the undo. Success → phase `undone`, the copies go, and `app.yaml` records `last_update_undone: {to, at, why}` — the page shows one line under the badge until the next successful update. Failure → the hold, whose sentence now opens with *„A frissítés nem sikerült, és az automatikus visszaállítás sem."* and what state the data is in (`untouched`, `half`, diff --git a/controller/internal/stacks/deploy.go b/controller/internal/stacks/deploy.go index da21479..05cfdb4 100644 --- a/controller/internal/stacks/deploy.go +++ b/controller/internal/stacks/deploy.go @@ -490,6 +490,9 @@ func (m *Manager) runComposeDeploy(name, stackDir string, env map[string]string, m.logger.Printf("[WARN] [stacks] pin %s: cannot pin from the deployed compose file: %v", name, err) } else if err := m.SetPin(name, stackDir, pin, data); err != nil { m.logger.Printf("[ERROR] [stacks] pin %s: %v", name, err) + } else { + // v0.263.2: the deployed version's .felhom.yml — the probe an undo will judge it by later. + m.storeAppliedMetaFrom(name, stackDir, filepath.Join(stackDir, ".felhom.yml")) } // Post-deploy container state check (async, non-blocking) diff --git a/controller/internal/stacks/pin.go b/controller/internal/stacks/pin.go index bebf08f..d0517fa 100644 --- a/controller/internal/stacks/pin.go +++ b/controller/internal/stacks/pin.go @@ -281,6 +281,7 @@ func (m *Manager) AdoptPins() int { m.logger.Printf("[ERROR] [stacks] pin adoption: %s: %v", s.Name, err) continue } + m.storeAppliedMetaFrom(s.Name, stackDir, filepath.Join(stackDir, ".felhom.yml")) // adopted only when running == template pinned++ } @@ -359,6 +360,8 @@ func (m *Manager) advancePinToCatalog(name, stackDir string) error { } m.mu.Unlock() + // v0.263.2: the new version's own .felhom.yml becomes the pinned version's record. + m.storeAppliedMetaFrom(name, stackDir, m.CatalogTemplatePath(name, ".felhom.yml")) m.logger.Printf("[INFO] [stacks] update %s: pin advanced to the catalog's current definition (%s)", name, summarisePin(pin)) return nil } diff --git a/controller/internal/stacks/undo.go b/controller/internal/stacks/undo.go index 54ada00..f71ca9b 100644 --- a/controller/internal/stacks/undo.go +++ b/controller/internal/stacks/undo.go @@ -71,10 +71,47 @@ const undoCopyLabel = "felhom.undo-copy-of" // undoCopyMarker is written last into a copy volume, beside the data (never inside it). const undoCopyMarker = "felhom-undo-complete" -// preUpdateMetaDir holds the previous .felhom.yml. A DIRECTORY, so LoadMetadata can read it; the -// syncer copies only two files into the stack dir's root and never touches a subdirectory. +// preUpdateMetaDir holds the previous version's .felhom.yml for the undo. A DIRECTORY, so +// LoadMetadata can read it; the syncer copies only two files into the stack dir's root and never +// touches a subdirectory. const preUpdateMetaDir = "pre-update-meta" +// appliedMetaDir (v0.263.2) holds the .felhom.yml that belongs to the PINNED version — the probe the +// running version answers. Written when a version is pinned (deploy, adoption, a guarded update) and +// put back by an undo. WHY IT EXISTS: `.felhom.yml` flows into the stack dir on every catalog sync +// (§5.4 — it is never frozen), so by the time anyone presses Update the stack dir already holds the +// NEW version's file. MEASURED LIVE on 9202 2026-09-23: v0.263.1 saved "the old .felhom.yml" at update +// time, got the new probe (romm :8999), and judged the serving old version "did not start". +const appliedMetaDir = "applied-meta" + +// storeAppliedMeta records the .felhom.yml of the version just pinned. A failure is logged by the +// caller and never fails the act — without it the undo falls back to the current file, loudly. +func storeAppliedMeta(stackDir string, src []byte) error { + if len(src) == 0 { + return fmt.Errorf("refusing to store an empty applied .felhom.yml") + } + d := filepath.Join(stackDir, appliedMetaDir) + if err := os.MkdirAll(d, 0o755); err != nil { + return err + } + tmp := filepath.Join(d, ".felhom.yml.tmp") + if err := os.WriteFile(tmp, src, 0o644); err != nil { + return err + } + return os.Rename(tmp, filepath.Join(d, ".felhom.yml")) +} + +// storeAppliedMetaFrom copies a .felhom.yml file into the applied record, logging (never failing). +func (m *Manager) storeAppliedMetaFrom(name, stackDir, src string) { + b, err := os.ReadFile(src) + if err == nil { + err = storeAppliedMeta(stackDir, b) + } + if err != nil { + m.logger.Printf("[WARN] [stacks] pin %s: could not record the pinned version's .felhom.yml (%v) — an undo would check health with whatever file is current then", name, err) + } +} + // undoHelperImage is the helper the backup legs already use for volume tars (backup.go, restore.go), // so no new image is introduced onto a box. const undoHelperImage = "alpine" @@ -267,19 +304,32 @@ func (m *Manager) RemoveUndoCopies(name string) int { return n } -// savePreUpdateMeta keeps the previous .felhom.yml for the undo's health check. +// savePreUpdateMeta keeps the PINNED version's .felhom.yml for the undo's health check: the applied +// record when there is one; otherwise — an app pinned before v0.263.2 — the current file, which the +// catalog sync may already have replaced with the new version's (said in the returned note). func savePreUpdateMeta(dir string) (string, error) { - src, err := os.ReadFile(filepath.Join(dir, ".felhom.yml")) + src, err := os.ReadFile(filepath.Join(dir, appliedMetaDir, ".felhom.yml")) if err != nil { - return "", err + if src, err = os.ReadFile(filepath.Join(dir, ".felhom.yml")); err != nil { + return "", err + } + err = errNoAppliedMeta } + note := err md := filepath.Join(dir, preUpdateMetaDir) if err := os.MkdirAll(md, 0o755); err != nil { return "", err } - return md, os.WriteFile(filepath.Join(md, ".felhom.yml"), src, 0o644) + if err := os.WriteFile(filepath.Join(md, ".felhom.yml"), src, 0o644); err != nil { + return "", err + } + return md, note } +// errNoAppliedMeta: the app has no applied .felhom.yml record (pinned before v0.263.2), so the undo's +// probe is taken from the current file. The copy WAS made — callers log this and carry on. +var errNoAppliedMeta = fmt.Errorf("no applied .felhom.yml for the pinned version (pinned before v0.263.2) — the undo will probe with the current file") + func (m *Manager) undoHealth(ctx context.Context, name string, timeout time.Duration, meta *Metadata) (bool, string) { if m.updateUndoHealthFn != nil { return m.updateUndoHealthFn(ctx, name, timeout, meta) diff --git a/controller/internal/stacks/undo_test.go b/controller/internal/stacks/undo_test.go index 460a8ff..4b4da74 100644 --- a/controller/internal/stacks/undo_test.go +++ b/controller/internal/stacks/undo_test.go @@ -4,6 +4,9 @@ import ( "context" "errors" "fmt" + "go/ast" + "go/parser" + "go/token" "os" "path/filepath" "sort" @@ -117,14 +120,23 @@ const ( undoVol = "nextcloud_db" ) -// newUndoManager: slice 4's manager plus a data volume holding "OLD", the OLD .felhom.yml in the stack -// dir, and a compose fake that plays the two things the real world does in between: the catalog's -// .felhom.yml flows in with the new probe during the pull (§5.4 — .felhom.yml is never frozen), and -// the NEW version migrates the data on its first `up`. +// newUndoManager: slice 4's manager plus a data volume holding "OLD" and the world as the catalog +// sync leaves it BEFORE anyone presses Update: the catalog's NEW .felhom.yml has already flowed into +// the stack dir (§5.4 — .felhom.yml is never frozen; it arrives on the sync, not at the pull), while +// the applied record (v0.263.2) still holds the pinned OLD version's file. The compose fake plays the +// new version migrating the data on its first `up`. +// +// v0.263.1's version of this helper wrote the new .felhom.yml at PULL time — the wrong moment — and +// so every undo test passed while the live box judged the old version with the new probe (9202, +// 2026-09-23, romm). The order of events is part of the fixture. func newUndoManager(t *testing.T) (*Manager, string, *fakeGuards, *composeRec, *fakeCopier) { t.Helper() m, dir, g, c := newSlice4Manager(t) - mustWrite(t, filepath.Join(dir, ".felhom.yml"), undoMetaOld) + mustWrite(t, filepath.Join(dir, ".felhom.yml"), undoMetaNew) + mustWrite(t, m.CatalogTemplatePath("nextcloud", ".felhom.yml"), undoMetaNew) + if err := storeAppliedMeta(dir, []byte(undoMetaOld)); err != nil { + t.Fatal(err) + } fc := newFakeCopier(map[string]string{undoVol: "OLD"}) m.undoCopier = fc pulled, migrated := false, false @@ -132,7 +144,6 @@ func newUndoManager(t *testing.T) (*Manager, string, *fakeGuards, *composeRec, * switch args[0] { case "pull": pulled = true - mustWrite(t, filepath.Join(dir, ".felhom.yml"), undoMetaNew) case "up": // Only the NEW version migrates: the first `up` after a pull, with the undo copy taken. // (After a failed copy, or in a resumed undo, the `up` starts the OLD version.) @@ -222,11 +233,14 @@ func TestUndo_CutOffCopyIsRefusedBeforeAnythingMoves(t *testing.T) { } // TestUndo_UsesTheOldProbe: the new .felhom.yml names a port the old version never answers (the drill -// case, and a real one whenever a probe moves with a version). The undo must judge the old version -// with the OLD probe. +// case, and a real one whenever a probe moves with a version) — and it is ALREADY in the stack dir +// when Update is pressed. The undo must judge the old version with the PINNED version's probe, taken +// from the applied record. // // COMPANION RED-PROOF 3 (REPORT.md): make tryUndo use LoadMetadata(dir) (the current file) instead of // entry.PrevMeta — the undo then probes port 3999 and the app ends HELD; this test fails. +// COMPANION RED-PROOF 3b: make savePreUpdateMeta read the stack dir's .felhom.yml instead of the +// applied record (v0.263.1's shape) — the same failure, and the one the live box showed. func TestUndo_UsesTheOldProbe(t *testing.T) { m, _, g, _, _ := newUndoManager(t) if err := m.StartGuardedUpdate("nextcloud"); err != nil { @@ -319,11 +333,11 @@ func TestUndo_NoRoomForTheCopyRefusesBeforeAnythingMoves(t *testing.T) { // falls to `default` (dropped), nothing is resumed, and this test fails at the first assertion. func TestUndo_PowerCutDuringTheUndoResumesIt(t *testing.T) { m, dir, g, _, fc := newUndoManager(t) - e := simulateAdvanced(t, m, dir) - md, err := savePreUpdateMeta(dir) + md, err := savePreUpdateMeta(dir) // the job keeps the pinned version's file BEFORE the pin moves if err != nil { t.Fatal(err) } + e := simulateAdvanced(t, m, dir) e.PrevMeta, e.Copied, e.Phase = md, true, UpdatePhaseUndoing e.NewPin = map[string]string{"web": "nextcloud:34.0.1-apache"} e.UndoCopies = []undoCopy{{Volume: undoVol, Copy: undoVol + ".pre-update-x"}} @@ -478,3 +492,81 @@ func TestUndo_OldProbeRunsOnAnAppTheCurrentProbeMarkedUnhealthy(t *testing.T) { t.Errorf("without an override an Unhealthy app must stay unhealthy and unprobed; ok=%v probed=%v", ok, probed) } } + +// TestUndo_PinningRecordsThatVersionsProbe: every act that pins a version records its .felhom.yml, +// and the undo's pin-back puts the previous record back. +func TestUndo_PinningRecordsThatVersionsProbe(t *testing.T) { + m, dir, _, _, _ := newUndoManager(t) + appliedPort := func() int { + meta := LoadMetadata(filepath.Join(dir, appliedMetaDir)) + if meta.HealthCheck == nil || len(meta.HealthCheck.Checks) == 0 { + return 0 + } + return meta.HealthCheck.Checks[0].Port + } + md, err := savePreUpdateMeta(dir) + if err != nil || appliedPort() != 3000 { + t.Fatalf("setup: md=%q err=%v applied=%d", md, err, appliedPort()) + } + if err := m.advancePinToCatalog("nextcloud", dir); err != nil { + t.Fatal(err) + } + if got := appliedPort(); got != 3999 { + t.Errorf("advancing the pin must record the NEW version's .felhom.yml, applied port = %d", got) + } + m.restoreDefinition("nextcloud", dir, updateJournalEntry{PrevMeta: md, PrevPin: map[string]string{"web": "nextcloud:31.0.14-apache"}}) + if got := appliedPort(); got != 3000 { + t.Errorf("the undo's pin-back must put the OLD record back, applied port = %d", got) + } +} + +// TestUndo_NoAppliedRecordFallsBackLoudly: an app pinned before v0.263.2 has no applied record; the +// copy is still made from the current file, and the caller is TOLD (the note) rather than left to +// believe it holds the old probe. +func TestUndo_NoAppliedRecordFallsBackLoudly(t *testing.T) { + _, dir, _, _, _ := newUndoManager(t) + if err := os.RemoveAll(filepath.Join(dir, appliedMetaDir)); err != nil { + t.Fatal(err) + } + md, err := savePreUpdateMeta(dir) + if md == "" || !errors.Is(err, errNoAppliedMeta) { + t.Errorf("the fallback must still copy and must say so; md=%q err=%v", md, err) + } +} + +// TestUndo_EveryPinWriterRecordsTheProbe walks the AST: deploy, adoption and the pin advance each CALL +// storeAppliedMetaFrom (a comment naming it does not count). +func TestUndo_EveryPinWriterRecordsTheProbe(t *testing.T) { + for file, fn := range map[string]string{"deploy.go": "runComposeDeploy", "pin.go": "AdoptPins"} { + if !funcCalls(t, file, fn, "storeAppliedMetaFrom") { + t.Errorf("%s.%s must call storeAppliedMetaFrom", file, fn) + } + } + if !funcCalls(t, "pin.go", "advancePinToCatalog", "storeAppliedMetaFrom") { + t.Error("advancePinToCatalog must call storeAppliedMetaFrom") + } +} + +func funcCalls(t *testing.T, file, fn, callee string) bool { + t.Helper() + f, err := parser.ParseFile(token.NewFileSet(), file, nil, 0) + if err != nil { + t.Fatal(err) + } + found := false + for _, d := range f.Decls { + fd, ok := d.(*ast.FuncDecl) + if !ok || fd.Name.Name != fn { + continue + } + ast.Inspect(fd, func(n ast.Node) bool { + if c, ok := n.(*ast.CallExpr); ok { + if se, ok := c.Fun.(*ast.SelectorExpr); ok && se.Sel.Name == callee { + found = true + } + } + return true + }) + } + return found +} diff --git a/controller/internal/stacks/update.go b/controller/internal/stacks/update.go index a113c59..9e0591c 100644 --- a/controller/internal/stacks/update.go +++ b/controller/internal/stacks/update.go @@ -688,9 +688,14 @@ func (m *Manager) runGuardedUpdate(ctx context.Context, name string) { } entry.PrevCompose = filepath.Join(dir, preUpdateComposeFile) // R-639: the OLD .felhom.yml too — its probe is the one the old version answers. - if md, merr := savePreUpdateMeta(dir); merr != nil { + md, merr := savePreUpdateMeta(dir) + switch { + case md == "": m.logger.Printf("[WARN] [stacks] update %s: could not keep the previous .felhom.yml (%v) — an undo would check health with the current one", name, merr) - } else { + case merr != nil: + m.logger.Printf("[WARN] [stacks] update %s: %v", name, merr) + entry.PrevMeta = md + default: entry.PrevMeta = md } if applied, aerr := LoadAppliedDefinition(dir); aerr == nil { @@ -861,8 +866,12 @@ func (m *Manager) pinBack(name, dir string, entry updateJournalEntry) { } // restoreDefinition is pinBack WITHOUT removing the copies — the undo's form, so a power cut after it -// can run it again (RecoverUpdates → undoing) and find the copies still there. +// can run it again (RecoverUpdates → undoing) and find the copies still there. It also puts the pinned +// version's .felhom.yml record back (v0.263.2), so the NEXT update's undo probes the right version. func (m *Manager) restoreDefinition(name, dir string, entry updateJournalEntry) { + if entry.PrevMeta != "" { + m.storeAppliedMetaFrom(name, dir, filepath.Join(entry.PrevMeta, ".felhom.yml")) + } prevLive, lerr := os.ReadFile(entry.PrevCompose) if lerr != nil { m.logger.Printf("[ERROR] [stacks] update %s: cannot read the pre-update compose copy (%v) — the definition could NOT be put back", name, lerr)