v0.64.0: additive storage discovery (A1) + internal-SSD label (A2)
A1: AutoDiscoverStoragePaths no longer bails on a non-empty registry; registers only deployed-app paths missing from the registry. Never mutates/removes existing entries, never re-adds or reactivates a path present in ANY state (incl. Decommissioned), never flips IsDefault. A2: InferStorageLabel maps base==felhom-data namespace dir to 'Belső SSD (rendszer)' to disambiguate the internal system volume. Table-driven tests incl. a companion that fails without the skip-by-presence guard. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,8 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
|
||||
)
|
||||
|
||||
// Settings holds customer-modifiable overrides and cached state.
|
||||
@@ -39,8 +41,8 @@ type Settings struct {
|
||||
|
||||
// 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
|
||||
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"`
|
||||
@@ -59,7 +61,7 @@ type Settings struct {
|
||||
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"
|
||||
Status string `json:"status,omitempty"` // "active", "error", "disabled", "provider_stopped", "target_unavailable"
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
}
|
||||
|
||||
@@ -80,8 +82,8 @@ type CrossDriveBackup struct {
|
||||
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"
|
||||
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"
|
||||
@@ -95,17 +97,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
|
||||
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
|
||||
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.
|
||||
@@ -144,10 +146,10 @@ type GeoRestriction struct {
|
||||
AppOverrides map[string]AppGeoOverride `json:"app_overrides,omitempty"`
|
||||
|
||||
// Sync state (updated by geo sync manager)
|
||||
LastSync string `json:"last_sync,omitempty"` // RFC3339
|
||||
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
|
||||
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.
|
||||
@@ -157,7 +159,7 @@ type AppGeoOverride struct {
|
||||
|
||||
// DBValidationCache holds cached DB dump validation results.
|
||||
type DBValidationCache struct {
|
||||
ValidatedAt string `json:"validated_at"` // RFC3339
|
||||
ValidatedAt string `json:"validated_at"` // RFC3339
|
||||
TableCount int `json:"table_count"`
|
||||
HasHeader bool `json:"has_header"`
|
||||
Error string `json:"error,omitempty"`
|
||||
@@ -578,9 +580,19 @@ func (s *Settings) SetStorageLabel(path, label string) error {
|
||||
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).
|
||||
// AutoDiscoverStoragePaths scans for HDD_PATH values and registers any that are not
|
||||
// already in the registry. It is ADDITIVE: pre-existing entries are never removed,
|
||||
// modified, or reactivated.
|
||||
// - discoveredPaths are pre-scanned HDD_PATH values from deployed apps' app.yaml.
|
||||
// - fallbackHDDPath is the legacy controller.yaml paths.hdd_path (may be empty).
|
||||
//
|
||||
// Invariants:
|
||||
// - A path already present in the registry IN ANY STATE (including a Decommissioned
|
||||
// soft-marked entry) is SKIPPED — never re-added and never re-activated.
|
||||
// - A manually-added path is never removed or modified.
|
||||
// - IsDefault is never flipped on an existing entry. A newly-discovered path becomes
|
||||
// default ONLY if the registry currently has no default at all (and then only the
|
||||
// first such new path).
|
||||
func (s *Settings) AutoDiscoverStoragePaths(discoveredPaths []string, fallbackHDDPath string, logger *log.Logger) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
@@ -589,53 +601,77 @@ func (s *Settings) AutoDiscoverStoragePaths(discoveredPaths []string, fallbackHD
|
||||
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
|
||||
// Index existing paths (in ANY state) and whether a default already exists.
|
||||
existing := make(map[string]bool, len(s.StoragePaths))
|
||||
hasDefault := false
|
||||
for i := range s.StoragePaths {
|
||||
existing[filepath.Clean(s.StoragePaths[i].Path)] = true
|
||||
if s.StoragePaths[i].IsDefault {
|
||||
hasDefault = true
|
||||
}
|
||||
}
|
||||
|
||||
// Build the de-duplicated, cleaned candidate list (discovered first, then fallback).
|
||||
seen := make(map[string]bool)
|
||||
var ordered []string
|
||||
for _, p := range discoveredPaths {
|
||||
cleaned := filepath.Clean(p)
|
||||
if cleaned != "" && !seen[cleaned] {
|
||||
if cleaned != "" && cleaned != "." && !seen[cleaned] {
|
||||
seen[cleaned] = true
|
||||
ordered = append(ordered, cleaned)
|
||||
}
|
||||
}
|
||||
if fallbackHDDPath != "" {
|
||||
cleaned := filepath.Clean(fallbackHDDPath)
|
||||
if !seen[cleaned] {
|
||||
if cleaned != "" && cleaned != "." && !seen[cleaned] {
|
||||
seen[cleaned] = true
|
||||
ordered = append(ordered, cleaned)
|
||||
}
|
||||
}
|
||||
|
||||
for i, path := range ordered {
|
||||
added := 0
|
||||
for _, path := range ordered {
|
||||
if existing[path] {
|
||||
continue // already registered in some state — never re-add or reactivate
|
||||
}
|
||||
sp := StoragePath{
|
||||
Path: path,
|
||||
Label: InferStorageLabel(path),
|
||||
IsDefault: i == 0,
|
||||
IsDefault: !hasDefault, // first newly-added path defaults only if none exists yet
|
||||
Schedulable: true,
|
||||
AddedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
if sp.IsDefault {
|
||||
hasDefault = true // don't promote a second new path
|
||||
}
|
||||
s.StoragePaths = append(s.StoragePaths, sp)
|
||||
existing[path] = true
|
||||
added++
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
if added == 0 {
|
||||
return // nothing new to register
|
||||
}
|
||||
|
||||
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 new storage path(s)", added)
|
||||
for _, sp := range s.StoragePaths {
|
||||
logger.Printf("[INFO] [settings] %s (%s) default=%v decommissioned=%v", sp.Path, sp.Label, sp.IsDefault, sp.Decommissioned)
|
||||
}
|
||||
}
|
||||
|
||||
// InferStorageLabel generates a human-readable label for a storage path.
|
||||
func InferStorageLabel(path string) string {
|
||||
base := filepath.Base(path)
|
||||
// The internal system volume's data path ends in the felhom-data namespace dir
|
||||
// (e.g. /mnt/sys_drive/felhom-data) — Model-A user drives register their MOUNT ROOT
|
||||
// (e.g. /mnt/felhom-usb), never .../felhom-data, so this can't mislabel a user drive.
|
||||
if base == appbackup.FelhomDataDir {
|
||||
return "Belső SSD (rendszer)"
|
||||
}
|
||||
if strings.HasPrefix(base, "hdd") || strings.HasPrefix(base, "ssd") || strings.HasPrefix(base, "usb") {
|
||||
return fmt.Sprintf("Külső tárhely (%s)", base)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user