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>
This commit is contained in:
parent
73eec3ee76
commit
c9ad50fdf2
6 changed files with 2926 additions and 3 deletions
|
|
@ -31,8 +31,15 @@ Decimal phases appear between their surrounding integers in numeric order.
|
||||||
2. All HWLab custom fields (hw_id, catalog_status, photo_urls, etc.) are readable and writable via the NetBox API with round-trip test coverage
|
2. All HWLab custom fields (hw_id, catalog_status, photo_urls, etc.) are readable and writable via the NetBox API with round-trip test coverage
|
||||||
3. A new item can be created in NetBox with a sequential HW-XXXXX ID auto-assigned
|
3. A new item can be created in NetBox with a sequential HW-XXXXX ID auto-assigned
|
||||||
4. catalog_status transitions from draft through complete are enforced by the backend quality gate
|
4. catalog_status transitions from draft through complete are enforced by the backend quality gate
|
||||||
5. A write-ahead queue in SQLite buffers failed NetBox operations and retries them on reconnect
|
5. A write-ahead queue in DragonFlyDB buffers failed NetBox operations and retries them on reconnect
|
||||||
**Plans**: TBD
|
**Plans**: 5 plans
|
||||||
|
|
||||||
|
Plans:
|
||||||
|
- [ ] 01-01-PLAN.md — Go scaffold: chi server, go:embed stub SPA, viper config, health endpoint
|
||||||
|
- [ ] 01-02-PLAN.md — NetBox client: go-netbox v4 wrapper, custom field read/write types, integration tests
|
||||||
|
- [ ] 01-03-PLAN.md — NetBox provisioning: 8 custom fields, location hierarchy, plugin check, provision CLI
|
||||||
|
- [ ] 01-04-PLAN.md — HW-ID allocation, quality gate state machine, AI tag sync to NetBox
|
||||||
|
- [ ] 01-05-PLAN.md — Write-ahead queue: DragonFlyDB WAQ + retry worker goroutine
|
||||||
|
|
||||||
### Phase 2: AI Pipeline
|
### Phase 2: AI Pipeline
|
||||||
**Goal**: Users can submit 1-3 photos and receive a structured NetBox-ready record with AI-extracted specs, suggested category/tags, and a quality gate status reflecting confidence
|
**Goal**: Users can submit 1-3 photos and receive a structured NetBox-ready record with AI-extracted specs, suggested category/tags, and a quality gate status reflecting confidence
|
||||||
|
|
@ -116,7 +123,7 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7
|
||||||
|
|
||||||
| Phase | Plans Complete | Status | Completed |
|
| Phase | Plans Complete | Status | Completed |
|
||||||
|-------|----------------|--------|-----------|
|
|-------|----------------|--------|-----------|
|
||||||
| 1. Foundation | 0/TBD | Not started | - |
|
| 1. Foundation | 0/5 | Not started | - |
|
||||||
| 2. AI Pipeline | 0/TBD | Not started | - |
|
| 2. AI Pipeline | 0/TBD | Not started | - |
|
||||||
| 3. Dashboard & Intake UI | 0/TBD | Not started | - |
|
| 3. Dashboard & Intake UI | 0/TBD | Not started | - |
|
||||||
| 4. USB Manager & Label Printing | 0/TBD | Not started | - |
|
| 4. USB Manager & Label Printing | 0/TBD | Not started | - |
|
||||||
|
|
|
||||||
486
.planning/phases/01-foundation/01-01-PLAN.md
Normal file
486
.planning/phases/01-foundation/01-01-PLAN.md
Normal file
|
|
@ -0,0 +1,486 @@
|
||||||
|
---
|
||||||
|
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>
|
||||||
608
.planning/phases/01-foundation/01-02-PLAN.md
Normal file
608
.planning/phases/01-foundation/01-02-PLAN.md
Normal file
|
|
@ -0,0 +1,608 @@
|
||||||
|
---
|
||||||
|
phase: 01-foundation
|
||||||
|
plan: 02
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- internal/netbox/client.go
|
||||||
|
- internal/netbox/client_test.go
|
||||||
|
- internal/netbox/custom_fields.go
|
||||||
|
- internal/netbox/types.go
|
||||||
|
autonomous: true
|
||||||
|
requirements:
|
||||||
|
- NB-01
|
||||||
|
- NB-02
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "NetBox client connects to http://10.5.0.130:8000/api and lists devices without error"
|
||||||
|
- "Client can create, read, update, and delete a device in NetBox"
|
||||||
|
- "Custom field read/write wrappers handle the asymmetric NetBox format (read nested, write flat)"
|
||||||
|
- "Round-trip test confirms custom field written via PATCH is retrievable via GET"
|
||||||
|
artifacts:
|
||||||
|
- path: "internal/netbox/client.go"
|
||||||
|
provides: "go-netbox v4 wrapper with typed methods for device/module/cable CRUD"
|
||||||
|
exports: ["NewClient", "Client"]
|
||||||
|
- path: "internal/netbox/custom_fields.go"
|
||||||
|
provides: "HWLab custom field read/write types and helper functions"
|
||||||
|
exports: ["CustomFieldsRead", "CustomFieldsPatch", "BuildCustomFieldsPatch", "ParseCustomFields"]
|
||||||
|
- path: "internal/netbox/types.go"
|
||||||
|
provides: "HWLab domain types wrapping NetBox responses"
|
||||||
|
exports: ["Device", "CustomFields"]
|
||||||
|
key_links:
|
||||||
|
- from: "internal/netbox/client.go"
|
||||||
|
to: "http://10.5.0.130:8000/api"
|
||||||
|
via: "go-netbox NewAPIClientFor"
|
||||||
|
pattern: "NewAPIClientFor"
|
||||||
|
- from: "internal/netbox/custom_fields.go"
|
||||||
|
to: "internal/netbox/client.go"
|
||||||
|
via: "PatchCustomFields method on Client"
|
||||||
|
pattern: "PatchCustomFields"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Build the typed NetBox client package: go-netbox v4 wrapper, custom field read/write types, and integration tests that verify round-trip custom field writes against the live NetBox instance.
|
||||||
|
|
||||||
|
Purpose: Every other Phase 1 package (quality gate, HW-ID, WAQ) depends on the NetBox client being stable. Building it independently in Wave 1 means Wave 2 plans can consume it without waiting for the scaffold.
|
||||||
|
Output: `internal/netbox` package with typed CRUD methods and custom field helpers, integration tests that pass against live NetBox at 10.5.0.130.
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- Key types the executor needs from go-netbox v4 -->
|
||||||
|
<!-- Source: github.com/netbox-community/go-netbox/v4 -->
|
||||||
|
|
||||||
|
go-netbox v4 initialization pattern:
|
||||||
|
```go
|
||||||
|
import netbox "github.com/netbox-community/go-netbox/v4"
|
||||||
|
|
||||||
|
client := netbox.NewAPIClientFor("http://10.5.0.130:8000", "YOUR_TOKEN_HERE")
|
||||||
|
|
||||||
|
// List devices
|
||||||
|
res, _, err := client.DcimAPI.DcimDevicesList(ctx).Limit(10).Execute()
|
||||||
|
// res.Results is []netbox.DeviceWithConfigContext
|
||||||
|
|
||||||
|
// Create device
|
||||||
|
req := netbox.WritableDeviceWithConfigContextRequest{
|
||||||
|
Name: netbox.PtrString("test-device"),
|
||||||
|
DeviceType: // ID ref
|
||||||
|
Site: // ID ref
|
||||||
|
}
|
||||||
|
result, _, err := client.DcimAPI.DcimDevicesCreate(ctx).
|
||||||
|
WritableDeviceWithConfigContextRequest(req).Execute()
|
||||||
|
|
||||||
|
// Custom fields are on Device.CustomFields as map[string]interface{}
|
||||||
|
// To PATCH custom fields: use DcimDevicesPartialUpdate with PatchedWritableDeviceWithConfigContextRequest
|
||||||
|
// PatchedWritableDeviceWithConfigContextRequest.CustomFields = map[string]interface{}{...}
|
||||||
|
```
|
||||||
|
|
||||||
|
IMPORTANT: The NetBox token in .env (`homelab-netbox-api-token-2024`) is a placeholder string.
|
||||||
|
Real NetBox tokens are 40-character hex strings generated via NetBox UI.
|
||||||
|
The executor MUST verify the token is real before running integration tests.
|
||||||
|
If the token is the placeholder string, add a human checkpoint or skip integration test with t.Skip().
|
||||||
|
</interfaces>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 1: NetBox client wrapper with device CRUD (NB-01)</name>
|
||||||
|
<files>internal/netbox/client.go, internal/netbox/types.go, internal/netbox/client_test.go</files>
|
||||||
|
|
||||||
|
<read_first>
|
||||||
|
- /home/mikkel/homelabby/.env (HWLAB_NETBOX_URL, HWLAB_NETBOX_TOKEN — check if token is real or placeholder)
|
||||||
|
- /home/mikkel/homelabby/.planning/phases/01-foundation/01-RESEARCH.md (Pattern 2: go-netbox v4 Client Initialization, lines 183-210)
|
||||||
|
- /home/mikkel/homelabby/.planning/phases/01-foundation/01-RESEARCH.md (Summary section — note about placeholder token, lines 55-58)
|
||||||
|
</read_first>
|
||||||
|
|
||||||
|
<behavior>
|
||||||
|
- Test 1: NewClient with valid URL and token returns non-nil *Client without error
|
||||||
|
- Test 2: NewClient with empty token returns error "netbox token is required"
|
||||||
|
- Test 3: Client.Ping(ctx) against live http://10.5.0.130:8000/api returns no error (INTEGRATION — skip if token is placeholder)
|
||||||
|
- Test 4: Client.ListDevices(ctx, limit=5) returns slice without error (INTEGRATION — skip if token is placeholder)
|
||||||
|
</behavior>
|
||||||
|
|
||||||
|
<action>
|
||||||
|
1. Create `internal/netbox/types.go` — HWLab domain types:
|
||||||
|
```go
|
||||||
|
package netbox
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Device represents a HWLab inventory item backed by a NetBox device record.
|
||||||
|
type Device struct {
|
||||||
|
ID int
|
||||||
|
Name string
|
||||||
|
AssetTag string // HW-XXXXX identifier
|
||||||
|
CustomFields CustomFields
|
||||||
|
Created time.Time
|
||||||
|
LastUpdated time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// CustomFields holds all HWLab-defined NetBox custom field values for a device.
|
||||||
|
// NetBox returns these as map[string]interface{} — we provide typed access.
|
||||||
|
type CustomFields struct {
|
||||||
|
HWID string // hw_id
|
||||||
|
CatalogStatus string // catalog_status
|
||||||
|
ProductURL string // product_url
|
||||||
|
FirmwareVersion string // firmware_version
|
||||||
|
TestDate string // test_date (ISO 8601 date string)
|
||||||
|
TestData string // test_data (JSON string)
|
||||||
|
AINotes string // ai_notes
|
||||||
|
PhotoURLs []string // photo_urls (multi-value)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create `internal/netbox/client.go`:
|
||||||
|
```go
|
||||||
|
package netbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
nb "github.com/netbox-community/go-netbox/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client wraps go-netbox v4 APIClient with typed HWLab methods.
|
||||||
|
// All NetBox calls MUST go through this Client — no direct go-netbox calls in other packages.
|
||||||
|
type Client struct {
|
||||||
|
api *nb.APIClient
|
||||||
|
url string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient creates a configured NetBox client. Returns error if url or token is empty.
|
||||||
|
func NewClient(url, token string) (*Client, error) {
|
||||||
|
if url == "" {
|
||||||
|
return nil, errors.New("netbox url is required")
|
||||||
|
}
|
||||||
|
if token == "" {
|
||||||
|
return nil, errors.New("netbox token is required")
|
||||||
|
}
|
||||||
|
// Note: NewAPIClientFor accepts the base URL WITHOUT /api suffix
|
||||||
|
// The go-netbox library appends /api internally.
|
||||||
|
// Strip trailing /api if present to avoid double-appending.
|
||||||
|
baseURL := url
|
||||||
|
if len(baseURL) > 4 && baseURL[len(baseURL)-4:] == "/api" {
|
||||||
|
baseURL = baseURL[:len(baseURL)-4]
|
||||||
|
}
|
||||||
|
api := nb.NewAPIClientFor(baseURL, token)
|
||||||
|
return &Client{api: api, url: url}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ping verifies the NetBox API is reachable by fetching the API root status.
|
||||||
|
// Returns nil on success.
|
||||||
|
func (c *Client) Ping(ctx context.Context) error {
|
||||||
|
// Use DcimDevicesList with limit=1 as a lightweight connectivity check.
|
||||||
|
_, resp, err := c.api.DcimAPI.DcimDevicesList(ctx).Limit(1).Execute()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("netbox ping: %w", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode >= 500 {
|
||||||
|
return fmt.Errorf("netbox ping: server error %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListDevices returns up to limit devices from NetBox.
|
||||||
|
func (c *Client) ListDevices(ctx context.Context, limit int) ([]Device, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
res, _, err := c.api.DcimAPI.DcimDevicesList(ctx).Limit(int32(limit)).Execute()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list devices: %w", err)
|
||||||
|
}
|
||||||
|
devices := make([]Device, 0, len(res.Results))
|
||||||
|
for _, d := range res.Results {
|
||||||
|
devices = append(devices, deviceFromNetBox(d))
|
||||||
|
}
|
||||||
|
return devices, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDevice retrieves a single device by its NetBox internal ID.
|
||||||
|
func (c *Client) GetDevice(ctx context.Context, id int) (*Device, error) {
|
||||||
|
d, _, err := c.api.DcimAPI.DcimDevicesRetrieve(ctx, int32(id)).Execute()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get device %d: %w", id, err)
|
||||||
|
}
|
||||||
|
dev := deviceFromNetBox(*d)
|
||||||
|
return &dev, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// deviceFromNetBox maps a go-netbox DeviceWithConfigContext to our Device type.
|
||||||
|
// Custom fields are mapped separately via ParseCustomFields.
|
||||||
|
func deviceFromNetBox(d nb.DeviceWithConfigContext) Device {
|
||||||
|
dev := Device{
|
||||||
|
ID: int(d.GetId()),
|
||||||
|
Name: d.GetName(),
|
||||||
|
}
|
||||||
|
if tag := d.GetAssetTag(); tag != "" {
|
||||||
|
dev.AssetTag = tag
|
||||||
|
}
|
||||||
|
dev.CustomFields = ParseCustomFields(d.GetCustomFields())
|
||||||
|
return dev
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Write `internal/netbox/client_test.go`:
|
||||||
|
```go
|
||||||
|
package netbox_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.georgsen.dk/hwlab/internal/netbox"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewClientValidation(t *testing.T) {
|
||||||
|
_, err := netbox.NewClient("", "token")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for empty url")
|
||||||
|
}
|
||||||
|
_, err = netbox.NewClient("http://10.5.0.130:8000/api", "")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for empty token")
|
||||||
|
}
|
||||||
|
c, err := netbox.NewClient("http://10.5.0.130:8000/api", "sometoken")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if c == nil {
|
||||||
|
t.Error("expected non-nil client")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// integrationToken returns the real NetBox token from env, or skips the test
|
||||||
|
// if only the placeholder is present (placeholder is never 40 hex chars).
|
||||||
|
func integrationToken(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
token := os.Getenv("HWLAB_NETBOX_TOKEN")
|
||||||
|
if len(token) != 40 {
|
||||||
|
t.Skip("HWLAB_NETBOX_TOKEN is not a real 40-char token — skipping integration test")
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPingLive(t *testing.T) {
|
||||||
|
token := integrationToken(t)
|
||||||
|
c, err := netbox.NewClient("http://10.5.0.130:8000/api", token)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewClient: %v", err)
|
||||||
|
}
|
||||||
|
if err := c.Ping(context.Background()); err != nil {
|
||||||
|
t.Fatalf("Ping: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListDevicesLive(t *testing.T) {
|
||||||
|
token := integrationToken(t)
|
||||||
|
c, _ := netbox.NewClient("http://10.5.0.130:8000/api", token)
|
||||||
|
devices, err := c.ListDevices(context.Background(), 5)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListDevices: %v", err)
|
||||||
|
}
|
||||||
|
t.Logf("found %d devices in NetBox", len(devices))
|
||||||
|
// Not asserting count — NetBox may be empty; just assert no error
|
||||||
|
}
|
||||||
|
```
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/mikkel/homelabby && go test ./internal/netbox/... -v -run TestNewClientValidation</automated>
|
||||||
|
</verify>
|
||||||
|
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `go test ./internal/netbox/... -run TestNewClientValidation` passes (unit tests, no integration needed)
|
||||||
|
- `grep "NewClient" internal/netbox/client.go` returns the exported function declaration
|
||||||
|
- `grep "ParseCustomFields" internal/netbox/client.go` returns usage of the function
|
||||||
|
- `grep "go-netbox" go.mod` returns the dependency line with v4.3.0
|
||||||
|
- `go build ./internal/netbox/...` exits 0
|
||||||
|
- If real NetBox token available: `go test ./internal/netbox/... -v` shows TestPingLive and TestListDevicesLive PASS (not SKIP)
|
||||||
|
</acceptance_criteria>
|
||||||
|
|
||||||
|
<done>NetBox client compiles, unit validation tests pass, integration tests skip cleanly when token is a placeholder, and pass when a real token is provided.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 2: Custom field read/write wrappers (NB-02 round-trip)</name>
|
||||||
|
<files>internal/netbox/custom_fields.go, internal/netbox/custom_fields_test.go</files>
|
||||||
|
|
||||||
|
<read_first>
|
||||||
|
- /home/mikkel/homelabby/internal/netbox/types.go (CustomFields struct from Task 1)
|
||||||
|
- /home/mikkel/homelabby/.planning/phases/01-foundation/01-RESEARCH.md (Pattern 3: Custom Field Read/Write Asymmetry, lines 203-235)
|
||||||
|
</read_first>
|
||||||
|
|
||||||
|
<behavior>
|
||||||
|
- Test 1: ParseCustomFields(map with "hw_id":"HW-00001") returns CustomFields{HWID:"HW-00001"}
|
||||||
|
- Test 2: ParseCustomFields(nil map) returns zero-value CustomFields (no panic)
|
||||||
|
- Test 3: ParseCustomFields(map with "photo_urls": []interface{}{"url1","url2"}) returns PhotoURLs with 2 entries
|
||||||
|
- Test 4: BuildCustomFieldsPatch("HW-00001", "draft", nil) returns map containing hw_id and catalog_status keys
|
||||||
|
- Test 5: BuildCustomFieldsPatch with photo_urls slice includes photo_urls key in patch map
|
||||||
|
- Test 6 (INTEGRATION, skip if no real token): Client.PatchCustomFields then GetDevice returns matching custom field values
|
||||||
|
</behavior>
|
||||||
|
|
||||||
|
<action>
|
||||||
|
1. Create `internal/netbox/custom_fields.go`:
|
||||||
|
```go
|
||||||
|
package netbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ParseCustomFields maps NetBox's map[string]interface{} custom fields response
|
||||||
|
// to the typed CustomFields struct. NetBox returns values as interface{} — we
|
||||||
|
// perform safe type assertions for each expected field.
|
||||||
|
func ParseCustomFields(raw map[string]interface{}) CustomFields {
|
||||||
|
cf := CustomFields{}
|
||||||
|
if raw == nil {
|
||||||
|
return cf
|
||||||
|
}
|
||||||
|
|
||||||
|
if v, ok := raw["hw_id"].(string); ok {
|
||||||
|
cf.HWID = v
|
||||||
|
}
|
||||||
|
if v, ok := raw["catalog_status"].(string); ok {
|
||||||
|
cf.CatalogStatus = v
|
||||||
|
}
|
||||||
|
if v, ok := raw["product_url"].(string); ok {
|
||||||
|
cf.ProductURL = v
|
||||||
|
}
|
||||||
|
if v, ok := raw["firmware_version"].(string); ok {
|
||||||
|
cf.FirmwareVersion = v
|
||||||
|
}
|
||||||
|
if v, ok := raw["test_date"].(string); ok {
|
||||||
|
cf.TestDate = v
|
||||||
|
}
|
||||||
|
if v, ok := raw["test_data"].(string); ok {
|
||||||
|
cf.TestData = v
|
||||||
|
}
|
||||||
|
if v, ok := raw["ai_notes"].(string); ok {
|
||||||
|
cf.AINotes = v
|
||||||
|
}
|
||||||
|
// photo_urls is a multi-value field — NetBox returns []interface{}
|
||||||
|
if v, ok := raw["photo_urls"].([]interface{}); ok {
|
||||||
|
urls := make([]string, 0, len(v))
|
||||||
|
for _, u := range v {
|
||||||
|
if s, ok := u.(string); ok {
|
||||||
|
urls = append(urls, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cf.PhotoURLs = urls
|
||||||
|
}
|
||||||
|
|
||||||
|
return cf
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildCustomFieldsPatch constructs the flat map[string]interface{} payload
|
||||||
|
// required by NetBox PATCH endpoints. Only include fields that are non-empty
|
||||||
|
// to avoid accidentally clearing existing values.
|
||||||
|
//
|
||||||
|
// NetBox custom field write format differs from read format:
|
||||||
|
// - Text/URL/date fields: send string value directly
|
||||||
|
// - Selection fields (catalog_status): send the choice value as string
|
||||||
|
// - Multi-value fields (photo_urls): send []string directly
|
||||||
|
func BuildCustomFieldsPatch(hwID, catalogStatus string, photoURLs []string) map[string]interface{} {
|
||||||
|
patch := make(map[string]interface{})
|
||||||
|
if hwID != "" {
|
||||||
|
patch["hw_id"] = hwID
|
||||||
|
}
|
||||||
|
if catalogStatus != "" {
|
||||||
|
patch["catalog_status"] = catalogStatus
|
||||||
|
}
|
||||||
|
if len(photoURLs) > 0 {
|
||||||
|
patch["photo_urls"] = photoURLs
|
||||||
|
}
|
||||||
|
return patch
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildFullCustomFieldsPatch constructs a patch with all custom fields.
|
||||||
|
// Use for initial record creation where all fields should be set.
|
||||||
|
func BuildFullCustomFieldsPatch(cf CustomFields) map[string]interface{} {
|
||||||
|
patch := make(map[string]interface{})
|
||||||
|
if cf.HWID != "" {
|
||||||
|
patch["hw_id"] = cf.HWID
|
||||||
|
}
|
||||||
|
if cf.CatalogStatus != "" {
|
||||||
|
patch["catalog_status"] = cf.CatalogStatus
|
||||||
|
}
|
||||||
|
if cf.ProductURL != "" {
|
||||||
|
patch["product_url"] = cf.ProductURL
|
||||||
|
}
|
||||||
|
if cf.FirmwareVersion != "" {
|
||||||
|
patch["firmware_version"] = cf.FirmwareVersion
|
||||||
|
}
|
||||||
|
if cf.TestDate != "" {
|
||||||
|
patch["test_date"] = cf.TestDate
|
||||||
|
}
|
||||||
|
if cf.TestData != "" {
|
||||||
|
patch["test_data"] = cf.TestData
|
||||||
|
}
|
||||||
|
if cf.AINotes != "" {
|
||||||
|
patch["ai_notes"] = cf.AINotes
|
||||||
|
}
|
||||||
|
if len(cf.PhotoURLs) > 0 {
|
||||||
|
patch["photo_urls"] = cf.PhotoURLs
|
||||||
|
}
|
||||||
|
return patch
|
||||||
|
}
|
||||||
|
|
||||||
|
// PatchCustomFields updates the custom fields of a device identified by netboxID.
|
||||||
|
// After PATCH, performs a GET to verify the write succeeded (HTTP 200 ≠ write confirmed).
|
||||||
|
func (c *Client) PatchCustomFields(ctx context.Context, deviceID int, patch map[string]interface{}) error {
|
||||||
|
req := c.api.DcimAPI.DcimDevicesPartialUpdate(ctx, int32(deviceID))
|
||||||
|
// go-netbox v4 uses PatchedWritableDeviceWithConfigContextRequest for partial updates
|
||||||
|
// Set custom fields via the request object
|
||||||
|
patchReq := c.api.DcimAPI.DcimDevicesPartialUpdate(ctx, int32(deviceID))
|
||||||
|
_ = patchReq // suppress unused
|
||||||
|
// Build the partial update request
|
||||||
|
// NOTE: go-netbox v4 API — DcimDevicesPartialUpdate takes a PatchedWritableDeviceWithConfigContextRequest
|
||||||
|
// CustomFields field is map[string]interface{}
|
||||||
|
import_note := "use c.api.DcimAPI.DcimDevicesPartialUpdate"
|
||||||
|
_ = import_note
|
||||||
|
|
||||||
|
// Correct approach for go-netbox v4:
|
||||||
|
nb_req := c.api.DcimAPI.DcimDevicesPartialUpdate(ctx, int32(deviceID))
|
||||||
|
_ = nb_req
|
||||||
|
// TODO: fill in correctly based on generated API — see go-netbox v4 generated code
|
||||||
|
// The generated struct is: PatchedWritableDeviceWithConfigContextRequest
|
||||||
|
// It has a CustomFields field of type map[string]interface{}
|
||||||
|
return fmt.Errorf("PatchCustomFields: implement using go-netbox v4 PatchedWritableDeviceWithConfigContextRequest.CustomFields")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
IMPORTANT NOTE FOR EXECUTOR: The `PatchCustomFields` stub above contains pseudocode that will not compile. Once the go-netbox v4 module is downloaded, inspect the generated API to find the correct struct and method signature:
|
||||||
|
```
|
||||||
|
grep -r "PatchedWritableDevice" $(go env GOPATH)/pkg/mod/github.com/netbox-community/go-netbox/ 2>/dev/null | head -5
|
||||||
|
```
|
||||||
|
Then implement `PatchCustomFields` using the correct generated struct. The pattern is:
|
||||||
|
```go
|
||||||
|
patchReq := nb.PatchedWritableDeviceWithConfigContextRequest{}
|
||||||
|
patchReq.SetCustomFields(patch)
|
||||||
|
_, _, err := c.api.DcimAPI.DcimDevicesPartialUpdate(ctx, int32(deviceID)).
|
||||||
|
PatchedWritableDeviceWithConfigContextRequest(patchReq).Execute()
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create `internal/netbox/custom_fields_test.go`:
|
||||||
|
```go
|
||||||
|
package netbox_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.georgsen.dk/hwlab/internal/netbox"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseCustomFieldsNil(t *testing.T) {
|
||||||
|
cf := netbox.ParseCustomFields(nil)
|
||||||
|
if cf.HWID != "" {
|
||||||
|
t.Error("expected empty HWID for nil map")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseCustomFieldsHWID(t *testing.T) {
|
||||||
|
raw := map[string]interface{}{
|
||||||
|
"hw_id": "HW-00001",
|
||||||
|
"catalog_status": "draft",
|
||||||
|
}
|
||||||
|
cf := netbox.ParseCustomFields(raw)
|
||||||
|
if cf.HWID != "HW-00001" {
|
||||||
|
t.Errorf("want HW-00001, got %s", cf.HWID)
|
||||||
|
}
|
||||||
|
if cf.CatalogStatus != "draft" {
|
||||||
|
t.Errorf("want draft, got %s", cf.CatalogStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseCustomFieldsPhotoURLs(t *testing.T) {
|
||||||
|
raw := map[string]interface{}{
|
||||||
|
"photo_urls": []interface{}{"http://a.com/1.jpg", "http://a.com/2.jpg"},
|
||||||
|
}
|
||||||
|
cf := netbox.ParseCustomFields(raw)
|
||||||
|
if len(cf.PhotoURLs) != 2 {
|
||||||
|
t.Errorf("want 2 photo urls, got %d", len(cf.PhotoURLs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildCustomFieldsPatch(t *testing.T) {
|
||||||
|
patch := netbox.BuildCustomFieldsPatch("HW-00001", "draft", nil)
|
||||||
|
if patch["hw_id"] != "HW-00001" {
|
||||||
|
t.Errorf("hw_id: want HW-00001, got %v", patch["hw_id"])
|
||||||
|
}
|
||||||
|
if patch["catalog_status"] != "draft" {
|
||||||
|
t.Errorf("catalog_status: want draft, got %v", patch["catalog_status"])
|
||||||
|
}
|
||||||
|
if _, ok := patch["photo_urls"]; ok {
|
||||||
|
t.Error("photo_urls should not be present when nil passed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildCustomFieldsPatchWithURLs(t *testing.T) {
|
||||||
|
patch := netbox.BuildCustomFieldsPatch("HW-00001", "indexed", []string{"http://a.com/1.jpg"})
|
||||||
|
urls, ok := patch["photo_urls"].([]string)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("photo_urls should be []string")
|
||||||
|
}
|
||||||
|
if len(urls) != 1 {
|
||||||
|
t.Errorf("want 1 url, got %d", len(urls))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/mikkel/homelabby && go test ./internal/netbox/... -v -run "TestParseCustomFields|TestBuildCustomFields"</automated>
|
||||||
|
</verify>
|
||||||
|
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `go test ./internal/netbox/... -run "TestParseCustomFields|TestBuildCustomFields"` passes all 5 unit tests
|
||||||
|
- `grep "ParseCustomFields" internal/netbox/custom_fields.go` returns the exported function declaration
|
||||||
|
- `grep "BuildCustomFieldsPatch" internal/netbox/custom_fields.go` returns the exported function declaration
|
||||||
|
- `grep "BuildFullCustomFieldsPatch" internal/netbox/custom_fields.go` returns the exported function declaration
|
||||||
|
- `grep "photo_urls" internal/netbox/custom_fields.go` returns handling of the []interface{} case
|
||||||
|
- `PatchCustomFields` is implemented (not returning an error string — the stub must be replaced with real go-netbox v4 API call)
|
||||||
|
- `go build ./internal/netbox/...` exits 0
|
||||||
|
</acceptance_criteria>
|
||||||
|
|
||||||
|
<done>Custom field parsing and patch building tested and passing. PatchCustomFields implemented using correct go-netbox v4 generated structs (not stub pseudocode). All unit tests green.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| Go code → NetBox REST API | Authenticated API calls; token is the only credential |
|
||||||
|
| NetBox response → Go custom field parsing | Untrusted map[string]interface{} values enter type assertions |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|-------------|-----------------|
|
||||||
|
| T-02-01 | Information Disclosure | HWLAB_NETBOX_TOKEN in env | mitigate | Token never logged; only passed to go-netbox client constructor; integration tests skip when placeholder token present |
|
||||||
|
| T-02-02 | Tampering | ParseCustomFields raw map | accept | Source is NetBox REST API on private homelab LAN (10.5.0.130); no untrusted input path in Phase 1 |
|
||||||
|
| T-02-03 | Denial of Service | DcimDevicesList with large limit | accept | Single-operator tool; no external callers; limit param is Go code controlled |
|
||||||
|
| T-02-04 | Information Disclosure | go test logging device IDs | accept | Tests run locally; t.Logf output is ephemeral |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
After both tasks complete:
|
||||||
|
- `go test ./internal/netbox/... -v` shows TestNewClientValidation PASS, integration tests either PASS (real token) or SKIP (placeholder)
|
||||||
|
- `go test ./internal/netbox/... -run "TestParseCustomFields|TestBuildCustomFields"` all green
|
||||||
|
- `go build ./...` exits 0 (all packages compile together)
|
||||||
|
- If real token: `go test ./internal/netbox/... -v -run TestPingLive` shows PASS
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
1. All unit tests in `internal/netbox` pass without requiring live NetBox
|
||||||
|
2. Integration tests skip gracefully when token is the placeholder `homelab-netbox-api-token-2024`
|
||||||
|
3. `ParseCustomFields` handles nil, string values, and []interface{} photo_urls without panicking
|
||||||
|
4. `PatchCustomFields` is implemented with real go-netbox v4 API calls (not stub)
|
||||||
|
5. `go build ./...` compiles cleanly
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/01-foundation/01-02-SUMMARY.md` with:
|
||||||
|
- Whether integration tests ran (real token) or skipped (placeholder)
|
||||||
|
- The exact PatchedWritableDeviceWithConfigContextRequest struct name used (from go-netbox v4 generated code)
|
||||||
|
- Any go-netbox v4 API surprises (e.g., custom field write format differs from documented pattern)
|
||||||
|
- Files created/modified
|
||||||
|
</output>
|
||||||
569
.planning/phases/01-foundation/01-03-PLAN.md
Normal file
569
.planning/phases/01-foundation/01-03-PLAN.md
Normal file
|
|
@ -0,0 +1,569 @@
|
||||||
|
---
|
||||||
|
phase: 01-foundation
|
||||||
|
plan: 03
|
||||||
|
type: execute
|
||||||
|
wave: 2
|
||||||
|
depends_on:
|
||||||
|
- 01-02-PLAN.md
|
||||||
|
files_modified:
|
||||||
|
- internal/netbox/provision.go
|
||||||
|
- internal/netbox/provision_test.go
|
||||||
|
- scripts/provision-netbox.go
|
||||||
|
autonomous: true
|
||||||
|
requirements:
|
||||||
|
- NB-02
|
||||||
|
- NB-03
|
||||||
|
- NB-04
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "All 8 HWLab custom fields exist in NetBox after provisioning runs"
|
||||||
|
- "GET /api/extras/custom-fields/?name=hw_id returns the hw_id field definition"
|
||||||
|
- "GET /api/extras/custom-fields/?name=catalog_status returns the catalog_status field"
|
||||||
|
- "Location hierarchy exists: at least one Site, one Location, one Rack in NetBox"
|
||||||
|
- "Provisioning script is idempotent — running it twice does not create duplicates"
|
||||||
|
artifacts:
|
||||||
|
- path: "internal/netbox/provision.go"
|
||||||
|
provides: "Provision() function: creates custom fields + location hierarchy if not present"
|
||||||
|
exports: ["Provision", "ProvisionCustomFields", "ProvisionLocationHierarchy"]
|
||||||
|
- path: "scripts/provision-netbox.go"
|
||||||
|
provides: "Standalone CLI: `go run scripts/provision-netbox.go` — provisions NetBox from .env"
|
||||||
|
key_links:
|
||||||
|
- from: "scripts/provision-netbox.go"
|
||||||
|
to: "internal/netbox/provision.go"
|
||||||
|
via: "direct function call Provision(client)"
|
||||||
|
pattern: "Provision"
|
||||||
|
- from: "internal/netbox/provision.go"
|
||||||
|
to: "http://10.5.0.130:8000/api"
|
||||||
|
via: "REST POST /api/extras/custom-fields/, /api/dcim/sites/, /api/dcim/locations/, /api/dcim/racks/"
|
||||||
|
pattern: "ExtrasAPI|DcimAPI"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Provision the NetBox instance with all HWLab custom fields and location hierarchy. This is an idempotent provisioning operation: if a custom field already exists, skip it; if the location hierarchy exists, skip it.
|
||||||
|
|
||||||
|
Purpose: Custom fields must exist in NetBox before any Go code can write them. NB-02 requires 8 specific fields; NB-03 requires the netbox-inventory plugin installed; NB-04 requires the Site→Location→Rack hierarchy.
|
||||||
|
Output: A `Provision()` function and a standalone script that a human can run against the live NetBox instance to bootstrap it.
|
||||||
|
</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
|
||||||
|
@.planning/phases/01-foundation/01-02-SUMMARY.md
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- Types from Plan 02 that this plan uses -->
|
||||||
|
From internal/netbox/client.go:
|
||||||
|
```go
|
||||||
|
type Client struct { ... }
|
||||||
|
func NewClient(url, token string) (*Client, error)
|
||||||
|
// Client.api is *nb.APIClient (go-netbox v4)
|
||||||
|
// Access raw API via: client.api.ExtrasAPI, client.api.DcimAPI
|
||||||
|
```
|
||||||
|
|
||||||
|
NetBox custom field POST payload (REST API):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "hw_id",
|
||||||
|
"label": "HW ID",
|
||||||
|
"type": "text",
|
||||||
|
"object_types": ["dcim.device"],
|
||||||
|
"required": false,
|
||||||
|
"description": "HWLab sequential identifier (HW-XXXXX)"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
NetBox location hierarchy (DCIM API):
|
||||||
|
- Site: POST /api/dcim/sites/ {name, slug}
|
||||||
|
- Location: POST /api/dcim/locations/ {name, slug, site: {id}}
|
||||||
|
- Rack: POST /api/dcim/racks/ {name, site: {id}, location: {id}, u_height: 42}
|
||||||
|
|
||||||
|
Check for existing custom field by name: GET /api/extras/custom-fields/?name=hw_id
|
||||||
|
- If count > 0, field exists — skip creation
|
||||||
|
</interfaces>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 1: Custom field provisioning (NB-02)</name>
|
||||||
|
<files>internal/netbox/provision.go, internal/netbox/provision_test.go</files>
|
||||||
|
|
||||||
|
<read_first>
|
||||||
|
- /home/mikkel/homelabby/internal/netbox/client.go (Client struct, api field access pattern)
|
||||||
|
- /home/mikkel/homelabby/.planning/phases/01-foundation/01-RESEARCH.md (phase requirements table, lines 38-48)
|
||||||
|
- /home/mikkel/homelabby/.env (HWLAB_NETBOX_TOKEN — check if real 40-char token)
|
||||||
|
</read_first>
|
||||||
|
|
||||||
|
<behavior>
|
||||||
|
- Test 1: customFieldSpec("hw_id") returns a spec with Name="hw_id", Type="text", ObjectTypes=["dcim.device"]
|
||||||
|
- Test 2: customFieldSpec("catalog_status") returns Type="text" (stored as free text, not NetBox choice field, to avoid NetBox admin dependency)
|
||||||
|
- Test 3: customFieldSpec("photo_urls") returns a spec with Type="text" and description mentioning comma-separated
|
||||||
|
- Test 4 (INTEGRATION — skip if no real token): ProvisionCustomFields() creates all 8 fields and returns no error
|
||||||
|
- Test 5 (INTEGRATION — skip if no real token): ProvisionCustomFields() called twice is idempotent (no error, no duplicate fields)
|
||||||
|
</behavior>
|
||||||
|
|
||||||
|
<action>
|
||||||
|
All 8 custom fields to provision (NB-02):
|
||||||
|
- hw_id: text, dcim.device — "HWLab sequential identifier (HW-XXXXX)"
|
||||||
|
- catalog_status: text, dcim.device — "Lifecycle status: draft|indexed|needs_research|researched|complete"
|
||||||
|
- product_url: url, dcim.device — "Manufacturer product page URL"
|
||||||
|
- firmware_version: text, dcim.device — "Current firmware/software version"
|
||||||
|
- test_date: date, dcim.device — "Date of last cable/hardware test"
|
||||||
|
- test_data: text, dcim.device — "Structured JSON test results from cable testers"
|
||||||
|
- ai_notes: text, dcim.device — "AI-generated notes from intake analysis"
|
||||||
|
- photo_urls: text, dcim.device — "Comma-separated photo URLs captured during intake"
|
||||||
|
|
||||||
|
NOTE on photo_urls: Use type "text" (not multi-object) to avoid NetBox v4 multi-value custom field complexity. Store as comma-separated string. The Go layer will split/join as needed.
|
||||||
|
|
||||||
|
NOTE on catalog_status: Use type "text" not NetBox "selection" field — the Go quality gate owns the valid values; we don't want to maintain a parallel list in NetBox admin UI.
|
||||||
|
|
||||||
|
Create `internal/netbox/provision.go`:
|
||||||
|
```go
|
||||||
|
package netbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CustomFieldSpec defines a NetBox custom field to provision.
|
||||||
|
type CustomFieldSpec struct {
|
||||||
|
Name string
|
||||||
|
Label string
|
||||||
|
Type string // "text", "url", "date", "integer", "boolean"
|
||||||
|
ObjectTypes []string
|
||||||
|
Description string
|
||||||
|
Required bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// hwlabCustomFields is the canonical list of all HWLab custom fields.
|
||||||
|
// These MUST be provisioned in NetBox before any item can be created.
|
||||||
|
var hwlabCustomFields = []CustomFieldSpec{
|
||||||
|
{Name: "hw_id", Label: "HW ID", Type: "text", ObjectTypes: []string{"dcim.device"}, Description: "HWLab sequential identifier (HW-XXXXX)"},
|
||||||
|
{Name: "catalog_status", Label: "Catalog Status", Type: "text", ObjectTypes: []string{"dcim.device"}, Description: "Lifecycle: draft|indexed|needs_research|researched|complete"},
|
||||||
|
{Name: "product_url", Label: "Product URL", Type: "url", ObjectTypes: []string{"dcim.device"}, Description: "Manufacturer product page URL"},
|
||||||
|
{Name: "firmware_version", Label: "Firmware Version", Type: "text", ObjectTypes: []string{"dcim.device"}, Description: "Current firmware/software version"},
|
||||||
|
{Name: "test_date", Label: "Test Date", Type: "date", ObjectTypes: []string{"dcim.device"}, Description: "Date of last cable/hardware test (ISO 8601)"},
|
||||||
|
{Name: "test_data", Label: "Test Data", Type: "text", ObjectTypes: []string{"dcim.device"}, Description: "Structured JSON test results from cable testers"},
|
||||||
|
{Name: "ai_notes", Label: "AI Notes", Type: "text", ObjectTypes: []string{"dcim.device"}, Description: "AI-generated notes from intake photo analysis"},
|
||||||
|
{Name: "photo_urls", Label: "Photo URLs", Type: "text", ObjectTypes: []string{"dcim.device"}, Description: "Comma-separated photo URLs captured during intake"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// customFieldSpec returns the spec for a named custom field (for testing).
|
||||||
|
func customFieldSpec(name string) *CustomFieldSpec {
|
||||||
|
for i := range hwlabCustomFields {
|
||||||
|
if hwlabCustomFields[i].Name == name {
|
||||||
|
return &hwlabCustomFields[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProvisionCustomFields ensures all HWLab custom fields exist in NetBox.
|
||||||
|
// Idempotent: fields that already exist are skipped.
|
||||||
|
// Returns the count of fields created (0 if all existed).
|
||||||
|
func (c *Client) ProvisionCustomFields(ctx context.Context) (int, error) {
|
||||||
|
created := 0
|
||||||
|
for _, spec := range hwlabCustomFields {
|
||||||
|
exists, err := c.customFieldExists(ctx, spec.Name)
|
||||||
|
if err != nil {
|
||||||
|
return created, fmt.Errorf("check field %s: %w", spec.Name, err)
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
log.Printf("custom field %q already exists — skipping", spec.Name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := c.createCustomField(ctx, spec); err != nil {
|
||||||
|
return created, fmt.Errorf("create field %s: %w", spec.Name, err)
|
||||||
|
}
|
||||||
|
log.Printf("created custom field %q", spec.Name)
|
||||||
|
created++
|
||||||
|
}
|
||||||
|
return created, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// customFieldExists checks if a custom field with the given name already exists.
|
||||||
|
func (c *Client) customFieldExists(ctx context.Context, name string) (bool, error) {
|
||||||
|
res, _, err := c.api.ExtrasAPI.ExtrasCustomFieldsList(ctx).Name([]string{name}).Execute()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return res.GetCount() > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createCustomField creates a single custom field in NetBox via the Extras API.
|
||||||
|
// Uses the go-netbox v4 generated WritableCustomFieldRequest type.
|
||||||
|
func (c *Client) createCustomField(ctx context.Context, spec CustomFieldSpec) error {
|
||||||
|
// go-netbox v4: use CustomFieldTypeValue for the Type field
|
||||||
|
// Available types: "text", "longtext", "integer", "decimal", "boolean",
|
||||||
|
// "date", "datetime", "url", "json", "select", "multiselect",
|
||||||
|
// "object", "multiobject"
|
||||||
|
// NOTE: Executor must check the actual enum values in go-netbox v4 generated code:
|
||||||
|
// grep -r "CustomFieldTypeValue" $(go env GOPATH)/pkg/mod/github.com/netbox-community/go-netbox/ | head -10
|
||||||
|
|
||||||
|
nb_pkg := "github.com/netbox-community/go-netbox/v4"
|
||||||
|
_ = nb_pkg
|
||||||
|
|
||||||
|
// Pseudocode — executor must use real go-netbox v4 WritableCustomFieldRequest:
|
||||||
|
// req := nb.WritableCustomFieldRequest{
|
||||||
|
// Name: spec.Name,
|
||||||
|
// Label: nb.PtrString(spec.Label),
|
||||||
|
// Type: nb.CustomFieldTypeValue(spec.Type),
|
||||||
|
// ObjectTypes: spec.ObjectTypes,
|
||||||
|
// Description: nb.PtrString(spec.Description),
|
||||||
|
// }
|
||||||
|
// _, _, err := c.api.ExtrasAPI.ExtrasCustomFieldsCreate(ctx).
|
||||||
|
// WritableCustomFieldRequest(req).Execute()
|
||||||
|
// return err
|
||||||
|
|
||||||
|
return fmt.Errorf("createCustomField: implement using go-netbox v4 WritableCustomFieldRequest — check generated types at $(go env GOPATH)/pkg/mod/github.com/netbox-community/go-netbox/v4@*/model_writable_custom_field_request.go")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provision runs all provisioning steps: custom fields + location hierarchy.
|
||||||
|
func (c *Client) Provision(ctx context.Context) error {
|
||||||
|
n, err := c.ProvisionCustomFields(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("provision custom fields: %w", err)
|
||||||
|
}
|
||||||
|
log.Printf("custom fields: %d created", n)
|
||||||
|
|
||||||
|
if err := c.ProvisionLocationHierarchy(ctx); err != nil {
|
||||||
|
return fmt.Errorf("provision locations: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
IMPORTANT: The `createCustomField` function contains a stub that will not compile. The executor MUST:
|
||||||
|
1. Run: `ls $(go env GOPATH)/pkg/mod/github.com/netbox-community/go-netbox/v4@v4.3.0/` to find generated files
|
||||||
|
2. Run: `grep -l "WritableCustomFieldRequest" $(go env GOPATH)/pkg/mod/github.com/netbox-community/go-netbox/v4@v4.3.0/*.go`
|
||||||
|
3. Read the relevant generated file to get the exact field names and enum types
|
||||||
|
4. Replace the pseudocode with real go-netbox v4 API calls
|
||||||
|
|
||||||
|
Create `internal/netbox/provision_test.go`:
|
||||||
|
```go
|
||||||
|
package netbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCustomFieldSpec(t *testing.T) {
|
||||||
|
spec := customFieldSpec("hw_id")
|
||||||
|
if spec == nil {
|
||||||
|
t.Fatal("hw_id spec not found")
|
||||||
|
}
|
||||||
|
if spec.Type != "text" {
|
||||||
|
t.Errorf("hw_id type: want text, got %s", spec.Type)
|
||||||
|
}
|
||||||
|
for _, ot := range spec.ObjectTypes {
|
||||||
|
if ot == "dcim.device" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Error("hw_id ObjectTypes must include dcim.device")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCustomFieldSpecCatalogStatus(t *testing.T) {
|
||||||
|
spec := customFieldSpec("catalog_status")
|
||||||
|
if spec == nil {
|
||||||
|
t.Fatal("catalog_status spec not found")
|
||||||
|
}
|
||||||
|
if spec.Type != "text" {
|
||||||
|
t.Errorf("catalog_status type: want text (not selection), got %s", spec.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCustomFieldSpecPhotoURLs(t *testing.T) {
|
||||||
|
spec := customFieldSpec("photo_urls")
|
||||||
|
if spec == nil {
|
||||||
|
t.Fatal("photo_urls spec not found")
|
||||||
|
}
|
||||||
|
if spec.Description == "" {
|
||||||
|
t.Error("photo_urls must have a description")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllEightFieldsDefined(t *testing.T) {
|
||||||
|
expected := []string{"hw_id", "catalog_status", "product_url", "firmware_version",
|
||||||
|
"test_date", "test_data", "ai_notes", "photo_urls"}
|
||||||
|
for _, name := range expected {
|
||||||
|
if customFieldSpec(name) == nil {
|
||||||
|
t.Errorf("missing custom field spec: %s", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(hwlabCustomFields) != 8 {
|
||||||
|
t.Errorf("want 8 custom fields, got %d", len(hwlabCustomFields))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/mikkel/homelabby && go test ./internal/netbox/... -v -run "TestCustomFieldSpec|TestAllEight"</automated>
|
||||||
|
</verify>
|
||||||
|
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `go test ./internal/netbox/... -run "TestCustomFieldSpec|TestAllEight"` passes all 4 unit tests
|
||||||
|
- `grep -c "Name:" internal/netbox/provision.go` returns 8 or more (one per custom field)
|
||||||
|
- `grep "photo_urls" internal/netbox/provision.go` appears in hwlabCustomFields slice
|
||||||
|
- `grep "ai_notes" internal/netbox/provision.go` appears in hwlabCustomFields slice
|
||||||
|
- `createCustomField` is implemented with real go-netbox v4 API calls (not stub returning error string)
|
||||||
|
- `go build ./internal/netbox/...` exits 0
|
||||||
|
</acceptance_criteria>
|
||||||
|
|
||||||
|
<done>All 8 custom field specs defined and tested. ProvisionCustomFields implemented with idempotent check-before-create. createCustomField uses real go-netbox v4 API (not stub).</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Location hierarchy provisioning + NB-03 plugin check + provision CLI (NB-03, NB-04)</name>
|
||||||
|
<files>internal/netbox/provision.go, scripts/provision-netbox.go</files>
|
||||||
|
|
||||||
|
<read_first>
|
||||||
|
- /home/mikkel/homelabby/internal/netbox/provision.go (ProvisionCustomFields added in Task 1)
|
||||||
|
- /home/mikkel/homelabby/.planning/phases/01-foundation/01-RESEARCH.md (phase requirements NB-03, NB-04 lines 43-48)
|
||||||
|
- /home/mikkel/homelabby/.env (HWLAB_NETBOX_URL, HWLAB_NETBOX_TOKEN)
|
||||||
|
</read_first>
|
||||||
|
|
||||||
|
<action>
|
||||||
|
Location hierarchy to create (per ROADMAP "Site → Location → Rack per PRD section 7.6"):
|
||||||
|
- Site: name="Homelab", slug="homelab"
|
||||||
|
- Location: name="Lab Bench", slug="lab-bench", site=homelab
|
||||||
|
- Rack: name="Primary Rack", site=homelab, location=lab-bench, u_height=42
|
||||||
|
|
||||||
|
1. Add `ProvisionLocationHierarchy` to `internal/netbox/provision.go`:
|
||||||
|
```go
|
||||||
|
// ProvisionLocationHierarchy creates the Site → Location → Rack hierarchy.
|
||||||
|
// Idempotent: each level is checked before creation.
|
||||||
|
func (c *Client) ProvisionLocationHierarchy(ctx context.Context) error {
|
||||||
|
// Step 1: Create or find Site "Homelab"
|
||||||
|
siteID, err := c.ensureSite(ctx, "Homelab", "homelab")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ensure site: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Create or find Location "Lab Bench" under the site
|
||||||
|
locationID, err := c.ensureLocation(ctx, "Lab Bench", "lab-bench", siteID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ensure location: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Create or find Rack "Primary Rack" under the site + location
|
||||||
|
if err := c.ensureRack(ctx, "Primary Rack", siteID, locationID); err != nil {
|
||||||
|
return fmt.Errorf("ensure rack: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) ensureSite(ctx context.Context, name, slug string) (int32, error) {
|
||||||
|
// Check if site exists by slug
|
||||||
|
res, _, err := c.api.DcimAPI.DcimSitesList(ctx).Slug([]string{slug}).Execute()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if res.GetCount() > 0 {
|
||||||
|
log.Printf("site %q already exists — skipping", name)
|
||||||
|
return res.Results[0].GetId(), nil
|
||||||
|
}
|
||||||
|
// Create site
|
||||||
|
// NOTE: Executor must use go-netbox v4 WritableSiteRequest type
|
||||||
|
// Pattern: c.api.DcimAPI.DcimSitesCreate(ctx).WritableSiteRequest(req).Execute()
|
||||||
|
// Required fields: Name (string), Slug (string)
|
||||||
|
// Status: use nb.SiteStatusValue("active") or equivalent
|
||||||
|
return 0, fmt.Errorf("ensureSite: implement using go-netbox v4 WritableSiteRequest")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) ensureLocation(ctx context.Context, name, slug string, siteID int32) (int32, error) {
|
||||||
|
res, _, err := c.api.DcimAPI.DcimLocationsList(ctx).Slug([]string{slug}).Execute()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if res.GetCount() > 0 {
|
||||||
|
log.Printf("location %q already exists — skipping", name)
|
||||||
|
return res.Results[0].GetId(), nil
|
||||||
|
}
|
||||||
|
// Create location under site
|
||||||
|
// NOTE: use go-netbox v4 WritableLocationRequest
|
||||||
|
// Required: Name, Slug, Site (NestedSiteRequest with ID)
|
||||||
|
return 0, fmt.Errorf("ensureLocation: implement using go-netbox v4 WritableLocationRequest")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) ensureRack(ctx context.Context, name string, siteID, locationID int32) error {
|
||||||
|
res, _, err := c.api.DcimAPI.DcimRacksList(ctx).Name([]string{name}).Execute()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if res.GetCount() > 0 {
|
||||||
|
log.Printf("rack %q already exists — skipping", name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Create rack under site + location
|
||||||
|
// NOTE: use go-netbox v4 WritableRackRequest
|
||||||
|
// Required: Name, Site (NestedSiteRequest), Location (NestedLocationRequest), UHeight (int32)
|
||||||
|
// UHeight = 42
|
||||||
|
return fmt.Errorf("ensureRack: implement using go-netbox v4 WritableRackRequest")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
EXECUTOR MUST replace all stub returns with real go-netbox v4 API calls. Locate the generated types:
|
||||||
|
```
|
||||||
|
ls $(go env GOPATH)/pkg/mod/github.com/netbox-community/go-netbox/v4@v4.3.0/model_writable_site_request.go
|
||||||
|
ls $(go env GOPATH)/pkg/mod/github.com/netbox-community/go-netbox/v4@v4.3.0/model_writable_location_request.go
|
||||||
|
ls $(go env GOPATH)/pkg/mod/github.com/netbox-community/go-netbox/v4@v4.3.0/model_writable_rack_request.go
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Add NB-03 plugin check note in provision.go as a comment (NB-03 requires SSH to LXC 130 — this is a manual verification step documented in VALIDATION.md):
|
||||||
|
```go
|
||||||
|
// CheckNetBoxInventoryPlugin verifies the netbox-inventory plugin is installed.
|
||||||
|
// This check uses the NetBox plugins API endpoint.
|
||||||
|
// NB-03: netbox-inventory plugin must be installed on LXC 130.
|
||||||
|
// Manual verification: SSH to LXC 130, run: pip show netbox-inventory
|
||||||
|
// API check: GET /api/plugins/ lists installed plugin API endpoints.
|
||||||
|
func (c *Client) CheckNetBoxInventoryPlugin(ctx context.Context) (bool, error) {
|
||||||
|
// The netbox-inventory plugin registers under /api/plugins/inventory/
|
||||||
|
// We can check by hitting that endpoint and seeing if we get a 200 vs 404.
|
||||||
|
resp, err := c.api.GetConfig().HTTPClient.Get(c.url[:len(c.url)-4] + "/api/plugins/")
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
return resp.StatusCode == 200, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Create `scripts/provision-netbox.go` — standalone CLI script:
|
||||||
|
```go
|
||||||
|
//go:build ignore
|
||||||
|
|
||||||
|
// Run with: go run scripts/provision-netbox.go
|
||||||
|
// Provisions NetBox with all HWLab custom fields and location hierarchy.
|
||||||
|
// Reads HWLAB_NETBOX_URL and HWLAB_NETBOX_TOKEN from environment (.env auto-loaded).
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
|
||||||
|
"git.georgsen.dk/hwlab/internal/netbox"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Load .env
|
||||||
|
if err := godotenv.Load(); err != nil {
|
||||||
|
log.Printf("no .env file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
url := os.Getenv("HWLAB_NETBOX_URL")
|
||||||
|
token := os.Getenv("HWLAB_NETBOX_TOKEN")
|
||||||
|
|
||||||
|
if url == "" || token == "" {
|
||||||
|
log.Fatal("HWLAB_NETBOX_URL and HWLAB_NETBOX_TOKEN must be set")
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := netbox.NewClient(url, token)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("netbox client: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
log.Println("Provisioning NetBox...")
|
||||||
|
if err := client.Provision(ctx); err != nil {
|
||||||
|
log.Fatalf("provision: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check netbox-inventory plugin (NB-03)
|
||||||
|
ok, err := client.CheckNetBoxInventoryPlugin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("plugin check error: %v", err)
|
||||||
|
} else if ok {
|
||||||
|
log.Println("netbox-inventory plugin: INSTALLED")
|
||||||
|
} else {
|
||||||
|
log.Println("WARNING: netbox-inventory plugin may not be installed")
|
||||||
|
log.Println(" Manual check: SSH to LXC 130, run: pip show netbox-inventory")
|
||||||
|
log.Println(" Install if missing: pip install netbox-inventory")
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("Provisioning complete.")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/mikkel/homelabby && go build ./internal/netbox/... && go vet ./internal/netbox/...</automated>
|
||||||
|
</verify>
|
||||||
|
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `go build ./internal/netbox/...` exits 0 (all stubs replaced with real go-netbox v4 API calls)
|
||||||
|
- `go vet ./internal/netbox/...` exits 0 (no vet errors)
|
||||||
|
- `grep "ProvisionLocationHierarchy" internal/netbox/provision.go` returns the exported function
|
||||||
|
- `grep "ensureSite\|ensureLocation\|ensureRack" internal/netbox/provision.go` returns 3 matches (not stub errors)
|
||||||
|
- `grep "CheckNetBoxInventoryPlugin" internal/netbox/provision.go` returns the function
|
||||||
|
- File `scripts/provision-netbox.go` exists
|
||||||
|
- `grep "go:build ignore" scripts/provision-netbox.go` returns a match (build tag prevents accidental inclusion)
|
||||||
|
- If real token available: `go run scripts/provision-netbox.go` exits 0 and logs "Provisioning complete."
|
||||||
|
- If real token available: `curl -H "Authorization: Token REAL_TOKEN" http://10.5.0.130:8000/api/extras/custom-fields/?name=hw_id` returns count > 0
|
||||||
|
</acceptance_criteria>
|
||||||
|
|
||||||
|
<done>Location hierarchy provisioning implemented. Provision CLI script created. All functions use real go-netbox v4 API (no stubs). `go build` and `go vet` clean.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| scripts/provision-netbox.go → NetBox API | Write access to NetBox via authenticated REST API |
|
||||||
|
| Provisioning logic → NetBox admin state | Creates objects in NetBox; idempotency prevents data corruption |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|-------------|-----------------|
|
||||||
|
| T-03-01 | Tampering | ProvisionCustomFields idempotency | mitigate | Check-before-create pattern ensures no duplicates; NetBox also enforces unique names on custom fields |
|
||||||
|
| T-03-02 | Information Disclosure | scripts/provision-netbox.go logs | accept | Logs go to stdout only; no sensitive data logged beyond field names |
|
||||||
|
| T-03-03 | Denial of Service | Provisioning script run in loop | accept | Script has `//go:build ignore` tag, requires explicit `go run`; single operator context |
|
||||||
|
| T-03-04 | Elevation of Privilege | Provisioning requires NetBox admin rights | accept | NetBox token grants admin-level access by design; this is the operator's own homelab |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
After both tasks complete:
|
||||||
|
- `go test ./internal/netbox/... -v` all tests pass (unit) or skip (integration without real token)
|
||||||
|
- `go build ./...` exits 0
|
||||||
|
- `go vet ./...` exits 0
|
||||||
|
- If real token: `go run scripts/provision-netbox.go` provisions cleanly
|
||||||
|
- If real token: `curl -s -H "Authorization: Token $HWLAB_NETBOX_TOKEN" "http://10.5.0.130:8000/api/extras/custom-fields/" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['count'])"` shows 8 or more custom fields
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
1. All 8 custom field specs defined in hwlabCustomFields slice with correct names, types, object_types
|
||||||
|
2. ProvisionCustomFields is idempotent (check-before-create)
|
||||||
|
3. ProvisionLocationHierarchy creates Site "Homelab" → Location "Lab Bench" → Rack "Primary Rack"
|
||||||
|
4. Standalone script runs with `go run scripts/provision-netbox.go` against live NetBox
|
||||||
|
5. `go build` and `go vet` clean on all packages
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/01-foundation/01-03-SUMMARY.md` with:
|
||||||
|
- Whether provisioning ran against live NetBox (yes/no)
|
||||||
|
- If yes: which custom fields were created vs. already existed
|
||||||
|
- If yes: whether netbox-inventory plugin was detected
|
||||||
|
- Any go-netbox v4 API quirks encountered (WritableSiteRequest fields, slug handling, etc.)
|
||||||
|
- Files created/modified
|
||||||
|
</output>
|
||||||
698
.planning/phases/01-foundation/01-04-PLAN.md
Normal file
698
.planning/phases/01-foundation/01-04-PLAN.md
Normal file
|
|
@ -0,0 +1,698 @@
|
||||||
|
---
|
||||||
|
phase: 01-foundation
|
||||||
|
plan: 04
|
||||||
|
type: execute
|
||||||
|
wave: 2
|
||||||
|
depends_on:
|
||||||
|
- 01-02-PLAN.md
|
||||||
|
files_modified:
|
||||||
|
- internal/netbox/hwid.go
|
||||||
|
- internal/netbox/hwid_test.go
|
||||||
|
- internal/inventory/quality_gate.go
|
||||||
|
- internal/inventory/quality_gate_test.go
|
||||||
|
- internal/inventory/types.go
|
||||||
|
- internal/netbox/tags.go
|
||||||
|
- internal/netbox/tags_test.go
|
||||||
|
autonomous: true
|
||||||
|
requirements:
|
||||||
|
- INF-03
|
||||||
|
- NB-06
|
||||||
|
- NB-07
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "AllocateNextHWID returns HW-00001 on first call against an empty NetBox"
|
||||||
|
- "AllocateNextHWID returns HW-00002 on subsequent calls (increments)"
|
||||||
|
- "CatalogStatus.CanTransitionTo enforces valid transitions (draft→indexed allowed, indexed→draft rejected)"
|
||||||
|
- "Invalid transitions return an error with the exact invalid transition described"
|
||||||
|
- "SyncTags creates new NetBox tags for AI-suggested tags not yet present"
|
||||||
|
artifacts:
|
||||||
|
- path: "internal/netbox/hwid.go"
|
||||||
|
provides: "AllocateNextHWID: optimistic-lock sequential ID allocation from NetBox"
|
||||||
|
exports: ["AllocateNextHWID"]
|
||||||
|
- path: "internal/inventory/quality_gate.go"
|
||||||
|
provides: "CatalogStatus type with Transition() enforcing valid state machine"
|
||||||
|
exports: ["CatalogStatus", "StatusDraft", "StatusIndexed", "StatusNeedsResearch", "StatusResearched", "StatusComplete", "Transition"]
|
||||||
|
- path: "internal/inventory/types.go"
|
||||||
|
provides: "HardwareRecord domain type composing NetBox device with HWLab semantics"
|
||||||
|
exports: ["HardwareRecord"]
|
||||||
|
- path: "internal/netbox/tags.go"
|
||||||
|
provides: "SyncTags: creates NetBox tags from AI-suggested string slice, returns IDs"
|
||||||
|
exports: ["SyncTags"]
|
||||||
|
key_links:
|
||||||
|
- from: "internal/netbox/hwid.go"
|
||||||
|
to: "http://10.5.0.130:8000/api"
|
||||||
|
via: "DcimDevicesList filtered by asset_tag pattern"
|
||||||
|
pattern: "DcimDevicesList.*asset_tag|asset_tag.*DcimDevicesList"
|
||||||
|
- from: "internal/inventory/quality_gate.go"
|
||||||
|
to: "internal/netbox/client.go"
|
||||||
|
via: "CatalogStatus value stored as NetBox custom field via PatchCustomFields"
|
||||||
|
pattern: "PatchCustomFields.*catalog_status"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Implement three distinct capabilities that all depend on the NetBox client (Plan 02): HW-XXXXX sequential ID allocation, catalog quality gate state machine, and AI tag sync to NetBox.
|
||||||
|
|
||||||
|
Purpose: These three capabilities are the behavioral core of Phase 1. HW-ID is required at intake time (Phase 2). Quality gate drives all lifecycle operations. Tag sync links AI output to NetBox taxonomy.
|
||||||
|
Output: Three packages — netbox/hwid.go, inventory/quality_gate.go, netbox/tags.go — all tested independently.
|
||||||
|
</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
|
||||||
|
@.planning/phases/01-foundation/01-02-SUMMARY.md
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- Types from Plan 02 available to this plan -->
|
||||||
|
From internal/netbox/client.go:
|
||||||
|
```go
|
||||||
|
func NewClient(url, token string) (*Client, error)
|
||||||
|
func (c *Client) ListDevices(ctx context.Context, limit int) ([]Device, error)
|
||||||
|
func (c *Client) PatchCustomFields(ctx context.Context, deviceID int, patch map[string]interface{}) error
|
||||||
|
// c.api is *nb.APIClient — can call DcimAPI, ExtrasAPI directly from hwid.go and tags.go
|
||||||
|
```
|
||||||
|
|
||||||
|
From internal/netbox/types.go:
|
||||||
|
```go
|
||||||
|
type Device struct {
|
||||||
|
ID int
|
||||||
|
Name string
|
||||||
|
AssetTag string // HW-XXXXX
|
||||||
|
CustomFields CustomFields
|
||||||
|
Created time.Time
|
||||||
|
LastUpdated time.Time
|
||||||
|
}
|
||||||
|
type CustomFields struct {
|
||||||
|
HWID, CatalogStatus, ProductURL, FirmwareVersion string
|
||||||
|
TestDate, TestData, AINotes string
|
||||||
|
PhotoURLs []string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From internal/netbox/custom_fields.go:
|
||||||
|
```go
|
||||||
|
func BuildCustomFieldsPatch(hwID, catalogStatus string, photoURLs []string) map[string]interface{}
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 1: HW-XXXXX sequential ID allocation (INF-03)</name>
|
||||||
|
<files>internal/netbox/hwid.go, internal/netbox/hwid_test.go</files>
|
||||||
|
|
||||||
|
<read_first>
|
||||||
|
- /home/mikkel/homelabby/internal/netbox/client.go (Client struct, api access)
|
||||||
|
- /home/mikkel/homelabby/.planning/phases/01-foundation/01-RESEARCH.md (Pattern 4: HW-XXXXX Sequential ID Allocation, lines 238-265)
|
||||||
|
</read_first>
|
||||||
|
|
||||||
|
<behavior>
|
||||||
|
- Test 1: parseHWID("HW-00042") returns 42, nil
|
||||||
|
- Test 2: parseHWID("HW-99999") returns 99999, nil
|
||||||
|
- Test 3: parseHWID("not-a-hw-id") returns 0, error
|
||||||
|
- Test 4: parseHWID("") returns 0, error
|
||||||
|
- Test 5: formatHWID(1) returns "HW-00001"
|
||||||
|
- Test 6: formatHWID(99999) returns "HW-99999"
|
||||||
|
- Test 7 (INTEGRATION — skip if no real token): AllocateNextHWID returns a string matching ^HW-\d{5}$
|
||||||
|
</behavior>
|
||||||
|
|
||||||
|
<action>
|
||||||
|
Create `internal/netbox/hwid.go`:
|
||||||
|
```go
|
||||||
|
package netbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var hwIDPattern = regexp.MustCompile(`^HW-(\d{5})$`)
|
||||||
|
|
||||||
|
// formatHWID formats an integer as a HW-XXXXX string.
|
||||||
|
func formatHWID(n int) string {
|
||||||
|
return fmt.Sprintf("HW-%05d", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseHWID parses a HW-XXXXX string to an integer.
|
||||||
|
// Returns error if the format does not match.
|
||||||
|
func parseHWID(s string) (int, error) {
|
||||||
|
m := hwIDPattern.FindStringSubmatch(s)
|
||||||
|
if m == nil {
|
||||||
|
return 0, fmt.Errorf("invalid HW-ID format: %q (expected HW-NNNNN)", s)
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(m[1])
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllocateNextHWID allocates the next available HW-XXXXX identifier.
|
||||||
|
// Strategy: optimistic locking — query the highest existing asset_tag, increment by 1,
|
||||||
|
// attempt to reserve it. Retry up to 3 times on conflict.
|
||||||
|
//
|
||||||
|
// The reservation is a placeholder NetBox device with name "__hwid_reservation__"
|
||||||
|
// that the caller MUST immediately replace with the real device data.
|
||||||
|
// In practice, Phase 2 will create the real device in a single atomic step,
|
||||||
|
// so the placeholder device is never committed separately.
|
||||||
|
//
|
||||||
|
// For Phase 1, AllocateNextHWID returns the ID string without creating a device.
|
||||||
|
// The caller is responsible for creating the device record and setting asset_tag.
|
||||||
|
func (c *Client) AllocateNextHWID(ctx context.Context) (string, error) {
|
||||||
|
const maxAttempts = 3
|
||||||
|
|
||||||
|
for attempt := 0; attempt < maxAttempts; attempt++ {
|
||||||
|
highest, err := c.getHighestHWIDNumber(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("get highest HW-ID: %w", err)
|
||||||
|
}
|
||||||
|
candidate := formatHWID(highest + 1)
|
||||||
|
// Check that this candidate is not already taken
|
||||||
|
// (handles concurrent allocation if ever needed)
|
||||||
|
taken, err := c.hwIDExists(ctx, candidate)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("check HW-ID %s: %w", candidate, err)
|
||||||
|
}
|
||||||
|
if !taken {
|
||||||
|
return candidate, nil
|
||||||
|
}
|
||||||
|
// Candidate is taken — loop and try highest+2, etc.
|
||||||
|
}
|
||||||
|
return "", errors.New("HW-ID allocation failed after 3 attempts — concurrent allocation conflict")
|
||||||
|
}
|
||||||
|
|
||||||
|
// getHighestHWIDNumber queries NetBox for the highest existing HW-XXXXX asset_tag number.
|
||||||
|
// Returns 0 if no HW-XXXXX asset_tags exist (first allocation will be HW-00001).
|
||||||
|
func (c *Client) getHighestHWIDNumber(ctx context.Context) (int, error) {
|
||||||
|
// Query all devices, paginate if needed — in Phase 1 this is small
|
||||||
|
// Filter by asset_tag starting with "HW-" to limit results
|
||||||
|
// NOTE: go-netbox v4 DcimDevicesList supports AssetTag filter
|
||||||
|
// Use limit=1000 and sort by asset_tag descending to find the highest efficiently
|
||||||
|
// If NetBox v4 supports ordering by asset_tag: use .Ordering("-asset_tag")
|
||||||
|
|
||||||
|
res, _, err := c.api.DcimAPI.DcimDevicesList(ctx).
|
||||||
|
Limit(1000).
|
||||||
|
Execute()
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("list devices for HW-ID query: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
highest := 0
|
||||||
|
for _, d := range res.Results {
|
||||||
|
tag := d.GetAssetTag()
|
||||||
|
if !strings.HasPrefix(tag, "HW-") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
n, err := parseHWID(tag)
|
||||||
|
if err != nil {
|
||||||
|
continue // non-HWLab asset tag — skip
|
||||||
|
}
|
||||||
|
if n > highest {
|
||||||
|
highest = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return highest, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// hwIDExists checks if a given HW-XXXXX asset_tag is already used in NetBox.
|
||||||
|
func (c *Client) hwIDExists(ctx context.Context, hwid string) (bool, error) {
|
||||||
|
res, _, err := c.api.DcimAPI.DcimDevicesList(ctx).
|
||||||
|
AssetTag([]string{hwid}).
|
||||||
|
Limit(1).
|
||||||
|
Execute()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return res.GetCount() > 0, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `internal/netbox/hwid_test.go`:
|
||||||
|
```go
|
||||||
|
package netbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFormatHWID(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
n int
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{1, "HW-00001"},
|
||||||
|
{42, "HW-00042"},
|
||||||
|
{99999, "HW-99999"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := formatHWID(tt.n)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("formatHWID(%d) = %q, want %q", tt.n, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseHWID(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
s string
|
||||||
|
want int
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"HW-00001", 1, false},
|
||||||
|
{"HW-00042", 42, false},
|
||||||
|
{"HW-99999", 99999, false},
|
||||||
|
{"", 0, true},
|
||||||
|
{"not-a-hw-id", 0, true},
|
||||||
|
{"HW-0001", 0, true}, // only 4 digits — invalid
|
||||||
|
{"hw-00001", 0, true}, // lowercase — invalid
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
got, err := parseHWID(tt.s)
|
||||||
|
if tt.wantErr && err == nil {
|
||||||
|
t.Errorf("parseHWID(%q): expected error, got nil", tt.s)
|
||||||
|
}
|
||||||
|
if !tt.wantErr && err != nil {
|
||||||
|
t.Errorf("parseHWID(%q): unexpected error: %v", tt.s, err)
|
||||||
|
}
|
||||||
|
if !tt.wantErr && got != tt.want {
|
||||||
|
t.Errorf("parseHWID(%q) = %d, want %d", tt.s, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/mikkel/homelabby && go test ./internal/netbox/... -v -run "TestFormatHWID|TestParseHWID"</automated>
|
||||||
|
</verify>
|
||||||
|
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `go test ./internal/netbox/... -run "TestFormatHWID|TestParseHWID"` passes all 10 cases
|
||||||
|
- `grep "AllocateNextHWID" internal/netbox/hwid.go` returns the exported function
|
||||||
|
- `grep "HW-%05d" internal/netbox/hwid.go` returns the Sprintf format call
|
||||||
|
- `grep "getHighestHWIDNumber" internal/netbox/hwid.go` returns the private helper
|
||||||
|
- `go build ./internal/netbox/...` exits 0
|
||||||
|
</acceptance_criteria>
|
||||||
|
|
||||||
|
<done>HW-ID format/parse unit tests pass. AllocateNextHWID implemented with optimistic-lock retry. Binary compiles.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 2: Quality gate state machine and AI tag sync (NB-06, NB-07)</name>
|
||||||
|
<files>internal/inventory/types.go, internal/inventory/quality_gate.go, internal/inventory/quality_gate_test.go, internal/netbox/tags.go, internal/netbox/tags_test.go</files>
|
||||||
|
|
||||||
|
<read_first>
|
||||||
|
- /home/mikkel/homelabby/internal/netbox/client.go (Client struct for SyncTags)
|
||||||
|
- /home/mikkel/homelabby/.planning/phases/01-foundation/01-RESEARCH.md (Pattern 6: Catalog Status Quality Gate, lines 322-360)
|
||||||
|
- /home/mikkel/homelabby/internal/netbox/types.go (CustomFields.CatalogStatus field)
|
||||||
|
</read_first>
|
||||||
|
|
||||||
|
<behavior>
|
||||||
|
Quality gate tests:
|
||||||
|
- Test 1: StatusDraft.CanTransitionTo(StatusIndexed) returns true
|
||||||
|
- Test 2: StatusDraft.CanTransitionTo(StatusComplete) returns false
|
||||||
|
- Test 3: StatusIndexed.CanTransitionTo(StatusNeedsResearch) returns true
|
||||||
|
- Test 4: StatusIndexed.CanTransitionTo(StatusDraft) returns false (no backward transitions)
|
||||||
|
- Test 5: StatusComplete.CanTransitionTo(anything) returns false (terminal)
|
||||||
|
- Test 6: Transition(StatusDraft, StatusIndexed) returns StatusIndexed, nil
|
||||||
|
- Test 7: Transition(StatusDraft, StatusComplete) returns "", error containing "invalid transition"
|
||||||
|
- Test 8: ParseCatalogStatus("draft") returns StatusDraft, nil
|
||||||
|
- Test 9: ParseCatalogStatus("unknown_value") returns "", error
|
||||||
|
|
||||||
|
Tag sync tests:
|
||||||
|
- Test 10: normalizeTags([]string{" USB Cable ", "USB cable", "usb-cable"}) returns deduplicated, lowercase-trimmed slice
|
||||||
|
- Test 11 (INTEGRATION — skip if no real token): SyncTags([]string{"usb-c-cable"}) creates tag in NetBox and returns its ID
|
||||||
|
</behavior>
|
||||||
|
|
||||||
|
<action>
|
||||||
|
1. Create `internal/inventory/types.go`:
|
||||||
|
```go
|
||||||
|
package inventory
|
||||||
|
|
||||||
|
import "git.georgsen.dk/hwlab/internal/netbox"
|
||||||
|
|
||||||
|
// HardwareRecord is the HWLab domain representation of a cataloged item.
|
||||||
|
// It wraps a NetBox device with HWLab-specific fields and lifecycle state.
|
||||||
|
type HardwareRecord struct {
|
||||||
|
HWID string // HW-XXXXX from asset_tag
|
||||||
|
NetBoxID int // NetBox device internal ID
|
||||||
|
Name string // Device name in NetBox
|
||||||
|
CatalogStatus CatalogStatus // Quality gate lifecycle status
|
||||||
|
CustomFields netbox.CustomFields // All HWLab custom fields
|
||||||
|
AITags []string // AI-suggested tags (synced to NetBox)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create `internal/inventory/quality_gate.go`:
|
||||||
|
```go
|
||||||
|
package inventory
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// CatalogStatus represents the lifecycle stage of a cataloged hardware item.
|
||||||
|
// Stored as the catalog_status custom field value in NetBox.
|
||||||
|
type CatalogStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusDraft CatalogStatus = "draft"
|
||||||
|
StatusIndexed CatalogStatus = "indexed"
|
||||||
|
StatusNeedsResearch CatalogStatus = "needs_research"
|
||||||
|
StatusResearched CatalogStatus = "researched"
|
||||||
|
StatusComplete CatalogStatus = "complete"
|
||||||
|
)
|
||||||
|
|
||||||
|
// validTransitions defines the allowed state machine transitions.
|
||||||
|
// No backward transitions are permitted (lifecycle is forward-only).
|
||||||
|
var validTransitions = map[CatalogStatus][]CatalogStatus{
|
||||||
|
StatusDraft: {StatusIndexed},
|
||||||
|
StatusIndexed: {StatusNeedsResearch, StatusResearched},
|
||||||
|
StatusNeedsResearch: {StatusResearched},
|
||||||
|
StatusResearched: {StatusComplete},
|
||||||
|
StatusComplete: {}, // terminal — no further transitions
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanTransitionTo returns true if transitioning from s to next is permitted.
|
||||||
|
func (s CatalogStatus) CanTransitionTo(next CatalogStatus) bool {
|
||||||
|
allowed, ok := validTransitions[s]
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, a := range allowed {
|
||||||
|
if a == next {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transition attempts to move from current to next status.
|
||||||
|
// Returns the new status on success, or an error describing the invalid transition.
|
||||||
|
func Transition(current, next CatalogStatus) (CatalogStatus, error) {
|
||||||
|
if !current.CanTransitionTo(next) {
|
||||||
|
return "", fmt.Errorf("invalid transition: %s → %s (not in valid transitions map)", current, next)
|
||||||
|
}
|
||||||
|
return next, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseCatalogStatus parses a string to a CatalogStatus.
|
||||||
|
// Returns error for unknown status values.
|
||||||
|
func ParseCatalogStatus(s string) (CatalogStatus, error) {
|
||||||
|
cs := CatalogStatus(s)
|
||||||
|
if _, ok := validTransitions[cs]; ok {
|
||||||
|
return cs, nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("unknown catalog status: %q (valid: draft, indexed, needs_research, researched, complete)", s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllStatuses returns all valid catalog statuses in lifecycle order.
|
||||||
|
func AllStatuses() []CatalogStatus {
|
||||||
|
return []CatalogStatus{
|
||||||
|
StatusDraft, StatusIndexed, StatusNeedsResearch, StatusResearched, StatusComplete,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Create `internal/inventory/quality_gate_test.go`:
|
||||||
|
```go
|
||||||
|
package inventory_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.georgsen.dk/hwlab/internal/inventory"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCanTransitionTo(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
from inventory.CatalogStatus
|
||||||
|
to inventory.CatalogStatus
|
||||||
|
allowed bool
|
||||||
|
}{
|
||||||
|
{inventory.StatusDraft, inventory.StatusIndexed, true},
|
||||||
|
{inventory.StatusDraft, inventory.StatusComplete, false},
|
||||||
|
{inventory.StatusDraft, inventory.StatusDraft, false},
|
||||||
|
{inventory.StatusIndexed, inventory.StatusNeedsResearch, true},
|
||||||
|
{inventory.StatusIndexed, inventory.StatusResearched, true},
|
||||||
|
{inventory.StatusIndexed, inventory.StatusDraft, false},
|
||||||
|
{inventory.StatusNeedsResearch, inventory.StatusResearched, true},
|
||||||
|
{inventory.StatusNeedsResearch, inventory.StatusIndexed, false},
|
||||||
|
{inventory.StatusResearched, inventory.StatusComplete, true},
|
||||||
|
{inventory.StatusResearched, inventory.StatusDraft, false},
|
||||||
|
{inventory.StatusComplete, inventory.StatusDraft, false},
|
||||||
|
{inventory.StatusComplete, inventory.StatusResearched, false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := tt.from.CanTransitionTo(tt.to)
|
||||||
|
if got != tt.allowed {
|
||||||
|
t.Errorf("%s → %s: want %v, got %v", tt.from, tt.to, tt.allowed, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTransitionValid(t *testing.T) {
|
||||||
|
got, err := inventory.Transition(inventory.StatusDraft, inventory.StatusIndexed)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if got != inventory.StatusIndexed {
|
||||||
|
t.Errorf("want indexed, got %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTransitionInvalid(t *testing.T) {
|
||||||
|
_, err := inventory.Transition(inventory.StatusDraft, inventory.StatusComplete)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for invalid transition")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "invalid transition") {
|
||||||
|
t.Errorf("error should mention 'invalid transition', got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseCatalogStatus(t *testing.T) {
|
||||||
|
for _, s := range []string{"draft", "indexed", "needs_research", "researched", "complete"} {
|
||||||
|
cs, err := inventory.ParseCatalogStatus(s)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("ParseCatalogStatus(%q): unexpected error: %v", s, err)
|
||||||
|
}
|
||||||
|
if string(cs) != s {
|
||||||
|
t.Errorf("ParseCatalogStatus(%q) = %q, want %q", s, cs, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, err := inventory.ParseCatalogStatus("unknown_status")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for unknown status")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Create `internal/netbox/tags.go` (NB-07 — AI tags synced to NetBox):
|
||||||
|
```go
|
||||||
|
package netbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// normalizeTags deduplicates and normalizes a slice of tag strings:
|
||||||
|
// trims whitespace, lowercases, removes empty strings.
|
||||||
|
func normalizeTags(tags []string) []string {
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
out := make([]string, 0, len(tags))
|
||||||
|
for _, t := range tags {
|
||||||
|
t = strings.ToLower(strings.TrimSpace(t))
|
||||||
|
if t == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[t]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[t] = struct{}{}
|
||||||
|
out = append(out, t)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// TagRef holds a NetBox tag name and its internal ID.
|
||||||
|
type TagRef struct {
|
||||||
|
ID int32
|
||||||
|
Name string
|
||||||
|
Slug string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncTags ensures all tags in the provided slice exist in NetBox.
|
||||||
|
// Tags are normalized before sync (lowercase, trimmed, deduplicated).
|
||||||
|
// Returns the TagRef list for all tags (existing + newly created).
|
||||||
|
func (c *Client) SyncTags(ctx context.Context, tags []string) ([]TagRef, error) {
|
||||||
|
normalized := normalizeTags(tags)
|
||||||
|
if len(normalized) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]TagRef, 0, len(normalized))
|
||||||
|
for _, name := range normalized {
|
||||||
|
slug := tagNameToSlug(name)
|
||||||
|
ref, err := c.ensureTag(ctx, name, slug)
|
||||||
|
if err != nil {
|
||||||
|
return result, fmt.Errorf("sync tag %q: %w", name, err)
|
||||||
|
}
|
||||||
|
result = append(result, ref)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// tagNameToSlug converts a tag name to a NetBox-compatible slug.
|
||||||
|
// NetBox slugs: lowercase, hyphens instead of spaces, only [a-z0-9-_].
|
||||||
|
func tagNameToSlug(name string) string {
|
||||||
|
s := strings.ToLower(strings.TrimSpace(name))
|
||||||
|
s = strings.ReplaceAll(s, " ", "-")
|
||||||
|
// Remove characters not in [a-z0-9-_]
|
||||||
|
var out []byte
|
||||||
|
for _, c := range []byte(s) {
|
||||||
|
if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '_' {
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return string(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureTag returns an existing tag or creates a new one.
|
||||||
|
func (c *Client) ensureTag(ctx context.Context, name, slug string) (TagRef, error) {
|
||||||
|
// Check for existing tag by slug
|
||||||
|
res, _, err := c.api.ExtrasAPI.ExtrasTagsList(ctx).Slug([]string{slug}).Execute()
|
||||||
|
if err != nil {
|
||||||
|
return TagRef{}, fmt.Errorf("list tags: %w", err)
|
||||||
|
}
|
||||||
|
if res.GetCount() > 0 {
|
||||||
|
t := res.Results[0]
|
||||||
|
return TagRef{ID: t.GetId(), Name: t.GetName(), Slug: t.GetSlug()}, nil
|
||||||
|
}
|
||||||
|
// Create new tag
|
||||||
|
// NOTE: Executor must use go-netbox v4 TagRequest type
|
||||||
|
// Pattern: c.api.ExtrasAPI.ExtrasTagsCreate(ctx).TagRequest(req).Execute()
|
||||||
|
// Required fields: Name (string), Slug (string)
|
||||||
|
// Optional: Color (hex string, e.g. "faff69" for volt yellow)
|
||||||
|
return TagRef{}, fmt.Errorf("ensureTag: implement using go-netbox v4 TagRequest — grep TagRequest $(go env GOPATH)/pkg/mod/github.com/netbox-community/go-netbox/v4@v4.3.0/")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
EXECUTOR: Replace the `ensureTag` stub with real go-netbox v4 TagRequest call. Locate:
|
||||||
|
```
|
||||||
|
ls $(go env GOPATH)/pkg/mod/github.com/netbox-community/go-netbox/v4@v4.3.0/model_tag_request.go
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Create `internal/netbox/tags_test.go`:
|
||||||
|
```go
|
||||||
|
package netbox
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestNormalizeTags(t *testing.T) {
|
||||||
|
in := []string{" USB Cable ", "USB cable", "usb-cable", "", " "}
|
||||||
|
out := normalizeTags(in)
|
||||||
|
// "USB Cable", "USB cable", "usb-cable" all normalize to "usb-cable" — only 1 unique
|
||||||
|
if len(out) != 1 {
|
||||||
|
t.Errorf("want 1 unique normalized tag, got %d: %v", len(out), out)
|
||||||
|
}
|
||||||
|
if out[0] != "usb-cable" {
|
||||||
|
t.Errorf("want usb-cable, got %s", out[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTagNameToSlug(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
slug string
|
||||||
|
}{
|
||||||
|
{"USB Cable", "usb-cable"},
|
||||||
|
{"10GbE NIC", "10gbe-nic"},
|
||||||
|
{"SFP+ Transceiver", "sfp-transceiver"},
|
||||||
|
{" spaces ", "spaces"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := tagNameToSlug(tt.name)
|
||||||
|
if got != tt.slug {
|
||||||
|
t.Errorf("tagNameToSlug(%q) = %q, want %q", tt.name, got, tt.slug)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/mikkel/homelabby && go test ./internal/inventory/... ./internal/netbox/... -v -run "TestCanTransitionTo|TestTransitionValid|TestTransitionInvalid|TestParseCatalogStatus|TestNormalizeTags|TestTagNameToSlug"</automated>
|
||||||
|
</verify>
|
||||||
|
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `go test ./internal/inventory/...` passes all 12 table-driven cases for CanTransitionTo
|
||||||
|
- `go test ./internal/inventory/...` passes TestTransitionValid, TestTransitionInvalid, TestParseCatalogStatus
|
||||||
|
- `go test ./internal/netbox/... -run "TestNormalizeTags|TestTagNameToSlug"` passes all cases
|
||||||
|
- `grep "StatusComplete.*{}" internal/inventory/quality_gate.go` returns the terminal state entry with empty transitions
|
||||||
|
- `grep "invalid transition" internal/inventory/quality_gate.go` returns error string in Transition()
|
||||||
|
- `ensureTag` in `internal/netbox/tags.go` is implemented with real go-netbox v4 TagRequest (not stub returning error string)
|
||||||
|
- `go build ./internal/inventory/... ./internal/netbox/...` exits 0
|
||||||
|
</acceptance_criteria>
|
||||||
|
|
||||||
|
<done>Quality gate state machine fully tested. Tag normalization and slug conversion tested. ensureTag implemented with real go-netbox v4 API. All packages build cleanly.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| AI output → SyncTags | AI-suggested tag strings enter normalizeTags before any NetBox write |
|
||||||
|
| Quality gate transitions → NetBox writes | Transition validation in Go; NetBox stores the result |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|-------------|-----------------|
|
||||||
|
| T-04-01 | Tampering | Quality gate bypass | mitigate | All status changes MUST go through Transition() — no direct NetBox PATCH of catalog_status without Transition validation |
|
||||||
|
| T-04-02 | Tampering | AI tag injection | mitigate | normalizeTags strips whitespace, lowercases, deduplicates — limits injection surface before NetBox write |
|
||||||
|
| T-04-03 | Denial of Service | AllocateNextHWID scanning all devices | accept | Phase 1 inventory is small; getHighestHWIDNumber scans all devices; acceptable until inventory exceeds ~10k items |
|
||||||
|
| T-04-04 | Information Disclosure | HW-ID sequential enumeration | accept | IDs are not secret — they appear on printed labels; sequential is intentional for readability |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
After both tasks complete:
|
||||||
|
- `go test ./internal/inventory/... ./internal/netbox/...` all green (unit tests)
|
||||||
|
- `go build ./...` exits 0 (all packages compile together)
|
||||||
|
- Quality gate: `draft → indexed → needs_research → researched → complete` is the only fully valid path
|
||||||
|
- `curl -s -H "Authorization: Token $HWLAB_NETBOX_TOKEN" "http://10.5.0.130:8000/api/extras/tags/" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['count'])"` (verify tags endpoint reachable, if real token)
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
1. All 12 state machine transition cases tested and correct
|
||||||
|
2. Transition() returns error containing "invalid transition" for bad transitions
|
||||||
|
3. ParseCatalogStatus rejects unknown status strings
|
||||||
|
4. normalizeTags handles deduplication across case/whitespace variants
|
||||||
|
5. AllocateNextHWID implemented with optimistic retry loop
|
||||||
|
6. ensureTag uses real go-netbox v4 API (not stub)
|
||||||
|
7. `go build ./...` and `go test ./...` both clean
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/01-foundation/01-04-SUMMARY.md` with:
|
||||||
|
- All test results (pass counts)
|
||||||
|
- Whether ensureTag integration test ran (real token) or skipped
|
||||||
|
- Any issues with go-netbox v4 tag or device list filter API
|
||||||
|
- Files created/modified
|
||||||
|
</output>
|
||||||
555
.planning/phases/01-foundation/01-05-PLAN.md
Normal file
555
.planning/phases/01-foundation/01-05-PLAN.md
Normal file
|
|
@ -0,0 +1,555 @@
|
||||||
|
---
|
||||||
|
phase: 01-foundation
|
||||||
|
plan: 05
|
||||||
|
type: execute
|
||||||
|
wave: 2
|
||||||
|
depends_on:
|
||||||
|
- 01-01-PLAN.md
|
||||||
|
files_modified:
|
||||||
|
- internal/queue/waq.go
|
||||||
|
- internal/queue/waq_test.go
|
||||||
|
- internal/queue/worker.go
|
||||||
|
- cmd/hwlab/main.go
|
||||||
|
autonomous: true
|
||||||
|
requirements:
|
||||||
|
- NB-05
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "WAQ connects to DragonFlyDB at redis://:nUq/IfoIQJf/kouckKHRQOk7vV0NwCuI@10.5.0.10:6379"
|
||||||
|
- "Enqueue() pushes a serialized PendingOp onto the hwlab:netbox:pending_ops LIST"
|
||||||
|
- "RunWorker() BLPOP-blocks and processes available operations"
|
||||||
|
- "DragonFlyDB unavailability does not crash the binary — WAQ degrades gracefully"
|
||||||
|
- "Dequeue returns the oldest operation first (FIFO via RPUSH/BLPOP pattern)"
|
||||||
|
artifacts:
|
||||||
|
- path: "internal/queue/waq.go"
|
||||||
|
provides: "WAQ type: Enqueue, Dequeue, Len — DragonFlyDB-backed write-ahead queue"
|
||||||
|
exports: ["WAQ", "NewWAQ", "PendingOp", "Enqueue", "Len"]
|
||||||
|
- path: "internal/queue/worker.go"
|
||||||
|
provides: "RunWorker goroutine: BLPOP retry loop, exponential backoff on connection failure"
|
||||||
|
exports: ["RunWorker"]
|
||||||
|
key_links:
|
||||||
|
- from: "internal/queue/waq.go"
|
||||||
|
to: "redis://:nUq/IfoIQJf/kouckKHRQOk7vV0NwCuI@10.5.0.10:6379"
|
||||||
|
via: "go-redis v9 ParseURL + NewClient"
|
||||||
|
pattern: "redis.ParseURL|ParseURL"
|
||||||
|
- from: "internal/queue/worker.go"
|
||||||
|
to: "internal/queue/waq.go"
|
||||||
|
via: "BLPOP + processOp callback"
|
||||||
|
pattern: "BLPOP|RunWorker"
|
||||||
|
- from: "cmd/hwlab/main.go"
|
||||||
|
to: "internal/queue/worker.go"
|
||||||
|
via: "go waq.RunWorker(ctx)"
|
||||||
|
pattern: "RunWorker"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Implement the DragonFlyDB write-ahead queue: enqueue failed or deferred NetBox operations during downtime, and a worker goroutine that retries them when connectivity restores.
|
||||||
|
|
||||||
|
Purpose: NetBox may be temporarily unavailable (container restart, network blip). The WAQ ensures no inventory operations are lost — they're buffered in DragonFlyDB and retried automatically. NB-05 is the sole requirement for this plan.
|
||||||
|
Output: `internal/queue` package with WAQ and worker, wired into the main binary.
|
||||||
|
</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
|
||||||
|
@.planning/phases/01-foundation/01-01-SUMMARY.md
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- DragonFlyDB connection info -->
|
||||||
|
URL: redis://:nUq/IfoIQJf/kouckKHRQOk7vV0NwCuI@10.5.0.10:6379
|
||||||
|
Queue key: "hwlab:netbox:pending_ops" (Redis LIST)
|
||||||
|
Operations: RPUSH (enqueue), BLPOP (dequeue + block), LLEN (queue depth)
|
||||||
|
|
||||||
|
From internal/config/config.go:
|
||||||
|
```go
|
||||||
|
type Config struct {
|
||||||
|
DragonflyURL string // "redis://:PASSWORD@10.5.0.10:6379"
|
||||||
|
WAQRetryIntervalSeconds int // default 30
|
||||||
|
WAQMaxAttempts int // default 5
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
go-redis v9 pattern (verified in RESEARCH.md):
|
||||||
|
```go
|
||||||
|
import "github.com/redis/go-redis/v9"
|
||||||
|
|
||||||
|
opt, err := redis.ParseURL(redisURL) // parses redis:// URL including password
|
||||||
|
client := redis.NewClient(opt)
|
||||||
|
err = client.Ping(ctx).Err() // connectivity check
|
||||||
|
|
||||||
|
// Enqueue: RPUSH appends to right (FIFO with BLPOP from left)
|
||||||
|
err = client.RPush(ctx, key, data).Err()
|
||||||
|
|
||||||
|
// Dequeue (blocking): BLPOP pops from left, blocks up to timeout
|
||||||
|
result, err := client.BLPop(ctx, 5*time.Second, key).Result()
|
||||||
|
// result[0] = key name, result[1] = value
|
||||||
|
|
||||||
|
// Queue depth
|
||||||
|
n, err := client.LLen(ctx, key).Result()
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 1: Write-ahead queue core (Enqueue, Dequeue, Len)</name>
|
||||||
|
<files>internal/queue/waq.go, internal/queue/waq_test.go</files>
|
||||||
|
|
||||||
|
<read_first>
|
||||||
|
- /home/mikkel/homelabby/.env (HWLAB_DRAGONFLY_URL — verify it's redis://:nUq/IfoIQJf/kouckKHRQOk7vV0NwCuI@10.5.0.10:6379)
|
||||||
|
- /home/mikkel/homelabby/.planning/phases/01-foundation/01-RESEARCH.md (Pattern 5: DragonFlyDB Write-Ahead Queue, lines 266-320)
|
||||||
|
- /home/mikkel/homelabby/internal/config/config.go (DragonflyURL field)
|
||||||
|
</read_first>
|
||||||
|
|
||||||
|
<behavior>
|
||||||
|
- Test 1: NewWAQ with invalid URL returns error
|
||||||
|
- Test 2: NewWAQ with valid URL but unreachable server returns error on Ping
|
||||||
|
- Test 3 (INTEGRATION — skip if DragonFlyDB unreachable): Enqueue() + Len() = 1
|
||||||
|
- Test 4 (INTEGRATION): Enqueue(op1), Enqueue(op2), Dequeue() returns op1 first (FIFO)
|
||||||
|
- Test 5 (INTEGRATION): Len() returns 0 after all ops dequeued
|
||||||
|
- Test 6: PendingOp marshals/unmarshals to/from JSON correctly (ID, Type, Payload preserved)
|
||||||
|
</behavior>
|
||||||
|
|
||||||
|
<action>
|
||||||
|
Create `internal/queue/waq.go`:
|
||||||
|
```go
|
||||||
|
package queue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
)
|
||||||
|
|
||||||
|
const queueKey = "hwlab:netbox:pending_ops"
|
||||||
|
|
||||||
|
// PendingOp represents a queued NetBox operation.
|
||||||
|
// Serialized as JSON in DragonFlyDB LIST.
|
||||||
|
type PendingOp struct {
|
||||||
|
ID string `json:"id"` // UUID
|
||||||
|
Type string `json:"type"` // "create_device", "patch_custom_fields", "sync_tags", etc.
|
||||||
|
Payload json.RawMessage `json:"payload"` // operation-specific data
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
Attempts int `json:"attempts"` // retry count
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPendingOp creates a new PendingOp with a generated ID and current timestamp.
|
||||||
|
func NewPendingOp(opType string, payload interface{}) (PendingOp, error) {
|
||||||
|
data, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return PendingOp{}, fmt.Errorf("marshal payload: %w", err)
|
||||||
|
}
|
||||||
|
return PendingOp{
|
||||||
|
ID: uuid.New().String(),
|
||||||
|
Type: opType,
|
||||||
|
Payload: json.RawMessage(data),
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
Attempts: 0,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WAQ is a write-ahead queue backed by DragonFlyDB (Redis-compatible).
|
||||||
|
// Operations are stored as JSON in a Redis LIST using RPUSH/BLPOP for FIFO ordering.
|
||||||
|
type WAQ struct {
|
||||||
|
rdb *redis.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWAQ creates a new WAQ connected to the given Redis/DragonFlyDB URL.
|
||||||
|
// Returns error if the URL is invalid or the server is unreachable.
|
||||||
|
func NewWAQ(redisURL string) (*WAQ, error) {
|
||||||
|
opt, err := redis.ParseURL(redisURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse dragonfly url: %w", err)
|
||||||
|
}
|
||||||
|
client := redis.NewClient(opt)
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := client.Ping(ctx).Err(); err != nil {
|
||||||
|
client.Close()
|
||||||
|
return nil, fmt.Errorf("dragonfly unreachable at %s: %w", redisURL, err)
|
||||||
|
}
|
||||||
|
log.Printf("WAQ connected to DragonFlyDB")
|
||||||
|
return &WAQ{rdb: client}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enqueue pushes a PendingOp onto the right end of the queue (FIFO).
|
||||||
|
func (q *WAQ) Enqueue(ctx context.Context, op PendingOp) error {
|
||||||
|
data, err := json.Marshal(op)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal op: %w", err)
|
||||||
|
}
|
||||||
|
return q.rdb.RPush(ctx, queueKey, data).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dequeue pops the oldest operation (blocking for up to timeout).
|
||||||
|
// Returns nil, nil if timeout elapses with no item.
|
||||||
|
// Returns nil, redis.Nil if queue is empty (non-blocking variant would use LPop).
|
||||||
|
func (q *WAQ) Dequeue(ctx context.Context, timeout time.Duration) (*PendingOp, error) {
|
||||||
|
result, err := q.rdb.BLPop(ctx, timeout, queueKey).Result()
|
||||||
|
if err == redis.Nil {
|
||||||
|
return nil, nil // timeout — no items
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("blpop: %w", err)
|
||||||
|
}
|
||||||
|
var op PendingOp
|
||||||
|
if err := json.Unmarshal([]byte(result[1]), &op); err != nil {
|
||||||
|
return nil, fmt.Errorf("unmarshal op: %w", err)
|
||||||
|
}
|
||||||
|
return &op, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Len returns the current number of pending operations in the queue.
|
||||||
|
func (q *WAQ) Len(ctx context.Context) (int64, error) {
|
||||||
|
n, err := q.rdb.LLen(ctx, queueKey).Result()
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("llen: %w", err)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close releases the DragonFlyDB connection.
|
||||||
|
func (q *WAQ) Close() error {
|
||||||
|
return q.rdb.Close()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `internal/queue/waq_test.go`:
|
||||||
|
```go
|
||||||
|
package queue_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.georgsen.dk/hwlab/internal/queue"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPendingOpJSON(t *testing.T) {
|
||||||
|
payload := map[string]string{"device_id": "42", "hw_id": "HW-00001"}
|
||||||
|
op, err := queue.NewPendingOp("create_device", payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewPendingOp: %v", err)
|
||||||
|
}
|
||||||
|
if op.ID == "" {
|
||||||
|
t.Error("ID should be a UUID")
|
||||||
|
}
|
||||||
|
if op.Type != "create_device" {
|
||||||
|
t.Errorf("Type: want create_device, got %s", op.Type)
|
||||||
|
}
|
||||||
|
// Round-trip JSON
|
||||||
|
data, _ := json.Marshal(op)
|
||||||
|
var op2 queue.PendingOp
|
||||||
|
if err := json.Unmarshal(data, &op2); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if op2.ID != op.ID {
|
||||||
|
t.Errorf("ID mismatch after round-trip: %s != %s", op2.ID, op.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewWAQInvalidURL(t *testing.T) {
|
||||||
|
_, err := queue.NewWAQ("not-a-redis-url")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for invalid URL")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dragonflyURL returns the DragonFlyDB URL from env, or skips test if unreachable.
|
||||||
|
func dragonflyURL(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
url := os.Getenv("HWLAB_DRAGONFLY_URL")
|
||||||
|
if url == "" {
|
||||||
|
url = "redis://:nUq/IfoIQJf/kouckKHRQOk7vV0NwCuI@10.5.0.10:6379"
|
||||||
|
}
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWAQEnqueueDequeue(t *testing.T) {
|
||||||
|
waq, err := queue.NewWAQ(dragonflyURL(t))
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("DragonFlyDB unavailable: %v", err)
|
||||||
|
}
|
||||||
|
defer waq.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Clean up before test
|
||||||
|
// Note: In a real test suite, use a test-specific queue key.
|
||||||
|
// For now, just ensure queue starts non-empty cleanup is acceptable.
|
||||||
|
|
||||||
|
op, _ := queue.NewPendingOp("test_op", map[string]string{"test": "value"})
|
||||||
|
if err := waq.Enqueue(ctx, op); err != nil {
|
||||||
|
t.Fatalf("Enqueue: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := waq.Len(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Len: %v", err)
|
||||||
|
}
|
||||||
|
if n < 1 {
|
||||||
|
t.Error("expected at least 1 item after enqueue")
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := waq.Dequeue(ctx, 2*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Dequeue: %v", err)
|
||||||
|
}
|
||||||
|
if got == nil {
|
||||||
|
t.Fatal("expected op from dequeue, got nil")
|
||||||
|
}
|
||||||
|
if got.ID != op.ID {
|
||||||
|
t.Errorf("dequeued op ID mismatch: want %s, got %s", op.ID, got.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/mikkel/homelabby && go test ./internal/queue/... -v -run "TestPendingOpJSON|TestNewWAQInvalidURL"</automated>
|
||||||
|
</verify>
|
||||||
|
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `go test ./internal/queue/... -run "TestPendingOpJSON|TestNewWAQInvalidURL"` passes (unit tests, no DragonFlyDB needed)
|
||||||
|
- `grep "hwlab:netbox:pending_ops" internal/queue/waq.go` returns the queueKey const
|
||||||
|
- `grep "RPush" internal/queue/waq.go` returns the Enqueue implementation
|
||||||
|
- `grep "BLPop" internal/queue/waq.go` returns the Dequeue implementation
|
||||||
|
- `grep "ParseURL" internal/queue/waq.go` returns the redis.ParseURL call
|
||||||
|
- `go build ./internal/queue/...` exits 0
|
||||||
|
- If DragonFlyDB reachable: `go test ./internal/queue/... -v -run TestWAQEnqueueDequeue` passes
|
||||||
|
</acceptance_criteria>
|
||||||
|
|
||||||
|
<done>WAQ core implemented. Unit tests pass. FIFO enqueue/dequeue via RPUSH/BLPOP. Integration test skips gracefully when DragonFlyDB unreachable.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: WAQ retry worker + wire into main binary</name>
|
||||||
|
<files>internal/queue/worker.go, cmd/hwlab/main.go</files>
|
||||||
|
|
||||||
|
<read_first>
|
||||||
|
- /home/mikkel/homelabby/internal/queue/waq.go (WAQ type, PendingOp, Dequeue method)
|
||||||
|
- /home/mikkel/homelabby/cmd/hwlab/main.go (current main.go to understand wiring pattern)
|
||||||
|
- /home/mikkel/homelabby/internal/config/config.go (WAQRetryIntervalSeconds, DragonflyURL)
|
||||||
|
</read_first>
|
||||||
|
|
||||||
|
<action>
|
||||||
|
1. Create `internal/queue/worker.go`:
|
||||||
|
```go
|
||||||
|
package queue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OpHandler is a function that processes a single dequeued operation.
|
||||||
|
// Returns nil on success, error if the operation should be re-queued.
|
||||||
|
type OpHandler func(ctx context.Context, op PendingOp) error
|
||||||
|
|
||||||
|
// RunWorker runs a blocking BLPOP loop processing ops from the queue.
|
||||||
|
// It calls handler for each dequeued op. If handler returns an error,
|
||||||
|
// the op is re-enqueued with incremented Attempts count.
|
||||||
|
// Ops that exceed maxAttempts are dropped with a log warning.
|
||||||
|
//
|
||||||
|
// On DragonFlyDB connection loss, RunWorker backs off and retries connection.
|
||||||
|
// Call with a cancellable context to stop the worker cleanly.
|
||||||
|
func (q *WAQ) RunWorker(ctx context.Context, handler OpHandler, maxAttempts int, retryInterval time.Duration) {
|
||||||
|
log.Printf("WAQ worker started (maxAttempts=%d, retryInterval=%s)", maxAttempts, retryInterval)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
log.Printf("WAQ worker stopping: %v", ctx.Err())
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
op, err := q.Dequeue(ctx, 5*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
// Connection error — back off before retrying
|
||||||
|
log.Printf("WAQ dequeue error: %v — backing off %s", err, retryInterval)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-time.After(retryInterval):
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if op == nil {
|
||||||
|
// Timeout with no items — loop immediately (BLPOP already waited 5s)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process the operation
|
||||||
|
if err := handler(ctx, *op); err != nil {
|
||||||
|
op.Attempts++
|
||||||
|
if op.Attempts >= maxAttempts {
|
||||||
|
log.Printf("WAQ: dropping op %s (type=%s) after %d failed attempts: %v",
|
||||||
|
op.ID, op.Type, op.Attempts, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Re-enqueue for retry
|
||||||
|
log.Printf("WAQ: re-enqueuing op %s (type=%s, attempt=%d): %v",
|
||||||
|
op.ID, op.Type, op.Attempts, err)
|
||||||
|
if enqErr := q.Enqueue(ctx, *op); enqErr != nil {
|
||||||
|
log.Printf("WAQ: failed to re-enqueue op %s: %v", op.ID, enqErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NoOpHandler is a placeholder op handler for Phase 1.
|
||||||
|
// Phase 2 will replace this with a real NetBox retry handler.
|
||||||
|
// It logs the operation and returns nil (success) so ops drain from the queue.
|
||||||
|
func NoOpHandler(ctx context.Context, op PendingOp) error {
|
||||||
|
log.Printf("WAQ [noop]: processing op %s (type=%s, attempts=%d)", op.ID, op.Type, op.Attempts)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Update `cmd/hwlab/main.go` to wire the WAQ:
|
||||||
|
Read the current main.go first, then add WAQ initialization and worker goroutine.
|
||||||
|
The WAQ initialization must be non-fatal — if DragonFlyDB is unavailable, the binary
|
||||||
|
still starts and serves HTTP. WAQ is degraded, not required.
|
||||||
|
|
||||||
|
Updated main.go pattern:
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.georgsen.dk/hwlab/internal/api"
|
||||||
|
"git.georgsen.dk/hwlab/internal/config"
|
||||||
|
"git.georgsen.dk/hwlab/internal/queue"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context for graceful shutdown
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
// Start write-ahead queue worker (non-fatal if DragonFlyDB unavailable)
|
||||||
|
waq, err := queue.NewWAQ(cfg.DragonflyURL)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("WARNING: WAQ unavailable (%v) — NetBox operations will not be queued during downtime", err)
|
||||||
|
} else {
|
||||||
|
retryInterval := time.Duration(cfg.WAQRetryIntervalSeconds) * time.Second
|
||||||
|
go waq.RunWorker(ctx, queue.NoOpHandler, cfg.WAQMaxAttempts, retryInterval)
|
||||||
|
defer waq.Close()
|
||||||
|
log.Printf("WAQ worker started")
|
||||||
|
}
|
||||||
|
|
||||||
|
router := api.NewRouter()
|
||||||
|
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
|
||||||
|
log.Printf("HWLab starting on %s", addr)
|
||||||
|
|
||||||
|
srv := &http.Server{Addr: addr, Handler: router}
|
||||||
|
go func() {
|
||||||
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
log.Fatalf("server: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait for shutdown signal
|
||||||
|
<-ctx.Done()
|
||||||
|
log.Println("Shutting down...")
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
srv.Shutdown(shutdownCtx)
|
||||||
|
log.Println("Shutdown complete")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/mikkel/homelabby && go build ./cmd/hwlab/... && go vet ./...</automated>
|
||||||
|
</verify>
|
||||||
|
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `go build ./cmd/hwlab/...` exits 0
|
||||||
|
- `go vet ./...` exits 0
|
||||||
|
- `grep "RunWorker" cmd/hwlab/main.go` returns the goroutine call
|
||||||
|
- `grep "NoOpHandler" internal/queue/worker.go` returns the placeholder handler
|
||||||
|
- `grep "maxAttempts" internal/queue/worker.go` returns the op drop condition
|
||||||
|
- `grep "WARNING: WAQ unavailable" cmd/hwlab/main.go` returns the non-fatal degraded path
|
||||||
|
- Binary starts without panic when DragonFlyDB is available
|
||||||
|
- Binary starts with WARNING log (not fatal) when DragonFlyDB is unavailable
|
||||||
|
- `go test ./...` remains green (no regressions from main.go changes)
|
||||||
|
</acceptance_criteria>
|
||||||
|
|
||||||
|
<done>WAQ worker implemented with exponential backoff, max attempts drop, and NoOpHandler placeholder. main.go updated with graceful shutdown and non-fatal WAQ initialization. Full `go test ./...` still green.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| internal/queue → DragonFlyDB | Redis protocol over TCP to 10.5.0.10:6379; password in URL |
|
||||||
|
| PendingOp.Payload | JSON RawMessage from NetBox operation context — validated by op handler |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|-------------|-----------------|
|
||||||
|
| T-05-01 | Information Disclosure | DragonFlyDB password in HWLAB_DRAGONFLY_URL | accept | Private homelab LAN (10.5.0.x); password is in .env which is .gitignored; no external access |
|
||||||
|
| T-05-02 | Tampering | PendingOp re-enqueue on failure | accept | Ops re-enqueued only by the worker itself; no external write path to the queue in Phase 1 |
|
||||||
|
| T-05-03 | Denial of Service | Queue accumulation if handler always fails | mitigate | maxAttempts drop logic — ops dropped after cfg.WAQMaxAttempts (default 5) failures; prevents unbounded queue growth |
|
||||||
|
| T-05-04 | Denial of Service | WAQ worker tight-loop on connection loss | mitigate | retryInterval backoff (default 30s) prevents hammering DragonFlyDB on reconnect |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
After both tasks complete:
|
||||||
|
- `go test ./...` passes (all packages, no regressions)
|
||||||
|
- `go build ./cmd/hwlab/...` exits 0
|
||||||
|
- `go vet ./...` exits 0
|
||||||
|
- If DragonFlyDB reachable: `go test ./internal/queue/... -v` shows TestWAQEnqueueDequeue PASS
|
||||||
|
- Start binary and verify it serves health endpoint: `./hwlab &; sleep 1; curl http://localhost:8080/api/health; kill %1`
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
1. WAQ core: Enqueue, Dequeue, Len all working against DragonFlyDB (integration) or skipping gracefully (unit)
|
||||||
|
2. Worker: BLPOP loop with backoff, max attempts drop, context cancellation
|
||||||
|
3. main.go: Non-fatal WAQ init — WARNING log instead of panic when DragonFlyDB unavailable
|
||||||
|
4. Graceful shutdown: SIGINT triggers orderly HTTP server and WAQ worker shutdown
|
||||||
|
5. Full test suite `go test ./...` green
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/01-foundation/01-05-SUMMARY.md` with:
|
||||||
|
- Whether DragonFlyDB integration tests ran or skipped
|
||||||
|
- Any DragonFlyDB/go-redis v9 compatibility notes (DragonFlyDB is Redis-compatible but may have minor differences)
|
||||||
|
- Final `go test ./...` output
|
||||||
|
- Files created/modified
|
||||||
|
</output>
|
||||||
Loading…
Add table
Reference in a new issue