feat: search multiple NetBox instances with isolated host identities
Release / release (push) Successful in 5m16s

This commit is contained in:
Sebastian Unterschütz committed 2026-10-27 23:14:42 +01:00
1 parent a2a6dd7591
commit 7f53b90266
26 files changed
+1299 -115

No files matched your search

+112
View File
@@ -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
}
+23 -43
View File
@@ -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")
+34
View File
@@ -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")
}
}