db83db383c
Critical: watchdog mutex panic safety, SetGeoAppOverride nil guard, SSD-only app DB restore fallback. High: double deploy race (atomic Deploying flag), delete/remove during deploy guard, ScanStacks overwrite protection, FileBrowser mount mutex, PushEvent history, PushOnce error handling, DB dump sync+close before rename, restic retry fresh context, encrypt failure logging, cross-backup path traversal validation, deepCopyStack completeness. Security: constant-time API key comparison, login rate limiting (5/min), git credential masking in logs, storage path prefix traversal fix. Concurrency: MigrateEncryption lock ordering, SubdomainInUse I/O outside lock, scheduler late-registered jobs, SQLite WAL verification, metrics shutdown context, telemetry scan error logging, asset sync lock scope. Optimization: streaming file copy for DB dumps, restic stats dedup, atomic infra config copy. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
206 lines
6.5 KiB
Go
206 lines
6.5 KiB
Go
//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()
|
|
}
|