Files
deploy-felhom-compose/controller/internal/storage/migrate.go
T
admin 7abd1c5954 v0.26.0: Storage namespace felhom-data/ + test node wipe script
All felhom-managed data on external drives now lives under felhom-data/
subdirectory, cleanly separating controller data from user files.

- backup/paths.go: add FelhomDataDir constant, update 8 path helpers
- stacks/delete.go: add local felhomDataDir constant (circular import
  boundary), update ProtectedHDDPaths + GetStackBackupData
- storage/migrate_drive.go: import backup pkg, fix conflict check, verify,
  rsync excludes (felhom-data/backups/*/restic/), size estimation
- storage/migrate.go: import backup pkg, fix DB dump paths
- web/handlers.go: fix legacy 'storage' path -> backup.AppDataDir()
- storage/format_linux.go: create felhom-data/ instead of storage/
- storage/attach_linux.go: create felhom-data/ instead of storage/
- scripts/felhom-wipe.sh: new multi-level test node wipe script
  (soft/controller/full/nuclear)
- CHANGELOG.md, controller/README.md, scripts/README.md: updated docs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-22 10:10:51 +01:00

489 lines
16 KiB
Go

package storage
import (
"bufio"
"context"
"fmt"
"io"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// MigrateRequest holds parameters for migrating app data.
type MigrateRequest struct {
StackName string // e.g., "immich"
DisplayName string // e.g., "Immich"
CurrentHDDPath string // e.g., "/mnt/hdd_placeholder"
TargetPath string // e.g., "/mnt/hdd_1"
HDDMounts []string // host-side paths to rsync (e.g., ["/mnt/hdd_placeholder/storage/immich"])
Logger *log.Logger // Optional logger for debug output
Debug bool // Enable debug logging
}
// MigrateProgress tracks migration state.
type MigrateProgress struct {
Step string // "stopping","copying","updating","starting","done","error","rolling_back"
Message string
BytesCopied int64
BytesTotal int64
Percent int
Error string
ElapsedSeconds int
}
// StopFunc stops an app's containers. Returns error if stop fails.
type StopFunc func(stackName string) error
// StartFunc starts an app's containers. Returns error if start fails.
type StartFunc func(stackName string) error
// UpdateHDDPathFunc updates the HDD_PATH in app.yaml. Returns error on failure.
type UpdateHDDPathFunc func(stackName, newPath string) error
// MigrateAppData moves app data from current to target storage path.
// stopFn and startFn are called to stop/start the app containers.
// updateFn is called to update the app's HDD_PATH configuration.
func MigrateAppData(
req MigrateRequest,
stopFn StopFunc,
startFn StartFunc,
updateFn UpdateHDDPathFunc,
progress chan<- MigrateProgress,
) error {
start := time.Now()
send := func(step, msg string, pct int, bytesCopied, bytesTotal int64) {
progress <- MigrateProgress{
Step: step,
Message: msg,
Percent: pct,
BytesCopied: bytesCopied,
BytesTotal: bytesTotal,
ElapsedSeconds: int(time.Since(start).Seconds()),
}
}
fail := func(step, msg string, err error) error {
errStr := ""
if err != nil {
errStr = err.Error()
}
progress <- MigrateProgress{
Step: "error",
Message: msg,
Error: errStr,
ElapsedSeconds: int(time.Since(start).Seconds()),
}
return fmt.Errorf("%s: %w", msg, err)
}
dbg := func(format string, args ...interface{}) {
if req.Logger != nil && req.Debug {
req.Logger.Printf("[DEBUG] MigrateAppData: "+format, args...)
}
}
dbg("starting migration: stack=%s from=%s to=%s mounts=%d", req.StackName, req.CurrentHDDPath, req.TargetPath, len(req.HDDMounts))
// --- Step 1: Validate ---
if req.CurrentHDDPath == "" {
return fail("validating", "A jelenlegi tárhely nem megadott", fmt.Errorf("empty current HDD path"))
}
if req.TargetPath == "" {
return fail("validating", "A cél tárhely nem megadott", fmt.Errorf("empty target path"))
}
if req.CurrentHDDPath == req.TargetPath {
return fail("validating", "A forrás és a cél tárhely azonos", fmt.Errorf("source equals target"))
}
if _, err := os.Stat(req.TargetPath); err != nil {
return fail("validating", "A cél tárhely nem létezik: "+req.TargetPath, err)
}
if len(req.HDDMounts) == 0 {
return fail("validating", "Nincsenek HDD csatlakozások az alkalmazáshoz", fmt.Errorf("no HDD mounts"))
}
// Estimate total size
var totalBytes int64
for _, m := range req.HDDMounts {
if info, err := os.Stat(m); err == nil && info.IsDir() {
totalBytes += dirSize(m)
}
}
dbg("estimated total size: %s (%d bytes)", bytesHuman(totalBytes), totalBytes)
// Check free space on target
freeBytes := getFreeBytes(req.TargetPath)
dbg("target free space: %s (%d bytes)", bytesHuman(freeBytes), freeBytes)
if freeBytes > 0 && totalBytes > 0 && int64(float64(totalBytes)*1.05) > freeBytes {
return fail("validating", fmt.Sprintf(
"Nincs elég szabad hely a céltárolón: szükséges ~%s, szabad %s",
bytesHuman(totalBytes), bytesHuman(freeBytes),
), fmt.Errorf("insufficient disk space"))
}
send("stopping", "Alkalmazás leállítása...", 5, 0, totalBytes)
// --- Step 2: Stop app ---
if err := stopFn(req.StackName); err != nil {
return fail("stopping", "Alkalmazás leállítása sikertelen", err)
}
send("stopping", "Alkalmazás leállítva", 10, 0, totalBytes)
// --- Step 3: rsync ---
dbg("starting rsync phase: %d mount(s) to copy", len(req.HDDMounts))
var bytesCopied int64
for i, srcPath := range req.HDDMounts {
// Determine destination path: replace CurrentHDDPath prefix with TargetPath.
// H13: Require trailing separator to prevent /mnt/hdd matching /mnt/hdd_backup/data.
if srcPath != req.CurrentHDDPath && !strings.HasPrefix(srcPath, req.CurrentHDDPath+"/") {
dbg("skipping mount %s (not under %s)", srcPath, req.CurrentHDDPath)
continue
}
relPath := strings.TrimPrefix(srcPath, req.CurrentHDDPath)
dstPath := filepath.Join(req.TargetPath, relPath)
dbg("rsync %d/%d: %s → %s", i+1, len(req.HDDMounts), srcPath, dstPath)
// Ensure destination parent exists
if err := os.MkdirAll(filepath.Dir(dstPath), 0755); err != nil {
// Rollback
send("rolling_back", "Hiba: mappa létrehozása sikertelen, visszagörgetés...", 0, bytesCopied, totalBytes)
_ = startFn(req.StackName)
return fail("copying", "Cél mappa létrehozása sikertelen: "+filepath.Dir(dstPath), err)
}
mountPct := 10 + (i * 60 / len(req.HDDMounts))
send("copying", fmt.Sprintf("Adatok másolása (%d/%d): %s...", i+1, len(req.HDDMounts), filepath.Base(srcPath)),
mountPct, bytesCopied, totalBytes)
var rsyncErr error
bytesCopied, rsyncErr = runRsync(srcPath, dstPath, totalBytes, bytesCopied, mountPct, progress, start)
if rsyncErr != nil {
// Rollback
send("rolling_back", "rsync sikertelen, alkalmazás visszaállítása az eredeti tárolóra...", 0, bytesCopied, totalBytes)
_ = startFn(req.StackName)
return fail("copying", "Adatmásolás sikertelen", rsyncErr)
}
}
send("updating", "Konfiguráció frissítése...", 75, bytesCopied, totalBytes)
// --- Step 4: Update app.yaml HDD_PATH ---
dbg("updating config: HDD_PATH %s → %s for stack %s", req.CurrentHDDPath, req.TargetPath, req.StackName)
if err := updateFn(req.StackName, req.TargetPath); err != nil {
send("rolling_back", "Konfiguráció frissítése sikertelen, visszaállítás...", 0, bytesCopied, totalBytes)
_ = startFn(req.StackName)
return fail("updating", "HDD_PATH frissítése sikertelen", err)
}
send("starting", "Alkalmazás indítása az új tárolóról...", 85, bytesCopied, totalBytes)
// --- Step 5: Start app ---
if err := startFn(req.StackName); err != nil {
// Revert config and restart with old path
_ = updateFn(req.StackName, req.CurrentHDDPath)
_ = startFn(req.StackName)
return fail("starting", "Alkalmazás indítása sikertelen az új tárolóról", err)
}
dbg("migration completed: stack=%s bytesCopied=%d elapsed=%ds", req.StackName, bytesCopied, int(time.Since(start).Seconds()))
send("done",
fmt.Sprintf("Áthelyezés kész! Az alkalmazás az új tárolóról fut. (Régi adat: %s, idő: %ds)",
req.CurrentHDDPath, int(time.Since(start).Seconds())),
100, bytesCopied, totalBytes)
return nil
}
// runRsync runs rsync from srcPath to dstPath and reports progress.
func runRsync(srcPath, dstPath string, totalBytes, prevCopied int64, basePct int, progress chan<- MigrateProgress, start time.Time) (int64, error) {
// Ensure src ends with / for rsync to sync contents (not the directory itself)
if !strings.HasSuffix(srcPath, "/") {
srcPath += "/"
}
cmd := exec.Command(
"rsync", "-a", "--info=progress2", "--human-readable",
srcPath, dstPath,
)
stdout, err := cmd.StdoutPipe()
if err != nil {
return prevCopied, err
}
stderr, err := cmd.StderrPipe()
if err != nil {
return prevCopied, err
}
if err := cmd.Start(); err != nil {
return prevCopied, fmt.Errorf("rsync start failed: %w", err)
}
var bytesCopied int64 = prevCopied
var mu sync.Mutex
// Parse stdout progress
go func() {
scanner := bufio.NewScanner(stdout)
for scanner.Scan() {
line := scanner.Text()
if b, pct, ok := parseRsyncProgress(line); ok {
mu.Lock()
bytesCopied = prevCopied + b
// Scale pct into our range
scaledPct := basePct + pct*40/100
if scaledPct > 99 {
scaledPct = 99
}
mu.Unlock()
progress <- MigrateProgress{
Step: "copying",
Message: fmt.Sprintf("Adatok másolása... %s / %s", bytesHuman(b), bytesHuman(totalBytes)),
Percent: scaledPct,
BytesCopied: bytesCopied,
BytesTotal: totalBytes,
ElapsedSeconds: int(time.Since(start).Seconds()),
}
}
}
}()
var stderrBuf strings.Builder
io.Copy(&stderrBuf, stderr)
if err := cmd.Wait(); err != nil {
// H11: Read bytesCopied under lock to avoid data race with the progress goroutine.
mu.Lock()
copied := bytesCopied
mu.Unlock()
return copied, fmt.Errorf("rsync failed: %w — %s", err, stderrBuf.String())
}
mu.Lock()
finalCopied := bytesCopied
mu.Unlock()
return finalCopied, nil
}
// dirSize returns the total bytes in a directory (best-effort).
func dirSize(path string) int64 {
var total int64
filepath.Walk(path, func(_ string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
}
total += info.Size()
return nil
})
return total
}
// getFreeBytes returns available bytes on the filesystem at path.
func getFreeBytes(path string) int64 {
// Use df to get available bytes — works cross-platform within Linux container
out, err := exec.Command("df", "-B1", "--output=avail", path).Output()
if err != nil {
return 0
}
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
if len(lines) < 2 {
return 0
}
var avail int64
fmt.Sscanf(strings.TrimSpace(lines[1]), "%d", &avail)
return avail
}
// bytesHuman converts a byte count to human-readable string.
func bytesHuman(b int64) string {
const (
KB = 1024
MB = KB * 1024
GB = MB * 1024
)
switch {
case b >= GB:
return fmt.Sprintf("%.1f GB", float64(b)/float64(GB))
case b >= MB:
return fmt.Sprintf("%.0f MB", float64(b)/float64(MB))
case b >= KB:
return fmt.Sprintf("%.0f KB", float64(b)/float64(KB))
default:
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 := backup.AppDBDumpPath(req.CurrentHDDPath, req.StackName)
dstDBDumps := backup.AppDBDumpPath(req.TargetPath, req.StackName)
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
}