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
|
||||
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
|
||||
5. A write-ahead queue in SQLite buffers failed NetBox operations and retries them on reconnect
|
||||
**Plans**: TBD
|
||||
5. A write-ahead queue in DragonFlyDB buffers failed NetBox operations and retries them on reconnect
|
||||
**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
|
||||
**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 |
|
||||
|-------|----------------|--------|-----------|
|
||||
| 1. Foundation | 0/TBD | Not started | - |
|
||||
| 1. Foundation | 0/5 | Not started | - |
|
||||
| 2. AI Pipeline | 0/TBD | Not started | - |
|
||||
| 3. Dashboard & Intake UI | 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