b6810f14ff
gates / gates (push) Successful in 23s
The unit's data files are stamped with the versions that wrote them; the capture keeps the definition the data belongs to; a restore never starts data under another version's definition (unit restores refuse a mismatch; the off-site restore writes the snapshot's definition); every tier's time is its data's; the conversion-copy release needs a dump on the new engine. File-browser sync single-flight + no empty kept folder (R-695); the kept view joins the folder's owning group, language switch resyncs (R-691); a restore-generated login is not shown as the password (R-694). Red-proofs in felhom.eu/documentation/audits/version-travel-2026-09-26/. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
248 lines
11 KiB
Go
248 lines
11 KiB
Go
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)
|
|
}
|
|
// v0.269.0: the step's own definition, run with its TESTED digest (`09` §6.4 part 6)
|
|
wantB := string(renderDigests([]byte(ladderBDef), map[string]string{"web": "sha256:" + strings.Repeat("a", 64)}))
|
|
if body := fileBody(t, ComposePathIn(dir)); body != wantB {
|
|
t.Fatalf("press 1 rendered\n%s\nwant step B's OWN definition (with STEP_B_OWN_DEFINITION) and its digest", body)
|
|
}
|
|
if body := fileBody(t, AppliedComposePath(dir)); body != wantB {
|
|
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 != string(renderDigests([]byte(pinTplNew), map[string]string{"web": "sha256:" + strings.Repeat("a", 64)})) {
|
|
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)
|
|
}
|
|
|
|
// TestA3_RestoredVersionPosition — after a restore brought an app back at an older pin: behind, and
|
|
// climbable only when a tested ladder step leads on from that pin (the automatic leg's own rule:
|
|
// LegSkipOlderThanLadder). A pin at the catalog's version is not behind; digests do not count.
|
|
func TestA3_RestoredVersionPosition(t *testing.T) {
|
|
m, _, _, _, _ := ladderManager(t, true)
|
|
set := func(pin string) {
|
|
m.mu.Lock()
|
|
st := m.stacks["nextcloud"]
|
|
st.CatalogImages = map[string]string{"web": ladderC}
|
|
if st.AppConfig == nil {
|
|
st.AppConfig = &AppConfig{}
|
|
}
|
|
st.AppConfig.PinnedImages = map[string]string{"web": pin}
|
|
m.mu.Unlock()
|
|
}
|
|
for _, c := range []struct {
|
|
pin string
|
|
behind, climbable bool
|
|
}{
|
|
{ladderA, true, true}, // on the ladder: the leg climbs it
|
|
{"nextcloud:20.0.0-apache", true, false}, // older than the ladder: the leg will not
|
|
{ladderC, false, false}, // at the catalog's version
|
|
{ladderC + "@sha256:0123", false, false}, // a digest is not a version
|
|
} {
|
|
set(c.pin)
|
|
if b, cl := m.RestoredVersionPosition("nextcloud"); b != c.behind || cl != c.climbable {
|
|
t.Errorf("pin %s: behind=%v climbable=%v, want %v %v", c.pin, b, cl, c.behind, c.climbable)
|
|
}
|
|
}
|
|
}
|