--- phase: 01-foundation plan: "02" subsystem: netbox tags: [go, netbox, client, custom-fields, tdd] dependency_graph: requires: [] provides: [internal/netbox.Client, internal/netbox.ParseCustomFields, internal/netbox.BuildCustomFieldsPatch] affects: [internal/quality, internal/hwid, internal/waq] tech_stack: added: [github.com/netbox-community/go-netbox/v4 v4.3.0, gopkg.in/validator.v2 v2.0.1] patterns: [wrapper-client, typed-custom-fields, integration-skip-guard] key_files: created: - internal/netbox/client.go - internal/netbox/types.go - internal/netbox/client_test.go - internal/netbox/custom_fields.go - internal/netbox/custom_fields_test.go modified: - go.mod - go.sum decisions: - id: NB-CLIENT-01 summary: "Strip /api suffix from base URL in NewClient to avoid go-netbox double-appending /api" - id: NB-CLIENT-02 summary: "PatchedWritableDeviceWithConfigContextRequest.SetCustomFields(map) is the correct go-netbox v4 PATCH pattern" - id: NB-CF-01 summary: "BuildCustomFieldsPatch only includes non-empty fields — avoids clearing existing NetBox values on partial update" metrics: duration: "~15 minutes" completed: "2026-04-10T05:17:22Z" tasks_completed: 2 files_created: 5 files_modified: 2 --- # Phase 1 Plan 02: NetBox Client Package Summary **One-liner:** go-netbox v4 typed client wrapper with safe custom field read/write helpers and integration-skip guards for placeholder tokens. ## What Was Built ### `internal/netbox/types.go` Domain types that decouple the rest of HWLab from go-netbox generated types: - `Device` — HWLab inventory item (ID, Name, AssetTag, CustomFields, Created, LastUpdated) - `CustomFields` — typed struct over NetBox's `map[string]interface{}` response (HWID, CatalogStatus, ProductURL, FirmwareVersion, TestDate, TestData, AINotes, PhotoURLs) ### `internal/netbox/client.go` `Client` struct wrapping `*nb.APIClient` with methods: - `NewClient(url, token string) (*Client, error)` — validates inputs, strips trailing `/api` from URL before passing to `nb.NewAPIClientFor` - `Ping(ctx)` — lightweight connectivity check via `DcimDevicesList(limit=1)` - `ListDevices(ctx, limit)` — returns `[]Device` mapped via `deviceFromNetBox` - `GetDevice(ctx, id)` — retrieves single device by NetBox internal ID ### `internal/netbox/custom_fields.go` - `ParseCustomFields(raw map[string]interface{}) CustomFields` — safe type assertions for all 8 HWLab custom field keys; handles nil input; handles `[]interface{}` photo_urls - `BuildCustomFieldsPatch(hwID, catalogStatus string, photoURLs []string) map[string]interface{}` — selective patch (only non-empty fields included) - `BuildFullCustomFieldsPatch(cf CustomFields) map[string]interface{}` — full patch for initial record creation - `(c *Client) PatchCustomFields(ctx, deviceID, patch)` — uses `nb.PatchedWritableDeviceWithConfigContextRequest.SetCustomFields()` then `.Execute()` ## Integration Test Status Integration tests (TestPingLive, TestListDevicesLive, TestPatchCustomFieldsRoundTrip) **SKIPPED** because `HWLAB_NETBOX_TOKEN=homelab-netbox-api-token-2024` is a placeholder (22 chars, not 40). Skip guard: `if len(token) != 40 { t.Skip(...) }` — correct behavior, no errors. To run integration tests: set `HWLAB_NETBOX_TOKEN` to a real 40-char hex token from NetBox UI, and `HWLAB_TEST_DEVICE_ID` to an existing device ID for round-trip test. ## go-netbox v4 API Notes - **Struct confirmed:** `nb.PatchedWritableDeviceWithConfigContextRequest` with `SetCustomFields(map[string]interface{})` method - **PATCH pattern:** `c.api.DcimAPI.DcimDevicesPartialUpdate(ctx, int32(id)).PatchedWritableDeviceWithConfigContextRequest(req).Execute()` - **URL handling:** `NewAPIClientFor` appends `/api` internally — must strip trailing `/api` from the URL in the config to avoid `http://host:8000/api/api/` - **AssetTag type:** `NullableString` on `DeviceWithConfigContext` — `GetAssetTag()` returns empty string when null (safe) - **CustomFields on read:** `map[string]interface{}` — requires type assertions per field ## Deviations from Plan ### Auto-fixed Issues **1. [Rule 3 - Blocking] custom_fields.go written in Task 1 to unblock compilation** - **Found during:** Task 1 GREEN phase - **Issue:** `client.go` calls `ParseCustomFields` which was planned for Task 2; package would not compile without it - **Fix:** Wrote `custom_fields.go` as part of Task 1 implementation to make `go test` work; Task 2 then added only `custom_fields_test.go` - **Files modified:** internal/netbox/custom_fields.go (created during Task 1 pass) - **Commit:** 9f3ed9f (Task 1 commit includes custom_fields.go) **2. [Rule 2 - Missing] Added HWLAB_TEST_DEVICE_ID guard to round-trip integration test** - **Found during:** Task 2 test design - **Issue:** Round-trip test needs an existing device ID — hardcoding one would fail on fresh NetBox installs - **Fix:** Added second `t.Skip()` guard when `HWLAB_TEST_DEVICE_ID` env var is absent - **Files modified:** internal/netbox/custom_fields_test.go ## Self-Check Files created: - internal/netbox/client.go: FOUND - internal/netbox/types.go: FOUND - internal/netbox/client_test.go: FOUND - internal/netbox/custom_fields.go: FOUND - internal/netbox/custom_fields_test.go: FOUND Commits: - 9f3ed9f — Task 1 (NetBox client wrapper) - 17a2eb6 — Task 2 (custom field wrappers) `go build ./...`: PASS `go test ./internal/netbox/... -run TestNewClientValidation`: PASS `go test ./internal/netbox/... -run "TestParseCustomFields|TestBuildCustomFields"`: PASS (5 tests) Integration tests: SKIP (placeholder token — correct behavior) ## Self-Check: PASSED