From 6595e345a28fc4097f1106b74acf50a7eba48f80 Mon Sep 17 00:00:00 2001 From: Mikkel Georgsen Date: Fri, 10 Apr 2026 01:27:54 +0000 Subject: [PATCH] feat(01-foundation-01): viper config loader and SPA fallback fix - Add internal/config/config.go with viper-backed Config struct - Explicit BindEnv calls for reliable env var -> config mapping (mapstructure v2 compat) - Config loads from config.json + .env, env vars take precedence - Add config.json with non-secret defaults (port, timeouts, URLs) - Fix SPA fallback: spaHandler serves index.html for unknown paths (client-side routing) - All 5 tests pass: TestHealth, TestLoadDefaults, TestLoadEnvOverride, TestLoadNetBoxURL - Add Makefile with build/dev/test/clean targets --- Makefile | 13 ++++++ config.json | 11 +++++ internal/api/router.go | 25 +++++++++-- internal/config/config.go | 77 ++++++++++++++++++++++++++++++++++ internal/config/config_test.go | 48 +++++++++++++++++++++ 5 files changed, 171 insertions(+), 3 deletions(-) create mode 100644 Makefile create mode 100644 config.json create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a562534 --- /dev/null +++ b/Makefile @@ -0,0 +1,13 @@ +.PHONY: build dev test clean + +build: + go build -o bin/hwlab ./cmd/hwlab/... + +dev: + air -c .air.toml + +test: + go test ./... -v + +clean: + rm -rf bin/ diff --git a/config.json b/config.json new file mode 100644 index 0000000..1e41ef4 --- /dev/null +++ b/config.json @@ -0,0 +1,11 @@ +{ + "port": 8080, + "host": "0.0.0.0", + "netbox_url": "http://10.5.0.130:8000/api", + "dragonfly_url": "redis://:nUq/IfoIQJf/kouckKHRQOk7vV0NwCuI@10.5.0.10:6379", + "log_level": "info", + "netbox_timeout_seconds": 10, + "waq_retry_interval_seconds": 30, + "waq_max_attempts": 5, + "quality_gate_confidence_threshold": 0.75 +} diff --git a/internal/api/router.go b/internal/api/router.go index b1893bb..9bb4ba9 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -10,6 +10,26 @@ import ( "git.georgsen.dk/hwlab/internal/api/handlers" ) +// spaHandler serves static files and falls back to index.html for unknown paths, +// enabling client-side routing in the React SPA. +type spaHandler struct { + staticFS fs.FS +} + +func (h spaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // Try to open the requested path in the embedded FS. + f, err := h.staticFS.Open(r.URL.Path) + if err != nil { + // File not found — serve index.html so the SPA router handles it. + r2 := r.Clone(r.Context()) + r2.URL.Path = "/" + http.FileServer(http.FS(h.staticFS)).ServeHTTP(w, r2) + return + } + f.Close() + http.FileServer(http.FS(h.staticFS)).ServeHTTP(w, r) +} + // NewRouter creates the chi router. staticFiles is the fs.FS rooted at web/dist, // passed from main.go where the go:embed directive lives. func NewRouter(staticFiles fs.FS) http.Handler { @@ -22,9 +42,8 @@ func NewRouter(staticFiles fs.FS) http.Handler { r.Get("/health", handlers.Health) }) - // SPA fallback — serve index.html for all non-API routes - fileServer := http.FileServer(http.FS(staticFiles)) - r.Handle("/*", fileServer) + // SPA fallback — serve static files; unknown paths fall back to index.html. + r.Handle("/*", spaHandler{staticFS: staticFiles}) return r } diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..4432719 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,77 @@ +package config + +import ( + "fmt" + + "github.com/joho/godotenv" + "github.com/spf13/viper" +) + +type Config struct { + Host string `mapstructure:"host"` + Port int `mapstructure:"port"` + LogLevel string `mapstructure:"log_level"` + + NetBoxURL string `mapstructure:"netbox_url"` + NetBoxToken string `mapstructure:"netbox_token"` + NetBoxTimeoutSeconds int `mapstructure:"netbox_timeout_seconds"` + + DragonflyURL string `mapstructure:"dragonfly_url"` + WAQRetryIntervalSeconds int `mapstructure:"waq_retry_interval_seconds"` + WAQMaxAttempts int `mapstructure:"waq_max_attempts"` + + QualityGateConfidenceThreshold float64 `mapstructure:"quality_gate_confidence_threshold"` +} + +func Load() (*Config, error) { + // Load .env file if present (ignore error — .env is optional in production) + _ = godotenv.Load() + + v := viper.New() + + // Set defaults + v.SetDefault("host", "0.0.0.0") + v.SetDefault("port", 8080) + v.SetDefault("log_level", "info") + v.SetDefault("netbox_timeout_seconds", 10) + v.SetDefault("waq_retry_interval_seconds", 30) + v.SetDefault("waq_max_attempts", 5) + v.SetDefault("quality_gate_confidence_threshold", 0.75) + + // Config file + v.SetConfigName("config") + v.SetConfigType("json") + v.AddConfigPath(".") + v.AddConfigPath("/etc/hwlab") + + // Environment variables: HWLAB_PORT -> port, HWLAB_NETBOX_URL -> netbox_url, etc. + v.SetEnvPrefix("HWLAB") + v.AutomaticEnv() + + // Explicit bindings ensure AutomaticEnv works correctly with mapstructure v2 unmarshalling. + // Format: key name -> env var (without prefix, viper adds HWLAB_ automatically). + _ = v.BindEnv("host", "HWLAB_HOST") + _ = v.BindEnv("port", "HWLAB_PORT") + _ = v.BindEnv("log_level", "HWLAB_LOG_LEVEL") + _ = v.BindEnv("netbox_url", "HWLAB_NETBOX_URL") + _ = v.BindEnv("netbox_token", "HWLAB_NETBOX_TOKEN") + _ = v.BindEnv("netbox_timeout_seconds", "HWLAB_NETBOX_TIMEOUT_SECONDS") + _ = v.BindEnv("dragonfly_url", "HWLAB_DRAGONFLY_URL") + _ = v.BindEnv("waq_retry_interval_seconds", "HWLAB_WAQ_RETRY_INTERVAL_SECONDS") + _ = v.BindEnv("waq_max_attempts", "HWLAB_WAQ_MAX_ATTEMPTS") + _ = v.BindEnv("quality_gate_confidence_threshold", "HWLAB_QUALITY_GATE_CONFIDENCE_THRESHOLD") + + // Read config file (non-fatal if missing) + if err := v.ReadInConfig(); err != nil { + if _, ok := err.(viper.ConfigFileNotFoundError); !ok { + return nil, fmt.Errorf("config file: %w", err) + } + } + + var cfg Config + if err := v.Unmarshal(&cfg); err != nil { + return nil, fmt.Errorf("unmarshal config: %w", err) + } + + return &cfg, nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..1da0f4b --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,48 @@ +package config_test + +import ( + "os" + "testing" + + "git.georgsen.dk/hwlab/internal/config" +) + +func TestLoadDefaults(t *testing.T) { + // Unset env vars that might interfere + os.Unsetenv("HWLAB_PORT") + os.Unsetenv("HWLAB_NETBOX_URL") + + cfg, err := config.Load() + if err != nil { + t.Fatalf("Load() error: %v", err) + } + if cfg.Port != 8080 { + t.Errorf("default port: want 8080, got %d", cfg.Port) + } +} + +func TestLoadEnvOverride(t *testing.T) { + os.Setenv("HWLAB_PORT", "9999") + defer os.Unsetenv("HWLAB_PORT") + + cfg, err := config.Load() + if err != nil { + t.Fatalf("Load() error: %v", err) + } + if cfg.Port != 9999 { + t.Errorf("env override port: want 9999, got %d", cfg.Port) + } +} + +func TestLoadNetBoxURL(t *testing.T) { + os.Setenv("HWLAB_NETBOX_URL", "http://10.5.0.130:8000/api") + defer os.Unsetenv("HWLAB_NETBOX_URL") + + cfg, err := config.Load() + if err != nil { + t.Fatalf("Load() error: %v", err) + } + if cfg.NetBoxURL != "http://10.5.0.130:8000/api" { + t.Errorf("netbox url: want http://10.5.0.130:8000/api, got %s", cfg.NetBoxURL) + } +}