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//steps/.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"` // TestedAt is when the step was proven (RFC3339) — the badge compares it with the install (v0.269.0). TestedAt string `yaml:"tested_at" json:"tested_at"` // Marks are decision 13's two exceptions the test sets on a step (v0.271.0 reads them): the // automatic leg never takes a step that needs a person, and takes a files-may-change step only when // a fresh WHOLE copy exists. A person's press ignores both — the marks bind the leg only. Marks LadderMarks `yaml:"marks" json:"marks"` } // LadderMarks is the `marks` object of a ladder entry. NeedsPerson is JSON null (nil) or the tester's // reason; an entry with no `marks` key reads as no marks. type LadderMarks struct { FilesMayChange bool `yaml:"files_may_change" json:"files_may_change"` NeedsPerson *string `yaml:"needs_person" json:"needs_person"` MemoryTight bool `yaml:"memory_tight" json:"memory_tight"` } // LadderPrint is a short fingerprint of an app's whole ladder as the box parsed it — what R-680's // failed-step record is tied to: when the catalog changes the ladder (a new step, a re-test, a mark), // the print changes and the automatic leg may try again. "" = no ladder. func LadderPrint(ladder []LadderEntry) string { if len(ladder) == 0 { return "" } b, _ := json.Marshal(ladder) sum := sha256.Sum256(b) return hex.EncodeToString(sum[:])[:16] } 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") } // StepMetaFile is the step's own `.felhom.yml` (R-664, v0.269.0): its probe, memory and applied record. func StepMetaFile(templateDir string, to map[string]string) string { return filepath.Join(templateDir, "steps", StepKey(to)+".felhom.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 // Meta is the `.felhom.yml` that belongs to Source (R-664): steps/.felhom.yml when the catalog // carries it, else the template's own — never the stack dir's, which a restore may have rewritten // with an older one (R-665). Meta 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") currentMeta := filepath.Join(templateDir, ".felhom.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, Meta: currentMeta, 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 { // R-674 (v0.270.0): a pin equal to the newest entry's `to` is AT THE HEAD, not "older than the // ladder" — the old sentence sent an operator looking for a missing record. Pinned by // TestR674_HeadIsNotCalledOlder. if sameRefs(ladder[len(ladder)-1].To, pinned) { return LadderStep{Index: -1, Source: current, Meta: currentMeta, Why: fmt.Sprintf("the installed version %s is AT THE HEAD of the update_ladder (%d entries) — the catalog's current definition", summarisePin(pinned), len(ladder))}, nil } return LadderStep{Index: -1, Source: current, Meta: currentMeta, 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, Meta: currentMeta, 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)) } meta := StepMetaFile(templateDir, ladder[idx].To) if _, err := os.Stat(meta); err != nil { meta = currentMeta // a step written before R-664: the template's own, said in the log } return LadderStep{Index: idx, Left: left, Source: src, Meta: meta, Why: fmt.Sprintf("step %d of %d: %s → %s, from %s (probe from %s)", idx+1, len(ladder), summarisePin(ladder[idx].From), summarisePin(ladder[idx].To), filepath.Base(src), filepath.Base(meta))}, 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 } // loadMetadataFile reads a `.felhom.yml` that is not named `.felhom.yml` (a step's // `steps/.felhom.yml`) through LoadMetadata — the ONE validating reader — by giving it a scratch // directory. Only the health check and the resources are read from the result. func loadMetadataFile(path string) (Metadata, error) { data, err := os.ReadFile(path) if err != nil { return Metadata{}, err } tmp, err := os.MkdirTemp("", "felhom-step-meta-") if err != nil { return Metadata{}, err } defer os.RemoveAll(tmp) if err := os.WriteFile(filepath.Join(tmp, ".felhom.yml"), data, 0o600); err != nil { return Metadata{}, err } return LoadMetadata(tmp), nil }