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

217 lines
9.6 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)
}