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>
486 lines
16 KiB
Markdown
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>
|