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>
25 KiB
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 01-foundation | 02 | execute | 1 |
|
true |
|
|
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.mdgo-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().
<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> |
<success_criteria>
- All unit tests in
internal/netboxpass without requiring live NetBox - Integration tests skip gracefully when token is the placeholder
homelab-netbox-api-token-2024 ParseCustomFieldshandles nil, string values, and []interface{} photo_urls without panickingPatchCustomFieldsis implemented with real go-netbox v4 API calls (not stub)go build ./...compiles cleanly </success_criteria>