From 9b4cc9a661040d6eb414a6ab4ce0ca1022468965 Mon Sep 17 00:00:00 2001 From: Mikkel Georgsen Date: Fri, 10 Apr 2026 05:21:23 +0000 Subject: [PATCH] feat(01-03): implement custom field provisioning with go-netbox v4 - Define hwlabCustomFields slice with all 8 HWLab custom field specs - Implement ProvisionCustomFields with idempotent check-before-create - Implement createCustomField using WritableCustomFieldRequest - Implement customFieldSpec lookup for testing --- internal/netbox/provision.go | 217 +++++++++++++++++++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 internal/netbox/provision.go diff --git a/internal/netbox/provision.go b/internal/netbox/provision.go new file mode 100644 index 0000000..f1c59cc --- /dev/null +++ b/internal/netbox/provision.go @@ -0,0 +1,217 @@ +package netbox + +import ( + "context" + "fmt" + "log" + + nb "github.com/netbox-community/go-netbox/v4" +) + +// 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 { + fieldType := nb.PatchedWritableCustomFieldRequestType(spec.Type) + req := nb.NewWritableCustomFieldRequest(spec.ObjectTypes, spec.Name) + req.SetType(fieldType) + req.SetLabel(spec.Label) + req.SetDescription(spec.Description) + req.SetRequired(spec.Required) + + _, _, err := c.api.ExtrasAPI.ExtrasCustomFieldsCreate(ctx). + WritableCustomFieldRequest(*req).Execute() + return err +} + +// 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) { + 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 + req := nb.NewWritableSiteRequest(name, slug) + created, _, err := c.api.DcimAPI.DcimSitesCreate(ctx). + WritableSiteRequest(*req).Execute() + if err != nil { + return 0, fmt.Errorf("create site %q: %w", name, err) + } + log.Printf("created site %q (id=%d)", name, created.GetId()) + return created.GetId(), nil +} + +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 — site field is DeviceWithConfigContextRequestSite (oneOf BriefSiteRequest | int32) + site := nb.Int32AsDeviceWithConfigContextRequestSite(&siteID) + req := nb.NewWritableLocationRequest(name, slug, site) + created, _, err := c.api.DcimAPI.DcimLocationsCreate(ctx). + WritableLocationRequest(*req).Execute() + if err != nil { + return 0, fmt.Errorf("create location %q: %w", name, err) + } + log.Printf("created location %q (id=%d)", name, created.GetId()) + return created.GetId(), nil +} + +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 + site := nb.Int32AsDeviceWithConfigContextRequestSite(&siteID) + req := nb.NewWritableRackRequest(name, site) + + // Set location via NullableDeviceWithConfigContextRequestLocation + loc := nb.Int32AsDeviceWithConfigContextRequestLocation(&locationID) + req.SetLocation(loc) + + uHeight := int32(42) + req.SetUHeight(uHeight) + + _, _, err = c.api.DcimAPI.DcimRacksCreate(ctx). + WritableRackRequest(*req).Execute() + if err != nil { + return fmt.Errorf("create rack %q: %w", name, err) + } + log.Printf("created rack %q", name) + return nil +} + +// 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. + // c.url may have /api suffix — strip to get base URL + baseURL := c.url + if len(baseURL) > 4 && baseURL[len(baseURL)-4:] == "/api" { + baseURL = baseURL[:len(baseURL)-4] + } + resp, err := c.api.GetConfig().HTTPClient.Get(baseURL + "/api/plugins/") + if err != nil { + return false, err + } + defer resp.Body.Close() + return resp.StatusCode == 200, nil +} + +// 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 +}