feat: search multiple NetBox instances with isolated host identities
Release / release (push) Successful in 5m16s
Release / release (push) Successful in 5m16s
This commit is contained in:
1 parent
a2a6dd7591
commit
7f53b90266
26 files changed
+1299
-115
No files matched your search
Vendored
+2
-1
@@ -258,7 +258,8 @@ func (c *Cache) Search(prefix string) []Entry {
|
||||
prefix = strings.ToLower(prefix)
|
||||
var out []Entry
|
||||
for name, e := range c.entries {
|
||||
if strings.HasPrefix(strings.ToLower(name), prefix) {
|
||||
_, hostname, qualified := strings.Cut(strings.ToLower(name), "/")
|
||||
if strings.HasPrefix(strings.ToLower(name), prefix) || (qualified && !strings.Contains(prefix, "/") && strings.HasPrefix(hostname, prefix)) {
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+12
@@ -483,3 +483,15 @@ func TestUpsertPreservesUsageHistory(t *testing.T) {
|
||||
t.Fatalf("history lost: %+v", after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchMatchesHostnamesAcrossQualifiedSources(t *testing.T) {
|
||||
c := New("", 3600)
|
||||
c.Upsert(Entry{Name: "office/web01", IP: "10.0.0.1"})
|
||||
c.Upsert(Entry{Name: "lab/web01", IP: "10.0.0.2"})
|
||||
c.Upsert(Entry{Name: "lab/db01", IP: "10.0.0.3"})
|
||||
for query, want := range map[string]int{"web": 2, "office/web": 1, "lab/": 2, "WEB": 2} {
|
||||
if got := len(c.Search(query)); got != want {
|
||||
t.Errorf("Search(%q)=%d, want %d", query, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
type Config struct {
|
||||
Shell ShellConfig `mapstructure:"shell"`
|
||||
NetBox NetBoxConfig `mapstructure:"netbox"`
|
||||
NetBoxes []NetBoxConfig `mapstructure:"netboxes"`
|
||||
Resolver ResolverConfig `mapstructure:"resolver"`
|
||||
Cache CacheConfig `mapstructure:"cache"`
|
||||
SSH SSHConfig `mapstructure:"ssh"`
|
||||
@@ -24,6 +25,7 @@ type ShellConfig struct {
|
||||
}
|
||||
|
||||
type NetBoxConfig struct {
|
||||
Name string `mapstructure:"name"`
|
||||
URL string `mapstructure:"url"`
|
||||
Token string `mapstructure:"token"`
|
||||
TokenVersion int `mapstructure:"token_version"`
|
||||
@@ -93,6 +95,11 @@ func Load() (*Config, error) {
|
||||
if err := v.Unmarshal(&cfg); err != nil {
|
||||
return nil, fmt.Errorf("parsing config: %w", err)
|
||||
}
|
||||
// Legacy environment variables must not invent an additional unnamed source
|
||||
// when a file explicitly configures named instances (including wizard migration).
|
||||
if len(cfg.NetBoxes) > 0 && !v.InConfig("netbox.url") {
|
||||
cfg.NetBox = NetBoxConfig{}
|
||||
}
|
||||
|
||||
if err := ValidateCommandAlias(cfg.Shell.Alias); err != nil {
|
||||
return nil, err
|
||||
@@ -106,6 +113,20 @@ func Load() (*Config, error) {
|
||||
}
|
||||
}
|
||||
|
||||
if err := cfg.ValidateNetBoxes(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range cfg.NetBoxes {
|
||||
source := &cfg.NetBoxes[i]
|
||||
source.Name = strings.ToLower(source.Name)
|
||||
if source.TokenVersion == 0 && source.Token != "" {
|
||||
source.TokenVersion = 1
|
||||
if strings.HasPrefix(source.Token, "nbt_") {
|
||||
source.TokenVersion = 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.Cache.Path == "" {
|
||||
cacheDir, err := os.UserCacheDir()
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var instanceName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_-]*$`)
|
||||
|
||||
// ValidateNetBoxes keeps source names usable as instance/hostname selectors.
|
||||
func (c *Config) ValidateNetBoxes() error {
|
||||
if len(c.NetBoxes) > 0 && c.NetBox.URL != "" {
|
||||
return fmt.Errorf("configure either netbox or netboxes, not both")
|
||||
}
|
||||
seen := make(map[string]bool)
|
||||
for _, source := range c.NetBoxes {
|
||||
if !instanceName.MatchString(source.Name) {
|
||||
return fmt.Errorf("netboxes.name %q must contain only letters, digits, underscores, or hyphens", source.Name)
|
||||
}
|
||||
name := strings.ToLower(source.Name)
|
||||
if seen[name] {
|
||||
return fmt.Errorf("duplicate NetBox instance name %q", source.Name)
|
||||
}
|
||||
seen[name] = true
|
||||
u, err := url.Parse(source.URL)
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.RawQuery != "" || u.Fragment != "" {
|
||||
return fmt.Errorf("NetBox %q requires an HTTP(S) URL without query or fragment", source.Name)
|
||||
}
|
||||
if source.TokenVersion < 0 || source.TokenVersion > 2 {
|
||||
return fmt.Errorf("NetBox %q: token_version must be 0, 1, or 2", source.Name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Config) HasNetBox() bool { return c.NetBox.URL != "" || len(c.NetBoxes) > 0 }
|
||||
|
||||
// CachePath isolates named sources from legacy caches and from changed endpoints.
|
||||
// Rotating a token or reordering the sources does not discard usage history.
|
||||
func (c *Config) CachePath() string {
|
||||
if len(c.NetBoxes) == 0 {
|
||||
return c.Cache.Path
|
||||
}
|
||||
var identities []string
|
||||
for _, source := range c.NetBoxes {
|
||||
identities = append(identities, strings.ToLower(source.Name)+"\x00"+strings.TrimRight(source.URL, "/"))
|
||||
}
|
||||
sort.Strings(identities)
|
||||
sum := sha256.Sum256([]byte(strings.Join(identities, "\x00")))
|
||||
return filepath.Join(filepath.Dir(c.Cache.Path), fmt.Sprintf("instances-%x", sum[:8]), filepath.Base(c.Cache.Path))
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadNamedNetBoxes(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", dir)
|
||||
t.Setenv("NETSSH_NETBOX_URL", "https://legacy-env.example")
|
||||
t.Setenv("NETSSH_NETBOX_TOKEN", "legacy-env-token")
|
||||
data := `netboxes:
|
||||
- name: Office
|
||||
url: https://office.example/netbox
|
||||
token: nbt_office
|
||||
- name: lab
|
||||
url: https://lab.example
|
||||
token: legacy-token
|
||||
cache:
|
||||
path: /tmp/netssh-tests/hosts.json
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(dir, "netssh.yaml"), []byte(data), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !cfg.HasNetBox() || len(cfg.NetBoxes) != 2 || cfg.NetBoxes[0].Name != "office" || cfg.NetBoxes[0].TokenVersion != 2 || cfg.NetBoxes[1].TokenVersion != 1 {
|
||||
t.Fatalf("bad instances: %+v", cfg.NetBoxes)
|
||||
}
|
||||
if cfg.NetBox.URL != "" {
|
||||
t.Fatal("legacy environment variables added an unnamed instance")
|
||||
}
|
||||
first := cfg.CachePath()
|
||||
if first == cfg.Cache.Path {
|
||||
t.Fatal("named sources reuse legacy cache")
|
||||
}
|
||||
cfg.NetBoxes[0], cfg.NetBoxes[1] = cfg.NetBoxes[1], cfg.NetBoxes[0]
|
||||
cfg.NetBoxes[0].Token = "rotated"
|
||||
if cfg.CachePath() != first {
|
||||
t.Fatal("reordering/rotating credentials discarded cache")
|
||||
}
|
||||
cfg.NetBoxes[0].URL = "https://other.example"
|
||||
if cfg.CachePath() == first {
|
||||
t.Fatal("changed endpoint reuses old hosts")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateNetBoxes(t *testing.T) {
|
||||
good := NetBoxConfig{Name: "office", URL: "https://office.example"}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
cfg Config
|
||||
}{
|
||||
{"duplicate", Config{NetBoxes: []NetBoxConfig{good, {Name: "OFFICE", URL: "https://other.example"}}}},
|
||||
{"empty name", Config{NetBoxes: []NetBoxConfig{{URL: "https://office.example"}}}},
|
||||
{"invalid name", Config{NetBoxes: []NetBoxConfig{{Name: "a/b", URL: "https://office.example"}}}},
|
||||
{"missing URL", Config{NetBoxes: []NetBoxConfig{{Name: "office"}}}},
|
||||
{"invalid URL", Config{NetBoxes: []NetBoxConfig{{Name: "office", URL: "file:///tmp/netbox"}}}},
|
||||
{"conflicting config", Config{NetBox: good, NetBoxes: []NetBoxConfig{good}}},
|
||||
{"invalid token version", Config{NetBoxes: []NetBoxConfig{{Name: "office", URL: good.URL, TokenVersion: 3}}}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if err := tc.cfg.ValidateNetBoxes(); err == nil {
|
||||
t.Fatal("invalid configuration accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
legacy := Config{NetBox: good, Cache: CacheConfig{Path: "hosts.json"}}
|
||||
if err := legacy.ValidateNetBoxes(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !legacy.HasNetBox() || legacy.CachePath() != "hosts.json" {
|
||||
t.Fatal("legacy behavior changed")
|
||||
}
|
||||
}
|
||||
@@ -227,3 +227,18 @@ func TestCommandAliasGenerationAndCollision(t *testing.T) {
|
||||
t.Fatal(got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAliasesIncludeInstanceName(t *testing.T) {
|
||||
cfg := config.Config{Shortcuts: config.ShortcutsConfig{Domains: []string{".example.com"}}}
|
||||
entries := hook.BuildAliasEntries([]cache.Entry{{Name: "office/web01.example.com"}, {Name: "lab/web01.example.com"}}, cfg)
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("missing qualified aliases: %+v", entries)
|
||||
}
|
||||
got := map[string]string{}
|
||||
for _, entry := range entries {
|
||||
got[entry.Name] = entry.Host
|
||||
}
|
||||
if got["office_web01"] != "office/web01.example.com" || got["lab_web01"] != "lab/web01.example.com" {
|
||||
t.Fatalf("alias lost source: %+v", entries)
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
sources map[string]*Client
|
||||
baseURL string
|
||||
token string
|
||||
tokenVersion int
|
||||
@@ -38,6 +39,9 @@ func NewClient(baseURL, token string, tokenVersion int) *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) {
|
||||
if c.sources != nil {
|
||||
return c.searchSources(ctx, query, opts, false)
|
||||
}
|
||||
if opts.Kind != "" && opts.Kind != "device" && opts.Kind != "vm" {
|
||||
return nil, fmt.Errorf("invalid host kind %q", opts.Kind)
|
||||
}
|
||||
@@ -95,6 +99,9 @@ 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 c.sources != nil {
|
||||
return c.searchSources(ctx, query, opts, true)
|
||||
}
|
||||
if opts.Kind != "" && opts.Kind != "device" && opts.Kind != "vm" {
|
||||
return nil, fmt.Errorf("invalid host kind %q", opts.Kind)
|
||||
}
|
||||
@@ -146,6 +153,13 @@ func (c *Client) SearchAll(ctx context.Context, query string, opts SearchOptions
|
||||
// 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) {
|
||||
if c.sources != nil {
|
||||
source, host, err := c.sourceForHost(entry)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return source.GetIPs(ctx, host)
|
||||
}
|
||||
var apiURL string
|
||||
switch entry.Kind {
|
||||
case "device":
|
||||
@@ -170,6 +184,9 @@ func (c *Client) GetIPs(ctx context.Context, entry HostEntry) ([]string, error)
|
||||
|
||||
// GetIPsWithFilter calls /api/ipam/ip-addresses/ with arbitrary filter query parameters.
|
||||
func (c *Client) GetIPsWithFilter(ctx context.Context, filterParams string) ([]string, error) {
|
||||
if c.sources != nil {
|
||||
return nil, fmt.Errorf("IP filters require a host instance; use GetIPsWithFilterForHost")
|
||||
}
|
||||
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 {
|
||||
@@ -360,12 +377,13 @@ func (c *Client) FindHost(ctx context.Context, name, kind string) (HostEntry, er
|
||||
return HostEntry{}, fmt.Errorf("invalid kind %q", kind)
|
||||
}
|
||||
entries, err := c.SearchAll(ctx, name, SearchOptions{Kind: kind})
|
||||
_, hostname := SplitTarget(name)
|
||||
if err != nil {
|
||||
return HostEntry{}, err
|
||||
}
|
||||
var matches []HostEntry
|
||||
for _, e := range entries {
|
||||
if strings.EqualFold(e.Name, name) {
|
||||
if strings.EqualFold(e.Name, hostname) {
|
||||
matches = append(matches, e)
|
||||
}
|
||||
}
|
||||
@@ -373,6 +391,9 @@ func (c *Client) FindHost(ctx context.Context, name, kind string) (HostEntry, er
|
||||
return HostEntry{}, fmt.Errorf("%w: %q", ErrHostNotFound, name)
|
||||
}
|
||||
if len(matches) != 1 {
|
||||
if c.sources != nil {
|
||||
return HostEntry{}, fmt.Errorf("host %q: found %d exact matches; use instance/hostname and --kind to select a unique host", name, len(matches))
|
||||
}
|
||||
return HostEntry{}, fmt.Errorf("host %q: found %d exact matches; use --kind or a unique hostname", name, len(matches))
|
||||
}
|
||||
return matches[0], nil
|
||||
@@ -395,6 +416,13 @@ func (c *Client) hostURL(host HostEntry) (string, error) {
|
||||
}
|
||||
|
||||
func (c *Client) GetComments(ctx context.Context, host HostEntry) (string, string, error) {
|
||||
if c.sources != nil {
|
||||
source, host, err := c.sourceForHost(host)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return source.GetComments(ctx, host)
|
||||
}
|
||||
u, err := c.hostURL(host)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
@@ -407,6 +435,13 @@ func (c *Client) GetComments(ctx context.Context, host HostEntry) (string, strin
|
||||
}
|
||||
|
||||
func (c *Client) SetComments(ctx context.Context, host HostEntry, comments, etag string) error {
|
||||
if c.sources != nil {
|
||||
source, host, err := c.sourceForHost(host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return source.SetComments(ctx, host, comments, etag)
|
||||
}
|
||||
u, err := c.hostURL(host)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package netbox
|
||||
|
||||
import "strings"
|
||||
|
||||
// SearchOptions filters a Search or SearchAll query.
|
||||
// Zero value means no filtering (return all kinds, no tag filter).
|
||||
type SearchOptions struct {
|
||||
@@ -9,6 +11,7 @@ type SearchOptions struct {
|
||||
|
||||
// HostEntry is a unified model for both devices and virtual machines from NetBox.
|
||||
type HostEntry struct {
|
||||
Source string // named NetBox instance; empty for the legacy single-instance config
|
||||
ID int
|
||||
Name string
|
||||
Kind string // "device" | "vm"
|
||||
@@ -58,3 +61,19 @@ type netboxIPListResponse struct {
|
||||
} `json:"assigned_object"`
|
||||
} `json:"results"`
|
||||
}
|
||||
|
||||
// Target returns an unambiguous selector for cache, completion, and SSH.
|
||||
func (h HostEntry) Target() string {
|
||||
if h.Source == "" {
|
||||
return h.Name
|
||||
}
|
||||
return h.Source + "/" + h.Name
|
||||
}
|
||||
|
||||
// SplitTarget separates an optional instance prefix from a hostname.
|
||||
func SplitTarget(target string) (source, name string) {
|
||||
if source, name, ok := strings.Cut(target, "/"); ok {
|
||||
return strings.ToLower(source), name
|
||||
}
|
||||
return "", target
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package netbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Source binds a name to its own API client and credentials.
|
||||
type Source struct {
|
||||
Name string
|
||||
URL string
|
||||
Token string
|
||||
TokenVersion int
|
||||
}
|
||||
|
||||
func NewMultiClient(sources []Source) *Client {
|
||||
c := &Client{sources: make(map[string]*Client)}
|
||||
for _, source := range sources {
|
||||
c.sources[strings.ToLower(source.Name)] = NewClient(source.URL, source.Token, source.TokenVersion)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Client) searchSources(ctx context.Context, query string, opts SearchOptions, all bool) ([]HostEntry, error) {
|
||||
requested, query := SplitTarget(query)
|
||||
if requested != "" {
|
||||
if _, ok := c.sources[requested]; !ok {
|
||||
return nil, fmt.Errorf("unknown NetBox instance %q", requested)
|
||||
}
|
||||
}
|
||||
var mu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
var entries []HostEntry
|
||||
var errs []error
|
||||
started := 0
|
||||
for name, client := range c.sources {
|
||||
if requested != "" && name != requested {
|
||||
continue
|
||||
}
|
||||
started++
|
||||
wg.Add(1)
|
||||
go func(name string, client *Client) {
|
||||
defer wg.Done()
|
||||
var found []HostEntry
|
||||
var err error
|
||||
if all {
|
||||
found, err = client.SearchAll(ctx, query, opts)
|
||||
} else {
|
||||
found, err = client.Search(ctx, query, opts)
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("NetBox %s: %w", name, err))
|
||||
return
|
||||
}
|
||||
for i := range found {
|
||||
found[i].Source = name
|
||||
}
|
||||
entries = append(entries, found...)
|
||||
}(name, client)
|
||||
}
|
||||
wg.Wait()
|
||||
// Full lookups must see every source to rule out ambiguous hostnames.
|
||||
if len(errs) > 0 && (all || len(errs) == started) {
|
||||
return nil, errors.Join(errs...)
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool { return entries[i].Target() < entries[j].Target() })
|
||||
return entries, errors.Join(errs...)
|
||||
}
|
||||
|
||||
func (c *Client) sourceForHost(host HostEntry) (*Client, HostEntry, error) {
|
||||
prefix, name := SplitTarget(host.Name)
|
||||
if prefix != "" {
|
||||
if host.Source != "" && !strings.EqualFold(prefix, host.Source) {
|
||||
return nil, host, fmt.Errorf("conflicting NetBox instance for %q", host.Name)
|
||||
}
|
||||
host.Source, host.Name = prefix, name
|
||||
}
|
||||
if c.sources == nil {
|
||||
return c, host, nil
|
||||
}
|
||||
source, ok := c.sources[strings.ToLower(host.Source)]
|
||||
if !ok {
|
||||
return nil, host, fmt.Errorf("specify a configured NetBox instance as instance/hostname (got %q)", host.Target())
|
||||
}
|
||||
host.Source = strings.ToLower(host.Source)
|
||||
return source, host, nil
|
||||
}
|
||||
|
||||
// HostScope identifies a local note by its original NetBox URL and canonical
|
||||
// hostname, so renaming an instance does not move or mix its notes.
|
||||
func (c *Client) HostScope(host HostEntry) (HostEntry, string, error) {
|
||||
source, host, err := c.sourceForHost(host)
|
||||
if err != nil {
|
||||
return host, "", err
|
||||
}
|
||||
return host, source.baseURL, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetIPsWithFilterForHost(ctx context.Context, host HostEntry, filters string) ([]string, error) {
|
||||
source, _, err := c.sourceForHost(host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return source.GetIPsWithFilter(ctx, filters)
|
||||
}
|
||||
|
||||
func (c *Client) MultipleSources() bool { return c.sources != nil }
|
||||
@@ -0,0 +1,192 @@
|
||||
package netbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func sourceTestServer(t *testing.T, token, ip, comment string) *httptest.Server {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("Authorization"); got != "Token "+token {
|
||||
t.Errorf("credentials crossed sources: %q, want %q", got, token)
|
||||
http.Error(w, "bad token", 401)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/api/dcim/devices/":
|
||||
json.NewEncoder(w).Encode(deviceListResponse(netboxDevice{ID: 1, Name: "web", PrimaryIP4: &netboxIP{Address: ip + "/24"}}))
|
||||
case "/api/virtualization/virtual-machines/":
|
||||
json.NewEncoder(w).Encode(vmListResponse())
|
||||
case "/api/ipam/ip-addresses/":
|
||||
if r.URL.Query().Get("device_id") != "1" {
|
||||
t.Errorf("wrong host ID: %s", r.URL.RawQuery)
|
||||
}
|
||||
json.NewEncoder(w).Encode(ipListResponse(ip + "/24"))
|
||||
case "/api/dcim/devices/1/":
|
||||
if r.Method == http.MethodPatch {
|
||||
var data map[string]string
|
||||
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if data["comments"] != comment+" updated" {
|
||||
t.Errorf("comment write crossed sources: %v", data)
|
||||
}
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]string{"comments": comment})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
func TestMultiSourceSearchAndRouting(t *testing.T) {
|
||||
office := sourceTestServer(t, "office-token", "10.0.0.1", "office note")
|
||||
lab := sourceTestServer(t, "lab-token", "10.0.0.2", "lab note")
|
||||
c := NewMultiClient([]Source{{Name: "office", URL: office.URL, Token: "office-token"}, {Name: "lab", URL: lab.URL, Token: "lab-token"}})
|
||||
ctx := context.Background()
|
||||
entries, err := c.Search(ctx, "web", SearchOptions{})
|
||||
if err != nil || len(entries) != 2 {
|
||||
t.Fatalf("combined search: %+v, %v", entries, err)
|
||||
}
|
||||
if entries[0].Target() != "lab/web" || entries[1].Target() != "office/web" {
|
||||
t.Fatalf("source lost: %+v", entries)
|
||||
}
|
||||
if _, err := c.FindHost(ctx, "web", ""); err == nil {
|
||||
t.Fatal("ambiguous unqualified hostname accepted")
|
||||
}
|
||||
for _, tc := range []struct{ name, ip, note string }{{"office", "10.0.0.1", "office note"}, {"lab", "10.0.0.2", "lab note"}} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
host, err := c.FindHost(ctx, tc.name+"/web", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if host.Source != tc.name || host.Name != "web" || host.ID != 1 {
|
||||
t.Fatalf("wrong host identity: %+v", host)
|
||||
}
|
||||
ips, err := c.GetIPs(ctx, host)
|
||||
if err != nil || len(ips) != 1 || ips[0] != tc.ip {
|
||||
t.Fatalf("wrong IP source: %v, %v", ips, err)
|
||||
}
|
||||
ips, err = c.GetIPsWithFilterForHost(ctx, host, "device_id=1&interface_name=mgmt0")
|
||||
if err != nil || len(ips) != 1 || ips[0] != tc.ip {
|
||||
t.Fatalf("wrong interface source: %v, %v", ips, err)
|
||||
}
|
||||
text, _, err := c.GetComments(ctx, host)
|
||||
if err != nil || text != tc.note {
|
||||
t.Fatalf("wrong comment source: %q, %v", text, err)
|
||||
}
|
||||
// TUI cache items carry a qualified name rather than a separate source.
|
||||
qualified := HostEntry{Name: tc.name + "/web", ID: 1, Kind: "device"}
|
||||
if err := c.SetComments(ctx, qualified, tc.note+" updated", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
if _, err := c.Search(ctx, "missing/web", SearchOptions{}); err == nil {
|
||||
t.Fatal("unknown source accepted")
|
||||
}
|
||||
if _, _, err := c.HostScope(HostEntry{Name: "web", Kind: "device"}); err == nil {
|
||||
t.Fatal("unqualified note namespace accepted")
|
||||
}
|
||||
if _, err := c.GetIPsWithFilter(ctx, "device_id=1"); err == nil {
|
||||
t.Fatal("IP filter without instance accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultiSourceSearchRunsConcurrently(t *testing.T) {
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
makeServer := func() *httptest.Server {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
wg.Done()
|
||||
both := make(chan struct{})
|
||||
go func() { wg.Wait(); close(both) }()
|
||||
select {
|
||||
case <-both:
|
||||
fmt.Fprint(w, `{"count":0,"results":[]}`)
|
||||
case <-r.Context().Done():
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
a, b := makeServer(), makeServer()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
c := NewMultiClient([]Source{{Name: "a", URL: a.URL}, {Name: "b", URL: b.URL}})
|
||||
if _, err := c.Search(ctx, "web", SearchOptions{Kind: "device"}); err != nil {
|
||||
t.Fatalf("sources did not run concurrently: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultiSourcePartialFailure(t *testing.T) {
|
||||
good := sourceTestServer(t, "good-token", "10.0.0.1", "note")
|
||||
failed := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "unavailable", 503) }))
|
||||
defer failed.Close()
|
||||
c := NewMultiClient([]Source{{Name: "good", URL: good.URL, Token: "good-token"}, {Name: "offline", URL: failed.URL}})
|
||||
ctx := context.Background()
|
||||
entries, err := c.Search(ctx, "web", SearchOptions{})
|
||||
if len(entries) != 1 || err == nil || !strings.Contains(err.Error(), "offline") {
|
||||
t.Fatalf("partial search lost results or warning: %+v, %v", entries, err)
|
||||
}
|
||||
if _, err := c.SearchAll(ctx, "web", SearchOptions{}); err == nil {
|
||||
t.Fatal("incomplete full search succeeded")
|
||||
}
|
||||
if _, err := c.FindHost(ctx, "web", ""); err == nil {
|
||||
t.Fatal("incomplete search claimed unique host")
|
||||
}
|
||||
if host, err := c.FindHost(ctx, "good/web", ""); err != nil || host.Source != "good" {
|
||||
t.Fatalf("qualified lookup blocked by unrelated outage: %+v, %v", host, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultiSourceKeepsIndependentDeviceAndVMInventories(t *testing.T) {
|
||||
office := newTestServer(t, map[string]any{
|
||||
"/api/dcim/devices/": deviceListResponse(netboxDevice{ID: 1, Name: "office-router", PrimaryIP4: &netboxIP{Address: "10.1.0.1/24"}}),
|
||||
"/api/virtualization/virtual-machines/": vmListResponse(netboxVM{ID: 2, Name: "office-vm", PrimaryIP4: &netboxIP{Address: "10.1.0.2/24"}}),
|
||||
})
|
||||
defer office.Close()
|
||||
lab := newTestServer(t, map[string]any{
|
||||
"/api/dcim/devices/": deviceListResponse(netboxDevice{ID: 1, Name: "lab-switch", PrimaryIP4: &netboxIP{Address: "10.2.0.1/24"}}),
|
||||
"/api/virtualization/virtual-machines/": vmListResponse(netboxVM{ID: 2, Name: "lab-vm", PrimaryIP4: &netboxIP{Address: "10.2.0.2/24"}}),
|
||||
})
|
||||
defer lab.Close()
|
||||
client := NewMultiClient([]Source{{Name: "office", URL: office.URL}, {Name: "lab", URL: lab.URL}})
|
||||
entries, err := client.SearchAll(context.Background(), "", SearchOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[string]string{"office/office-router": "device", "office/office-vm": "vm", "lab/lab-switch": "device", "lab/lab-vm": "vm"}
|
||||
if len(entries) != len(want) {
|
||||
t.Fatalf("independent inventories missing: %+v", entries)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if want[entry.Target()] != entry.Kind {
|
||||
t.Fatalf("inventory crossed source or kind: %+v", entry)
|
||||
}
|
||||
}
|
||||
vms, err := client.Search(context.Background(), "", SearchOptions{Kind: "vm"})
|
||||
if err != nil || len(vms) != 2 {
|
||||
t.Fatalf("VM filter across instances: %+v, %v", vms, err)
|
||||
}
|
||||
for _, host := range vms {
|
||||
if host.Kind != "vm" {
|
||||
t.Fatalf("device leaked into VM filter: %+v", host)
|
||||
}
|
||||
}
|
||||
host, err := client.FindHost(context.Background(), "lab/lab-vm", "vm")
|
||||
if err != nil || host.Source != "lab" || host.ID != 2 || host.PrimaryIP4 != "10.2.0.2" {
|
||||
t.Fatalf("wrong VM selected: %+v, %v", host, err)
|
||||
}
|
||||
}
|
||||
+23
-2
@@ -52,7 +52,11 @@ func (s *Service) Read(ctx context.Context, host netbox.HostEntry, remote bool)
|
||||
note.Text, note.ETag, err = s.Client.GetComments(ctx, note.Host)
|
||||
return note, err
|
||||
}
|
||||
data, err := os.ReadFile(s.path(host))
|
||||
path, err := s.localPath(host)
|
||||
if err != nil {
|
||||
return note, err
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return note, nil
|
||||
}
|
||||
@@ -67,5 +71,22 @@ func (s *Service) Write(ctx context.Context, note Note, remote bool) error {
|
||||
}
|
||||
return s.Client.SetComments(ctx, note.Host, note.Text, note.ETag)
|
||||
}
|
||||
return storage.WriteFile(s.path(note.Host), []byte(note.Text), 0o600)
|
||||
path, err := s.localPath(note.Host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return storage.WriteFile(path, []byte(note.Text), 0o600)
|
||||
}
|
||||
|
||||
func (s *Service) localPath(host netbox.HostEntry) (string, error) {
|
||||
if s.Client == nil {
|
||||
return s.path(host), nil
|
||||
}
|
||||
host, namespace, err := s.Client.HostScope(host)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
scoped := *s
|
||||
scoped.Namespace = namespace
|
||||
return scoped.path(host), nil
|
||||
}
|
||||
@@ -50,3 +50,35 @@ func TestUnreadableNoteIsNotTreatedAsEmpty(t *testing.T) {
|
||||
t.Fatal("expected read error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalNotesSeparateInstancesAndSurviveRename(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
client := netbox.NewMultiClient([]netbox.Source{{Name: "office", URL: "https://office.example"}, {Name: "lab", URL: "https://lab.example"}})
|
||||
service := &Service{Dir: t.TempDir(), Client: client}
|
||||
for _, source := range []string{"office", "lab"} {
|
||||
note := Note{Host: netbox.HostEntry{Name: source + "/web", Kind: "device"}, Text: source + " private note"}
|
||||
if err := service.Write(ctx, note, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, source := range []string{"office", "lab"} {
|
||||
note, err := service.Read(ctx, netbox.HostEntry{Name: source + "/web", Kind: "device"}, false)
|
||||
if err != nil || note.Text != source+" private note" {
|
||||
t.Fatalf("notes crossed instances: %+v, %v", note, err)
|
||||
}
|
||||
}
|
||||
service.Client = netbox.NewMultiClient([]netbox.Source{{Name: "renamed", URL: "https://office.example"}})
|
||||
note, err := service.Read(ctx, netbox.HostEntry{Name: "renamed/web", Kind: "device"}, false)
|
||||
if err != nil || note.Text != "office private note" {
|
||||
t.Fatalf("renaming source lost note: %+v, %v", note, err)
|
||||
}
|
||||
// This is also the namespace used before migrating a legacy single NetBox.
|
||||
legacy := &Service{Dir: service.Dir, Namespace: "https://office.example"}
|
||||
note, err = legacy.Read(ctx, netbox.HostEntry{Name: "web", Kind: "device"}, false)
|
||||
if err != nil || note.Text != "office private note" {
|
||||
t.Fatalf("legacy note namespace changed: %+v, %v", note, err)
|
||||
}
|
||||
if err := service.Write(ctx, Note{Host: netbox.HostEntry{Name: "web", Kind: "device"}}, false); err == nil {
|
||||
t.Fatal("unqualified notes accepted for multiple sources")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package resolver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
||||
)
|
||||
|
||||
func TestVMResolversStayWithinTheirNetBoxInstance(t *testing.T) {
|
||||
sources := make([]netbox.Source, 0, 2)
|
||||
for i, name := range []string{"office", "lab"} {
|
||||
ip := fmt.Sprintf("10.0.0.%d", i+1)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("virtual_machine_id") != "7" {
|
||||
t.Errorf("wrong VM filter: %s", r.URL.RawQuery)
|
||||
}
|
||||
if r.Header.Get("Authorization") != "Token "+name {
|
||||
t.Error("VM lookup used another instance's credentials")
|
||||
}
|
||||
fmt.Fprintf(w, `{"count":1,"results":[{"address":%q}]}`, ip+"/24")
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
sources = append(sources, netbox.Source{Name: name, URL: srv.URL, Token: name})
|
||||
}
|
||||
client := netbox.NewMultiClient(sources)
|
||||
management, err := NewManagementSubnetStrategy([]string{"10.0.0.0/8"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, source := range sources {
|
||||
host := &netbox.HostEntry{Source: source.Name, Name: "vm", Kind: "vm", ID: 7}
|
||||
for _, strategy := range []Strategy{management, &InterfaceNameStrategy{name: "mgmt0"}} {
|
||||
ip, err := strategy.Resolve(context.Background(), host, client)
|
||||
if want := fmt.Sprintf("10.0.0.%d", i+1); err != nil || ip != want {
|
||||
t.Fatalf("%s VM resolved through wrong instance: %q, %v", strategy.Name(), ip, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ func (s *InterfaceNameStrategy) Resolve(ctx context.Context, entry *netbox.HostE
|
||||
return "", fmt.Errorf("unknown kind %q", entry.Kind)
|
||||
}
|
||||
|
||||
ips, err := client.GetIPsWithFilter(ctx, filterParam)
|
||||
ips, err := client.GetIPsWithFilterForHost(ctx, *entry, filterParam)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("fetching IPs for interface %q: %w", s.name, err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"charm.land/huh/v2"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
||||
)
|
||||
|
||||
// configureInstances edits a copy; nothing is written until the whole wizard succeeds.
|
||||
func configureInstances(cfg *config.Config) ([]config.NetBoxConfig, error) {
|
||||
sources := append([]config.NetBoxConfig(nil), cfg.NetBoxes...)
|
||||
if len(sources) == 0 && cfg.NetBox.URL != "" {
|
||||
source := cfg.NetBox
|
||||
source.Name = "default"
|
||||
sources = append(sources, source)
|
||||
}
|
||||
for {
|
||||
if len(sources) == 0 {
|
||||
source, err := editInstance(config.NetBoxConfig{Name: "default"}, sources, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sources = append(sources, source)
|
||||
}
|
||||
action := "done"
|
||||
var options []huh.Option[string]
|
||||
for _, source := range sources {
|
||||
options = append(options, huh.NewOption("Edit "+source.Name+" ("+source.URL+")", "edit:"+source.Name))
|
||||
options = append(options, huh.NewOption("Remove "+source.Name, "remove:"+source.Name))
|
||||
}
|
||||
options = append(options, huh.NewOption("Add another NetBox", "add"), huh.NewOption("Continue with these instances", "done"))
|
||||
err := huh.NewForm(huh.NewGroup(huh.NewSelect[string]().Title("NetBox instances").Description("All configured instances are searched together.").Options(options...).Value(&action))).Run()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if action == "done" {
|
||||
return sources, nil
|
||||
}
|
||||
if action == "add" {
|
||||
source, err := editInstance(config.NetBoxConfig{}, sources, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sources = append(sources, source)
|
||||
continue
|
||||
}
|
||||
verb, name, _ := strings.Cut(action, ":")
|
||||
for i, source := range sources {
|
||||
if source.Name != name {
|
||||
continue
|
||||
}
|
||||
if verb == "edit" {
|
||||
edited, err := editInstance(source, sources, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sources[i] = edited
|
||||
} else {
|
||||
remove := false
|
||||
if err := huh.NewConfirm().Title("Remove " + name + "?").Value(&remove).Run(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if remove {
|
||||
sources = append(sources[:i], sources[i+1:]...)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func editInstance(source config.NetBoxConfig, sources []config.NetBoxConfig, original string) (config.NetBoxConfig, error) {
|
||||
err := huh.NewForm(huh.NewGroup(
|
||||
huh.NewInput().Title("Instance name").Description("Used in selectors such as office/web01.").Value(&source.Name).Validate(func(name string) error {
|
||||
name = strings.ToLower(strings.TrimSpace(name))
|
||||
candidate := config.Config{NetBoxes: []config.NetBoxConfig{{Name: name, URL: "https://example.invalid"}}}
|
||||
if err := candidate.ValidateNetBoxes(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, existing := range sources {
|
||||
if strings.EqualFold(existing.Name, name) && !strings.EqualFold(existing.Name, original) {
|
||||
return fmt.Errorf("instance name %q is already used", name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
huh.NewInput().Title("NetBox URL").Placeholder("https://netbox.example.com").Value(&source.URL).Validate(func(value string) error {
|
||||
return (&config.Config{NetBoxes: []config.NetBoxConfig{{Name: "instance", URL: strings.TrimSpace(value)}}}).ValidateNetBoxes()
|
||||
}),
|
||||
huh.NewInput().Title("NetBox API token").EchoMode(huh.EchoModePassword).Value(&source.Token).Validate(func(value string) error {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return fmt.Errorf("required")
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
).Title("NetBox connection")).Run()
|
||||
if err != nil {
|
||||
return source, err
|
||||
}
|
||||
source.Name = strings.ToLower(strings.TrimSpace(source.Name))
|
||||
source.URL = strings.TrimRight(strings.TrimSpace(source.URL), "/")
|
||||
source.Token = strings.TrimSpace(source.Token)
|
||||
source.TokenVersion = netbox.TokenVersion(source.Token)
|
||||
if source.TokenVersion == 1 {
|
||||
fmt.Fprintf(os.Stderr, "Note: NetBox %s uses a legacy v1 token. A v2 token starts with nbt_.\n", source.Name)
|
||||
}
|
||||
return source, nil
|
||||
}
|
||||
+23
-43
@@ -13,15 +13,20 @@ import (
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/hook"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/storage"
|
||||
)
|
||||
|
||||
// RunWizard runs the interactive setup form, pre-filled with any existing cfg values.
|
||||
// It saves the result to the config file on success.
|
||||
func RunWizard(cfg *config.Config) error {
|
||||
url := cfg.NetBox.URL
|
||||
token := cfg.NetBox.Token
|
||||
sources, err := configureInstances(cfg)
|
||||
if err != nil {
|
||||
if errors.Is(err, huh.ErrUserAborted) {
|
||||
fmt.Fprintln(os.Stderr, "Setup cancelled.")
|
||||
os.Exit(0)
|
||||
}
|
||||
return err
|
||||
}
|
||||
defaultUser := cfg.SSH.DefaultUser
|
||||
defaultPort := ""
|
||||
if cfg.SSH.DefaultPort > 0 {
|
||||
@@ -43,30 +48,6 @@ func RunWizard(cfg *config.Config) error {
|
||||
}
|
||||
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().
|
||||
Title("NetBox URL").
|
||||
Description("e.g. https://netbox.example.com").
|
||||
Placeholder("https://").
|
||||
Value(&url).
|
||||
Validate(func(s string) error {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return errors.New("required")
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
huh.NewInput().
|
||||
Title("NetBox API token").
|
||||
EchoMode(huh.EchoModePassword).
|
||||
Value(&token).
|
||||
Validate(func(s string) error {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return errors.New("required")
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
).Title("NetBox connection"),
|
||||
|
||||
huh.NewGroup(
|
||||
huh.NewInput().
|
||||
Title("Default SSH user").
|
||||
@@ -144,11 +125,6 @@ func RunWizard(cfg *config.Config) error {
|
||||
}
|
||||
|
||||
strategies := parseStrategies(strategiesRaw)
|
||||
tokenVersion := netbox.TokenVersion(token)
|
||||
if tokenVersion == 1 {
|
||||
fmt.Fprintln(os.Stderr, "\nNote: You are using a legacy token (v1). Create a v2 token in NetBox (starting with nbt_) for better compatibility.")
|
||||
fmt.Fprintln(os.Stderr, " NetBox → Admin → API Tokens → Add Token")
|
||||
}
|
||||
|
||||
ttl, _ := strconv.Atoi(cacheTTL)
|
||||
|
||||
@@ -175,12 +151,8 @@ func RunWizard(cfg *config.Config) error {
|
||||
}
|
||||
|
||||
out := config.Config{
|
||||
Shell: config.ShellConfig{Alias: strings.TrimSpace(commandAlias)},
|
||||
NetBox: config.NetBoxConfig{
|
||||
URL: strings.TrimRight(strings.TrimSpace(url), "/"),
|
||||
Token: strings.TrimSpace(token),
|
||||
TokenVersion: tokenVersion,
|
||||
},
|
||||
Shell: config.ShellConfig{Alias: strings.TrimSpace(commandAlias)},
|
||||
NetBoxes: sources,
|
||||
SSH: config.SSHConfig{
|
||||
DefaultUser: strings.TrimSpace(defaultUser),
|
||||
DefaultPort: port,
|
||||
@@ -207,7 +179,7 @@ func RunWizard(cfg *config.Config) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c := cache.New(loaded.Cache.Path, loaded.Cache.TTL)
|
||||
c := cache.New(loaded.CachePath(), loaded.Cache.TTL)
|
||||
if err := c.Load(); err != nil {
|
||||
return fmt.Errorf("updating aliases: %w", err)
|
||||
}
|
||||
@@ -266,16 +238,24 @@ func save(cfg config.Config) error {
|
||||
if err := config.ValidateCommandAlias(cfg.Shell.Alias); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cfg.ValidateNetBoxes(); err != nil {
|
||||
return err
|
||||
}
|
||||
path := config.Path()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return fmt.Errorf("creating config dir: %w", err)
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("netbox:\n")
|
||||
b.WriteString(fmt.Sprintf(" url: %q\n", cfg.NetBox.URL))
|
||||
b.WriteString(fmt.Sprintf(" token: %q\n", cfg.NetBox.Token))
|
||||
fmt.Fprintf(&b, " token_version: %d\n", cfg.NetBox.TokenVersion)
|
||||
if len(cfg.NetBoxes) > 0 {
|
||||
b.WriteString("netboxes:\n")
|
||||
for _, source := range cfg.NetBoxes {
|
||||
fmt.Fprintf(&b, " - name: %q\n url: %q\n token: %q\n token_version: %d\n", source.Name, source.URL, source.Token, source.TokenVersion)
|
||||
}
|
||||
} else {
|
||||
b.WriteString("netbox:\n")
|
||||
fmt.Fprintf(&b, " url: %q\n token: %q\n token_version: %d\n", cfg.NetBox.URL, cfg.NetBox.Token, cfg.NetBox.TokenVersion)
|
||||
}
|
||||
|
||||
b.WriteString("\nresolver:\n")
|
||||
b.WriteString(" strategies:\n")
|
||||
|
||||
@@ -386,3 +386,37 @@ func TestSaveCommandAliasRoundtripAndDisable(t *testing.T) {
|
||||
t.Fatalf("disable: %+v, %v", loaded, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveMultipleNetBoxesRoundTrip(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
cfg := config.Config{
|
||||
NetBoxes: []config.NetBoxConfig{
|
||||
{Name: "office", URL: "https://office.example/netbox", Token: "nbt_secret: office", TokenVersion: 2},
|
||||
{Name: "lab", URL: "https://lab.example", Token: "legacy#token", TokenVersion: 1},
|
||||
},
|
||||
Resolver: config.ResolverConfig{Strategies: []string{"primary_ip"}},
|
||||
Cache: config.CacheConfig{TTL: 3600},
|
||||
}
|
||||
if err := save(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(loaded.NetBoxes) != 2 || loaded.NetBox.URL != "" {
|
||||
t.Fatalf("instances lost during save: %+v", loaded)
|
||||
}
|
||||
for i, want := range cfg.NetBoxes {
|
||||
if loaded.NetBoxes[i] != want {
|
||||
t.Fatalf("instance changed: %+v, want %+v", loaded.NetBoxes[i], want)
|
||||
}
|
||||
}
|
||||
info, err := os.Stat(config.Path())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Mode().Perm() != 0o600 {
|
||||
t.Fatal("credentials are not private")
|
||||
}
|
||||
}
|
||||
@@ -38,5 +38,5 @@ func MakeNormalizer(cfg config.ShortcutsConfig) func(string) string {
|
||||
// replaces any remaining dots with underscores so the result is a valid identifier.
|
||||
func AliasName(name string, cfg config.ShortcutsConfig) string {
|
||||
s := Normalize(name, cfg)
|
||||
return strings.ReplaceAll(s, ".", "_")
|
||||
return strings.NewReplacer(".", "_", "/", "_").Replace(s)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
||||
)
|
||||
|
||||
func tuiSourceServer(t *testing.T, fail bool) *httptest.Server {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if fail {
|
||||
http.Error(w, "offline", 503)
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/api/dcim/devices/" {
|
||||
fmt.Fprint(w, `{"count":1,"results":[{"id":1,"name":"web","primary_ip4":{"address":"10.0.0.1/24"}}]}`)
|
||||
} else {
|
||||
fmt.Fprint(w, `{"count":0,"results":[]}`)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
func TestCombinedSearchDoesNotStopAtFirstCachedInstance(t *testing.T) {
|
||||
office, lab := tuiSourceServer(t, false), tuiSourceServer(t, false)
|
||||
client := netbox.NewMultiClient([]netbox.Source{{Name: "office", URL: office.URL}, {Name: "lab", URL: lab.URL}})
|
||||
c := cache.New("", 3600)
|
||||
c.Upsert(cache.Entry{Name: "office/web", Kind: "device", IP: "10.0.0.9"})
|
||||
m := New(client, c, "")
|
||||
msg := m.doSearch("web", 1)().(searchResultMsg)
|
||||
if msg.err != nil || len(msg.entries) != 2 {
|
||||
t.Fatalf("cache hid another instance: %+v, %v", msg.entries, msg.err)
|
||||
}
|
||||
m.input.SetValue("web")
|
||||
m.seq = 1
|
||||
m.Update(msg)
|
||||
if len(m.list.Items()) != 2 {
|
||||
t.Fatal("combined results not displayed")
|
||||
}
|
||||
for _, item := range m.list.Items() {
|
||||
if !strings.Contains(item.(hostItem).name, "/") {
|
||||
t.Fatal("instance not visible in results")
|
||||
}
|
||||
}
|
||||
m.list.Select(0)
|
||||
m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
|
||||
if m.selected == nil || m.selected.Name != "lab/web" {
|
||||
t.Fatalf("selection lost source: %+v", m.selected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPartialOutageKeepsLiveAndCachedResultsVisible(t *testing.T) {
|
||||
office, lab := tuiSourceServer(t, false), tuiSourceServer(t, true)
|
||||
client := netbox.NewMultiClient([]netbox.Source{{Name: "office", URL: office.URL}, {Name: "lab", URL: lab.URL}})
|
||||
c := cache.New("", 3600)
|
||||
c.Upsert(cache.Entry{Name: "lab/web", Kind: "device", IP: "10.0.0.2"})
|
||||
m := New(client, c, "")
|
||||
m.input.SetValue("web")
|
||||
m.seq = 1
|
||||
m.Update(tea.WindowSizeMsg{Width: 100, Height: 24})
|
||||
msg := m.doSearch("web", 1)().(searchResultMsg)
|
||||
if len(msg.entries) != 2 || msg.err == nil {
|
||||
t.Fatalf("partial outage lost cache or warning: %+v, %v", msg.entries, msg.err)
|
||||
}
|
||||
m.Update(msg)
|
||||
view := m.View().Content
|
||||
if !strings.Contains(view, "warning:") || !strings.Contains(view, "lab/web") || !strings.Contains(view, "office/web") {
|
||||
t.Fatalf("partial results hidden: %s", view)
|
||||
}
|
||||
if rows := strings.Count(view, "\n"); rows > 24 {
|
||||
t.Fatalf("outage warning overflowed view: %d", rows)
|
||||
}
|
||||
}
|
||||
+31
-20
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -412,13 +413,9 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil // discard stale result
|
||||
}
|
||||
m.loading = false
|
||||
if msg.err != nil {
|
||||
m.err = msg.err
|
||||
return m, nil
|
||||
}
|
||||
m.err = msg.err
|
||||
m.lastResults = msg.entries
|
||||
m.updateListItems(msg.entries)
|
||||
m.err = nil
|
||||
return m, nil
|
||||
}
|
||||
|
||||
@@ -495,9 +492,15 @@ func (m *Model) viewContent() string {
|
||||
|
||||
if m.loading {
|
||||
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(" searching…") + "\n")
|
||||
} else if m.err != nil {
|
||||
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Render(" error: "+m.err.Error()) + "\n")
|
||||
} else {
|
||||
if m.err != nil {
|
||||
warning := " warning: " + strings.ReplaceAll(m.err.Error(), "\n", "; ")
|
||||
style := lipgloss.NewStyle().Foreground(lipgloss.Color("9"))
|
||||
if m.width > 0 {
|
||||
style = style.MaxWidth(m.width)
|
||||
}
|
||||
sb.WriteString(style.Render(warning) + "\n")
|
||||
}
|
||||
if m.recentMode && m.input.Value() == "" {
|
||||
if len(m.list.Items()) > 0 {
|
||||
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(" Recent connections") + "\n")
|
||||
@@ -534,25 +537,30 @@ func (m *Model) startDebounce() tea.Cmd {
|
||||
func (m *Model) doSearch(query string, seq int) tea.Cmd {
|
||||
opts := m.netboxSearchOpts()
|
||||
return func() tea.Msg {
|
||||
// Return cache hits immediately without a network round-trip.
|
||||
var cached []netbox.HostEntry
|
||||
if m.cache != nil {
|
||||
if cached := m.cache.Search(query); len(cached) > 0 {
|
||||
entries := make([]netbox.HostEntry, len(cached))
|
||||
for i, c := range cached {
|
||||
entries[i] = netbox.HostEntry{Name: c.Name, PrimaryIP4: c.IP, Kind: c.Kind, Tags: c.Tags}
|
||||
}
|
||||
return searchResultMsg{query: query, seq: seq, entries: entries}
|
||||
for _, entry := range m.cache.Search(query) {
|
||||
source, name := netbox.SplitTarget(entry.Name)
|
||||
cached = append(cached, netbox.HostEntry{Source: source, Name: name, PrimaryIP4: entry.IP, Kind: entry.Kind, Tags: entry.Tags})
|
||||
}
|
||||
}
|
||||
|
||||
if m.client == nil {
|
||||
return searchResultMsg{query: query, seq: seq, entries: nil}
|
||||
if m.client == nil || (len(cached) > 0 && !m.client.MultipleSources()) {
|
||||
return searchResultMsg{query: query, seq: seq, entries: cached}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
entries, err := m.client.Search(ctx, query, opts)
|
||||
// A cached hit in one instance must not hide live hosts in other instances.
|
||||
seen := make(map[string]bool)
|
||||
for _, e := range entries {
|
||||
seen[e.Target()+"\x00"+e.Kind] = true
|
||||
}
|
||||
for _, e := range cached {
|
||||
if !seen[e.Target()+"\x00"+e.Kind] {
|
||||
entries = append(entries, e)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(entries, func(i, j int) bool { return entries[i].Target() < entries[j].Target() })
|
||||
return searchResultMsg{query: query, seq: seq, entries: entries, err: err}
|
||||
}
|
||||
}
|
||||
@@ -577,7 +585,7 @@ func (m *Model) updateListItems(entries []netbox.HostEntry) {
|
||||
if ip == "" {
|
||||
ip = e.PrimaryIP6
|
||||
}
|
||||
items[i] = hostItem{id: e.ID, name: e.Name, ip: ip, kind: e.Kind, tags: e.Tags}
|
||||
items[i] = hostItem{id: e.ID, name: e.Target(), ip: ip, kind: e.Kind, tags: e.Tags}
|
||||
}
|
||||
m.list.SetItems(items)
|
||||
m.resizeList()
|
||||
@@ -589,6 +597,9 @@ func (m *Model) resizeList() {
|
||||
return
|
||||
}
|
||||
extraRows := 5 // title, blank line, search, footer separator, footer
|
||||
if m.err != nil {
|
||||
extraRows++
|
||||
}
|
||||
if m.recentMode && m.input.Value() == "" && len(m.list.Items()) > 0 {
|
||||
extraRows++
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user