homelabby/.planning/phases/01-foundation/01-01-PLAN.md
Mikkel Georgsen c9ad50fdf2 docs(01-foundation): create phase 1 plans (5 plans, 2 waves)
Plans 01-02 are Wave 1 (parallel). Plans 03-04-05 are Wave 2.
All 11 requirements covered: INF-01, INF-02, INF-03, NB-01 through NB-07.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 01:07:55 +00:00

486 lines
16 KiB
Markdown

---
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"
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.env
@.planning/phases/01-foundation/01-RESEARCH.md
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Go module init and chi server with go:embed SPA</name>
<files>go.mod, go.sum, cmd/hwlab/main.go, internal/api/router.go, internal/api/handlers/health.go, web/dist/index.html</files>
<read_first>
- /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)
</read_first>
<behavior>
- 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)
</behavior>
<action>
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
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HWLab</title>
<style>
body { background: #000; color: #faff69; font-family: monospace; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; }
h1 { font-size: 2rem; }
p { color: #666; }
</style>
</head>
<body>
<div>
<h1>HWLab</h1>
<p>Backend is running. UI coming in Phase 3.</p>
</div>
</body>
</html>
```
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)
}
}
```
</action>
<verify>
<automated>cd /home/mikkel/homelabby && go test ./internal/api/handlers/... -v</automated>
</verify>
<acceptance_criteria>
- `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"
</acceptance_criteria>
<done>Go module compiles, health handler test passes, chi router wires go:embed SPA fallback, binary starts without panic.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: viper config loader (INF-02)</name>
<files>internal/config/config.go, internal/config/config_test.go, config.json</files>
<read_first>
- /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+)
</read_first>
<behavior>
- 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)
</behavior>
<action>
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)
}
}
```
</action>
<verify>
<automated>cd /home/mikkel/homelabby && go test ./internal/config/... -v</automated>
</verify>
<acceptance_criteria>
- `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
</acceptance_criteria>
<done>Config loads from config.json and .env, env vars override file values, all tests pass, binary compiles with config wired.</done>
</task>
</tasks>
<threat_model>
## 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 |
</threat_model>
<verification>
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
</verification>
<success_criteria>
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
</success_criteria>
<output>
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
</output>