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

16 KiB

phase plan type wave depends_on files_modified autonomous requirements must_haves
01-foundation 01 execute 1
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
true
INF-01
INF-02
truths artifacts key_links
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
path provides
cmd/hwlab/main.go Binary entry point — wires config, server, starts listener
path provides
internal/api/router.go chi router with middleware, /api routes, SPA fallback
path provides
internal/api/handlers/health.go GET /api/health handler
path provides
internal/config/config.go viper-backed Config struct loaded from .env + config.json
path provides
web/dist/index.html Stub SPA embedded via go:embed
path provides
go.mod Module declaration git.georgsen.dk/hwlab with all Phase 1 deps
from to via pattern
cmd/hwlab/main.go internal/api/router.go NewRouter(cfg) call NewRouter
from to via pattern
internal/api/router.go web/dist go:embed + http.FileServer 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.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.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

<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>

- 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
   <!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)
       }
   }
   ```
cd /home/mikkel/homelabby && go test ./internal/api/handlers/... -v

<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>

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

<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>

- 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

<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>

Config loads from config.json and .env, env vars override file values, all tests pass, binary compiles with config wired.

<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>
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

<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>
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