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,214 @@
|
||||
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)
|
||||
}
|
||||
if body := fileBody(t, ComposePathIn(dir)); body != ladderBDef {
|
||||
t.Fatalf("press 1 rendered\n%s\nwant step B's OWN definition (with STEP_B_OWN_DEFINITION)", body)
|
||||
}
|
||||
if body := fileBody(t, AppliedComposePath(dir)); body != ladderBDef {
|
||||
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 != pinTplNew {
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user