feat: Docker volume backup, Tier 2 restore, restore dropdown fixes (v0.33.0)

- Add Docker named volume backup to Tier 1 (dump to tar, include in restic)
  and Tier 2 (copy tars to rsync mirror _volumes/ dir)
- Fix volume name resolution: use project-prefixed names (mealie_mealie_data)
- Fix double Tier 1 in restore dropdown: filter snapshots by app's home drive
- Add Tier 2 restore: RestoreAppFromTier2() restores from rsync mirror
- Show Tier 2 entry in restore dropdown when cross-drive backup succeeded
- Add .fab import link in restore section
- Volume-aware restore type banners and backup content labels

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 21:43:02 +01:00
parent 5bf13ca19d
commit c929948f27
12 changed files with 655 additions and 45 deletions
+62 -1
View File
@@ -22,11 +22,17 @@ type DBDumper interface {
DumpStackDB(ctx context.Context, stackName string) error
}
// VolumeDumper can dump Docker named volumes for a specific stack.
type VolumeDumper interface {
DumpAppVolumes(stackName string) error
}
// CrossDriveRunner handles per-app backup to secondary storage.
type CrossDriveRunner struct {
sett *settings.Settings
stackProvider StackDataProvider
dbDumper DBDumper
volDumper VolumeDumper
systemDataPath string // fallback drive for SSD-only apps
stacksDir string // path to stacks dir (for infra backup)
controllerYAMLPath string // path to controller.yaml (for infra backup)
@@ -56,6 +62,11 @@ func (r *CrossDriveRunner) SetDBDumper(d DBDumper) {
r.dbDumper = d
}
// SetVolumeDumper sets the volume dumper for pre-backup Docker volume dumps.
func (r *CrossDriveRunner) SetVolumeDumper(d VolumeDumper) {
r.volDumper = d
}
// GetAppDrivePath returns the drive path for an app (HDD path or system data path fallback).
func (r *CrossDriveRunner) GetAppDrivePath(stackName string) string {
if hddPath := r.stackProvider.GetStackHDDPath(stackName); hddPath != "" {
@@ -125,7 +136,16 @@ func (r *CrossDriveRunner) RunAppBackup(ctx context.Context, stackName string) e
}
if err := r.dbDumper.DumpStackDB(ctx, stackName); err != nil {
r.logger.Printf("[WARN] [backup] Pre-backup DB dump failed for %s: %v — proceeding with user data backup", stackName, err)
// Non-fatal: user data backup is still valuable without fresh dump
}
}
// Trigger fresh volume dump for this app before cross-drive backup
if r.volDumper != nil {
if r.debug {
r.logger.Printf("[DEBUG] RunAppBackup: triggering pre-backup volume dump for %s", stackName)
}
if err := r.volDumper.DumpAppVolumes(stackName); err != nil {
r.logger.Printf("[WARN] [backup] Pre-backup volume dump failed for %s: %v — proceeding with backup", stackName, err)
}
}
@@ -441,6 +461,16 @@ func (r *CrossDriveRunner) runRsyncBackup(ctx context.Context, stackName, destBa
// Non-fatal: user data is the primary concern
}
// --- Copy volume dumps for this stack from its home drive ---
volDestDir := filepath.Join(destDir, "_volumes")
if err := os.MkdirAll(volDestDir, 0755); err != nil {
return fmt.Errorf("creating volume dump dest dir: %w", err)
}
if err := r.copyStackVolumeDumps(stackName, volDestDir); err != nil {
r.logger.Printf("[WARN] [backup] Cross-drive volume dump copy failed for %s: %v", stackName, err)
// Non-fatal: user data is the primary concern
}
// --- Rsync app config (compose dir) ---
if composePath, ok := r.stackProvider.GetStackComposePath(stackName); ok {
configSrcDir := filepath.Dir(composePath)
@@ -494,6 +524,37 @@ func (r *CrossDriveRunner) copyStackDBDumps(stackName, destDir string) error {
return nil
}
// copyStackVolumeDumps copies Docker volume dump tars for the given stack from its home drive.
func (r *CrossDriveRunner) copyStackVolumeDumps(stackName, destDir string) error {
appDrive := r.GetAppDrivePath(stackName)
dumpDir := AppVolumeDumpPath(appDrive, stackName)
entries, err := os.ReadDir(dumpDir)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("reading volume dump dir: %w", err)
}
copied := 0
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".tar") {
continue
}
src := filepath.Join(dumpDir, e.Name())
dst := filepath.Join(destDir, e.Name())
if err := copyFile(src, dst); err != nil {
return fmt.Errorf("copying %s: %w", e.Name(), err)
}
copied++
}
if copied > 0 {
r.logger.Printf("[INFO] [backup] Copied %d volume dump(s) for %s", copied, stackName)
}
return nil
}
// --- infra backup ---
// syncInfraConfig rsyncs infrastructure config (stacks dir + controller.yaml) to all