Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f53b90266 | ||
|
|
a2a6dd7591 |
No files matched your search
@@ -1,5 +1,19 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.0.20
|
||||||
|
|
||||||
|
- Configure multiple named NetBox instances and search them together in parallel.
|
||||||
|
- Add, edit, and remove instances in the interactive setup wizard while retaining legacy single-instance configuration support.
|
||||||
|
- Display instance-qualified hostnames and support `instance/hostname` for SSH, completion, aliases, and notes.
|
||||||
|
- Keep credentials, IP resolution, comments, cache entries, and local notes tied to the correct instance.
|
||||||
|
- Preserve partial interactive search results with an outage warning; reject incomplete full refreshes and ambiguous direct connections.
|
||||||
|
- Add `cache refresh --instance NAME` and isolate caches when configured endpoints change.
|
||||||
|
- Add regression tests covering duplicate names and IDs across instances, outages, SSH routing, notes, aliases, and configuration round trips.
|
||||||
|
|
||||||
|
## v0.0.19
|
||||||
|
|
||||||
|
- Update Ultraviolet to commit `a45b7c8`.
|
||||||
|
|
||||||
## v0.0.18
|
## v0.0.18
|
||||||
|
|
||||||
- Require Go 1.27.3 and migrate Bubbles, Bubble Tea, Huh, and Lip Gloss to their v2 modules under `charm.land`.
|
- Require Go 1.27.3 and migrate Bubbles, Bubble Tea, Huh, and Lip Gloss to their v2 modules under `charm.land`.
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ Re-run it at any time to change settings without editing the file manually:
|
|||||||
netssh configure
|
netssh configure
|
||||||
```
|
```
|
||||||
|
|
||||||
The wizard walks through NetBox connection, SSH defaults, resolver strategies, cache TTL, and an optional command alias,
|
The wizard lets you add, edit, and remove named NetBox instances, then walks through SSH defaults, resolver strategies, cache TTL, and an optional command alias,
|
||||||
then saves to `~/.config/netssh.yaml`.
|
then saves to `~/.config/netssh.yaml`.
|
||||||
|
|
||||||
### Manual config
|
### Manual config
|
||||||
@@ -142,7 +142,44 @@ shortcuts:
|
|||||||
strip_hyphens: false # if true, fsn1-web01 → fsn1web01 (cache-only resolution)
|
strip_hyphens: false # if true, fsn1-web01 → fsn1web01 (cache-only resolution)
|
||||||
```
|
```
|
||||||
|
|
||||||
Any value can be overridden with environment variables (`NETSSH_NETBOX_URL`, `NETSSH_NETBOX_TOKEN`, etc.).
|
Single-instance and shared settings can be overridden with environment variables (`NETSSH_NETBOX_URL`, `NETSSH_NETBOX_TOKEN`, etc.).
|
||||||
|
|
||||||
|
### Multiple NetBox instances
|
||||||
|
|
||||||
|
Use `netssh configure` to manage instances, or replace the `netbox:` section with a `netboxes:` list:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
netboxes:
|
||||||
|
- name: office
|
||||||
|
url: https://netbox.office.example
|
||||||
|
token: nbt_office-token
|
||||||
|
- name: lab
|
||||||
|
url: https://netbox.lab.example
|
||||||
|
token: nbt_lab-token
|
||||||
|
```
|
||||||
|
|
||||||
|
Names must be unique and contain only letters, digits, underscores, or hyphens. Names are normalized to lowercase. Each instance has its own token and optional `token_version`; SSH, resolver, and shortcut settings apply to all instances. Configure either `netbox:` or `netboxes:`. Existing single-instance files continue to work; the wizard migrates them to a named `default` instance when saved.
|
||||||
|
|
||||||
|
The legacy `NETSSH_NETBOX_URL` and `NETSSH_NETBOX_TOKEN` variables apply to single-instance configuration. They are ignored when the file contains only a named `netboxes:` list.
|
||||||
|
|
||||||
|
Each NetBox keeps its own inventory of devices and virtual machines; either inventory may be empty. The interactive search queries all instances in parallel, including when one already has cached matches. Results show the instance as part of the hostname, for example `office/web01`. If an instance is unavailable, available results and cached hosts remain visible with a warning.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
netssh # search all instances interactively
|
||||||
|
netssh office/web01 # choose an instance explicitly
|
||||||
|
netssh admin@lab/web01 uptime
|
||||||
|
netssh web01 # accepted only if the live lookup is unambiguous
|
||||||
|
netssh cache refresh # refresh all instances
|
||||||
|
netssh cache refresh --instance lab # refresh just one instance
|
||||||
|
netssh notes set office/web01 "Office maintenance"
|
||||||
|
netssh notes show lab/web01 --netbox --kind device
|
||||||
|
```
|
||||||
|
|
||||||
|
For direct connections without an instance prefix, every instance must be reachable to verify uniqueness. A qualified target queries only its own instance and can use a fresh cached IP while other instances are offline. Qualified shortcuts such as `office/web01` also support configured domain suffixes and hyphen folding.
|
||||||
|
|
||||||
|
Cache entries and generated aliases retain the instance name (`office_web01` and `lab_web01`). Named-instance caches live in a configuration-specific subdirectory beneath the configured cache path, so changing an endpoint cannot reuse another server's cached IPs. Run `netssh cache refresh` after adding or changing instances. A failed full refresh leaves the cache and its refresh timestamp unchanged; a per-instance refresh preserves the other instances and does not mark the whole cache as refreshed.
|
||||||
|
|
||||||
|
Local notes remain keyed by the NetBox URL, host kind, and canonical hostname. Migrating a single instance or renaming it preserves its notes. Use `instance/hostname` and `--kind` for uncached local notes; these operations work offline. NetBox comments and interface/IP lookups always use the selected instance's credentials.
|
||||||
|
|
||||||
### Short command alias
|
### Short command alias
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/shortcuts"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newNetBoxClient(cfg *config.Config) *netbox.Client {
|
||||||
|
if len(cfg.NetBoxes) > 0 {
|
||||||
|
sources := make([]netbox.Source, 0, len(cfg.NetBoxes))
|
||||||
|
for _, source := range cfg.NetBoxes {
|
||||||
|
sources = append(sources, netbox.Source{Name: source.Name, URL: source.URL, Token: source.Token, TokenVersion: source.TokenVersion})
|
||||||
|
}
|
||||||
|
return netbox.NewMultiClient(sources)
|
||||||
|
}
|
||||||
|
if cfg.NetBox.URL == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return netbox.NewClient(cfg.NetBox.URL, cfg.NetBox.Token, cfg.NetBox.TokenVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeTarget(cfg config.ShortcutsConfig, qualified bool) func(string) string {
|
||||||
|
return func(target string) string {
|
||||||
|
source, name := netbox.SplitTarget(target)
|
||||||
|
name = shortcuts.Normalize(name, cfg)
|
||||||
|
if qualified && source != "" {
|
||||||
|
return source + "/" + name
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache"
|
||||||
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func prepareInstancesTest(t *testing.T, officeURL, labURL string) *config.Config {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("HOME", t.TempDir())
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", dir)
|
||||||
|
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
||||||
|
t.Setenv("NETSSH_NETBOX_URL", "")
|
||||||
|
t.Setenv("NETSSH_NETBOX_TOKEN", "")
|
||||||
|
t.Setenv("NETSSH_CACHE_PATH", filepath.Join(t.TempDir(), "hosts.json"))
|
||||||
|
t.Setenv("NETSSH_SHELL_ALIAS", "")
|
||||||
|
data := fmt.Sprintf("netboxes:\n - name: office\n url: %q\n token: office-token\n - name: lab\n url: %q\n token: lab-token\nresolver:\n strategies: [primary_ip]\n", officeURL, labURL)
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "netssh.yaml"), []byte(data), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func multiHost(name, ip string) connectionTestHost {
|
||||||
|
return connectionTestHost{ID: 1, Name: name, PrimaryIP4: &struct {
|
||||||
|
Address string `json:"address"`
|
||||||
|
}{Address: ip + "/24"}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefreshStoresSameHostnameFromBothInstances(t *testing.T) {
|
||||||
|
office := connectionTestServer(t, []connectionTestHost{multiHost("web", "10.0.0.1")}, nil)
|
||||||
|
lab := connectionTestServer(t, []connectionTestHost{multiHost("web", "10.0.0.2")}, nil)
|
||||||
|
cfg := prepareInstancesTest(t, office.URL, lab.URL)
|
||||||
|
cmd := rootCmd()
|
||||||
|
cmd.SetArgs([]string{"cache", "refresh"})
|
||||||
|
if err := cmd.Execute(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||||
|
if err := c.Load(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for name, ip := range map[string]string{"office/web": "10.0.0.1", "lab/web": "10.0.0.2"} {
|
||||||
|
got, fresh := c.Get(name)
|
||||||
|
if !fresh || got.IP != ip {
|
||||||
|
t.Fatalf("cache source collision: %+v, fresh=%v", got, fresh)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(c.All()) != 2 || c.NeedsRefresh(time.Hour) {
|
||||||
|
t.Fatal("full refresh is incomplete")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefreshFailureAndPerInstanceRefresh(t *testing.T) {
|
||||||
|
office := connectionTestServer(t, []connectionTestHost{multiHost("web", "10.0.0.1")}, nil)
|
||||||
|
failed := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "offline", 503) }))
|
||||||
|
defer failed.Close()
|
||||||
|
cfg := prepareInstancesTest(t, office.URL, failed.URL)
|
||||||
|
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||||
|
c.Upsert(cache.Entry{Name: "lab/old", Kind: "device", IP: "10.0.0.2"})
|
||||||
|
if err := c.Save(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cmd := rootCmd()
|
||||||
|
cmd.SetArgs([]string{"cache", "refresh"})
|
||||||
|
if err := cmd.Execute(); err == nil {
|
||||||
|
t.Fatal("failed instance marked as full refresh")
|
||||||
|
}
|
||||||
|
if err := c.Load(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(c.All()) != 1 || !c.NeedsRefresh(time.Hour) {
|
||||||
|
t.Fatal("partial refresh changed cache or full-refresh stamp")
|
||||||
|
}
|
||||||
|
cmd = rootCmd()
|
||||||
|
cmd.SetArgs([]string{"cache", "refresh", "--instance", "office"})
|
||||||
|
if err := cmd.Execute(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := c.Load(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(c.All()) != 2 || !c.NeedsRefresh(time.Hour) {
|
||||||
|
t.Fatal("per-instance refresh lost other source or updated full stamp")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalNotesCLIKeepsInstancesSeparate(t *testing.T) {
|
||||||
|
cfg := prepareInstancesTest(t, "https://office.example", "https://lab.example")
|
||||||
|
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||||
|
for _, name := range []string{"office/web", "lab/web"} {
|
||||||
|
c.Upsert(cache.Entry{Name: name, Kind: "device", IP: "10.0.0.1"})
|
||||||
|
}
|
||||||
|
if err := c.Save(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
run := func(args ...string) (string, error) {
|
||||||
|
var out bytes.Buffer
|
||||||
|
cmd := rootCmd()
|
||||||
|
cmd.SetArgs(append([]string{"notes"}, args...))
|
||||||
|
cmd.SetOut(&out)
|
||||||
|
cmd.SetErr(&out)
|
||||||
|
err := cmd.Execute()
|
||||||
|
return out.String(), err
|
||||||
|
}
|
||||||
|
for _, source := range []string{"office", "lab"} {
|
||||||
|
if _, err := run("set", source+"/web", source+" note"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, source := range []string{"office", "lab"} {
|
||||||
|
got, err := run("show", source+"/web")
|
||||||
|
if err != nil || got != source+" note" {
|
||||||
|
t.Fatalf("CLI notes crossed sources: %q, %v", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := run("set", "web", "wrong note"); err == nil {
|
||||||
|
t.Fatal("ambiguous note target accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMultiInstanceWrapperHelper(t *testing.T) {
|
||||||
|
if target := os.Getenv("NETSSH_TEST_MULTI_TARGET"); target != "" {
|
||||||
|
runSSHWrapper([]string{target})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWrapperRoutesQualifiedTargetsAndRejectsAmbiguity(t *testing.T) {
|
||||||
|
office := connectionTestServer(t, []connectionTestHost{multiHost("web", "10.0.0.1")}, nil)
|
||||||
|
lab := connectionTestServer(t, []connectionTestHost{multiHost("web", "10.0.0.2")}, nil)
|
||||||
|
cfg := prepareInstancesTest(t, office.URL, lab.URL)
|
||||||
|
c := cache.New(cfg.CachePath(), 3600)
|
||||||
|
if err := c.SetRefreshed(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
bin := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(bin, "ssh"), []byte("#!/bin/sh\nprintf '%s\\n' \"$@\"\n"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for target, want := range map[string]string{"office/web": "10.0.0.1", "lab/web": "10.0.0.2", "web": "ambiguous"} {
|
||||||
|
cmd := exec.Command(os.Args[0], "-test.run=^TestMultiInstanceWrapperHelper$")
|
||||||
|
cmd.Env = append(os.Environ(), "NETSSH_TEST_MULTI_TARGET="+target, "PATH="+bin+":"+os.Getenv("PATH"))
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if want == "ambiguous" {
|
||||||
|
if err == nil || !strings.Contains(string(out), "2 exact matches") {
|
||||||
|
t.Fatalf("ambiguous connection accepted: %s, %v", out, err)
|
||||||
|
}
|
||||||
|
} else if err != nil || !strings.Contains(string(out), want+"\n") {
|
||||||
|
t.Fatalf("wrong SSH destination for %s: %s, %v", target, out, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQualifiedShortcutDoesNotMatchOtherInstance(t *testing.T) {
|
||||||
|
cfg := config.ShortcutsConfig{Domains: []string{".example.com"}, StripHyphens: true}
|
||||||
|
normalize := normalizeTarget(cfg, true)
|
||||||
|
if normalize("office/web-01.example.com") != normalize("office/web01") {
|
||||||
|
t.Fatal("qualified shortcut does not normalize")
|
||||||
|
}
|
||||||
|
if normalize("office/web01") == normalize("lab/web01") {
|
||||||
|
t.Fatal("qualified shortcut lost instance")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefreshCachesIndependentDevicesAndVMs(t *testing.T) {
|
||||||
|
office := connectionTestServer(t, []connectionTestHost{multiHost("router", "10.1.0.1")}, []connectionTestHost{multiHost("app", "10.1.0.2")})
|
||||||
|
lab := connectionTestServer(t, []connectionTestHost{multiHost("switch", "10.2.0.1")}, []connectionTestHost{multiHost("test-vm", "10.2.0.2")})
|
||||||
|
cfg := prepareInstancesTest(t, office.URL, lab.URL)
|
||||||
|
cmd := rootCmd()
|
||||||
|
cmd.SetArgs([]string{"cache", "refresh"})
|
||||||
|
if err := cmd.Execute(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||||
|
if err := c.Load(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := map[string]struct{ kind, ip string }{
|
||||||
|
"office/router": {"device", "10.1.0.1"}, "office/app": {"vm", "10.1.0.2"},
|
||||||
|
"lab/switch": {"device", "10.2.0.1"}, "lab/test-vm": {"vm", "10.2.0.2"},
|
||||||
|
}
|
||||||
|
if len(c.All()) != len(want) {
|
||||||
|
t.Fatalf("missing inventory entries: %+v", c.All())
|
||||||
|
}
|
||||||
|
for name, entry := range want {
|
||||||
|
got, fresh := c.Get(name)
|
||||||
|
if !fresh || got.Kind != entry.kind || got.IP != entry.ip {
|
||||||
|
t.Fatalf("wrong cached host %s: %+v", name, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+63
-37
@@ -23,7 +23,6 @@ import (
|
|||||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/notes"
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/notes"
|
||||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/resolver"
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/resolver"
|
||||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/setup"
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/setup"
|
||||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/shortcuts"
|
|
||||||
internalssh "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/ssh"
|
internalssh "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/ssh"
|
||||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/tui"
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/tui"
|
||||||
)
|
)
|
||||||
@@ -66,7 +65,7 @@ func loadConfigOrSetup() *config.Config {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
fatalf("config: %v", err)
|
fatalf("config: %v", err)
|
||||||
}
|
}
|
||||||
if cfg.NetBox.URL == "" {
|
if !cfg.HasNetBox() {
|
||||||
fmt.Fprintln(os.Stderr, "No configuration found. Starting setup…")
|
fmt.Fprintln(os.Stderr, "No configuration found. Starting setup…")
|
||||||
if err := setup.RunWizard(cfg); err != nil {
|
if err := setup.RunWizard(cfg); err != nil {
|
||||||
fatalf("setup: %v", err)
|
fatalf("setup: %v", err)
|
||||||
@@ -103,12 +102,15 @@ func runSSHWrapper(args []string) {
|
|||||||
parsed.DestIdx += 2
|
parsed.DestIdx += 2
|
||||||
}
|
}
|
||||||
|
|
||||||
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
|
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||||
_ = c.Load()
|
_ = c.Load()
|
||||||
maybeBackgroundRefresh(cfg, c)
|
maybeBackgroundRefresh(cfg, c)
|
||||||
|
|
||||||
|
source, _ := netbox.SplitTarget(parsed.Host)
|
||||||
|
useCache := len(cfg.NetBoxes) == 0 || source != ""
|
||||||
|
|
||||||
// Cache hit with a fresh TTL — connect directly without querying NetBox.
|
// Cache hit with a fresh TTL — connect directly without querying NetBox.
|
||||||
if entry, fresh := c.Get(parsed.Host); fresh {
|
if entry, fresh := c.Get(parsed.Host); fresh && useCache {
|
||||||
c.MarkUsed(parsed.Host)
|
c.MarkUsed(parsed.Host)
|
||||||
_ = c.Save()
|
_ = c.Save()
|
||||||
connect(entry.IP, parsed, args)
|
connect(entry.IP, parsed, args)
|
||||||
@@ -119,8 +121,8 @@ func runSSHWrapper(args []string) {
|
|||||||
// If a stale match is found, use the canonical name for the NetBox re-fetch below.
|
// If a stale match is found, use the canonical name for the NetBox re-fetch below.
|
||||||
lookupHost := parsed.Host
|
lookupHost := parsed.Host
|
||||||
shortcutsEnabled := len(cfg.Shortcuts.Domains) > 0 || cfg.Shortcuts.StripHyphens
|
shortcutsEnabled := len(cfg.Shortcuts.Domains) > 0 || cfg.Shortcuts.StripHyphens
|
||||||
if cached, _ := c.Get(lookupHost); shortcutsEnabled && cached.Name == "" {
|
if cached, _ := c.Get(lookupHost); shortcutsEnabled && cached.Name == "" && useCache {
|
||||||
normalize := shortcuts.MakeNormalizer(cfg.Shortcuts)
|
normalize := normalizeTarget(cfg.Shortcuts, source != "")
|
||||||
entry, found, fresh, err := c.GetByShortcut(lookupHost, normalize)
|
entry, found, fresh, err := c.GetByShortcut(lookupHost, normalize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fatalf("%v", err)
|
fatalf("%v", err)
|
||||||
@@ -137,19 +139,35 @@ func runSSHWrapper(args []string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.NetBox.URL == "" {
|
if !cfg.HasNetBox() {
|
||||||
fatalf("netbox.url is not configured (~/.config/netssh.yaml)")
|
fatalf("netbox.url is not configured (~/.config/netssh.yaml)")
|
||||||
}
|
}
|
||||||
nbClient := netbox.NewClient(cfg.NetBox.URL, cfg.NetBox.Token, cfg.NetBox.TokenVersion)
|
nbClient := newNetBoxClient(cfg)
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
target, err := findSSHHost(ctx, nbClient, lookupHost, cfg.Shortcuts.Domains)
|
target, err := findSSHHost(ctx, nbClient, lookupHost, cfg.Shortcuts.Domains)
|
||||||
|
if errors.Is(err, netbox.ErrHostNotFound) && !useCache && shortcutsEnabled {
|
||||||
|
entry, found, _, cacheErr := c.GetByShortcut(parsed.Host, normalizeTarget(cfg.Shortcuts, false))
|
||||||
|
if cacheErr != nil {
|
||||||
|
fatalf("%v", cacheErr)
|
||||||
|
}
|
||||||
|
if found {
|
||||||
|
target, err = findSSHHost(ctx, nbClient, entry.Name, cfg.Shortcuts.Domains)
|
||||||
|
}
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fatalf("NetBox: %v", err)
|
fatalf("NetBox: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cached, fresh := c.Get(target.Target()); fresh && cached.Kind == target.Kind {
|
||||||
|
c.MarkUsed(target.Target())
|
||||||
|
_ = c.Save()
|
||||||
|
connect(cached.IP, parsed, args)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
chain, err := resolver.New(cfg.Resolver)
|
chain, err := resolver.New(cfg.Resolver)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fatalf("resolver: %v", err)
|
fatalf("resolver: %v", err)
|
||||||
@@ -160,8 +178,8 @@ func runSSHWrapper(args []string) {
|
|||||||
fatalf("IP resolution for %q: %v", parsed.Host, err)
|
fatalf("IP resolution for %q: %v", parsed.Host, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Upsert(cache.Entry{Name: target.Name, IP: ip, Kind: target.Kind, Tags: target.Tags})
|
c.Upsert(cache.Entry{Name: target.Target(), IP: ip, Kind: target.Kind, Tags: target.Tags})
|
||||||
c.MarkUsed(target.Name)
|
c.MarkUsed(target.Target())
|
||||||
_ = c.Save()
|
_ = c.Save()
|
||||||
|
|
||||||
connect(ip, parsed, args)
|
connect(ip, parsed, args)
|
||||||
@@ -198,13 +216,13 @@ func connect(ip string, parsed *internalssh.ParsedArgs, originalArgs []string) {
|
|||||||
func runTUI() {
|
func runTUI() {
|
||||||
cfg := loadConfigOrSetup()
|
cfg := loadConfigOrSetup()
|
||||||
|
|
||||||
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
|
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||||
_ = c.Load()
|
_ = c.Load()
|
||||||
maybeBackgroundRefresh(cfg, c)
|
maybeBackgroundRefresh(cfg, c)
|
||||||
|
|
||||||
var nbClient *netbox.Client
|
var nbClient *netbox.Client
|
||||||
if cfg.NetBox.URL != "" {
|
if cfg.HasNetBox() {
|
||||||
nbClient = netbox.NewClient(cfg.NetBox.URL, cfg.NetBox.Token, cfg.NetBox.TokenVersion)
|
nbClient = newNetBoxClient(cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
m := tui.New(nbClient, c, cfg.SSH.DefaultUser, cfg.SSH.DefaultPort)
|
m := tui.New(nbClient, c, cfg.SSH.DefaultUser, cfg.SSH.DefaultPort)
|
||||||
@@ -245,7 +263,7 @@ func runTUI() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
fatalf("IP resolution: %v", err)
|
fatalf("IP resolution: %v", err)
|
||||||
}
|
}
|
||||||
c.Upsert(cache.Entry{Name: target.Name, IP: host.IP, Kind: target.Kind, Tags: target.Tags})
|
c.Upsert(cache.Entry{Name: target.Target(), IP: host.IP, Kind: target.Kind, Tags: target.Tags})
|
||||||
}
|
}
|
||||||
fmt.Fprintf(os.Stderr, "Connecting to %s (%s)…\n", host.Name, host.IP)
|
fmt.Fprintf(os.Stderr, "Connecting to %s (%s)…\n", host.Name, host.IP)
|
||||||
|
|
||||||
@@ -290,7 +308,7 @@ func rootCmd() *cobra.Command {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, cobra.ShellCompDirectiveError
|
return nil, cobra.ShellCompDirectiveError
|
||||||
}
|
}
|
||||||
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
|
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||||
_ = c.Load()
|
_ = c.Load()
|
||||||
|
|
||||||
var completions []cobra.Completion
|
var completions []cobra.Completion
|
||||||
@@ -403,9 +421,9 @@ func configureCmd() *cobra.Command {
|
|||||||
Use: "configure",
|
Use: "configure",
|
||||||
Short: "Interactively configure netssh",
|
Short: "Interactively configure netssh",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
cfg, _ := config.Load()
|
cfg, err := config.Load()
|
||||||
if cfg == nil {
|
if err != nil {
|
||||||
cfg = &config.Config{}
|
return err
|
||||||
}
|
}
|
||||||
return setup.RunWizard(cfg)
|
return setup.RunWizard(cfg)
|
||||||
},
|
},
|
||||||
@@ -422,7 +440,7 @@ func searchCmd() *cobra.Command {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
|
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||||
if err := c.Load(); err != nil {
|
if err := c.Load(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -456,7 +474,7 @@ func cacheListCmd() *cobra.Command {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
|
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||||
if err := c.Load(); err != nil {
|
if err := c.Load(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -482,7 +500,7 @@ func cacheClearCmd() *cobra.Command {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
|
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||||
c.Clear()
|
c.Clear()
|
||||||
if err := c.Save(); err != nil {
|
if err := c.Save(); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -494,7 +512,7 @@ func cacheClearCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func cacheRefreshCmd() *cobra.Command {
|
func cacheRefreshCmd() *cobra.Command {
|
||||||
var filterTag, filterKind string
|
var filterTag, filterKind, filterInstance string
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "refresh",
|
Use: "refresh",
|
||||||
Short: "Re-fetch all known hosts from NetBox",
|
Short: "Re-fetch all known hosts from NetBox",
|
||||||
@@ -503,21 +521,28 @@ func cacheRefreshCmd() *cobra.Command {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if cfg.NetBox.URL == "" {
|
if !cfg.HasNetBox() {
|
||||||
return fmt.Errorf("netbox.url is not configured")
|
return fmt.Errorf("netbox.url is not configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
nbClient := netbox.NewClient(cfg.NetBox.URL, cfg.NetBox.Token, cfg.NetBox.TokenVersion)
|
nbClient := newNetBoxClient(cfg)
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
opts := netbox.SearchOptions{Tag: filterTag, Kind: filterKind}
|
opts := netbox.SearchOptions{Tag: filterTag, Kind: filterKind}
|
||||||
entries, err := nbClient.SearchAll(ctx, "", opts)
|
query := ""
|
||||||
|
if filterInstance != "" {
|
||||||
|
if !nbClient.MultipleSources() {
|
||||||
|
return fmt.Errorf("--instance requires named netboxes configuration")
|
||||||
|
}
|
||||||
|
query = filterInstance + "/"
|
||||||
|
}
|
||||||
|
entries, err := nbClient.SearchAll(ctx, query, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("NetBox: %w", err)
|
return fmt.Errorf("NetBox: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
|
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||||
_ = c.Load()
|
_ = c.Load()
|
||||||
|
|
||||||
chain, err := resolver.New(cfg.Resolver)
|
chain, err := resolver.New(cfg.Resolver)
|
||||||
@@ -527,12 +552,12 @@ func cacheRefreshCmd() *cobra.Command {
|
|||||||
written := 0
|
written := 0
|
||||||
nameCounts := make(map[string]int)
|
nameCounts := make(map[string]int)
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
nameCounts[strings.ToLower(e.Name)]++
|
nameCounts[strings.ToLower(e.Target())]++
|
||||||
}
|
}
|
||||||
for i := range entries {
|
for i := range entries {
|
||||||
e := &entries[i]
|
e := &entries[i]
|
||||||
if nameCounts[strings.ToLower(e.Name)] > 1 {
|
if nameCounts[strings.ToLower(e.Target())] > 1 {
|
||||||
c.Delete(e.Name)
|
c.Delete(e.Target())
|
||||||
fmt.Fprintf(os.Stderr, "warning: skipping ambiguous hostname %q\n", e.Name)
|
fmt.Fprintf(os.Stderr, "warning: skipping ambiguous hostname %q\n", e.Name)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -544,18 +569,18 @@ func cacheRefreshCmd() *cobra.Command {
|
|||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
}
|
}
|
||||||
c.Delete(e.Name)
|
c.Delete(e.Target())
|
||||||
fmt.Fprintf(os.Stderr, "warning: skipping %s: %v\n", e.Name, err)
|
fmt.Fprintf(os.Stderr, "warning: skipping %s: %v\n", e.Name, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
c.Upsert(cache.Entry{Name: e.Name, IP: ip, Kind: e.Kind, Tags: e.Tags})
|
c.Upsert(cache.Entry{Name: e.Target(), IP: ip, Kind: e.Kind, Tags: e.Tags})
|
||||||
written++
|
written++
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.Save(); err != nil {
|
if err := c.Save(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if filterTag == "" && filterKind == "" {
|
if filterTag == "" && filterKind == "" && filterInstance == "" {
|
||||||
if err := c.SetRefreshed(); err != nil {
|
if err := c.SetRefreshed(); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "warning: could not update refresh timestamp: %v\n", err)
|
fmt.Fprintf(os.Stderr, "warning: could not update refresh timestamp: %v\n", err)
|
||||||
}
|
}
|
||||||
@@ -571,6 +596,7 @@ func cacheRefreshCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
cmd.Flags().StringVar(&filterTag, "tag", "", "Filter by NetBox tag slug (e.g. prod)")
|
cmd.Flags().StringVar(&filterTag, "tag", "", "Filter by NetBox tag slug (e.g. prod)")
|
||||||
cmd.Flags().StringVar(&filterKind, "kind", "", "Filter by kind: device or vm")
|
cmd.Flags().StringVar(&filterKind, "kind", "", "Filter by kind: device or vm")
|
||||||
|
cmd.Flags().StringVar(&filterInstance, "instance", "", "Refresh only this named NetBox instance")
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -599,7 +625,7 @@ Or use in a script:
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
|
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||||
if err := c.Load(); err != nil {
|
if err := c.Load(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -644,7 +670,7 @@ func shellInitCmd() *cobra.Command {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
|
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||||
_ = c.Load()
|
_ = c.Load()
|
||||||
|
|
||||||
// Regenerate alias files from the current cache every shell start
|
// Regenerate alias files from the current cache every shell start
|
||||||
@@ -705,8 +731,8 @@ After installation, reload your profile or open a new shell.`,
|
|||||||
|
|
||||||
// Generate the aliases file immediately so it is ready on the next shell start.
|
// Generate the aliases file immediately so it is ready on the next shell start.
|
||||||
cfg, cfgErr := config.Load()
|
cfg, cfgErr := config.Load()
|
||||||
if cfgErr == nil && cfg.NetBox.URL != "" {
|
if cfgErr == nil && cfg.HasNetBox() {
|
||||||
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
|
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||||
if _ = c.Load(); true {
|
if _ = c.Load(); true {
|
||||||
if entries := buildAliasEntries(c, *cfg); len(entries) > 0 {
|
if entries := buildAliasEntries(c, *cfg); len(entries) > 0 {
|
||||||
if err := hook.WriteAliasFiles(entries); err != nil {
|
if err := hook.WriteAliasFiles(entries); err != nil {
|
||||||
@@ -758,7 +784,7 @@ func hookUninstallCmd() *cobra.Command {
|
|||||||
// maybeBackgroundRefresh starts a background `netssh cache refresh` if the cache
|
// maybeBackgroundRefresh starts a background `netssh cache refresh` if the cache
|
||||||
// has not been fully refreshed within the last 24 hours.
|
// has not been fully refreshed within the last 24 hours.
|
||||||
func maybeBackgroundRefresh(cfg *config.Config, c *cache.Cache) {
|
func maybeBackgroundRefresh(cfg *config.Config, c *cache.Cache) {
|
||||||
if cfg.NetBox.URL == "" {
|
if !cfg.HasNetBox() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !c.NeedsRefresh(24 * time.Hour) {
|
if !c.NeedsRefresh(24 * time.Hour) {
|
||||||
|
|||||||
+17
-7
@@ -34,11 +34,11 @@ func notesCmd() *cobra.Command {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var client *netbox.Client
|
var client *netbox.Client
|
||||||
if cfg.NetBox.URL != "" {
|
if cfg.HasNetBox() {
|
||||||
client = netbox.NewClient(cfg.NetBox.URL, cfg.NetBox.Token, cfg.NetBox.TokenVersion)
|
client = newNetBoxClient(cfg)
|
||||||
}
|
}
|
||||||
host := netbox.HostEntry{Name: args[0], Kind: kind}
|
host := netbox.HostEntry{Name: args[0], Kind: kind}
|
||||||
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
|
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||||
if err := c.Load(); err != nil {
|
if err := c.Load(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -46,13 +46,23 @@ func notesCmd() *cobra.Command {
|
|||||||
if !remote {
|
if !remote {
|
||||||
cached = c.All()
|
cached = c.All()
|
||||||
}
|
}
|
||||||
|
query := host.Name
|
||||||
|
source, rawName := netbox.SplitTarget(query)
|
||||||
|
var matches []cache.Entry
|
||||||
for _, entry := range cached {
|
for _, entry := range cached {
|
||||||
if strings.EqualFold(entry.Name, host.Name) && (kind == "" || kind == entry.Kind) {
|
_, entryName := netbox.SplitTarget(entry.Name)
|
||||||
host.Name = entry.Name
|
if (kind == "" || kind == entry.Kind) && (strings.EqualFold(entry.Name, query) || (source == "" && strings.EqualFold(entryName, rawName))) {
|
||||||
host.Kind = entry.Kind
|
matches = append(matches, entry)
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if len(matches) > 1 {
|
||||||
|
return fmt.Errorf("ambiguous hostname %q; use instance/hostname", query)
|
||||||
|
}
|
||||||
|
if len(matches) == 1 {
|
||||||
|
host.Name = matches[0].Name
|
||||||
|
host.Kind = matches[0].Kind
|
||||||
|
}
|
||||||
|
|
||||||
if !remote && host.Kind == "" {
|
if !remote && host.Kind == "" {
|
||||||
return fmt.Errorf("host is not cached; specify --kind device or --kind vm")
|
return fmt.Errorf("host is not cached; specify --kind device or --kind vm")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ require (
|
|||||||
github.com/atotto/clipboard v0.1.4 // indirect
|
github.com/atotto/clipboard v0.1.4 // indirect
|
||||||
github.com/catppuccin/go v0.3.0 // indirect
|
github.com/catppuccin/go v0.3.0 // indirect
|
||||||
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
||||||
github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 // indirect
|
github.com/charmbracelet/ultraviolet v0.0.0-20261023060817-a45b7c86333f // indirect
|
||||||
github.com/charmbracelet/x/ansi v0.11.9 // indirect
|
github.com/charmbracelet/x/ansi v0.11.9 // indirect
|
||||||
github.com/charmbracelet/x/exp/golden v0.1.0 // indirect
|
github.com/charmbracelet/x/exp/golden v0.1.0 // indirect
|
||||||
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
|
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
|
|||||||
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
|
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
|
||||||
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
|
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
|
||||||
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
||||||
github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 h1:hISLzUbwcvJbGoNR6A3L/TpguRRnACBmZOee8LcHLJw=
|
github.com/charmbracelet/ultraviolet v0.0.0-20261023060817-a45b7c86333f h1:D2JpB65eehsnkEwEG401dMBSNYtTDgvKXZZSl7ZCxWI=
|
||||||
github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4/go.mod h1:aJDVk+WOVM1K04rdc/71PMPoUGfjxjhvXfP3Pjz1Zio=
|
github.com/charmbracelet/ultraviolet v0.0.0-20261023060817-a45b7c86333f/go.mod h1:aJDVk+WOVM1K04rdc/71PMPoUGfjxjhvXfP3Pjz1Zio=
|
||||||
github.com/charmbracelet/x/ansi v0.11.9 h1:kIrqLBxtTVbLyyHO2Evoi93EjQo1mylWmpNEsfPxXAM=
|
github.com/charmbracelet/x/ansi v0.11.9 h1:kIrqLBxtTVbLyyHO2Evoi93EjQo1mylWmpNEsfPxXAM=
|
||||||
github.com/charmbracelet/x/ansi v0.11.9/go.mod h1:DG/hpOnaI2QJ/KMMqLL2h0KVhluuJLazf8973P+Vy5w=
|
github.com/charmbracelet/x/ansi v0.11.9/go.mod h1:DG/hpOnaI2QJ/KMMqLL2h0KVhluuJLazf8973P+Vy5w=
|
||||||
github.com/charmbracelet/x/conpty v0.2.0 h1:eKtA2hm34qNfgJCDp/M6Dc0gLy7e07YEK4qAdNGOvVY=
|
github.com/charmbracelet/x/conpty v0.2.0 h1:eKtA2hm34qNfgJCDp/M6Dc0gLy7e07YEK4qAdNGOvVY=
|
||||||
|
|||||||
Vendored
+2
-1
@@ -258,7 +258,8 @@ func (c *Cache) Search(prefix string) []Entry {
|
|||||||
prefix = strings.ToLower(prefix)
|
prefix = strings.ToLower(prefix)
|
||||||
var out []Entry
|
var out []Entry
|
||||||
for name, e := range c.entries {
|
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)
|
out = append(out, e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+12
@@ -483,3 +483,15 @@ func TestUpsertPreservesUsageHistory(t *testing.T) {
|
|||||||
t.Fatalf("history lost: %+v", after)
|
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 {
|
type Config struct {
|
||||||
Shell ShellConfig `mapstructure:"shell"`
|
Shell ShellConfig `mapstructure:"shell"`
|
||||||
NetBox NetBoxConfig `mapstructure:"netbox"`
|
NetBox NetBoxConfig `mapstructure:"netbox"`
|
||||||
|
NetBoxes []NetBoxConfig `mapstructure:"netboxes"`
|
||||||
Resolver ResolverConfig `mapstructure:"resolver"`
|
Resolver ResolverConfig `mapstructure:"resolver"`
|
||||||
Cache CacheConfig `mapstructure:"cache"`
|
Cache CacheConfig `mapstructure:"cache"`
|
||||||
SSH SSHConfig `mapstructure:"ssh"`
|
SSH SSHConfig `mapstructure:"ssh"`
|
||||||
@@ -24,6 +25,7 @@ type ShellConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type NetBoxConfig struct {
|
type NetBoxConfig struct {
|
||||||
|
Name string `mapstructure:"name"`
|
||||||
URL string `mapstructure:"url"`
|
URL string `mapstructure:"url"`
|
||||||
Token string `mapstructure:"token"`
|
Token string `mapstructure:"token"`
|
||||||
TokenVersion int `mapstructure:"token_version"`
|
TokenVersion int `mapstructure:"token_version"`
|
||||||
@@ -93,6 +95,11 @@ func Load() (*Config, error) {
|
|||||||
if err := v.Unmarshal(&cfg); err != nil {
|
if err := v.Unmarshal(&cfg); err != nil {
|
||||||
return nil, fmt.Errorf("parsing config: %w", err)
|
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 {
|
if err := ValidateCommandAlias(cfg.Shell.Alias); err != nil {
|
||||||
return nil, err
|
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 == "" {
|
if cfg.Cache.Path == "" {
|
||||||
cacheDir, err := os.UserCacheDir()
|
cacheDir, err := os.UserCacheDir()
|
||||||
if err != nil {
|
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)
|
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 {
|
type Client struct {
|
||||||
|
sources map[string]*Client
|
||||||
baseURL string
|
baseURL string
|
||||||
token string
|
token string
|
||||||
tokenVersion int
|
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.
|
// Search queries up to 50 devices and VMs in parallel and merges the results.
|
||||||
// Use SearchOptions to restrict by kind or tag.
|
// Use SearchOptions to restrict by kind or tag.
|
||||||
func (c *Client) Search(ctx context.Context, query string, opts SearchOptions) ([]HostEntry, error) {
|
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" {
|
if opts.Kind != "" && opts.Kind != "device" && opts.Kind != "vm" {
|
||||||
return nil, fmt.Errorf("invalid host kind %q", opts.Kind)
|
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.
|
// SearchAll paginates through all matching devices and VMs, fetching every page.
|
||||||
// Intended for cache refresh; use Search for interactive queries.
|
// Intended for cache refresh; use Search for interactive queries.
|
||||||
func (c *Client) SearchAll(ctx context.Context, query string, opts SearchOptions) ([]HostEntry, error) {
|
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" {
|
if opts.Kind != "" && opts.Kind != "device" && opts.Kind != "vm" {
|
||||||
return nil, fmt.Errorf("invalid host kind %q", opts.Kind)
|
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
|
// GetIPs returns all IP addresses assigned to a host, used by resolver strategies
|
||||||
// that need more than just the primary IP.
|
// that need more than just the primary IP.
|
||||||
func (c *Client) GetIPs(ctx context.Context, entry HostEntry) ([]string, error) {
|
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
|
var apiURL string
|
||||||
switch entry.Kind {
|
switch entry.Kind {
|
||||||
case "device":
|
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.
|
// GetIPsWithFilter calls /api/ipam/ip-addresses/ with arbitrary filter query parameters.
|
||||||
func (c *Client) GetIPsWithFilter(ctx context.Context, filterParams string) ([]string, error) {
|
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)
|
apiURL := fmt.Sprintf("%s/api/ipam/ip-addresses/?%s&limit=100", c.baseURL, filterParams)
|
||||||
var resp netboxIPListResponse
|
var resp netboxIPListResponse
|
||||||
if err := c.get(ctx, apiURL, &resp); err != nil {
|
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)
|
return HostEntry{}, fmt.Errorf("invalid kind %q", kind)
|
||||||
}
|
}
|
||||||
entries, err := c.SearchAll(ctx, name, SearchOptions{Kind: kind})
|
entries, err := c.SearchAll(ctx, name, SearchOptions{Kind: kind})
|
||||||
|
_, hostname := SplitTarget(name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return HostEntry{}, err
|
return HostEntry{}, err
|
||||||
}
|
}
|
||||||
var matches []HostEntry
|
var matches []HostEntry
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
if strings.EqualFold(e.Name, name) {
|
if strings.EqualFold(e.Name, hostname) {
|
||||||
matches = append(matches, e)
|
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)
|
return HostEntry{}, fmt.Errorf("%w: %q", ErrHostNotFound, name)
|
||||||
}
|
}
|
||||||
if len(matches) != 1 {
|
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 HostEntry{}, fmt.Errorf("host %q: found %d exact matches; use --kind or a unique hostname", name, len(matches))
|
||||||
}
|
}
|
||||||
return matches[0], nil
|
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) {
|
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)
|
u, err := c.hostURL(host)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", err
|
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 {
|
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)
|
u, err := c.hostURL(host)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package netbox
|
package netbox
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
// SearchOptions filters a Search or SearchAll query.
|
// SearchOptions filters a Search or SearchAll query.
|
||||||
// Zero value means no filtering (return all kinds, no tag filter).
|
// Zero value means no filtering (return all kinds, no tag filter).
|
||||||
type SearchOptions struct {
|
type SearchOptions struct {
|
||||||
@@ -9,6 +11,7 @@ type SearchOptions struct {
|
|||||||
|
|
||||||
// HostEntry is a unified model for both devices and virtual machines from NetBox.
|
// HostEntry is a unified model for both devices and virtual machines from NetBox.
|
||||||
type HostEntry struct {
|
type HostEntry struct {
|
||||||
|
Source string // named NetBox instance; empty for the legacy single-instance config
|
||||||
ID int
|
ID int
|
||||||
Name string
|
Name string
|
||||||
Kind string // "device" | "vm"
|
Kind string // "device" | "vm"
|
||||||
@@ -58,3 +61,19 @@ type netboxIPListResponse struct {
|
|||||||
} `json:"assigned_object"`
|
} `json:"assigned_object"`
|
||||||
} `json:"results"`
|
} `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)
|
note.Text, note.ETag, err = s.Client.GetComments(ctx, note.Host)
|
||||||
return note, err
|
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) {
|
if os.IsNotExist(err) {
|
||||||
return note, nil
|
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 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")
|
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)
|
return "", fmt.Errorf("unknown kind %q", entry.Kind)
|
||||||
}
|
}
|
||||||
|
|
||||||
ips, err := client.GetIPsWithFilter(ctx, filterParam)
|
ips, err := client.GetIPsWithFilterForHost(ctx, *entry, filterParam)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("fetching IPs for interface %q: %w", s.name, err)
|
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/cache"
|
||||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
"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/hook"
|
||||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
|
||||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/storage"
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RunWizard runs the interactive setup form, pre-filled with any existing cfg values.
|
// RunWizard runs the interactive setup form, pre-filled with any existing cfg values.
|
||||||
// It saves the result to the config file on success.
|
// It saves the result to the config file on success.
|
||||||
func RunWizard(cfg *config.Config) error {
|
func RunWizard(cfg *config.Config) error {
|
||||||
url := cfg.NetBox.URL
|
sources, err := configureInstances(cfg)
|
||||||
token := cfg.NetBox.Token
|
if err != nil {
|
||||||
|
if errors.Is(err, huh.ErrUserAborted) {
|
||||||
|
fmt.Fprintln(os.Stderr, "Setup cancelled.")
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
defaultUser := cfg.SSH.DefaultUser
|
defaultUser := cfg.SSH.DefaultUser
|
||||||
defaultPort := ""
|
defaultPort := ""
|
||||||
if cfg.SSH.DefaultPort > 0 {
|
if cfg.SSH.DefaultPort > 0 {
|
||||||
@@ -43,30 +48,6 @@ func RunWizard(cfg *config.Config) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
form := huh.NewForm(
|
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.NewGroup(
|
||||||
huh.NewInput().
|
huh.NewInput().
|
||||||
Title("Default SSH user").
|
Title("Default SSH user").
|
||||||
@@ -144,11 +125,6 @@ func RunWizard(cfg *config.Config) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
strategies := parseStrategies(strategiesRaw)
|
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)
|
ttl, _ := strconv.Atoi(cacheTTL)
|
||||||
|
|
||||||
@@ -175,12 +151,8 @@ func RunWizard(cfg *config.Config) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
out := config.Config{
|
out := config.Config{
|
||||||
Shell: config.ShellConfig{Alias: strings.TrimSpace(commandAlias)},
|
Shell: config.ShellConfig{Alias: strings.TrimSpace(commandAlias)},
|
||||||
NetBox: config.NetBoxConfig{
|
NetBoxes: sources,
|
||||||
URL: strings.TrimRight(strings.TrimSpace(url), "/"),
|
|
||||||
Token: strings.TrimSpace(token),
|
|
||||||
TokenVersion: tokenVersion,
|
|
||||||
},
|
|
||||||
SSH: config.SSHConfig{
|
SSH: config.SSHConfig{
|
||||||
DefaultUser: strings.TrimSpace(defaultUser),
|
DefaultUser: strings.TrimSpace(defaultUser),
|
||||||
DefaultPort: port,
|
DefaultPort: port,
|
||||||
@@ -207,7 +179,7 @@ func RunWizard(cfg *config.Config) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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 {
|
if err := c.Load(); err != nil {
|
||||||
return fmt.Errorf("updating aliases: %w", err)
|
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 {
|
if err := config.ValidateCommandAlias(cfg.Shell.Alias); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := cfg.ValidateNetBoxes(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
path := config.Path()
|
path := config.Path()
|
||||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||||
return fmt.Errorf("creating config dir: %w", err)
|
return fmt.Errorf("creating config dir: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString("netbox:\n")
|
if len(cfg.NetBoxes) > 0 {
|
||||||
b.WriteString(fmt.Sprintf(" url: %q\n", cfg.NetBox.URL))
|
b.WriteString("netboxes:\n")
|
||||||
b.WriteString(fmt.Sprintf(" token: %q\n", cfg.NetBox.Token))
|
for _, source := range cfg.NetBoxes {
|
||||||
fmt.Fprintf(&b, " token_version: %d\n", cfg.NetBox.TokenVersion)
|
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("\nresolver:\n")
|
||||||
b.WriteString(" strategies:\n")
|
b.WriteString(" strategies:\n")
|
||||||
|
|||||||
@@ -386,3 +386,37 @@ func TestSaveCommandAliasRoundtripAndDisable(t *testing.T) {
|
|||||||
t.Fatalf("disable: %+v, %v", loaded, err)
|
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.
|
// replaces any remaining dots with underscores so the result is a valid identifier.
|
||||||
func AliasName(name string, cfg config.ShortcutsConfig) string {
|
func AliasName(name string, cfg config.ShortcutsConfig) string {
|
||||||
s := Normalize(name, cfg)
|
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"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -412,13 +413,9 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil // discard stale result
|
return m, nil // discard stale result
|
||||||
}
|
}
|
||||||
m.loading = false
|
m.loading = false
|
||||||
if msg.err != nil {
|
m.err = msg.err
|
||||||
m.err = msg.err
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
m.lastResults = msg.entries
|
m.lastResults = msg.entries
|
||||||
m.updateListItems(msg.entries)
|
m.updateListItems(msg.entries)
|
||||||
m.err = nil
|
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -495,9 +492,15 @@ func (m *Model) viewContent() string {
|
|||||||
|
|
||||||
if m.loading {
|
if m.loading {
|
||||||
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(" searching…") + "\n")
|
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 {
|
} 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 m.recentMode && m.input.Value() == "" {
|
||||||
if len(m.list.Items()) > 0 {
|
if len(m.list.Items()) > 0 {
|
||||||
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(" Recent connections") + "\n")
|
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 {
|
func (m *Model) doSearch(query string, seq int) tea.Cmd {
|
||||||
opts := m.netboxSearchOpts()
|
opts := m.netboxSearchOpts()
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
// Return cache hits immediately without a network round-trip.
|
var cached []netbox.HostEntry
|
||||||
if m.cache != nil {
|
if m.cache != nil {
|
||||||
if cached := m.cache.Search(query); len(cached) > 0 {
|
for _, entry := range m.cache.Search(query) {
|
||||||
entries := make([]netbox.HostEntry, len(cached))
|
source, name := netbox.SplitTarget(entry.Name)
|
||||||
for i, c := range cached {
|
cached = append(cached, netbox.HostEntry{Source: source, Name: name, PrimaryIP4: entry.IP, Kind: entry.Kind, Tags: entry.Tags})
|
||||||
entries[i] = netbox.HostEntry{Name: c.Name, PrimaryIP4: c.IP, Kind: c.Kind, Tags: c.Tags}
|
|
||||||
}
|
|
||||||
return searchResultMsg{query: query, seq: seq, entries: entries}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if m.client == nil || (len(cached) > 0 && !m.client.MultipleSources()) {
|
||||||
if m.client == nil {
|
return searchResultMsg{query: query, seq: seq, entries: cached}
|
||||||
return searchResultMsg{query: query, seq: seq, entries: nil}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
entries, err := m.client.Search(ctx, query, opts)
|
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}
|
return searchResultMsg{query: query, seq: seq, entries: entries, err: err}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -577,7 +585,7 @@ func (m *Model) updateListItems(entries []netbox.HostEntry) {
|
|||||||
if ip == "" {
|
if ip == "" {
|
||||||
ip = e.PrimaryIP6
|
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.list.SetItems(items)
|
||||||
m.resizeList()
|
m.resizeList()
|
||||||
@@ -589,6 +597,9 @@ func (m *Model) resizeList() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
extraRows := 5 // title, blank line, search, footer separator, footer
|
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 {
|
if m.recentMode && m.input.Value() == "" && len(m.list.Items()) > 0 {
|
||||||
extraRows++
|
extraRows++
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user