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
+38 -1
View File
@@ -795,7 +795,18 @@ func (r *Router) backupSnapshots(w http.ResponseWriter, req *http.Request) {
return
}
snapshots, err := r.backupMgr.ListAllSnapshots(50)
stackName := req.URL.Query().Get("stack")
var snapshots []backup.SnapshotInfo
var err error
if stackName != "" {
// Per-app: only snapshots from the app's home drive
snapshots, err = r.backupMgr.ListSnapshotsForApp(stackName, 20)
} else {
// Fallback: all snapshots (general use)
snapshots, err = r.backupMgr.ListAllSnapshots(50)
}
if err != nil {
r.logger.Printf("[ERROR] [api] Failed to list backup snapshots: %v", err)
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
@@ -814,6 +825,32 @@ func (r *Router) backupSnapshots(w http.ResponseWriter, req *http.Request) {
}
}
}
// Append Tier 2 (cross-drive rsync) entry if available for this app
if stackName != "" {
cdCfg := r.sett.GetCrossDriveConfig(stackName)
if cdCfg != nil && cdCfg.Enabled && cdCfg.LastStatus == "ok" && cdCfg.LastRun != "" {
lastRun, _ := time.Parse(time.RFC3339, cdCfg.LastRun)
if !lastRun.IsZero() {
// Resolve drive label for destination
var destLabel string
for _, sp := range storagePaths {
if sp.Path == cdCfg.DestinationPath {
destLabel = sp.Label
break
}
}
tier2 := backup.SnapshotInfo{
ID: "tier2-rsync",
Time: lastRun,
Tier: 2,
Source: "rsync",
DriveLabel: destLabel,
}
snapshots = append(snapshots, tier2)
}
}
}
}
if snapshots == nil {
+21 -1
View File
@@ -6,6 +6,7 @@ import (
"log"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
@@ -17,7 +18,8 @@ type StackDataProvider interface {
GetStackComposePath(name string) (composePath string, ok bool)
ListDeployedStacks() []StackSummary
GetStackHDDMounts(name string) []string
GetStackHDDPath(name string) string // raw HDD_PATH from app.yaml (empty if no HDD)
GetStackHDDPath(name string) string // raw HDD_PATH from app.yaml (empty if no HDD)
GetDockerVolumes(name string) []string // full Docker volume names (project-prefixed)
StopStack(name string) error
StartStack(name string) error
}
@@ -28,6 +30,7 @@ type StackSummary struct {
DisplayName string
ComposePath string
NeedsHDD bool
HasVolumes bool
}
// AppBackupInfo holds backup-relevant data paths for a deployed app.
@@ -42,6 +45,7 @@ type AppBackupInfo struct {
BackupEnabled bool
HasHDDData bool
HasDBDump bool
HasVolumeData bool
StorageLabel string // resolved from registered storage paths
}
@@ -91,6 +95,7 @@ func DiscoverAppData(provider StackDataProvider, discoveredDBs []DiscoveredDB) [
// Discover Docker named volumes from compose
info.DockerVolumes = ParseComposeNamedVolumes(stack.ComposePath)
info.HasVolumeData = len(info.DockerVolumes) > 0
// Check if app has a DB container (already backed up via DB dump)
for _, db := range discoveredDBs {
@@ -137,6 +142,21 @@ func ParseComposeNamedVolumes(composePath string) []AppDockerVolume {
return volumes
}
// ResolveDockerVolumeNames returns full Docker volume names with the compose project prefix.
// Docker Compose V2 creates volumes as <project>_<volumeName> where project = directory name.
func ResolveDockerVolumeNames(composePath string) []string {
vols := ParseComposeNamedVolumes(composePath)
if len(vols) == 0 {
return nil
}
project := filepath.Base(filepath.Dir(composePath))
names := make([]string, 0, len(vols))
for _, v := range vols {
names = append(names, project+"_"+v.Name)
}
return names
}
// appDirSize returns the total byte count and a human-readable string for a directory.
// H2/H3: Single du invocation with 30s timeout replaces two separate calls.
func appDirSize(path string) (int64, string) {
+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) {
+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
+5
View File
@@ -20,6 +20,11 @@ func AppDBDumpPath(drivePath, stackName string) string {
return filepath.Join(drivePath, FelhomDataDir, "backups", "primary", stackName, "db-dumps")
}
// AppVolumeDumpPath returns the directory for Docker volume dump tars on an app's home drive.
func AppVolumeDumpPath(drivePath, stackName string) string {
return filepath.Join(drivePath, FelhomDataDir, "backups", "primary", stackName, "volume-dumps")
}
// SecondaryBackupPath returns the root secondary backup directory for a drive.
func SecondaryBackupPath(drivePath string) string {
return filepath.Join(drivePath, FelhomDataDir, "backups", "secondary")
+1
View File
@@ -44,6 +44,7 @@ type SnapshotInfo struct {
RepoPath string `json:"-"` // set by caller for multi-repo aggregation
Tier int `json:"tier"` // 1 = primary, 2 = secondary
DriveLabel string `json:"drive_label"` // filled by caller from settings
Source string `json:"source"` // "restic" or "rsync"
}
// RepoStats holds repository statistics.
+275 -3
View File
@@ -1,9 +1,14 @@
package backup
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
)
// snapshotIDRe validates restic snapshot IDs: 8-64 lowercase hex characters.
@@ -76,6 +81,10 @@ func (m *Manager) RestoreApp(stackName, snapshotID string) error {
}
}
// Restore Docker volume dumps (if present in snapshot)
volDumpDir := AppVolumeDumpPath(drivePath, stackName)
restorePaths = append(restorePaths, volDumpDir)
if len(restorePaths) == 0 {
return fmt.Errorf("no restorable paths found for %s", stackName)
}
@@ -113,21 +122,284 @@ func (m *Manager) RestoreApp(stackName, snapshotID string) error {
return err
}
// Populate Docker volumes from restored tars
if m.isDebug() {
m.logger.Printf("[DEBUG] RestoreApp: step 3/5 — restoring Docker volumes for %s", stackName)
}
if err := m.restoreDockerVolumes(stackName, drivePath); err != nil {
m.logger.Printf("[WARN] RESTORE volume restore failed for %s: %v (continuing)", stackName, err)
}
// Restart the app
if m.isDebug() {
m.logger.Printf("[DEBUG] RestoreApp: step 3/4 — restarting app %s after successful restore", stackName)
m.logger.Printf("[DEBUG] RestoreApp: step 4/5 — restarting app %s after successful restore", stackName)
}
if err := m.stackProvider.StartStack(stackName); err != nil {
m.logger.Printf("[WARN] RESTORE could not restart %s after restore: %v", stackName, err)
}
hasVolumes := len(m.stackProvider.GetDockerVolumes(stackName)) > 0
restoreType := "config+DB"
if hasHDD {
if hasHDD || hasVolumes {
restoreType = "full (config+DB+userdata)"
}
if m.isDebug() {
m.logger.Printf("[DEBUG] RestoreApp: step 4/4 — restore completed, type=%s", restoreType)
m.logger.Printf("[DEBUG] RestoreApp: step 5/5 — restore completed, type=%s", restoreType)
}
m.logger.Printf("[INFO] RESTORE completed: stack=%s, snapshot=%s, type=%s", stackName, snapshotID, restoreType)
return nil
}
// RestoreAppFromTier2 restores an app from its cross-drive rsync backup mirror.
func (m *Manager) RestoreAppFromTier2(stackName string) error {
if m.stackProvider == nil {
return fmt.Errorf("stack provider not configured")
}
if m.settings == nil {
return fmt.Errorf("settings not available")
}
cdCfg := m.settings.GetCrossDriveConfig(stackName)
if cdCfg == nil || !cdCfg.Enabled {
return fmt.Errorf("cross-drive backup not configured for %s", stackName)
}
rsyncDir := AppSecondaryRsyncPath(cdCfg.DestinationPath, stackName)
if _, err := os.Stat(rsyncDir); os.IsNotExist(err) {
return fmt.Errorf("Tier 2 backup directory not found: %s", rsyncDir)
}
if m.isDebug() {
m.logger.Printf("[DEBUG] RestoreAppFromTier2: stack=%s, rsyncDir=%s", stackName, rsyncDir)
}
// Prevent concurrent operations
m.mu.Lock()
if m.running {
m.mu.Unlock()
return fmt.Errorf("backup or restore already in progress")
}
m.running = true
m.mu.Unlock()
defer func() {
m.mu.Lock()
m.running = false
m.mu.Unlock()
}()
hddMounts := m.stackProvider.GetStackHDDMounts(stackName)
hasHDD := len(hddMounts) > 0
drivePath := m.GetAppDrivePath(stackName)
m.logger.Printf("[INFO] [backup] Starting Tier 2 restore for %s from %s", stackName, rsyncDir)
// Step 1: Stop the app
if err := m.stackProvider.StopStack(stackName); err != nil {
m.logger.Printf("[WARN] RESTORE could not stop %s: %v (proceeding anyway)", stackName, err)
}
// Step 2: Restore config from _config/
configSrc := filepath.Join(rsyncDir, "_config") + "/"
if _, err := os.Stat(filepath.Join(rsyncDir, "_config")); err == nil {
if composePath, ok := m.stackProvider.GetStackComposePath(stackName); ok {
configDst := filepath.Dir(composePath) + "/"
if m.isDebug() {
m.logger.Printf("[DEBUG] RestoreAppFromTier2: rsync config %s → %s", configSrc, configDst)
}
cmd := exec.Command("rsync", "-a", "--delete", configSrc, configDst)
if out, err := cmd.CombinedOutput(); err != nil {
m.logger.Printf("[ERROR] [backup] Tier 2 config restore failed for %s: %v (%s)", stackName, err, strings.TrimSpace(string(out)))
// Try to restart and return error
m.stackProvider.StartStack(stackName)
return fmt.Errorf("config restore failed: %w", err)
}
}
}
// Step 3: Restore HDD data
if hasHDD {
// Check for data directory structure — single mount vs multi-mount
if len(hddMounts) == 1 {
// Single mount: data is directly in rsyncDir (excluding _* dirs)
src := strings.TrimRight(rsyncDir, "/") + "/"
dst := strings.TrimRight(hddMounts[0], "/") + "/"
if m.isDebug() {
m.logger.Printf("[DEBUG] RestoreAppFromTier2: rsync HDD data %s → %s", src, dst)
}
cmd := exec.Command("rsync", "-a", "--delete",
"--exclude", "_*",
src, dst)
if out, err := cmd.CombinedOutput(); err != nil {
m.logger.Printf("[ERROR] [backup] Tier 2 HDD data restore failed for %s: %v (%s)", stackName, err, strings.TrimSpace(string(out)))
m.stackProvider.StartStack(stackName)
return fmt.Errorf("HDD data restore failed: %w", err)
}
} else {
// Multiple mounts: each has a subdirectory named by leaf
for _, mount := range hddMounts {
leaf := filepath.Base(mount)
src := filepath.Join(rsyncDir, leaf) + "/"
dst := strings.TrimRight(mount, "/") + "/"
if _, err := os.Stat(filepath.Join(rsyncDir, leaf)); os.IsNotExist(err) {
m.logger.Printf("[WARN] [backup] Tier 2 restore: no backup data for mount %s", mount)
continue
}
if m.isDebug() {
m.logger.Printf("[DEBUG] RestoreAppFromTier2: rsync HDD mount %s → %s", src, dst)
}
cmd := exec.Command("rsync", "-a", "--delete", src, dst)
if out, err := cmd.CombinedOutput(); err != nil {
m.logger.Printf("[ERROR] [backup] Tier 2 HDD restore failed for mount %s: %v (%s)", mount, err, strings.TrimSpace(string(out)))
m.stackProvider.StartStack(stackName)
return fmt.Errorf("HDD restore failed for %s: %w", mount, err)
}
}
}
}
// Step 4: Restore DB dumps from _db/
dbSrc := filepath.Join(rsyncDir, "_db")
if _, err := os.Stat(dbSrc); err == nil {
dbDst := AppDBDumpPath(drivePath, stackName)
if err := os.MkdirAll(dbDst, 0755); err == nil {
entries, _ := os.ReadDir(dbSrc)
for _, e := range entries {
if !e.IsDir() {
src := filepath.Join(dbSrc, e.Name())
dst := filepath.Join(dbDst, e.Name())
if data, err := os.ReadFile(src); err == nil {
os.WriteFile(dst, data, 0644)
}
}
}
if m.isDebug() {
m.logger.Printf("[DEBUG] RestoreAppFromTier2: restored DB dumps from %s", dbSrc)
}
}
}
// Step 5: Restore Docker volumes from _volumes/
volSrc := filepath.Join(rsyncDir, "_volumes")
if _, err := os.Stat(volSrc); err == nil {
if err := m.restoreDockerVolumesFromDir(stackName, volSrc); err != nil {
m.logger.Printf("[WARN] [backup] Tier 2 volume restore failed for %s: %v (continuing)", stackName, err)
}
}
// Step 6: Restart the app
if err := m.stackProvider.StartStack(stackName); err != nil {
m.logger.Printf("[WARN] RESTORE could not restart %s after Tier 2 restore: %v", stackName, err)
}
hasVolumes := len(m.stackProvider.GetDockerVolumes(stackName)) > 0
restoreType := "config+DB"
if hasHDD || hasVolumes {
restoreType = "full (config+DB+userdata)"
}
m.logger.Printf("[INFO] RESTORE (Tier 2) completed: stack=%s, type=%s", stackName, restoreType)
return nil
}
// restoreDockerVolumesFromDir populates Docker volumes from tar files in an arbitrary directory.
// Used by Tier 2 restore where volume tars are in the rsync mirror's _volumes/ dir.
func (m *Manager) restoreDockerVolumesFromDir(stackName, dumpDir string) error {
entries, err := os.ReadDir(dumpDir)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("reading volume dump dir: %w", err)
}
var restored int
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".tar") {
continue
}
volName := strings.TrimSuffix(entry.Name(), ".tar")
m.logger.Printf("[INFO] [backup] Restoring Docker volume %s for %s (Tier 2)", volName, stackName)
exec.Command("docker", "volume", "rm", "-f", volName).Run()
if out, err := exec.Command("docker", "volume", "create", volName).CombinedOutput(); err != nil {
m.logger.Printf("[WARN] [backup] Failed to create volume %s: %s — %v", volName, strings.TrimSpace(string(out)), err)
continue
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
cmd := exec.CommandContext(ctx, "docker", "run", "--rm",
"-v", volName+":/vol",
"-v", dumpDir+":/in:ro",
"alpine", "tar", "xf", "/in/"+entry.Name(), "-C", "/vol")
out, err := cmd.CombinedOutput()
cancel()
if err != nil {
m.logger.Printf("[WARN] [backup] Failed to populate volume %s: %s — %v", volName, strings.TrimSpace(string(out)), err)
continue
}
restored++
}
if restored > 0 {
m.logger.Printf("[INFO] [backup] Restored %d Docker volume(s) for %s (Tier 2)", restored, stackName)
}
return nil
}
// restoreDockerVolumes populates Docker volumes from tar files in the volume dump directory.
func (m *Manager) restoreDockerVolumes(stackName, drivePath string) error {
dumpDir := AppVolumeDumpPath(drivePath, stackName)
entries, err := os.ReadDir(dumpDir)
if err != nil {
if os.IsNotExist(err) {
return nil // No volume dumps to restore
}
return fmt.Errorf("reading volume dump dir: %w", err)
}
var restored int
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".tar") {
continue
}
volName := strings.TrimSuffix(entry.Name(), ".tar")
m.logger.Printf("[INFO] [backup] Restoring Docker volume %s for %s", volName, stackName)
// Remove existing volume (ignore errors — may not exist)
exec.Command("docker", "volume", "rm", "-f", volName).Run()
// Create fresh volume
if out, err := exec.Command("docker", "volume", "create", volName).CombinedOutput(); err != nil {
m.logger.Printf("[WARN] [backup] Failed to create volume %s: %s — %v", volName, strings.TrimSpace(string(out)), err)
continue
}
// Populate from tar
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
cmd := exec.CommandContext(ctx, "docker", "run", "--rm",
"-v", volName+":/vol",
"-v", dumpDir+":/in:ro",
"alpine", "tar", "xf", "/in/"+entry.Name(), "-C", "/vol")
out, err := cmd.CombinedOutput()
cancel()
if err != nil {
m.logger.Printf("[WARN] [backup] Failed to populate volume %s: %s — %v", volName, strings.TrimSpace(string(out)), err)
continue
}
restored++
if m.isDebug() {
m.logger.Printf("[DEBUG] [backup] Volume %s restored successfully", volName)
}
}
if restored > 0 {
m.logger.Printf("[INFO] [backup] Restored %d Docker volume(s) for %s", restored, stackName)
}
return nil
}
+7 -1
View File
@@ -1094,7 +1094,13 @@ func (s *Server) backupRestoreHandler(w http.ResponseWriter, r *http.Request) {
s.logger.Printf("[WARN] [web] Restore requested: stack=%s, snapshot=%s from %s", stackName, snapshotID, r.RemoteAddr)
start := time.Now()
if err := s.backupMgr.RestoreApp(stackName, snapshotID); err != nil {
var err error
if snapshotID == "tier2-rsync" {
err = s.backupMgr.RestoreAppFromTier2(stackName)
} else {
err = s.backupMgr.RestoreApp(stackName, snapshotID)
}
if err != nil {
s.logger.Printf("[ERROR] [web] Restore failed: %v", err)
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] backupRestoreHandler: stack=%s failed after %s", stackName, time.Since(start))
@@ -268,6 +268,8 @@
{{else if .HasHDDData}}
{{if .StorageLabel}}<span class="meta-badge meta-badge-storage">{{.StorageLabel}}</span>{{end}}
<span class="mono app-backup-size" style="font-size:.8rem">{{.HDDSizeHuman}}</span>
{{else if .HasVolumeData}}
<span class="meta-badge">Konfig{{if .HasDB}} + DB{{end}} + Adatok</span>
{{else}}
<span class="meta-badge">Konfig{{if .HasDB}} + DB{{end}}</span>
{{end}}
@@ -540,7 +542,7 @@
<select id="restore-app" class="restore-select" onchange="onRestoreAppChange()">
<option value="">— Válasszon —</option>
{{range .Backup.AppDataInfo}}
<option value="{{.StackName}}" data-has-hdd="{{.HasHDDData}}" data-has-db="{{.HasDBDump}}">{{.DisplayName}}</option>
<option value="{{.StackName}}" data-has-hdd="{{.HasHDDData}}" data-has-db="{{.HasDBDump}}" data-has-volumes="{{.HasVolumeData}}">{{.DisplayName}}</option>
{{end}}
</select>
</div>
@@ -568,6 +570,9 @@
<div class="restore-actions">
<button type="button" class="btn btn-sm btn-danger" id="restore-btn" disabled onclick="submitRestore()">Visszaállítás indítása</button>
</div>
<div style="margin-top: 1rem; text-align: center; border-top: 1px solid var(--border); padding-top: 1rem;">
<a href="/import" class="btn btn-sm btn-outline">Importálás mentett csomagból (.fab)</a>
</div>
</div>
</div>
{{end}}
@@ -731,8 +736,9 @@ function onRestoreAppChange() {
var opt = sel.options[sel.selectedIndex];
var hasHDD = opt.getAttribute('data-has-hdd') === 'true';
var hasDB = opt.getAttribute('data-has-db') === 'true';
var hasVolumes = opt.getAttribute('data-has-volumes') === 'true';
if (hasHDD) {
if (hasHDD || hasVolumes) {
typeInfo.innerHTML = '🔄 Teljes visszaállítás: adatbázis + konfiguráció + felhasználói adatok a kiválasztott pillanatképből.';
typeInfo.className = 'restore-info';
} else if (hasDB) {