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
+152 -3
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
@@ -493,6 +494,11 @@ func (m *Manager) backupDrive(ctx context.Context, drivePath string, stacks []St
if _, err := os.Stat(dumpDir); err == nil {
paths = append(paths, dumpDir)
}
// Docker volume dumps for this stack
volDumpDir := AppVolumeDumpPath(drivePath, stack.Name)
if _, err := os.Stat(volDumpDir); err == nil {
paths = append(paths, volDumpDir)
}
}
// Deduplicate paths
@@ -624,7 +630,107 @@ func (m *Manager) RunIntegrityCheck(ctx context.Context) error {
return nil
}
// RunFullBackup runs DB dumps followed by restic backup.
// 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).
func (m *Manager) DumpAppVolumes(stackName string) error {
if m.stackProvider == nil {
return nil
}
volumes := m.stackProvider.GetDockerVolumes(stackName)
if len(volumes) == 0 {
return nil
}
drivePath := m.GetAppDrivePath(stackName)
if drivePath == "" {
return fmt.Errorf("cannot determine drive path for %s", stackName)
}
dumpDir := AppVolumeDumpPath(drivePath, stackName)
if err := os.MkdirAll(dumpDir, 0755); err != nil {
return fmt.Errorf("creating volume dump dir: %w", err)
}
var dumpErrors []string
for _, volName := range volumes {
tarPath := filepath.Join(dumpDir, volName+".tar")
if m.isDebug() {
m.logger.Printf("[DEBUG] [backup] Dumping volume %s for %s", volName, stackName)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
cmd := exec.CommandContext(ctx, "docker", "run", "--rm",
"-v", volName+":/vol:ro",
"-v", dumpDir+":/out",
"alpine", "tar", "cf", "/out/"+volName+".tar", "-C", "/vol", ".")
out, err := cmd.CombinedOutput()
cancel()
if err != nil {
m.logger.Printf("[WARN] [backup] Volume dump failed for %s/%s: %s — %v",
stackName, volName, strings.TrimSpace(string(out)), err)
os.Remove(tarPath)
dumpErrors = append(dumpErrors, volName)
continue
}
if info, _ := os.Stat(tarPath); info != nil {
m.logger.Printf("[INFO] [backup] Volume dump: %s/%s → %s", stackName, volName, humanizeBytes(info.Size()))
}
}
// Clean up tars for volumes that no longer exist
entries, _ := os.ReadDir(dumpDir)
activeVols := make(map[string]bool)
for _, v := range volumes {
activeVols[v+".tar"] = true
}
for _, e := range entries {
if !activeVols[e.Name()] && strings.HasSuffix(e.Name(), ".tar") {
os.Remove(filepath.Join(dumpDir, e.Name()))
if m.isDebug() {
m.logger.Printf("[DEBUG] [backup] Removed stale volume dump: %s/%s", stackName, e.Name())
}
}
}
if len(dumpErrors) > 0 {
return fmt.Errorf("volume dump failed for: %s", strings.Join(dumpErrors, ", "))
}
return nil
}
// runVolumeDumpsInternal dumps Docker named volumes for all deployed apps.
func (m *Manager) runVolumeDumpsInternal(ctx context.Context) error {
if m.stackProvider == nil {
return nil
}
stacks := m.stackProvider.ListDeployedStacks()
var dumped, failed int
for _, stack := range stacks {
if !stack.HasVolumes {
continue
}
if ctx.Err() != nil {
return ctx.Err()
}
if err := m.DumpAppVolumes(stack.Name); err != nil {
m.logger.Printf("[WARN] [backup] Volume dump error for %s: %v", stack.Name, err)
failed++
} else {
dumped++
}
}
if dumped > 0 || failed > 0 {
m.logger.Printf("[INFO] [backup] Volume dumps completed: %d ok, %d failed", dumped, failed)
}
return nil
}
// RunFullBackup runs DB dumps, volume dumps, then restic backup.
func (m *Manager) RunFullBackup(ctx context.Context) error {
if err := m.acquireRunning(); err != nil {
return err
@@ -643,13 +749,21 @@ func (m *Manager) RunFullBackup(ctx context.Context) error {
// Step 1: DB dumps
if m.isDebug() {
m.logger.Printf("[DEBUG] RunFullBackup: phase 1 — database dumps")
m.logger.Printf("[DEBUG] RunFullBackup: phase 1a — database dumps")
}
if err := m.runDBDumpsInternal(ctx); err != nil {
m.logger.Printf("[WARN] [backup] DB dump had errors, continuing with backup anyway")
}
// Step 2: Restic backup
// Step 2: Volume dumps
if m.isDebug() {
m.logger.Printf("[DEBUG] RunFullBackup: phase 1b — Docker volume dumps")
}
if err := m.runVolumeDumpsInternal(ctx); err != nil {
m.logger.Printf("[WARN] [backup] Volume dump had errors, continuing with backup anyway")
}
// Step 3: Restic backup
if m.isDebug() {
m.logger.Printf("[DEBUG] RunFullBackup: phase 2 — restic snapshots")
}
@@ -764,6 +878,41 @@ func (m *Manager) ListAllSnapshots(limit int) ([]SnapshotInfo, error) {
return allSnapshots, nil
}
// ListSnapshotsForApp returns snapshots only from the app's home drive primary repo.
// This prevents showing irrelevant snapshots from other drives (e.g. a 544 KB SYS_DRIVE
// snapshot appearing for Immich because it contains the shared stacks directory).
func (m *Manager) ListSnapshotsForApp(stackName string, limit int) ([]SnapshotInfo, error) {
drivePath := m.GetAppDrivePath(stackName)
if drivePath == "" {
return []SnapshotInfo{}, nil
}
repoPath := PrimaryResticRepoPath(drivePath)
if !m.restic.RepoExists(repoPath) {
return []SnapshotInfo{}, nil
}
snapshots, err := m.restic.ListSnapshots(repoPath, limit)
if err != nil {
return nil, err
}
for i := range snapshots {
snapshots[i].RepoPath = repoPath
snapshots[i].Tier = 1
snapshots[i].Source = "restic"
}
// Sort newest first
sort.Slice(snapshots, func(i, j int) bool {
return snapshots[i].Time.After(snapshots[j].Time)
})
if limit > 0 && len(snapshots) > limit {
snapshots = snapshots[:limit]
}
return snapshots, nil
}
// SetStackProvider sets the stack data provider for app data discovery.
// C3: Write is protected by mutex since stackProvider is read by concurrent goroutines.
func (m *Manager) SetStackProvider(provider StackDataProvider) {