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,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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user