- 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
217 lines
8.1 KiB
Go
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
|
|
}
|