2cd66663f3
gates / gates (push) Successful in 25s
Found live on 9202 (romm): .felhom.yml flows into the stack dir on every catalog sync, so "the old .felhom.yml" saved at update time was already the new one, and the serving old version was judged with the new probe. New record applied-meta/.felhom.yml, written whenever a version is pinned (deploy, adoption, pin advance) and put back by the undo, like applied-compose.yml. The fixture now places the new file at sync time. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
573 lines
24 KiB
Go
573 lines
24 KiB
Go
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
|
|
}
|
|
|
|
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 {
|
|
out = append(out, v)
|
|
}
|
|
sort.Strings(out)
|
|
return out, 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)
|
|
}
|
|
}
|
|
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
|
|
}
|