Fix/instance prefix completion #20
No files matched your search
@@ -1,5 +1,10 @@
|
||||
# Changelog
|
||||
|
||||
## v0.0.22
|
||||
|
||||
- Fix shell completion with multiple NetBox instances: typing a hostname without an instance prefix (`fsn1-bx1<Tab>`) found nothing in bash and zsh, because only `prod/…` was offered. Hostnames that are unique across all instances are now offered without the prefix; connecting to them still checks every instance for ambiguous names.
|
||||
- zsh also accepts instance-qualified matches for the typed hostname, so names that exist in several instances are offered as `prod/…` and `stg/…`.
|
||||
|
||||
## v0.0.21
|
||||
|
||||
- Ship a zsh completion script built for large caches: 5000 hosts complete in under 0.1 s instead of hanging for seconds with Cobra's generated script.
|
||||
|
||||
@@ -552,6 +552,10 @@ rm -f ~/.zcompdump* && exec zsh
|
||||
```
|
||||
|
||||
Completions are served from the local cache — no network request on every `<Tab>`.
|
||||
With several NetBox instances, a hostname that exists in only one instance is
|
||||
completed without the instance prefix (`web01`); names that exist in several
|
||||
instances are offered as `prod/web01`, `staging/web01`. In bash, type the
|
||||
instance prefix for those; zsh and fish offer them for the hostname alone.
|
||||
The zsh script that `netssh completion zsh` prints is built for large caches:
|
||||
5000 hosts complete in well under a second. To limit the number of hostnames
|
||||
offered, set `completion.max_results`.
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"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/netbox"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/shortcuts"
|
||||
@@ -30,3 +34,36 @@ func normalizeTarget(cfg config.ShortcutsConfig, qualified bool) func(string) st
|
||||
return name
|
||||
}
|
||||
}
|
||||
|
||||
// hostCompletions returns the cached hosts matching toComplete in a form that
|
||||
// starts with the typed text, because bash and fish only accept such
|
||||
// candidates. Without an instance prefix, a hostname that is unique across
|
||||
// all instances is offered bare ("web01"), so it completes like with a single
|
||||
// NetBox; connecting to it still checks every instance for ambiguity.
|
||||
// Names present in several instances stay qualified ("prod/web01").
|
||||
func hostCompletions(entries []cache.Entry, toComplete string) []string {
|
||||
prefix := strings.ToLower(toComplete)
|
||||
qualifiedInput := strings.Contains(prefix, "/")
|
||||
instances := make(map[string]int)
|
||||
for _, e := range entries {
|
||||
_, name := netbox.SplitTarget(e.Name)
|
||||
instances[strings.ToLower(name)]++
|
||||
}
|
||||
var out []string
|
||||
for _, e := range entries {
|
||||
source, name := netbox.SplitTarget(e.Name)
|
||||
target := strings.ToLower(e.Name)
|
||||
switch {
|
||||
case source != "" && !qualifiedInput && strings.HasPrefix(strings.ToLower(name), prefix):
|
||||
if instances[strings.ToLower(name)] == 1 {
|
||||
out = append(out, name)
|
||||
} else {
|
||||
out = append(out, e.Name)
|
||||
}
|
||||
case strings.HasPrefix(target, prefix):
|
||||
out = append(out, e.Name)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -206,3 +207,33 @@ func TestRefreshCachesIndependentDevicesAndVMs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostCompletionsMatchTypedText(t *testing.T) {
|
||||
entries := []cache.Entry{
|
||||
{Name: "prod/fsn1-bx1.example.com"},
|
||||
{Name: "prod/fsn1-bx2.example.com"},
|
||||
{Name: "stg/fsn1-bx2.example.com"},
|
||||
{Name: "stg/hel1-tb1.example.com"},
|
||||
{Name: "prod/HEL1-TB2"},
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
input string
|
||||
want []string
|
||||
}{
|
||||
// Unique names are bare; names in both instances stay qualified.
|
||||
{"fsn1", []string{"fsn1-bx1.example.com", "prod/fsn1-bx2.example.com", "stg/fsn1-bx2.example.com"}},
|
||||
{"hel1", []string{"HEL1-TB2", "hel1-tb1.example.com"}},
|
||||
{"prod/", []string{"prod/HEL1-TB2", "prod/fsn1-bx1.example.com", "prod/fsn1-bx2.example.com"}},
|
||||
{"stg/fsn", []string{"stg/fsn1-bx2.example.com"}},
|
||||
// An instance name typed without the slash completes qualified targets.
|
||||
{"st", []string{"stg/fsn1-bx2.example.com", "stg/hel1-tb1.example.com"}},
|
||||
} {
|
||||
if got := hostCompletions(entries, tc.input); !reflect.DeepEqual(got, tc.want) {
|
||||
t.Errorf("%q: got %v, want %v", tc.input, got, tc.want)
|
||||
}
|
||||
}
|
||||
// Single-instance caches are unchanged.
|
||||
if got := hostCompletions([]cache.Entry{{Name: "web01"}, {Name: "db01"}}, "w"); !reflect.DeepEqual(got, []string{"web01"}) {
|
||||
t.Errorf("single instance: %v", got)
|
||||
}
|
||||
}
|
||||
+3
-4
@@ -339,14 +339,13 @@ func rootCmd() *cobra.Command {
|
||||
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||
_ = c.Load()
|
||||
|
||||
hosts := c.Search(toComplete)
|
||||
sort.Slice(hosts, func(i, j int) bool { return hosts[i].Name < hosts[j].Name })
|
||||
hosts := hostCompletions(c.All(), toComplete)
|
||||
if limit := cfg.Completion.MaxResults; limit > 0 && len(hosts) > limit {
|
||||
hosts = hosts[:limit]
|
||||
}
|
||||
completions := make([]cobra.Completion, 0, len(hosts))
|
||||
for _, e := range hosts {
|
||||
completions = append(completions, cobra.Completion(e.Name))
|
||||
for _, host := range hosts {
|
||||
completions = append(completions, cobra.Completion(host))
|
||||
}
|
||||
// Subcommands at the end, after all hostnames.
|
||||
for _, sub := range cmd.Commands() {
|
||||
|
||||
Reference in new issue
Block a user