diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go index defdf89..9811f40 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -132,7 +132,7 @@ func main() { } // --- Initialize cross-drive backup runner --- - crossDriveRunner := backup.NewCrossDriveRunner(sett, stackProv, cfg.Paths.SystemDataPath, logger) + crossDriveRunner := backup.NewCrossDriveRunner(sett, stackProv, cfg.Paths.SystemDataPath, cfg.Paths.StacksDir, logger) // Wire cross-drive → backup manager for pre-backup DB dumps if backupMgr != nil { diff --git a/controller/internal/backup/crossdrive.go b/controller/internal/backup/crossdrive.go index a6fdc94..e897ca0 100644 --- a/controller/internal/backup/crossdrive.go +++ b/controller/internal/backup/crossdrive.go @@ -22,23 +22,27 @@ type DBDumper interface { // CrossDriveRunner handles per-app backup to secondary storage. type CrossDriveRunner struct { - sett *settings.Settings - stackProvider StackDataProvider - dbDumper DBDumper - systemDataPath string // fallback drive for SSD-only apps - logger *log.Logger - mu sync.Mutex - running map[string]bool // per-app running state + sett *settings.Settings + stackProvider StackDataProvider + dbDumper DBDumper + 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) + logger *log.Logger + mu sync.Mutex + running map[string]bool // per-app running state } // NewCrossDriveRunner creates a new CrossDriveRunner. -func NewCrossDriveRunner(sett *settings.Settings, provider StackDataProvider, systemDataPath string, logger *log.Logger) *CrossDriveRunner { +func NewCrossDriveRunner(sett *settings.Settings, provider StackDataProvider, systemDataPath, stacksDir string, logger *log.Logger) *CrossDriveRunner { return &CrossDriveRunner{ - sett: sett, - stackProvider: provider, - systemDataPath: systemDataPath, - logger: logger, - running: make(map[string]bool), + sett: sett, + stackProvider: provider, + systemDataPath: systemDataPath, + stacksDir: stacksDir, + controllerYAMLPath: "/opt/docker/felhom-controller/controller.yaml", + logger: logger, + running: make(map[string]bool), } } @@ -146,6 +150,12 @@ func (r *CrossDriveRunner) RunAppBackup(ctx context.Context, stackName string) e // RunAllScheduled runs cross-drive backups for all apps matching the schedule. // Runs sequentially (disk I/O bound). func (r *CrossDriveRunner) RunAllScheduled(ctx context.Context, schedule string) error { + // Auto-enable Tier 2 for small apps (no HDD mounts) before running backups + r.AutoEnableSmallApps() + + // Sync infrastructure config to all secondary destinations + r.syncInfraConfig(ctx) + configs := r.sett.GetAllCrossDriveConfigs() if len(configs) == 0 { return nil @@ -345,6 +355,12 @@ func (r *CrossDriveRunner) runResticBackup(ctx context.Context, stackName, destB args = append(args, dumpDir) } + // Include infrastructure paths (same as primary restic) + args = append(args, r.stacksDir) + if _, err := os.Stat(r.controllerYAMLPath); err == nil { + args = append(args, r.controllerYAMLPath) + } + cmd := exec.CommandContext(ctx, "restic", args...) r.logger.Printf("[DEBUG] restic backup: %v", args) if out, err := cmd.CombinedOutput(); err != nil { @@ -427,6 +443,107 @@ func (r *CrossDriveRunner) copyStackDBDumps(stackName, destDir string) error { return nil } +// --- infra backup --- + +// syncInfraConfig rsyncs infrastructure config (stacks dir + controller.yaml) to all +// secondary backup destinations. Runs once per RunAllScheduled cycle, before per-app backups. +func (r *CrossDriveRunner) syncInfraConfig(ctx context.Context) { + // Collect unique destination drives from enabled cross-drive configs + destDrives := make(map[string]bool) + for _, cfg := range r.sett.GetAllCrossDriveConfigs() { + if cfg.Enabled && cfg.DestinationPath != "" { + destDrives[cfg.DestinationPath] = true + } + } + if len(destDrives) == 0 { + return + } + + for dest := range destDrives { + infraDir := SecondaryInfraPath(dest) + if err := os.MkdirAll(infraDir, 0755); err != nil { + r.logger.Printf("[WARN] Cannot create infra backup dir %s: %v", infraDir, err) + continue + } + + // Rsync stacks dir → _infra/stacks/ + stacksDest := filepath.Join(infraDir, "stacks") + "/" + if err := os.MkdirAll(stacksDest, 0755); err == nil { + stacksSrc := strings.TrimRight(r.stacksDir, "/") + "/" + cmd := exec.CommandContext(ctx, "rsync", "-a", "--delete", stacksSrc, stacksDest) + if out, err := cmd.CombinedOutput(); err != nil { + r.logger.Printf("[WARN] Infra rsync (stacks) failed for %s: %v (%s)", dest, err, strings.TrimSpace(string(out))) + } + } + + // Copy controller.yaml → _infra/controller.yaml + if _, err := os.Stat(r.controllerYAMLPath); err == nil { + yamlDest := filepath.Join(infraDir, "controller.yaml") + data, err := os.ReadFile(r.controllerYAMLPath) + if err != nil { + r.logger.Printf("[WARN] Cannot read controller.yaml for infra backup: %v", err) + } else if err := os.WriteFile(yamlDest, data, 0644); err != nil { + r.logger.Printf("[WARN] Cannot write controller.yaml to %s: %v", yamlDest, err) + } + } + + r.logger.Printf("[INFO] Infrastructure config synced to %s", infraDir) + } +} + +// --- auto-enable --- + +// AutoEnableSmallApps auto-configures cross-drive backup for apps without HDD user data +// when at least 2 storage paths are registered. Apps with existing cross-drive config +// (even if disabled) are never modified. +func (r *CrossDriveRunner) AutoEnableSmallApps() { + storagePaths := r.sett.GetStoragePaths() + if len(storagePaths) < 2 { + return // no secondary drive available + } + + deployed := r.stackProvider.ListDeployedStacks() + existingConfigs := r.sett.GetAllCrossDriveConfigs() + + for _, stack := range deployed { + // Skip if already has cross-drive config (user has touched it) + if _, exists := existingConfigs[stack.Name]; exists { + continue + } + + // Skip if app has HDD mounts (large user data — needs manual config) + if mounts := r.stackProvider.GetStackHDDMounts(stack.Name); len(mounts) > 0 { + continue + } + + // Find destination: first storage path that differs from the app's home drive + appDrive := r.getAppDrivePath(stack.Name) + var destPath string + for _, sp := range storagePaths { + if sp.Path != appDrive { + destPath = sp.Path + break + } + } + if destPath == "" { + continue // no suitable destination found + } + + // Auto-configure daily rsync + cfg := &settings.CrossDriveBackup{ + Enabled: true, + Method: "rsync", + DestinationPath: destPath, + Schedule: "daily", + } + if err := r.sett.SetCrossDriveConfig(stack.Name, cfg); err != nil { + r.logger.Printf("[WARN] Auto-enable Tier 2 failed for %s: %v", stack.Name, err) + continue + } + r.logger.Printf("[INFO] Auto-enabled Tier 2 backup for %s → %s (no HDD mounts, daily rsync)", stack.Name, destPath) + } +} + // --- helpers --- func (r *CrossDriveRunner) updateStatus(stackName, status, errMsg string, duration time.Duration, sizeHuman string) { diff --git a/controller/internal/backup/paths.go b/controller/internal/backup/paths.go index 18e97ed..2e2f23a 100644 --- a/controller/internal/backup/paths.go +++ b/controller/internal/backup/paths.go @@ -32,6 +32,11 @@ func SecondaryResticRepoPath(drivePath string) string { return filepath.Join(drivePath, "backups", "secondary", "restic") } +// SecondaryInfraPath returns the infrastructure config mirror directory on a drive's secondary backup. +func SecondaryInfraPath(drivePath string) string { + return filepath.Join(drivePath, "backups", "secondary", "_infra") +} + // AppDataDir returns the app data directory path on a drive. func AppDataDir(drivePath, stackName string) string { return filepath.Join(drivePath, "appdata", stackName)