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
+279 -11
View File
@@ -1,6 +1,7 @@
package web
import (
"context"
"encoding/json"
"fmt"
"net/http"
@@ -18,11 +19,12 @@ import (
// activeDiskJob tracks an in-progress disk operation (format or migrate).
type activeDiskJob struct {
mu sync.RWMutex
jobType string // "format" or "migrate"
done bool
fmtProg []storage.FormatProgress
migProg []storage.MigrateProgress
mu sync.RWMutex
jobType string // "format", "migrate", or "migrate-drive"
done bool
fmtProg []storage.FormatProgress
migProg []storage.MigrateProgress
driveMigProg []storage.DriveMigrateProgress
}
// DeployStorageInfo holds storage info for the deploy page (already-deployed apps).
@@ -54,6 +56,26 @@ func (j *activeDiskJob) appendMigProg(p storage.MigrateProgress) {
}
}
// appendDriveMigProg adds a drive migration progress update to the job.
func (j *activeDiskJob) appendDriveMigProg(p storage.DriveMigrateProgress) {
j.mu.Lock()
defer j.mu.Unlock()
j.driveMigProg = append(j.driveMigProg, p)
if p.Step == "done" || p.Step == "error" {
j.done = true
}
}
// lastDriveMigProg returns the most recent drive migration progress.
func (j *activeDiskJob) lastDriveMigProg() (storage.DriveMigrateProgress, bool) {
j.mu.RLock()
defer j.mu.RUnlock()
if len(j.driveMigProg) == 0 {
return storage.DriveMigrateProgress{}, false
}
return j.driveMigProg[len(j.driveMigProg)-1], true
}
// lastFmtProg returns the most recent format progress snapshot.
func (j *activeDiskJob) lastFmtProg() (storage.FormatProgress, bool) {
j.mu.RLock()
@@ -162,6 +184,12 @@ func (s *Server) storageAPIHandler(w http.ResponseWriter, r *http.Request) {
s.storageRestartAppsHandler(w, r)
case path == "/api/storage/status" && r.Method == http.MethodGet:
s.storageStatusHandler(w, r)
case path == "/api/storage/migrate-drive" && r.Method == http.MethodPost:
s.driveMigrateAPIHandler(w, r)
case path == "/api/storage/migrate-drive/status" && r.Method == http.MethodGet:
s.driveMigrateStatusHandler(w, r)
case path == "/api/storage/decommission/remove" && r.Method == http.MethodPost:
s.decommissionRemoveHandler(w, r)
default:
http.NotFound(w, r)
}
@@ -273,7 +301,7 @@ func (s *Server) storageInitAPIHandler(w http.ResponseWriter, r *http.Request) {
} else {
s.logger.Printf("[INFO] Storage path registered: %s (%s)", mountPath, label)
// Sync FileBrowser mounts with new storage path
s.syncFileBrowserMounts()
s.SyncFileBrowserMounts()
}
}()
@@ -393,8 +421,9 @@ func (s *Server) migratePageHandler(w http.ResponseWriter, r *http.Request, stac
// storageMigrateAPIHandler handles POST /api/storage/migrate — starts migration job.
func (s *Server) storageMigrateAPIHandler(w http.ResponseWriter, r *http.Request) {
var req struct {
StackName string `json:"stack_name"`
TargetPath string `json:"target_path"`
StackName string `json:"stack_name"`
TargetPath string `json:"target_path"`
AutoDeleteStale *bool `json:"auto_delete_stale"` // default true
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, "Érvénytelen kérés", http.StatusBadRequest)
@@ -476,6 +505,20 @@ func (s *Server) storageMigrateAPIHandler(w http.ResponseWriter, r *http.Request
return s.updateStackHDDPath(name, newPath)
}
autoDelete := true
if req.AutoDeleteStale != nil {
autoDelete = *req.AutoDeleteStale
}
orch := &storage.MigrateOrchestrator{
Sett: s.settings,
BackupTrigger: s.backupMgr,
Logger: s.logger,
}
opts := storage.MigrateOptions{
AutoDeleteStale: autoDelete,
}
go func() {
progressCh := make(chan storage.MigrateProgress, 64)
go func() {
@@ -484,12 +527,12 @@ func (s *Server) storageMigrateAPIHandler(w http.ResponseWriter, r *http.Request
}
}()
if err := storage.MigrateAppData(migrReq, stopFn, startFn, updateFn, progressCh); err != nil {
if err := orch.RunEnhancedMigration(migrReq, stopFn, startFn, updateFn, opts, progressCh); err != nil {
s.logger.Printf("[ERROR] Migration failed: stack=%s: %v", req.StackName, err)
} else {
s.logger.Printf("[INFO] Migration complete: stack=%s → %s", req.StackName, req.TargetPath)
// Sync FileBrowser mounts (storage paths may now have new app data)
go s.syncFileBrowserMounts()
go s.SyncFileBrowserMounts()
}
close(progressCh)
}()
@@ -1033,7 +1076,7 @@ func (s *Server) storageAttachAPIHandler(w http.ResponseWriter, r *http.Request)
s.logger.Printf("[WARN] Failed to register storage path after attach: %v", err)
} else {
s.logger.Printf("[INFO] Storage path registered: %s (%s)", mountPath, label)
s.syncFileBrowserMounts()
s.SyncFileBrowserMounts()
}
}()
@@ -1251,3 +1294,228 @@ func (s *Server) storageStatusHandler(w http.ResponseWriter, r *http.Request) {
"data": result,
})
}
// migrateDrivePageHandler handles GET /settings/storage/migrate-drive.
func (s *Server) migrateDrivePageHandler(w http.ResponseWriter, r *http.Request) {
sourcePath := r.URL.Query().Get("source")
if sourcePath == "" {
http.Redirect(w, r, "/settings", http.StatusFound)
return
}
data := s.baseData("settings", "Meghajtó kiváltása")
data["SourcePath"] = sourcePath
data["SourceLabel"] = s.settings.GetStorageLabel(sourcePath)
data["SourceDiskInfo"] = system.GetDiskUsage(sourcePath)
// Find apps on source drive
type appInfo struct {
Name string
DisplayName string
}
var appsOnSource []appInfo
for _, stack := range s.stackMgr.GetStacks() {
if !stack.Deployed {
continue
}
cfg := s.stackMgr.LoadAppConfigByName(stack.Name)
if cfg != nil && cfg.Env["HDD_PATH"] == sourcePath {
appsOnSource = append(appsOnSource, appInfo{
Name: stack.Name,
DisplayName: stack.Meta.DisplayName,
})
}
}
data["AppsOnSource"] = appsOnSource
// Available destination paths
type destPathInfo struct {
Path string
Label string
FreeHuman string
}
var destPaths []destPathInfo
for _, sp := range s.settings.GetConnectedPaths() {
if sp.Path == sourcePath {
continue
}
free := ""
if di := system.GetDiskUsage(sp.Path); di != nil {
free = fmt.Sprintf("%.1f GB", di.AvailGB)
}
destPaths = append(destPaths, destPathInfo{
Path: sp.Path,
Label: sp.Label,
FreeHuman: free,
})
}
data["DestPaths"] = destPaths
// Tier 2 impact analysis
var tier2Impact []string
allCrossConfigs := s.settings.GetAllCrossDriveConfigs()
for _, app := range appsOnSource {
if cfg := allCrossConfigs[app.Name]; cfg != nil && cfg.Enabled {
tier2Impact = append(tier2Impact, fmt.Sprintf("%s: 2. szintű mentés → %s (automatikusan átirányításra kerül)",
app.DisplayName, s.settings.GetStorageLabel(cfg.DestinationPath)))
}
}
data["Tier2Impact"] = tier2Impact
s.render(w, "migrate_drive", data)
}
// driveMigrateAPIHandler handles POST /api/storage/migrate-drive — starts drive migration.
func (s *Server) driveMigrateAPIHandler(w http.ResponseWriter, r *http.Request) {
if s.driveMigrator == nil {
jsonError(w, "Meghajtó-migráció nem elérhető", http.StatusServiceUnavailable)
return
}
var req struct {
SourcePath string `json:"source_path"`
DestPath string `json:"dest_path"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, "Érvénytelen kérés", http.StatusBadRequest)
return
}
if req.SourcePath == "" || req.DestPath == "" {
jsonError(w, "Hiányos paraméterek", http.StatusBadRequest)
return
}
// Validate against registered paths
registeredPaths := s.settings.GetStoragePaths()
validSrc, validDst := false, false
for _, sp := range registeredPaths {
if sp.Path == req.SourcePath {
validSrc = true
}
if sp.Path == req.DestPath {
validDst = true
}
}
if !validSrc || !validDst {
jsonError(w, "Érvénytelen útvonal: nem regisztrált adattároló", http.StatusBadRequest)
return
}
job, ok := s.tryStartDiskJob("migrate-drive")
if !ok {
jsonError(w, "Egy másik lemezművelet folyamatban van", http.StatusConflict)
return
}
s.logger.Printf("[INFO] Drive migration started: %s → %s by %s", req.SourcePath, req.DestPath, r.RemoteAddr)
go func() {
ctx := context.Background()
progressCh := make(chan storage.DriveMigrateProgress, 64)
go func() {
for p := range progressCh {
job.appendDriveMigProg(p)
}
}()
migrReq := storage.DriveMigrateRequest{
SourcePath: req.SourcePath,
DestPath: req.DestPath,
}
if err := s.driveMigrator.MigrateDrive(ctx, migrReq, progressCh); err != nil {
s.logger.Printf("[ERROR] Drive migration failed: %v", err)
} else {
s.logger.Printf("[INFO] Drive migration complete: %s → %s", req.SourcePath, req.DestPath)
go s.SyncFileBrowserMounts()
}
close(progressCh)
}()
jsonResponse(w, map[string]interface{}{
"ok": true,
"msg": "Meghajtó kiváltás elindítva",
})
}
// driveMigrateStatusHandler handles GET /api/storage/migrate-drive/status.
func (s *Server) driveMigrateStatusHandler(w http.ResponseWriter, r *http.Request) {
job := s.currentDiskJob()
if job == nil || job.jobType != "migrate-drive" {
jsonResponse(w, map[string]interface{}{
"ok": true,
"active": false,
})
return
}
p, ok := job.lastDriveMigProg()
if !ok {
jsonResponse(w, map[string]interface{}{
"ok": true,
"active": true,
"step": "validating",
"msg": "Meghajtó kiváltás elindult...",
"pct": 0,
})
return
}
jsonResponse(w, map[string]interface{}{
"ok": true,
"active": !job.isDone(),
"step": p.Step,
"msg": p.Message,
"detail": p.Detail,
"pct": p.Percent,
"error": p.Error,
"done": job.isDone(),
"elapsed_sec": p.ElapsedSeconds,
})
}
// decommissionRemoveHandler handles POST /api/storage/decommission/remove.
func (s *Server) decommissionRemoveHandler(w http.ResponseWriter, r *http.Request) {
var req struct {
Path string `json:"storage_path"`
}
// Support both form and JSON
if r.Header.Get("Content-Type") == "application/json" {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, "Érvénytelen kérés", http.StatusBadRequest)
return
}
} else {
_ = r.ParseForm()
req.Path = r.FormValue("storage_path")
}
if req.Path == "" {
jsonError(w, "Hiányzó útvonal", http.StatusBadRequest)
return
}
if !s.settings.IsDecommissioned(req.Path) {
jsonError(w, "A meghajtó nincs kiváltva állapotban", http.StatusBadRequest)
return
}
if err := s.settings.RemoveStoragePath(req.Path); err != nil {
s.logger.Printf("[ERROR] Failed to remove decommissioned path %s: %v", req.Path, err)
jsonError(w, "Eltávolítás sikertelen: "+err.Error(), http.StatusInternalServerError)
return
}
s.logger.Printf("[INFO] Decommissioned storage path removed: %s", req.Path)
// For form submissions, redirect back to settings
if r.Header.Get("Content-Type") != "application/json" {
http.Redirect(w, r, "/settings?storage_msg=success&storage_detail=Meghajtó+eltávolítva+a+rendszerből.", http.StatusFound)
return
}
jsonResponse(w, map[string]interface{}{
"ok": true,
"msg": "Meghajtó eltávolítva a rendszerből",
})
}