0d402f711d
gates / gates (push) Successful in 13s
POST /api/stacks/{name}/update is now a guarded job answering 202:
cheap refusals (hold — R-439, busy, migration, deploying, memory via the
deploy's own memoryVerdict, a fixed 2 GB disk floor, and no restorable
Tier-2 copy) → backup-first when the proven copy is older than
update.backup_max_age (24h) → safety dump BEFORE the pin moves → pin →
pull (failure puts the pin back) → up → health (.felhom.yml check or 60 s
settle, update.health_timeout 5m). Not healthy → the app is stopped and
HELD (RestoreHold reason update_failed, same store and gate as R-379) and
the page names the backup to restore from; the pin stays. Success is only
ever update_phase=done after health (R-443). UpdateStack is deleted.
The restorable-unit predicate is EXTRACTED to backup.Tier2UnitRestorePoint
and shared with the backups page (row pinned unchanged). The copy is aged
by the last successful Tier-2 copy, not the manifest created_at — measured
on demo-hp that created_at moves only on definition changes.
Crash safety: update-journal.json before each phase; RecoverUpdates before
the boot sweep, ResumeInterruptedUpdates after the guards are wired.
Three unattended start paths ignored a hold and now honour it: the
drive-return gate (restart + boot recreate) and the nightly volume dump.
The nightly capture and Tier-2 run skip held apps so the restore point
survives. No automatic rollback — measured per-app; route back = restore.
Tests A–H across stacks/backup/api/web/cmd; six red-proofs seen to fail.
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
706 lines
28 KiB
Go
706 lines
28 KiB
Go
package stacks
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"go/ast"
|
|
"go/parser"
|
|
"go/token"
|
|
"io"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// Slice 1 (v0.233.0) — the box writes down what it ACTUALLY installed.
|
|
//
|
|
// Every assertion here reads app.yaml BACK OFF DISK and checks the entries, their count and their
|
|
// digests. "recordInstalledImages returned" proves nothing: the whole feature is a durable record.
|
|
|
|
// --- the docker seam ---
|
|
|
|
// fakeContainer is one scripted container: what `docker inspect` will say about it.
|
|
type fakeContainer struct {
|
|
id string
|
|
ref string
|
|
imageID string
|
|
}
|
|
|
|
// scriptedInstalledDocker returns an execRunner that answers the recorder's three reads from canned data and
|
|
// never touches a daemon. It fails the test on an argv it does not recognise, so a change to the
|
|
// commands the recorder issues cannot pass silently.
|
|
func scriptedInstalledDocker(t *testing.T, psOut string, containers []fakeContainer, digests map[string]string) execRunner {
|
|
t.Helper()
|
|
return func(_ context.Context, _ string, _ []string, name string, args ...string) (string, error) {
|
|
// Accept BOTH compose spellings — composeArgv emits `docker compose ps` or `docker-compose
|
|
// ps` depending on the configured command, and the wiring tests use the latter.
|
|
if name == "docker" && len(args) >= 1 && args[0] == "compose" {
|
|
args = args[1:]
|
|
name = "docker-compose"
|
|
}
|
|
switch {
|
|
case name == "docker-compose" && len(args) >= 1 && args[0] == "ps":
|
|
return psOut, nil
|
|
case name == "docker" && len(args) >= 1 && args[0] == "inspect":
|
|
var b strings.Builder
|
|
for _, want := range args {
|
|
for _, c := range containers {
|
|
if c.id == want {
|
|
fmt.Fprintf(&b, "%s%s%s%s%s\n", c.id, inspectSep, c.ref, inspectSep, c.imageID)
|
|
}
|
|
}
|
|
}
|
|
return b.String(), nil
|
|
case name == "docker" && len(args) >= 2 && args[0] == "image" && args[1] == "inspect":
|
|
var b strings.Builder
|
|
for _, want := range args {
|
|
if d, ok := digests[want]; ok {
|
|
fmt.Fprintf(&b, "%s%s%s\n", want, inspectSep, d)
|
|
}
|
|
}
|
|
return b.String(), nil
|
|
}
|
|
t.Fatalf("unexpected command in test: %s %v", name, args)
|
|
return "", nil
|
|
}
|
|
}
|
|
|
|
const threeServiceCompose = `services:
|
|
web:
|
|
image: lscr.io/linuxserver/bookstack:26.05.2
|
|
db:
|
|
image: mariadb:12.3
|
|
cache:
|
|
image: redis:7-alpine
|
|
volumes:
|
|
bookstack_config:
|
|
`
|
|
|
|
// newInstalledManager builds a Manager over one real stack directory. Real FS, because the thing
|
|
// under test is a file write.
|
|
func newInstalledManager(t *testing.T, compose, appYAML string) (*Manager, string) {
|
|
t.Helper()
|
|
root := t.TempDir()
|
|
dir := filepath.Join(root, "bookstack")
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, "docker-compose.yml"), []byte(compose), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if appYAML != "" {
|
|
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte(appYAML), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
cfg := &config.Config{}
|
|
cfg.Paths.StacksDir = root
|
|
m := &Manager{
|
|
cfg: cfg,
|
|
logger: log.New(io.Discard, "", 0),
|
|
composeCmd: "docker compose",
|
|
encKey: []byte("0123456789abcdef0123456789abcdef"),
|
|
stacks: map[string]*Stack{
|
|
"bookstack": {Name: "bookstack", ComposePath: filepath.Join(dir, "docker-compose.yml"), Deployed: true},
|
|
},
|
|
}
|
|
// Mirror ScanStacks: the in-memory stack carries the loaded app.yaml and the template's pins.
|
|
m.stacks["bookstack"].AppConfig = LoadAppConfig(dir)
|
|
if imgs, err := ParseComposeImages(filepath.Join(dir, "docker-compose.yml")); err == nil {
|
|
m.stacks["bookstack"].TemplateImages = imgs
|
|
}
|
|
return m, dir
|
|
}
|
|
|
|
func readInstalled(t *testing.T, dir string) *AppConfig {
|
|
t.Helper()
|
|
b, err := os.ReadFile(filepath.Join(dir, "app.yaml"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg := &AppConfig{}
|
|
if err := yaml.Unmarshal(b, cfg); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return cfg
|
|
}
|
|
|
|
const ndjsonPS = `{"ID":"aaa111","Name":"bookstack","Service":"web"}
|
|
{"ID":"bbb222","Name":"bookstack-db","Service":"db"}
|
|
{"ID":"ccc333","Name":"bookstack-cache","Service":"cache"}`
|
|
|
|
func threeContainers() ([]fakeContainer, map[string]string) {
|
|
return []fakeContainer{
|
|
{id: "aaa111", ref: "lscr.io/linuxserver/bookstack:26.05.2", imageID: "sha256:img-web"},
|
|
{id: "bbb222", ref: "mariadb:12.3", imageID: "sha256:img-db"},
|
|
{id: "ccc333", ref: "redis:7-alpine", imageID: "sha256:img-cache"},
|
|
}, map[string]string{
|
|
"sha256:img-web": "lscr.io/linuxserver/bookstack@sha256:aaaaaaaa",
|
|
"sha256:img-db": "mariadb@sha256:bbbbbbbb",
|
|
"sha256:img-cache": "redis@sha256:cccccccc",
|
|
}
|
|
}
|
|
|
|
// --- GROUP A: one entry PER COMPOSE SERVICE, with digests ---
|
|
|
|
// TestGroupA_RecordsOneEntryPerService is the case that matters: a MULTI-container app. The wrong
|
|
// implementation records one entry for the whole stack, and it would pass any single-service test.
|
|
func TestGroupA_RecordsOneEntryPerService(t *testing.T) {
|
|
m, dir := newInstalledManager(t, threeServiceCompose, "deployed: true\nenv: {}\n")
|
|
cs, digs := threeContainers()
|
|
m.installedExecFn = scriptedInstalledDocker(t, ndjsonPS, cs, digs)
|
|
|
|
before := time.Now().UTC().Add(-time.Second)
|
|
m.recordInstalledImages("bookstack", dir, nil)
|
|
|
|
got := readInstalled(t, dir).InstalledImages
|
|
if len(got) != 3 {
|
|
t.Fatalf("recorded %d entries, want ONE PER COMPOSE SERVICE (3): %+v", len(got), got)
|
|
}
|
|
want := map[string][2]string{
|
|
"web": {"lscr.io/linuxserver/bookstack:26.05.2", "sha256:aaaaaaaa"},
|
|
"db": {"mariadb:12.3", "sha256:bbbbbbbb"},
|
|
"cache": {"redis:7-alpine", "sha256:cccccccc"},
|
|
}
|
|
for svc, w := range want {
|
|
e, ok := got[svc]
|
|
if !ok {
|
|
t.Fatalf("service %q missing — entries must be keyed by COMPOSE SERVICE NAME, got %+v", svc, got)
|
|
}
|
|
if e.Ref != w[0] {
|
|
t.Errorf("%s ref = %q, want %q", svc, e.Ref, w[0])
|
|
}
|
|
if e.Digest != w[1] {
|
|
t.Errorf("%s digest = %q, want %q — the digest is the only identifier that cannot lie", svc, e.Digest, w[1])
|
|
}
|
|
ts, err := time.Parse(time.RFC3339, e.At)
|
|
if err != nil {
|
|
t.Errorf("%s at = %q, not RFC3339: %v", svc, e.At, err)
|
|
} else if ts.Before(before) {
|
|
t.Errorf("%s at = %v, older than the run that produced it", svc, ts)
|
|
}
|
|
}
|
|
// The record must NOT have been assembled from the compose file: prove it by checking the
|
|
// deployed marker survived the copy-and-overlay save.
|
|
if !readInstalled(t, dir).Deployed {
|
|
t.Error("the save dropped deployed=true — SaveAppConfig must stay copy-and-overlay")
|
|
}
|
|
}
|
|
|
|
// TestGroupA_ImageWithNoRepoDigestRecordsAnEmptyDigest — a locally built or imported image has no
|
|
// RepoDigests. The entry is still recorded, with an empty digest: skipping it would silently lose a
|
|
// service from the record.
|
|
func TestGroupA_ImageWithNoRepoDigestRecordsAnEmptyDigest(t *testing.T) {
|
|
m, dir := newInstalledManager(t, "services:\n web:\n image: local/built:dev\n", "deployed: true\nenv: {}\n")
|
|
m.installedExecFn = scriptedInstalledDocker(t,
|
|
`{"ID":"aaa111","Name":"w","Service":"web"}`,
|
|
[]fakeContainer{{id: "aaa111", ref: "local/built:dev", imageID: "sha256:local"}},
|
|
map[string]string{"sha256:local": ""})
|
|
|
|
m.recordInstalledImages("app", dir, nil)
|
|
got := readInstalled(t, dir).InstalledImages
|
|
if len(got) != 1 {
|
|
t.Fatalf("an image with no repo digest must still be RECORDED, got %+v", got)
|
|
}
|
|
if got["web"].Ref != "local/built:dev" || got["web"].Digest != "" {
|
|
t.Fatalf("want ref recorded and digest empty, got %+v", got["web"])
|
|
}
|
|
}
|
|
|
|
// TestGroupA_MissingContainerRecordsWhatExists — the edge-case table: record what is there, and say
|
|
// the count out loud. A partial record written silently would read as a complete answer.
|
|
func TestGroupA_MissingContainerRecordsWhatExists(t *testing.T) {
|
|
var logs strings.Builder
|
|
m, dir := newInstalledManager(t, threeServiceCompose, "deployed: true\nenv: {}\n")
|
|
m.logger = log.New(&logs, "", 0)
|
|
cs, digs := threeContainers()
|
|
m.installedExecFn = scriptedInstalledDocker(t,
|
|
`{"ID":"aaa111","Name":"bookstack","Service":"web"}
|
|
{"ID":"bbb222","Name":"bookstack-db","Service":"db"}`, cs, digs)
|
|
|
|
m.recordInstalledImages("bookstack", dir, nil)
|
|
got := readInstalled(t, dir).InstalledImages
|
|
if len(got) != 2 {
|
|
t.Fatalf("want the 2 observed services recorded, got %+v", got)
|
|
}
|
|
if !strings.Contains(logs.String(), "recorded 2 of 3") || !strings.Contains(logs.String(), "cache") {
|
|
t.Fatalf("a partial read must be said out loud, naming what is missing. Log was:\n%s", logs.String())
|
|
}
|
|
}
|
|
|
|
// --- GROUP B: the record follows the CONTAINER, not the file ---
|
|
|
|
// TestGroupB_RecordFollowsTheContainerNotTheFile is the reason this feature exists. The compose file
|
|
// and the running container can disagree indefinitely (measured: SPIKE §3 — 25 minutes). Here the
|
|
// FILE says one thing and the CONTAINER another; the record must carry the container's answer.
|
|
//
|
|
// It also pins the re-record half of Scenario B: an existing record for the OLD image is replaced,
|
|
// not left standing. A record that goes stale is worse than none, because it will be trusted.
|
|
func TestGroupB_RecordFollowsTheContainerNotTheFile(t *testing.T) {
|
|
const old = `deployed: true
|
|
env: {}
|
|
installed_images:
|
|
web:
|
|
ref: ghcr.io/alam00000/bentopdf:v2.8.5
|
|
digest: sha256:oldoldold
|
|
at: "2026-09-01T17:36:35Z"
|
|
`
|
|
// The FILE pins v2.8.5 — exactly the post-sync state the spike measured.
|
|
m, dir := newInstalledManager(t, "services:\n web:\n image: ghcr.io/alam00000/bentopdf:v2.8.5\n", old)
|
|
// The CONTAINER runs v2.8.6.
|
|
m.installedExecFn = scriptedInstalledDocker(t,
|
|
`{"ID":"aaa111","Name":"bentopdf","Service":"web"}`,
|
|
[]fakeContainer{{id: "aaa111", ref: "ghcr.io/alam00000/bentopdf:v2.8.6", imageID: "sha256:new"}},
|
|
map[string]string{"sha256:new": "ghcr.io/alam00000/bentopdf@sha256:newnewnew"})
|
|
|
|
m.recordInstalledImages("bentopdf", dir, nil)
|
|
|
|
got := readInstalled(t, dir).InstalledImages["web"]
|
|
if got.Ref != "ghcr.io/alam00000/bentopdf:v2.8.6" {
|
|
t.Fatalf("ref = %q — the record must read the CONTAINER; the file is the value that has already moved", got.Ref)
|
|
}
|
|
if got.Digest != "sha256:newnewnew" {
|
|
t.Fatalf("digest = %q, want the new one — a record that goes stale is worse than none", got.Digest)
|
|
}
|
|
if got.At == "2026-09-01T17:36:35Z" {
|
|
t.Fatal("`at` must be re-stamped when the image CHANGES")
|
|
}
|
|
}
|
|
|
|
// TestGroupB_UnchangedObservationDoesNotRewriteAppYAML — the SetDesiredState rule. app.yaml holds
|
|
// encrypted secrets; rewriting it on every restart for no new information is pure risk. `at` is
|
|
// therefore also carried forward, so it answers "running since" and not "last looked at".
|
|
func TestGroupB_UnchangedObservationDoesNotRewriteAppYAML(t *testing.T) {
|
|
const same = `deployed: true
|
|
env: {}
|
|
installed_images:
|
|
web:
|
|
ref: nginx:1.27
|
|
digest: sha256:keepme
|
|
at: "2026-08-01T00:00:00Z"
|
|
`
|
|
m, dir := newInstalledManager(t, "services:\n web:\n image: nginx:1.27\n", same)
|
|
m.installedExecFn = scriptedInstalledDocker(t,
|
|
`{"ID":"aaa111","Name":"n","Service":"web"}`,
|
|
[]fakeContainer{{id: "aaa111", ref: "nginx:1.27", imageID: "sha256:i"}},
|
|
map[string]string{"sha256:i": "nginx@sha256:keepme"})
|
|
|
|
path := filepath.Join(dir, "app.yaml")
|
|
st0, err := os.Stat(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
m.recordInstalledImages("app", dir, nil)
|
|
st1, err := os.Stat(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !st0.ModTime().Equal(st1.ModTime()) || st0.Size() != st1.Size() {
|
|
t.Error("an unchanged observation must not rewrite app.yaml")
|
|
}
|
|
if got := readInstalled(t, dir).InstalledImages["web"].At; got != "2026-08-01T00:00:00Z" {
|
|
t.Errorf("at = %q — the first-seen timestamp must be carried forward, not re-stamped", got)
|
|
}
|
|
}
|
|
|
|
// --- GROUP C: recording fails, the ACTION still succeeds ---
|
|
|
|
// TestGroupC_UnwritableAppYAMLDoesNotFailTheAction is the deliberate opposite of SetDesiredState.
|
|
// `desired_state` is INTENT and a failed write correctly refuses the act. `installed_images` is an
|
|
// OBSERVATION: refusing to restart a customer's app because we could not write down which version it
|
|
// is would trade a real outage for a bookkeeping gap.
|
|
//
|
|
// COMPANION RED-PROOF (run 2026-09-02): give recordInstalledImages an `error` return and make
|
|
// RestartStack `return` it on failure. This test then fails with "restart must SUCCEED" — i.e. the
|
|
// customer's app refuses to start because a note could not be written. Reverted.
|
|
func TestGroupC_UnwritableAppYAMLDoesNotFailTheAction(t *testing.T) {
|
|
if os.Getuid() == 0 {
|
|
t.Skip("root ignores directory permissions — this test cannot make a write fail")
|
|
}
|
|
var logs strings.Builder
|
|
m, dir := newInstalledManager(t, "services:\n web:\n image: nginx:1.27\n", "deployed: true\nenv: {}\n")
|
|
m.logger = log.New(&logs, "", 0)
|
|
m.installedExecFn = scriptedInstalledDocker(t,
|
|
`{"ID":"aaa111","Name":"n","Service":"web"}`,
|
|
[]fakeContainer{{id: "aaa111", ref: "nginx:1.27", imageID: "sha256:i"}},
|
|
map[string]string{"sha256:i": "nginx@sha256:d"})
|
|
withFakeCompose(t, m)
|
|
|
|
// Read-only stack dir: SaveAppConfig's tmp+rename cannot create its temp file.
|
|
if err := os.Chmod(dir, 0o555); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = os.Chmod(dir, 0o755) })
|
|
|
|
if err := m.RestartStack("bookstack"); err != nil {
|
|
t.Fatalf("restart must SUCCEED even when the record cannot be written: %v", err)
|
|
}
|
|
out := logs.String()
|
|
if !strings.Contains(out, "[ERROR]") || !strings.Contains(out, "installed-images bookstack") {
|
|
t.Fatalf("the failure must be logged at ERROR, naming the app. Log was:\n%s", out)
|
|
}
|
|
if !strings.Contains(out, "unaffected") {
|
|
t.Errorf("the ERROR line should say the app is unaffected, so it is not read as an outage. Log was:\n%s", out)
|
|
}
|
|
}
|
|
|
|
// --- GROUP E: the WIRING — reached through the REAL caller ---
|
|
|
|
// withFakeCompose puts a stub `docker-compose` on PATH and points the manager at it, so a REAL
|
|
// RestartStack can run to completion without a docker daemon. It is the compose process boundary
|
|
// that is faked, not the recorder — the recorder is reached exactly as production reaches it.
|
|
func withFakeCompose(t *testing.T, m *Manager) {
|
|
t.Helper()
|
|
if runtime.GOOS != "linux" {
|
|
t.Skip("the stub compose binary is a shell script")
|
|
}
|
|
bin := t.TempDir()
|
|
script := "#!/bin/sh\nexit 0\n"
|
|
if err := os.WriteFile(filepath.Join(bin, "docker-compose"), []byte(script), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
|
|
m.composeCmd = "docker-compose"
|
|
// refreshStatusLocked's `docker ps` — the OTHER, pre-existing seam.
|
|
m.execFn = func(string, ...string) (string, error) { return "", nil }
|
|
}
|
|
|
|
// TestGroupE_RestartStackReachesTheRecorder is the seam-discipline test. Three shipped defects in
|
|
// three days were injected-seam tests that proved a component whose caller never invoked it, so at
|
|
// least one test must reach recordInstalledImages through a REAL production caller. RestartStack is
|
|
// invoked here in full; only the compose and `docker ps` process boundaries are stubbed.
|
|
func TestGroupE_RestartStackReachesTheRecorder(t *testing.T) {
|
|
m, dir := newInstalledManager(t, "services:\n web:\n image: nginx:1.27\n", "deployed: true\nenv: {}\n")
|
|
m.installedExecFn = scriptedInstalledDocker(t,
|
|
`{"ID":"aaa111","Name":"n","Service":"web"}`,
|
|
[]fakeContainer{{id: "aaa111", ref: "nginx:1.27", imageID: "sha256:i"}},
|
|
map[string]string{"sha256:i": "nginx@sha256:wired"})
|
|
withFakeCompose(t, m)
|
|
|
|
if err := m.RestartStack("bookstack"); err != nil {
|
|
t.Fatalf("restart: %v", err)
|
|
}
|
|
got := readInstalled(t, dir).InstalledImages
|
|
if len(got) != 1 || got["web"].Digest != "sha256:wired" {
|
|
t.Fatalf("RestartStack did not reach the recorder — app.yaml holds %+v", got)
|
|
}
|
|
// And the in-memory view is in step, so the badge does not lag a ScanStacks behind the file.
|
|
if s, ok := m.GetStack("bookstack"); !ok || s.AppConfig == nil || s.AppConfig.InstalledImages["web"].Digest != "sha256:wired" {
|
|
t.Error("the in-memory AppConfig must be updated too")
|
|
}
|
|
}
|
|
|
|
// TestGroupE_EveryBringUpPathCallsTheRecorder walks the AST of the production sources for the four
|
|
// paths that cannot each be driven to completion from a unit test.
|
|
//
|
|
// An AST walk, NOT a strings.Contains: a commented-out call still contains the string, and that is
|
|
// exactly the shape a "seam built but never wired" defect takes. It also asserts the NEGATIVE —
|
|
// StartStackServices must NOT call it, because that path starts only the database service for the
|
|
// R-47 window and would overwrite a complete record with an incomplete one.
|
|
func TestGroupE_EveryBringUpPathCallsTheRecorder(t *testing.T) {
|
|
callers := map[string]bool{} // enclosing func name -> calls recordInstalledImages
|
|
fset := token.NewFileSet()
|
|
// update.go since v0.237.0: the guarded update replaced UpdateStack, and it records in
|
|
// verifyAndConclude — only after the app's health is known (slice 4).
|
|
for _, src := range []string{"manager.go", "deploy.go", "update.go"} {
|
|
f, err := parser.ParseFile(fset, src, nil, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, d := range f.Decls {
|
|
fn, ok := d.(*ast.FuncDecl)
|
|
if !ok {
|
|
continue
|
|
}
|
|
found := false
|
|
ast.Inspect(fn.Body, func(n ast.Node) bool {
|
|
call, ok := n.(*ast.CallExpr)
|
|
if !ok {
|
|
return true
|
|
}
|
|
if sel, ok := call.Fun.(*ast.SelectorExpr); ok && sel.Sel.Name == "recordInstalledImages" {
|
|
found = true
|
|
}
|
|
return true
|
|
})
|
|
if found {
|
|
callers[fn.Name.Name] = true
|
|
}
|
|
}
|
|
}
|
|
for _, want := range []string{"StartStack", "RestartStack", "verifyAndConclude", "runComposeDeploy"} {
|
|
if !callers[want] {
|
|
t.Errorf("%s does not call recordInstalledImages — a bring-up path that records nothing leaves a stale record standing", want)
|
|
}
|
|
}
|
|
if callers["StartStackServices"] {
|
|
t.Error("StartStackServices must NOT record: it starts only the DB service for the R-47 window, and a partial record would overwrite a complete one")
|
|
}
|
|
}
|
|
|
|
// --- parsing units ---
|
|
|
|
func TestParseComposePS_BothShapes(t *testing.T) {
|
|
arr := `[{"ID":"a","Name":"n1","Service":"web"},{"ID":"b","Name":"n2","Service":"db"}]`
|
|
for name, in := range map[string]string{"ndjson": ndjsonPS, "array": arr} {
|
|
got, err := parseComposePS(in)
|
|
if err != nil {
|
|
t.Fatalf("%s: %v", name, err)
|
|
}
|
|
if len(got) < 2 || got[0].Service == "" {
|
|
t.Fatalf("%s: parsed %+v", name, got)
|
|
}
|
|
}
|
|
if got, err := parseComposePS(" "); err != nil || got != nil {
|
|
t.Errorf("empty output must be an empty list, not an error: %v %v", got, err)
|
|
}
|
|
if _, err := parseComposePS("not json"); err == nil {
|
|
t.Error("unparseable output must be an ERROR — cannot-tell must never read as no-containers")
|
|
}
|
|
}
|
|
|
|
func TestPickDigestAndRefRepository(t *testing.T) {
|
|
cases := []struct{ ref, digests, want string }{
|
|
{"mariadb:12.3", "mariadb@sha256:aaa", "sha256:aaa"},
|
|
{"mariadb:12.3", "", ""},
|
|
// Two repos, same bytes: pick the one this app's ref names, never the other.
|
|
{"mariadb:12.3", "mirror.example/mariadb@sha256:zzz mariadb@sha256:aaa", "sha256:aaa"},
|
|
// A registry PORT is not a tag.
|
|
{"registry:5000/app:1.2", "registry:5000/app@sha256:bbb", "sha256:bbb"},
|
|
// Sole entry, repo does not match: fall back rather than lose the digest.
|
|
{"weird:1", "other@sha256:ccc", "sha256:ccc"},
|
|
// Several unrelated entries and none matches: give up rather than guess.
|
|
{"weird:1", "a@sha256:1 b@sha256:2", ""},
|
|
}
|
|
for _, c := range cases {
|
|
if got := pickDigest(c.ref, c.digests); got != c.want {
|
|
t.Errorf("pickDigest(%q, %q) = %q, want %q", c.ref, c.digests, got, c.want)
|
|
}
|
|
}
|
|
if got := refRepository("registry:5000/app:1.2"); got != "registry:5000/app" {
|
|
t.Errorf("refRepository dropped a registry port: %q", got)
|
|
}
|
|
}
|
|
|
|
// TestParseComposeImages_RealYAMLParse pins the reason this is not a line scan: immich's top-level
|
|
// volume keys have exactly the shape a naive scan misreads as a service.
|
|
func TestParseComposeImages_RealYAMLParse(t *testing.T) {
|
|
dir := t.TempDir()
|
|
p := filepath.Join(dir, "docker-compose.yml")
|
|
body := `services:
|
|
immich-server:
|
|
image: ghcr.io/immich-app/immich-server:v2.0.1
|
|
immich-db:
|
|
image: ghcr.io/immich-app/postgres:16
|
|
volumes:
|
|
immich_ml_cache:
|
|
immich_postgres_data:
|
|
`
|
|
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err := ParseComposeImages(p)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(got) != 2 {
|
|
t.Fatalf("parsed %d services, want 2 — a top-level volume key is NOT a service: %+v", len(got), got)
|
|
}
|
|
if _, err := ParseComposeImages(filepath.Join(dir, "nope.yml")); err == nil {
|
|
t.Error("an unreadable file must be an ERROR — cannot-tell must never read as no-images")
|
|
}
|
|
}
|
|
|
|
// --- GROUP G: the startup backfill (v0.234.0) ---
|
|
//
|
|
// v0.233.0 wrote the record only from the four bring-up paths, so an app nobody restarts showed no
|
|
// badge indefinitely. Found live on demo-felhom the day after the release: OpenGist, up 15 hours,
|
|
// running exactly what the catalog pins, and showing nothing.
|
|
|
|
// newBackfillManager registers `names` as deployed stacks under one temp root.
|
|
func newBackfillManager(t *testing.T, specs map[string]string) (*Manager, map[string]string) {
|
|
t.Helper()
|
|
root := t.TempDir()
|
|
cfg := &config.Config{}
|
|
cfg.Paths.StacksDir = root
|
|
m := &Manager{
|
|
cfg: cfg, logger: log.New(io.Discard, "", 0), composeCmd: "docker compose",
|
|
encKey: []byte("0123456789abcdef0123456789abcdef"),
|
|
stacks: map[string]*Stack{},
|
|
}
|
|
dirs := map[string]string{}
|
|
for name, compose := range specs {
|
|
dir := filepath.Join(root, name)
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, "docker-compose.yml"), []byte(compose), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("deployed: true\nenv: {}\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
m.stacks[name] = &Stack{
|
|
Name: name, Deployed: true,
|
|
ComposePath: filepath.Join(dir, "docker-compose.yml"),
|
|
AppConfig: LoadAppConfig(dir),
|
|
}
|
|
dirs[name] = dir
|
|
}
|
|
return m, dirs
|
|
}
|
|
|
|
// TestGroupG_BackfillSeedsAnUntouchedApp is the case the operator reported: a deployed app that has
|
|
// simply been running, with no record and therefore no badge.
|
|
func TestGroupG_BackfillSeedsAnUntouchedApp(t *testing.T) {
|
|
m, dirs := newBackfillManager(t, map[string]string{
|
|
"opengist": "services:\n opengist:\n image: ghcr.io/thomiceli/opengist:1.13\n",
|
|
})
|
|
m.installedExecFn = scriptedInstalledDocker(t,
|
|
`{"ID":"aaa111","Name":"opengist","Service":"opengist"}`,
|
|
[]fakeContainer{{id: "aaa111", ref: "ghcr.io/thomiceli/opengist:1.13", imageID: "sha256:og"}},
|
|
map[string]string{"sha256:og": "ghcr.io/thomiceli/opengist@sha256:seeded"})
|
|
|
|
if n := m.BackfillInstalledImages(); n != 1 {
|
|
t.Fatalf("backfilled %d, want 1", n)
|
|
}
|
|
got := readInstalled(t, dirs["opengist"]).InstalledImages
|
|
if len(got) != 1 || got["opengist"].Digest != "sha256:seeded" {
|
|
t.Fatalf("app.yaml holds %+v", got)
|
|
}
|
|
// And the in-memory view, so the badge does not wait for the next ScanStacks.
|
|
if s, _ := m.GetStack("opengist"); s.AppConfig.InstalledImages["opengist"].Digest != "sha256:seeded" {
|
|
t.Error("the in-memory AppConfig must be seeded too")
|
|
}
|
|
}
|
|
|
|
// TestGroupG_BackfillRefusesAPartialObservation is THE reason this is not a three-line loop.
|
|
//
|
|
// compareInstalledToTemplate reads a service-count mismatch as BEHIND. A degraded or crash-looping
|
|
// app has fewer live containers than its template has services, so seeding what can be seen would
|
|
// render „Frissítés elérhető" over an app that is perfectly current — a confident WRONG answer,
|
|
// which is worse than the silence it replaces.
|
|
//
|
|
// COMPANION RED-PROOF (run 2026-09-03): delete the `observationCoversTemplate` guard from
|
|
// BackfillInstalledImages. This test then fails with "backfilled 1, want 0" and the follow-up
|
|
// assertion shows a 1-of-2 record on disk — the exact shape that renders a false "update available".
|
|
// Reverted.
|
|
func TestGroupG_BackfillRefusesAPartialObservation(t *testing.T) {
|
|
m, dirs := newBackfillManager(t, map[string]string{
|
|
"bookstack": threeServiceCompose,
|
|
})
|
|
cs, digs := threeContainers()
|
|
// Only TWO of the three services are observable — the `cache` container is gone.
|
|
m.installedExecFn = scriptedInstalledDocker(t,
|
|
`{"ID":"aaa111","Name":"bookstack","Service":"web"}
|
|
{"ID":"bbb222","Name":"bookstack-db","Service":"db"}`, cs, digs)
|
|
|
|
if n := m.BackfillInstalledImages(); n != 0 {
|
|
t.Fatalf("backfilled %d, want 0 — a partial observation must NOT be seeded", n)
|
|
}
|
|
if got := readInstalled(t, dirs["bookstack"]).InstalledImages; len(got) != 0 {
|
|
t.Fatalf("app.yaml must carry NO record rather than a partial one, got %+v", got)
|
|
}
|
|
}
|
|
|
|
// TestGroupG_BackfillNeverOverwritesAnExistingRecord — the bring-up paths own updates; this only
|
|
// seeds absences. Overwriting would let a boot re-stamp a record the lifecycle paths had just moved.
|
|
func TestGroupG_BackfillNeverOverwritesAnExistingRecord(t *testing.T) {
|
|
m, dirs := newBackfillManager(t, map[string]string{
|
|
"app": "services:\n web:\n image: nginx:1.27\n",
|
|
})
|
|
existing := `deployed: true
|
|
env: {}
|
|
installed_images:
|
|
web:
|
|
ref: nginx:1.26
|
|
digest: sha256:original
|
|
at: "2026-08-01T00:00:00Z"
|
|
`
|
|
if err := os.WriteFile(filepath.Join(dirs["app"], "app.yaml"), []byte(existing), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
m.stacks["app"].AppConfig = LoadAppConfig(dirs["app"])
|
|
m.installedExecFn = func(context.Context, string, []string, string, ...string) (string, error) {
|
|
t.Fatal("an app that already has a record must not even be OBSERVED")
|
|
return "", nil
|
|
}
|
|
if n := m.BackfillInstalledImages(); n != 0 {
|
|
t.Fatalf("backfilled %d, want 0", n)
|
|
}
|
|
if got := readInstalled(t, dirs["app"]).InstalledImages["web"]; got.Digest != "sha256:original" || got.At != "2026-08-01T00:00:00Z" {
|
|
t.Fatalf("the existing record was disturbed: %+v", got)
|
|
}
|
|
}
|
|
|
|
// TestGroupG_BackfillSkipsProtectedAndUndeployed — infra is not the customer's to update, and an
|
|
// undeployed template has nothing running to read.
|
|
func TestGroupG_BackfillSkipsProtectedAndUndeployed(t *testing.T) {
|
|
m, _ := newBackfillManager(t, map[string]string{
|
|
"traefik": "services:\n traefik:\n image: traefik:v3\n",
|
|
"unused": "services:\n web:\n image: nginx:1.27\n",
|
|
})
|
|
m.stacks["traefik"].Protected = true
|
|
m.stacks["unused"].Deployed = false
|
|
m.installedExecFn = func(context.Context, string, []string, string, ...string) (string, error) {
|
|
t.Fatal("neither a protected nor an undeployed stack may be observed")
|
|
return "", nil
|
|
}
|
|
if n := m.BackfillInstalledImages(); n != 0 {
|
|
t.Fatalf("backfilled %d, want 0", n)
|
|
}
|
|
}
|
|
|
|
// TestGroupG_BackfillIsWiredAtStartup — the seam-discipline half. BackfillInstalledImages cannot be
|
|
// driven from this package's tests through main(), so the call is proven by walking the AST of the
|
|
// production entry point, NOT by a strings.Contains that a commented-out call would satisfy.
|
|
//
|
|
// It also asserts the ORDER against its sibling: both backfills run before the boot reconciler, so a
|
|
// just-recovered app is observed in its settled state.
|
|
func TestGroupG_BackfillIsWiredAtStartup(t *testing.T) {
|
|
src := filepath.Join("..", "..", "cmd", "controller", "main.go")
|
|
fset := token.NewFileSet()
|
|
f, err := parser.ParseFile(fset, src, nil, 0)
|
|
if err != nil {
|
|
t.Skipf("cmd/controller is gitignored in some checkouts: %v", err)
|
|
}
|
|
var backfillPos, desiredPos, reconPos int
|
|
ast.Inspect(f, func(n ast.Node) bool {
|
|
call, ok := n.(*ast.CallExpr)
|
|
if !ok {
|
|
return true
|
|
}
|
|
sel, ok := call.Fun.(*ast.SelectorExpr)
|
|
if !ok {
|
|
return true
|
|
}
|
|
switch sel.Sel.Name {
|
|
case "BackfillInstalledImages":
|
|
backfillPos = fset.Position(call.Pos()).Line
|
|
case "BackfillDesiredState":
|
|
desiredPos = fset.Position(call.Pos()).Line
|
|
case "runBootReconcile":
|
|
if reconPos == 0 {
|
|
reconPos = fset.Position(call.Pos()).Line
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
if backfillPos == 0 {
|
|
t.Fatal("BackfillInstalledImages is never called from cmd/controller — a backfill nothing invokes seeds nothing")
|
|
}
|
|
if desiredPos == 0 || backfillPos <= desiredPos {
|
|
t.Errorf("the installed-images backfill (line %d) must run after the desired-state one (line %d)", backfillPos, desiredPos)
|
|
}
|
|
if reconPos != 0 && backfillPos > reconPos {
|
|
t.Errorf("the backfill (line %d) must run BEFORE the boot reconciler (line %d)", backfillPos, reconPos)
|
|
}
|
|
}
|