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

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

608 lines
25 KiB
Markdown

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