package stacks import ( "context" "errors" "fmt" "go/ast" "go/parser" "go/token" "os" "path/filepath" "sort" "strings" "sync" "testing" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/util" ) // v0.263.0 — the undo (09 §3 decision 15, undo.go). Every test runs the REAL job (runGuardedUpdate / // RecoverUpdates / ResumeInterruptedUpdates) with the process boundaries faked, and reads the EFFECT // back: the data in the fake volume, the pin in app.yaml, the phase, the hold, the journal. // fakeCopier is the volume boundary. `vols` is each app volume's CONTENT, so "the data came back" is // a string compare, and `complete` is each copy's finished-marker. type fakeCopier struct { mu sync.Mutex vols map[string]string copies map[string]string complete map[string]bool calls []string copyErr error 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 { return &fakeCopier{vols: vols, copies: map[string]string{}, complete: map[string]bool{}, bytes: 1 << 20} } func (f *fakeCopier) note(c string) { f.calls = append(f.calls, c) } func (f *fakeCopier) ProjectVolumes(string) ([]string, error) { f.mu.Lock() defer f.mu.Unlock() var out []string for v := range f.vols { 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() defer f.mu.Unlock() f.note("copy " + src) if f.copyErr != nil { return f.copyErr } f.copies[dst] = f.vols[src] f.complete[dst] = !f.cutOff return nil } func (f *fakeCopier) Complete(c string) bool { f.mu.Lock() defer f.mu.Unlock() return f.complete[c] } // Restore refuses a copy with no marker, exactly as the real helper does (`test -f` in the same shell). func (f *fakeCopier) Restore(c, v string) error { f.mu.Lock() defer f.mu.Unlock() f.note("restore " + v) if f.restoreErr != nil { return f.restoreErr } if !f.complete[c] { return fmt.Errorf("no finished-marker in %s", c) } f.vols[v] = f.copies[c] return nil } func (f *fakeCopier) Remove(v string) error { f.mu.Lock() defer f.mu.Unlock() f.note("remove " + v) delete(f.copies, v) delete(f.complete, v) return nil } func (f *fakeCopier) CopiesOf(string) []string { f.mu.Lock() defer f.mu.Unlock() var out []string for c := range f.copies { out = append(out, c) } return out } func (f *fakeCopier) vol(v string) string { f.mu.Lock(); defer f.mu.Unlock(); return f.vols[v] } func (f *fakeCopier) setVol(v, s string) { f.mu.Lock(); f.vols[v] = s; f.mu.Unlock() } func (f *fakeCopier) nCopies() int { f.mu.Lock(); defer f.mu.Unlock(); return len(f.copies) } func (f *fakeCopier) callsHave(p string) bool { f.mu.Lock() defer f.mu.Unlock() for _, c := range f.calls { if strings.HasPrefix(c, p) { return true } } return false } const ( undoMetaOld = "healthcheck:\n checks:\n - type: http\n port: 3000\n" undoMetaNew = "healthcheck:\n checks:\n - type: http\n port: 3999\n" // the drill's wrong probe undoVol = "nextcloud_db" ) // 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"), 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 m.updateComposeFn = func(d string, env []string, args ...string) (string, error) { switch args[0] { case "pull": pulled = true 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.) if pulled && !migrated && fc.nCopies() > 0 { migrated = true fc.setVol(undoVol, "MIGRATED") } } return c.fn(d, env, args...) } m.updateHealthFn = func(context.Context, string, time.Duration) (bool, string) { return false, "new version unhealthy" } m.updateUndoHealthFn = func(_ context.Context, _ string, _ time.Duration, meta *Metadata) (bool, string) { if meta != nil && meta.HealthCheck != nil && len(meta.HealthCheck.Checks) > 0 && meta.HealthCheck.Checks[0].Port == 3000 { return true, "old probe answered" } return false, "probed the wrong port" } return m, dir, g, c, fc } // TestUndo_FailedUpdateIsPutBackWithItsData is decision 15 in one test: the new version migrates the // data and fails its check; the box puts the old version back WITH THE PRE-UPDATE DATA, and nothing is // held. // // COMPANION RED-PROOF 1 (REPORT.md): at v0.262.1's shape — failAndHold without the tryUndo call — the // app ends HELD with the data still "MIGRATED", and this test fails on the first assertion. func TestUndo_FailedUpdateIsPutBackWithItsData(t *testing.T) { m, dir, g, _, fc := newUndoManager(t) if err := m.StartGuardedUpdate("nextcloud"); err != nil { t.Fatal(err) } st := waitUpdateDone(t, m, "nextcloud") if held, _ := g.HoldFor("nextcloud"); held || st.UpdatePhase != UpdatePhaseUndone { t.Fatalf("a failed update must be UNDONE, not held; held=%v phase=%q err=%q", held, st.UpdatePhase, st.UpdateError) } if got := fc.vol(undoVol); got != "OLD" { t.Errorf("the data must be the PRE-UPDATE data again, got %q", got) } if got := pinOf(t, dir); got != "nextcloud:31.0.14-apache" { t.Errorf("the pin must be the old version again, got %q", got) } if got := fileBody(t, filepath.Join(dir, "docker-compose.yml")); got != pinTplOld { t.Errorf("the live definition must be the old one again:\n%s", got) } if st.UpdateError != "" || st.UpdatePhaseLabel != "Visszaállítva az előző változatra" { t.Errorf("an undone update carries no error and the undone label; err=%q label=%q", st.UpdateError, st.UpdatePhaseLabel) } u := readPin(t, dir).LastUpdateUndone if u == nil || u.To["web"] != "nextcloud:34.0.1-apache" || u.At == "" || !strings.Contains(u.Why, "unhealthy") { t.Errorf("app.yaml must remember the undone step (to, at, why), got %+v", u) } if fc.nCopies() != 0 || journalExists(m) { t.Errorf("after a successful undo the copies and the journal are gone; copies=%d journal=%v", fc.nCopies(), journalExists(m)) } for _, f := range []string{preUpdateComposeFile, preUpdateAppliedFile, preUpdateMetaDir} { if _, err := os.Stat(filepath.Join(dir, f)); err == nil { t.Errorf("the pre-update copy %s must be removed once the undo is over", f) } } } // TestUndo_CutOffCopyIsRefusedBeforeAnythingMoves: a copy without its finished-marker is detected // BEFORE anything is poured back; the outcome is today's HOLD, saying the data is as the new version // left it — never a "success". // // COMPANION RED-PROOF 2 (REPORT.md): delete the Complete() validation loop in tryUndo. Restore is then // called on the cut copy and the undo state reads "half" — this test fails on both assertions. func TestUndo_CutOffCopyIsRefusedBeforeAnythingMoves(t *testing.T) { m, _, g, _, fc := newUndoManager(t) fc.cutOff = true if err := m.StartGuardedUpdate("nextcloud"); err != nil { t.Fatal(err) } st := waitUpdateDone(t, m, "nextcloud") if held, _ := g.HoldFor("nextcloud"); !held || st.UpdatePhase != UpdatePhaseFailed || g.undoState != UndoStateUntouched { t.Fatalf("a cut-off copy must end HELD with state %q; held=%v phase=%q state=%q", UndoStateUntouched, held, st.UpdatePhase, g.undoState) } if fc.callsHave("restore ") { t.Error("NOTHING may be poured back from a copy that is not whole") } if got := fc.vol(undoVol); got != "MIGRATED" { t.Errorf("the data must be left as the new version left it, got %q", got) } if fc.nCopies() == 0 { t.Error("a failed undo keeps its copies for the operator") } } // 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) — 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 { t.Fatal(err) } st := waitUpdateDone(t, m, "nextcloud") if held, _ := g.HoldFor("nextcloud"); held || st.UpdatePhase != UpdatePhaseUndone { t.Fatalf("with the old probe the old version is healthy and the undo completes; held=%v phase=%q state=%q", held, st.UpdatePhase, g.undoState) } } // TestUndo_OldVersionThatDoesNotStartIsHeldSayingSo: data and definition put back, the old version // still unhealthy → HOLD with the not_started state, and the copies kept. func TestUndo_OldVersionThatDoesNotStartIsHeldSayingSo(t *testing.T) { m, _, g, _, fc := newUndoManager(t) m.updateUndoHealthFn = func(context.Context, string, time.Duration, *Metadata) (bool, string) { return false, "old version broken too" } if err := m.StartGuardedUpdate("nextcloud"); err != nil { t.Fatal(err) } st := waitUpdateDone(t, m, "nextcloud") if held, _ := g.HoldFor("nextcloud"); !held || g.undoState != UndoStateNotStarted || st.UpdatePhase != UpdatePhaseFailed { t.Fatalf("held=%v state=%q phase=%q", held, g.undoState, st.UpdatePhase) } if got := fc.vol(undoVol); got != "OLD" { t.Errorf("the data was put back before the check, got %q", got) } } // TestUndo_RestoreFailureIsHalf: putting the copy back fails part-way → HOLD, state "half". func TestUndo_RestoreFailureIsHalf(t *testing.T) { m, _, g, _, fc := newUndoManager(t) fc.restoreErr = errors.New("disk full") if err := m.StartGuardedUpdate("nextcloud"); err != nil { t.Fatal(err) } waitUpdateDone(t, m, "nextcloud") if held, _ := g.HoldFor("nextcloud"); !held || g.undoState != UndoStateHalf { t.Fatalf("held=%v state=%q", held, g.undoState) } } // TestUndo_CopyFailureMovesNothing: the copy is taken after the pull; if it fails the update stops // there — the partial copy goes, the pin goes back, the old version starts, and nothing is held. func TestUndo_CopyFailureMovesNothing(t *testing.T) { m, dir, g, c, fc := newUndoManager(t) fc.copyErr = errors.New("helper exited 137") if err := m.StartGuardedUpdate("nextcloud"); err != nil { t.Fatal(err) } st := waitUpdateDone(t, m, "nextcloud") if held, _ := g.HoldFor("nextcloud"); held { t.Fatal("a failed copy moved nothing and must not hold") } if st.UpdateError != util.Text("hu", "err.stacks.update_undo_copy_failed") { t.Errorf("err = %q", st.UpdateError) } if got := pinOf(t, dir); got != "nextcloud:31.0.14-apache" { t.Errorf("the pin must go back, got %q", got) } if fc.vol(undoVol) != "OLD" || fc.nCopies() != 0 { t.Errorf("data untouched and no copy left; data=%q copies=%d", fc.vol(undoVol), fc.nCopies()) } if got := strings.Join(c.list(), " | "); got != "pull | stop | up -d --remove-orphans" { t.Errorf("the previous version must be started again after the failed copy; compose calls = %q", got) } } // TestUndo_NoRoomForTheCopyRefusesBeforeAnythingMoves: decision 19's disk limit. func TestUndo_NoRoomForTheCopyRefusesBeforeAnythingMoves(t *testing.T) { m, dir, _, c, fc := newUndoManager(t) fc.bytes = 49 << 30 // 49 GiB of volumes; 50 GiB free; the 2 GiB floor must hold if err := m.StartGuardedUpdate("nextcloud"); err != nil { t.Fatal(err) } st := waitUpdateDone(t, m, "nextcloud") if !strings.Contains(st.UpdateError, "nincs elég szabad hely a frissítés előtti adatmásolathoz") { t.Errorf("err = %q", st.UpdateError) } if pinOf(t, dir) != "nextcloud:31.0.14-apache" || len(c.list()) != 0 || fc.callsHave("copy ") { t.Errorf("nothing may move: pin=%q compose=%v", pinOf(t, dir), c.list()) } } // TestUndo_PowerCutDuringTheUndoResumesIt: the journal says `undoing`; after a restart the undo runs // again from the copies and ends healthy — never "done", never dropped. // // COMPANION RED-PROOF 4 (REPORT.md): delete the UpdatePhaseUndoing arm from RecoverUpdates — the entry // 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) 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"}} fc.copies[undoVol+".pre-update-x"], fc.complete[undoVol+".pre-update-x"] = "OLD", true fc.setVol(undoVol, "MIGRATED") writeTestJournal(t, m, "nextcloud", e) guards := m.updateGuards m.updateGuards = nil resumed := m.RecoverUpdates() if len(resumed) != 1 || !m.IsUpdating("nextcloud") { t.Fatalf("an interrupted undo must be resumed and the app marked Updating; resumed=%v", resumed) } if st, _ := m.GetStack("nextcloud"); st.UpdatePhase != UpdatePhaseUndoing { t.Errorf("phase after recovery = %q, want %q", st.UpdatePhase, UpdatePhaseUndoing) } m.updateGuards = guards if n := m.ResumeInterruptedUpdates(context.Background()); n != 1 { t.Fatalf("resumed %d", n) } st := waitUpdateDone(t, m, "nextcloud") if held, _ := g.HoldFor("nextcloud"); held || st.UpdatePhase != UpdatePhaseUndone { t.Fatalf("the resumed undo must complete; held=%v phase=%q", held, st.UpdatePhase) } if fc.vol(undoVol) != "OLD" || pinOf(t, dir) != "nextcloud:31.0.14-apache" { t.Errorf("data=%q pin=%q", fc.vol(undoVol), pinOf(t, dir)) } } // TestUndo_PowerCutDuringTheCopyPutsTheOldVersionBack: interrupted in `copying` — nothing new ran; the // partial copies go, the pin goes back and the old version is started. func TestUndo_PowerCutDuringTheCopyPutsTheOldVersionBack(t *testing.T) { m, dir, _, c, fc := newUndoManager(t) e := simulateAdvanced(t, m, dir) e.Phase = UpdatePhaseCopying e.UndoCopies = []undoCopy{{Volume: undoVol, Copy: undoVol + ".pre-update-x"}} fc.copies[undoVol+".pre-update-x"] = "OL" // cut writeTestJournal(t, m, "nextcloud", e) if resumed := m.RecoverUpdates(); len(resumed) != 0 { t.Fatalf("resumed = %v", resumed) } if pinOf(t, dir) != "nextcloud:31.0.14-apache" || fc.nCopies() != 0 || journalExists(m) { t.Errorf("pin=%q copies=%d journal=%v", pinOf(t, dir), fc.nCopies(), journalExists(m)) } if got := strings.Join(c.list(), " | "); got != "up -d --remove-orphans" { t.Errorf("the old version must be started again; compose calls = %q", got) } } // TestUndo_ASuccessfulUpdateEndsTheUndoneNote: the next update that works clears last_update_undone. // // COMPANION RED-PROOF 5 (REPORT.md): drop the recordUpdateUndone(nil) call from verifyAndConclude — // the note survives a successful update and this test fails. func TestUndo_ASuccessfulUpdateEndsTheUndoneNote(t *testing.T) { m, dir, _, _, _ := newUndoManager(t) m.recordUpdateUndone("nextcloud", dir, &UpdateUndone{To: map[string]string{"web": "x"}, At: "2026-09-23T10:00:00Z"}) if readPin(t, dir).LastUpdateUndone == nil { t.Fatal("setup: the note was not written") } m.updateHealthFn = func(context.Context, string, time.Duration) (bool, string) { return true, "fine" } if err := m.StartGuardedUpdate("nextcloud"); err != nil { t.Fatal(err) } if st := waitUpdateDone(t, m, "nextcloud"); st.UpdatePhase != UpdatePhaseDone { t.Fatalf("phase = %q", st.UpdatePhase) } if u := readPin(t, dir).LastUpdateUndone; u != nil { t.Errorf("a successful update must end the undone note, got %+v", u) } } // TestUndo_PhaseLabelsMatchTheBundle pins the Hungarian labels to the born-as-key bundle text. func TestUndo_PhaseLabelsMatchTheBundle(t *testing.T) { for phase, key := range map[string]string{UpdatePhaseCopying: "update.phase.copying", UpdatePhaseUndoing: "update.phase.undoing", UpdatePhaseUndone: "update.phase.undone"} { if got, want := UpdatePhaseLabel(phase), util.Text("hu", key); got != want || got == "" || got == key { t.Errorf("%s: label %q, bundle %q", phase, got, want) } } if util.Text("en", "update.phase.undone") != "Put back to the previous version" { t.Errorf("English label = %q", util.Text("en", "update.phase.undone")) } } // TestUndo_RemovalDeletesKeptCopies: a copy kept by a failed undo goes with the app. func TestUndo_RemovalDeletesKeptCopies(t *testing.T) { m, _, _, _, fc := newUndoManager(t) fc.copies["nextcloud_db.pre-update-x"] = "OLD" if n := m.RemoveUndoCopies("nextcloud"); n != 1 || fc.nCopies() != 0 { t.Errorf("removed %d, left %d", n, fc.nCopies()) } } // TestUndo_OldProbeRunsOnAnAppTheCurrentProbeMarkedUnhealthy drives the REAL wait loop // (waitUpdateHealthyMeta → RefreshStatus → `docker ps` → the health-probe override → the state gate → // findProbeContainerMeta) with only the network probe faked. The app's CURRENT .felhom.yml probe has // failed, so the refresh reads it Unhealthy; the undo's OLD probe answers. // // FOUND LIVE, NOT BY A TEST: v0.263.0's first build gated on StateRunning, so the old probe was never // asked and docmost's undo on 9202 was reported "did not start" after the full 90 s while the old // version served. TestUndo_UsesTheOldProbe could not see it — it injects updateUndoHealthFn and so // never reaches this loop (the seam-boundary class: a test proves only what is behind its seam). // // COMPANION RED-PROOF (REPORT.md): drop `|| undoProbe` from the state case — the wait times out with // `last: state unhealthy` and this test fails. func TestUndo_OldProbeRunsOnAnAppTheCurrentProbeMarkedUnhealthy(t *testing.T) { m, dir, _, _, _ := newUndoManager(t) mustWrite(t, filepath.Join(dir, ".felhom.yml"), undoMetaNew) // the catalog's probe (3999) is current m.mu.Lock() m.stacks["nextcloud"].Meta = LoadMetadata(dir) m.stacks["nextcloud"].HealthProbe = &HealthProbeResult{Healthy: false} // the periodic probe failed on 3999 m.mu.Unlock() m.execFn = func(name string, args ...string) (string, error) { if name == "docker" && len(args) > 0 && args[0] == "ps" { return "nextcloud\tnextcloud:31.0.14-apache\trunning\tUp 30 seconds\tnextcloud\n", nil } return "", nil } m.inspectRestartPolicyFn = func(string) (string, error) { return "unless-stopped", nil } m.updateNowFn = time.Now // the shared setup freezes the clock; this loop's deadline needs it moving var probed []int m.probeRunFn = func(pt probeTarget) *HealthProbeResult { probed = append(probed, pt.checks[0].Port) return &HealthProbeResult{Healthy: pt.checks[0].Port == 3000} } if err := m.RefreshStatus(); err != nil { t.Fatal(err) } if st, _ := m.GetStack("nextcloud"); st.State != StateUnhealthy { t.Fatalf("setup: the refresh must read the app Unhealthy (the current probe failed), got %q", st.State) } old, err := savePreUpdateMeta(dir) // what the job kept — but it holds the NEW file here, so write the OLD one if err != nil { t.Fatal(err) } mustWrite(t, filepath.Join(old, ".felhom.yml"), undoMetaOld) oldMeta := LoadMetadata(old) ok, detail := m.waitUpdateHealthyMeta(context.Background(), "nextcloud", time.Second, &oldMeta) if !ok { t.Fatalf("the old version answers its own probe and must be judged healthy; got %q, probed ports %v", detail, probed) } if len(probed) == 0 || probed[0] != 3000 { t.Errorf("the undo must probe the OLD port (3000), probed %v", probed) } // And without an override the same Unhealthy app is NOT probed or settled — the normal update path // is unchanged. probed = nil m.mu.Lock() m.stacks["nextcloud"].HealthProbe = &HealthProbeResult{Healthy: false} // the first call stored its healthy result m.mu.Unlock() if ok, _ := m.waitUpdateHealthyMeta(context.Background(), "nextcloud", time.Second, nil); ok || len(probed) != 0 { 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) } } // 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") } } 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 }