8a0e0a59ad
gates / gates (push) Successful in 12s
Slice 3. R-447 was BLOCKED because R-438 established that RestartStack's use of up -d to pick up template changes was CHOSEN and written down in its own comment. The operator ruled Option 1, and this implements it. The rule: while the catalog offers the same version you run, its fixes flow to you; the moment it moves to a newer version you are frozen until you update. NOTHING was added to any of the thirteen compose up -d call sites. Most of them are repairs - the boot reconciler, the drive-return gate, the app-stop guard - and a repair path that refuses to repair leaves a customer's app down, which is worse than the problem. They are made safe by removing the reason. app.yaml gains pinned_images: what the app is SUPPOSED to run. It is NOT installed_images, which is an observation; letting a reading become a deployment is the R-166 category error one field over. Four writers, each also storing the exact definition as applied-compose.yml. UpdateStack advances the pin and re-renders BEFORE the pull, because pull and up -d act on the file on disk, and a pin set afterwards would pull the frozen version and report success. The syncer renders instead of copying, through one nil-safe seam. Catalog images equal the pin -> verbatim, so fixes and self-healing both survive; they differ -> the WHOLE stored definition, never a substitution of refs into a newer template (wger 2.6 needs a DB config the older template cannot supply). This is deliberately not 'skip deployed apps', which was option B and was rejected. AdoptPins runs once at boot after the backfill, files only, and skips loudly rather than inventing a pin. syncer.Start() moved to after it: the initial sync would otherwise run while every app was unpinned and overwrite a deployed app's version once per boot. THE BADGE HAD TO CHANGE OR SLICE 2 WOULD HAVE INVERTED SILENTLY. TemplateImages reads the LIVE compose file, which is now the frozen one, so the comparison would have answered Naprakesz on exactly the apps that are behind - with every test green, because the new field has the same type. It now reads CatalogImages. +16 tests (1729 -> 1745), 28 packages green. Three red-proofs run and reverted. A test also caught the syncer writing an empty compose file over a live app.
344 lines
15 KiB
Go
344 lines
15 KiB
Go
package web
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
|
)
|
|
|
|
// Slice 2 (v0.233.0) — the badge. FOUR states, and the fourth is the load-bearing one:
|
|
// NO RECORD RENDERS NOTHING. An app with no record showing "Naprakész" is the R-166 failure in a
|
|
// new place — absent means UNKNOWN and never means current.
|
|
|
|
var badgeNow = time.Date(2026, 9, 2, 12, 0, 0, 0, time.UTC)
|
|
|
|
// ubStack builds a deployed app whose record and CATALOG images are stated explicitly.
|
|
//
|
|
// Since v0.235.0 the badge compares against `CatalogImages` — what the catalog OFFERS — and never
|
|
// against `TemplateImages`, which after the freeze is the app's own (possibly frozen) live file.
|
|
// Both are set to the same map here because that is the un-frozen case; `ubFrozenStack` is the one
|
|
// where they deliberately differ.
|
|
func ubStack(installed map[string]stacks.InstalledImage, catalog map[string]string, since string) stacks.Stack {
|
|
return stacks.Stack{
|
|
Name: "bookstack",
|
|
Deployed: true,
|
|
State: stacks.StateRunning,
|
|
Meta: stacks.Metadata{DisplayName: "BookStack", Slug: "bookstack", CatalogSince: since},
|
|
AppConfig: &stacks.AppConfig{Deployed: true, InstalledImages: installed},
|
|
TemplateImages: catalog,
|
|
CatalogImages: catalog,
|
|
}
|
|
}
|
|
|
|
// ubFrozenStack models a v0.235.0 FROZEN app: it runs an old version, its LIVE compose file has been
|
|
// rendered from its own stored definition and therefore also names the old version, and the CATALOG
|
|
// has moved on. This is the shape that silently inverts the badge if the comparison reads the wrong
|
|
// field — see TestGroupG.
|
|
func ubFrozenStack(running, catalogRef, since string) stacks.Stack {
|
|
st := ubStack(map[string]stacks.InstalledImage{"web": rec(running)}, map[string]string{"web": catalogRef}, since)
|
|
st.AppConfig.PinnedImages = map[string]string{"web": running}
|
|
st.TemplateImages = map[string]string{"web": running} // the frozen live file
|
|
return st
|
|
}
|
|
|
|
func rec(ref string) stacks.InstalledImage {
|
|
return stacks.InstalledImage{Ref: ref, Digest: "sha256:x", At: "2026-09-01T00:00:00Z"}
|
|
}
|
|
|
|
// --- GROUP D: the four states ---
|
|
|
|
// TestGroupD_FourStates.
|
|
//
|
|
// COMPANION RED-PROOF (run 2026-09-02): in compareInstalledToTemplate, change the
|
|
// `len(s.AppConfig.InstalledImages) == 0` guard to fall through to updateCurrent instead of
|
|
// updateUnknown — i.e. the trivial implementation that treats "we never wrote it down" as
|
|
// "up to date". The `no record at all` sub-test then fails with a "Naprakész" badge on an app
|
|
// nobody has ever measured. Reverted.
|
|
func TestGroupD_FourStates(t *testing.T) {
|
|
tpl := map[string]string{"web": "lscr.io/linuxserver/bookstack:26.05.2", "db": "mariadb:12.3"}
|
|
|
|
cases := []struct {
|
|
name string
|
|
stack stacks.Stack
|
|
wantBadge bool
|
|
wantLabel string
|
|
wantClass string
|
|
}{
|
|
{
|
|
name: "current",
|
|
stack: ubStack(map[string]stacks.InstalledImage{"web": rec(tpl["web"]), "db": rec(tpl["db"])}, tpl, "2026-07-18"),
|
|
wantBadge: true, wantLabel: "Naprakész", wantClass: "tag-ok",
|
|
},
|
|
{
|
|
name: "behind, age known",
|
|
stack: ubStack(map[string]stacks.InstalledImage{"web": rec("lscr.io/linuxserver/bookstack:25.02.2"), "db": rec(tpl["db"])}, tpl, "2026-07-18"),
|
|
wantBadge: true, wantLabel: "Frissítés elérhető — 46 napja", wantClass: "tag-warn",
|
|
},
|
|
{
|
|
name: "behind, age unknown",
|
|
stack: ubStack(map[string]stacks.InstalledImage{"web": rec("lscr.io/linuxserver/bookstack:25.02.2"), "db": rec(tpl["db"])}, tpl, ""),
|
|
wantBadge: true, wantLabel: "Frissítés elérhető", wantClass: "tag-warn",
|
|
},
|
|
{
|
|
name: "behind, catalog moved TODAY",
|
|
stack: ubStack(map[string]stacks.InstalledImage{"web": rec("old:1"), "db": rec(tpl["db"])}, tpl, "2026-09-02"),
|
|
wantBadge: true, wantLabel: "Frissítés elérhető — ma", wantClass: "tag-warn",
|
|
},
|
|
{
|
|
name: "NO RECORD AT ALL (legacy app.yaml) — nothing rendered",
|
|
stack: ubStack(nil, tpl, "2026-07-18"),
|
|
wantBadge: false,
|
|
},
|
|
{
|
|
name: "a service was ADDED by the template",
|
|
stack: ubStack(map[string]stacks.InstalledImage{"web": rec(tpl["web"])}, tpl, "2026-07-18"),
|
|
wantBadge: true, wantLabel: "Frissítés elérhető — 46 napja", wantClass: "tag-warn",
|
|
},
|
|
{
|
|
name: "template unreadable — nothing rendered",
|
|
stack: ubStack(map[string]stacks.InstalledImage{"web": rec(tpl["web"]), "db": rec(tpl["db"])}, nil, "2026-07-18"),
|
|
wantBadge: false,
|
|
},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
b := updateBadgeAt(c.stack, badgeNow)
|
|
if (b != nil) != c.wantBadge {
|
|
t.Fatalf("badge = %+v, want present=%v", b, c.wantBadge)
|
|
}
|
|
if b == nil {
|
|
return
|
|
}
|
|
if b.Label != c.wantLabel {
|
|
t.Errorf("label = %q, want %q", b.Label, c.wantLabel)
|
|
}
|
|
if b.Class != c.wantClass {
|
|
t.Errorf("class = %q, want %q", b.Class, c.wantClass)
|
|
}
|
|
if b.Title == "" {
|
|
t.Error("a badge that is only a word is a riddle — it must carry an explanation")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestGroupD_NoVersionNumberIsEverShown — the operator ruled it: a household cannot act on
|
|
// "26.05.2". Version strings stay in the logs, the API and the hub.
|
|
func TestGroupD_NoVersionNumberIsEverShown(t *testing.T) {
|
|
tpl := map[string]string{"web": "lscr.io/linuxserver/bookstack:26.05.2"}
|
|
for _, s := range []stacks.Stack{
|
|
ubStack(map[string]stacks.InstalledImage{"web": rec(tpl["web"])}, tpl, "2026-07-18"),
|
|
ubStack(map[string]stacks.InstalledImage{"web": rec("lscr.io/linuxserver/bookstack:25.02.2")}, tpl, "2026-07-18"),
|
|
} {
|
|
b := updateBadgeAt(s, badgeNow)
|
|
if b == nil {
|
|
t.Fatal("expected a badge")
|
|
}
|
|
for _, forbidden := range []string{"26.05.2", "25.02.2", "bookstack:", "mariadb", "sha256"} {
|
|
if strings.Contains(b.Label+b.Title, forbidden) {
|
|
t.Errorf("badge text leaks %q: label=%q title=%q", forbidden, b.Label, b.Title)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestGroupD_NothingIsBadgedThatCannotBeJudged — undeployed, protected and orphaned apps.
|
|
func TestGroupD_NothingIsBadgedThatCannotBeJudged(t *testing.T) {
|
|
tpl := map[string]string{"web": "nginx:1.27"}
|
|
inst := map[string]stacks.InstalledImage{"web": rec("nginx:1.26")}
|
|
for name, mutate := range map[string]func(*stacks.Stack){
|
|
"not deployed": func(s *stacks.Stack) { s.Deployed = false },
|
|
"protected": func(s *stacks.Stack) { s.Protected = true },
|
|
"orphaned": func(s *stacks.Stack) { s.Orphaned = true },
|
|
} {
|
|
s := ubStack(inst, tpl, "2026-07-18")
|
|
mutate(&s)
|
|
if b := updateBadgeAt(s, badgeNow); b != nil {
|
|
t.Errorf("%s: rendered %q — there is nothing to be current WITH", name, b.Label)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- GROUP D, rendered: the PRODUCTION templates ---
|
|
|
|
func ubAppInfoData(st stacks.Stack) map[string]interface{} {
|
|
return map[string]interface{}{
|
|
"Page": "stacks", "Title": st.Meta.DisplayName,
|
|
"Stack": &st, "Meta": st.Meta, "AppInfo": st.Meta.AppInfo,
|
|
"HasAppInfo": st.Meta.HasAppInfo(), "EffectiveSubdomain": st.Meta.Subdomain,
|
|
"Domain": "demo-felhom.eu",
|
|
}
|
|
}
|
|
|
|
func ubStacksData(st stacks.Stack) map[string]interface{} {
|
|
return map[string]interface{}{
|
|
"Page": "stacks", "Title": "Alkalmazások",
|
|
"Stacks": []stacks.Stack{st},
|
|
"MissingStorage": map[string]string{},
|
|
"NetworkWarnings": map[string]string{},
|
|
"NetworkStubs": map[string]string{},
|
|
"StorageLabels": map[string]string{},
|
|
"Subdomains": map[string]string{},
|
|
}
|
|
}
|
|
|
|
// TestGroupD_BadgeRendersOnBothSurfaces renders the REAL templates, which is the only thing that
|
|
// catches a template-time failure: `.Stack` reaches app_info as a *stacks.Stack, and a funcmap entry
|
|
// taking a VALUE has to be reachable from it. A compile-clean funcmap that 500s at render is exactly
|
|
// how v0.158.0's pointer-receiver defect shipped.
|
|
//
|
|
// COMPANION RED-PROOF (run 2026-09-02): delete the {{template "meta_badge" (updateBadge …)}} line
|
|
// from stacks.html and the "app list" sub-test fails; delete it from app_info.html and the "app page"
|
|
// sub-test fails. Reverted.
|
|
//
|
|
// THE CLOCK, and why `catalog_since` is computed rather than written down: the templates call the
|
|
// funcmap entry `updateBadge`, which uses time.Now() — the injected `badgeNow` reaches only the pure
|
|
// tests. A hardcoded date plus a hardcoded age is therefore a test that passes on the day it is
|
|
// written and FAILS THE NEXT MORNING, which is exactly what this one did (written 2026-09-02
|
|
// asserting "46 napja", red on 2026-09-03). Derive the date from the same clock the code will read.
|
|
func TestGroupD_BadgeRendersOnBothSurfaces(t *testing.T) {
|
|
const behindDays = 46
|
|
since := time.Now().UTC().AddDate(0, 0, -behindDays).Format("2006-01-02")
|
|
wantBehind := fmt.Sprintf("Frissítés elérhető — %d napja", behindDays)
|
|
|
|
tpl := map[string]string{"web": "lscr.io/linuxserver/bookstack:26.05.2"}
|
|
behind := ubStack(map[string]stacks.InstalledImage{"web": rec("lscr.io/linuxserver/bookstack:25.02.2")}, tpl, since)
|
|
current := ubStack(map[string]stacks.InstalledImage{"web": rec(tpl["web"])}, tpl, since)
|
|
legacy := ubStack(nil, tpl, since)
|
|
|
|
for _, surface := range []struct {
|
|
name string
|
|
data func(stacks.Stack) map[string]interface{}
|
|
tmpl string
|
|
}{
|
|
{"app page", ubAppInfoData, "app_info"},
|
|
{"app list", ubStacksData, "stacks"},
|
|
} {
|
|
t.Run(surface.name, func(t *testing.T) {
|
|
h := renderBackupPage(t, surface.tmpl, surface.data(behind))
|
|
if !strings.Contains(h, wantBehind) {
|
|
t.Errorf("the behind badge is missing from %s", surface.tmpl)
|
|
}
|
|
h = renderBackupPage(t, surface.tmpl, surface.data(current))
|
|
if !strings.Contains(h, "Naprakész") {
|
|
t.Errorf("the current badge is missing from %s", surface.tmpl)
|
|
}
|
|
// The negative half. Without it, an implementation that badges everything passes.
|
|
h = renderBackupPage(t, surface.tmpl, surface.data(legacy))
|
|
if strings.Contains(h, "Naprakész") || strings.Contains(h, "Frissítés elérhető") {
|
|
t.Errorf("an app with NO RECORD must be badged with NOTHING on %s", surface.tmpl)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// --- SCENARIO E: nothing about updating changed ---
|
|
|
|
// TestScenarioE_TheUpdateButtonIsUntouched. This slice is information only. The badge must be wired
|
|
// to no action, and the three lifecycle buttons must render exactly as they did before it existed.
|
|
func TestScenarioE_TheUpdateButtonIsUntouched(t *testing.T) {
|
|
tpl := map[string]string{"web": "nginx:1.27"}
|
|
states := map[string]stacks.Stack{
|
|
"current": ubStack(map[string]stacks.InstalledImage{"web": rec("nginx:1.27")}, tpl, "2026-07-18"),
|
|
"behind": ubStack(map[string]stacks.InstalledImage{"web": rec("nginx:1.26")}, tpl, "2026-07-18"),
|
|
"behind/na": ubStack(map[string]stacks.InstalledImage{"web": rec("nginx:1.26")}, tpl, ""),
|
|
"no record": ubStack(nil, tpl, "2026-07-18"),
|
|
}
|
|
for name, st := range states {
|
|
h := renderBackupPage(t, "stacks", ubStacksData(st))
|
|
for _, want := range []string{
|
|
`stackAction(event, 'bookstack', 'update')`,
|
|
`stackAction(event, 'bookstack', 'restart')`,
|
|
`stackAction(event, 'bookstack', 'stop')`,
|
|
} {
|
|
if !strings.Contains(h, want) {
|
|
t.Errorf("%s: the %q button changed — this slice must change no behaviour", name, want)
|
|
}
|
|
}
|
|
// No new action may hang off the badge.
|
|
if strings.Contains(h, "updateBadgeAction") || strings.Contains(h, "'autoupdate'") {
|
|
t.Errorf("%s: the badge must be wired to NOTHING", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- GROUP F: catalog_since tolerance ---
|
|
|
|
// TestGroupF_CatalogSinceTolerance — absent, empty, malformed and FUTURE all degrade to "no age
|
|
// known" and never brick the badge. The future case is not pedantry: a box whose clock is behind the
|
|
// catalog would otherwise print "-3 napja".
|
|
func TestGroupF_CatalogSinceTolerance(t *testing.T) {
|
|
for _, since := range []string{"", " ", "tegnap", "18/07/2026", "2026-13-45", "2026-12-31"} {
|
|
m := stacks.Metadata{CatalogSince: since}
|
|
if days, ok := m.CatalogSinceAge(badgeNow); ok {
|
|
t.Errorf("catalog_since %q must be UNUSABLE, got %d napja", since, days)
|
|
}
|
|
tpl := map[string]string{"web": "nginx:1.27"}
|
|
s := ubStack(map[string]stacks.InstalledImage{"web": rec("nginx:1.26")}, tpl, since)
|
|
b := updateBadgeAt(s, badgeNow)
|
|
if b == nil {
|
|
t.Fatalf("catalog_since %q: the badge must still render, just without an age", since)
|
|
}
|
|
if b.Label != "Frissítés elérhető" {
|
|
t.Errorf("catalog_since %q: label = %q, want the age-less form", since, b.Label)
|
|
}
|
|
}
|
|
// And the usable cases.
|
|
for since, want := range map[string]int{"2026-09-02": 0, "2026-09-01": 1, "2026-07-18": 46} {
|
|
got, ok := stacks.Metadata{CatalogSince: since}.CatalogSinceAge(badgeNow)
|
|
if !ok || got != want {
|
|
t.Errorf("catalog_since %q → (%d, %v), want (%d, true)", since, got, ok, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- GROUP G (v0.235.0): the badge must read the CATALOG, not the rendered file ---
|
|
|
|
// TestGroupG_FrozenAppStillReadsBehind is the test that stops slice 3 from silently inverting slice 2.
|
|
//
|
|
// After the freeze, a pinned app whose version the catalog has moved past has its LIVE
|
|
// docker-compose.yml rendered from its own stored definition — so that file names the OLD version.
|
|
// A comparison against it finds installed == template and answers „Naprakész" on precisely the apps
|
|
// that are behind. The two fields have the same type and shape, so nothing but this test catches it.
|
|
//
|
|
// COMPANION RED-PROOF 3 (run 2026-09-06): point compareInstalledToTemplate back at
|
|
// s.TemplateImages. This test then fails with „Naprakész" on a frozen app. Reverted.
|
|
func TestGroupG_FrozenAppStillReadsBehind(t *testing.T) {
|
|
since := time.Now().UTC().AddDate(0, 0, -46).Format("2006-01-02")
|
|
frozen := ubFrozenStack("nextcloud:31.0.14-apache", "nextcloud:34.0.1-apache", since)
|
|
|
|
b := updateBadgeAt(frozen, time.Now().UTC())
|
|
if b == nil {
|
|
t.Fatal("a frozen, behind app must carry a badge")
|
|
}
|
|
if b.Label == "Naprakész" {
|
|
t.Fatal("THE FEATURE IS INVERTED: the comparison read the frozen live file instead of the catalog")
|
|
}
|
|
if !strings.HasPrefix(b.Label, "Frissítés elérhető") || b.Class != "tag-warn" {
|
|
t.Fatalf("label = %q class = %q", b.Label, b.Class)
|
|
}
|
|
|
|
// And it renders that way on the real page, not just in the pure function.
|
|
html := renderBackupPage(t, "stacks", ubStacksData(frozen))
|
|
if !strings.Contains(html, "Frissítés elérhető") {
|
|
t.Error("the frozen app must be badged as behind on the app list")
|
|
}
|
|
if strings.Contains(html, "Naprakész") {
|
|
t.Error("a frozen, behind app must never render Naprakész")
|
|
}
|
|
}
|
|
|
|
// TestGroupG_NoCatalogEntryRendersNothing — an orphaned app, or a box whose catalog cache is missing,
|
|
// cannot be judged. Absent is unknown; it is never „Naprakész".
|
|
func TestGroupG_NoCatalogEntryRendersNothing(t *testing.T) {
|
|
st := ubStack(map[string]stacks.InstalledImage{"web": rec("nginx:1.27")},
|
|
map[string]string{"web": "nginx:1.27"}, "2026-07-18")
|
|
st.CatalogImages = nil // the catalog cache could not be read
|
|
if b := updateBadgeAt(st, badgeNow); b != nil {
|
|
t.Fatalf("no readable catalog template must render NOTHING, got %q", b.Label)
|
|
}
|
|
}
|