Add local and NetBox server notes and improve reliability
Release / release (push) Successful in 6m52s

This commit is contained in:
Sebastian Unterschütz committed 2026-10-27 17:51:59 +01:00
1 parent 2852dff2c4
commit 12cc64fdd9
17 files changed
+848 -83

No files matched your search

+99 -17
View File
@@ -1,13 +1,17 @@
package netbox
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
type Client struct {
@@ -27,13 +31,16 @@ func NewClient(baseURL, token string, tokenVersion int) *Client {
baseURL: strings.TrimRight(baseURL, "/"),
token: token,
tokenVersion: tokenVersion,
httpClient: &http.Client{},
httpClient: &http.Client{Timeout: 15 * time.Second},
}
}
// Search queries up to 50 devices and VMs in parallel and merges the results.
// Use SearchOptions to restrict by kind or tag.
func (c *Client) Search(ctx context.Context, query string, opts SearchOptions) ([]HostEntry, error) {
if opts.Kind != "" && opts.Kind != "device" && opts.Kind != "vm" {
return nil, fmt.Errorf("invalid host kind %q", opts.Kind)
}
var (
mu sync.Mutex
results []HostEntry
@@ -88,16 +95,17 @@ func (c *Client) Search(ctx context.Context, query string, opts SearchOptions) (
// SearchAll paginates through all matching devices and VMs, fetching every page.
// Intended for cache refresh; use Search for interactive queries.
func (c *Client) SearchAll(ctx context.Context, query string, opts SearchOptions) ([]HostEntry, error) {
if opts.Kind != "" && opts.Kind != "device" && opts.Kind != "vm" {
return nil, fmt.Errorf("invalid host kind %q", opts.Kind)
}
var (
mu sync.Mutex
results []HostEntry
errs []error
wg sync.WaitGroup
started int
)
if opts.Kind != "vm" {
started++
wg.Add(1)
go func() {
defer wg.Done()
@@ -113,7 +121,6 @@ func (c *Client) SearchAll(ctx context.Context, query string, opts SearchOptions
}
if opts.Kind != "device" {
started++
wg.Add(1)
go func() {
defer wg.Done()
@@ -130,11 +137,8 @@ func (c *Client) SearchAll(ctx context.Context, query string, opts SearchOptions
wg.Wait()
if len(errs) == started {
if started == 1 {
return nil, errs[0]
}
return nil, fmt.Errorf("netbox search failed: %v; %v", errs[0], errs[1])
if len(errs) > 0 {
return nil, errors.Join(errs...)
}
return results, nil
}
@@ -265,16 +269,31 @@ func TokenVersion(token string) int {
}
func (c *Client) get(ctx context.Context, apiURL string, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
_, err := c.request(ctx, http.MethodGet, apiURL, nil, "", out)
return err
}
func (c *Client) request(ctx context.Context, method, apiURL string, body io.Reader, etag string, out any) (string, error) {
req, err := http.NewRequestWithContext(ctx, method, apiURL, body)
if err != nil {
return fmt.Errorf("creating request: %w", err)
return "", fmt.Errorf("creating request: %w", err)
}
scheme := "Token "
if c.tokenVersion == 2 {
scheme = "Bearer "
}
req.Header.Set("Authorization", scheme+c.token)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if etag != "" {
req.Header.Set("If-Match", etag)
}
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)
return "", fmt.Errorf("request to %s: %w", apiURL, err)
}
defer resp.Body.Close()
@@ -283,17 +302,17 @@ func (c *Client) get(ctx context.Context, apiURL string, out any) error {
if c.tokenVersion == 1 {
hint += " — legacy v1 token detected, consider upgrading to a v2 token (starts with nbt_)"
}
return fmt.Errorf("%s: %s", apiURL, hint)
return "", fmt.Errorf("%s: %s", apiURL, hint)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("netbox returned %d for %s", resp.StatusCode, apiURL)
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 "", fmt.Errorf("decoding response: %w", err)
}
return nil
return resp.Header.Get("ETag"), nil
}
func deviceToEntry(d netboxDevice) HostEntry {
@@ -331,3 +350,66 @@ func stripPrefix(cidr string) string {
}
return cidr
}
// FindHost refuses ambiguous names so writes cannot target an arbitrary server.
func (c *Client) FindHost(ctx context.Context, name, kind string) (HostEntry, error) {
if kind != "" && kind != "device" && kind != "vm" {
return HostEntry{}, fmt.Errorf("invalid kind %q", kind)
}
entries, err := c.SearchAll(ctx, name, SearchOptions{Kind: kind})
if err != nil {
return HostEntry{}, err
}
var matches []HostEntry
for _, e := range entries {
if strings.EqualFold(e.Name, name) {
matches = append(matches, e)
}
}
if len(matches) != 1 {
return HostEntry{}, fmt.Errorf("host %q: found %d exact matches; use --kind or a unique hostname", name, len(matches))
}
return matches[0], nil
}
func (c *Client) hostURL(host HostEntry) (string, error) {
var path string
switch host.Kind {
case "device":
path = "dcim/devices"
case "vm":
path = "virtualization/virtual-machines"
default:
return "", fmt.Errorf("invalid host kind %q", host.Kind)
}
if host.ID <= 0 {
return "", fmt.Errorf("invalid host ID")
}
return fmt.Sprintf("%s/api/%s/%d/", c.baseURL, path, host.ID), nil
}
func (c *Client) GetComments(ctx context.Context, host HostEntry) (string, string, error) {
u, err := c.hostURL(host)
if err != nil {
return "", "", err
}
var out struct {
Comments string `json:"comments"`
}
etag, err := c.request(ctx, http.MethodGet, u, nil, "", &out)
return out.Comments, etag, err
}
func (c *Client) SetComments(ctx context.Context, host HostEntry, comments, etag string) error {
u, err := c.hostURL(host)
if err != nil {
return err
}
data, err := json.Marshal(map[string]string{"comments": comments})
if err != nil {
return err
}
var out json.RawMessage
_, err = c.request(ctx, http.MethodPatch, u, bytes.NewReader(data), etag, &out)
return err
}
+80 -15
View File
@@ -318,21 +318,26 @@ func Test403_V2Token_NoV1Hint(t *testing.T) {
}
func TestGet_SendsAuthorizationHeader(t *testing.T) {
var gotAuth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
w.Header().Set("Content-Type", "application/json")
b, _ := json.Marshal(deviceListResponse())
w.Write(b)
}))
defer srv.Close()
c := NewClient(srv.URL, "nbt_mytoken", 2)
c.Search(context.Background(), "", SearchOptions{}) //nolint:errcheck
want := "Token nbt_mytoken"
if gotAuth != want {
t.Errorf("Authorization header: got %q, want %q", gotAuth, want)
for _, token := range []string{"nbt_mytoken", "legacytoken"} {
t.Run(token, func(t *testing.T) {
want := "Token " + token
if strings.HasPrefix(token, "nbt_") {
want = "Bearer " + token
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != want {
t.Errorf("Authorization: got %q, want %q", got, want)
}
w.Header().Set("Content-Type", "application/json")
b, _ := json.Marshal(deviceListResponse())
w.Write(b)
}))
defer srv.Close()
c := NewClient(srv.URL, token, 0)
if _, err := c.Search(context.Background(), "", SearchOptions{}); err != nil {
t.Fatal(err)
}
})
}
}
@@ -434,3 +439,63 @@ func TestStripPrefix(t *testing.T) {
}
}
}
func TestCommentsPreserveFieldAndSendConflictHeader(t *testing.T) {
for _, kind := range []string{"device", "vm"} {
t.Run(kind, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer nbt_test" {
t.Error("wrong authentication")
}
if r.Method == http.MethodPatch {
if r.Header.Get("If-Match") != `W/"123"` {
t.Error("missing conflict protection")
}
var payload map[string]string
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Error(err)
}
if len(payload) != 1 || payload["comments"] != "new\nnotes" {
t.Errorf("unexpected patch: %v", payload)
}
}
w.Header().Set("ETag", `W/"123"`)
w.Write([]byte(`{"comments":"existing notes"}`))
}))
defer srv.Close()
c := NewClient(srv.URL, "nbt_test", 0)
host := HostEntry{ID: 1, Kind: kind}
text, etag, err := c.GetComments(context.Background(), host)
if err != nil || text != "existing notes" {
t.Fatalf("read: %q, %v", text, err)
}
if err := c.SetComments(context.Background(), host, "new\nnotes", etag); err != nil {
t.Fatal(err)
}
})
}
}
func TestFullRefreshRejectsPartialFailure(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "virtualization") {
w.WriteHeader(http.StatusForbidden)
return
}
w.Write([]byte(`{"count":0,"results":[]}`))
}))
defer srv.Close()
c := NewClient(srv.URL, "token", 0)
if _, err := c.SearchAll(context.Background(), "", SearchOptions{}); err == nil {
t.Fatal("partial refresh reported as successful")
}
}
func TestCommentConflictIsReported(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusPreconditionFailed) }))
defer srv.Close()
c := NewClient(srv.URL, "token", 0)
if err := c.SetComments(context.Background(), HostEntry{ID: 1, Kind: "device"}, "draft", "old"); err == nil {
t.Fatal("conflict ignored")
}
}