c732006d26
Every unit path helper took (nsRoot, stackName) and joined backups/primary/<stack>/... . That hard-coded 'primary' is the mechanism of R-102: Tier-2 mirrors the whole unit directory to <dest>/backups/secondary/<stack>/recovery-unit/ every night, and because no reader could NAME a unit outside backups/primary/, that mirror has been captured for months and read by nothing. Adds four unit-directory-relative primitives - UnitComposeDir, UnitManifestFile, UnitDBDumpDir, UnitVolumeDumpDir - each taking the recovery-unit DIRECTORY itself. The four existing (nsRoot, stackName) helpers become thin wrappers over them and keep their exact signatures and their exact return values; every current caller compiles untouched. ONE implementation, two callers - the rule restoreDockerVolumesFrom already follows in this repo. TestR102_PathWrappersAreByteIdenticalToToday pins the wrappers against hand-written literals (not re-derived from the helpers under test). Red-proof: UnitComposeDir join changed to 'compose2' -> the test fails on all three fixtures.
194 lines
9.9 KiB
Go
194 lines
9.9 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)
|
|
}
|
|
|
|
// IsEnrolledDrive reports whether a drive path is an ENROLLED user-data drive (Model A: its in-guest
|
|
// mount already IS the namespace root) rather than the system-data fallback. It is the ONE comparison
|
|
// that decides which NamespaceRoot mode applies, and it lives here so no package re-derives it.
|
|
//
|
|
// Both sides are Clean'd: `/mnt/sys_drive/` and `/mnt/sys_drive` are the same drive, and a trailing
|
|
// slash arriving from config must not silently flip the mode.
|
|
func IsEnrolledDrive(drivePath, systemDataPath string) bool {
|
|
return filepath.Clean(drivePath) != filepath.Clean(systemDataPath)
|
|
}
|
|
|
|
// NamespaceRootFor is the resolver every caller should use when it holds a bare DRIVE path and the
|
|
// system-data path — i.e. everywhere outside the backup package, which already had this rule.
|
|
//
|
|
// R-203: FIVE call sites passed a bare drive path straight to UserdataDir (and its siblings), which
|
|
// take a NAMESPACE ROOT. On an enrolled drive the two coincide, so nothing showed; on the system-data
|
|
// fallback they differ by exactly the felhom-data segment, and the app then bound a directory the
|
|
// backup never looked at. The run still reported ok. Measured live on demo-hp 2026-08-04:
|
|
// the app wrote to /mnt/sys_drive/userdata/media/books while the off-site capture set looked for
|
|
// /mnt/sys_drive/felhom-data/userdata/media/books.
|
|
//
|
|
// THE CONTRACT, restated because four callers got it wrong and a fifth will: UserdataDir,
|
|
// PrimaryBackupPath, RecoveryUnitPath and AppDataDir all take a NAMESPACE ROOT. If you are holding
|
|
// something that came out of HDD_PATH or a StoragePath, it is a DRIVE path — put it through here
|
|
// first. `UserdataDir(bareDrivePath)` still compiles and is still wrong; TestNoBareDrivePathToUserdataDir
|
|
// is the guard that keeps the count from growing.
|
|
func NamespaceRootFor(drivePath, systemDataPath string) string {
|
|
return NamespaceRoot(drivePath, IsEnrolledDrive(drivePath, systemDataPath))
|
|
}
|
|
|
|
// 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). Since D5 the
|
|
// unit's compose/app.yaml CARRIES the portable secret class (data keys, DB passwords, internal signing
|
|
// secrets) at mode 0600, so a Tier-1/2 restore needs the drive and nothing else; internet-reachable
|
|
// admin logins are still withheld. See backup.recoveryUnit / restore for the capture + restore flow.
|
|
func RecoveryUnitPath(nsRoot, stackName string) string {
|
|
return filepath.Join(nsRoot, "backups", "primary", stackName)
|
|
}
|
|
|
|
// UNIT-DIRECTORY-RELATIVE HELPERS (R-102). The four below take THE RECOVERY-UNIT DIRECTORY ITSELF
|
|
// and know only the unit's internal layout. The `(nsRoot, stackName)` helpers that follow are thin
|
|
// wrappers over them, and every existing caller keeps its exact signature and its exact result.
|
|
//
|
|
// They exist because the `(nsRoot, stackName)` form resolves through RecoveryUnitPath, which joins
|
|
// `backups/primary/<stack>` — a hard-coded `primary` that was the MECHANISM of R-102. Tier-2 mirrors
|
|
// the whole unit directory to `<dest>/backups/secondary/<stack>/recovery-unit/`, and because every
|
|
// reader of a unit could only name a `primary` path, that mirror was captured nightly for months and
|
|
// read by nothing. The unit's INTERNAL layout is identical wherever the directory sits, so the fix is
|
|
// to let a reader name the directory rather than re-derive it.
|
|
//
|
|
// ONE implementation, two callers — the same rule as backup.restoreDockerVolumesFrom. Do not add a
|
|
// second copy of a join: a duplicated layout constant is how the two sides drift apart.
|
|
|
|
// UnitComposeDir returns the compose/config capture dir within a recovery-unit DIRECTORY
|
|
// (docker-compose.yml + .felhom.yml + app.yaml carrying the portable secret class).
|
|
func UnitComposeDir(unitDir string) string {
|
|
return filepath.Join(unitDir, "compose")
|
|
}
|
|
|
|
// UnitManifestFile returns the manifest.json path within a recovery-unit DIRECTORY.
|
|
func UnitManifestFile(unitDir string) string {
|
|
return filepath.Join(unitDir, "manifest.json")
|
|
}
|
|
|
|
// UnitDBDumpDir returns the DB dump directory within a recovery-unit DIRECTORY.
|
|
func UnitDBDumpDir(unitDir string) string {
|
|
return filepath.Join(unitDir, "db-dumps")
|
|
}
|
|
|
|
// UnitVolumeDumpDir returns the Docker-volume dump-tar directory within a recovery-unit DIRECTORY.
|
|
func UnitVolumeDumpDir(unitDir string) string {
|
|
return filepath.Join(unitDir, "volume-dumps")
|
|
}
|
|
|
|
// 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 UnitComposeDir(RecoveryUnitPath(nsRoot, stackName))
|
|
}
|
|
|
|
// RecoveryUnitManifestPath returns the manifest.json path within an app's recovery unit.
|
|
func RecoveryUnitManifestPath(nsRoot, stackName string) string {
|
|
return UnitManifestFile(RecoveryUnitPath(nsRoot, stackName))
|
|
}
|
|
|
|
// AppDBDumpPath returns the DB dump directory for an app under a felhom-data namespace root.
|
|
func AppDBDumpPath(nsRoot, stackName string) string {
|
|
return UnitDBDumpDir(RecoveryUnitPath(nsRoot, stackName))
|
|
}
|
|
|
|
// AppVolumeDumpPath returns the Docker-volume dump-tar directory for an app under a namespace root.
|
|
func AppVolumeDumpPath(nsRoot, stackName string) string {
|
|
return UnitVolumeDumpDir(RecoveryUnitPath(nsRoot, stackName))
|
|
}
|
|
|
|
// 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
|
|
}
|