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 }