--- 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" --- 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. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.env @.planning/phases/01-foundation/01-RESEARCH.md 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(). Task 1: NetBox client wrapper with device CRUD (NB-01) internal/netbox/client.go, internal/netbox/types.go, internal/netbox/client_test.go - /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) - 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) 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 } ``` cd /home/mikkel/homelabby && go test ./internal/netbox/... -v -run TestNewClientValidation - `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) NetBox client compiles, unit validation tests pass, integration tests skip cleanly when token is a placeholder, and pass when a real token is provided. Task 2: Custom field read/write wrappers (NB-02 round-trip) internal/netbox/custom_fields.go, internal/netbox/custom_fields_test.go - /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) - 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 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)) } } ``` cd /home/mikkel/homelabby && go test ./internal/netbox/... -v -run "TestParseCustomFields|TestBuildCustomFields" - `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 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. ## 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 | 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 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 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