feat: netssh multi — several hosts side by side in tmux or zellij
netssh multi opens one pane per host in a tmux window or zellij tab. Hosts come from names, tag:TAG (NetBox and static hosts), cluster:NAME (NetBox virtualization clusters; devices by default, --kind vm|all), and groups from the config (@name, nested, with cycle detection). Every pane runs netssh HOST, so lookup, rules, and jump hosts apply. - tmux: a new window inside tmux, else a new session that is attached; tiled layout, pane borders with the host and a [SYNC] marker. - zellij: a generated grid layout; a new tab inside zellij, else a new session. - --sync broadcasts typing to all panes; netssh sync toggles it in the current tmux window or zellij tab. - TUI: Space marks hosts, Ctrl+A marks all, Enter opens them together. - Configurator: multiplexer, panes per window, groups. NetBox SearchAll gains a cluster filter (name to cluster IDs per instance), and cache entries keep the host's cluster for completion and an offline fallback. Panes skip the 24-hour background refresh (checked once by multi) and get HOME/XDG paths but no NETSSH_* values, which would expose tokens in the process list. testenv/tests/15-multi.sh checks real tmux and zellij sessions, including a broadcast command arriving on all three cluster nodes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
dbb99bdded
commit
a31aab4110
24 files changed
+1754
-40
No files matched your search
+14
-5
@@ -46,6 +46,8 @@ var managedSubcommands = map[string]bool{
|
||||
"scp": true,
|
||||
"tunnel": true,
|
||||
"hosts": true,
|
||||
"multi": true,
|
||||
"sync": true,
|
||||
}
|
||||
|
||||
func main() {
|
||||
@@ -291,7 +293,7 @@ func resolveTarget(cfg *config.Config, host string) resolvedHost {
|
||||
fatalf("IP resolution for %q: %v", host, err)
|
||||
}
|
||||
|
||||
entry := cache.Entry{Name: target.Target(), IP: ip, Kind: target.Kind, Tags: target.Tags}
|
||||
entry := cache.Entry{Name: target.Target(), IP: ip, Kind: target.Kind, Tags: target.Tags, Cluster: target.Cluster}
|
||||
c.Upsert(entry)
|
||||
return used(entry)
|
||||
}
|
||||
@@ -389,6 +391,12 @@ func runTUI() {
|
||||
if host == nil {
|
||||
return
|
||||
}
|
||||
if host.Action == tui.ActionMulti {
|
||||
if err := runMulti(cfg, host.Targets, multiFlags{}); err != nil {
|
||||
fatalf("%v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Revalidate stale or live TUI results through the configured resolver chain.
|
||||
var tags []string
|
||||
@@ -413,7 +421,7 @@ func runTUI() {
|
||||
if err != nil {
|
||||
fatalf("IP resolution: %v", err)
|
||||
}
|
||||
c.Upsert(cache.Entry{Name: target.Target(), 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, Cluster: target.Cluster})
|
||||
tags = target.Tags
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "Connecting to %s (%s)…\n", host.Name, host.IP)
|
||||
@@ -499,7 +507,7 @@ func rootCmd() *cobra.Command {
|
||||
},
|
||||
}
|
||||
|
||||
root.AddCommand(notesCmd(), configureCmd(), searchCmd(), cacheCmd(), aliasCmd(), hookCmd(), shellInitCmd(), versionCmd(), uninstallCmd(), sftpCmd(), scpCmd(), tunnelCmd(), hostsCmd())
|
||||
root.AddCommand(notesCmd(), configureCmd(), searchCmd(), cacheCmd(), aliasCmd(), hookCmd(), shellInitCmd(), versionCmd(), uninstallCmd(), sftpCmd(), scpCmd(), tunnelCmd(), hostsCmd(), multiCmd(), syncCmd())
|
||||
|
||||
// cobra builds the "completion" command lazily; force init so we can extend it.
|
||||
root.InitDefaultCompletionCmd()
|
||||
@@ -754,7 +762,7 @@ func cacheRefreshCmd() *cobra.Command {
|
||||
fmt.Fprintf(os.Stderr, "warning: skipping %s: %v\n", e.Name, err)
|
||||
continue
|
||||
}
|
||||
c.Upsert(cache.Entry{Name: e.Target(), IP: ip, Kind: e.Kind, Tags: e.Tags})
|
||||
c.Upsert(cache.Entry{Name: e.Target(), IP: ip, Kind: e.Kind, Tags: e.Tags, Cluster: e.Cluster})
|
||||
written++
|
||||
}
|
||||
|
||||
@@ -995,7 +1003,8 @@ 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.HasNetBox() {
|
||||
// netssh multi checks once and starts its panes with NETSSH_NO_REFRESH.
|
||||
if !cfg.HasNetBox() || os.Getenv("NETSSH_NO_REFRESH") != "" {
|
||||
return
|
||||
}
|
||||
if !c.NeedsRefresh(24 * time.Hour) {
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"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/multiplex"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
||||
)
|
||||
|
||||
// maxHostsWithoutConfirm guards against opening hundreds of panes by accident.
|
||||
const maxHostsWithoutConfirm = 24
|
||||
|
||||
// multiFlags are the options shared by netssh multi and the TUI.
|
||||
type multiFlags struct {
|
||||
kind, user, jump, multiplexer string
|
||||
noJump, sync, dryRun, yes bool
|
||||
}
|
||||
|
||||
func multiCmd() *cobra.Command {
|
||||
var f multiFlags
|
||||
var useTmux, useZellij bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "multi [flags] HOST|tag:TAG|cluster:NAME|@GROUP...",
|
||||
Short: "Open several hosts side by side in tmux or zellij",
|
||||
Long: `Open one pane per host in a tmux window or zellij tab. Hosts are given as
|
||||
names (shortcuts, instance/host, static hosts) or selectors:
|
||||
|
||||
tag:TAG all hosts with this NetBox tag, and static hosts with this tag
|
||||
cluster:NAME devices of a NetBox virtualization cluster (--kind vm|all for VMs)
|
||||
@GROUP a group from the config (groups:)
|
||||
|
||||
Broadcast typing to all panes of a window with --sync, or toggle it later with
|
||||
netssh sync. Inside tmux or zellij the panes open in the current session.`,
|
||||
Example: ` netssh multi web01 web02 db01
|
||||
netssh multi cluster:pve-fsn1
|
||||
netssh multi --kind vm cluster:pve-fsn1
|
||||
netssh multi --sync tag:k8s-worker
|
||||
netssh multi @k8s`,
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if useTmux && useZellij {
|
||||
return errors.New("--tmux and --zellij are mutually exclusive")
|
||||
}
|
||||
if useTmux {
|
||||
f.multiplexer = multiplex.Tmux
|
||||
} else if useZellij {
|
||||
f.multiplexer = multiplex.Zellij
|
||||
}
|
||||
return runMulti(loadConfigOrSetup(), args, f)
|
||||
},
|
||||
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return nil, cobra.ShellCompDirectiveError
|
||||
}
|
||||
return multiCompletions(cfg, toComplete)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&f.kind, "kind", "", "Host kind for tag: and cluster: — device, vm, or all (default: all for tags, device for clusters)")
|
||||
cmd.Flags().BoolVar(&f.sync, "sync", false, "Start with typing broadcast to all panes")
|
||||
cmd.Flags().BoolVar(&useTmux, "tmux", false, "Use tmux")
|
||||
cmd.Flags().BoolVar(&useZellij, "zellij", false, "Use zellij")
|
||||
cmd.Flags().StringVarP(&f.user, "user", "l", "", "Login user for every host")
|
||||
cmd.Flags().StringVar(&f.jump, "jump", "", "Jump host for every host")
|
||||
cmd.Flags().BoolVar(&f.noJump, "no-jump", false, "Disable jump hosts")
|
||||
cmd.Flags().BoolVar(&f.dryRun, "dry-run", false, "Print the hosts and multiplexer commands")
|
||||
cmd.Flags().BoolVarP(&f.yes, "yes", "y", false, fmt.Sprintf("Open more than %d hosts without asking", maxHostsWithoutConfirm))
|
||||
_ = cmd.RegisterFlagCompletionFunc("kind", cobra.FixedCompletions([]cobra.Completion{"device", "vm", "all"}, cobra.ShellCompDirectiveNoFileComp))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func syncCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "sync",
|
||||
Short: "Toggle typing into all panes of the current tmux window or zellij tab",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
state, err := multiplex.ToggleSync(multiplex.System{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "netssh: "+state)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// runMulti expands selectors and opens one pane per host.
|
||||
func runMulti(cfg *config.Config, selectors []string, f multiFlags) error {
|
||||
if f.jump != "" && f.noJump {
|
||||
return errors.New("--jump and --no-jump are mutually exclusive")
|
||||
}
|
||||
switch f.kind {
|
||||
case "", "device", "vm", "all":
|
||||
default:
|
||||
return fmt.Errorf("--kind must be device, vm, or all")
|
||||
}
|
||||
c := newCache(cfg)
|
||||
_ = c.Load()
|
||||
targets, warnings, err := expandSelectors(cfg, c, selectors, f.kind)
|
||||
for _, warning := range warnings {
|
||||
fmt.Fprintln(os.Stderr, "netssh: warning: "+warning)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(targets) > maxHostsWithoutConfirm && !f.yes && !f.dryRun {
|
||||
return fmt.Errorf("%d hosts selected; pass --yes to open them all", len(targets))
|
||||
}
|
||||
|
||||
var runner multiplex.Runner = multiplex.System{}
|
||||
if f.dryRun {
|
||||
fmt.Printf("# %d hosts: %s\n", len(targets), strings.Join(targets, " "))
|
||||
runner = &multiplex.DryRun{W: os.Stdout}
|
||||
}
|
||||
requested := f.multiplexer
|
||||
if requested == "" {
|
||||
requested = cfg.Multi.Multiplexer
|
||||
}
|
||||
kind, err := multiplex.Detect(requested, runner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !f.dryRun {
|
||||
// Checked once here; the panes skip it (NETSSH_NO_REFRESH).
|
||||
maybeBackgroundRefresh(cfg, c)
|
||||
}
|
||||
|
||||
panes := make([]multiplex.Pane, 0, len(targets))
|
||||
for _, target := range targets {
|
||||
panes = append(panes, multiplex.Pane{Title: target, Argv: paneArgv(target, f)})
|
||||
}
|
||||
layoutDir := filepath.Dir(cfg.Cache.Path)
|
||||
if err := os.MkdirAll(layoutDir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
return multiplex.Open(kind, panes, multiplex.Options{
|
||||
Label: multiLabel(selectors),
|
||||
Sync: f.sync,
|
||||
PerWindow: cfg.Multi.PanesPerWindowOrDefault(),
|
||||
LayoutDir: layoutDir,
|
||||
Hint: func(s string) { fmt.Fprintln(os.Stderr, "netssh: "+s) },
|
||||
}, runner)
|
||||
}
|
||||
|
||||
// paneArgv runs netssh for one host. Panes of an existing tmux or zellij
|
||||
// session inherit that server's environment, so the paths that select the
|
||||
// config and cache are passed explicitly; NETSSH_* values are not, because
|
||||
// arguments are visible to other users (tokens).
|
||||
func paneArgv(target string, f multiFlags) []string {
|
||||
self, err := os.Executable()
|
||||
if err != nil {
|
||||
self = "netssh"
|
||||
}
|
||||
env, err := exec.LookPath("env")
|
||||
if err != nil {
|
||||
env = "/usr/bin/env"
|
||||
}
|
||||
argv := []string{env, "NETSSH_NO_REFRESH=1"}
|
||||
for _, key := range []string{"HOME", "XDG_CONFIG_HOME", "XDG_CACHE_HOME"} {
|
||||
if value, ok := os.LookupEnv(key); ok {
|
||||
argv = append(argv, key+"="+value)
|
||||
}
|
||||
}
|
||||
argv = append(argv, self)
|
||||
switch {
|
||||
case f.jump != "":
|
||||
argv = append(argv, "--jump", f.jump)
|
||||
case f.noJump:
|
||||
argv = append(argv, "--no-jump")
|
||||
}
|
||||
if f.user != "" {
|
||||
argv = append(argv, "-l", f.user)
|
||||
}
|
||||
return append(argv, target)
|
||||
}
|
||||
|
||||
var unsafeLabel = regexp.MustCompile(`[^A-Za-z0-9_-]+`)
|
||||
|
||||
// multiLabel names the window, session, or tab after a single selector.
|
||||
func multiLabel(selectors []string) string {
|
||||
if len(selectors) != 1 {
|
||||
return "multi"
|
||||
}
|
||||
s := selectors[0]
|
||||
for _, prefix := range []string{"@", "tag:", "cluster:"} {
|
||||
s = strings.TrimPrefix(s, prefix)
|
||||
}
|
||||
if s = strings.Trim(unsafeLabel.ReplaceAllString(s, "-"), "-"); s == "" {
|
||||
return "multi"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// expander resolves selectors into an ordered list of unique host targets.
|
||||
type expander struct {
|
||||
cfg *config.Config
|
||||
cache *cache.Cache
|
||||
kind string
|
||||
client *netbox.Client
|
||||
seen map[string]bool
|
||||
visiting map[string]bool
|
||||
targets []string
|
||||
warnings []string
|
||||
}
|
||||
|
||||
func expandSelectors(cfg *config.Config, c *cache.Cache, selectors []string, kind string) ([]string, []string, error) {
|
||||
e := &expander{cfg: cfg, cache: c, kind: kind, seen: map[string]bool{}, visiting: map[string]bool{}}
|
||||
for _, selector := range selectors {
|
||||
if err := e.expand(selector); err != nil {
|
||||
return nil, e.warnings, err
|
||||
}
|
||||
}
|
||||
if len(e.targets) == 0 {
|
||||
return nil, e.warnings, errors.New("no hosts selected")
|
||||
}
|
||||
return e.targets, e.warnings, nil
|
||||
}
|
||||
|
||||
func (e *expander) add(target string) {
|
||||
key := strings.ToLower(target)
|
||||
if !e.seen[key] {
|
||||
e.seen[key] = true
|
||||
e.targets = append(e.targets, target)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *expander) expand(selector string) error {
|
||||
switch {
|
||||
case strings.HasPrefix(selector, "@"):
|
||||
return e.group(strings.ToLower(strings.TrimPrefix(selector, "@")))
|
||||
case strings.HasPrefix(selector, "tag:"):
|
||||
return e.query(selector, netbox.SearchOptions{Tag: strings.TrimPrefix(selector, "tag:")}, "all")
|
||||
case strings.HasPrefix(selector, "cluster:"):
|
||||
return e.query(selector, netbox.SearchOptions{Cluster: strings.TrimPrefix(selector, "cluster:")}, "device")
|
||||
case selector == "":
|
||||
return nil
|
||||
}
|
||||
e.add(selector)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *expander) group(name string) error {
|
||||
members, ok := e.cfg.Groups[name]
|
||||
if !ok {
|
||||
names := make([]string, 0, len(e.cfg.Groups))
|
||||
for group := range e.cfg.Groups {
|
||||
names = append(names, "@"+group)
|
||||
}
|
||||
sort.Strings(names)
|
||||
if len(names) == 0 {
|
||||
return fmt.Errorf("unknown group @%s; no groups are configured (groups:)", name)
|
||||
}
|
||||
return fmt.Errorf("unknown group @%s; available: %s", name, strings.Join(names, ", "))
|
||||
}
|
||||
if e.visiting[name] {
|
||||
return fmt.Errorf("group @%s contains itself", name)
|
||||
}
|
||||
e.visiting[name] = true
|
||||
defer delete(e.visiting, name)
|
||||
for _, member := range members {
|
||||
if err := e.expand(member); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// query expands tag: and cluster: through NetBox, plus static hosts for tags;
|
||||
// if NetBox fails, the cache answers with a warning.
|
||||
func (e *expander) query(selector string, opts netbox.SearchOptions, defaultKind string) error {
|
||||
kind := e.kind
|
||||
if kind == "" {
|
||||
kind = defaultKind
|
||||
}
|
||||
if kind != "all" {
|
||||
opts.Kind = kind
|
||||
}
|
||||
before := len(e.targets)
|
||||
if opts.Tag != "" && (kind == "all" || kind == config.StaticKind) {
|
||||
for _, host := range e.cfg.Hosts {
|
||||
if hasTag(host.Tags, opts.Tag) {
|
||||
e.add(host.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if e.cfg.HasNetBox() {
|
||||
if e.client == nil {
|
||||
e.client = newNetBoxClient(e.cfg)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
entries, err := e.client.SearchAll(ctx, "", opts)
|
||||
cancel()
|
||||
if err != nil {
|
||||
e.warnings = append(e.warnings, fmt.Sprintf("%s: NetBox unavailable, using the cache: %v", selector, err))
|
||||
entries = e.fromCache(opts)
|
||||
} else {
|
||||
sort.Slice(entries, func(i, j int) bool { return entries[i].Target() < entries[j].Target() })
|
||||
}
|
||||
for _, entry := range entries {
|
||||
e.add(entry.Target())
|
||||
}
|
||||
}
|
||||
if len(e.targets) == before {
|
||||
hint := ""
|
||||
if opts.Cluster != "" && e.kind == "" {
|
||||
hint = " (only devices by default; try --kind vm or --kind all)"
|
||||
}
|
||||
return fmt.Errorf("%s matches no hosts%s", selector, hint)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *expander) fromCache(opts netbox.SearchOptions) []netbox.HostEntry {
|
||||
var out []netbox.HostEntry
|
||||
for _, entry := range e.cache.All() {
|
||||
if entry.Static || opts.Kind != "" && entry.Kind != opts.Kind {
|
||||
continue
|
||||
}
|
||||
if opts.Tag != "" && !hasTag(entry.Tags, opts.Tag) || opts.Cluster != "" && !strings.EqualFold(entry.Cluster, opts.Cluster) {
|
||||
continue
|
||||
}
|
||||
source, name := netbox.SplitTarget(entry.Name)
|
||||
out = append(out, netbox.HostEntry{Source: source, Name: name, Kind: entry.Kind})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Target() < out[j].Target() })
|
||||
return out
|
||||
}
|
||||
|
||||
// hasTag compares a tag by name or slug, like ssh.rules.
|
||||
func hasTag(tags []string, want string) bool {
|
||||
for _, tag := range tags {
|
||||
if strings.EqualFold(tag, want) || strings.EqualFold(strings.Join(strings.Fields(strings.ToLower(tag)), "-"), want) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// multiCompletions offers hosts, @groups, and tag:/cluster: values from the cache.
|
||||
func multiCompletions(cfg *config.Config, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
|
||||
c := newCache(cfg)
|
||||
_ = c.Load()
|
||||
lower := strings.ToLower(toComplete)
|
||||
var out []string
|
||||
switch {
|
||||
case strings.HasPrefix(lower, "tag:"), strings.HasPrefix(lower, "cluster:"):
|
||||
prefix, value, _ := strings.Cut(lower, ":")
|
||||
values := map[string]bool{}
|
||||
for _, entry := range c.All() {
|
||||
if prefix == "tag" {
|
||||
for _, tag := range entry.Tags {
|
||||
values[strings.ToLower(tag)] = true
|
||||
}
|
||||
} else if entry.Cluster != "" {
|
||||
values[entry.Cluster] = true
|
||||
}
|
||||
}
|
||||
for v := range values {
|
||||
if strings.HasPrefix(strings.ToLower(v), value) {
|
||||
out = append(out, prefix+":"+v)
|
||||
}
|
||||
}
|
||||
case strings.HasPrefix(lower, "@"):
|
||||
for group := range cfg.Groups {
|
||||
if strings.HasPrefix("@"+group, lower) {
|
||||
out = append(out, "@"+group)
|
||||
}
|
||||
}
|
||||
default:
|
||||
out = completeHosts(toComplete)
|
||||
var prefixes []string
|
||||
for _, p := range []string{"tag:", "cluster:"} {
|
||||
if toComplete != "" && strings.HasPrefix(p, lower) {
|
||||
prefixes = append(prefixes, p)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 && len(prefixes) > 0 {
|
||||
return prefixes, cobra.ShellCompDirectiveNoSpace | cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
for group := range cfg.Groups {
|
||||
if toComplete == "" {
|
||||
out = append(out, "@"+group)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
||||
)
|
||||
|
||||
func TestExpandSelectors(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Hosts: []config.StaticHost{{Name: "box", Address: "10.0.0.1", Tags: []string{"Web Servers"}}},
|
||||
Groups: map[string][]string{
|
||||
"all": {"@web", "db1", "box"},
|
||||
"web": {"web1", "tag:web-servers", "web1"},
|
||||
"loop": {"@loop"},
|
||||
},
|
||||
}
|
||||
c := cache.New(t.TempDir()+"/hosts.json", 60).WithStatic(cfg.Hosts)
|
||||
got, _, err := expandSelectors(cfg, c, []string{"@all", "WEB1"}, "")
|
||||
if err != nil || !reflect.DeepEqual(got, []string{"web1", "box", "db1"}) {
|
||||
t.Fatalf("got %v %v", got, err)
|
||||
}
|
||||
if _, _, err := expandSelectors(cfg, c, []string{"@loop"}, ""); err == nil || !strings.Contains(err.Error(), "contains itself") {
|
||||
t.Fatalf("cycle: %v", err)
|
||||
}
|
||||
if _, _, err := expandSelectors(cfg, c, []string{"@nope"}, ""); err == nil || !strings.Contains(err.Error(), "available: @all, @loop, @web") {
|
||||
t.Fatalf("unknown group: %v", err)
|
||||
}
|
||||
if _, _, err := expandSelectors(cfg, c, []string{"tag:web-servers"}, "device"); err == nil {
|
||||
t.Fatal("--kind device must exclude static hosts")
|
||||
}
|
||||
}
|
||||
|
||||
// Without a reachable NetBox, tag: and cluster: fall back to the cache.
|
||||
func TestExpandSelectorsCacheFallback(t *testing.T) {
|
||||
cfg := &config.Config{NetBox: config.NetBoxConfig{URL: "http://127.0.0.1:1", Token: "t", TokenVersion: 1}}
|
||||
c := cache.New(t.TempDir()+"/hosts.json", 60)
|
||||
c.Upsert(cache.Entry{Name: "node2", Kind: "device", Cluster: "pve"})
|
||||
c.Upsert(cache.Entry{Name: "node1", Kind: "device", Cluster: "pve", Tags: []string{"pve"}})
|
||||
c.Upsert(cache.Entry{Name: "vm1", Kind: "vm", Cluster: "pve"})
|
||||
got, warnings, err := expandSelectors(cfg, c, []string{"cluster:PVE"}, "")
|
||||
if err != nil || !reflect.DeepEqual(got, []string{"node1", "node2"}) || len(warnings) != 1 {
|
||||
t.Fatalf("devices: %v %v %v", got, warnings, err)
|
||||
}
|
||||
if got, _, _ := expandSelectors(cfg, c, []string{"cluster:pve"}, "all"); len(got) != 3 {
|
||||
t.Fatalf("all: %v", got)
|
||||
}
|
||||
if _, _, err := expandSelectors(cfg, c, []string{"cluster:other"}, ""); err == nil || !strings.Contains(err.Error(), "--kind vm") {
|
||||
t.Fatalf("empty cluster: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultiLabelAndPaneArgv(t *testing.T) {
|
||||
for selectors, want := range map[string]string{"@k8s": "k8s", "cluster:pve-fsn1": "pve-fsn1", "tag:a.b": "a-b", "a b": "multi"} {
|
||||
if got := multiLabel(strings.Fields(selectors)); got != want {
|
||||
t.Errorf("%q: got %q", selectors, got)
|
||||
}
|
||||
}
|
||||
t.Setenv("XDG_CONFIG_HOME", "/cfg")
|
||||
t.Setenv("NETSSH_NETBOX_TOKEN", "secret")
|
||||
argv := strings.Join(paneArgv("prod/web1", multiFlags{user: "ops", jump: "jh"}), " ")
|
||||
for _, want := range []string{"NETSSH_NO_REFRESH=1", "XDG_CONFIG_HOME=/cfg", "--jump jh -l ops prod/web1"} {
|
||||
if !strings.Contains(argv, want) {
|
||||
t.Errorf("argv lacks %q: %s", want, argv)
|
||||
}
|
||||
}
|
||||
if strings.Contains(argv, "secret") {
|
||||
t.Fatalf("token leaked into the pane arguments: %s", argv)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultiDryRun(t *testing.T) {
|
||||
prepareConfig(t, "hosts:\n - {name: a, address: 10.0.0.1, tags: [web]}\n - {name: b, address: 10.0.0.2, tags: [web]}\ngroups:\n web: [\"tag:web\"]\n")
|
||||
out, err := runNetssh(t, "multi", "--dry-run", "--tmux", "--sync", "@web")
|
||||
if err != nil {
|
||||
t.Fatalf("%s %v", out, err)
|
||||
}
|
||||
for _, want := range []string{"# 2 hosts: a b", "tmux new-session -d -x 240 -y 70 -s netssh-web", "synchronize-panes on", "tmux attach-session -t netssh-web"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
if out, err := runNetssh(t, "multi", "--dry-run", "--zellij", "a", "b"); err != nil || !strings.Contains(out, `pane name="b"`) {
|
||||
t.Errorf("zellij: %s %v", out, err)
|
||||
}
|
||||
if out, err := runNetssh(t, "sync"); err == nil || !strings.Contains(out, "inside tmux or zellij") {
|
||||
t.Errorf("sync outside: %s %v", out, err)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user