catalog: app lifecycle states - available/hidden/abandoned (v0.158.0)
The catalog knew only 'present' or 'gone', and 'gone' orphans every customer already running the app. lifecycle: in .felhom.yml withdraws an app from new installs without touching anyone running it. Deploy gate is server-side and fail-closed, before any mutation, with the ruled Hungarian refusal - hiding a button is not a gate. Unknown values fail OPEN (available + one WARN), deliberately opposite, so a typo or a newer catalog cannot pull a working app out of every customer's list. Orphan detection never sees the field - a red-proof adds that filter and shows the abandoned app immediately reading as an orphan. Badge plumbing is generic (MetaBadge + meta_badge partial) so R-56's difficulty labels drop in with no new markup.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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</a>") {
|
||||
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</a>") {
|
||||
t.Fatal("an available, undeployed app must still offer Telepítés")
|
||||
}
|
||||
}
|
||||
@@ -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á.",
|
||||
}
|
||||
}
|
||||
@@ -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 .}}<span class="tag {{.Class}}" title="{{.Title}}">{{.Label}}</span>{{end}}{{end}}
|
||||
Reference in New Issue
Block a user