Files
felhom-controller/controller/internal/stacks/classify_binds_test.go
T
admin 0649f9a3e6 Backup classification: schema + parser + pure classifier (INERT, v0.132.0)
Task 2 of the backup-classification-redesign arc. Ships the referential-
coupling classification as DATA + PARSER + PURE CLASSIFIER, deliberately
inert — no backup tier changes behavior. Task 3 (tier policy engine) and
Task 4 (manual .fab UI) consume it.

- appbackup/classify.go: BackupSpec/BindSpec/ComposeBind/ClassifiedBind;
  ClassifyBinds (SQ5 two-level default — explicit beats :ro; unlisted
  writable→mandatory, unlisted :ro→excluded; nil spec→legacy/false);
  ValidateBackupSpec (whole-block-reject on any defect, first defect named).
- stacks/classify_binds.go: ParseComposeClassifiableBinds — ${VAR}-relative
  binds + :ro flag (NOT ParseComposeHDDMounts/ExportDataMounts, the traps).
- Metadata.Backup + LoadMetadata as the single validation choke point (bad
  catalog block → nil + one ERROR → legacy, within one sync cycle).
- Manager.ClassifiedBinds + StackDataProvider.GetStackClassifiedBinds seam
  (delegated by stackAdapter, nil-stubbed in every fake) — wired + tested
  now so Task 3 consumes a tested seam.

INERT: full pre-existing suite green with zero test-logic edits. +14 tests;
red-proofs RP-1..RP-4 confirmed. The 13 catalog backup: blocks ship in the
same app-catalog change (this controller deploys first).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A45Qop8YY8tS94bz63LFne
2026-07-14 18:46:32 +02:00

105 lines
3.8 KiB
Go

package stacks
import (
"os"
"path/filepath"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
)
func writeClassCompose(t *testing.T, body string) string {
t.Helper()
dir := t.TempDir()
p := filepath.Join(dir, "docker-compose.yml")
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
return p
}
func findBind(binds []appbackup.ComposeBind, root appbackup.BindRoot, rel string) (appbackup.ComposeBind, bool) {
for _, b := range binds {
if b.Root == root && b.RelPath == rel {
return b, true
}
}
return appbackup.ComposeBind{}, false
}
// TestParseClassifiableBinds_Variants (Group C): short-syntax variants, quotes, mode tokens
// (ro/rw/ro,z/z), dedupe, bare root, both roots, and non-volume sections ignored.
func TestParseClassifiableBinds_Variants(t *testing.T) {
compose := `
services:
app:
image: x:1
environment:
- HDD_PATH=${HDD_PATH} # NOT a volume — must be ignored
volumes:
- ${HDD_PATH}/appdata/immich:/upload
- "${USERDATA_PATH}/media/photos:/external:ro"
- ${USERDATA_PATH}/media/music:/music:ro,z
- ${USERDATA_PATH}/downloads:/dl:z
- ${USERDATA_PATH}/downloads:/dl2 # duplicate (root,relpath) — deduped, first wins
- ${HDD_PATH}:/hddroot # bare root
- ./local-only:/x # not a classifiable var — ignored
- named_vol:/data # named volume — ignored
ports:
- 8080:80 # ports section — not volumes
`
binds := ParseComposeClassifiableBinds(writeClassCompose(t, compose))
// Expect exactly: hdd/appdata/immich, userdata/media/photos(ro), userdata/media/music(ro),
// userdata/downloads(writable, first occ), hdd/"" (bare root).
if len(binds) != 5 {
t.Fatalf("got %d binds, want 5: %+v", len(binds), binds)
}
if b, ok := findBind(binds, appbackup.RootHDD, "appdata/immich"); !ok || b.ReadOnly {
t.Errorf("appdata/immich = %+v, want writable", b)
}
if b, ok := findBind(binds, appbackup.RootUserdata, "media/photos"); !ok || !b.ReadOnly {
t.Errorf("media/photos = %+v, want ro", b)
}
if b, ok := findBind(binds, appbackup.RootUserdata, "media/music"); !ok || !b.ReadOnly {
t.Errorf("media/music (ro,z) = %+v, want ro", b)
}
// downloads: first occurrence was `:z` (writable) — dedupe keeps the first, so writable wins.
if b, ok := findBind(binds, appbackup.RootUserdata, "downloads"); !ok || b.ReadOnly {
t.Errorf("downloads = %+v, want single writable entry (first-wins dedupe)", b)
}
if b, ok := findBind(binds, appbackup.RootHDD, ""); !ok || b.ReadOnly {
t.Errorf("bare ${HDD_PATH} = %+v, want RelPath '' writable", b)
}
}
// TestParseClassifiableBinds_CleanAndSubpaths: nested paths clean correctly; a false-prefix var
// (${HDD_PATH_X}) does NOT match.
func TestParseClassifiableBinds_CleanAndSubpaths(t *testing.T) {
compose := `
services:
app:
volumes:
- ${HDD_PATH}/appdata/paperless/media:/m
- ${HDD_PATH_EXTRA}/nope:/n # false prefix — must NOT match ${HDD_PATH}
- ${USERDATA_PATH}/import/calibre:/i:rw
`
binds := ParseComposeClassifiableBinds(writeClassCompose(t, compose))
if len(binds) != 2 {
t.Fatalf("got %d binds, want 2 (false-prefix excluded): %+v", len(binds), binds)
}
if _, ok := findBind(binds, appbackup.RootHDD, "appdata/paperless/media"); !ok {
t.Errorf("nested hdd path missing: %+v", binds)
}
if b, ok := findBind(binds, appbackup.RootUserdata, "import/calibre"); !ok || b.ReadOnly {
t.Errorf("import/calibre (rw) = %+v, want writable", b)
}
}
// TestParseClassifiableBinds_Missing: an unreadable compose yields nil (no panic).
func TestParseClassifiableBinds_Missing(t *testing.T) {
if b := ParseComposeClassifiableBinds(filepath.Join(t.TempDir(), "nope.yml")); b != nil {
t.Errorf("missing compose should yield nil, got %+v", b)
}
}