v0.14.1: Auto Tier 2 for small apps + infra config in cross-drive backup

- Auto-enable daily rsync Tier 2 for apps without HDD mounts when ≥2
  storage paths exist (AutoEnableSmallApps)
- Sync infrastructure config (stacks dir + controller.yaml) to all
  secondary destinations via _infra/ directory (syncInfraConfig)
- Include infra paths in cross-drive restic snapshots
- Add SecondaryInfraPath() helper to paths.go

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-18 19:11:57 +01:00
parent 0e13d42ccd
commit f7518c0529
3 changed files with 136 additions and 14 deletions
+1 -1
View File
@@ -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 {
+130 -13
View File
@@ -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) {
+5
View File
@@ -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)