R-492: delete the always-empty cfg.Paths.HDDPath (field, env binding, every fallback)

Readers (report builder, health check, /api/system, web primaryHDDPath, metrics collector,
AutoDiscoverStoragePaths' fallback parameter) now use the storage registry only. An old
controller.yaml still carrying paths.hdd_path keeps loading (non-strict YAML), pinned by
TestR492_OldConfigWithHDDPathStillLoads.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-05 21:45:53 +02:00
parent d15ad105be
commit 7b8216abac
11 changed files with 45 additions and 47 deletions
+2 -5
View File
@@ -355,7 +355,7 @@ func main() {
// --- Auto-discover storage paths from deployed apps ---
discoveredPaths := discoverHDDPaths(cfg.Paths.StacksDir, logger)
sett.AutoDiscoverStoragePaths(discoveredPaths, cfg.Paths.HDDPath, logger)
sett.AutoDiscoverStoragePaths(discoveredPaths, logger)
// --- Load or create encryption key ---
encKeyPath := filepath.Join(cfg.Paths.DataDir, "encryption.key")
@@ -523,10 +523,7 @@ func main() {
if metricsStore != nil {
defer metricsStore.Close()
metricsHDDPath := cfg.Paths.HDDPath
if p := sett.GetDefaultStoragePath(); p != "" {
metricsHDDPath = p
}
metricsHDDPath := sett.GetDefaultStoragePath() // R-492: no global fallback any more
metricsCollector := metrics.NewMetricsCollector(metricsStore, cpuCollector, metricsHDDPath, logger)
metricsCollector.Start(ctx)
defer metricsCollector.Stop()
+3 -3
View File
@@ -1171,9 +1171,9 @@ func (r *Router) triggerSync(w http.ResponseWriter, _ *http.Request) {
}
func (r *Router) systemInfo(w http.ResponseWriter, _ *http.Request) {
// R-490 / R-465: the same default-storage-path fallback every other reader of the always-empty
// cfg.Paths.HDDPath has; without it hdd_configured was false on every box.
hddPath := r.cfg.Paths.HDDPath
// R-490 / R-465 / R-492: the default storage path is the only source (the always-empty global
// paths.hdd_path was deleted in R-492); without it hdd_configured was false on every box.
hddPath := ""
if r.sett != nil {
if p := r.sett.GetDefaultStoragePath(); p != "" {
hddPath = p
-2
View File
@@ -164,7 +164,6 @@ type PathsConfig struct {
StacksDir string `yaml:"stacks_dir"`
DataDir string `yaml:"data_dir"`
SystemDataPath string `yaml:"system_data_path"`
HDDPath string `yaml:"hdd_path"`
}
type WebConfig struct {
@@ -450,7 +449,6 @@ func applyEnvOverrides(cfg *Config) {
envStr("FELHOM_WEB_LISTEN", &cfg.Web.Listen)
envStr("FELHOM_WEB_PASSWORD_HASH", &cfg.Web.PasswordHash)
envStr("FELHOM_PATHS_STACKS_DIR", &cfg.Paths.StacksDir)
envStr("FELHOM_PATHS_HDD_PATH", &cfg.Paths.HDDPath)
envStr("FELHOM_LOGGING_LEVEL", &cfg.Logging.Level)
envStr("FELHOM_MONITORING_SYSTEM_HEALTH_INTERVAL", &cfg.Monitoring.SystemHealthInterval)
}
@@ -0,0 +1,27 @@
package config
import (
"path/filepath"
"testing"
)
// R-492 — the global paths.hdd_path was deleted (it was empty on every box and every reader already
// fell back to the storage registry). The consequence that matters is the OLD box: a controller.yaml
// written before the deletion that still carries `hdd_path` (empty or not) must keep loading, with
// every other field intact — a config that fails to parse is a controller that does not start.
// RED-PROOF (REPORT): switch loadAndParse to a strict decoder (yaml KnownFields) and this fails.
func TestR492_OldConfigWithHDDPathStillLoads(t *testing.T) {
for _, v := range []string{`""`, `/mnt/legacy_hdd`} {
y := minimalYAML(bcryptHash) + "paths:\n stacks_dir: /opt/stacks\n hdd_path: " + v + "\n"
cfg, err := LoadFromBytes([]byte(y))
if err != nil {
t.Fatalf("hdd_path=%s: an old config no longer loads: %v", v, err)
}
if cfg.Paths.StacksDir != filepath.FromSlash("/opt/stacks") && cfg.Paths.StacksDir != "/opt/stacks" {
t.Errorf("hdd_path=%s: neighbouring field lost: stacks_dir=%q", v, cfg.Paths.StacksDir)
}
if cfg.Customer.ID != "demo" {
t.Errorf("hdd_path=%s: customer.id lost: %q", v, cfg.Customer.ID)
}
}
}
+1 -1
View File
@@ -76,7 +76,7 @@ func RunHealthCheck(cfg *config.Config, cpuCollector *system.CPUCollector, stora
debug := cfg.Logging.Level == "debug" && logger != nil
hddPath := cfg.Paths.HDDPath
hddPath := "" // R-492: the global paths.hdd_path is gone; the registry is the only source
if len(storagePaths) > 0 {
hddPath = storagePaths[0].Path
}
+1 -1
View File
@@ -66,7 +66,7 @@ func BuildReport(
// System info
staticInfo := metrics.GetStaticInfo()
hddPath := cfg.Paths.HDDPath
hddPath := "" // R-492: the global paths.hdd_path is gone; the registry is the only source
if len(storagePaths) > 0 {
hddPath = storagePaths[0].Path
}
+3 -11
View File
@@ -1738,7 +1738,6 @@ func (s *Settings) SetStorageLabel(path, label string) error {
// 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
@@ -1747,12 +1746,12 @@ func (s *Settings) SetStorageLabel(path, label string) error {
// - 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) {
func (s *Settings) AutoDiscoverStoragePaths(discoveredPaths []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))
s.log.Printf("[DEBUG] [settings] AutoDiscoverStoragePaths discovered=%v existing=%d", discoveredPaths, len(s.StoragePaths))
}
// Index existing paths (in ANY state) and whether a default already exists.
@@ -1765,7 +1764,7 @@ func (s *Settings) AutoDiscoverStoragePaths(discoveredPaths []string, fallbackHD
}
}
// Build the de-duplicated, cleaned candidate list (discovered first, then fallback).
// Build the de-duplicated, cleaned candidate list.
seen := make(map[string]bool)
var ordered []string
for _, p := range discoveredPaths {
@@ -1775,13 +1774,6 @@ func (s *Settings) AutoDiscoverStoragePaths(discoveredPaths []string, fallbackHD
ordered = append(ordered, cleaned)
}
}
if fallbackHDDPath != "" {
cleaned := filepath.Clean(fallbackHDDPath)
if cleaned != "" && cleaned != "." && !seen[cleaned] {
seen[cleaned] = true
ordered = append(ordered, cleaned)
}
}
added := 0
for _, path := range ordered {
@@ -30,7 +30,6 @@ func TestAutoDiscoverStoragePaths_Additive(t *testing.T) {
name string
existing []StoragePath
discovered []string
fallback string
wantPaths []string // expected Path set after discovery (order-insensitive)
wantNewPath string // a path expected to be newly added (may be "")
// assertions run against the resulting registry
@@ -101,17 +100,6 @@ func TestAutoDiscoverStoragePaths_Additive(t *testing.T) {
}
},
},
{
name: "fallback path registered when missing",
existing: []StoragePath{
{Path: "/mnt/felhom-usb", Label: "x", IsDefault: true, Schedulable: true, AddedAt: "2026-01-01T00:00:00Z"},
},
discovered: nil,
fallback: "/mnt/legacy_hdd",
wantPaths: []string{"/mnt/felhom-usb", "/mnt/legacy_hdd"},
wantNewPath: "/mnt/legacy_hdd",
check: nil,
},
}
for _, tc := range tests {
@@ -119,7 +107,7 @@ func TestAutoDiscoverStoragePaths_Additive(t *testing.T) {
s := newTestSettings(t, cloneStoragePaths(tc.existing))
before := cloneStoragePaths(tc.existing)
s.AutoDiscoverStoragePaths(tc.discovered, tc.fallback, logger)
s.AutoDiscoverStoragePaths(tc.discovered, logger)
// Normalize with filepath.Clean so comparisons hold on both Linux (the deploy
// target) and Windows (the dev machine), where Clean uses backslashes.
@@ -158,7 +146,7 @@ func TestAutoDiscoverStoragePaths_DecommissionedNotReactivated(t *testing.T) {
before := cloneStoragePaths(existing)
// A deployed app still points at the decommissioned drive.
s.AutoDiscoverStoragePaths([]string{"/mnt/old_hdd", "/mnt/felhom-usb"}, "", logger)
s.AutoDiscoverStoragePaths([]string{"/mnt/old_hdd", "/mnt/felhom-usb"}, logger)
if len(s.StoragePaths) != 2 {
t.Fatalf("path count changed: got %d want 2 (%v)", len(s.StoragePaths), pathList(s))
@@ -399,15 +399,13 @@ func TestDeleteStack_R442_RemovesDeclaredDriveData(t *testing.T) {
}
// GetStackHDDData (the modal's source) reads the per-app record too: with HDD_PATH recorded it
// lists the folder; the global config stays empty throughout.
// lists the folder; there is no global config value to fall back to (deleted in R-492).
func TestGetStackHDDData_R442_ReadsPerAppRecord(t *testing.T) {
drive := t.TempDir()
m, _, _ := newR442Manager(t, "app", driveCompose, driveAppYAML(drive), drive)
data := filepath.Join(drive, "appdata", "app")
plantFile(t, filepath.Join(data, "one.bin"))
if m.cfg.Paths.HDDPath != "" {
t.Fatal("fixture error: the global must stay empty to prove the per-app read")
}
// (R-492: the global paths.hdd_path no longer exists, so the per-app record is the only source.)
resp, err := m.GetStackHDDData("app")
if err != nil {
t.Fatal(err)
+3 -5
View File
@@ -982,12 +982,10 @@ func (s *Server) findStackBySubdomain(subdomain string) (*stacks.Stack, bool) {
return nil, false
}
// primaryHDDPath returns the default storage path, or the legacy config value.
// primaryHDDPath returns the default storage path ("" when none is registered — R-492 deleted the
// always-empty legacy config value it used to fall back to).
func (s *Server) primaryHDDPath() string {
if p := s.settings.GetDefaultStoragePath(); p != "" {
return p
}
return s.cfg.Paths.HDDPath
return s.settings.GetDefaultStoragePath()
}
func (s *Server) render(w http.ResponseWriter, name string, data interface{}) {