diff --git a/CHANGELOG.md b/CHANGELOG.md index a2e588d..de01afc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # 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 - Require Go 1.27.3 and migrate Bubbles, Bubble Tea, Huh, and Lip Gloss to their v2 modules under `charm.land`. diff --git a/README.md b/README.md index ba0d27e..d332cc3 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ Re-run it at any time to change settings without editing the file manually: 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`. ### Manual config @@ -142,7 +142,44 @@ shortcuts: 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 diff --git a/cmd/netssh/instances.go b/cmd/netssh/instances.go new file mode 100644 index 0000000..fb61f8a --- /dev/null +++ b/cmd/netssh/instances.go @@ -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 + } +} diff --git a/cmd/netssh/instances_test.go b/cmd/netssh/instances_test.go new file mode 100644 index 0000000..a626c69 --- /dev/null +++ b/cmd/netssh/instances_test.go @@ -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) + } + } +} diff --git a/cmd/netssh/main.go b/cmd/netssh/main.go index 5c0d4e4..7d52e17 100644 --- a/cmd/netssh/main.go +++ b/cmd/netssh/main.go @@ -23,7 +23,6 @@ import ( "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/setup" - "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/shortcuts" internalssh "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/ssh" "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/tui" ) @@ -66,7 +65,7 @@ func loadConfigOrSetup() *config.Config { if err != nil { fatalf("config: %v", err) } - if cfg.NetBox.URL == "" { + if !cfg.HasNetBox() { fmt.Fprintln(os.Stderr, "No configuration found. Starting setup…") if err := setup.RunWizard(cfg); err != nil { fatalf("setup: %v", err) @@ -103,12 +102,15 @@ func runSSHWrapper(args []string) { parsed.DestIdx += 2 } - c := cache.New(cfg.Cache.Path, cfg.Cache.TTL) + c := cache.New(cfg.CachePath(), cfg.Cache.TTL) _ = c.Load() 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. - if entry, fresh := c.Get(parsed.Host); fresh { + if entry, fresh := c.Get(parsed.Host); fresh && useCache { c.MarkUsed(parsed.Host) _ = c.Save() 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. lookupHost := parsed.Host shortcutsEnabled := len(cfg.Shortcuts.Domains) > 0 || cfg.Shortcuts.StripHyphens - if cached, _ := c.Get(lookupHost); shortcutsEnabled && cached.Name == "" { - normalize := shortcuts.MakeNormalizer(cfg.Shortcuts) + if cached, _ := c.Get(lookupHost); shortcutsEnabled && cached.Name == "" && useCache { + normalize := normalizeTarget(cfg.Shortcuts, source != "") entry, found, fresh, err := c.GetByShortcut(lookupHost, normalize) if err != nil { 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)") } - nbClient := netbox.NewClient(cfg.NetBox.URL, cfg.NetBox.Token, cfg.NetBox.TokenVersion) + nbClient := newNetBoxClient(cfg) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() 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 { 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) if err != nil { fatalf("resolver: %v", err) @@ -160,8 +178,8 @@ func runSSHWrapper(args []string) { 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.MarkUsed(target.Name) + c.Upsert(cache.Entry{Name: target.Target(), IP: ip, Kind: target.Kind, Tags: target.Tags}) + c.MarkUsed(target.Target()) _ = c.Save() connect(ip, parsed, args) @@ -198,13 +216,13 @@ func connect(ip string, parsed *internalssh.ParsedArgs, originalArgs []string) { func runTUI() { cfg := loadConfigOrSetup() - c := cache.New(cfg.Cache.Path, cfg.Cache.TTL) + c := cache.New(cfg.CachePath(), cfg.Cache.TTL) _ = c.Load() maybeBackgroundRefresh(cfg, c) var nbClient *netbox.Client - if cfg.NetBox.URL != "" { - nbClient = netbox.NewClient(cfg.NetBox.URL, cfg.NetBox.Token, cfg.NetBox.TokenVersion) + if cfg.HasNetBox() { + nbClient = newNetBoxClient(cfg) } m := tui.New(nbClient, c, cfg.SSH.DefaultUser, cfg.SSH.DefaultPort) @@ -245,7 +263,7 @@ func runTUI() { if err != nil { 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) @@ -290,7 +308,7 @@ func rootCmd() *cobra.Command { if err != nil { return nil, cobra.ShellCompDirectiveError } - c := cache.New(cfg.Cache.Path, cfg.Cache.TTL) + c := cache.New(cfg.CachePath(), cfg.Cache.TTL) _ = c.Load() var completions []cobra.Completion @@ -403,9 +421,9 @@ func configureCmd() *cobra.Command { Use: "configure", Short: "Interactively configure netssh", RunE: func(cmd *cobra.Command, args []string) error { - cfg, _ := config.Load() - if cfg == nil { - cfg = &config.Config{} + cfg, err := config.Load() + if err != nil { + return err } return setup.RunWizard(cfg) }, @@ -422,7 +440,7 @@ func searchCmd() *cobra.Command { if err != nil { 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 { return err } @@ -456,7 +474,7 @@ func cacheListCmd() *cobra.Command { if err != nil { 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 { return err } @@ -482,7 +500,7 @@ func cacheClearCmd() *cobra.Command { if err != nil { return err } - c := cache.New(cfg.Cache.Path, cfg.Cache.TTL) + c := cache.New(cfg.CachePath(), cfg.Cache.TTL) c.Clear() if err := c.Save(); err != nil { return err @@ -494,7 +512,7 @@ func cacheClearCmd() *cobra.Command { } func cacheRefreshCmd() *cobra.Command { - var filterTag, filterKind string + var filterTag, filterKind, filterInstance string cmd := &cobra.Command{ Use: "refresh", Short: "Re-fetch all known hosts from NetBox", @@ -503,21 +521,28 @@ func cacheRefreshCmd() *cobra.Command { if err != nil { return err } - if cfg.NetBox.URL == "" { + if !cfg.HasNetBox() { 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) defer cancel() 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 { 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() chain, err := resolver.New(cfg.Resolver) @@ -527,12 +552,12 @@ func cacheRefreshCmd() *cobra.Command { written := 0 nameCounts := make(map[string]int) for _, e := range entries { - nameCounts[strings.ToLower(e.Name)]++ + nameCounts[strings.ToLower(e.Target())]++ } for i := range entries { e := &entries[i] - if nameCounts[strings.ToLower(e.Name)] > 1 { - c.Delete(e.Name) + if nameCounts[strings.ToLower(e.Target())] > 1 { + c.Delete(e.Target()) fmt.Fprintf(os.Stderr, "warning: skipping ambiguous hostname %q\n", e.Name) continue } @@ -544,18 +569,18 @@ func cacheRefreshCmd() *cobra.Command { if ctx.Err() != nil { return ctx.Err() } - c.Delete(e.Name) + c.Delete(e.Target()) fmt.Fprintf(os.Stderr, "warning: skipping %s: %v\n", e.Name, err) 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++ } if err := c.Save(); err != nil { return err } - if filterTag == "" && filterKind == "" { + if filterTag == "" && filterKind == "" && filterInstance == "" { if err := c.SetRefreshed(); err != nil { 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(&filterKind, "kind", "", "Filter by kind: device or vm") + cmd.Flags().StringVar(&filterInstance, "instance", "", "Refresh only this named NetBox instance") return cmd } @@ -599,7 +625,7 @@ Or use in a script: if err != nil { 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 { return err } @@ -644,7 +670,7 @@ func shellInitCmd() *cobra.Command { if err != nil { return nil } - c := cache.New(cfg.Cache.Path, cfg.Cache.TTL) + c := cache.New(cfg.CachePath(), cfg.Cache.TTL) _ = c.Load() // 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. cfg, cfgErr := config.Load() - if cfgErr == nil && cfg.NetBox.URL != "" { - c := cache.New(cfg.Cache.Path, cfg.Cache.TTL) + if cfgErr == nil && cfg.HasNetBox() { + c := cache.New(cfg.CachePath(), cfg.Cache.TTL) if _ = c.Load(); true { if entries := buildAliasEntries(c, *cfg); len(entries) > 0 { 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 // has not been fully refreshed within the last 24 hours. func maybeBackgroundRefresh(cfg *config.Config, c *cache.Cache) { - if cfg.NetBox.URL == "" { + if !cfg.HasNetBox() { return } if !c.NeedsRefresh(24 * time.Hour) { diff --git a/cmd/netssh/notes.go b/cmd/netssh/notes.go index 41f25af..5c62579 100644 --- a/cmd/netssh/notes.go +++ b/cmd/netssh/notes.go @@ -34,11 +34,11 @@ func notesCmd() *cobra.Command { return err } var client *netbox.Client - if cfg.NetBox.URL != "" { - client = netbox.NewClient(cfg.NetBox.URL, cfg.NetBox.Token, cfg.NetBox.TokenVersion) + if cfg.HasNetBox() { + client = newNetBoxClient(cfg) } 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 { return err } @@ -46,13 +46,23 @@ func notesCmd() *cobra.Command { if !remote { cached = c.All() } + query := host.Name + source, rawName := netbox.SplitTarget(query) + var matches []cache.Entry for _, entry := range cached { - if strings.EqualFold(entry.Name, host.Name) && (kind == "" || kind == entry.Kind) { - host.Name = entry.Name - host.Kind = entry.Kind - break + _, entryName := netbox.SplitTarget(entry.Name) + if (kind == "" || kind == entry.Kind) && (strings.EqualFold(entry.Name, query) || (source == "" && strings.EqualFold(entryName, rawName))) { + matches = append(matches, entry) } } + 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 == "" { return fmt.Errorf("host is not cached; specify --kind device or --kind vm") } diff --git a/internal/cache/cache.go b/internal/cache/cache.go index ef9665e..0b1c6fd 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -258,7 +258,8 @@ func (c *Cache) Search(prefix string) []Entry { prefix = strings.ToLower(prefix) var out []Entry for name, e := range c.entries { - if strings.HasPrefix(strings.ToLower(name), prefix) { + _, hostname, qualified := strings.Cut(strings.ToLower(name), "/") + if strings.HasPrefix(strings.ToLower(name), prefix) || (qualified && !strings.Contains(prefix, "/") && strings.HasPrefix(hostname, prefix)) { out = append(out, e) } } diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go index 9cf912c..638e08d 100644 --- a/internal/cache/cache_test.go +++ b/internal/cache/cache_test.go @@ -483,3 +483,15 @@ func TestUpsertPreservesUsageHistory(t *testing.T) { t.Fatalf("history lost: %+v", after) } } + +func TestSearchMatchesHostnamesAcrossQualifiedSources(t *testing.T) { + c := New("", 3600) + c.Upsert(Entry{Name: "office/web01", IP: "10.0.0.1"}) + c.Upsert(Entry{Name: "lab/web01", IP: "10.0.0.2"}) + c.Upsert(Entry{Name: "lab/db01", IP: "10.0.0.3"}) + for query, want := range map[string]int{"web": 2, "office/web": 1, "lab/": 2, "WEB": 2} { + if got := len(c.Search(query)); got != want { + t.Errorf("Search(%q)=%d, want %d", query, got, want) + } + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 211084f..6c686c0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -13,6 +13,7 @@ import ( type Config struct { Shell ShellConfig `mapstructure:"shell"` NetBox NetBoxConfig `mapstructure:"netbox"` + NetBoxes []NetBoxConfig `mapstructure:"netboxes"` Resolver ResolverConfig `mapstructure:"resolver"` Cache CacheConfig `mapstructure:"cache"` SSH SSHConfig `mapstructure:"ssh"` @@ -24,6 +25,7 @@ type ShellConfig struct { } type NetBoxConfig struct { + Name string `mapstructure:"name"` URL string `mapstructure:"url"` Token string `mapstructure:"token"` TokenVersion int `mapstructure:"token_version"` @@ -93,6 +95,11 @@ func Load() (*Config, error) { if err := v.Unmarshal(&cfg); err != nil { return nil, fmt.Errorf("parsing config: %w", err) } + // Legacy environment variables must not invent an additional unnamed source + // when a file explicitly configures named instances (including wizard migration). + if len(cfg.NetBoxes) > 0 && !v.InConfig("netbox.url") { + cfg.NetBox = NetBoxConfig{} + } if err := ValidateCommandAlias(cfg.Shell.Alias); err != nil { return nil, err @@ -106,6 +113,20 @@ func Load() (*Config, error) { } } + if err := cfg.ValidateNetBoxes(); err != nil { + return nil, err + } + for i := range cfg.NetBoxes { + source := &cfg.NetBoxes[i] + source.Name = strings.ToLower(source.Name) + if source.TokenVersion == 0 && source.Token != "" { + source.TokenVersion = 1 + if strings.HasPrefix(source.Token, "nbt_") { + source.TokenVersion = 2 + } + } + } + if cfg.Cache.Path == "" { cacheDir, err := os.UserCacheDir() if err != nil { diff --git a/internal/config/instances.go b/internal/config/instances.go new file mode 100644 index 0000000..19f8b0c --- /dev/null +++ b/internal/config/instances.go @@ -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)) +} diff --git a/internal/config/instances_test.go b/internal/config/instances_test.go new file mode 100644 index 0000000..4adf6d4 --- /dev/null +++ b/internal/config/instances_test.go @@ -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") + } +} diff --git a/internal/hook/aliases_test.go b/internal/hook/aliases_test.go index d7ebd0d..2facfa2 100644 --- a/internal/hook/aliases_test.go +++ b/internal/hook/aliases_test.go @@ -227,3 +227,18 @@ func TestCommandAliasGenerationAndCollision(t *testing.T) { t.Fatal(got) } } + +func TestAliasesIncludeInstanceName(t *testing.T) { + cfg := config.Config{Shortcuts: config.ShortcutsConfig{Domains: []string{".example.com"}}} + entries := hook.BuildAliasEntries([]cache.Entry{{Name: "office/web01.example.com"}, {Name: "lab/web01.example.com"}}, cfg) + if len(entries) != 2 { + t.Fatalf("missing qualified aliases: %+v", entries) + } + got := map[string]string{} + for _, entry := range entries { + got[entry.Name] = entry.Host + } + if got["office_web01"] != "office/web01.example.com" || got["lab_web01"] != "lab/web01.example.com" { + t.Fatalf("alias lost source: %+v", entries) + } +} diff --git a/internal/netbox/client.go b/internal/netbox/client.go index 1326087..dd868ed 100644 --- a/internal/netbox/client.go +++ b/internal/netbox/client.go @@ -15,6 +15,7 @@ import ( ) type Client struct { + sources map[string]*Client baseURL string token string tokenVersion int @@ -38,6 +39,9 @@ func NewClient(baseURL, token string, tokenVersion int) *Client { // Search queries up to 50 devices and VMs in parallel and merges the results. // Use SearchOptions to restrict by kind or tag. func (c *Client) Search(ctx context.Context, query string, opts SearchOptions) ([]HostEntry, error) { + if c.sources != nil { + return c.searchSources(ctx, query, opts, false) + } if opts.Kind != "" && opts.Kind != "device" && opts.Kind != "vm" { return nil, fmt.Errorf("invalid host kind %q", opts.Kind) } @@ -95,6 +99,9 @@ func (c *Client) Search(ctx context.Context, query string, opts SearchOptions) ( // SearchAll paginates through all matching devices and VMs, fetching every page. // Intended for cache refresh; use Search for interactive queries. func (c *Client) SearchAll(ctx context.Context, query string, opts SearchOptions) ([]HostEntry, error) { + if c.sources != nil { + return c.searchSources(ctx, query, opts, true) + } if opts.Kind != "" && opts.Kind != "device" && opts.Kind != "vm" { return nil, fmt.Errorf("invalid host kind %q", opts.Kind) } @@ -146,6 +153,13 @@ func (c *Client) SearchAll(ctx context.Context, query string, opts SearchOptions // GetIPs returns all IP addresses assigned to a host, used by resolver strategies // that need more than just the primary IP. func (c *Client) GetIPs(ctx context.Context, entry HostEntry) ([]string, error) { + if c.sources != nil { + source, host, err := c.sourceForHost(entry) + if err != nil { + return nil, err + } + return source.GetIPs(ctx, host) + } var apiURL string switch entry.Kind { case "device": @@ -170,6 +184,9 @@ func (c *Client) GetIPs(ctx context.Context, entry HostEntry) ([]string, error) // GetIPsWithFilter calls /api/ipam/ip-addresses/ with arbitrary filter query parameters. func (c *Client) GetIPsWithFilter(ctx context.Context, filterParams string) ([]string, error) { + if c.sources != nil { + return nil, fmt.Errorf("IP filters require a host instance; use GetIPsWithFilterForHost") + } apiURL := fmt.Sprintf("%s/api/ipam/ip-addresses/?%s&limit=100", c.baseURL, filterParams) var resp netboxIPListResponse if err := c.get(ctx, apiURL, &resp); err != nil { @@ -360,12 +377,13 @@ func (c *Client) FindHost(ctx context.Context, name, kind string) (HostEntry, er return HostEntry{}, fmt.Errorf("invalid kind %q", kind) } entries, err := c.SearchAll(ctx, name, SearchOptions{Kind: kind}) + _, hostname := SplitTarget(name) if err != nil { return HostEntry{}, err } var matches []HostEntry for _, e := range entries { - if strings.EqualFold(e.Name, name) { + if strings.EqualFold(e.Name, hostname) { matches = append(matches, e) } } @@ -373,6 +391,9 @@ func (c *Client) FindHost(ctx context.Context, name, kind string) (HostEntry, er return HostEntry{}, fmt.Errorf("%w: %q", ErrHostNotFound, name) } if len(matches) != 1 { + if c.sources != nil { + return HostEntry{}, fmt.Errorf("host %q: found %d exact matches; use instance/hostname and --kind to select a unique host", name, len(matches)) + } return HostEntry{}, fmt.Errorf("host %q: found %d exact matches; use --kind or a unique hostname", name, len(matches)) } return matches[0], nil @@ -395,6 +416,13 @@ func (c *Client) hostURL(host HostEntry) (string, error) { } func (c *Client) GetComments(ctx context.Context, host HostEntry) (string, string, error) { + if c.sources != nil { + source, host, err := c.sourceForHost(host) + if err != nil { + return "", "", err + } + return source.GetComments(ctx, host) + } u, err := c.hostURL(host) if err != nil { return "", "", err @@ -407,6 +435,13 @@ func (c *Client) GetComments(ctx context.Context, host HostEntry) (string, strin } func (c *Client) SetComments(ctx context.Context, host HostEntry, comments, etag string) error { + if c.sources != nil { + source, host, err := c.sourceForHost(host) + if err != nil { + return err + } + return source.SetComments(ctx, host, comments, etag) + } u, err := c.hostURL(host) if err != nil { return err diff --git a/internal/netbox/models.go b/internal/netbox/models.go index 54b7a4c..e2341fd 100644 --- a/internal/netbox/models.go +++ b/internal/netbox/models.go @@ -1,5 +1,7 @@ package netbox +import "strings" + // SearchOptions filters a Search or SearchAll query. // Zero value means no filtering (return all kinds, no tag filter). type SearchOptions struct { @@ -9,6 +11,7 @@ type SearchOptions struct { // HostEntry is a unified model for both devices and virtual machines from NetBox. type HostEntry struct { + Source string // named NetBox instance; empty for the legacy single-instance config ID int Name string Kind string // "device" | "vm" @@ -58,3 +61,19 @@ type netboxIPListResponse struct { } `json:"assigned_object"` } `json:"results"` } + +// Target returns an unambiguous selector for cache, completion, and SSH. +func (h HostEntry) Target() string { + if h.Source == "" { + return h.Name + } + return h.Source + "/" + h.Name +} + +// SplitTarget separates an optional instance prefix from a hostname. +func SplitTarget(target string) (source, name string) { + if source, name, ok := strings.Cut(target, "/"); ok { + return strings.ToLower(source), name + } + return "", target +} diff --git a/internal/netbox/sources.go b/internal/netbox/sources.go new file mode 100644 index 0000000..bb7bbe8 --- /dev/null +++ b/internal/netbox/sources.go @@ -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 } diff --git a/internal/netbox/sources_test.go b/internal/netbox/sources_test.go new file mode 100644 index 0000000..a3686c6 --- /dev/null +++ b/internal/netbox/sources_test.go @@ -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) + } +} diff --git a/internal/notes/notes.go b/internal/notes/notes.go index 677e6f1..036aa2c 100644 --- a/internal/notes/notes.go +++ b/internal/notes/notes.go @@ -52,7 +52,11 @@ func (s *Service) Read(ctx context.Context, host netbox.HostEntry, remote bool) note.Text, note.ETag, err = s.Client.GetComments(ctx, note.Host) return note, err } - data, err := os.ReadFile(s.path(host)) + path, err := s.localPath(host) + if err != nil { + return note, err + } + data, err := os.ReadFile(path) if os.IsNotExist(err) { return note, nil } @@ -67,5 +71,22 @@ func (s *Service) Write(ctx context.Context, note Note, remote bool) error { } return s.Client.SetComments(ctx, note.Host, note.Text, note.ETag) } - return storage.WriteFile(s.path(note.Host), []byte(note.Text), 0o600) + path, err := s.localPath(note.Host) + if err != nil { + return err + } + return storage.WriteFile(path, []byte(note.Text), 0o600) +} + +func (s *Service) localPath(host netbox.HostEntry) (string, error) { + if s.Client == nil { + return s.path(host), nil + } + host, namespace, err := s.Client.HostScope(host) + if err != nil { + return "", err + } + scoped := *s + scoped.Namespace = namespace + return scoped.path(host), nil } diff --git a/internal/notes/notes_test.go b/internal/notes/notes_test.go index 9ade833..debc917 100644 --- a/internal/notes/notes_test.go +++ b/internal/notes/notes_test.go @@ -50,3 +50,35 @@ func TestUnreadableNoteIsNotTreatedAsEmpty(t *testing.T) { t.Fatal("expected read error") } } + +func TestLocalNotesSeparateInstancesAndSurviveRename(t *testing.T) { + ctx := context.Background() + client := netbox.NewMultiClient([]netbox.Source{{Name: "office", URL: "https://office.example"}, {Name: "lab", URL: "https://lab.example"}}) + service := &Service{Dir: t.TempDir(), Client: client} + for _, source := range []string{"office", "lab"} { + note := Note{Host: netbox.HostEntry{Name: source + "/web", Kind: "device"}, Text: source + " private note"} + if err := service.Write(ctx, note, false); err != nil { + t.Fatal(err) + } + } + for _, source := range []string{"office", "lab"} { + note, err := service.Read(ctx, netbox.HostEntry{Name: source + "/web", Kind: "device"}, false) + if err != nil || note.Text != source+" private note" { + t.Fatalf("notes crossed instances: %+v, %v", note, err) + } + } + service.Client = netbox.NewMultiClient([]netbox.Source{{Name: "renamed", URL: "https://office.example"}}) + note, err := service.Read(ctx, netbox.HostEntry{Name: "renamed/web", Kind: "device"}, false) + if err != nil || note.Text != "office private note" { + t.Fatalf("renaming source lost note: %+v, %v", note, err) + } + // This is also the namespace used before migrating a legacy single NetBox. + legacy := &Service{Dir: service.Dir, Namespace: "https://office.example"} + note, err = legacy.Read(ctx, netbox.HostEntry{Name: "web", Kind: "device"}, false) + if err != nil || note.Text != "office private note" { + t.Fatalf("legacy note namespace changed: %+v, %v", note, err) + } + if err := service.Write(ctx, Note{Host: netbox.HostEntry{Name: "web", Kind: "device"}}, false); err == nil { + t.Fatal("unqualified notes accepted for multiple sources") + } +} diff --git a/internal/resolver/instances_test.go b/internal/resolver/instances_test.go new file mode 100644 index 0000000..0df2240 --- /dev/null +++ b/internal/resolver/instances_test.go @@ -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) + } + } + } +} diff --git a/internal/resolver/interface_name.go b/internal/resolver/interface_name.go index 37ed9a2..2b3483f 100644 --- a/internal/resolver/interface_name.go +++ b/internal/resolver/interface_name.go @@ -27,7 +27,7 @@ func (s *InterfaceNameStrategy) Resolve(ctx context.Context, entry *netbox.HostE return "", fmt.Errorf("unknown kind %q", entry.Kind) } - ips, err := client.GetIPsWithFilter(ctx, filterParam) + ips, err := client.GetIPsWithFilterForHost(ctx, *entry, filterParam) if err != nil { return "", fmt.Errorf("fetching IPs for interface %q: %w", s.name, err) } diff --git a/internal/setup/instances.go b/internal/setup/instances.go new file mode 100644 index 0000000..a4a4b8e --- /dev/null +++ b/internal/setup/instances.go @@ -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 +} diff --git a/internal/setup/wizard.go b/internal/setup/wizard.go index 826b7bb..f866312 100644 --- a/internal/setup/wizard.go +++ b/internal/setup/wizard.go @@ -13,15 +13,20 @@ import ( "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache" "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config" "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/hook" - "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox" "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/storage" ) // RunWizard runs the interactive setup form, pre-filled with any existing cfg values. // It saves the result to the config file on success. func RunWizard(cfg *config.Config) error { - url := cfg.NetBox.URL - token := cfg.NetBox.Token + sources, err := configureInstances(cfg) + if err != nil { + if errors.Is(err, huh.ErrUserAborted) { + fmt.Fprintln(os.Stderr, "Setup cancelled.") + os.Exit(0) + } + return err + } defaultUser := cfg.SSH.DefaultUser defaultPort := "" if cfg.SSH.DefaultPort > 0 { @@ -43,30 +48,6 @@ func RunWizard(cfg *config.Config) error { } form := huh.NewForm( - huh.NewGroup( - huh.NewInput(). - Title("NetBox URL"). - Description("e.g. https://netbox.example.com"). - Placeholder("https://"). - Value(&url). - Validate(func(s string) error { - if strings.TrimSpace(s) == "" { - return errors.New("required") - } - return nil - }), - huh.NewInput(). - Title("NetBox API token"). - EchoMode(huh.EchoModePassword). - Value(&token). - Validate(func(s string) error { - if strings.TrimSpace(s) == "" { - return errors.New("required") - } - return nil - }), - ).Title("NetBox connection"), - huh.NewGroup( huh.NewInput(). Title("Default SSH user"). @@ -144,11 +125,6 @@ func RunWizard(cfg *config.Config) error { } strategies := parseStrategies(strategiesRaw) - tokenVersion := netbox.TokenVersion(token) - if tokenVersion == 1 { - fmt.Fprintln(os.Stderr, "\nNote: You are using a legacy token (v1). Create a v2 token in NetBox (starting with nbt_) for better compatibility.") - fmt.Fprintln(os.Stderr, " NetBox → Admin → API Tokens → Add Token") - } ttl, _ := strconv.Atoi(cacheTTL) @@ -175,12 +151,8 @@ func RunWizard(cfg *config.Config) error { } out := config.Config{ - Shell: config.ShellConfig{Alias: strings.TrimSpace(commandAlias)}, - NetBox: config.NetBoxConfig{ - URL: strings.TrimRight(strings.TrimSpace(url), "/"), - Token: strings.TrimSpace(token), - TokenVersion: tokenVersion, - }, + Shell: config.ShellConfig{Alias: strings.TrimSpace(commandAlias)}, + NetBoxes: sources, SSH: config.SSHConfig{ DefaultUser: strings.TrimSpace(defaultUser), DefaultPort: port, @@ -207,7 +179,7 @@ func RunWizard(cfg *config.Config) error { if err != nil { return err } - c := cache.New(loaded.Cache.Path, loaded.Cache.TTL) + c := cache.New(loaded.CachePath(), loaded.Cache.TTL) if err := c.Load(); err != nil { return fmt.Errorf("updating aliases: %w", err) } @@ -266,16 +238,24 @@ func save(cfg config.Config) error { if err := config.ValidateCommandAlias(cfg.Shell.Alias); err != nil { return err } + if err := cfg.ValidateNetBoxes(); err != nil { + return err + } path := config.Path() if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return fmt.Errorf("creating config dir: %w", err) } var b strings.Builder - b.WriteString("netbox:\n") - b.WriteString(fmt.Sprintf(" url: %q\n", cfg.NetBox.URL)) - b.WriteString(fmt.Sprintf(" token: %q\n", cfg.NetBox.Token)) - fmt.Fprintf(&b, " token_version: %d\n", cfg.NetBox.TokenVersion) + if len(cfg.NetBoxes) > 0 { + b.WriteString("netboxes:\n") + for _, source := range cfg.NetBoxes { + fmt.Fprintf(&b, " - name: %q\n url: %q\n token: %q\n token_version: %d\n", source.Name, source.URL, source.Token, source.TokenVersion) + } + } else { + b.WriteString("netbox:\n") + fmt.Fprintf(&b, " url: %q\n token: %q\n token_version: %d\n", cfg.NetBox.URL, cfg.NetBox.Token, cfg.NetBox.TokenVersion) + } b.WriteString("\nresolver:\n") b.WriteString(" strategies:\n") diff --git a/internal/setup/wizard_test.go b/internal/setup/wizard_test.go index 372ff92..077e5c2 100644 --- a/internal/setup/wizard_test.go +++ b/internal/setup/wizard_test.go @@ -386,3 +386,37 @@ func TestSaveCommandAliasRoundtripAndDisable(t *testing.T) { t.Fatalf("disable: %+v, %v", loaded, err) } } + +func TestSaveMultipleNetBoxesRoundTrip(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + cfg := config.Config{ + NetBoxes: []config.NetBoxConfig{ + {Name: "office", URL: "https://office.example/netbox", Token: "nbt_secret: office", TokenVersion: 2}, + {Name: "lab", URL: "https://lab.example", Token: "legacy#token", TokenVersion: 1}, + }, + Resolver: config.ResolverConfig{Strategies: []string{"primary_ip"}}, + Cache: config.CacheConfig{TTL: 3600}, + } + if err := save(cfg); err != nil { + t.Fatal(err) + } + loaded, err := config.Load() + if err != nil { + t.Fatal(err) + } + if len(loaded.NetBoxes) != 2 || loaded.NetBox.URL != "" { + t.Fatalf("instances lost during save: %+v", loaded) + } + for i, want := range cfg.NetBoxes { + if loaded.NetBoxes[i] != want { + t.Fatalf("instance changed: %+v, want %+v", loaded.NetBoxes[i], want) + } + } + info, err := os.Stat(config.Path()) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Fatal("credentials are not private") + } +} diff --git a/internal/shortcuts/shortcuts.go b/internal/shortcuts/shortcuts.go index e357ccb..bfae38c 100644 --- a/internal/shortcuts/shortcuts.go +++ b/internal/shortcuts/shortcuts.go @@ -38,5 +38,5 @@ func MakeNormalizer(cfg config.ShortcutsConfig) func(string) string { // replaces any remaining dots with underscores so the result is a valid identifier. func AliasName(name string, cfg config.ShortcutsConfig) string { s := Normalize(name, cfg) - return strings.ReplaceAll(s, ".", "_") + return strings.NewReplacer(".", "_", "/", "_").Replace(s) } diff --git a/internal/tui/instances_test.go b/internal/tui/instances_test.go new file mode 100644 index 0000000..e3982e1 --- /dev/null +++ b/internal/tui/instances_test.go @@ -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) + } +} diff --git a/internal/tui/model.go b/internal/tui/model.go index be9f943..a8b903c 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "io" + "sort" "strconv" "strings" "time" @@ -412,13 +413,9 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil // discard stale result } m.loading = false - if msg.err != nil { - m.err = msg.err - return m, nil - } + m.err = msg.err m.lastResults = msg.entries m.updateListItems(msg.entries) - m.err = nil return m, nil } @@ -495,9 +492,15 @@ func (m *Model) viewContent() string { if m.loading { sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(" searching…") + "\n") - } else if m.err != nil { - sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Render(" error: "+m.err.Error()) + "\n") } else { + if m.err != nil { + warning := " warning: " + strings.ReplaceAll(m.err.Error(), "\n", "; ") + style := lipgloss.NewStyle().Foreground(lipgloss.Color("9")) + if m.width > 0 { + style = style.MaxWidth(m.width) + } + sb.WriteString(style.Render(warning) + "\n") + } if m.recentMode && m.input.Value() == "" { if len(m.list.Items()) > 0 { sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(" Recent connections") + "\n") @@ -534,25 +537,30 @@ func (m *Model) startDebounce() tea.Cmd { func (m *Model) doSearch(query string, seq int) tea.Cmd { opts := m.netboxSearchOpts() return func() tea.Msg { - // Return cache hits immediately without a network round-trip. + var cached []netbox.HostEntry if m.cache != nil { - if cached := m.cache.Search(query); len(cached) > 0 { - entries := make([]netbox.HostEntry, len(cached)) - for i, c := range cached { - entries[i] = netbox.HostEntry{Name: c.Name, PrimaryIP4: c.IP, Kind: c.Kind, Tags: c.Tags} - } - return searchResultMsg{query: query, seq: seq, entries: entries} + for _, entry := range m.cache.Search(query) { + source, name := netbox.SplitTarget(entry.Name) + cached = append(cached, netbox.HostEntry{Source: source, Name: name, PrimaryIP4: entry.IP, Kind: entry.Kind, Tags: entry.Tags}) } } - - if m.client == nil { - return searchResultMsg{query: query, seq: seq, entries: nil} + if m.client == nil || (len(cached) > 0 && !m.client.MultipleSources()) { + return searchResultMsg{query: query, seq: seq, entries: cached} } - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - entries, err := m.client.Search(ctx, query, opts) + // A cached hit in one instance must not hide live hosts in other instances. + seen := make(map[string]bool) + for _, e := range entries { + seen[e.Target()+"\x00"+e.Kind] = true + } + for _, e := range cached { + if !seen[e.Target()+"\x00"+e.Kind] { + entries = append(entries, e) + } + } + sort.SliceStable(entries, func(i, j int) bool { return entries[i].Target() < entries[j].Target() }) return searchResultMsg{query: query, seq: seq, entries: entries, err: err} } } @@ -577,7 +585,7 @@ func (m *Model) updateListItems(entries []netbox.HostEntry) { if ip == "" { ip = e.PrimaryIP6 } - items[i] = hostItem{id: e.ID, name: e.Name, ip: ip, kind: e.Kind, tags: e.Tags} + items[i] = hostItem{id: e.ID, name: e.Target(), ip: ip, kind: e.Kind, tags: e.Tags} } m.list.SetItems(items) m.resizeList() @@ -589,6 +597,9 @@ func (m *Model) resizeList() { return } extraRows := 5 // title, blank line, search, footer separator, footer + if m.err != nil { + extraRows++ + } if m.recentMode && m.input.Value() == "" && len(m.list.Items()) > 0 { extraRows++ }