95c821deb2
Add detailed [DEBUG] logging to every controller module when logging.level is set to "debug". Each module with stateful debug uses SetDebug(bool) wired from main.go. Covers stacks, backup, cloudflare, integrations, system, monitor, settings, scheduler, web handlers, storage, metrics, API, selfupdate, and assets. Also includes the app export/import (.fab bundles) feature from v0.32.0 and its debug page integration. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
247 lines
8.9 KiB
Go
247 lines
8.9 KiB
Go
//go:build linux
|
|
|
|
package backup
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// 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)
|
|
start := time.Now()
|
|
|
|
logger.Printf("[DEBUG] [backup] RestoreAppFromBackup: app=%s, stackDir=%s, hasConfig=%v, hasData=%v, hasDBDump=%v, hasRsyncData=%v",
|
|
app.Name, stackDir, app.HasConfig, app.HasData, app.HasDBDump, app.HasRsyncData)
|
|
|
|
// Step 1: Restore stack config from _config/ backup
|
|
if app.HasConfig {
|
|
logger.Printf("[INFO] Restoring config for %s from %s", app.Name, app.ConfigPath)
|
|
stepStart := time.Now()
|
|
if err := restoreConfigDir(ctx, app.ConfigPath, stackDir); err != nil {
|
|
return fmt.Errorf("restoring config: %w", err)
|
|
}
|
|
logger.Printf("[DEBUG] [backup] RestoreAppFromBackup: config restore for %s completed in %s", app.Name, time.Since(stepStart))
|
|
} 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)
|
|
stepStart := time.Now()
|
|
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 {
|
|
logger.Printf("[DEBUG] [backup] RestoreAppFromBackup: user data restore for %s completed in %s", app.Name, time.Since(stepStart))
|
|
}
|
|
} else if app.HasData {
|
|
logger.Printf("[INFO] App data for %s found at %s — no restore needed", app.Name, app.DataPath)
|
|
} else {
|
|
logger.Printf("[DEBUG] [backup] RestoreAppFromBackup: %s — no user data to restore (needsHDD=%v, hasData=%v, hasRsyncData=%v)",
|
|
app.Name, app.NeedsHDD, app.HasData, app.HasRsyncData)
|
|
}
|
|
|
|
// Step 3: Copy DB dumps to primary backup location
|
|
if app.HasDBDump {
|
|
logger.Printf("[INFO] Restoring DB dumps for %s", app.Name)
|
|
stepStart := time.Now()
|
|
if err := restoreDBDumps(app, logger); err != nil {
|
|
logger.Printf("[WARN] DB dump restore failed for %s: %v", app.Name, err)
|
|
// Non-fatal
|
|
} else {
|
|
logger.Printf("[DEBUG] [backup] RestoreAppFromBackup: DB dump restore for %s completed in %s", app.Name, time.Since(stepStart))
|
|
}
|
|
}
|
|
|
|
// 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("[DEBUG] [backup] RestoreAppFromBackup: %s using compose file %s", app.Name, composePath)
|
|
|
|
logger.Printf("[INFO] Pulling images for %s", app.Name)
|
|
pullStart := time.Now()
|
|
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
|
|
} else {
|
|
logger.Printf("[DEBUG] [backup] RestoreAppFromBackup: docker compose pull for %s completed in %s", app.Name, time.Since(pullStart))
|
|
}
|
|
|
|
logger.Printf("[INFO] Starting %s", app.Name)
|
|
upStart := time.Now()
|
|
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)))
|
|
}
|
|
|
|
logger.Printf("[DEBUG] [backup] RestoreAppFromBackup: %s fully restored and started in %s", app.Name, time.Since(start))
|
|
logger.Printf("[DEBUG] [backup] RestoreAppFromBackup: docker compose up for %s completed in %s", app.Name, time.Since(upStart))
|
|
|
|
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")
|
|
}
|
|
|
|
logger.Printf("[DEBUG] [backup] restoreUserData: app=%s, rsyncPath=%s, hddPath=%s", app.Name, app.RsyncDataPath, app.HDDPath)
|
|
|
|
// 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)
|
|
logger.Printf("[DEBUG] [backup] restoreUserData: %s — target dataDir=%s, %d entries in backup", app.Name, dataDir, len(entries))
|
|
if err := os.MkdirAll(dataDir, 0755); err != nil {
|
|
return fmt.Errorf("creating data dir: %w", err)
|
|
}
|
|
|
|
restored := 0
|
|
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, "/") + "/"
|
|
logger.Printf("[DEBUG] [backup] restoreUserData: %s — rsync dir %s → %s", app.Name, src, 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 {
|
|
restored++
|
|
}
|
|
} else {
|
|
// Single file — copy directly
|
|
data, err := os.ReadFile(src)
|
|
if err != nil {
|
|
logger.Printf("[WARN] Cannot read %s: %v", src, err)
|
|
continue
|
|
}
|
|
logger.Printf("[DEBUG] [backup] restoreUserData: %s — copying file %s (%d bytes)", app.Name, name, len(data))
|
|
if err := os.WriteFile(dst, data, 0644); err != nil {
|
|
logger.Printf("[WARN] Cannot write %s: %v", dst, err)
|
|
} else {
|
|
restored++
|
|
}
|
|
}
|
|
}
|
|
|
|
logger.Printf("[DEBUG] [backup] restoreUserData: %s — restored %d items", app.Name, restored)
|
|
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)
|
|
logger.Printf("[DEBUG] [backup] restoreDBDumps: app=%s, src=%s, destDir=%s", app.Name, app.DBDumpPath, destDir)
|
|
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
|
|
}
|
|
|
|
copied := 0
|
|
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
|
|
}
|
|
logger.Printf("[DEBUG] [backup] restoreDBDumps: %s — copying %s (%d bytes)", app.Name, e.Name(), len(data))
|
|
if err := os.WriteFile(dst, data, 0644); err != nil {
|
|
logger.Printf("[WARN] Cannot write dump %s: %v", e.Name(), err)
|
|
} else {
|
|
copied++
|
|
}
|
|
}
|
|
|
|
logger.Printf("[DEBUG] [backup] restoreDBDumps: %s — copied %d dump files", app.Name, copied)
|
|
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()
|
|
}
|