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
This commit is contained in:
Mikkel Georgsen 2026-04-10 01:27:54 +00:00
parent 77e5a78d5a
commit 6595e345a2
5 changed files with 171 additions and 3 deletions

13
Makefile Normal file
View file

@ -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/

11
config.json Normal file
View file

@ -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
}

View file

@ -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
}

77
internal/config/config.go Normal file
View file

@ -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
}

View file

@ -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)
}
}