feat: search multiple NetBox instances with isolated host identities
Release / release (push) Successful in 5m16s
Release / release (push) Successful in 5m16s
This commit is contained in:
1 parent
a2a6dd7591
commit
7f53b90266
26 files changed
+1299
-115
No files matched your search
@@ -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
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user