Files
felhom-controller/controller/internal/stacks/ladder.go
T

199 lines
8.4 KiB
Go

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"`
// 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"`
}
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/<key>.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 {
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/<key>.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
}