homelabby/internal/netbox/provision.go
Mikkel Georgsen 9b4cc9a661 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
2026-04-10 05:21:23 +00:00

217 lines
8.1 KiB
Go

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
}