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