// Package appbackup holds the self-contained app-data backup primitives: // database dump and Docker-volume archive discovery/execution, plus the // keep-side storage path helpers. It depends only on stable abstractions // (the StackDataProvider interface) and has no dependency on the restic, // cross-drive, or drive-mount code in the backup package. package appbackup import ( "path/filepath" "sort" "strings" ) // FelhomDataDir is the namespace directory on storage drives for all felhom-managed data. const FelhomDataDir = "felhom-data" // NamespaceRoot resolves the felhom-data namespace ROOT for a drive path. All the path helpers // below take this namespace root (the directory that directly contains backups/ and appdata/), // NOT a bare drive path — they do not append felhom-data themselves. // // Model A (slice 10): the host agent binds /felhom-data onto the guest mountpoint, so an // enrolled user-data drive's IN-GUEST mount already IS the namespace root (and its basename need // NOT be "felhom-data" — e.g. /mnt/felhom-usb). For such mounts pass inGuestDrive=true → the path // is returned as-is, so callers no longer double-nest into .../felhom-data/felhom-data/... . // // For a bare drive root that still holds a felhom-data SUBDIR — the SSD-only system-data fallback, // or any legacy host-side layout — pass inGuestDrive=false → the felhom-data segment is appended. func NamespaceRoot(drivePath string, inGuestDrive bool) string { if inGuestDrive { return filepath.Clean(drivePath) } return filepath.Join(drivePath, FelhomDataDir) } // PrimaryBackupPath returns the root primary backup directory under a felhom-data namespace root. func PrimaryBackupPath(nsRoot string) string { return filepath.Join(nsRoot, "backups", "primary") } // RecoveryUnitPath returns the per-app self-contained recovery-unit ROOT under a namespace root. // It is the existing per-app backup dir (`backups/primary//`) — the legacy name is kept so the // db-dumps/ and volume-dumps/ already written there need no migration; the unit gains compose/ and // manifest.json as siblings, making the whole dir a complete, recreatable unit (Phase 2). The unit is // secret-free: secrets/data-keys are recovered from the guest's own app.yaml (live or via PBS), never // stored here. See backup.recoveryUnit / restore for the capture + restore flow. func RecoveryUnitPath(nsRoot, stackName string) string { return filepath.Join(nsRoot, "backups", "primary", stackName) } // RecoveryUnitComposePath returns the compose/config capture dir within an app's recovery unit // (docker-compose.yml + .felhom.yml + secret-stripped app.yaml). func RecoveryUnitComposePath(nsRoot, stackName string) string { return filepath.Join(RecoveryUnitPath(nsRoot, stackName), "compose") } // RecoveryUnitManifestPath returns the manifest.json path within an app's recovery unit. func RecoveryUnitManifestPath(nsRoot, stackName string) string { return filepath.Join(RecoveryUnitPath(nsRoot, stackName), "manifest.json") } // AppDBDumpPath returns the DB dump directory for an app under a felhom-data namespace root. func AppDBDumpPath(nsRoot, stackName string) string { return filepath.Join(RecoveryUnitPath(nsRoot, stackName), "db-dumps") } // AppVolumeDumpPath returns the Docker-volume dump-tar directory for an app under a namespace root. func AppVolumeDumpPath(nsRoot, stackName string) string { return filepath.Join(RecoveryUnitPath(nsRoot, stackName), "volume-dumps") } // AppDataDir returns the app data directory under a felhom-data namespace root. The final segment // is the app's real appdata dir NAME — usually the stack name, but NOT always: paperless-ngx writes // appdata/paperless (F-S2/F-S3). Callers that key by stack name silently miss such apps; use // AppDataDirNames to resolve the real name(s) from the app's compose binds and pass them here. func AppDataDir(nsRoot, stackName string) string { return filepath.Join(nsRoot, "appdata", stackName) } // AppDataDirNames returns the app's real directory name(s) under /appdata, derived from its // compose HDD bind mounts (F-S2/F-S3: the dir name is NOT always the stack name — paperless-ngx // writes appdata/paperless). hddMounts are resolved host paths in the ParseComposeHDDMounts shape // (each is itself or a subpath, filepath.Clean'd). The first path element under // /appdata/ is taken as the dir name; results are deduped and sorted. Falls back to // []string{stackName} when no appdata-prefixed mount is derivable (no HDD appdata binds, unreadable // compose, nil provider) — the exact legacy behavior. // // Today every catalog app resolves to exactly ONE name (immich→immich, nextcloud→nextcloud, // romm→romm, paperless-ngx→paperless). The N>1 return is defensive: tier-2 refuses it loudly, // migrate handles it naturally. func AppDataDirNames(hddPath, stackName string, hddMounts []string) []string { prefix := filepath.Clean(hddPath) + string(filepath.Separator) + "appdata" + string(filepath.Separator) seen := make(map[string]bool) var names []string for _, mnt := range hddMounts { cm := filepath.Clean(mnt) if !strings.HasPrefix(cm, prefix) { continue // not under appdata/ (a whole-root bind, a different subtree, a foreign drive) } rem := strings.TrimPrefix(cm, prefix) first := strings.Split(rem, string(filepath.Separator))[0] if first == "" { continue } if !seen[first] { seen[first] = true names = append(names, first) } } if len(names) == 0 { return []string{stackName} } sort.Strings(names) return names } // AppDataBindsPresent reports whether any of the app's resolved HDD mounts sits under // /appdata/ — i.e. the compose actually DECLARES an appdata bind. Callers use it to // distinguish "no appdata to back up" (silent skip is correct) from "declared appdata dir missing // on disk" (the silence that hid F-S2 — worth a WARN). Same prefix rule as AppDataDirNames. func AppDataBindsPresent(hddPath string, hddMounts []string) bool { prefix := filepath.Clean(hddPath) + string(filepath.Separator) + "appdata" + string(filepath.Separator) for _, mnt := range hddMounts { if strings.HasPrefix(filepath.Clean(mnt), prefix) { return true } } return false }