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
+110 -52
View File
@@ -1,10 +1,8 @@
package settings
import (
"crypto/rand"
"encoding/json"
"fmt"
"io"
"log"
"os"
"path/filepath"
@@ -13,9 +11,6 @@ import (
"time"
)
// cryptoRandRead is a var so tests can stub it.
var cryptoRandRead = func(b []byte) (int, error) { return io.ReadFull(rand.Reader, b) }
// Settings holds customer-modifiable overrides and cached state.
// Persisted as a single JSON file (settings.json) in the data directory.
type Settings struct {
@@ -68,14 +63,17 @@ type CrossDriveBackup struct {
// StoragePath represents a registered external storage location.
type StoragePath struct {
Path string `json:"path"` // e.g., "/mnt/hdd_1"
Label string `json:"label,omitempty"` // e.g., "Külső HDD 1TB"
IsDefault bool `json:"is_default,omitempty"` // new apps use this by default
Schedulable bool `json:"schedulable"` // whether new apps can be deployed here
AddedAt string `json:"added_at"` // RFC3339
Disconnected bool `json:"disconnected,omitempty"` // true when drive detected as disconnected
DisconnectedAt string `json:"disconnected_at,omitempty"` // RFC3339 timestamp of disconnect detection
StoppedStacks []string `json:"stopped_stacks,omitempty"` // stacks auto-stopped on disconnect
Path string `json:"path"` // e.g., "/mnt/hdd_1"
Label string `json:"label,omitempty"` // e.g., "Külső HDD 1TB"
IsDefault bool `json:"is_default,omitempty"` // new apps use this by default
Schedulable bool `json:"schedulable"` // whether new apps can be deployed here
AddedAt string `json:"added_at"` // RFC3339
Disconnected bool `json:"disconnected,omitempty"` // true when drive detected as disconnected
DisconnectedAt string `json:"disconnected_at,omitempty"` // RFC3339 timestamp of disconnect detection
StoppedStacks []string `json:"stopped_stacks,omitempty"` // stacks auto-stopped on disconnect
Decommissioned bool `json:"decommissioned,omitempty"` // true when drive data migrated to another
DecommissionedAt string `json:"decommissioned_at,omitempty"` // RFC3339 timestamp
MigratedTo string `json:"migrated_to,omitempty"` // path of target drive
}
// NotificationPrefs holds customer notification preferences.
@@ -124,9 +122,31 @@ func Load(path string, logger *log.Logger) (*Settings, error) {
}
logger.Printf("[DEBUG] Settings loaded from %s", path)
s.migrateResticToRsync()
return s, nil
}
// migrateResticToRsync converts any cross-drive backup configs using restic to rsync.
// Called once during Load() before the mutex is exposed.
func (s *Settings) migrateResticToRsync() {
changed := false
for name, prefs := range s.AppBackup {
if prefs.CrossDrive != nil && prefs.CrossDrive.Method == "restic" {
prefs.CrossDrive.Method = "rsync"
s.AppBackup[name] = prefs
if s.log != nil {
s.log.Printf("[INFO] Migrated cross-drive backup for %s from restic to rsync", name)
}
changed = true
}
}
if changed {
if err := s.save(); err != nil && s.log != nil {
s.log.Printf("[ERROR] Failed to save restic→rsync migration: %v", err)
}
}
}
// Save writes settings to disk atomically (write to .tmp, rename).
// Caller must hold the write lock or call this from a method that does.
func (s *Settings) save() error {
@@ -297,42 +317,10 @@ func (s *Settings) GetAllCrossDriveConfigs() map[string]*CrossDriveBackup {
return result
}
// GetCrossDriveResticPassword returns the cross-drive restic password (read-only).
// Returns empty string if not yet generated.
func (s *Settings) GetCrossDriveResticPassword() string {
s.mu.RLock()
defer s.mu.RUnlock()
return s.CrossDriveResticPassword
}
// SetCrossDriveResticPassword sets the cross-drive restic password (e.g., during DR restore).
func (s *Settings) SetCrossDriveResticPassword(password string) error {
s.mu.Lock()
defer s.mu.Unlock()
s.CrossDriveResticPassword = password
return s.save()
}
// GetOrCreateCrossDrivePassword returns the cross-drive restic password,
// generating and persisting one if it doesn't exist yet.
func (s *Settings) GetOrCreateCrossDrivePassword() (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.CrossDriveResticPassword != "" {
return s.CrossDriveResticPassword, nil
}
// Generate a random 32-byte password
buf := make([]byte, 32)
_, err := cryptoRandRead(buf)
if err != nil {
return "", fmt.Errorf("generating cross-drive restic password: %w", err)
}
s.CrossDriveResticPassword = fmt.Sprintf("%x", buf)
if err := s.save(); err != nil {
return "", err
}
return s.CrossDriveResticPassword, nil
}
// NOTE: GetCrossDriveResticPassword, SetCrossDriveResticPassword, and
// GetOrCreateCrossDrivePassword were removed in the Tier 2 restic deprecation.
// The CrossDriveResticPassword field is kept in the struct for backward-compat
// JSON loading but is no longer used.
// --- Storage Paths ---
@@ -360,13 +348,25 @@ func (s *Settings) GetDefaultStoragePath() string {
return ""
}
// GetStorageLabel returns the label for a storage path, or the base name if not found.
func (s *Settings) GetStorageLabel(path string) string {
s.mu.RLock()
defer s.mu.RUnlock()
for _, sp := range s.StoragePaths {
if sp.Path == path && sp.Label != "" {
return sp.Label
}
}
return filepath.Base(path)
}
// GetSchedulableStoragePaths returns paths available for new deployments.
func (s *Settings) GetSchedulableStoragePaths() []StoragePath {
s.mu.RLock()
defer s.mu.RUnlock()
var result []StoragePath
for _, sp := range s.StoragePaths {
if sp.Schedulable {
if sp.Schedulable && !sp.Decommissioned {
result = append(result, sp)
}
}
@@ -568,13 +568,13 @@ func (s *Settings) GetDisconnectedPaths() []StoragePath {
return result
}
// GetConnectedPaths returns a copy of all storage paths that are NOT disconnected.
// GetConnectedPaths returns a copy of all storage paths that are NOT disconnected and NOT decommissioned.
func (s *Settings) GetConnectedPaths() []StoragePath {
s.mu.RLock()
defer s.mu.RUnlock()
var result []StoragePath
for _, sp := range s.StoragePaths {
if !sp.Disconnected {
if !sp.Disconnected && !sp.Decommissioned {
result = append(result, sp)
}
}
@@ -610,3 +610,61 @@ func (s *Settings) ClearStoppedStacks(path string) error {
}
return fmt.Errorf("storage path %q not found", path)
}
// SetDecommissioned marks a storage path as decommissioned with migration target.
// Clears IsDefault and Schedulable.
func (s *Settings) SetDecommissioned(path, migratedTo string) error {
s.mu.Lock()
defer s.mu.Unlock()
for i := range s.StoragePaths {
if s.StoragePaths[i].Path == path {
s.StoragePaths[i].Decommissioned = true
s.StoragePaths[i].DecommissionedAt = time.Now().UTC().Format(time.RFC3339)
s.StoragePaths[i].MigratedTo = migratedTo
s.StoragePaths[i].IsDefault = false
s.StoragePaths[i].Schedulable = false
return s.save()
}
}
return fmt.Errorf("storage path %q not found", path)
}
// ClearDecommissioned removes the decommissioned state from a storage path.
func (s *Settings) ClearDecommissioned(path string) error {
s.mu.Lock()
defer s.mu.Unlock()
for i := range s.StoragePaths {
if s.StoragePaths[i].Path == path {
s.StoragePaths[i].Decommissioned = false
s.StoragePaths[i].DecommissionedAt = ""
s.StoragePaths[i].MigratedTo = ""
return s.save()
}
}
return fmt.Errorf("storage path %q not found", path)
}
// IsDecommissioned returns whether a storage path is marked as decommissioned.
func (s *Settings) IsDecommissioned(path string) bool {
s.mu.RLock()
defer s.mu.RUnlock()
for _, sp := range s.StoragePaths {
if sp.Path == path {
return sp.Decommissioned
}
}
return false
}
// GetDecommissionedPaths returns a copy of all decommissioned storage paths.
func (s *Settings) GetDecommissionedPaths() []StoragePath {
s.mu.RLock()
defer s.mu.RUnlock()
var result []StoragePath
for _, sp := range s.StoragePaths {
if sp.Decommissioned {
result = append(result, sp)
}
}
return result
}