homelabby/.planning/phases/01-foundation/01-03-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

569 lines
26 KiB
Markdown

---
phase: 01-foundation
plan: 03
type: execute
wave: 2
depends_on:
- 01-02-PLAN.md
files_modified:
- internal/netbox/provision.go
- internal/netbox/provision_test.go
- scripts/provision-netbox.go
autonomous: true
requirements:
- NB-02
- NB-03
- NB-04
must_haves:
truths:
- "All 8 HWLab custom fields exist in NetBox after provisioning runs"
- "GET /api/extras/custom-fields/?name=hw_id returns the hw_id field definition"
- "GET /api/extras/custom-fields/?name=catalog_status returns the catalog_status field"
- "Location hierarchy exists: at least one Site, one Location, one Rack in NetBox"
- "Provisioning script is idempotent — running it twice does not create duplicates"
artifacts:
- path: "internal/netbox/provision.go"
provides: "Provision() function: creates custom fields + location hierarchy if not present"
exports: ["Provision", "ProvisionCustomFields", "ProvisionLocationHierarchy"]
- path: "scripts/provision-netbox.go"
provides: "Standalone CLI: `go run scripts/provision-netbox.go` — provisions NetBox from .env"
key_links:
- from: "scripts/provision-netbox.go"
to: "internal/netbox/provision.go"
via: "direct function call Provision(client)"
pattern: "Provision"
- from: "internal/netbox/provision.go"
to: "http://10.5.0.130:8000/api"
via: "REST POST /api/extras/custom-fields/, /api/dcim/sites/, /api/dcim/locations/, /api/dcim/racks/"
pattern: "ExtrasAPI|DcimAPI"
---
<objective>
Provision the NetBox instance with all HWLab custom fields and location hierarchy. This is an idempotent provisioning operation: if a custom field already exists, skip it; if the location hierarchy exists, skip it.
Purpose: Custom fields must exist in NetBox before any Go code can write them. NB-02 requires 8 specific fields; NB-03 requires the netbox-inventory plugin installed; NB-04 requires the Site→Location→Rack hierarchy.
Output: A `Provision()` function and a standalone script that a human can run against the live NetBox instance to bootstrap it.
</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
@.planning/phases/01-foundation/01-02-SUMMARY.md
</context>
<interfaces>
<!-- Types from Plan 02 that this plan uses -->
From internal/netbox/client.go:
```go
type Client struct { ... }
func NewClient(url, token string) (*Client, error)
// Client.api is *nb.APIClient (go-netbox v4)
// Access raw API via: client.api.ExtrasAPI, client.api.DcimAPI
```
NetBox custom field POST payload (REST API):
```json
{
"name": "hw_id",
"label": "HW ID",
"type": "text",
"object_types": ["dcim.device"],
"required": false,
"description": "HWLab sequential identifier (HW-XXXXX)"
}
```
NetBox location hierarchy (DCIM API):
- Site: POST /api/dcim/sites/ {name, slug}
- Location: POST /api/dcim/locations/ {name, slug, site: {id}}
- Rack: POST /api/dcim/racks/ {name, site: {id}, location: {id}, u_height: 42}
Check for existing custom field by name: GET /api/extras/custom-fields/?name=hw_id
- If count > 0, field exists — skip creation
</interfaces>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Custom field provisioning (NB-02)</name>
<files>internal/netbox/provision.go, internal/netbox/provision_test.go</files>
<read_first>
- /home/mikkel/homelabby/internal/netbox/client.go (Client struct, api field access pattern)
- /home/mikkel/homelabby/.planning/phases/01-foundation/01-RESEARCH.md (phase requirements table, lines 38-48)
- /home/mikkel/homelabby/.env (HWLAB_NETBOX_TOKEN — check if real 40-char token)
</read_first>
<behavior>
- Test 1: customFieldSpec("hw_id") returns a spec with Name="hw_id", Type="text", ObjectTypes=["dcim.device"]
- Test 2: customFieldSpec("catalog_status") returns Type="text" (stored as free text, not NetBox choice field, to avoid NetBox admin dependency)
- Test 3: customFieldSpec("photo_urls") returns a spec with Type="text" and description mentioning comma-separated
- Test 4 (INTEGRATION — skip if no real token): ProvisionCustomFields() creates all 8 fields and returns no error
- Test 5 (INTEGRATION — skip if no real token): ProvisionCustomFields() called twice is idempotent (no error, no duplicate fields)
</behavior>
<action>
All 8 custom fields to provision (NB-02):
- hw_id: text, dcim.device — "HWLab sequential identifier (HW-XXXXX)"
- catalog_status: text, dcim.device — "Lifecycle status: draft|indexed|needs_research|researched|complete"
- product_url: url, dcim.device — "Manufacturer product page URL"
- firmware_version: text, dcim.device — "Current firmware/software version"
- test_date: date, dcim.device — "Date of last cable/hardware test"
- test_data: text, dcim.device — "Structured JSON test results from cable testers"
- ai_notes: text, dcim.device — "AI-generated notes from intake analysis"
- photo_urls: text, dcim.device — "Comma-separated photo URLs captured during intake"
NOTE on photo_urls: Use type "text" (not multi-object) to avoid NetBox v4 multi-value custom field complexity. Store as comma-separated string. The Go layer will split/join as needed.
NOTE on catalog_status: Use type "text" not NetBox "selection" field — the Go quality gate owns the valid values; we don't want to maintain a parallel list in NetBox admin UI.
Create `internal/netbox/provision.go`:
```go
package netbox
import (
"context"
"fmt"
"log"
)
// CustomFieldSpec defines a NetBox custom field to provision.
type CustomFieldSpec struct {
Name string
Label string
Type string // "text", "url", "date", "integer", "boolean"
ObjectTypes []string
Description string
Required bool
}
// hwlabCustomFields is the canonical list of all HWLab custom fields.
// These MUST be provisioned in NetBox before any item can be created.
var hwlabCustomFields = []CustomFieldSpec{
{Name: "hw_id", Label: "HW ID", Type: "text", ObjectTypes: []string{"dcim.device"}, Description: "HWLab sequential identifier (HW-XXXXX)"},
{Name: "catalog_status", Label: "Catalog Status", Type: "text", ObjectTypes: []string{"dcim.device"}, Description: "Lifecycle: draft|indexed|needs_research|researched|complete"},
{Name: "product_url", Label: "Product URL", Type: "url", ObjectTypes: []string{"dcim.device"}, Description: "Manufacturer product page URL"},
{Name: "firmware_version", Label: "Firmware Version", Type: "text", ObjectTypes: []string{"dcim.device"}, Description: "Current firmware/software version"},
{Name: "test_date", Label: "Test Date", Type: "date", ObjectTypes: []string{"dcim.device"}, Description: "Date of last cable/hardware test (ISO 8601)"},
{Name: "test_data", Label: "Test Data", Type: "text", ObjectTypes: []string{"dcim.device"}, Description: "Structured JSON test results from cable testers"},
{Name: "ai_notes", Label: "AI Notes", Type: "text", ObjectTypes: []string{"dcim.device"}, Description: "AI-generated notes from intake photo analysis"},
{Name: "photo_urls", Label: "Photo URLs", Type: "text", ObjectTypes: []string{"dcim.device"}, Description: "Comma-separated photo URLs captured during intake"},
}
// customFieldSpec returns the spec for a named custom field (for testing).
func customFieldSpec(name string) *CustomFieldSpec {
for i := range hwlabCustomFields {
if hwlabCustomFields[i].Name == name {
return &hwlabCustomFields[i]
}
}
return nil
}
// ProvisionCustomFields ensures all HWLab custom fields exist in NetBox.
// Idempotent: fields that already exist are skipped.
// Returns the count of fields created (0 if all existed).
func (c *Client) ProvisionCustomFields(ctx context.Context) (int, error) {
created := 0
for _, spec := range hwlabCustomFields {
exists, err := c.customFieldExists(ctx, spec.Name)
if err != nil {
return created, fmt.Errorf("check field %s: %w", spec.Name, err)
}
if exists {
log.Printf("custom field %q already exists — skipping", spec.Name)
continue
}
if err := c.createCustomField(ctx, spec); err != nil {
return created, fmt.Errorf("create field %s: %w", spec.Name, err)
}
log.Printf("created custom field %q", spec.Name)
created++
}
return created, nil
}
// customFieldExists checks if a custom field with the given name already exists.
func (c *Client) customFieldExists(ctx context.Context, name string) (bool, error) {
res, _, err := c.api.ExtrasAPI.ExtrasCustomFieldsList(ctx).Name([]string{name}).Execute()
if err != nil {
return false, err
}
return res.GetCount() > 0, nil
}
// createCustomField creates a single custom field in NetBox via the Extras API.
// Uses the go-netbox v4 generated WritableCustomFieldRequest type.
func (c *Client) createCustomField(ctx context.Context, spec CustomFieldSpec) error {
// go-netbox v4: use CustomFieldTypeValue for the Type field
// Available types: "text", "longtext", "integer", "decimal", "boolean",
// "date", "datetime", "url", "json", "select", "multiselect",
// "object", "multiobject"
// NOTE: Executor must check the actual enum values in go-netbox v4 generated code:
// grep -r "CustomFieldTypeValue" $(go env GOPATH)/pkg/mod/github.com/netbox-community/go-netbox/ | head -10
nb_pkg := "github.com/netbox-community/go-netbox/v4"
_ = nb_pkg
// Pseudocode — executor must use real go-netbox v4 WritableCustomFieldRequest:
// req := nb.WritableCustomFieldRequest{
// Name: spec.Name,
// Label: nb.PtrString(spec.Label),
// Type: nb.CustomFieldTypeValue(spec.Type),
// ObjectTypes: spec.ObjectTypes,
// Description: nb.PtrString(spec.Description),
// }
// _, _, err := c.api.ExtrasAPI.ExtrasCustomFieldsCreate(ctx).
// WritableCustomFieldRequest(req).Execute()
// return err
return fmt.Errorf("createCustomField: implement using go-netbox v4 WritableCustomFieldRequest — check generated types at $(go env GOPATH)/pkg/mod/github.com/netbox-community/go-netbox/v4@*/model_writable_custom_field_request.go")
}
// Provision runs all provisioning steps: custom fields + location hierarchy.
func (c *Client) Provision(ctx context.Context) error {
n, err := c.ProvisionCustomFields(ctx)
if err != nil {
return fmt.Errorf("provision custom fields: %w", err)
}
log.Printf("custom fields: %d created", n)
if err := c.ProvisionLocationHierarchy(ctx); err != nil {
return fmt.Errorf("provision locations: %w", err)
}
return nil
}
```
IMPORTANT: The `createCustomField` function contains a stub that will not compile. The executor MUST:
1. Run: `ls $(go env GOPATH)/pkg/mod/github.com/netbox-community/go-netbox/v4@v4.3.0/` to find generated files
2. Run: `grep -l "WritableCustomFieldRequest" $(go env GOPATH)/pkg/mod/github.com/netbox-community/go-netbox/v4@v4.3.0/*.go`
3. Read the relevant generated file to get the exact field names and enum types
4. Replace the pseudocode with real go-netbox v4 API calls
Create `internal/netbox/provision_test.go`:
```go
package netbox
import (
"testing"
)
func TestCustomFieldSpec(t *testing.T) {
spec := customFieldSpec("hw_id")
if spec == nil {
t.Fatal("hw_id spec not found")
}
if spec.Type != "text" {
t.Errorf("hw_id type: want text, got %s", spec.Type)
}
for _, ot := range spec.ObjectTypes {
if ot == "dcim.device" {
return
}
}
t.Error("hw_id ObjectTypes must include dcim.device")
}
func TestCustomFieldSpecCatalogStatus(t *testing.T) {
spec := customFieldSpec("catalog_status")
if spec == nil {
t.Fatal("catalog_status spec not found")
}
if spec.Type != "text" {
t.Errorf("catalog_status type: want text (not selection), got %s", spec.Type)
}
}
func TestCustomFieldSpecPhotoURLs(t *testing.T) {
spec := customFieldSpec("photo_urls")
if spec == nil {
t.Fatal("photo_urls spec not found")
}
if spec.Description == "" {
t.Error("photo_urls must have a description")
}
}
func TestAllEightFieldsDefined(t *testing.T) {
expected := []string{"hw_id", "catalog_status", "product_url", "firmware_version",
"test_date", "test_data", "ai_notes", "photo_urls"}
for _, name := range expected {
if customFieldSpec(name) == nil {
t.Errorf("missing custom field spec: %s", name)
}
}
if len(hwlabCustomFields) != 8 {
t.Errorf("want 8 custom fields, got %d", len(hwlabCustomFields))
}
}
```
</action>
<verify>
<automated>cd /home/mikkel/homelabby && go test ./internal/netbox/... -v -run "TestCustomFieldSpec|TestAllEight"</automated>
</verify>
<acceptance_criteria>
- `go test ./internal/netbox/... -run "TestCustomFieldSpec|TestAllEight"` passes all 4 unit tests
- `grep -c "Name:" internal/netbox/provision.go` returns 8 or more (one per custom field)
- `grep "photo_urls" internal/netbox/provision.go` appears in hwlabCustomFields slice
- `grep "ai_notes" internal/netbox/provision.go` appears in hwlabCustomFields slice
- `createCustomField` is implemented with real go-netbox v4 API calls (not stub returning error string)
- `go build ./internal/netbox/...` exits 0
</acceptance_criteria>
<done>All 8 custom field specs defined and tested. ProvisionCustomFields implemented with idempotent check-before-create. createCustomField uses real go-netbox v4 API (not stub).</done>
</task>
<task type="auto">
<name>Task 2: Location hierarchy provisioning + NB-03 plugin check + provision CLI (NB-03, NB-04)</name>
<files>internal/netbox/provision.go, scripts/provision-netbox.go</files>
<read_first>
- /home/mikkel/homelabby/internal/netbox/provision.go (ProvisionCustomFields added in Task 1)
- /home/mikkel/homelabby/.planning/phases/01-foundation/01-RESEARCH.md (phase requirements NB-03, NB-04 lines 43-48)
- /home/mikkel/homelabby/.env (HWLAB_NETBOX_URL, HWLAB_NETBOX_TOKEN)
</read_first>
<action>
Location hierarchy to create (per ROADMAP "Site → Location → Rack per PRD section 7.6"):
- Site: name="Homelab", slug="homelab"
- Location: name="Lab Bench", slug="lab-bench", site=homelab
- Rack: name="Primary Rack", site=homelab, location=lab-bench, u_height=42
1. Add `ProvisionLocationHierarchy` to `internal/netbox/provision.go`:
```go
// ProvisionLocationHierarchy creates the Site → Location → Rack hierarchy.
// Idempotent: each level is checked before creation.
func (c *Client) ProvisionLocationHierarchy(ctx context.Context) error {
// Step 1: Create or find Site "Homelab"
siteID, err := c.ensureSite(ctx, "Homelab", "homelab")
if err != nil {
return fmt.Errorf("ensure site: %w", err)
}
// Step 2: Create or find Location "Lab Bench" under the site
locationID, err := c.ensureLocation(ctx, "Lab Bench", "lab-bench", siteID)
if err != nil {
return fmt.Errorf("ensure location: %w", err)
}
// Step 3: Create or find Rack "Primary Rack" under the site + location
if err := c.ensureRack(ctx, "Primary Rack", siteID, locationID); err != nil {
return fmt.Errorf("ensure rack: %w", err)
}
return nil
}
func (c *Client) ensureSite(ctx context.Context, name, slug string) (int32, error) {
// Check if site exists by slug
res, _, err := c.api.DcimAPI.DcimSitesList(ctx).Slug([]string{slug}).Execute()
if err != nil {
return 0, err
}
if res.GetCount() > 0 {
log.Printf("site %q already exists — skipping", name)
return res.Results[0].GetId(), nil
}
// Create site
// NOTE: Executor must use go-netbox v4 WritableSiteRequest type
// Pattern: c.api.DcimAPI.DcimSitesCreate(ctx).WritableSiteRequest(req).Execute()
// Required fields: Name (string), Slug (string)
// Status: use nb.SiteStatusValue("active") or equivalent
return 0, fmt.Errorf("ensureSite: implement using go-netbox v4 WritableSiteRequest")
}
func (c *Client) ensureLocation(ctx context.Context, name, slug string, siteID int32) (int32, error) {
res, _, err := c.api.DcimAPI.DcimLocationsList(ctx).Slug([]string{slug}).Execute()
if err != nil {
return 0, err
}
if res.GetCount() > 0 {
log.Printf("location %q already exists — skipping", name)
return res.Results[0].GetId(), nil
}
// Create location under site
// NOTE: use go-netbox v4 WritableLocationRequest
// Required: Name, Slug, Site (NestedSiteRequest with ID)
return 0, fmt.Errorf("ensureLocation: implement using go-netbox v4 WritableLocationRequest")
}
func (c *Client) ensureRack(ctx context.Context, name string, siteID, locationID int32) error {
res, _, err := c.api.DcimAPI.DcimRacksList(ctx).Name([]string{name}).Execute()
if err != nil {
return err
}
if res.GetCount() > 0 {
log.Printf("rack %q already exists — skipping", name)
return nil
}
// Create rack under site + location
// NOTE: use go-netbox v4 WritableRackRequest
// Required: Name, Site (NestedSiteRequest), Location (NestedLocationRequest), UHeight (int32)
// UHeight = 42
return fmt.Errorf("ensureRack: implement using go-netbox v4 WritableRackRequest")
}
```
EXECUTOR MUST replace all stub returns with real go-netbox v4 API calls. Locate the generated types:
```
ls $(go env GOPATH)/pkg/mod/github.com/netbox-community/go-netbox/v4@v4.3.0/model_writable_site_request.go
ls $(go env GOPATH)/pkg/mod/github.com/netbox-community/go-netbox/v4@v4.3.0/model_writable_location_request.go
ls $(go env GOPATH)/pkg/mod/github.com/netbox-community/go-netbox/v4@v4.3.0/model_writable_rack_request.go
```
2. Add NB-03 plugin check note in provision.go as a comment (NB-03 requires SSH to LXC 130 — this is a manual verification step documented in VALIDATION.md):
```go
// CheckNetBoxInventoryPlugin verifies the netbox-inventory plugin is installed.
// This check uses the NetBox plugins API endpoint.
// NB-03: netbox-inventory plugin must be installed on LXC 130.
// Manual verification: SSH to LXC 130, run: pip show netbox-inventory
// API check: GET /api/plugins/ lists installed plugin API endpoints.
func (c *Client) CheckNetBoxInventoryPlugin(ctx context.Context) (bool, error) {
// The netbox-inventory plugin registers under /api/plugins/inventory/
// We can check by hitting that endpoint and seeing if we get a 200 vs 404.
resp, err := c.api.GetConfig().HTTPClient.Get(c.url[:len(c.url)-4] + "/api/plugins/")
if err != nil {
return false, err
}
defer resp.Body.Close()
return resp.StatusCode == 200, nil
}
```
3. Create `scripts/provision-netbox.go` — standalone CLI script:
```go
//go:build ignore
// Run with: go run scripts/provision-netbox.go
// Provisions NetBox with all HWLab custom fields and location hierarchy.
// Reads HWLAB_NETBOX_URL and HWLAB_NETBOX_TOKEN from environment (.env auto-loaded).
package main
import (
"context"
"log"
"os"
"github.com/joho/godotenv"
"git.georgsen.dk/hwlab/internal/netbox"
)
func main() {
// Load .env
if err := godotenv.Load(); err != nil {
log.Printf("no .env file: %v", err)
}
url := os.Getenv("HWLAB_NETBOX_URL")
token := os.Getenv("HWLAB_NETBOX_TOKEN")
if url == "" || token == "" {
log.Fatal("HWLAB_NETBOX_URL and HWLAB_NETBOX_TOKEN must be set")
}
client, err := netbox.NewClient(url, token)
if err != nil {
log.Fatalf("netbox client: %v", err)
}
ctx := context.Background()
log.Println("Provisioning NetBox...")
if err := client.Provision(ctx); err != nil {
log.Fatalf("provision: %v", err)
}
// Check netbox-inventory plugin (NB-03)
ok, err := client.CheckNetBoxInventoryPlugin(ctx)
if err != nil {
log.Printf("plugin check error: %v", err)
} else if ok {
log.Println("netbox-inventory plugin: INSTALLED")
} else {
log.Println("WARNING: netbox-inventory plugin may not be installed")
log.Println(" Manual check: SSH to LXC 130, run: pip show netbox-inventory")
log.Println(" Install if missing: pip install netbox-inventory")
}
log.Println("Provisioning complete.")
}
```
</action>
<verify>
<automated>cd /home/mikkel/homelabby && go build ./internal/netbox/... && go vet ./internal/netbox/...</automated>
</verify>
<acceptance_criteria>
- `go build ./internal/netbox/...` exits 0 (all stubs replaced with real go-netbox v4 API calls)
- `go vet ./internal/netbox/...` exits 0 (no vet errors)
- `grep "ProvisionLocationHierarchy" internal/netbox/provision.go` returns the exported function
- `grep "ensureSite\|ensureLocation\|ensureRack" internal/netbox/provision.go` returns 3 matches (not stub errors)
- `grep "CheckNetBoxInventoryPlugin" internal/netbox/provision.go` returns the function
- File `scripts/provision-netbox.go` exists
- `grep "go:build ignore" scripts/provision-netbox.go` returns a match (build tag prevents accidental inclusion)
- If real token available: `go run scripts/provision-netbox.go` exits 0 and logs "Provisioning complete."
- If real token available: `curl -H "Authorization: Token REAL_TOKEN" http://10.5.0.130:8000/api/extras/custom-fields/?name=hw_id` returns count > 0
</acceptance_criteria>
<done>Location hierarchy provisioning implemented. Provision CLI script created. All functions use real go-netbox v4 API (no stubs). `go build` and `go vet` clean.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| scripts/provision-netbox.go → NetBox API | Write access to NetBox via authenticated REST API |
| Provisioning logic → NetBox admin state | Creates objects in NetBox; idempotency prevents data corruption |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-03-01 | Tampering | ProvisionCustomFields idempotency | mitigate | Check-before-create pattern ensures no duplicates; NetBox also enforces unique names on custom fields |
| T-03-02 | Information Disclosure | scripts/provision-netbox.go logs | accept | Logs go to stdout only; no sensitive data logged beyond field names |
| T-03-03 | Denial of Service | Provisioning script run in loop | accept | Script has `//go:build ignore` tag, requires explicit `go run`; single operator context |
| T-03-04 | Elevation of Privilege | Provisioning requires NetBox admin rights | accept | NetBox token grants admin-level access by design; this is the operator's own homelab |
</threat_model>
<verification>
After both tasks complete:
- `go test ./internal/netbox/... -v` all tests pass (unit) or skip (integration without real token)
- `go build ./...` exits 0
- `go vet ./...` exits 0
- If real token: `go run scripts/provision-netbox.go` provisions cleanly
- If real token: `curl -s -H "Authorization: Token $HWLAB_NETBOX_TOKEN" "http://10.5.0.130:8000/api/extras/custom-fields/" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['count'])"` shows 8 or more custom fields
</verification>
<success_criteria>
1. All 8 custom field specs defined in hwlabCustomFields slice with correct names, types, object_types
2. ProvisionCustomFields is idempotent (check-before-create)
3. ProvisionLocationHierarchy creates Site "Homelab" → Location "Lab Bench" → Rack "Primary Rack"
4. Standalone script runs with `go run scripts/provision-netbox.go` against live NetBox
5. `go build` and `go vet` clean on all packages
</success_criteria>
<output>
After completion, create `.planning/phases/01-foundation/01-03-SUMMARY.md` with:
- Whether provisioning ran against live NetBox (yes/no)
- If yes: which custom fields were created vs. already existed
- If yes: whether netbox-inventory plugin was detected
- Any go-netbox v4 API quirks encountered (WritableSiteRequest fields, slug handling, etc.)
- Files created/modified
</output>