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
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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user