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
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
|
||||
)
|
||||
|
||||
// composeVarRoots maps the two deploy-time path variables to their classification root. A bind's
|
||||
// host side must reference one of these LITERALLY (the classifier works in ${VAR}-relative space —
|
||||
// it does NOT resolve envs, unlike ParseComposeHDDMounts). ${USERDATA_PATH} is <HDD_PATH>/userdata
|
||||
// at deploy, but in the compose file the two are written distinctly, so they are distinct roots.
|
||||
var composeVarRoots = []struct {
|
||||
varRef string
|
||||
root appbackup.BindRoot
|
||||
}{
|
||||
{"${USERDATA_PATH}", appbackup.RootUserdata},
|
||||
{"${HDD_PATH}", appbackup.RootHDD},
|
||||
}
|
||||
|
||||
// ParseComposeClassifiableBinds extracts the ${HDD_PATH}/${USERDATA_PATH}-relative host binds from a
|
||||
// docker-compose.yml, for backup classification (Part 2 of the classification arc). It copies the
|
||||
// ParseComposeUserdataMounts scanner shape (service-level `volumes:` section, `- ` short-syntax
|
||||
// lines, quote-trim, `SplitN(":",3)`) but stays in RELATIVE ${VAR} space and preserves the `:ro`
|
||||
// flag — both of which the classifier needs and which ParseComposeHDDMounts discards.
|
||||
//
|
||||
// RelPath is the path.Clean'd remainder after the variable (leading "/" stripped; "" for a bare-root
|
||||
// bind). ReadOnly is true iff the mode field (parts[2]) contains a `ro` token. Deduped on
|
||||
// (Root, RelPath) — the FIRST occurrence's ReadOnly wins (the catalog never mixes modes for one
|
||||
// path; noted so a future mixed case is a conscious change, not a silent one).
|
||||
//
|
||||
// Long-syntax volumes (`type: bind`) are NOT supported — parity with every existing compose parser;
|
||||
// the catalog uses short syntax only. Pure given the file bytes (no env resolution, no FS beyond the
|
||||
// read).
|
||||
func ParseComposeClassifiableBinds(composePath string) []appbackup.ComposeBind {
|
||||
data, err := os.ReadFile(composePath)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var binds []appbackup.ComposeBind
|
||||
seen := make(map[string]bool) // "<root>\x00<relpath>"
|
||||
|
||||
scanner := bufio.NewScanner(strings.NewReader(string(data)))
|
||||
inVolumes := false
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if strings.HasPrefix(line, "volumes:") {
|
||||
inVolumes = true
|
||||
continue
|
||||
}
|
||||
if inVolumes && !strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "#") && line != "" {
|
||||
inVolumes = false
|
||||
}
|
||||
if !inVolumes || !strings.HasPrefix(line, "- ") {
|
||||
continue
|
||||
}
|
||||
mountStr := strings.Trim(strings.TrimPrefix(line, "- "), "\"'")
|
||||
parts := strings.SplitN(mountStr, ":", 3)
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
root, relPath, ok := classifyRoot(parts[0])
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
readOnly := len(parts) == 3 && modeIsReadOnly(parts[2])
|
||||
key := string(root) + "\x00" + relPath
|
||||
if seen[key] {
|
||||
continue // first occurrence's ReadOnly wins
|
||||
}
|
||||
seen[key] = true
|
||||
binds = append(binds, appbackup.ComposeBind{Root: root, RelPath: relPath, ReadOnly: readOnly})
|
||||
}
|
||||
return binds
|
||||
}
|
||||
|
||||
// classifyRoot resolves a compose host-side token to its (root, relpath) if it references one of the
|
||||
// classification variables exactly or as a "/"-separated prefix. relPath is path.Clean'd, "" for a
|
||||
// bare root.
|
||||
func classifyRoot(hostPath string) (appbackup.BindRoot, string, bool) {
|
||||
for _, v := range composeVarRoots {
|
||||
var rem string
|
||||
switch {
|
||||
case hostPath == v.varRef:
|
||||
rem = ""
|
||||
case strings.HasPrefix(hostPath, v.varRef+"/"):
|
||||
rem = strings.TrimPrefix(hostPath, v.varRef+"/")
|
||||
default:
|
||||
continue
|
||||
}
|
||||
if rem == "" {
|
||||
return v.root, "", true
|
||||
}
|
||||
rel := path.Clean(rem)
|
||||
if rel == "." {
|
||||
rel = ""
|
||||
}
|
||||
return v.root, rel, true
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// modeIsReadOnly reports whether a docker volume mode field (e.g. "ro", "rw", "ro,z", "z") carries a
|
||||
// `ro` token.
|
||||
func modeIsReadOnly(mode string) bool {
|
||||
for _, tok := range strings.Split(mode, ",") {
|
||||
if strings.TrimSpace(tok) == "ro" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
@@ -32,6 +33,11 @@ type Metadata struct {
|
||||
// SMTPMapping declares how this app's compose env receives the managed app-email relay settings.
|
||||
// Present only for apps that support outbound email; absent = the app has no email UI/injection.
|
||||
SMTPMapping *SMTPMapping `yaml:"smtp_mapping,omitempty" json:"smtp_mapping,omitempty"`
|
||||
// Backup is the referential-coupling classification block (Task 2). Present only for the 13
|
||||
// bind-bearing apps; nil = legacy behavior (SQ5 two-level default). LoadMetadata REJECTS the whole
|
||||
// block (sets this back to nil + logs one ERROR) on any validation defect, so a bad catalog push
|
||||
// degrades to legacy loudly rather than partially classifying. Consumed by Task 3/4 — INERT today.
|
||||
Backup *appbackup.BackupSpec `yaml:"backup,omitempty" json:"backup,omitempty"`
|
||||
}
|
||||
|
||||
// SMTPMapping renames the generic relay settings (host / port / security / from / from-name)
|
||||
@@ -250,9 +256,43 @@ func LoadMetadata(stackDir string) Metadata {
|
||||
}
|
||||
}
|
||||
|
||||
// Backup classification (Task 2): the SINGLE validation choke point. Catalog listing, the
|
||||
// deployed-stack scan, and git-sync all flow through LoadMetadata, so a bad `backup:` block in a
|
||||
// catalog push screams here within one sync cycle. On ANY defect (or an unreadable compose while a
|
||||
// block exists) the WHOLE block is rejected — meta.Backup = nil, one ERROR — so the app degrades
|
||||
// to legacy (today's behavior) rather than partially classifying. INERT: nothing consumes
|
||||
// meta.Backup yet (Task 3/4).
|
||||
if meta.Backup != nil {
|
||||
composePath := filepath.Join(stackDir, "docker-compose.yml")
|
||||
binds := ParseComposeClassifiableBinds(composePath)
|
||||
if _, err := os.Stat(composePath); err != nil {
|
||||
log.Printf("[ERROR] [stacks] .felhom.yml backup block rejected in %s: docker-compose.yml unreadable: %v", stackDir, err)
|
||||
meta.Backup = nil
|
||||
} else if err := appbackup.ValidateBackupSpec(meta.Backup, binds); err != nil {
|
||||
log.Printf("[ERROR] [stacks] .felhom.yml backup block rejected in %s: %v", stackDir, err)
|
||||
meta.Backup = nil
|
||||
}
|
||||
}
|
||||
|
||||
return meta
|
||||
}
|
||||
|
||||
// ClassifiedBinds resolves the backup classification for a stack: it reads .felhom.yml (through the
|
||||
// SAME LoadMetadata validation path, so a rejected block is already nil here) and its compose binds,
|
||||
// then applies the two-level default via appbackup.ClassifyBinds. The bool reports whether the app
|
||||
// carries a (valid) backup block at all. INERT — exists so Task 3 consumes a wired, end-to-end-tested
|
||||
// seam instead of building one (the F-S3 lesson: wiring is where seams hide typos).
|
||||
func (m *Manager) ClassifiedBinds(name string) ([]appbackup.ClassifiedBind, bool) {
|
||||
stack, ok := m.GetStack(name)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
stackDir := filepath.Dir(stack.ComposePath)
|
||||
meta := LoadMetadata(stackDir)
|
||||
binds := ParseComposeClassifiableBinds(stack.ComposePath)
|
||||
return appbackup.ClassifyBinds(meta.Backup, binds)
|
||||
}
|
||||
|
||||
// HasDeployFields returns true if the app has any user-facing deploy fields
|
||||
// (i.e., fields beyond auto-filled domain and auto-generated secrets).
|
||||
func (m *Metadata) HasDeployFields() bool {
|
||||
|
||||
Reference in New Issue
Block a user