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