504eae018b
gates / gates (push) Successful in 24s
Five red-proofs. MinAgent 0.131.0 unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
206 lines
8.9 KiB
Go
206 lines
8.9 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 {
|
|
// 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/<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
|
|
}
|