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"
|
||||
)
|
||||
|
||||
// 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
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