feat: drive migration & Tier 2 restic deprecation (v0.18.0)

Phase 1: Deprecate restic as Tier 2 method (rsync only), auto-migrate on startup
Phase 2: Enhanced per-app migration with backup awareness, DB dump copy, auto-cleanup
Phase 3: Full drive migration with decommissioned state, rollback support, wizard UI
Phase 4: Hub report includes decommissioned drive state

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-19 21:49:14 +01:00
parent bdbe170a54
commit 99bf3ca7a8
22 changed files with 1725 additions and 402 deletions
+167
View File
@@ -2,14 +2,18 @@ package storage
import (
"bufio"
"context"
"fmt"
"io"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// MigrateRequest holds parameters for migrating app data.
@@ -300,3 +304,166 @@ func bytesHuman(b int64) string {
return fmt.Sprintf("%d B", b)
}
}
// BackupTrigger allows triggering backup operations without importing the backup package.
type BackupTrigger interface {
TryRunDriveBackup(ctx context.Context, drivePath string) error
}
// MigrateOptions holds optional configuration for enhanced migration.
type MigrateOptions struct {
AutoDeleteStale bool // delete old data from source after success (default true)
}
// MigrateOrchestrator wraps MigrateAppData with backup-aware pre/post steps.
type MigrateOrchestrator struct {
Sett *settings.Settings
BackupTrigger BackupTrigger // nil if backup disabled
Logger *log.Logger
}
// RunEnhancedMigration runs MigrateAppData with additional pre/post-migration steps:
// - Copy DB dumps from source to destination drive
// - Clear Tier 2 config if destination conflicts with cross-drive target
// - Optionally delete stale data from source drive
// - Trigger immediate Tier 1 backup on destination drive
func (o *MigrateOrchestrator) RunEnhancedMigration(
req MigrateRequest,
stopFn StopFunc,
startFn StartFunc,
updateFn UpdateHDDPathFunc,
opts MigrateOptions,
progress chan<- MigrateProgress,
) error {
start := time.Now()
// Pre-flight: detect Tier 2 conflict
var tier2WillClear bool
cfg := o.Sett.GetCrossDriveConfig(req.StackName)
if cfg != nil && cfg.Enabled && cfg.DestinationPath != "" {
// If destination is under the target drive, the Tier 2 backup would point
// to the same drive the app now lives on — no redundancy, so we clear it.
if strings.HasPrefix(cfg.DestinationPath, req.TargetPath) || cfg.DestinationPath == req.TargetPath {
tier2WillClear = true
o.Logger.Printf("[INFO] Migration %s: Tier 2 will be cleared (dest %s is under target %s)",
req.StackName, cfg.DestinationPath, req.TargetPath)
}
}
// Run core migration (stop, rsync, update config, start).
// Intercept the "done" step from MigrateAppData — we have post-steps to run.
innerCh := make(chan MigrateProgress, 64)
innerDone := make(chan struct{})
go func() {
for p := range innerCh {
if p.Step == "done" {
// Suppress MigrateAppData's "done" — we'll send our own after post-steps.
continue
}
progress <- p
}
close(innerDone)
}()
if err := MigrateAppData(req, stopFn, startFn, updateFn, innerCh); err != nil {
close(innerCh)
<-innerDone
return err
}
close(innerCh)
<-innerDone // wait for forwarding goroutine to finish
// --- Post-migration steps (all non-fatal) ---
// 1. Copy DB dumps from source to destination
srcDBDumps := filepath.Join(req.CurrentHDDPath, "backups", "primary", req.StackName, "db-dumps")
dstDBDumps := filepath.Join(req.TargetPath, "backups", "primary", req.StackName, "db-dumps")
if _, err := os.Stat(srcDBDumps); err == nil {
if err := os.MkdirAll(filepath.Dir(dstDBDumps), 0755); err != nil {
o.Logger.Printf("[WARN] Migration %s: failed to create DB dump dir: %v", req.StackName, err)
} else {
cmd := exec.Command("rsync", "-a", srcDBDumps+"/", dstDBDumps+"/")
if out, err := cmd.CombinedOutput(); err != nil {
o.Logger.Printf("[WARN] Migration %s: DB dump copy failed: %v — %s", req.StackName, err, string(out))
} else {
o.Logger.Printf("[INFO] Migration %s: DB dumps copied to %s", req.StackName, dstDBDumps)
}
}
}
// 2. Clear Tier 2 if conflict
if tier2WillClear {
if err := o.Sett.SetCrossDriveConfig(req.StackName, nil); err != nil {
o.Logger.Printf("[WARN] Migration %s: failed to clear Tier 2 config: %v", req.StackName, err)
} else {
o.Logger.Printf("[INFO] Migration %s: Tier 2 cross-drive config cleared (dest was on same drive)", req.StackName)
}
}
// 3. Auto-delete stale data from source
if opts.AutoDeleteStale {
progress <- MigrateProgress{
Step: "cleaning",
Message: "Régi adatok törlése a forrás meghajtóról...",
Percent: 92,
ElapsedSeconds: int(time.Since(start).Seconds()),
}
// Delete app data from source
for _, srcPath := range req.HDDMounts {
if !strings.HasPrefix(srcPath, req.CurrentHDDPath+"/") && srcPath != req.CurrentHDDPath {
continue
}
if err := os.RemoveAll(srcPath); err != nil {
o.Logger.Printf("[WARN] Migration %s: failed to delete stale data %s: %v", req.StackName, srcPath, err)
} else {
o.Logger.Printf("[INFO] Migration %s: deleted stale data %s", req.StackName, srcPath)
}
}
// Delete DB dumps from source
if _, err := os.Stat(srcDBDumps); err == nil {
if err := os.RemoveAll(srcDBDumps); err != nil {
o.Logger.Printf("[WARN] Migration %s: failed to delete stale DB dumps %s: %v", req.StackName, srcDBDumps, err)
} else {
o.Logger.Printf("[INFO] Migration %s: deleted stale DB dumps %s", req.StackName, srcDBDumps)
}
}
}
// 4. Trigger immediate Tier 1 backup on destination drive
if o.BackupTrigger != nil {
progress <- MigrateProgress{
Step: "backing_up",
Message: "Biztonsági mentés indítása az új meghajtón...",
Percent: 95,
ElapsedSeconds: int(time.Since(start).Seconds()),
}
if err := o.BackupTrigger.TryRunDriveBackup(context.Background(), req.TargetPath); err != nil {
o.Logger.Printf("[WARN] Migration %s: post-migration backup failed: %v", req.StackName, err)
progress <- MigrateProgress{
Step: "backing_up",
Message: "Biztonsági mentés nem indítható (másik mentés fut)",
Percent: 96,
ElapsedSeconds: int(time.Since(start).Seconds()),
}
} else {
o.Logger.Printf("[INFO] Migration %s: post-migration backup completed for %s", req.StackName, req.TargetPath)
}
}
// Final done step with enhanced info
msg := fmt.Sprintf("Áthelyezés kész! Az alkalmazás az új tárolóról fut. (idő: %ds)", int(time.Since(start).Seconds()))
if tier2WillClear {
msg += " A 2. szintű mentés törlésre került."
}
progress <- MigrateProgress{
Step: "done",
Message: msg,
Percent: 100,
ElapsedSeconds: int(time.Since(start).Seconds()),
}
return nil
}