--- phase: 01-foundation plan: 01 type: execute wave: 1 depends_on: [] files_modified: - go.mod - go.sum - cmd/hwlab/main.go - internal/api/router.go - internal/api/handlers/health.go - internal/config/config.go - config.json - Makefile - web/dist/index.html autonomous: true requirements: - INF-01 - INF-02 must_haves: truths: - "Running `go run ./cmd/hwlab/...` starts an HTTP server on port 8080" - "GET /api/health returns 200 with JSON {status:ok, version:0.1.0}" - "GET / serves the stub HTML page from web/dist/index.html (embedded in binary)" - "Config loads from config.json and .env without panicking on missing optional fields" artifacts: - path: "cmd/hwlab/main.go" provides: "Binary entry point — wires config, server, starts listener" - path: "internal/api/router.go" provides: "chi router with middleware, /api routes, SPA fallback" - path: "internal/api/handlers/health.go" provides: "GET /api/health handler" - path: "internal/config/config.go" provides: "viper-backed Config struct loaded from .env + config.json" - path: "web/dist/index.html" provides: "Stub SPA embedded via go:embed" - path: "go.mod" provides: "Module declaration git.georgsen.dk/hwlab with all Phase 1 deps" key_links: - from: "cmd/hwlab/main.go" to: "internal/api/router.go" via: "NewRouter(cfg) call" pattern: "NewRouter" - from: "internal/api/router.go" to: "web/dist" via: "go:embed + http.FileServer" pattern: "go:embed web/dist" --- Initialize the Go binary scaffold: module setup, chi HTTP server, viper config, health endpoint, and stub React SPA embedded via go:embed. Purpose: Every subsequent plan in this phase depends on the Go module and server existing. This plan creates that foundation. Output: A compilable Go binary that serves GET /api/health and the stub SPA, loads config from .env + config.json, and is ready to have NetBox and queue packages added in Wave 2. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.env @.planning/phases/01-foundation/01-RESEARCH.md Task 1: Go module init and chi server with go:embed SPA go.mod, go.sum, cmd/hwlab/main.go, internal/api/router.go, internal/api/handlers/health.go, web/dist/index.html - /home/mikkel/homelabby/.env (confirm HWLAB_PORT=8080) - /home/mikkel/homelabby/.planning/phases/01-foundation/01-RESEARCH.md (Pattern 1: chi Router with go:embed, lines 148-181) - Test 1: GET /api/health returns HTTP 200 with body {"status":"ok","version":"0.1.0"} - Test 2: GET / returns HTTP 200 with HTML body containing "HWLab" - Test 3: GET /api/nonexistent returns 404 (chi default) - Test 4: GET /some/spa/route returns the stub index.html (SPA fallback) 1. Initialize Go module: `go mod init git.georgsen.dk/hwlab` 2. Install dependencies: ``` go get github.com/go-chi/chi/v5@v5.2.5 go get github.com/redis/go-redis/v9@v9.18.0 go get github.com/spf13/viper@v1.21.0 go get github.com/joho/godotenv@v1.5.1 go get github.com/google/uuid@v1.6.0 go get github.com/netbox-community/go-netbox/v4@v4.3.0 ``` 3. Create `web/dist/index.html` — minimal stub (NOT a full React app; placeholder for Phase 3): ```html HWLab

HWLab

Backend is running. UI coming in Phase 3.

``` 4. Create `internal/api/handlers/health.go`: ```go package handlers import ( "encoding/json" "net/http" ) type HealthResponse struct { Status string `json:"status"` Version string `json:"version"` } func Health(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(HealthResponse{ Status: "ok", Version: "0.1.0", }) } ``` 5. Create `internal/api/router.go` — chi router with go:embed SPA fallback. The embed directive MUST be in the same file as the var declaration: ```go package api import ( "embed" "io/fs" "net/http" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "git.georgsen.dk/hwlab/internal/api/handlers" ) //go:embed ../../web/dist var staticFiles embed.FS func NewRouter() http.Handler { r := chi.NewRouter() r.Use(middleware.Logger) r.Use(middleware.Recoverer) r.Use(middleware.RealIP) r.Route("/api", func(r chi.Router) { r.Get("/health", handlers.Health) }) // SPA fallback — serve index.html for all non-API routes staticFS, _ := fs.Sub(staticFiles, "web/dist") fileServer := http.FileServer(http.FS(staticFS)) r.Handle("/*", fileServer) return r } ``` NOTE: The go:embed path `../../web/dist` is relative to the file location `internal/api/router.go`. Verify path resolves correctly. If the embed directive causes issues due to directory depth, move the embed var to `cmd/hwlab/main.go` and pass the fs.FS into NewRouter as a parameter instead. 6. Create `cmd/hwlab/main.go` — wire-up only, no business logic: ```go package main import ( "fmt" "log" "net/http" "git.georgsen.dk/hwlab/internal/api" "git.georgsen.dk/hwlab/internal/config" ) func main() { cfg, err := config.Load() if err != nil { log.Fatalf("config: %v", err) } router := api.NewRouter() addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port) log.Printf("HWLab starting on %s", addr) if err := http.ListenAndServe(addr, router); err != nil { log.Fatalf("server: %v", err) } } ``` 7. Write test `internal/api/handlers/health_test.go`: ```go package handlers_test import ( "encoding/json" "net/http" "net/http/httptest" "testing" "git.georgsen.dk/hwlab/internal/api/handlers" ) func TestHealth(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/api/health", nil) w := httptest.NewRecorder() handlers.Health(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } var resp handlers.HealthResponse if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { t.Fatalf("decode: %v", err) } if resp.Status != "ok" { t.Errorf("expected status=ok, got %s", resp.Status) } if resp.Version != "0.1.0" { t.Errorf("expected version=0.1.0, got %s", resp.Version) } } ```
cd /home/mikkel/homelabby && go test ./internal/api/handlers/... -v - `go build ./cmd/hwlab/...` exits 0 with no errors - `go test ./internal/api/handlers/...` passes (TestHealth green) - `grep -r "go:embed web/dist" internal/api/router.go` returns a match (OR the embed is in main.go — check whichever file contains it) - `grep "git.georgsen.dk/hwlab" go.mod` returns the module declaration line - `grep "go-chi/chi/v5" go.mod` returns a line with `v5.2.5` - File `web/dist/index.html` exists and contains "HWLab" Go module compiles, health handler test passes, chi router wires go:embed SPA fallback, binary starts without panic.
Task 2: viper config loader (INF-02) internal/config/config.go, internal/config/config_test.go, config.json - /home/mikkel/homelabby/.env (all existing env var names and values) - /home/mikkel/homelabby/.planning/phases/01-foundation/01-RESEARCH.md (Pattern 7: viper Config, lines 362+) - Test 1: Load() with env var HWLAB_PORT=9999 set returns cfg.Port == 9999 - Test 2: Load() with no env vars returns cfg.Port == 8080 (from config.json default) - Test 3: Load() returns cfg.NetBoxURL == "http://10.5.0.130:8000/api" when HWLAB_NETBOX_URL is set - Test 4: Load() does not return error when config.json fields are missing (optional fields use defaults) 1. Install godotenv: `go get github.com/joho/godotenv@v1.5.1` 2. Create `config.json` at project root with non-secret defaults: ```json { "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 } ``` 3. Create `internal/config/config.go`: ```go package config import ( "fmt" "strings" "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.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) v.AutomaticEnv() // 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 } ``` 4. Create `internal/config/config_test.go`: ```go 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) } } ``` cd /home/mikkel/homelabby && go test ./internal/config/... -v - `go test ./internal/config/...` passes all 3 tests - `grep "mapstructure:\"netbox_token\"" internal/config/config.go` returns a match - `grep "mapstructure:\"dragonfly_url\"" internal/config/config.go` returns a match - `grep "HWLAB" internal/config/config.go` returns the SetEnvPrefix line - File `config.json` exists at project root - `grep "quality_gate_confidence_threshold" config.json` returns a match - `go build ./cmd/hwlab/...` still exits 0 after adding config Config loads from config.json and .env, env vars override file values, all tests pass, binary compiles with config wired.
## Trust Boundaries | Boundary | Description | |----------|-------------| | .env file → config loader | Credentials (NetBox token, DragonFlyDB password) enter the process here | | HTTP client → Go server | All inbound HTTP requests to chi router | ## STRIDE Threat Register | Threat ID | Category | Component | Disposition | Mitigation Plan | |-----------|----------|-----------|-------------|-----------------| | T-01-01 | Information Disclosure | .env file | mitigate | .env is in .gitignore; HWLAB_NETBOX_TOKEN is a placeholder in this plan — real token generated in Plan 03 | | T-01-02 | Tampering | config.json | accept | Local homelab tool, single operator, no integrity threat model needed at this layer | | T-01-03 | Denial of Service | GET /api/health | accept | No auth needed on health endpoint; low-value target, single operator | | T-01-04 | Information Disclosure | chi middleware.Logger | accept | Logs go to stdout only, no external log shipping in Phase 1 | After both tasks complete: - `go test ./...` green (all packages) - `go build ./cmd/hwlab/...` exits 0 - Run binary: `./hwlab &` then `curl http://localhost:8080/api/health` returns `{"status":"ok","version":"0.1.0"}` - `curl http://localhost:8080/` returns HTML containing "HWLab" - `curl http://localhost:8080/some/deep/route` returns the same HTML (SPA fallback) - Kill binary, verify process exits cleanly 1. `go test ./...` passes with no failures 2. `go build ./cmd/hwlab/...` compiles a binary 3. Running the binary and hitting GET /api/health returns HTTP 200 JSON with status=ok 4. Running the binary and hitting GET / returns HTTP 200 HTML containing "HWLab" 5. Config reads HWLAB_PORT from environment when set After completion, create `.planning/phases/01-foundation/01-01-SUMMARY.md` with: - Files created/modified - Key decisions made (especially if go:embed path needed adjustment) - Test results - Any deviations from the plan