Add core modules (SSH args parser, cache, resolver, NetBox client) with tests
Release / release (push) Failing after 51s
Release / release (push) Failing after 51s
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
package netbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
baseURL string
|
||||
token string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewClient(baseURL, token string) *Client {
|
||||
return &Client{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
token: token,
|
||||
httpClient: &http.Client{},
|
||||
}
|
||||
}
|
||||
|
||||
// Search queries devices and VMs in parallel and merges the results.
|
||||
func (c *Client) Search(ctx context.Context, query string) ([]HostEntry, error) {
|
||||
var (
|
||||
mu sync.Mutex
|
||||
results []HostEntry
|
||||
errs []error
|
||||
wg sync.WaitGroup
|
||||
)
|
||||
|
||||
wg.Add(2)
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
devices, err := c.searchDevices(ctx, query)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("devices: %w", err))
|
||||
return
|
||||
}
|
||||
results = append(results, devices...)
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
vms, err := c.searchVMs(ctx, query)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("vms: %w", err))
|
||||
return
|
||||
}
|
||||
results = append(results, vms...)
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
|
||||
if len(errs) == 2 {
|
||||
return nil, fmt.Errorf("netbox search failed: %v; %v", errs[0], errs[1])
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// GetIPs returns all IP addresses assigned to a host, used by resolver strategies
|
||||
// that need more than just the primary IP.
|
||||
func (c *Client) GetIPs(ctx context.Context, entry HostEntry) ([]string, error) {
|
||||
var apiURL string
|
||||
switch entry.Kind {
|
||||
case "device":
|
||||
apiURL = fmt.Sprintf("%s/api/ipam/ip-addresses/?device_id=%d&limit=100", c.baseURL, entry.ID)
|
||||
case "vm":
|
||||
apiURL = fmt.Sprintf("%s/api/ipam/ip-addresses/?virtual_machine_id=%d&limit=100", c.baseURL, entry.ID)
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown host kind: %q", entry.Kind)
|
||||
}
|
||||
|
||||
var resp netboxIPListResponse
|
||||
if err := c.get(ctx, apiURL, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ips := make([]string, 0, len(resp.Results))
|
||||
for _, r := range resp.Results {
|
||||
ips = append(ips, stripPrefix(r.Address))
|
||||
}
|
||||
return ips, nil
|
||||
}
|
||||
|
||||
// GetIPsWithFilter calls /api/ipam/ip-addresses/ with arbitrary filter query parameters.
|
||||
func (c *Client) GetIPsWithFilter(ctx context.Context, filterParams string) ([]string, error) {
|
||||
apiURL := fmt.Sprintf("%s/api/ipam/ip-addresses/?%s&limit=100", c.baseURL, filterParams)
|
||||
var resp netboxIPListResponse
|
||||
if err := c.get(ctx, apiURL, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ips := make([]string, 0, len(resp.Results))
|
||||
for _, r := range resp.Results {
|
||||
ips = append(ips, stripPrefix(r.Address))
|
||||
}
|
||||
return ips, nil
|
||||
}
|
||||
|
||||
func (c *Client) searchDevices(ctx context.Context, query string) ([]HostEntry, error) {
|
||||
apiURL := fmt.Sprintf("%s/api/dcim/devices/?name__ic=%s&limit=50", c.baseURL, url.QueryEscape(query))
|
||||
var resp netboxListResponse[netboxDevice]
|
||||
if err := c.get(ctx, apiURL, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries := make([]HostEntry, 0, len(resp.Results))
|
||||
for _, d := range resp.Results {
|
||||
entries = append(entries, deviceToEntry(d))
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (c *Client) searchVMs(ctx context.Context, query string) ([]HostEntry, error) {
|
||||
apiURL := fmt.Sprintf("%s/api/virtualization/virtual-machines/?name__ic=%s&limit=50", c.baseURL, url.QueryEscape(query))
|
||||
var resp netboxListResponse[netboxVM]
|
||||
if err := c.get(ctx, apiURL, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries := make([]HostEntry, 0, len(resp.Results))
|
||||
for _, v := range resp.Results {
|
||||
entries = append(entries, vmToEntry(v))
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (c *Client) get(ctx context.Context, apiURL string, out any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Token "+c.token)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("request to %s: %w", apiURL, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("netbox returned %d for %s", resp.StatusCode, apiURL)
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
||||
return fmt.Errorf("decoding response: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func deviceToEntry(d netboxDevice) HostEntry {
|
||||
e := HostEntry{ID: d.ID, Name: d.Name, Kind: "device"}
|
||||
if d.PrimaryIP4 != nil {
|
||||
e.PrimaryIP4 = stripPrefix(d.PrimaryIP4.Address)
|
||||
}
|
||||
if d.PrimaryIP6 != nil {
|
||||
e.PrimaryIP6 = stripPrefix(d.PrimaryIP6.Address)
|
||||
}
|
||||
for _, t := range d.Tags {
|
||||
e.Tags = append(e.Tags, t.Name)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
func vmToEntry(v netboxVM) HostEntry {
|
||||
e := HostEntry{ID: v.ID, Name: v.Name, Kind: "vm"}
|
||||
if v.PrimaryIP4 != nil {
|
||||
e.PrimaryIP4 = stripPrefix(v.PrimaryIP4.Address)
|
||||
}
|
||||
if v.PrimaryIP6 != nil {
|
||||
e.PrimaryIP6 = stripPrefix(v.PrimaryIP6.Address)
|
||||
}
|
||||
for _, t := range v.Tags {
|
||||
e.Tags = append(e.Tags, t.Name)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// stripPrefix removes the CIDR prefix length from a NetBox IP (e.g. "10.0.1.5/24" → "10.0.1.5").
|
||||
func stripPrefix(cidr string) string {
|
||||
if idx := strings.Index(cidr, "/"); idx != -1 {
|
||||
return cidr[:idx]
|
||||
}
|
||||
return cidr
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package netbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// newTestServer returns an httptest.Server that serves fixed responses per path.
|
||||
func newTestServer(t *testing.T, handlers map[string]any) *httptest.Server {
|
||||
t.Helper()
|
||||
mux := http.NewServeMux()
|
||||
for path, body := range handlers {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatalf("marshalling handler for %s: %v", path, err)
|
||||
}
|
||||
captured := b
|
||||
mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write(captured)
|
||||
})
|
||||
}
|
||||
return httptest.NewServer(mux)
|
||||
}
|
||||
|
||||
func deviceListResponse(devices ...netboxDevice) netboxListResponse[netboxDevice] {
|
||||
return netboxListResponse[netboxDevice]{Count: len(devices), Results: devices}
|
||||
}
|
||||
|
||||
func vmListResponse(vms ...netboxVM) netboxListResponse[netboxVM] {
|
||||
return netboxListResponse[netboxVM]{Count: len(vms), Results: vms}
|
||||
}
|
||||
|
||||
func ipListResponse(addrs ...string) netboxIPListResponse {
|
||||
resp := netboxIPListResponse{Count: len(addrs)}
|
||||
for _, a := range addrs {
|
||||
resp.Results = append(resp.Results, struct {
|
||||
Address string `json:"address"`
|
||||
Interface *struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"assigned_object"`
|
||||
}{Address: a})
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func TestSearch_ReturnsBothDevicesAndVMs(t *testing.T) {
|
||||
srv := newTestServer(t, map[string]any{
|
||||
"/api/dcim/devices/": deviceListResponse(
|
||||
netboxDevice{ID: 1, Name: "router-01", PrimaryIP4: &netboxIP{Address: "10.0.0.1/24"}},
|
||||
),
|
||||
"/api/virtualization/virtual-machines/": vmListResponse(
|
||||
netboxVM{ID: 2, Name: "vm-01", PrimaryIP4: &netboxIP{Address: "10.0.0.2/24"}},
|
||||
),
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL, "token")
|
||||
results, err := c.Search(context.Background(), "")
|
||||
if err != nil {
|
||||
t.Fatalf("Search: %v", err)
|
||||
}
|
||||
if len(results) != 2 {
|
||||
t.Errorf("got %d results, want 2", len(results))
|
||||
}
|
||||
|
||||
names := map[string]bool{}
|
||||
for _, r := range results {
|
||||
names[r.Name] = true
|
||||
}
|
||||
if !names["router-01"] || !names["vm-01"] {
|
||||
t.Errorf("missing expected hosts in results: %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearch_MapsKindCorrectly(t *testing.T) {
|
||||
srv := newTestServer(t, map[string]any{
|
||||
"/api/dcim/devices/": deviceListResponse(
|
||||
netboxDevice{ID: 1, Name: "sw-01"},
|
||||
),
|
||||
"/api/virtualization/virtual-machines/": vmListResponse(
|
||||
netboxVM{ID: 2, Name: "vm-01"},
|
||||
),
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL, "token")
|
||||
results, _ := c.Search(context.Background(), "")
|
||||
|
||||
for _, r := range results {
|
||||
switch r.Name {
|
||||
case "sw-01":
|
||||
if r.Kind != "device" {
|
||||
t.Errorf("sw-01 kind: got %q, want %q", r.Kind, "device")
|
||||
}
|
||||
case "vm-01":
|
||||
if r.Kind != "vm" {
|
||||
t.Errorf("vm-01 kind: got %q, want %q", r.Kind, "vm")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearch_StripsPrefixFromPrimaryIP(t *testing.T) {
|
||||
srv := newTestServer(t, map[string]any{
|
||||
"/api/dcim/devices/": deviceListResponse(
|
||||
netboxDevice{ID: 1, Name: "host", PrimaryIP4: &netboxIP{Address: "192.168.1.10/24"}},
|
||||
),
|
||||
"/api/virtualization/virtual-machines/": vmListResponse(),
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL, "token")
|
||||
results, _ := c.Search(context.Background(), "host")
|
||||
if len(results) == 0 {
|
||||
t.Fatal("expected at least one result")
|
||||
}
|
||||
if results[0].PrimaryIP4 != "192.168.1.10" {
|
||||
t.Errorf("PrimaryIP4: got %q, want %q", results[0].PrimaryIP4, "192.168.1.10")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearch_TagsAreMapped(t *testing.T) {
|
||||
srv := newTestServer(t, map[string]any{
|
||||
"/api/dcim/devices/": deviceListResponse(
|
||||
netboxDevice{
|
||||
ID: 1,
|
||||
Name: "host",
|
||||
Tags: []struct {
|
||||
Name string `json:"name"`
|
||||
}{{Name: "prod"}, {Name: "mgmt"}},
|
||||
},
|
||||
),
|
||||
"/api/virtualization/virtual-machines/": vmListResponse(),
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL, "token")
|
||||
results, _ := c.Search(context.Background(), "")
|
||||
if len(results[0].Tags) != 2 {
|
||||
t.Errorf("tags: got %v, want [prod mgmt]", results[0].Tags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearch_PartialFailure_ReturnsAvailableResults(t *testing.T) {
|
||||
// Only devices endpoint works; VMs returns 500.
|
||||
mux := http.NewServeMux()
|
||||
body, _ := json.Marshal(deviceListResponse(netboxDevice{ID: 1, Name: "sw-01"}))
|
||||
mux.HandleFunc("/api/dcim/devices/", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write(body)
|
||||
})
|
||||
mux.HandleFunc("/api/virtualization/virtual-machines/", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL, "token")
|
||||
results, err := c.Search(context.Background(), "")
|
||||
if err != nil {
|
||||
t.Fatalf("partial failure should not return error, got: %v", err)
|
||||
}
|
||||
if len(results) != 1 || results[0].Name != "sw-01" {
|
||||
t.Errorf("expected device results, got %v", results)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearch_BothFail_ReturnsError(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "error", http.StatusInternalServerError)
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL, "token")
|
||||
_, err := c.Search(context.Background(), "")
|
||||
if err == nil {
|
||||
t.Error("both endpoints failing should return an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetIPs_Device(t *testing.T) {
|
||||
srv := newTestServer(t, map[string]any{
|
||||
"/api/ipam/ip-addresses/": ipListResponse("10.0.0.1/24", "10.0.0.2/24"),
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL, "token")
|
||||
ips, err := c.GetIPs(context.Background(), HostEntry{ID: 1, Kind: "device"})
|
||||
if err != nil {
|
||||
t.Fatalf("GetIPs: %v", err)
|
||||
}
|
||||
if len(ips) != 2 {
|
||||
t.Errorf("got %d IPs, want 2", len(ips))
|
||||
}
|
||||
if ips[0] != "10.0.0.1" || ips[1] != "10.0.0.2" {
|
||||
t.Errorf("IPs: got %v, want [10.0.0.1 10.0.0.2]", ips)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetIPs_VM(t *testing.T) {
|
||||
srv := newTestServer(t, map[string]any{
|
||||
"/api/ipam/ip-addresses/": ipListResponse("172.16.0.5/16"),
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL, "token")
|
||||
ips, err := c.GetIPs(context.Background(), HostEntry{ID: 2, Kind: "vm"})
|
||||
if err != nil {
|
||||
t.Fatalf("GetIPs: %v", err)
|
||||
}
|
||||
if len(ips) != 1 || ips[0] != "172.16.0.5" {
|
||||
t.Errorf("IPs: got %v, want [172.16.0.5]", ips)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetIPs_UnknownKind(t *testing.T) {
|
||||
c := NewClient("http://localhost", "token")
|
||||
_, err := c.GetIPs(context.Background(), HostEntry{ID: 1, Kind: "unknown"})
|
||||
if err == nil {
|
||||
t.Error("unknown kind should return an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetIPsWithFilter(t *testing.T) {
|
||||
srv := newTestServer(t, map[string]any{
|
||||
"/api/ipam/ip-addresses/": ipListResponse("10.10.10.1/24"),
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL, "token")
|
||||
ips, err := c.GetIPsWithFilter(context.Background(), "device_id=1&interface_name=mgmt0")
|
||||
if err != nil {
|
||||
t.Fatalf("GetIPsWithFilter: %v", err)
|
||||
}
|
||||
if len(ips) != 1 || ips[0] != "10.10.10.1" {
|
||||
t.Errorf("IPs: got %v, want [10.10.10.1]", ips)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripPrefix(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"10.0.0.1/24", "10.0.0.1"},
|
||||
{"::1/128", "::1"},
|
||||
{"192.168.1.1", "192.168.1.1"}, // no prefix — unchanged
|
||||
{"", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := stripPrefix(tt.in); got != tt.want {
|
||||
t.Errorf("stripPrefix(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package netbox
|
||||
|
||||
// HostEntry is a unified model for both devices and virtual machines from NetBox.
|
||||
type HostEntry struct {
|
||||
ID int
|
||||
Name string
|
||||
Kind string // "device" | "vm"
|
||||
PrimaryIP4 string // e.g. "10.0.1.5" (prefix length stripped)
|
||||
PrimaryIP6 string
|
||||
Tags []string
|
||||
}
|
||||
|
||||
// netboxIP represents an IP address as returned by the NetBox API.
|
||||
type netboxIP struct {
|
||||
Address string `json:"address"` // CIDR notation, e.g. "10.0.1.5/24"
|
||||
}
|
||||
|
||||
// netboxDevice matches the relevant fields of the NetBox /dcim/devices/ response.
|
||||
type netboxDevice struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Tags []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"tags"`
|
||||
PrimaryIP4 *netboxIP `json:"primary_ip4"`
|
||||
PrimaryIP6 *netboxIP `json:"primary_ip6"`
|
||||
}
|
||||
|
||||
// netboxVM matches the relevant fields of the NetBox /virtualization/virtual-machines/ response.
|
||||
type netboxVM struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Tags []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"tags"`
|
||||
PrimaryIP4 *netboxIP `json:"primary_ip4"`
|
||||
PrimaryIP6 *netboxIP `json:"primary_ip6"`
|
||||
}
|
||||
|
||||
type netboxListResponse[T any] struct {
|
||||
Count int `json:"count"`
|
||||
Results []T `json:"results"`
|
||||
}
|
||||
|
||||
type netboxIPListResponse struct {
|
||||
Count int `json:"count"`
|
||||
Results []struct {
|
||||
Address string `json:"address"`
|
||||
Interface *struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"assigned_object"`
|
||||
} `json:"results"`
|
||||
}
|
||||
Reference in New Issue
Block a user