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
This commit is contained in:
parent
6595e345a2
commit
9f3ed9fddc
5 changed files with 181 additions and 0 deletions
2
go.mod
2
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
|
||||
)
|
||||
|
|
|
|||
4
go.sum
4
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=
|
||||
|
|
|
|||
87
internal/netbox/client.go
Normal file
87
internal/netbox/client.go
Normal file
|
|
@ -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
|
||||
}
|
||||
62
internal/netbox/client_test.go
Normal file
62
internal/netbox/client_test.go
Normal file
|
|
@ -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
|
||||
}
|
||||
26
internal/netbox/types.go
Normal file
26
internal/netbox/types.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue