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

25 KiB

phase plan type wave depends_on files_modified autonomous requirements must_haves
01-foundation 02 execute 1
internal/netbox/client.go
internal/netbox/client_test.go
internal/netbox/custom_fields.go
internal/netbox/types.go
true
NB-01
NB-02
truths artifacts key_links
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
path provides exports
internal/netbox/client.go go-netbox v4 wrapper with typed methods for device/module/cable CRUD
NewClient
Client
path provides exports
internal/netbox/custom_fields.go HWLab custom field read/write types and helper functions
CustomFieldsRead
CustomFieldsPatch
BuildCustomFieldsPatch
ParseCustomFields
path provides exports
internal/netbox/types.go HWLab domain types wrapping NetBox responses
Device
CustomFields
from to via pattern
internal/netbox/client.go http://10.5.0.130:8000/api go-netbox NewAPIClientFor NewAPIClientFor
from to via pattern
internal/netbox/custom_fields.go internal/netbox/client.go PatchCustomFields method on Client 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.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.env @.planning/phases/01-foundation/01-RESEARCH.md

go-netbox v4 initialization pattern:

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

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

- 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

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

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

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

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

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

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.

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

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