Files
deploy-felhom-compose/controller/internal/settings/settings.go
T
admin 9b13c0e21c feat: Tier2 backup pauses when destination drive is inactive (Inaktív)
Deactivated drives (Schedulable=false) now treated like disconnected for
Tier2 backups. New IsStoragePathSchedulable() checks active+connected+not
decommissioned. UI shows yellow "Cél meghajtó inaktív" badge, scheduler
skips silently with WARN log.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 10:59:56 +01:00

1102 lines
34 KiB
Go

package settings
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
// 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:"-"`
debug bool `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"`
// Hub verification state
HubVerified bool `json:"hub_verified,omitempty"`
HubVerifiedAt string `json:"hub_verified_at,omitempty"` // RFC3339
HubLastCheck string `json:"hub_last_check,omitempty"` // RFC3339
// Recovery credentials (saved from setup wizard input)
RetrievalPassword string `json:"retrieval_password,omitempty"`
// Pending events (queued for next Hub push)
PendingEvents []PendingEvent `json:"pending_events,omitempty"`
// Geo-restriction settings (Cloudflare WAF rules)
GeoRestriction *GeoRestriction `json:"geo_restriction,omitempty"`
// App-to-app integration state (e.g., "onlyoffice:filebrowser" → state)
Integrations map[string]IntegrationState `json:"integrations,omitempty"`
}
// IntegrationState holds the state of a provider:target integration pair.
type IntegrationState struct {
Enabled bool `json:"enabled"`
EnabledAt string `json:"enabled_at,omitempty"` // RFC3339
Status string `json:"status,omitempty"` // "active", "error", "disabled", "provider_stopped", "target_unavailable"
LastError string `json:"last_error,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
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.
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{
"backup_failed",
"db_dump_failed",
"disk_warning",
"disk_critical",
"storage_disconnected",
"node_down",
"health_critical",
"expected_backup_missed",
"expected_dbdump_missed",
}
// PendingEvent is an event queued for the next Hub push cycle.
type PendingEvent struct {
EventType string `json:"event_type"`
Severity string `json:"severity"`
Message string `json:"message"`
Details string `json:"details"` // JSON string
CreatedAt string `json:"created_at"` // RFC3339
}
// GeoRestriction holds global and per-app geo-restriction settings.
type GeoRestriction struct {
Enabled bool `json:"enabled"`
AllowedCountries []string `json:"allowed_countries"`
AppOverrides map[string]AppGeoOverride `json:"app_overrides,omitempty"`
// Sync state (updated by geo sync manager)
LastSync string `json:"last_sync,omitempty"` // RFC3339
LastSyncError string `json:"last_sync_error,omitempty"`
ZoneID string `json:"zone_id,omitempty"` // cached Cloudflare zone ID
RulesetID string `json:"ruleset_id,omitempty"` // cached Cloudflare ruleset ID
}
// AppGeoOverride holds per-app country override.
type AppGeoOverride struct {
AllowedCountries []string `json:"allowed_countries"`
}
// 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"`
}
// SetDebug enables or disables debug logging for settings operations.
func (s *Settings) SetDebug(debug bool) {
s.debug = debug
}
// 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] [settings] 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("[INFO] [settings] Loaded settings from %s", path)
if s.debug {
s.log.Printf("[DEBUG] [settings] loaded: storage_paths=%d integrations=%d pending_events=%d",
len(s.StoragePaths), len(s.Integrations), len(s.PendingEvents))
}
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] [settings] 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] [settings] 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 {
data, err := json.MarshalIndent(s, "", " ")
if err != nil {
if s.log != nil {
s.log.Printf("[ERROR] [settings] Failed to save: %v", err)
}
return fmt.Errorf("marshaling settings: %w", err)
}
tmpPath := s.path + ".tmp"
if err := os.MkdirAll(filepath.Dir(s.path), 0755); err != nil {
if s.log != nil {
s.log.Printf("[ERROR] [settings] Failed to save: %v", err)
}
return fmt.Errorf("creating settings dir: %w", err)
}
if err := os.WriteFile(tmpPath, data, 0644); err != nil {
os.Remove(tmpPath) // clean up partial file
if s.log != nil {
s.log.Printf("[ERROR] [settings] Failed to save: %v", err)
}
return fmt.Errorf("writing tmp settings: %w", err)
}
if err := os.Rename(tmpPath, s.path); err != nil {
os.Remove(tmpPath)
if s.log != nil {
s.log.Printf("[ERROR] [settings] Failed to save: %v", err)
}
return fmt.Errorf("renaming settings file: %w", err)
}
if s.debug {
s.log.Printf("[DEBUG] [settings] saved to %s (%d bytes)", s.path, len(data))
}
if s.log != nil {
s.log.Printf("[INFO] [settings] Settings saved")
}
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 {
events := make([]string, len(DefaultEnabledEvents))
copy(events, DefaultEnabledEvents)
return &NotificationPrefs{
EnabledEvents: events,
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()
cp := *prefs
if len(prefs.EnabledEvents) > 0 {
cp.EnabledEvents = make([]string, len(prefs.EnabledEvents))
for i, e := range prefs.EnabledEvents {
cp.EnabledEvents[i] = e
}
}
s.Notifications = &cp
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
}
// 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 ---
// 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 ""
}
// 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 && !sp.Decommissioned {
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 s.debug {
s.log.Printf("[DEBUG] [settings] AddStoragePath path=%q label=%q default=%v", sp.Path, sp.Label, sp.IsDefault)
}
for _, existing := range s.StoragePaths {
if existing.Path == sp.Path {
return fmt.Errorf("storage path %q already registered", sp.Path)
}
}
if sp.IsDefault {
for i := range s.StoragePaths {
s.StoragePaths[i].IsDefault = false
}
}
s.StoragePaths = append(s.StoragePaths, sp)
if s.log != nil {
s.log.Printf("[INFO] [settings] Added storage path: %s", sp.Path)
}
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()
if s.debug {
s.log.Printf("[DEBUG] [settings] RemoveStoragePath path=%q", path)
}
var kept []StoragePath
for _, sp := range s.StoragePaths {
if sp.Path != path {
kept = append(kept, sp)
}
}
s.StoragePaths = kept
if s.log != nil {
s.log.Printf("[INFO] [settings] Removed storage path: %s", path)
}
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 s.debug {
s.log.Printf("[DEBUG] [settings] AutoDiscoverStoragePaths discovered=%v fallback=%q existing=%d", discoveredPaths, fallbackHDDPath, len(s.StoragePaths))
}
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] [settings] Failed to save auto-discovered storage paths: %v", err)
return
}
logger.Printf("[INFO] [settings] Auto-discovered %d storage path(s)", len(s.StoragePaths))
for _, sp := range s.StoragePaths {
logger.Printf("[INFO] [settings] %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()
if s.debug {
s.log.Printf("[DEBUG] [settings] SetDisconnected path=%q disconnected=%v stopped_stacks=%d", path, disconnected, len(stoppedStacks))
}
if s.log != nil {
s.log.Printf("[INFO] [settings] Storage path %s disconnected=%v", path, disconnected)
}
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 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 && !sp.Decommissioned {
result = append(result, sp)
}
}
return result
}
// IsStoragePathKnown returns whether a path belongs to any registered storage path
// (connected, disconnected, or decommissioned). A path removed entirely from
// storage_paths is NOT known.
func (s *Settings) IsStoragePathKnown(path string) bool {
s.mu.RLock()
defer s.mu.RUnlock()
for _, sp := range s.StoragePaths {
if path == sp.Path || strings.HasPrefix(path, sp.Path+"/") {
return true
}
}
return false
}
// IsStoragePathSchedulable returns whether a path belongs to a registered,
// schedulable (active) storage path. Returns false if the path is unknown,
// disconnected, decommissioned, or inactive.
func (s *Settings) IsStoragePathSchedulable(path string) bool {
s.mu.RLock()
defer s.mu.RUnlock()
for _, sp := range s.StoragePaths {
if path == sp.Path || strings.HasPrefix(path, sp.Path+"/") {
return sp.Schedulable && !sp.Disconnected && !sp.Decommissioned
}
}
return false
}
// 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)
}
// 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()
if s.debug {
s.log.Printf("[DEBUG] [settings] SetDecommissioned path=%q migrated_to=%q", path, migratedTo)
}
if s.log != nil {
s.log.Printf("[INFO] [settings] Storage path %s decommissioned (migrated_to=%s)", path, migratedTo)
}
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
}
// --- Hub Verification ---
// GetHubVerified returns the hub verification state.
func (s *Settings) GetHubVerified() (verified bool, verifiedAt string) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.HubVerified, s.HubVerifiedAt
}
// SetHubVerified updates the hub verification state and saves to disk.
func (s *Settings) SetHubVerified(verified bool, at time.Time) error {
s.mu.Lock()
defer s.mu.Unlock()
s.HubVerified = verified
s.HubVerifiedAt = at.UTC().Format(time.RFC3339)
s.HubLastCheck = at.UTC().Format(time.RFC3339)
return s.save()
}
// SetHubLastCheck updates the last Hub check timestamp without changing verification status.
func (s *Settings) SetHubLastCheck(at time.Time) error {
s.mu.Lock()
defer s.mu.Unlock()
s.HubLastCheck = at.UTC().Format(time.RFC3339)
return s.save()
}
// IsLimitedMode returns true if the controller should operate in limited mode
// (new deployments blocked). This happens when:
// - Never verified AND >7 days since controller started, OR
// - Hub explicitly set customer as blocked (HubVerified=false after a successful check)
func (s *Settings) IsLimitedMode() bool {
s.mu.RLock()
defer s.mu.RUnlock()
if s.HubVerified {
return false
}
// If we have a last check timestamp and it says not verified, limited mode
if s.HubLastCheck != "" {
return true
}
// Never checked yet — check if grace period (7 days) expired
if s.HubVerifiedAt == "" {
// No verification timestamp at all — not yet in limited mode (grace period from startup)
return false
}
t, err := time.Parse(time.RFC3339, s.HubVerifiedAt)
if err != nil {
return false
}
return time.Since(t) > 7*24*time.Hour
}
// --- Retrieval Password ---
// GetRetrievalPassword returns the stored retrieval password (thread-safe).
func (s *Settings) GetRetrievalPassword() string {
s.mu.RLock()
defer s.mu.RUnlock()
return s.RetrievalPassword
}
// SetRetrievalPassword updates the retrieval password and saves to disk.
func (s *Settings) SetRetrievalPassword(password string) error {
s.mu.Lock()
defer s.mu.Unlock()
s.RetrievalPassword = password
return s.save()
}
// --- Pending Events ---
// AddPendingEvent queues an event for the next Hub push cycle.
func (s *Settings) AddPendingEvent(event PendingEvent) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.debug {
s.log.Printf("[DEBUG] [settings] AddPendingEvent type=%q severity=%q", event.EventType, event.Severity)
}
if s.log != nil {
s.log.Printf("[INFO] [settings] Added pending event: %s", event.EventType)
}
s.PendingEvents = append(s.PendingEvents, event)
return s.save()
}
// DrainPendingEvents returns and clears all pending events (thread-safe).
func (s *Settings) DrainPendingEvents() []PendingEvent {
s.mu.Lock()
defer s.mu.Unlock()
if len(s.PendingEvents) == 0 {
return nil
}
if s.debug {
s.log.Printf("[DEBUG] [settings] DrainPendingEvents count=%d", len(s.PendingEvents))
}
events := make([]PendingEvent, len(s.PendingEvents))
copy(events, s.PendingEvents)
s.PendingEvents = nil
if err := s.save(); err != nil {
s.log.Printf("[ERROR] [settings] Failed to save after draining pending events: %v — restoring events", err)
s.PendingEvents = events
return nil
}
return events
}
// --- Geo-Restriction ---
// GetGeoRestriction returns a deep copy of the geo-restriction settings.
func (s *Settings) GetGeoRestriction() *GeoRestriction {
s.mu.RLock()
defer s.mu.RUnlock()
if s.GeoRestriction == nil {
return nil
}
geo := *s.GeoRestriction
if len(s.GeoRestriction.AllowedCountries) > 0 {
geo.AllowedCountries = make([]string, len(s.GeoRestriction.AllowedCountries))
copy(geo.AllowedCountries, s.GeoRestriction.AllowedCountries)
}
if len(s.GeoRestriction.AppOverrides) > 0 {
geo.AppOverrides = make(map[string]AppGeoOverride, len(s.GeoRestriction.AppOverrides))
for k, v := range s.GeoRestriction.AppOverrides {
ov := AppGeoOverride{AllowedCountries: make([]string, len(v.AllowedCountries))}
copy(ov.AllowedCountries, v.AllowedCountries)
geo.AppOverrides[k] = ov
}
}
return &geo
}
// SetGeoRestriction replaces the entire geo-restriction config and saves to disk.
func (s *Settings) SetGeoRestriction(geo *GeoRestriction) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.debug {
if geo == nil {
s.log.Printf("[DEBUG] [settings] SetGeoRestriction geo=nil (clearing)")
} else {
s.log.Printf("[DEBUG] [settings] SetGeoRestriction enabled=%v countries=%d", geo.Enabled, len(geo.AllowedCountries))
}
}
if geo == nil {
s.GeoRestriction = nil
return s.save()
}
cp := *geo
if len(geo.AllowedCountries) > 0 {
cp.AllowedCountries = make([]string, len(geo.AllowedCountries))
copy(cp.AllowedCountries, geo.AllowedCountries)
}
if len(geo.AppOverrides) > 0 {
cp.AppOverrides = make(map[string]AppGeoOverride, len(geo.AppOverrides))
for k, v := range geo.AppOverrides {
ov := AppGeoOverride{AllowedCountries: make([]string, len(v.AllowedCountries))}
copy(ov.AllowedCountries, v.AllowedCountries)
cp.AppOverrides[k] = ov
}
}
s.GeoRestriction = &cp
return s.save()
}
// SetGeoAppOverride sets a per-app geo override. Creates the GeoRestriction if nil.
// Pass override=nil to remove the override (same as RemoveGeoAppOverride).
func (s *Settings) SetGeoAppOverride(appName string, override *AppGeoOverride) error {
s.mu.Lock()
defer s.mu.Unlock()
if override == nil {
// nil override = remove (fall back to global)
if s.GeoRestriction != nil && s.GeoRestriction.AppOverrides != nil {
delete(s.GeoRestriction.AppOverrides, appName)
}
return s.save()
}
if s.GeoRestriction == nil {
s.GeoRestriction = &GeoRestriction{AllowedCountries: []string{"HU"}}
}
if s.GeoRestriction.AppOverrides == nil {
s.GeoRestriction.AppOverrides = make(map[string]AppGeoOverride)
}
ov := AppGeoOverride{AllowedCountries: make([]string, len(override.AllowedCountries))}
copy(ov.AllowedCountries, override.AllowedCountries)
s.GeoRestriction.AppOverrides[appName] = ov
return s.save()
}
// RemoveGeoAppOverride removes a per-app override (app falls back to global).
func (s *Settings) RemoveGeoAppOverride(appName string) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.GeoRestriction == nil || s.GeoRestriction.AppOverrides == nil {
return nil
}
delete(s.GeoRestriction.AppOverrides, appName)
return s.save()
}
// SetGeoSyncState updates the geo sync status fields.
func (s *Settings) SetGeoSyncState(zoneID, rulesetID, syncError string) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.GeoRestriction == nil {
return nil
}
s.GeoRestriction.LastSync = time.Now().UTC().Format(time.RFC3339)
s.GeoRestriction.LastSyncError = syncError
if zoneID != "" {
s.GeoRestriction.ZoneID = zoneID
}
if rulesetID != "" {
s.GeoRestriction.RulesetID = rulesetID
}
return s.save()
}
// --- App-to-app integrations ---
// GetIntegrationState returns the state for a specific integration key (e.g., "onlyoffice:filebrowser").
func (s *Settings) GetIntegrationState(key string) (IntegrationState, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
if s.Integrations == nil {
return IntegrationState{}, false
}
state, ok := s.Integrations[key]
return state, ok
}
// SetIntegrationState updates (or creates) the state for a single integration key.
func (s *Settings) SetIntegrationState(key string, state IntegrationState) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.debug {
s.log.Printf("[DEBUG] [settings] SetIntegrationState key=%q status=%q enabled=%v", key, state.Status, state.Enabled)
}
if s.Integrations == nil {
s.Integrations = make(map[string]IntegrationState)
}
s.Integrations[key] = state
return s.save()
}
// RemoveIntegrationState removes an integration key entirely.
func (s *Settings) RemoveIntegrationState(key string) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.Integrations != nil {
delete(s.Integrations, key)
}
return s.save()
}
// GetIntegrationsForProvider returns all integration states where key starts with "provider:".
func (s *Settings) GetIntegrationsForProvider(provider string) map[string]IntegrationState {
s.mu.RLock()
defer s.mu.RUnlock()
prefix := provider + ":"
result := make(map[string]IntegrationState)
for k, v := range s.Integrations {
if strings.HasPrefix(k, prefix) {
result[k] = v
}
}
return result
}
// GetIntegrationsForTarget returns all integration states where key ends with ":target".
func (s *Settings) GetIntegrationsForTarget(target string) map[string]IntegrationState {
s.mu.RLock()
defer s.mu.RUnlock()
suffix := ":" + target
result := make(map[string]IntegrationState)
for k, v := range s.Integrations {
if strings.HasSuffix(k, suffix) {
result[k] = v
}
}
return result
}