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 /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) // "\x00" 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 }