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