feat(shortcuts): regex rewrite rules for host shortcuts
shortcuts.rewrites applies regex replacements after the domain suffix is stripped, e.g. ^([a-z]+)[0-9]+- -> $1 turns fsn1-bx1.hoster.de into fsnbx1. The shortcut works for connecting (cached hosts), shell aliases, and Tab completion, where it is offered when the full name does not match the typed text. Rules are editable in netssh configure, and netssh alias --test HOSTNAME shows a name's shortcut and the cached hosts it matches. The shortcut lookup only ran when domains or strip_hyphens were set; it now also runs for rewrite-only configurations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d51c040d9f
commit
878701aba7
12 files changed
+408
-21
No files matched your search
+56
-6
@@ -41,7 +41,9 @@ func normalizeTarget(cfg config.ShortcutsConfig, qualified bool) func(string) st
|
||||
// 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 {
|
||||
// Hosts whose full name does not match are offered by their configured
|
||||
// shortcut instead ("fsnb" → "fsnbx1" for fsn1-bx1.example.com).
|
||||
func hostCompletions(entries []cache.Entry, toComplete string, sc config.ShortcutsConfig) []string {
|
||||
prefix := strings.ToLower(toComplete)
|
||||
qualifiedInput := strings.Contains(prefix, "/")
|
||||
instances := make(map[string]int)
|
||||
@@ -49,21 +51,69 @@ func hostCompletions(entries []cache.Entry, toComplete string) []string {
|
||||
_, name := netbox.SplitTarget(e.Name)
|
||||
instances[strings.ToLower(name)]++
|
||||
}
|
||||
var out []string
|
||||
var unmatched []cache.Entry
|
||||
var names []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)
|
||||
names = append(names, name)
|
||||
} else {
|
||||
out = append(out, e.Name)
|
||||
names = append(names, e.Name)
|
||||
}
|
||||
case strings.HasPrefix(target, prefix):
|
||||
out = append(out, e.Name)
|
||||
names = append(names, e.Name)
|
||||
default:
|
||||
unmatched = append(unmatched, e)
|
||||
}
|
||||
}
|
||||
if sc.Enabled() {
|
||||
names = append(names, shortcutCompletions(entries, unmatched, prefix, sc)...)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
// shortcutCompletions offers the shortcut of each unmatched host when it starts
|
||||
// with prefix and selects exactly one host, qualified by instance if needed.
|
||||
func shortcutCompletions(all, unmatched []cache.Entry, prefix string, sc config.ShortcutsConfig) []string {
|
||||
bare := make(map[string]int)
|
||||
qualified := make(map[string]int)
|
||||
short := func(e cache.Entry) (string, string) {
|
||||
source, name := netbox.SplitTarget(e.Name)
|
||||
s := shortcuts.Normalize(name, sc)
|
||||
if source == "" {
|
||||
return s, s
|
||||
}
|
||||
return s, strings.ToLower(source) + "/" + s
|
||||
}
|
||||
for _, e := range all {
|
||||
b, q := short(e)
|
||||
bare[b]++
|
||||
qualified[q]++
|
||||
}
|
||||
seen := make(map[string]bool)
|
||||
var out []string
|
||||
for _, e := range unmatched {
|
||||
b, q := short(e)
|
||||
candidate := ""
|
||||
switch {
|
||||
case strings.Contains(prefix, "/"):
|
||||
if qualified[q] == 1 && strings.HasPrefix(q, prefix) {
|
||||
candidate = q
|
||||
}
|
||||
case !strings.HasPrefix(b, prefix):
|
||||
case bare[b] == 1:
|
||||
candidate = b
|
||||
case q != b && qualified[q] == 1:
|
||||
candidate = q // like full names: zsh accepts it, bash/fish ignore it
|
||||
}
|
||||
if candidate != "" && !seen[candidate] {
|
||||
seen[candidate] = true
|
||||
out = append(out, candidate)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
@@ -228,12 +228,92 @@ func TestHostCompletionsMatchTypedText(t *testing.T) {
|
||||
// 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) {
|
||||
if got := hostCompletions(entries, tc.input, config.ShortcutsConfig{}); !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"}) {
|
||||
if got := hostCompletions([]cache.Entry{{Name: "web01"}, {Name: "db01"}}, "w", config.ShortcutsConfig{}); !reflect.DeepEqual(got, []string{"web01"}) {
|
||||
t.Errorf("single instance: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
var fsnRewrite = config.ShortcutsConfig{
|
||||
Domains: []string{".hoster.de"},
|
||||
Rewrites: []config.ShortcutRewrite{{Pattern: `^([a-z]+)[0-9]+-`, Replace: "$1"}},
|
||||
}
|
||||
|
||||
func TestHostCompletionsOfferShortcuts(t *testing.T) {
|
||||
entries := []cache.Entry{
|
||||
{Name: "prod/fsn1-bx1.hoster.de"},
|
||||
{Name: "prod/fsn1-db1.hoster.de"},
|
||||
{Name: "stg/fsn1-db1.hoster.de"},
|
||||
{Name: "prod/web01.hoster.de"},
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
input string
|
||||
want []string
|
||||
}{
|
||||
{"fsnb", []string{"fsnbx1"}},
|
||||
// Shortcut shared by two instances stays qualified.
|
||||
{"fsnd", []string{"prod/fsndb1", "stg/fsndb1"}},
|
||||
{"prod/fsn", []string{"prod/fsn1-bx1.hoster.de", "prod/fsn1-db1.hoster.de"}},
|
||||
{"stg/fsnd", []string{"stg/fsndb1"}},
|
||||
// A matching full name is not repeated as shortcut.
|
||||
{"web", []string{"web01.hoster.de"}},
|
||||
} {
|
||||
if got := hostCompletions(entries, tc.input, fsnRewrite); !reflect.DeepEqual(got, tc.want) {
|
||||
t.Errorf("%q: got %v, want %v", tc.input, got, tc.want)
|
||||
}
|
||||
}
|
||||
// Two hosts of the same instance with one shortcut cannot be selected by it.
|
||||
same := []cache.Entry{{Name: "fsn1-bx1.hoster.de"}, {Name: "fsn2-bx1.hoster.de"}}
|
||||
if got := hostCompletions(same, "fsnb", fsnRewrite); len(got) != 0 {
|
||||
t.Errorf("ambiguous shortcut offered: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortcutTestOutput(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
printShortcutTest(&out, "fsn1-bx1.hoster.de", []cache.Entry{{Name: "fsn1-bx1.hoster.de"}, {Name: "fsn2-bx1.hoster.de"}, {Name: "web01.hoster.de"}}, fsnRewrite)
|
||||
want := "fsn1-bx1.hoster.de → fsnbx1\nambiguous, matches 2 hosts:\n fsn1-bx1.hoster.de\n fsn2-bx1.hoster.de\n"
|
||||
if out.String() != want {
|
||||
t.Fatalf("got:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Rewrite rules alone must enable the shortcut lookup; NetBox is unreachable,
|
||||
// so only the cached host can answer.
|
||||
func TestWrapperConnectsByRewriteShortcut(t *testing.T) {
|
||||
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 := "netbox:\n url: http://127.0.0.1:1\n token: t\nshortcuts:\n rewrites:\n - pattern: '^([a-z]+)[0-9]+-'\n replace: '$1'\n"
|
||||
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)
|
||||
}
|
||||
c := cache.New(cfg.CachePath(), 3600)
|
||||
c.Upsert(cache.Entry{Name: "fsn1-bx1.hoster.de", IP: "10.0.0.9", Kind: "device"})
|
||||
if err := c.Save(); 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)
|
||||
}
|
||||
cmd := exec.Command(os.Args[0], "-test.run=^TestMultiInstanceWrapperHelper$")
|
||||
cmd.Env = append(os.Environ(), "NETSSH_TEST_MULTI_TARGET=fsnbx1.hoster.de", "PATH="+bin+":"+os.Getenv("PATH"))
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil || !strings.Contains(string(out), "10.0.0.9\n") {
|
||||
t.Fatalf("shortcut not resolved from cache: %s, %v", out, err)
|
||||
}
|
||||
}
|
||||
+37
-4
@@ -132,7 +132,7 @@ func runSSHWrapper(args []string) {
|
||||
// Shortcut cache lookup: scan entries whose normalized name matches the input.
|
||||
// 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
|
||||
shortcutsEnabled := cfg.Shortcuts.Enabled()
|
||||
if cached, _ := c.Get(lookupHost); shortcutsEnabled && cached.Name == "" && useCache {
|
||||
normalize := normalizeTarget(cfg.Shortcuts, source != "")
|
||||
entry, found, fresh, err := c.GetByShortcut(lookupHost, normalize)
|
||||
@@ -339,7 +339,7 @@ func rootCmd() *cobra.Command {
|
||||
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||
_ = c.Load()
|
||||
|
||||
hosts := hostCompletions(c.All(), toComplete)
|
||||
hosts := hostCompletions(c.All(), toComplete, cfg.Shortcuts)
|
||||
if limit := cfg.Completion.MaxResults; limit > 0 && len(hosts) > limit {
|
||||
hosts = hosts[:limit]
|
||||
}
|
||||
@@ -645,13 +645,15 @@ func buildAliasEntries(c *cache.Cache, cfg config.Config) []hook.AliasEntry {
|
||||
}
|
||||
|
||||
func aliasCmd() *cobra.Command {
|
||||
var shell string
|
||||
var shell, test string
|
||||
cmd := &cobra.Command{
|
||||
Use: "alias",
|
||||
Short: "Print shell aliases for all cached hosts",
|
||||
Long: `Print shell alias definitions for all cached hosts.
|
||||
The alias name is the shortened form derived from the configured shortcuts
|
||||
(domain suffixes stripped, hyphens optionally stripped).
|
||||
(domain suffixes stripped, rewrite rules applied, hyphens optionally stripped).
|
||||
|
||||
Check a rule with: netssh alias --test fsn1-bx1.example.com
|
||||
|
||||
Source the output in your shell profile:
|
||||
bash/zsh: eval "$(netssh alias)"
|
||||
@@ -668,6 +670,10 @@ Or use in a script:
|
||||
if err := c.Load(); err != nil {
|
||||
return err
|
||||
}
|
||||
if test != "" {
|
||||
printShortcutTest(os.Stdout, test, c.All(), cfg.Shortcuts)
|
||||
return nil
|
||||
}
|
||||
if shell == "" {
|
||||
shell = filepath.Base(os.Getenv("SHELL"))
|
||||
}
|
||||
@@ -694,9 +700,36 @@ Or use in a script:
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&shell, "shell", "", "Output format: bash, zsh, fish (default: $SHELL)")
|
||||
cmd.Flags().StringVar(&test, "test", "", "Show the shortcut for a hostname and the cached hosts it matches")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// printShortcutTest helps writing shortcut rules: it shows what a name is
|
||||
// shortened to and which cached hosts share that shortcut.
|
||||
func printShortcutTest(w io.Writer, name string, entries []cache.Entry, sc config.ShortcutsConfig) {
|
||||
normalize := normalizeTarget(sc, strings.Contains(name, "/"))
|
||||
short := normalize(name)
|
||||
fmt.Fprintf(w, "%s → %s\n", name, short)
|
||||
var matches []string
|
||||
for _, e := range entries {
|
||||
if normalize(e.Name) == short {
|
||||
matches = append(matches, e.Name)
|
||||
}
|
||||
}
|
||||
sort.Strings(matches)
|
||||
switch len(matches) {
|
||||
case 0:
|
||||
fmt.Fprintln(w, "no cached host has this shortcut")
|
||||
case 1:
|
||||
fmt.Fprintf(w, "matches %s\n", matches[0])
|
||||
default:
|
||||
fmt.Fprintf(w, "ambiguous, matches %d hosts:\n", len(matches))
|
||||
for _, m := range matches {
|
||||
fmt.Fprintf(w, " %s\n", m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// shellInitCmd is called at shell startup to update alias files and trigger a
|
||||
// background cache refresh when stale. Intentionally silent — never disrupts the prompt.
|
||||
func shellInitCmd() *cobra.Command {
|
||||
|
||||
Reference in new issue
Block a user