fix(backup): F3 — wire named-volume dumps into the app-data backup run

DumpAppVolumesSafe had NO production caller: no trigger ever produced
volume-dumps/, so named-volume app data (e.g. nextcloud's html volume) was never
captured into the recovery unit and the granular restore silently restored
nothing for class-B data (drill finding F3).

- runVolumeDumps: per-stack loop in runDBDumpsInternal, BEFORE
  captureAllRecoveryUnits (so manifests enumerate the fresh tars). Gate order is
  load-bearing: protected-stack and volume-check gates precede DumpAppVolumesSafe
  (which stops the stack before its own check — unconditional calls would bounce
  every volume-less app nightly). Disconnected/decommissioned drives skip with
  the same summary style as the DB loop.
- No silent partials: a per-stack failure lands as a FAIL summary entry, flips
  Success, and fails the run ("some backup steps failed: ..."), without aborting
  the other stacks.
- Zero-DB early return removed: volume-bearing apps without a database still get
  their class-B dump + unit refresh.
- dumpVolumesSafe seam (same style as the F17 discoverDBs/importDBDump seams) so
  the gating is unit-tested without Docker. Companion red-proof: neutering the
  volume gate fails TestRunVolumeDumps_GatesPrecedeDump (dump fired for the
  volume-less stack) and _VolumelessNeverStopped (verified, reverted).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
2026-07-05 11:46:37 +02:00
parent f413f9539d
commit 73378a812c
2 changed files with 282 additions and 15 deletions
+89 -15
View File
@@ -40,6 +40,10 @@ type Manager struct {
discoverDBs func(ctx context.Context) ([]DiscoveredDB, error)
importDBDump func(ctx context.Context, db DiscoveredDB, dumpPath string) error
// F3 volume-dump seam — overridable in tests so runVolumeDumps' gating (protected / volume-less /
// disconnected) can be unit-tested without Docker. Nil → the real DumpAppVolumesSafe.
dumpVolumesSafe func(stackName string) error
// migrationRunning, if set, reports whether a data migration is in progress. The scheduled
// backup paths skip when it returns true (Change 3 — backup ↔ migration mutual exclusion), so a
// nightly dump/Tier-2 can't race a migration copy/cleanup on the same drive.
@@ -202,20 +206,14 @@ func (m *Manager) runDBDumpsInternal(ctx context.Context) error {
return err
}
// F3: no early return on zero DBs — volume-bearing apps without a database still need their
// class-B volume dump + recovery-unit refresh below (the DB loop simply has no iterations).
if len(dbs) == 0 {
m.logger.Printf("[INFO] [backup] No database containers found")
m.mu.Lock()
m.lastDBDump = &DBDumpStatus{
LastRun: time.Now(),
Success: true,
Duration: time.Since(start),
}
m.mu.Unlock()
return nil
} else {
m.logger.Printf("[INFO] [backup] Discovered %d database(s): %s", len(dbs), dbNames(dbs))
}
m.logger.Printf("[INFO] [backup] Discovered %d database(s): %s", len(dbs), dbNames(dbs))
// Dump each DB to its app's drive path
var results []DumpResult
allOK := true
@@ -270,6 +268,13 @@ func (m *Manager) runDBDumpsInternal(ctx context.Context) error {
}
}
// F3: class-B leg — dump each app's named-volume data (stop → tar → restart). MUST run before
// captureAllRecoveryUnits so the manifests enumerate the fresh tars into VolumeDumps.
dbOK := allOK
volSummary, volDumped, volOK := m.runVolumeDumps()
summary = append(summary, volSummary...)
allOK = dbOK && volOK
duration := time.Since(start)
m.mu.Lock()
m.lastDBDump = &DBDumpStatus{
@@ -281,22 +286,91 @@ func (m *Manager) runDBDumpsInternal(ctx context.Context) error {
m.mu.Unlock()
if allOK {
m.logger.Printf("[INFO] [backup] DB dump completed: %d databases, %s total (%s)",
len(results), humanizeBytes(totalSize), duration.Round(time.Millisecond))
m.logger.Printf("[INFO] [backup] App-data backup completed: %d databases (%s total), %d volume dump(s) (%s)",
len(results), humanizeBytes(totalSize), volDumped, duration.Round(time.Millisecond))
} else {
// Still refresh recovery units below — a partial DB failure shouldn't leave units stale.
m.logger.Printf("[WARN] [backup] some database dumps failed; refreshing recovery units anyway")
// Still refresh recovery units below — a partial failure shouldn't leave units stale.
m.logger.Printf("[WARN] [backup] some backup steps failed (%s); refreshing recovery units anyway",
strings.Join(failedSummaryLines(summary), "; "))
}
// Phase 2: refresh each deployed app's self-contained recovery unit (compose + manifest).
m.captureAllRecoveryUnits()
// No silent partials: a DB-dump or volume-dump failure fails the whole run.
if !allOK {
return fmt.Errorf("some database dumps failed")
return fmt.Errorf("some backup steps failed: %s", strings.Join(failedSummaryLines(summary), "; "))
}
return nil
}
// failedSummaryLines filters a run summary down to its FAIL entries (for logs/errors).
func failedSummaryLines(summary []string) []string {
var failed []string
for _, s := range summary {
if strings.HasPrefix(s, "FAIL ") {
failed = append(failed, s)
}
}
return failed
}
// runVolumeDumps exports the Docker named-volume data of every deployed, unprotected stack whose
// drive is writable — the class-B leg of the nightly app-data backup. (F3: DumpAppVolumesSafe
// previously had NO production caller, so volume-dumps/ was never produced and the granular
// restore had nothing to restore for named-volume apps.) Caller must hold the running flag.
//
// Gate ORDER is load-bearing: the volume check precedes DumpAppVolumesSafe, because the Safe
// variant stops the stack before its own volume check — calling it unconditionally would bounce
// every volume-less app on every nightly run. Per-stack isolation mirrors the DB loop: one app's
// failure is recorded and does not abort the others.
func (m *Manager) runVolumeDumps() (summary []string, dumped int, allOK bool) {
allOK = true
if m.stackProvider == nil {
return nil, 0, true
}
dump := m.dumpVolumesSafe
if dump == nil {
dump = m.DumpAppVolumesSafe
}
for _, stack := range m.stackProvider.ListDeployedStacks() {
// Never stop/dump infra stacks (felhom-controller, traefik, cloudflared).
if m.cfg != nil && m.cfg.IsProtectedStack(stack.Name) {
continue
}
// Volume check FIRST — a volume-less stack must not be stopped at all (see gate-order note).
if len(m.stackProvider.GetDockerVolumes(stack.Name)) == 0 {
if m.isDebug() {
m.logger.Printf("[DEBUG] [backup] %s has no named volumes — volume dump skipped", stack.Name)
}
continue
}
// Same drive-state skip guards as the DB-dump loop.
drivePath := m.GetAppDrivePath(stack.Name)
if m.settings != nil && m.settings.IsDisconnected(drivePath) {
m.logger.Printf("[WARN] [backup] Skipping volume dump for %s — drive disconnected: %s", stack.Name, drivePath)
summary = append(summary, fmt.Sprintf("SKIP %s volumes (drive disconnected)", stack.Name))
continue
}
if m.settings != nil && m.settings.IsDecommissioned(drivePath) {
m.logger.Printf("[WARN] [backup] Skipping volume dump for %s — drive decommissioned: %s", stack.Name, drivePath)
summary = append(summary, fmt.Sprintf("SKIP %s volumes (drive decommissioned)", stack.Name))
continue
}
if err := dump(stack.Name); err != nil {
allOK = false
summary = append(summary, fmt.Sprintf("FAIL %s volumes: %v", stack.Name, err))
m.logger.Printf("[ERROR] [backup] Volume dump failed for %s: %v", stack.Name, err)
continue
}
dumped++
summary = append(summary, fmt.Sprintf("OK %s volumes", stack.Name))
}
return summary, dumped, allOK
}
// DumpAppVolumes exports Docker named volumes to tar files for the given stack.
// Tars are written to AppVolumeDumpPath(drivePath, stackName)/.
// Uses "docker run alpine tar" (same pattern as appexport).