Tier-2 engine rework: class-driven legs, v2 layout, NAS-target exclusion (Task 3b, v0.135.0)

tier2_capture.go: classified apps get TierSecondary per-bind legs (paperless copy shrinks — export
drops); legacy apps keep the byte-identical resolver set. v2 relpath-mirroring layout
(backups/secondary/<stack>/{marker LAST, recovery-unit/, hdd/<rel>/, userdata/<rel>/}); N>1 native
(errTier2MultiDir/tier2AppDataName deleted). Migration=delete-and-rebuild + reconcile; all RemoveAll
via tier2SafeRemove (refuses outside backups/secondary/). SSD=state-only tier. selectTier2Target
never picks network storage (pinned+auto, F-6C-1). Restore reads v2 behind a marker gate.
Part 0: offbox_enlarge_blocked is a persisted one-time Load seed (opt-out sticks), not a getter
append. Part 0.5: offsite restore scratch prefers a local (non-network) path.
Full v2 test suite + all 10 §10 red-proofs verified. Destructive writes bounded to backups/secondary/.
This commit is contained in:
2026-07-15 10:10:20 +02:00
parent 1245a6c46e
commit 3603d1fc7f
20 changed files with 1014 additions and 484 deletions
+37 -19
View File
@@ -29,9 +29,10 @@ var (
errTier2DriveGone = errors.New("a másodlagos meghajtó nincs csatlakoztatva")
errLiveDriveGone = errors.New("az alkalmazás meghajtója nincs csatlakoztatva")
errLiveDriveDecommed = errors.New("az alkalmazás meghajtója le van szerelve")
// errTier2MultiDirRestore (F-S2): the app resolves to more than one appdata dir, which the flat
// tier-2 copy layout does not represent — refused BEFORE the app is stopped.
errTier2MultiDirRestore = errors.New("az alkalmazáshoz több adatkönyvtár tartozik — a fájl-visszaállítás jelenleg nem támogatott")
// errTier2OldLayout (3b, §7-G2): the recorded copy predates the v2 relpath-mirroring layout (no
// marker). Refuse rather than read a flat layout we no longer understand — safe, because tier-2
// restore is missing-file recovery and the live data still exists in that scenario.
errTier2OldLayout = errors.New("A 2. mentés régi formátumú — futtass előbb egy új másodlagos mentést.")
)
// RestoreTier2Files restores the app's MISSING user files in place from its recorded Tier-2 copy
@@ -63,46 +64,63 @@ func (m *Manager) RestoreTier2Files(stackName string) (filesRestored int, err er
return 0, fmt.Errorf("%w (%s)", errLiveDriveDecommed, drive)
}
}
// F-S2: the live appdata dir is the app's REAL compose-derived dir (paperless-ngx → paperless),
// not the stack name. N>1 distinct dirs → refuse here, BEFORE the app is stopped.
// v2 relpath-mirroring: liveNsRoot == the app's HDD_PATH (Model A). The dest hdd/ and userdata/
// subtrees mirror the live relpath structure exactly, so restore is two whole-subtree merges (N>1
// dirs + nested binds handled natively — no per-appdata-dir resolution, no N>1 refusal).
liveNsRoot := m.namespaceRoot(drive)
appDataName, resErr := m.tier2AppDataName(stackName, liveNsRoot)
if resErr != nil {
return 0, errTier2MultiDirRestore
}
liveDir := AppDataDir(liveNsRoot, appDataName)
// Source side: the RECORDED Tier-2 copy must exist and its drive must be connected.
var srcDir string
// Source side: the RECORDED Tier-2 copy must exist, its drive connected, and it must be v2.
var destBase string
if m.settings != nil {
if cfg := m.settings.GetCrossDriveConfig(stackName); cfg != nil && cfg.LastRun != "" && cfg.DestinationPath != "" {
if m.settings.IsDisconnected(cfg.DestinationPath) {
return 0, errTier2DriveGone
}
// Same layout literals as RunTier2's destBase + the appdata leg.
srcDir = filepath.Join(cfg.DestinationPath, "backups", "secondary", stackName, "appdata")
destBase = filepath.Join(cfg.DestinationPath, "backups", "secondary", stackName)
}
}
if srcDir == "" {
if destBase == "" {
return 0, errNoTier2Copy
}
if _, statErr := os.Stat(srcDir); statErr != nil {
if _, statErr := os.Stat(destBase); statErr != nil {
return 0, errNoTier2Copy // recorded but the copy dir is gone — same honest refusal
}
m.logger.Printf("[INFO] [backup] Tier-2 file restore for %s: %s → %s (additive-only)", stackName, srcDir, liveDir)
// §7-G2 marker gate: a pre-v2 (flat) copy has no marker → refuse rather than read a layout we no
// longer understand (live data still exists for missing-file recovery).
if _, mErr := os.Stat(filepath.Join(destBase, tier2LayoutMarker)); mErr != nil {
return 0, errTier2OldLayout
}
copier := m.restoreFilesCopier
if copier == nil {
copier = rsyncRestoreMissing
}
// The two v2 subtree merges: destBase/hdd/<rel> ↔ liveNsRoot/<rel>;
// destBase/userdata/<rel> ↔ liveNsRoot/userdata/<rel>. Each missing-only, additive.
merges := []struct{ src, dst string }{
{filepath.Join(destBase, "hdd"), liveNsRoot},
{filepath.Join(destBase, "userdata"), filepath.Join(liveNsRoot, "userdata")},
}
m.logger.Printf("[INFO] [backup] Tier-2 file restore for %s: %s (v2) → %s (additive-only)", stackName, destBase, liveNsRoot)
// Stop → copy → start → health (the standard restore shape; F17: errors surface, never swallowed).
if stopErr := m.stackProvider.StopStack(stackName); stopErr != nil {
m.logger.Printf("[WARN] [backup] could not stop %s before Tier-2 file restore: %v (continuing)", stackName, stopErr)
}
start := time.Now()
filesRestored, copyErr := copier(srcDir, liveDir)
var copyErr error
for _, mg := range merges {
if _, err := os.Stat(mg.src); err != nil {
continue // that subtree is absent in this copy (e.g. no userdata legs) — skip
}
n, err := copier(mg.src, mg.dst)
filesRestored += n
if err != nil {
copyErr = err
break
}
}
startErr := m.stackProvider.StartStack(stackName)
if startErr != nil {
m.logger.Printf("[ERROR] [backup] failed to restart %s after Tier-2 file restore: %v", stackName, startErr)