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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user