controller v0.268.0: the undo finds volumes by definition; a held app names only a whole copy; one press = one tested step (R-658, R-659, R-660, R-651; 09 §6.4 part 5)
gates / gates (push) Successful in 27s

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:
2026-09-24 08:15:27 +02:00
parent 80e6ad8c47
commit 206b0357d1
36 changed files with 2201 additions and 40 deletions
+44 -2
View File
@@ -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{}
+163
View File
@@ -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/<app>/steps/<StepKey(to)>.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
}
+214
View File
@@ -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)
}
+26 -12
View File
@@ -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,
}
}
}
+6 -1
View File
@@ -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
+4 -2
View File
@@ -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) {
@@ -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)
}
}
@@ -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)
}
}
+146 -2
View File
@@ -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 <name>`, 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 `<project>_<key>`, 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 {
+17 -3
View File
@@ -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")
}
}
+23 -2
View File
@@ -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