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
This commit is contained in:
@@ -14,21 +14,59 @@ import (
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
|
||||
)
|
||||
|
||||
// Tier 2 = an off-drive (different physical disk) copy of an HDD app's recovery unit + bulk userdata.
|
||||
// It is the ONLY off-drive protection that browsable HDD userdata can get — PBS can't reach bind
|
||||
// mounts. Auto-enabled for every HDD app; the target is auto-picked: prefer another registered
|
||||
// user-data drive (can hold bulk), else the internal SSD for SMALL units only — and the SSD is the
|
||||
// guest rootfs (~8 GB), so we REFUSE rather than fill it (a size-aware headroom guard). When no
|
||||
// off-drive target fits, we record an honest "needs a 2nd HDD" status instead of silently doing
|
||||
// nothing useful.
|
||||
// Tier 2 = an off-drive (different physical disk) copy of an HDD app's recovery unit + its resolved
|
||||
// appdata/<name> dir(s). It does NOT copy the browsable userdata tree (F-S1: userdata is not backed
|
||||
// up at any tier yet — that gap is owned by the classification redesign, see
|
||||
// felhom.eu/documentation/audits/SPIKE-backup-classification-2026-07-14.md). The appdata dir NAME is
|
||||
// derived from the app's compose binds, NOT assumed to be the stack name (F-S2: paperless-ngx writes
|
||||
// appdata/paperless); see tier2AppDataName. Auto-enabled for every HDD app; the target is auto-picked:
|
||||
// prefer another registered user-data drive (can hold bulk), else the internal SSD for SMALL units
|
||||
// only — and the SSD is the guest rootfs (~8 GB), so we REFUSE rather than fill it (a size-aware
|
||||
// headroom guard). When no off-drive target fits, we record an honest "needs a 2nd HDD" status
|
||||
// instead of silently doing nothing useful.
|
||||
|
||||
const gibibyte = 1024 * 1024 * 1024
|
||||
|
||||
var (
|
||||
errNoOffDiskTarget = errors.New("no off-drive target (single drive, app already on the system disk)")
|
||||
errSSDNoHeadroom = errors.New("the internal SSD lacks headroom for this app's data — a 2nd drive is required for off-drive backup")
|
||||
// errTier2MultiDir is raised when an app's compose resolves to MORE THAN ONE distinct appdata
|
||||
// dir under <hddPath>/appdata (no catalog app does today). Tier 2's destination layout is flat
|
||||
// (<destBase>/appdata), so it refuses rather than silently collapse two source dirs into one.
|
||||
errTier2MultiDir = errors.New("az alkalmazáshoz több adatkönyvtár tartozik — a 2. mentés jelenleg alkalmazásonként egy könyvtárat támogat")
|
||||
)
|
||||
|
||||
// appDataDirNames resolves the app's real appdata dir name(s) under hddPath from its compose HDD
|
||||
// binds, via the stack provider (nil provider → legacy [stackName] fallback). See
|
||||
// appbackup.AppDataDirNames.
|
||||
func (m *Manager) appDataDirNames(stackName, hddPath string) []string {
|
||||
var mounts []string
|
||||
if m.stackProvider != nil {
|
||||
mounts = m.stackProvider.GetStackHDDMounts(stackName)
|
||||
}
|
||||
return AppDataDirNames(hddPath, stackName, mounts)
|
||||
}
|
||||
|
||||
// tier2AppDataName resolves the SINGLE appdata dir name for tier-2's flat destination. N>1 distinct
|
||||
// names → errTier2MultiDir (the one place the tier-2 multi-dir refusal is built). It always returns
|
||||
// at least one name from appDataDirNames' fallback, so name is meaningful only when err == nil.
|
||||
func (m *Manager) tier2AppDataName(stackName, hddPath string) (string, error) {
|
||||
names := m.appDataDirNames(stackName, hddPath)
|
||||
if len(names) > 1 {
|
||||
return "", errTier2MultiDir
|
||||
}
|
||||
return names[0], nil
|
||||
}
|
||||
|
||||
// tier2AppDataBindsPresent reports whether the app's compose declares an appdata bind (drives the
|
||||
// WARN-on-missing-declared-dir rule; nil provider → false).
|
||||
func (m *Manager) tier2AppDataBindsPresent(stackName, hddPath string) bool {
|
||||
if m.stackProvider == nil {
|
||||
return false
|
||||
}
|
||||
return AppDataBindsPresent(hddPath, m.stackProvider.GetStackHDDMounts(stackName))
|
||||
}
|
||||
|
||||
// Tier2Target is a resolved off-drive destination for an app's Tier 2 copy.
|
||||
type Tier2Target struct {
|
||||
NamespaceRoot string // felhom-data namespace root on the target drive
|
||||
@@ -143,7 +181,16 @@ func (m *Manager) RunTier2(stackName string) error {
|
||||
}
|
||||
sourceNsRoot := m.namespaceRoot(sourceDrive)
|
||||
unitDir := RecoveryUnitPath(sourceNsRoot, stackName)
|
||||
appDataDir := AppDataDir(sourceNsRoot, stackName)
|
||||
// F-S2: resolve the app's REAL appdata dir name from its compose binds (paperless-ngx writes
|
||||
// appdata/paperless, not appdata/paperless-ngx). For an HDD app HDD_PATH == nsRoot (Model A), so
|
||||
// the mounts (resolved against HDD_PATH) share the nsRoot prefix. N>1 distinct names → refuse.
|
||||
appDataName, resErr := m.tier2AppDataName(stackName, sourceNsRoot)
|
||||
if resErr != nil {
|
||||
m.recordTier2NoTarget(stackName, resErr.Error())
|
||||
m.logger.Printf("[ERROR] [backup] Tier 2 for %s refused: %v", stackName, resErr)
|
||||
return nil
|
||||
}
|
||||
appDataDir := AppDataDir(sourceNsRoot, appDataName)
|
||||
if _, err := os.Stat(unitDir); err != nil {
|
||||
return nil // no recovery unit yet — nothing to copy
|
||||
}
|
||||
@@ -166,7 +213,12 @@ func (m *Manager) RunTier2(stackName string) error {
|
||||
destBase := filepath.Join(target.NamespaceRoot, "backups", "secondary", stackName)
|
||||
start := time.Now()
|
||||
|
||||
if err := rsyncMirror(unitDir, filepath.Join(destBase, "recovery-unit")); err != nil {
|
||||
mirror := m.tier2Mirror
|
||||
if mirror == nil {
|
||||
mirror = rsyncMirror
|
||||
}
|
||||
|
||||
if err := mirror(unitDir, filepath.Join(destBase, "recovery-unit")); err != nil {
|
||||
m.recordTier2Failure(stackName, target, err)
|
||||
if m.tier2Notify != nil {
|
||||
m.tier2Notify(stackName, target.Label, time.Since(start), err)
|
||||
@@ -174,13 +226,18 @@ func (m *Manager) RunTier2(stackName string) error {
|
||||
return fmt.Errorf("tier2 rsync unit for %s: %w", stackName, err)
|
||||
}
|
||||
if _, e := os.Stat(appDataDir); e == nil {
|
||||
if err := rsyncMirror(appDataDir, filepath.Join(destBase, "appdata")); err != nil {
|
||||
if err := mirror(appDataDir, filepath.Join(destBase, "appdata")); err != nil {
|
||||
m.recordTier2Failure(stackName, target, err)
|
||||
if m.tier2Notify != nil {
|
||||
m.tier2Notify(stackName, target.Label, time.Since(start), err)
|
||||
}
|
||||
return fmt.Errorf("tier2 rsync appdata for %s: %w", stackName, err)
|
||||
}
|
||||
} else if m.tier2AppDataBindsPresent(stackName, sourceNsRoot) {
|
||||
// F-S2: the compose DECLARES an appdata bind but the dir is missing on disk. Skipping is kept
|
||||
// (nothing to copy) but the silence that hid F-S2 for months is now a loud WARN.
|
||||
m.logger.Printf("[WARN] [backup] Tier 2 for %s: compose declares appdata dir %q but it is absent at %s — appdata leg skipped",
|
||||
stackName, appDataName, appDataDir)
|
||||
}
|
||||
|
||||
dur := time.Since(start)
|
||||
@@ -279,8 +336,15 @@ func (m *Manager) Tier2Info(stackName string) Tier2Info {
|
||||
}
|
||||
|
||||
// Resolve what the runner WOULD pick right now (real unit size feeds the SSD headroom guard).
|
||||
// F-S2: N>1 distinct appdata dirs → the same honest refusal the runner records.
|
||||
sourceNsRoot := m.namespaceRoot(source)
|
||||
unitSize := dirSizeBytes(RecoveryUnitPath(sourceNsRoot, stackName)) + dirSizeBytes(AppDataDir(sourceNsRoot, stackName))
|
||||
appDataName, resErr := m.tier2AppDataName(stackName, sourceNsRoot)
|
||||
if resErr != nil {
|
||||
info.NoTarget = true
|
||||
info.NoTargetReason = resErr.Error()
|
||||
return info
|
||||
}
|
||||
unitSize := dirSizeBytes(RecoveryUnitPath(sourceNsRoot, stackName)) + dirSizeBytes(AppDataDir(sourceNsRoot, appDataName))
|
||||
target, err := m.selectTier2Target(stackName, unitSize)
|
||||
if err != nil {
|
||||
info.NoTarget = true
|
||||
|
||||
Reference in New Issue
Block a user