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