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:
parent
77e5a78d5a
commit
6595e345a2
5 changed files with 171 additions and 3 deletions
13
Makefile
Normal file
13
Makefile
Normal 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
11
config.json
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,26 @@ import (
|
||||||
"git.georgsen.dk/hwlab/internal/api/handlers"
|
"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,
|
// NewRouter creates the chi router. staticFiles is the fs.FS rooted at web/dist,
|
||||||
// passed from main.go where the go:embed directive lives.
|
// passed from main.go where the go:embed directive lives.
|
||||||
func NewRouter(staticFiles fs.FS) http.Handler {
|
func NewRouter(staticFiles fs.FS) http.Handler {
|
||||||
|
|
@ -22,9 +42,8 @@ func NewRouter(staticFiles fs.FS) http.Handler {
|
||||||
r.Get("/health", handlers.Health)
|
r.Get("/health", handlers.Health)
|
||||||
})
|
})
|
||||||
|
|
||||||
// SPA fallback — serve index.html for all non-API routes
|
// SPA fallback — serve static files; unknown paths fall back to index.html.
|
||||||
fileServer := http.FileServer(http.FS(staticFiles))
|
r.Handle("/*", spaHandler{staticFS: staticFiles})
|
||||||
r.Handle("/*", fileServer)
|
|
||||||
|
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
|
||||||
77
internal/config/config.go
Normal file
77
internal/config/config.go
Normal 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
|
||||||
|
}
|
||||||
48
internal/config/config_test.go
Normal file
48
internal/config/config_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue