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:
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user