v0.263.2: the undo keeps the probe of the pinned version (R-637)
gates / gates (push) Successful in 25s
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
This commit is contained in:
@@ -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, `<stack>/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.
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user