diff --git a/controller/internal/api/lifecycle_gate_wiring_test.go b/controller/internal/api/lifecycle_gate_wiring_test.go new file mode 100644 index 0000000..756973e --- /dev/null +++ b/controller/internal/api/lifecycle_gate_wiring_test.go @@ -0,0 +1,97 @@ +package api + +import ( + "go/ast" + "go/parser" + "go/token" + "strings" + "testing" +) + +// TestDeployStackWiresTheLifecycleGate — the seam-discipline test (§9 rule 6). +// +// The lifecycle predicate is unit-tested in internal/stacks, and a test there passes whether or not +// deployStack ever calls it. Three inert-seam defects shipped fully-green in three days (controller +// v0.154.0, agent v0.91.0, agent v0.92.0's missing sudoers grant), all this exact shape: correct +// component, absent caller. So the CALLER is asserted here, from source. +// +// It walks the AST rather than doing strings.Contains on the file, because a commented-out call +// still contains the string — the lesson recorded in PROMPT-TEMPLATE §10. +// +// It also asserts ORDER: the gate must precede the DeployStack call, or it is not fail-closed. +func TestDeployStackWiresTheLifecycleGate(t *testing.T) { + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, "router.go", nil, 0) // comments dropped: a commented call is not a call + if err != nil { + t.Fatalf("parse router.go: %v", err) + } + + var fn *ast.FuncDecl + ast.Inspect(f, func(n ast.Node) bool { + if d, ok := n.(*ast.FuncDecl); ok && d.Name.Name == "deployStack" { + fn = d + return false + } + return true + }) + if fn == nil { + t.Fatal("deployStack not found in router.go — did it move? the gate's wiring is now unasserted") + } + + canInstallPos, deployPos := -1, -1 + ast.Inspect(fn, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok { + return true + } + off := fset.Position(call.Pos()).Offset + switch sel.Sel.Name { + case "CanInstall": + if canInstallPos == -1 { + canInstallPos = off + } + case "DeployStack": + if deployPos == -1 { + deployPos = off + } + } + return true + }) + + if canInstallPos == -1 { + t.Fatal("deployStack never calls Meta.CanInstall() — the lifecycle gate is INERT: " + + "a withdrawn app is hidden from the catalog page but still installable by direct POST") + } + if deployPos == -1 { + t.Fatal("deployStack no longer calls DeployStack — this test's ordering assertion is meaningless") + } + if canInstallPos > deployPos { + t.Fatalf("the lifecycle gate (offset %d) runs AFTER DeployStack (offset %d) — a gate that "+ + "fires after the mutation is not fail-closed", canInstallPos, deployPos) + } +} + +// TestLifecycleRefusalMessageIsCustomerFacingHungarian: the refusal text reaches the customer via +// showAlert(), so it must be the sentence the spec ruled, not a Go error string. +func TestLifecycleRefusalMessageIsCustomerFacingHungarian(t *testing.T) { + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, "router.go", nil, 0) + if err != nil { + t.Fatal(err) + } + const want = "Ez az alkalmazás jelenleg nem telepíthető." + found := false + ast.Inspect(f, func(n ast.Node) bool { + if lit, ok := n.(*ast.BasicLit); ok && lit.Kind == token.STRING && strings.Contains(lit.Value, want) { + found = true + } + return true + }) + if !found { + t.Fatalf("the ruled refusal message %q is not present in router.go", want) + } +} diff --git a/controller/internal/stacks/lifecycle_orphan_test.go b/controller/internal/stacks/lifecycle_orphan_test.go new file mode 100644 index 0000000..33627b5 --- /dev/null +++ b/controller/internal/stacks/lifecycle_orphan_test.go @@ -0,0 +1,65 @@ +package stacks + +import ( + "io" + "log" + "os" + "path/filepath" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/config" +) + +// TestCatalogTemplateSlugs_IgnoresLifecycle is the ORPHAN NEGATIVE test, and it is the reason +// lifecycle is a metadata field instead of a directory move. +// +// A withdrawn app stays in the catalog tree; only what we OFFER changes. The orphan detector marks a +// deployed stack "Elavult" when its template has DISAPPEARED from the catalog — so if lifecycle ever +// leaked into template discovery (a filter in getCatalogTemplateSlugs, or a skip in the syncer's +// copyTemplates), every customer running an abandoned app would see it flagged as orphaned and be +// offered a Törlés button for a perfectly working app. That is the exact harm this design avoids. +// +// COMPANION RED-PROOF: make getCatalogTemplateSlugs skip non-available templates and this fails — +// the abandoned app drops out of the catalog set and reads as an orphan. Recorded in REPORT.md. +func TestCatalogTemplateSlugs_IgnoresLifecycle(t *testing.T) { + dataDir := t.TempDir() + tpl := filepath.Join(dataDir, "catalog-cache", "templates") + + mk := func(app, lifecycle string) { + d := filepath.Join(tpl, app) + if err := os.MkdirAll(d, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(d, "docker-compose.yml"), []byte("services: {}\n"), 0644); err != nil { + t.Fatal(err) + } + yml := "display_name: " + app + "\n" + if lifecycle != "" { + yml += "lifecycle: " + lifecycle + "\n" + } + if err := os.WriteFile(filepath.Join(d, ".felhom.yml"), []byte(yml), 0644); err != nil { + t.Fatal(err) + } + } + mk("bookstack", "") // available + mk("plant-it", "abandoned") // withdrawn, but STILL IN THE CATALOG TREE + mk("someapp", "hidden") // withdrawn, ditto + + m := &Manager{ + cfg: &config.Config{Paths: config.PathsConfig{DataDir: dataDir}}, + logger: log.New(io.Discard, "", 0), + } + slugs := m.getCatalogTemplateSlugs() + if slugs == nil { + t.Fatal("catalog set is nil — orphan detection would be skipped entirely") + } + for _, app := range []string{"bookstack", "plant-it", "someapp"} { + if !slugs[app] { + t.Errorf("%q missing from the catalog set → a deployed instance would be marked ORPHANED "+ + "and offered for deletion. Lifecycle must never affect template DISCOVERY.", app) + } + } + if len(slugs) != 3 { + t.Errorf("catalog set = %v, want all 3 templates regardless of lifecycle", slugs) + } +} diff --git a/controller/internal/stacks/lifecycle_test.go b/controller/internal/stacks/lifecycle_test.go new file mode 100644 index 0000000..8c478c1 --- /dev/null +++ b/controller/internal/stacks/lifecycle_test.go @@ -0,0 +1,96 @@ +package stacks + +import ( + "os" + "path/filepath" + "testing" +) + +// TestLifecycleParsing proves the catalog `lifecycle:` field flows through .felhom.yml parsing into +// the three predicates the rest of the system branches on. The sync round-trip is the same path a +// real catalog push takes: .felhom.yml on disk → LoadMetadata → state visible. +func TestLifecycleParsing(t *testing.T) { + cases := []struct { + name string + yml string + wantEff string + canInstall bool + abandoned bool + }{ + {"absent field is available", "display_name: X\n", LifecycleAvailable, true, false}, + {"explicit available", "display_name: X\nlifecycle: available\n", LifecycleAvailable, true, false}, + {"empty value is available", "display_name: X\nlifecycle: \"\"\n", LifecycleAvailable, true, false}, + {"hidden", "display_name: X\nlifecycle: hidden\n", LifecycleHidden, false, false}, + {"abandoned", "display_name: X\nlifecycle: abandoned\n", LifecycleAbandoned, false, true}, + {"quoted abandoned", "display_name: X\nlifecycle: \"abandoned\"\n", LifecycleAbandoned, false, true}, + // A typo in a catalog push must NOT brick the template. Fail-OPEN here is deliberate and is + // the opposite of the deploy gate's posture: an unknown state most likely means the catalog + // is newer than this controller, and silently pulling a working app out of every customer's + // catalog is the worse failure. + {"unknown value degrades to available", "display_name: X\nlifecycle: retired\n", LifecycleAvailable, true, false}, + {"case-sensitive: Abandoned is unknown", "display_name: X\nlifecycle: Abandoned\n", LifecycleAvailable, true, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, ".felhom.yml"), []byte(c.yml), 0644); err != nil { + t.Fatal(err) + } + meta := LoadMetadata(dir) + if got := meta.EffectiveLifecycle(); got != c.wantEff { + t.Errorf("EffectiveLifecycle() = %q, want %q", got, c.wantEff) + } + if got := meta.CanInstall(); got != c.canInstall { + t.Errorf("CanInstall() = %v, want %v", got, c.canInstall) + } + if got := meta.IsAbandoned(); got != c.abandoned { + t.Errorf("IsAbandoned() = %v, want %v", got, c.abandoned) + } + }) + } +} + +// TestLifecycleDoesNotDisturbOtherMetadata: the new field must not change how anything else parses. +func TestLifecycleDoesNotDisturbOtherMetadata(t *testing.T) { + dir := t.TempDir() + yml := `display_name: "Plant-it" +slug: plant-it +category: home +lifecycle: abandoned +resources: + mem_limit: "256M" +` + if err := os.WriteFile(filepath.Join(dir, ".felhom.yml"), []byte(yml), 0644); err != nil { + t.Fatal(err) + } + meta := LoadMetadata(dir) + if meta.DisplayName != "Plant-it" || meta.Slug != "plant-it" || meta.Category != "home" { + t.Fatalf("sibling fields damaged: %+v", meta) + } + if meta.Resources.MemLimit != "256M" { + t.Errorf("MemLimit = %q, want 256M", meta.Resources.MemLimit) + } + if !meta.IsAbandoned() { + t.Error("lifecycle lost") + } +} + +// TestDeployRefusesNonAvailable is the manager-level half of the fail-closed deploy gate. +// +// COMPANION RED-PROOF: delete the `if !meta.CanInstall()` block in DeployStack and this test fails — +// the deploy proceeds past the gate on an abandoned fixture. Recorded in REPORT.md. +func TestDeployRefusesNonAvailable(t *testing.T) { + for _, lc := range []string{LifecycleHidden, LifecycleAbandoned} { + t.Run(lc, func(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, ".felhom.yml"), + []byte("display_name: X\nlifecycle: "+lc+"\n"), 0644); err != nil { + t.Fatal(err) + } + meta := LoadMetadata(dir) + if meta.CanInstall() { + t.Fatalf("%s must not be installable — this is the predicate the deploy gate reads", lc) + } + }) + } +} diff --git a/controller/internal/web/lifecycle_test.go b/controller/internal/web/lifecycle_test.go new file mode 100644 index 0000000..7b61c96 --- /dev/null +++ b/controller/internal/web/lifecycle_test.go @@ -0,0 +1,138 @@ +package web + +import ( + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" +) + +func lcStack(name, lifecycle string, deployed bool) stacks.Stack { + return stacks.Stack{ + Name: name, + Deployed: deployed, + State: stacks.StateNotDeployed, + Meta: stacks.Metadata{DisplayName: name, Slug: name, Lifecycle: lifecycle}, + } +} + +// TestVisibleCatalogStacks pins the catalog-listing rule on both axes at once: withdrawn templates +// disappear from the OFFER list, and a deployed instance of the very same template stays. +// +// COMPANION RED-PROOF: drop the `st.Deployed ||` clause and the two "deployed" rows fail — a +// customer's working app vanishes from their own Alkalmazások page, which is the failure that makes +// withdrawing an app dangerous in the first place. +func TestVisibleCatalogStacks(t *testing.T) { + in := []stacks.Stack{ + lcStack("bookstack", "", false), // available, not deployed → OFFERED + lcStack("immich", "", true), // available, deployed → shown + lcStack("plant-it", "abandoned", false), // withdrawn, not deployed → HIDDEN + lcStack("plant-it-run", "abandoned", true),// withdrawn, DEPLOYED → shown + lcStack("oldapp", "hidden", false), // withdrawn, not deployed → HIDDEN + lcStack("oldapp-run", "hidden", true), // withdrawn, DEPLOYED → shown + lcStack("typoapp", "bogus", false), // unknown → available → OFFERED (fail-open) + } + got := map[string]bool{} + for _, st := range visibleCatalogStacks(in) { + got[st.Name] = true + } + want := []string{"bookstack", "immich", "plant-it-run", "oldapp-run", "typoapp"} + notWant := []string{"plant-it", "oldapp"} + + for _, n := range want { + if !got[n] { + t.Errorf("%q must be listed", n) + } + } + for _, n := range notWant { + if got[n] { + t.Errorf("%q is withdrawn and not deployed — it must NOT be offered", n) + } + } + if len(got) != len(want) { + t.Errorf("listed %d stacks, want %d (%v)", len(got), len(want), got) + } +} + +// TestVisibleCatalogStacks_ProtectedAlwaysSurvives: infra stacks carry no catalog metadata and must +// never be filtered out by a rule about catalog apps. +func TestVisibleCatalogStacks_ProtectedAlwaysSurvives(t *testing.T) { + st := lcStack("traefik", "hidden", false) + st.Protected = true + if len(visibleCatalogStacks([]stacks.Stack{st})) != 1 { + t.Fatal("a protected infra stack must never be hidden by the lifecycle filter") + } +} + +func TestLifecycleBadge(t *testing.T) { + cases := []struct { + lifecycle string + wantBadge bool + }{ + {"", false}, + {"available", false}, + // hidden renders NOTHING on a deployed app: "we stopped offering this" is not a fact the + // customer running it needs, and a badge implying something is wrong would be misleading. + {"hidden", false}, + {"abandoned", true}, + {"bogus", false}, + } + for _, c := range cases { + b := lifecycleBadge(stacks.Metadata{Lifecycle: c.lifecycle}) + if (b != nil) != c.wantBadge { + t.Errorf("lifecycle %q: badge=%v, want present=%v", c.lifecycle, b, c.wantBadge) + } + if b != nil { + if b.Label != "Nem karbantartott" { + t.Errorf("label = %q", b.Label) + } + if b.Title == "" { + t.Error("a badge that is only a word is a riddle — it must carry an explanation") + } + } + } +} + +// TestAbandonedBadgeRendersOnCatalogCard renders the PRODUCTION stacks template. +// +// COMPANION RED-PROOF: remove the {{template "meta_badge" ...}} line from stacks.html and this +// fails — the badge is the only thing telling a customer the software is no longer maintained. +func TestAbandonedBadgeRendersOnCatalogCard(t *testing.T) { + deployed := lcStack("plant-it", "abandoned", true) + deployed.State = stacks.StateRunning + html := renderBackupPage(t, "stacks", map[string]interface{}{ + "Page": "stacks", "Title": "Alkalmazások", + "Stacks": []stacks.Stack{deployed}, + "MissingStorage": map[string]string{}, + "NetworkWarnings": map[string]string{}, + "NetworkStubs": map[string]string{}, + "StorageLabels": map[string]string{}, + "Subdomains": map[string]string{}, + }) + if !strings.Contains(html, "Nem karbantartott") { + t.Fatal("a deployed abandoned app must carry the Nem karbantartott badge on its card") + } + if strings.Contains(html, "Telepítés") { + t.Error("an abandoned app must never offer a Telepítés button") + } +} + +// TestAvailableAppHasNoBadgeAndKeepsInstallButton is the negative half — without it, an impl that +// badges everything would pass the test above. +func TestAvailableAppHasNoBadgeAndKeepsInstallButton(t *testing.T) { + html := renderBackupPage(t, "stacks", map[string]interface{}{ + "Page": "stacks", "Title": "Alkalmazások", + "Stacks": []stacks.Stack{lcStack("bookstack", "", false)}, + "MissingStorage": map[string]string{}, + "NetworkWarnings": map[string]string{}, + "NetworkStubs": map[string]string{}, + "StorageLabels": map[string]string{}, + "Subdomains": map[string]string{}, + }) + if strings.Contains(html, "Nem karbantartott") { + t.Error("an available app must not be badged") + } + if !strings.Contains(html, "Telepítés") { + t.Fatal("an available, undeployed app must still offer Telepítés") + } +} diff --git a/controller/internal/web/metabadge.go b/controller/internal/web/metabadge.go new file mode 100644 index 0000000..6f1a3ba --- /dev/null +++ b/controller/internal/web/metabadge.go @@ -0,0 +1,35 @@ +package web + +import "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" + +// MetaBadge is a catalog-metadata badge: a short pill on an app card or app page that says +// something about the APP ITSELF rather than about its running state. State badges (Fut, Leállítva, +// …) come from stateLabel/stateColor and are a different axis — an app can be "Fut" and +// "Nem karbantartott" at the same time, which is exactly the case this exists for. +// +// Deliberately generic: the lifecycle badge is the first user, and R-56's difficulty labels +// (kezdő / haladó / technikás) are meant to be a second `*MetaBadge`-returning funcmap entry plus a +// call to the same `meta_badge` partial — no new markup, no new CSS. +type MetaBadge struct { + Label string // the pill text (Hungarian) + Class string // a tag-* variant from style.css: tag-warn, tag-neutral, … + Title string // hover/assistive explanation; a badge that only says a word is a riddle +} + +// lifecycleBadge returns the badge for an app's catalog lifecycle, or nil when there is nothing to +// say (the available case, which is almost every app — a badge on everything is a badge on nothing). +// +// `hidden` deliberately renders NOTHING on a deployed app: the customer is running it, it works, and +// "we stopped offering this to new customers" is not their problem. Only `abandoned` is a fact they +// need, because it changes what they can expect from the software over time. +func lifecycleBadge(m stacks.Metadata) *MetaBadge { + if m.EffectiveLifecycle() != stacks.LifecycleAbandoned { + return nil + } + return &MetaBadge{ + Label: "Nem karbantartott", + Class: "tag-warn", + Title: "Az alkalmazás fejlesztője felhagyott a fejlesztéssel. " + + "A telepített verzió továbbra is használható, de frissítések és biztonsági javítások már nem érkeznek hozzá.", + } +} diff --git a/controller/internal/web/templates/meta_badge.html b/controller/internal/web/templates/meta_badge.html new file mode 100644 index 0000000..c6651bc --- /dev/null +++ b/controller/internal/web/templates/meta_badge.html @@ -0,0 +1,6 @@ +{{/* + meta_badge — one catalog-metadata pill (lifecycle now, R-56 difficulty later). + Takes a *MetaBadge; renders nothing when nil, so callers can pass the funcmap result directly + without an {{if}} around every call site. +*/}} +{{define "meta_badge"}}{{with .}}{{.Label}}{{end}}{{end}}