95c821deb2
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>
489 lines
16 KiB
Go
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
|
|
}
|