homelabby/internal/netbox/client.go
Mikkel Georgsen 7908d40af3 feat(05-02): add CreateCable method and CableRecord type to netbox client
- CableRecord type added to types.go (ID, HWID, Label, TestData, CatalogStatus)
- CreateCable(ctx, label, assetTag, testDataJSON) uses DcimCablesCreate
- Sets test_data and catalog_status custom fields; hw_id if assetTag non-empty
- Rejects empty label with sentinel error message
- Unit tests use httptest.NewUnstartedServer (201 success, 422 error, empty label)
2026-04-10 07:12:00 +00:00

147 lines
4.8 KiB
Go

package netbox
import (
"context"
"errors"
"fmt"
nb "github.com/netbox-community/go-netbox/v4"
)
// Client wraps go-netbox v4 APIClient with typed HWLab methods.
// All NetBox calls MUST go through this Client — no direct go-netbox calls in other packages.
type Client struct {
api *nb.APIClient
url string
}
// NewClient creates a configured NetBox client. Returns error if url or token is empty.
func NewClient(url, token string) (*Client, error) {
if url == "" {
return nil, errors.New("netbox url is required")
}
if token == "" {
return nil, errors.New("netbox token is required")
}
// Note: NewAPIClientFor accepts the base URL WITHOUT /api suffix.
// Strip trailing /api if present to avoid double-appending.
baseURL := url
if len(baseURL) > 4 && baseURL[len(baseURL)-4:] == "/api" {
baseURL = baseURL[:len(baseURL)-4]
}
api := nb.NewAPIClientFor(baseURL, token)
return &Client{api: api, url: url}, nil
}
// Ping verifies the NetBox API is reachable by making a lightweight list call.
// Returns nil on success.
func (c *Client) Ping(ctx context.Context) error {
_, resp, err := c.api.DcimAPI.DcimDevicesList(ctx).Limit(1).Execute()
if err != nil {
return fmt.Errorf("netbox ping: %w", err)
}
if resp.StatusCode >= 500 {
return fmt.Errorf("netbox ping: server error %d", resp.StatusCode)
}
return nil
}
// ListDevices returns up to limit devices from NetBox.
func (c *Client) ListDevices(ctx context.Context, limit int) ([]Device, error) {
if limit <= 0 {
limit = 50
}
res, _, err := c.api.DcimAPI.DcimDevicesList(ctx).Limit(int32(limit)).Execute()
if err != nil {
return nil, fmt.Errorf("list devices: %w", err)
}
devices := make([]Device, 0, len(res.Results))
for _, d := range res.Results {
devices = append(devices, deviceFromNetBox(d))
}
return devices, nil
}
// GetDevice retrieves a single device by its NetBox internal ID.
func (c *Client) GetDevice(ctx context.Context, id int) (*Device, error) {
d, _, err := c.api.DcimAPI.DcimDevicesRetrieve(ctx, int32(id)).Execute()
if err != nil {
return nil, fmt.Errorf("get device %d: %w", id, err)
}
dev := deviceFromNetBox(*d)
return &dev, nil
}
// CreateDevice creates a new device in NetBox with the given name and asset tag.
// deviceTypeID, roleID, and siteID must be valid NetBox IDs (pre-existing objects).
// Returns the new device's NetBox ID or error.
func (c *Client) CreateDevice(ctx context.Context, name, assetTag string, deviceTypeID, roleID, siteID int32) (int64, error) {
if name == "" {
return 0, fmt.Errorf("device name must not be empty")
}
// go-netbox v4 uses oneOf wrapper types for FK fields — use the Int32As* helpers.
dtID := nb.Int32AsDeviceBayTemplateRequestDeviceType(&deviceTypeID)
role := nb.Int32AsDeviceWithConfigContextRequestRole(&roleID)
site := nb.Int32AsDeviceWithConfigContextRequestSite(&siteID)
req := nb.NewWritableDeviceWithConfigContextRequest(dtID, role, site)
req.SetName(name)
if assetTag != "" {
req.SetAssetTag(assetTag)
}
result, _, err := c.api.DcimAPI.DcimDevicesCreate(ctx).
WritableDeviceWithConfigContextRequest(*req).Execute()
if err != nil {
return 0, fmt.Errorf("CreateDevice %q: %w", name, err)
}
return int64(result.GetId()), nil
}
// CreateCable creates a new cable record in NetBox with the given label, optional asset tag,
// and test data JSON. Returns the new cable's NetBox ID or an error.
// The test_data and catalog_status custom fields are set from testDataJSON and "complete".
func (c *Client) CreateCable(ctx context.Context, label, assetTag, testDataJSON string) (int64, error) {
if label == "" {
return 0, fmt.Errorf("cable label must not be empty")
}
req := nb.NewWritableCableRequest()
req.SetLabel(label)
customFields := map[string]interface{}{
"test_data": testDataJSON,
"catalog_status": "complete",
}
if assetTag != "" {
customFields["hw_id"] = assetTag
}
req.SetCustomFields(customFields)
result, _, err := c.api.DcimAPI.DcimCablesCreate(ctx).
WritableCableRequest(*req).Execute()
if err != nil {
return 0, fmt.Errorf("CreateCable %q: %w", label, err)
}
return int64(result.GetId()), nil
}
// DeleteDevice removes a device from NetBox by its internal ID.
// Used primarily for test cleanup after CreateDevice integration tests.
func (c *Client) DeleteDevice(ctx context.Context, id int64) error {
_, err := c.api.DcimAPI.DcimDevicesDestroy(ctx, int32(id)).Execute()
if err != nil {
return fmt.Errorf("DeleteDevice %d: %w", id, err)
}
return nil
}
// deviceFromNetBox maps a go-netbox DeviceWithConfigContext to our Device type.
// Custom fields are mapped separately via ParseCustomFields.
func deviceFromNetBox(d nb.DeviceWithConfigContext) Device {
dev := Device{
ID: int(d.GetId()),
Name: d.GetName(),
}
if tag := d.GetAssetTag(); tag != "" {
dev.AssetTag = tag
}
dev.CustomFields = ParseCustomFields(d.GetCustomFields())
return dev
}