0649f9a3e6
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
105 lines
3.8 KiB
Go
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)
|
|
}
|
|
}
|