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
+102 -7
View File
@@ -29,6 +29,7 @@ import (
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
catalogsync "gitea.dooplex.hu/admin/felhom-controller/internal/sync"
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
"gitea.dooplex.hu/admin/felhom-controller/internal/storage"
"gitea.dooplex.hu/admin/felhom-controller/internal/web"
)
@@ -545,6 +546,48 @@ func main() {
webServer := web.NewServer(cfg, stackMgr, cpuCollector, backupMgr, crossDriveRunner, sched, sett, alertMgr, notifier, updater, logger, Version)
webServer.SetStorageWatchdog(storageWatchdog)
// --- Initialize drive migrator ---
driveMigrator := &storage.DriveMigrator{
Sett: sett,
StackProvider: &driveMigrateStackAdapter{mgr: stackMgr},
Logger: logger,
}
// Only set BackupTrigger if backup is enabled (avoid non-nil interface with nil concrete value)
if backupMgr != nil {
driveMigrator.BackupTrigger = backupMgr
}
driveMigrator.AlertRefresh = func() {
healthReport := monitor.RunHealthCheck(cfg, cpuCollector, sett.GetStoragePaths())
updateAvailable := false
latestVersion := ""
if updater != nil {
status := updater.GetStatus()
if status.LastCheck != nil {
updateAvailable = status.LastCheck.UpdateAvailable
latestVersion = status.LastCheck.LatestVersion
}
}
alertMgr.Refresh(healthReport, cfg, backupMgr, updateAvailable, latestVersion, sett.GetStoragePaths())
}
if hubPusher != nil {
driveMigrator.PushHubReport = func() {
r := report.BuildReport(cfg, stackMgr, backupMgr, cpuCollector, metricsStore, Version, sett.GetStoragePaths())
hubPusher.Push(r)
}
driveMigrator.PushInfraBackup = func() {
pushInfraBackup(cfg, sett, stackProv, hubPusher, logger)
}
}
driveMigrator.SyncFBMounts = func() {
webServer.SyncFileBrowserMounts()
}
webServer.SetDriveMigrator(driveMigrator)
// Wire migration-active check into backup manager
if backupMgr != nil {
backupMgr.MigrationActiveCheck = driveMigrator.IsActive
}
// Phase 3: Set DR restore mode if a restore plan was built
if restorePlan != nil && len(restorePlan.Apps) > 0 {
webServer.SetRestoreState(restorePlan)
@@ -741,6 +784,65 @@ func (a *watchdogStackAdapter) StartStack(name string) error {
return a.mgr.StartStack(name)
}
// driveMigrateStackAdapter implements storage.StackProviderForMigration using stacks.Manager.
type driveMigrateStackAdapter struct {
mgr *stacks.Manager
}
func (a *driveMigrateStackAdapter) ListDeployedStacks() []storage.StackSummaryForMigration {
var result []storage.StackSummaryForMigration
for _, s := range a.mgr.GetStacks() {
if !s.Deployed {
continue
}
result = append(result, storage.StackSummaryForMigration{
Name: s.Name,
DisplayName: s.Meta.DisplayName,
})
}
return result
}
func (a *driveMigrateStackAdapter) GetStackHDDPath(name string) string {
s, ok := a.mgr.GetStack(name)
if !ok {
return ""
}
stackDir := filepath.Dir(s.ComposePath)
appCfg := stacks.LoadAppConfig(stackDir)
if appCfg != nil && appCfg.Env["HDD_PATH"] != "" {
return filepath.Clean(appCfg.Env["HDD_PATH"])
}
return ""
}
func (a *driveMigrateStackAdapter) StopStack(name string) error {
return a.mgr.StopStack(name)
}
func (a *driveMigrateStackAdapter) StartStack(name string) error {
return a.mgr.StartStack(name)
}
func (a *driveMigrateStackAdapter) UpdateStackHDDPath(name, newPath string) error {
s, ok := a.mgr.GetStack(name)
if !ok {
return fmt.Errorf("stack not found: %s", name)
}
stackDir := filepath.Dir(s.ComposePath)
appCfg := stacks.LoadAppConfig(stackDir)
if appCfg == nil {
return fmt.Errorf("app.yaml not found for stack: %s", name)
}
appCfg.Env["HDD_PATH"] = newPath
return stacks.SaveAppConfig(stackDir, appCfg)
}
func (a *driveMigrateStackAdapter) StackExists(name string) bool {
_, ok := a.mgr.GetStack(name)
return ok
}
// pushInfraBackup builds and sends the infrastructure snapshot to the Hub.
func pushInfraBackup(cfg *config.Config, sett *settings.Settings,
stackProv *stackAdapter, pusher *report.Pusher, logger *log.Logger) {
@@ -793,13 +895,6 @@ func restorePasswordsFromHub(ib *report.InfraBackup, cfg *config.Config,
}
}
if ib.CrossDrivePassword != "" {
if err := sett.SetCrossDriveResticPassword(ib.CrossDrivePassword); err == nil {
logger.Println("[INFO] Cross-drive restic password restored from Hub")
} else {
logger.Printf("[WARN] Failed to set cross-drive password: %v", err)
}
}
}
// restoreSettingsFromHub restores settings.json from a Hub infra backup.