From 9f3ed9fddcb01a0b3d660d7e0bc743a2d26ca185 Mon Sep 17 00:00:00 2001 From: Mikkel Georgsen Date: Fri, 10 Apr 2026 05:16:17 +0000 Subject: [PATCH] feat(01-02): NetBox client wrapper with device CRUD (NB-01) - Add internal/netbox/types.go with Device and CustomFields domain types - Add internal/netbox/client.go with NewClient, Ping, ListDevices, GetDevice - Add client_test.go with validation unit tests and skippable integration tests - go-netbox v4.3.0 dependency added --- go.mod | 2 + go.sum | 4 ++ internal/netbox/client.go | 87 ++++++++++++++++++++++++++++++++++ internal/netbox/client_test.go | 62 ++++++++++++++++++++++++ internal/netbox/types.go | 26 ++++++++++ 5 files changed, 181 insertions(+) create mode 100644 internal/netbox/client.go create mode 100644 internal/netbox/client_test.go create mode 100644 internal/netbox/types.go diff --git a/go.mod b/go.mod index ad9bf7a..631923c 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( require ( github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/netbox-community/go-netbox/v4 v4.3.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect @@ -22,4 +23,5 @@ require ( golang.org/x/sys v0.29.0 // indirect golang.org/x/text v0.28.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect + gopkg.in/validator.v2 v2.0.1 // indirect ) diff --git a/go.sum b/go.sum index 065561d..798f566 100644 --- a/go.sum +++ b/go.sum @@ -19,6 +19,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/netbox-community/go-netbox/v4 v4.3.0 h1:1kYHscOJG8+GJobC9OdgXX39zBKrBzUE5bxwMgxdlaQ= +github.com/netbox-community/go-netbox/v4 v4.3.0/go.mod h1:1r1Dhs2sGD3izwvOBZwggFiEGLvyQ5hNgFR16nxsixg= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -50,5 +52,7 @@ golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/validator.v2 v2.0.1 h1:xF0KWyGWXm/LM2G1TrEjqOu4pa6coO9AlWSf3msVfDY= +gopkg.in/validator.v2 v2.0.1/go.mod h1:lIUZBlB3Im4s/eYp39Ry/wkR02yOPhZ9IwIRBjuPuG8= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/netbox/client.go b/internal/netbox/client.go new file mode 100644 index 0000000..d29fccb --- /dev/null +++ b/internal/netbox/client.go @@ -0,0 +1,87 @@ +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 +} + +// 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 +} diff --git a/internal/netbox/client_test.go b/internal/netbox/client_test.go new file mode 100644 index 0000000..e9d4693 --- /dev/null +++ b/internal/netbox/client_test.go @@ -0,0 +1,62 @@ +package netbox_test + +import ( + "context" + "os" + "testing" + + "git.georgsen.dk/hwlab/internal/netbox" +) + +func TestNewClientValidation(t *testing.T) { + _, err := netbox.NewClient("", "token") + if err == nil { + t.Error("expected error for empty url") + } + + _, err = netbox.NewClient("http://10.5.0.130:8000/api", "") + if err == nil { + t.Error("expected error for empty token") + } + + c, err := netbox.NewClient("http://10.5.0.130:8000/api", "sometoken") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if c == nil { + t.Error("expected non-nil client") + } +} + +// integrationToken returns the real NetBox token from env, or skips the test +// if only the placeholder is present (placeholder is never 40 hex chars). +func integrationToken(t *testing.T) string { + t.Helper() + token := os.Getenv("HWLAB_NETBOX_TOKEN") + if len(token) != 40 { + t.Skip("HWLAB_NETBOX_TOKEN is not a real 40-char token — skipping integration test") + } + return token +} + +func TestPingLive(t *testing.T) { + token := integrationToken(t) + c, err := netbox.NewClient("http://10.5.0.130:8000/api", token) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + if err := c.Ping(context.Background()); err != nil { + t.Fatalf("Ping: %v", err) + } +} + +func TestListDevicesLive(t *testing.T) { + token := integrationToken(t) + c, _ := netbox.NewClient("http://10.5.0.130:8000/api", token) + devices, err := c.ListDevices(context.Background(), 5) + if err != nil { + t.Fatalf("ListDevices: %v", err) + } + t.Logf("found %d devices in NetBox", len(devices)) + // Not asserting count — NetBox may be empty; just assert no error +} diff --git a/internal/netbox/types.go b/internal/netbox/types.go new file mode 100644 index 0000000..3d96c6c --- /dev/null +++ b/internal/netbox/types.go @@ -0,0 +1,26 @@ +package netbox + +import "time" + +// Device represents a HWLab inventory item backed by a NetBox device record. +type Device struct { + ID int + Name string + AssetTag string // HW-XXXXX identifier + CustomFields CustomFields + Created time.Time + LastUpdated time.Time +} + +// CustomFields holds all HWLab-defined NetBox custom field values for a device. +// NetBox returns these as map[string]interface{} — we provide typed access. +type CustomFields struct { + HWID string // hw_id + CatalogStatus string // catalog_status + ProductURL string // product_url + FirmwareVersion string // firmware_version + TestDate string // test_date (ISO 8601 date string) + TestData string // test_data (JSON string) + AINotes string // ai_notes + PhotoURLs []string // photo_urls (multi-value) +}