Files
deploy-felhom-compose/controller/internal/storage/migrate.go
T
admin 95c821deb2 feat: comprehensive debug logging across all controller modules
Add detailed [DEBUG] logging to every controller module when
logging.level is set to "debug". Each module with stateful debug
uses SetDebug(bool) wired from main.go. Covers stacks, backup,
cloudflare, integrations, system, monitor, settings, scheduler,
web handlers, storage, metrics, API, selfupdate, and assets.

Also includes the app export/import (.fab bundles) feature from
v0.32.0 and its debug page integration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 18:14:43 +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] [storage] 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
}