Files
deploy-felhom-compose/controller/internal/settings/settings.go
T
admin bdbe170a54 feat: storage watchdog — USB disconnect detection, auto-stop, safe eject, auto-reconnect (v0.17.0)
New storage watchdog monitors registered storage paths every 5s. On disconnect
(3 consecutive probe failures), auto-stops affected apps, lazy-unmounts stale
VFS entries, fires alerts/notifications/hub report. On reconnect (UUID detected),
auto-remounts via fstab, cleans stale restic locks, offers app restart.

Safe disconnect UI for USB drives: confirmation dialog, stop apps, sync, unmount.
Disconnected state visible across all pages (dashboard, settings, backups, monitoring)
with hatched red bars and badges. Backup guards skip disconnected drives.

22 files changed (1 new: monitor/watchdog.go), ~1500 lines added.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 19:42:26 +01:00

613 lines
18 KiB
Go

package settings
import (
"crypto/rand"
"encoding/json"
"fmt"
"io"
"log"
"os"
"path/filepath"
"strings"
"sync"
"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 {
mu sync.RWMutex `json:"-"`
path string `json:"-"`
log *log.Logger `json:"-"`
// Auth
PasswordHash string `json:"password_hash,omitempty"` // bcrypt hash, overrides controller.yaml
// Notification preferences (Phase 2 — define struct now, leave empty)
Notifications *NotificationPrefs `json:"notifications,omitempty"`
// Cached state
DBValidations map[string]DBValidationCache `json:"db_validations,omitempty"`
// Per-app backup preferences
AppBackup map[string]AppBackupPrefs `json:"app_backup,omitempty"`
// Storage paths registry
StoragePaths []StoragePath `json:"storage_paths,omitempty"`
// Cross-drive restic repo password (auto-generated on first use)
CrossDriveResticPassword string `json:"cross_drive_restic_password,omitempty"`
}
// AppBackupPrefs holds per-app backup toggle state.
type AppBackupPrefs struct {
// Existing: includes app data in nightly restic (same drive)
Enabled bool `json:"enabled"`
// Cross-drive backup to secondary storage
CrossDrive *CrossDriveBackup `json:"cross_drive,omitempty"`
}
// CrossDriveBackup configures per-app backup to a secondary drive.
type CrossDriveBackup struct {
Enabled bool `json:"enabled"`
Method string `json:"method"` // "rsync" or "restic"
DestinationPath string `json:"destination_path"` // e.g., "/mnt/hdd_1"
Schedule string `json:"schedule"` // "daily", "weekly", "manual"
// Runtime state (updated by backup runner, persisted for display)
LastRun string `json:"last_run,omitempty"` // RFC3339
LastStatus string `json:"last_status,omitempty"` // "ok", "error", "running"
LastError string `json:"last_error,omitempty"`
LastDuration string `json:"last_duration,omitempty"` // "2m34s"
LastSizeHuman string `json:"last_size_human,omitempty"` // "1.2 GB"
}
// 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
}
// NotificationPrefs holds customer notification preferences.
type NotificationPrefs struct {
Email string `json:"email,omitempty"`
EnabledEvents []string `json:"enabled_events,omitempty"`
CooldownHours int `json:"cooldown_hours,omitempty"` // default: 6
}
// DefaultEnabledEvents are the events enabled by default for new customers.
var DefaultEnabledEvents = []string{
"disk_warning",
"backup_failed",
"update_available",
"storage_disconnected",
"storage_reconnected",
}
// DBValidationCache holds cached DB dump validation results.
type DBValidationCache struct {
ValidatedAt string `json:"validated_at"` // RFC3339
TableCount int `json:"table_count"`
HasHeader bool `json:"has_header"`
Error string `json:"error,omitempty"`
}
// Load reads settings from the given file path.
// Returns empty Settings if the file doesn't exist (not an error).
func Load(path string, logger *log.Logger) (*Settings, error) {
s := &Settings{
path: path,
log: logger,
}
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
logger.Printf("[INFO] No settings.json found, using defaults")
return s, nil
}
return nil, fmt.Errorf("reading settings file: %w", err)
}
if err := json.Unmarshal(data, s); err != nil {
return nil, fmt.Errorf("parsing settings file: %w", err)
}
logger.Printf("[DEBUG] Settings loaded from %s", path)
return s, nil
}
// 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 {
data, err := json.MarshalIndent(s, "", " ")
if err != nil {
return fmt.Errorf("marshaling settings: %w", err)
}
tmpPath := s.path + ".tmp"
if err := os.MkdirAll(filepath.Dir(s.path), 0755); err != nil {
return fmt.Errorf("creating settings dir: %w", err)
}
if err := os.WriteFile(tmpPath, data, 0644); err != nil {
os.Remove(tmpPath) // clean up partial file
return fmt.Errorf("writing tmp settings: %w", err)
}
if err := os.Rename(tmpPath, s.path); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("renaming settings file: %w", err)
}
s.log.Printf("[DEBUG] Settings saved to %s", s.path)
return nil
}
// GetPasswordHash returns the stored password hash (thread-safe).
func (s *Settings) GetPasswordHash() string {
s.mu.RLock()
defer s.mu.RUnlock()
return s.PasswordHash
}
// SetPasswordHash updates the password hash and saves to disk.
func (s *Settings) SetPasswordHash(hash string) error {
s.mu.Lock()
defer s.mu.Unlock()
s.PasswordHash = hash
return s.save()
}
// GetDBValidations returns a copy of the cached DB validations.
func (s *Settings) GetDBValidations() map[string]DBValidationCache {
s.mu.RLock()
defer s.mu.RUnlock()
if s.DBValidations == nil {
return nil
}
result := make(map[string]DBValidationCache, len(s.DBValidations))
for k, v := range s.DBValidations {
result[k] = v
}
return result
}
// SetDBValidation saves a validation result for a dump file and persists to disk.
func (s *Settings) SetDBValidation(filename string, cache DBValidationCache) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.DBValidations == nil {
s.DBValidations = make(map[string]DBValidationCache)
}
s.DBValidations[filename] = cache
return s.save()
}
// GetNotificationPrefs returns a copy of the notification preferences.
func (s *Settings) GetNotificationPrefs() *NotificationPrefs {
s.mu.RLock()
defer s.mu.RUnlock()
if s.Notifications == nil {
return &NotificationPrefs{
EnabledEvents: DefaultEnabledEvents,
CooldownHours: 6,
}
}
prefs := *s.Notifications
if prefs.CooldownHours == 0 {
prefs.CooldownHours = 6
}
if prefs.EnabledEvents == nil {
prefs.EnabledEvents = DefaultEnabledEvents
}
// Return a copy of the slice
events := make([]string, len(prefs.EnabledEvents))
copy(events, prefs.EnabledEvents)
prefs.EnabledEvents = events
return &prefs
}
// SetNotificationPrefs updates notification preferences and saves to disk.
// H17: Deep-copies prefs so caller mutations after the call don't affect stored state.
func (s *Settings) SetNotificationPrefs(prefs *NotificationPrefs) error {
if prefs == nil {
return fmt.Errorf("notification preferences cannot be nil")
}
s.mu.Lock()
defer s.mu.Unlock()
copy := *prefs
if len(prefs.EnabledEvents) > 0 {
copy.EnabledEvents = make([]string, len(prefs.EnabledEvents))
for i, e := range prefs.EnabledEvents {
copy.EnabledEvents[i] = e
}
}
s.Notifications = &copy
return s.save()
}
// GetCrossDriveConfig returns the cross-drive backup config for a stack (nil if not set).
func (s *Settings) GetCrossDriveConfig(stackName string) *CrossDriveBackup {
s.mu.RLock()
defer s.mu.RUnlock()
if s.AppBackup == nil {
return nil
}
prefs, ok := s.AppBackup[stackName]
if !ok || prefs.CrossDrive == nil {
return nil
}
cp := *prefs.CrossDrive
return &cp
}
// SetCrossDriveConfig saves (or clears) the cross-drive backup config for a stack.
func (s *Settings) SetCrossDriveConfig(stackName string, cfg *CrossDriveBackup) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.AppBackup == nil {
s.AppBackup = make(map[string]AppBackupPrefs)
}
existing := s.AppBackup[stackName]
existing.CrossDrive = cfg
s.AppBackup[stackName] = existing
return s.save()
}
// UpdateCrossDriveStatus updates runtime status fields for a cross-drive backup in-place.
// fn receives a pointer to the CrossDriveBackup and may mutate it.
// If no cross-drive config exists for the stack, does nothing and returns nil.
func (s *Settings) UpdateCrossDriveStatus(stackName string, fn func(*CrossDriveBackup)) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.AppBackup == nil {
s.AppBackup = make(map[string]AppBackupPrefs)
}
existing := s.AppBackup[stackName]
if existing.CrossDrive == nil {
return nil // don't create config from thin air — just skip status update
}
fn(existing.CrossDrive)
s.AppBackup[stackName] = existing
return s.save()
}
// GetAllCrossDriveConfigs returns all apps with a cross-drive config (enabled or not).
func (s *Settings) GetAllCrossDriveConfigs() map[string]*CrossDriveBackup {
s.mu.RLock()
defer s.mu.RUnlock()
result := make(map[string]*CrossDriveBackup)
for name, prefs := range s.AppBackup {
if prefs.CrossDrive != nil {
cp := *prefs.CrossDrive
result[name] = &cp
}
}
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
}
// --- Storage Paths ---
// GetStoragePaths returns a copy of all registered storage paths.
func (s *Settings) GetStoragePaths() []StoragePath {
s.mu.RLock()
defer s.mu.RUnlock()
if len(s.StoragePaths) == 0 {
return nil
}
result := make([]StoragePath, len(s.StoragePaths))
copy(result, s.StoragePaths)
return result
}
// GetDefaultStoragePath returns the default storage path string, or "".
func (s *Settings) GetDefaultStoragePath() string {
s.mu.RLock()
defer s.mu.RUnlock()
for _, sp := range s.StoragePaths {
if sp.IsDefault {
return sp.Path
}
}
return ""
}
// 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 {
result = append(result, sp)
}
}
return result
}
// AddStoragePath registers a new storage path. Validation is done by caller.
func (s *Settings) AddStoragePath(sp StoragePath) error {
s.mu.Lock()
defer s.mu.Unlock()
if sp.IsDefault {
for i := range s.StoragePaths {
s.StoragePaths[i].IsDefault = false
}
}
s.StoragePaths = append(s.StoragePaths, sp)
return s.save()
}
// RemoveStoragePath removes a path by its path string.
func (s *Settings) RemoveStoragePath(path string) error {
s.mu.Lock()
defer s.mu.Unlock()
var kept []StoragePath
for _, sp := range s.StoragePaths {
if sp.Path != path {
kept = append(kept, sp)
}
}
s.StoragePaths = kept
return s.save()
}
// SetDefaultStoragePath changes which path is the default.
func (s *Settings) SetDefaultStoragePath(path string) error {
s.mu.Lock()
defer s.mu.Unlock()
found := false
for i := range s.StoragePaths {
if s.StoragePaths[i].Path == path {
s.StoragePaths[i].IsDefault = true
found = true
} else {
s.StoragePaths[i].IsDefault = false
}
}
if !found {
return fmt.Errorf("storage path %q not found", path)
}
return s.save()
}
// SetSchedulable enables/disables a path for new deployments.
func (s *Settings) SetSchedulable(path string, schedulable bool) error {
s.mu.Lock()
defer s.mu.Unlock()
for i := range s.StoragePaths {
if s.StoragePaths[i].Path == path {
s.StoragePaths[i].Schedulable = schedulable
return s.save()
}
}
return fmt.Errorf("storage path %q not found", path)
}
// SetStorageLabel updates the label for a storage path.
func (s *Settings) SetStorageLabel(path, label string) error {
s.mu.Lock()
defer s.mu.Unlock()
for i := range s.StoragePaths {
if s.StoragePaths[i].Path == path {
s.StoragePaths[i].Label = label
return s.save()
}
}
return fmt.Errorf("storage path %q not found", path)
}
// AutoDiscoverStoragePaths scans for HDD_PATH values and registers them if none exist.
// discoveredPaths are pre-scanned HDD_PATH values from deployed apps' app.yaml.
// fallbackHDDPath is the legacy controller.yaml paths.hdd_path (may be empty).
func (s *Settings) AutoDiscoverStoragePaths(discoveredPaths []string, fallbackHDDPath string, logger *log.Logger) {
s.mu.Lock()
defer s.mu.Unlock()
if len(s.StoragePaths) > 0 {
return // already configured
}
seen := make(map[string]bool)
var ordered []string
for _, p := range discoveredPaths {
cleaned := filepath.Clean(p)
if cleaned != "" && !seen[cleaned] {
seen[cleaned] = true
ordered = append(ordered, cleaned)
}
}
if fallbackHDDPath != "" {
cleaned := filepath.Clean(fallbackHDDPath)
if !seen[cleaned] {
seen[cleaned] = true
ordered = append(ordered, cleaned)
}
}
for i, path := range ordered {
sp := StoragePath{
Path: path,
Label: InferStorageLabel(path),
IsDefault: i == 0,
Schedulable: true,
AddedAt: time.Now().UTC().Format(time.RFC3339),
}
s.StoragePaths = append(s.StoragePaths, sp)
}
if len(s.StoragePaths) > 0 {
if err := s.save(); err != nil {
logger.Printf("[ERROR] Failed to save auto-discovered storage paths: %v", err)
return
}
logger.Printf("[INFO] Auto-discovered %d storage path(s)", len(s.StoragePaths))
for _, sp := range s.StoragePaths {
logger.Printf("[INFO] %s (%s) default=%v", sp.Path, sp.Label, sp.IsDefault)
}
}
}
// InferStorageLabel generates a human-readable label for a storage path.
func InferStorageLabel(path string) string {
base := filepath.Base(path)
if strings.HasPrefix(base, "hdd") || strings.HasPrefix(base, "ssd") || strings.HasPrefix(base, "usb") {
return fmt.Sprintf("Külső tárhely (%s)", base)
}
return fmt.Sprintf("Tárhely (%s)", base)
}
// SetDisconnected marks a storage path as disconnected (or connected) and records which stacks were stopped.
func (s *Settings) SetDisconnected(path string, disconnected bool, stoppedStacks []string) error {
s.mu.Lock()
defer s.mu.Unlock()
for i := range s.StoragePaths {
if s.StoragePaths[i].Path == path {
s.StoragePaths[i].Disconnected = disconnected
if disconnected {
s.StoragePaths[i].DisconnectedAt = time.Now().UTC().Format(time.RFC3339)
s.StoragePaths[i].StoppedStacks = stoppedStacks
} else {
s.StoragePaths[i].DisconnectedAt = ""
// Preserve StoppedStacks on reconnect so the UI can offer restart
if stoppedStacks != nil {
s.StoragePaths[i].StoppedStacks = stoppedStacks
}
}
return s.save()
}
}
return fmt.Errorf("storage path %q not found", path)
}
// ClearDisconnected marks a path as connected and clears all disconnect-related fields.
func (s *Settings) ClearDisconnected(path string) error {
s.mu.Lock()
defer s.mu.Unlock()
for i := range s.StoragePaths {
if s.StoragePaths[i].Path == path {
s.StoragePaths[i].Disconnected = false
s.StoragePaths[i].DisconnectedAt = ""
s.StoragePaths[i].StoppedStacks = nil
return s.save()
}
}
return fmt.Errorf("storage path %q not found", path)
}
// IsDisconnected returns whether a storage path is marked as disconnected.
func (s *Settings) IsDisconnected(path string) bool {
s.mu.RLock()
defer s.mu.RUnlock()
for _, sp := range s.StoragePaths {
if sp.Path == path {
return sp.Disconnected
}
}
return false
}
// GetDisconnectedPaths returns a copy of all storage paths that are marked disconnected.
func (s *Settings) GetDisconnectedPaths() []StoragePath {
s.mu.RLock()
defer s.mu.RUnlock()
var result []StoragePath
for _, sp := range s.StoragePaths {
if sp.Disconnected {
result = append(result, sp)
}
}
return result
}
// GetConnectedPaths returns a copy of all storage paths that are NOT disconnected.
func (s *Settings) GetConnectedPaths() []StoragePath {
s.mu.RLock()
defer s.mu.RUnlock()
var result []StoragePath
for _, sp := range s.StoragePaths {
if !sp.Disconnected {
result = append(result, sp)
}
}
return result
}
// GetStoppedStacks returns the list of stacks that were auto-stopped for a storage path.
func (s *Settings) GetStoppedStacks(path string) []string {
s.mu.RLock()
defer s.mu.RUnlock()
for _, sp := range s.StoragePaths {
if sp.Path == path {
if len(sp.StoppedStacks) == 0 {
return nil
}
result := make([]string, len(sp.StoppedStacks))
copy(result, sp.StoppedStacks)
return result
}
}
return nil
}
// ClearStoppedStacks removes the stopped stacks list for a storage path (e.g., after restart).
func (s *Settings) ClearStoppedStacks(path string) error {
s.mu.Lock()
defer s.mu.Unlock()
for i := range s.StoragePaths {
if s.StoragePaths[i].Path == path {
s.StoragePaths[i].StoppedStacks = nil
return s.save()
}
}
return fmt.Errorf("storage path %q not found", path)
}