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