diff --git a/.planning/phases/01-foundation/01-02-SUMMARY.md b/.planning/phases/01-foundation/01-02-SUMMARY.md new file mode 100644 index 0000000..785193a --- /dev/null +++ b/.planning/phases/01-foundation/01-02-SUMMARY.md @@ -0,0 +1,113 @@ +--- +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