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
218 lines
6.4 KiB
Go
218 lines
6.4 KiB
Go
package stacks
|
|
|
|
import (
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"testing"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
|
)
|
|
|
|
// newClassifyManager registers a single stack "app" whose .felhom.yml + docker-compose.yml are
|
|
// written to a temp dir, so Manager.ClassifiedBinds runs the REAL LoadMetadata → validate → classify
|
|
// path end-to-end (NO seams) — the F-S3 lesson: the wiring is where typos hide.
|
|
func newClassifyManager(t *testing.T, felhomYML, compose string) *Manager {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(dir, ".felhom.yml"), []byte(felhomYML), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
composePath := filepath.Join(dir, "docker-compose.yml")
|
|
if err := os.WriteFile(composePath, []byte(compose), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return &Manager{
|
|
cfg: &config.Config{},
|
|
logger: log.New(os.Stderr, "", 0),
|
|
mu: sync.RWMutex{},
|
|
stacks: map[string]*Stack{"app": {Name: "app", ComposePath: composePath}},
|
|
}
|
|
}
|
|
|
|
func classSet(cbs []appbackup.ClassifiedBind) map[string]appbackup.BindClass {
|
|
m := map[string]appbackup.BindClass{}
|
|
for _, c := range cbs {
|
|
m[string(c.Root)+"/"+c.RelPath] = c.Class
|
|
}
|
|
return m
|
|
}
|
|
|
|
// --- Group D: end-to-end wiring (NO seams) ---
|
|
|
|
// TestClassifiedBinds_ValidFixture (Group D, immich shape): a clean block resolves through the REAL
|
|
// LoadMetadata path to the expected explicit classes.
|
|
func TestClassifiedBinds_ValidFixture(t *testing.T) {
|
|
felhom := `display_name: Immich
|
|
backup:
|
|
hdd:
|
|
- path: appdata/immich
|
|
class: mandatory
|
|
userdata:
|
|
- path: media/photos
|
|
class: optional
|
|
`
|
|
compose := `services:
|
|
immich:
|
|
volumes:
|
|
- ${HDD_PATH}/appdata/immich:/usr/src/app/upload
|
|
- ${USERDATA_PATH}/media/photos:/external/photos:ro
|
|
`
|
|
m := newClassifyManager(t, felhom, compose)
|
|
cbs, has := m.ClassifiedBinds("app")
|
|
if !has {
|
|
t.Fatal("valid block must classify (hasClassification=true)")
|
|
}
|
|
got := classSet(cbs)
|
|
if got["hdd/appdata/immich"] != appbackup.ClassMandatory {
|
|
t.Errorf("appdata/immich = %q, want mandatory", got["hdd/appdata/immich"])
|
|
}
|
|
if got["userdata/media/photos"] != appbackup.ClassOptional {
|
|
t.Errorf("media/photos = %q, want optional (explicit beats :ro default)", got["userdata/media/photos"])
|
|
}
|
|
}
|
|
|
|
// TestClassifiedBinds_RejectedFixture (Group D + RP-4): a block whose entry matches no compose bind
|
|
// is rejected by the REAL LoadMetadata choke point → the app degrades to legacy (hasClassification
|
|
// false, no class semantics). Companion RP-4: if LoadMetadata accepts meta.Backup WITHOUT validating,
|
|
// this returns hasClassification=true and the assertion FAILS.
|
|
func TestClassifiedBinds_RejectedFixture(t *testing.T) {
|
|
felhom := `display_name: Sonarr
|
|
backup:
|
|
userdata:
|
|
- path: media/tvv
|
|
class: excluded
|
|
`
|
|
compose := `services:
|
|
sonarr:
|
|
volumes:
|
|
- ${USERDATA_PATH}/media/tv:/tv
|
|
`
|
|
m := newClassifyManager(t, felhom, compose)
|
|
cbs, has := m.ClassifiedBinds("app")
|
|
if has {
|
|
t.Fatal("a block with an unmatched path must be REJECTED → legacy (hasClassification=false)")
|
|
}
|
|
// The real bind is still returned, but as legacy (no class) — never silently shifted to a default.
|
|
for _, c := range cbs {
|
|
if c.Origin != appbackup.OriginLegacy || c.Class != "" {
|
|
t.Errorf("rejected-block bind = %+v, want origin=legacy, empty class", c)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestClassifiedBinds_NoBlockLegacy: an app with no backup block classifies as legacy (inertness).
|
|
func TestClassifiedBinds_NoBlockLegacy(t *testing.T) {
|
|
felhom := `display_name: Navidrome`
|
|
compose := `services:
|
|
nav:
|
|
volumes:
|
|
- ${USERDATA_PATH}/media/music:/music:ro
|
|
`
|
|
m := newClassifyManager(t, felhom, compose)
|
|
if _, has := m.ClassifiedBinds("app"); has {
|
|
t.Error("no backup block → hasClassification must be false (legacy)")
|
|
}
|
|
}
|
|
|
|
// --- Group E: catalog fidelity (≥3 real blocks) ---
|
|
|
|
// TestCatalogBlocks_Fidelity pins the exact class sets of representative Part-4 blocks (immich,
|
|
// paperless-ngx, sonarr) against representative composes — the same content committed to the catalog.
|
|
// The full 13-app proof is the Part-4 cross-check against the live catalog clone (§13 + REPORT).
|
|
func TestCatalogBlocks_Fidelity(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
felhom string
|
|
compose string
|
|
want map[string]appbackup.BindClass
|
|
}{
|
|
{
|
|
name: "immich",
|
|
felhom: `backup:
|
|
hdd:
|
|
- path: appdata/immich
|
|
class: mandatory
|
|
userdata:
|
|
- path: media/photos
|
|
class: optional
|
|
`,
|
|
compose: `services:
|
|
immich:
|
|
volumes:
|
|
- ${HDD_PATH}/appdata/immich:/usr/src/app/upload
|
|
- ${USERDATA_PATH}/media/photos:/external/photos:ro
|
|
`,
|
|
want: map[string]appbackup.BindClass{
|
|
"hdd/appdata/immich": appbackup.ClassMandatory,
|
|
"userdata/media/photos": appbackup.ClassOptional,
|
|
},
|
|
},
|
|
{
|
|
name: "paperless-ngx",
|
|
felhom: `backup:
|
|
hdd:
|
|
- path: appdata/paperless/media
|
|
class: mandatory
|
|
- path: appdata/paperless/export
|
|
class: excluded
|
|
userdata:
|
|
- path: import/paperless
|
|
class: excluded
|
|
`,
|
|
compose: `services:
|
|
webserver:
|
|
volumes:
|
|
- ${HDD_PATH}/appdata/paperless/media:/usr/src/paperless/media
|
|
- ${HDD_PATH}/appdata/paperless/export:/usr/src/paperless/export
|
|
- ${USERDATA_PATH}/import/paperless:/usr/src/paperless/consume
|
|
`,
|
|
want: map[string]appbackup.BindClass{
|
|
"hdd/appdata/paperless/media": appbackup.ClassMandatory,
|
|
"hdd/appdata/paperless/export": appbackup.ClassExcluded,
|
|
"userdata/import/paperless": appbackup.ClassExcluded,
|
|
},
|
|
},
|
|
{
|
|
name: "sonarr",
|
|
felhom: `backup:
|
|
userdata:
|
|
- path: media/tv
|
|
class: excluded
|
|
- path: downloads
|
|
class: excluded
|
|
`,
|
|
compose: `services:
|
|
sonarr:
|
|
volumes:
|
|
- ${USERDATA_PATH}/media/tv:/tv
|
|
- ${USERDATA_PATH}/downloads:/downloads
|
|
`,
|
|
want: map[string]appbackup.BindClass{
|
|
"userdata/media/tv": appbackup.ClassExcluded,
|
|
"userdata/downloads": appbackup.ClassExcluded,
|
|
},
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
m := newClassifyManager(t, tc.felhom, tc.compose)
|
|
cbs, has := m.ClassifiedBinds("app")
|
|
if !has {
|
|
t.Fatalf("%s block must classify clean", tc.name)
|
|
}
|
|
got := classSet(cbs)
|
|
if len(got) != len(tc.want) {
|
|
t.Errorf("%s: got %d binds, want %d: %v", tc.name, len(got), len(tc.want), got)
|
|
}
|
|
for k, want := range tc.want {
|
|
if got[k] != want {
|
|
t.Errorf("%s: %s = %q, want %q", tc.name, k, got[k], want)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|