Files
felhom-controller/controller/internal/appbackup/paths.go
T
admin 68f0e0cf5c F-S2 + F-S3: compose-derived appdata dir resolution (v0.131.0)
The controller assumed an app's HDD appdata dir is always appdata/<stackName>.
paperless-ngx writes appdata/paperless (stack paperless-ngx), so every consumer
keying by stack name silently missed it via a stat-and-skip. One canonical
resolver appbackup.AppDataDirNames derives the real dir name(s) from the app's
compose ${HDD_PATH} binds; all consumers use it.

- F-S2 (tier-2): RunTier2 mirrors the resolved appdata/<name> (paperless docs
  got NO tier-2 copy before). Tier2Info size + RestoreTier2Files live dir use it.
  WARN when a declared appdata dir is absent. New tier2Mirror seam.
- F-S3 (migrate, NEW): all six per-app appdata legs (collision/size/copy/verify/
  cleanup/skip-set) now loop resolved names. scope="app" migration of paperless
  previously copied nothing and left an empty media dir (scope="all" was saved by
  the merge walk). WARN on missing declared dir in the copy leg.
- Multi-dir (N>1) refusal: tier-2 backup/info/restore refuse loudly (Hungarian);
  migrate supports N. No catalog app hits it today; lifted by Task 3.
- Display: storage page sums resolved dirs.
- Truth repair: the v0.130.0 "tier-2 copies the namespace wholesale" claim is
  false; corrected in CHANGELOG + main.go export-adapter comment.

+9 tests; red-proofs RP-1..RP-5 all confirmed. Controller-only, no agent/hub
coupling. Task 1 of the backup-classification-redesign arc.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A45Qop8YY8tS94bz63LFne
2026-07-14 17:45:53 +02:00

129 lines
6.1 KiB
Go

// 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 <drive>/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/<stack>/`) — 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 <hddPath>/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 <hddPath> itself or a subpath, filepath.Clean'd). The first path element under
// <hddPath>/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
// <hddPath>/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
}