//go:build linux package backup import ( "context" "fmt" "log" "os" "os/exec" "path/filepath" "strings" ) // RestoreAppFromBackup restores a single app from its cross-drive backup. // Steps: restore config → verify/restore data → copy DB dumps → docker compose up. func RestoreAppFromBackup(ctx context.Context, app *RestorableApp, stacksDir string, logger *log.Logger) error { stackDir := filepath.Join(stacksDir, app.Name) // Step 1: Restore stack config from _config/ backup if app.HasConfig { logger.Printf("[INFO] Restoring config for %s from %s", app.Name, app.ConfigPath) if err := restoreConfigDir(ctx, app.ConfigPath, stackDir); err != nil { return fmt.Errorf("restoring config: %w", err) } } else { // No config backup — check if stack dir already exists (from catalog sync) if !dirExists(stackDir) { return fmt.Errorf("no config backup and no stack directory for %s", app.Name) } logger.Printf("[INFO] No config backup for %s — using existing stack dir", app.Name) } // Step 2: Verify app data on HDD (common case: HDD survived, data is intact) if app.NeedsHDD && !app.HasData && app.HasRsyncData { // App data is missing but rsync backup exists — restore it logger.Printf("[INFO] Restoring user data for %s from rsync backup", app.Name) if err := restoreUserData(ctx, app, logger); err != nil { logger.Printf("[WARN] User data restore failed for %s: %v", app.Name, err) // Non-fatal: app might still start without all data } } else if app.HasData { logger.Printf("[INFO] App data for %s found at %s — no restore needed", app.Name, app.DataPath) } // Step 3: Copy DB dumps to primary backup location if app.HasDBDump { logger.Printf("[INFO] Restoring DB dumps for %s", app.Name) if err := restoreDBDumps(app, logger); err != nil { logger.Printf("[WARN] DB dump restore failed for %s: %v", app.Name, err) // Non-fatal } } // Step 4: Docker compose pull + up composePath := filepath.Join(stackDir, "docker-compose.yml") if !fileExistsCheck(composePath) { composePath = filepath.Join(stackDir, "compose.yml") if !fileExistsCheck(composePath) { return fmt.Errorf("no compose file found in %s", stackDir) } } composeDir := filepath.Dir(composePath) logger.Printf("[INFO] Pulling images for %s", app.Name) pullCmd := exec.CommandContext(ctx, "docker", "compose", "-f", composePath, "pull") pullCmd.Dir = composeDir if out, err := pullCmd.CombinedOutput(); err != nil { logger.Printf("[WARN] docker compose pull failed for %s: %v (%s)", app.Name, err, strings.TrimSpace(string(out))) // Non-fatal: might work with cached images } logger.Printf("[INFO] Starting %s", app.Name) upCmd := exec.CommandContext(ctx, "docker", "compose", "-f", composePath, "up", "-d") upCmd.Dir = composeDir if out, err := upCmd.CombinedOutput(); err != nil { return fmt.Errorf("docker compose up: %v (%s)", err, strings.TrimSpace(string(out))) } return nil } // restoreConfigDir rsyncs the backed-up _config/ directory to the stack directory. func restoreConfigDir(ctx context.Context, configBackupDir, stackDir string) error { if err := os.MkdirAll(stackDir, 0755); err != nil { return fmt.Errorf("creating stack dir: %w", err) } src := strings.TrimRight(configBackupDir, "/") + "/" dst := strings.TrimRight(stackDir, "/") + "/" cmd := exec.CommandContext(ctx, "rsync", "-a", src, dst) if out, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("rsync config: %v (%s)", err, strings.TrimSpace(string(out))) } return nil } // restoreUserData rsyncs user data from cross-drive backup back to the app's HDD path. func restoreUserData(ctx context.Context, app *RestorableApp, logger *log.Logger) error { if app.RsyncDataPath == "" || app.HDDPath == "" { return fmt.Errorf("no rsync data path or HDD path") } // The rsync backup contains the app's data directories. // Walk the backup dir and rsync each subdirectory (excluding _config/_db) // back to the app's HDD data directory. entries, err := os.ReadDir(app.RsyncDataPath) if err != nil { return err } dataDir := AppDataDir(app.HDDPath, app.Name) if err := os.MkdirAll(dataDir, 0755); err != nil { return fmt.Errorf("creating data dir: %w", err) } for _, e := range entries { name := e.Name() if name == "_config" || name == "_db" || strings.HasPrefix(name, ".") { continue } src := filepath.Join(app.RsyncDataPath, name) dst := filepath.Join(dataDir, name) if e.IsDir() { src = strings.TrimRight(src, "/") + "/" if err := os.MkdirAll(dst, 0755); err != nil { logger.Printf("[WARN] Cannot create %s: %v", dst, err) continue } dst = strings.TrimRight(dst, "/") + "/" cmd := exec.CommandContext(ctx, "rsync", "-a", src, dst) if out, err := cmd.CombinedOutput(); err != nil { logger.Printf("[WARN] rsync data %s: %v (%s)", name, err, strings.TrimSpace(string(out))) } } else { // Single file — copy directly data, err := os.ReadFile(src) if err != nil { logger.Printf("[WARN] Cannot read %s: %v", src, err) continue } if err := os.WriteFile(dst, data, 0644); err != nil { logger.Printf("[WARN] Cannot write %s: %v", dst, err) } } } return nil } // restoreDBDumps copies DB dump files from cross-drive backup to the primary dump dir. func restoreDBDumps(app *RestorableApp, logger *log.Logger) error { if app.DBDumpPath == "" { return nil } // Use HDDPath for apps with HDD data, fall back to DrivePath (system data path) // for SSD-only apps whose DB dumps live under the system drive. drivePath := app.HDDPath if drivePath == "" { drivePath = app.DrivePath } if drivePath == "" { logger.Printf("[WARN] Cannot restore DB dumps for %s: no drive path", app.Name) return nil } destDir := AppDBDumpPath(drivePath, app.Name) if err := os.MkdirAll(destDir, 0755); err != nil { return fmt.Errorf("creating dump dir: %w", err) } entries, err := os.ReadDir(app.DBDumpPath) if err != nil { return err } for _, e := range entries { if e.IsDir() { continue } src := filepath.Join(app.DBDumpPath, e.Name()) dst := filepath.Join(destDir, e.Name()) data, err := os.ReadFile(src) if err != nil { logger.Printf("[WARN] Cannot read dump %s: %v", e.Name(), err) continue } if err := os.WriteFile(dst, data, 0644); err != nil { logger.Printf("[WARN] Cannot write dump %s: %v", e.Name(), err) } } return nil } // fileExistsCheck returns true if path exists and is a file. func fileExistsCheck(path string) bool { info, err := os.Stat(path) return err == nil && !info.IsDir() }