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>
608 lines
25 KiB
Markdown
608 lines
25 KiB
Markdown
---
|
|
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"
|
|
---
|
|
|
|
<objective>
|
|
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.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<context>
|
|
@.planning/PROJECT.md
|
|
@.planning/ROADMAP.md
|
|
@.env
|
|
@.planning/phases/01-foundation/01-RESEARCH.md
|
|
</context>
|
|
|
|
<interfaces>
|
|
<!-- Key types the executor needs from go-netbox v4 -->
|
|
<!-- Source: github.com/netbox-community/go-netbox/v4 -->
|
|
|
|
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().
|
|
</interfaces>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 1: NetBox client wrapper with device CRUD (NB-01)</name>
|
|
<files>internal/netbox/client.go, internal/netbox/types.go, internal/netbox/client_test.go</files>
|
|
|
|
<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>
|
|
|
|
<behavior>
|
|
- 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)
|
|
</behavior>
|
|
|
|
<action>
|
|
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
|
|
}
|
|
```
|
|
</action>
|
|
|
|
<verify>
|
|
<automated>cd /home/mikkel/homelabby && go test ./internal/netbox/... -v -run TestNewClientValidation</automated>
|
|
</verify>
|
|
|
|
<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>
|
|
|
|
<done>NetBox client compiles, unit validation tests pass, integration tests skip cleanly when token is a placeholder, and pass when a real token is provided.</done>
|
|
</task>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 2: Custom field read/write wrappers (NB-02 round-trip)</name>
|
|
<files>internal/netbox/custom_fields.go, internal/netbox/custom_fields_test.go</files>
|
|
|
|
<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>
|
|
|
|
<behavior>
|
|
- 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
|
|
</behavior>
|
|
|
|
<action>
|
|
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))
|
|
}
|
|
}
|
|
```
|
|
</action>
|
|
|
|
<verify>
|
|
<automated>cd /home/mikkel/homelabby && go test ./internal/netbox/... -v -run "TestParseCustomFields|TestBuildCustomFields"</automated>
|
|
</verify>
|
|
|
|
<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>
|
|
|
|
<done>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.</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<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>
|
|
|
|
<verification>
|
|
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
|
|
</verification>
|
|
|
|
<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>
|
|
|
|
<output>
|
|
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
|
|
</output>
|