d127a3b957
Release / release (push) Successful in 49s
- **Strategies**: Add resolver strategy input validation and parsing in setup wizard. Support comma-separated input with known strategy mapping. - **Client**: Extend Search and SearchAll to include kind and tag filters. Add pagination for full cache refresh handling large datasets. - **Cache**: Introduce `RecentlyUsed` and `MarkUsed`. Persist `LastUsed` timestamps for entries. - **TUI**: Add recent hosts view, tag/kind filters, and inline editor for user/port override. - **Tests**: Comprehensive unit tests for new features, including strategy validation, cache behavior, and client filtering. - **Docs**: Update README with new TUI features and cache subcommands.
334 lines
8.6 KiB
Go
334 lines
8.6 KiB
Go
package netbox
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
type Client struct {
|
|
baseURL string
|
|
token string
|
|
tokenVersion int
|
|
httpClient *http.Client
|
|
}
|
|
|
|
// NewClient creates a NetBox API client. Pass tokenVersion=0 to auto-detect
|
|
// from the token string (1 for legacy, 2 for nbt_-prefixed tokens).
|
|
func NewClient(baseURL, token string, tokenVersion int) *Client {
|
|
if tokenVersion == 0 {
|
|
tokenVersion = TokenVersion(token)
|
|
}
|
|
return &Client{
|
|
baseURL: strings.TrimRight(baseURL, "/"),
|
|
token: token,
|
|
tokenVersion: tokenVersion,
|
|
httpClient: &http.Client{},
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
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()
|
|
devices, err := c.searchDevices(ctx, query, opts.Tag)
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if err != nil {
|
|
errs = append(errs, fmt.Errorf("devices: %w", err))
|
|
return
|
|
}
|
|
results = append(results, devices...)
|
|
}()
|
|
}
|
|
|
|
if opts.Kind != "device" {
|
|
started++
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
vms, err := c.searchVMs(ctx, query, opts.Tag)
|
|
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) == started {
|
|
if started == 1 {
|
|
return nil, errs[0]
|
|
}
|
|
return nil, fmt.Errorf("netbox search failed: %v; %v", errs[0], errs[1])
|
|
}
|
|
return results, nil
|
|
}
|
|
|
|
// 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) {
|
|
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()
|
|
devices, err := c.fetchAllDevices(ctx, query, opts.Tag)
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if err != nil {
|
|
errs = append(errs, fmt.Errorf("devices: %w", err))
|
|
return
|
|
}
|
|
results = append(results, devices...)
|
|
}()
|
|
}
|
|
|
|
if opts.Kind != "device" {
|
|
started++
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
vms, err := c.fetchAllVMs(ctx, query, opts.Tag)
|
|
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) == started {
|
|
if started == 1 {
|
|
return nil, errs[0]
|
|
}
|
|
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, tag string) ([]HostEntry, error) {
|
|
apiURL := fmt.Sprintf("%s/api/dcim/devices/?name__ic=%s&limit=50", c.baseURL, url.QueryEscape(query))
|
|
if tag != "" {
|
|
apiURL += "&tag=" + url.QueryEscape(tag)
|
|
}
|
|
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, tag string) ([]HostEntry, error) {
|
|
apiURL := fmt.Sprintf("%s/api/virtualization/virtual-machines/?name__ic=%s&limit=50", c.baseURL, url.QueryEscape(query))
|
|
if tag != "" {
|
|
apiURL += "&tag=" + url.QueryEscape(tag)
|
|
}
|
|
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) fetchAllDevices(ctx context.Context, query, tag string) ([]HostEntry, error) {
|
|
const pageSize = 50
|
|
var all []HostEntry
|
|
for offset := 0; ; offset += pageSize {
|
|
apiURL := fmt.Sprintf("%s/api/dcim/devices/?name__ic=%s&limit=%d&offset=%d",
|
|
c.baseURL, url.QueryEscape(query), pageSize, offset)
|
|
if tag != "" {
|
|
apiURL += "&tag=" + url.QueryEscape(tag)
|
|
}
|
|
var resp netboxListResponse[netboxDevice]
|
|
if err := c.get(ctx, apiURL, &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
for _, d := range resp.Results {
|
|
all = append(all, deviceToEntry(d))
|
|
}
|
|
if len(resp.Results) == 0 || len(all) >= resp.Count {
|
|
break
|
|
}
|
|
}
|
|
return all, nil
|
|
}
|
|
|
|
func (c *Client) fetchAllVMs(ctx context.Context, query, tag string) ([]HostEntry, error) {
|
|
const pageSize = 50
|
|
var all []HostEntry
|
|
for offset := 0; ; offset += pageSize {
|
|
apiURL := fmt.Sprintf("%s/api/virtualization/virtual-machines/?name__ic=%s&limit=%d&offset=%d",
|
|
c.baseURL, url.QueryEscape(query), pageSize, offset)
|
|
if tag != "" {
|
|
apiURL += "&tag=" + url.QueryEscape(tag)
|
|
}
|
|
var resp netboxListResponse[netboxVM]
|
|
if err := c.get(ctx, apiURL, &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
for _, v := range resp.Results {
|
|
all = append(all, vmToEntry(v))
|
|
}
|
|
if len(resp.Results) == 0 || len(all) >= resp.Count {
|
|
break
|
|
}
|
|
}
|
|
return all, nil
|
|
}
|
|
|
|
// TokenVersion returns 2 for NetBox v2 tokens (nbt_ prefix) or 1 for legacy tokens.
|
|
func TokenVersion(token string) int {
|
|
if strings.HasPrefix(token, "nbt_") {
|
|
return 2
|
|
}
|
|
return 1
|
|
}
|
|
|
|
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.StatusForbidden {
|
|
hint := "check token permissions in NetBox"
|
|
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)
|
|
}
|
|
|
|
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
|
|
}
|