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>
320 lines
11 KiB
Go
320 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"reflect"
|
|
"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)
|
|
}
|
|
}
|
|
}
|
|
|
|
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, 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", 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)
|
|
}
|
|
}
|