From 85ca5a8fe8704cb303f6e0d17c6a87ded73d9d8f Mon Sep 17 00:00:00 2001 From: Charlie-Root Date: Wed, 4 Nov 2026 06:14:49 +0100 Subject: [PATCH] NetBox host source, diagnostics, batch runner and safety flags Adds --source netbox, info/sensors/sel/power status, parallel per-host results with exit codes, --dry-run/--yes, request timeout, masked verbose output, and tests. --- .github/workflows/test.yml | 19 ++ README.md | 145 +++++++------- cmd/acl.go | 47 ++--- cmd/boot.go | 26 +-- cmd/info.go | 47 +++++ cmd/media.go | 41 ++-- cmd/power.go | 34 ++-- cmd/root.go | 72 ++++++- cmd/run.go | 113 +++++++++++ cmd/run_test.go | 64 +++++++ cmd/targets.go | 61 ++++++ cmd/user.go | 31 ++- config/config.go | 68 +++++-- host/client.go | 248 ++++++++++++++++++++++++ host/client_test.go | 155 +++++++++++++++ host/host.go | 27 --- host/ops.go | 375 +++++++++++++++++++++++++++++++++++++ host/redfish.go | 23 --- host/request.go | 83 -------- host/server.go | 72 ------- main.go | 25 +-- netbox/netbox.go | 222 ++++++++++++++++++++++ netbox/netbox_test.go | 82 ++++++++ 23 files changed, 1683 insertions(+), 397 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 cmd/info.go create mode 100644 cmd/run.go create mode 100644 cmd/run_test.go create mode 100644 cmd/targets.go create mode 100644 host/client.go create mode 100644 host/client_test.go delete mode 100644 host/host.go create mode 100644 host/ops.go delete mode 100644 host/redfish.go delete mode 100644 host/request.go delete mode 100644 host/server.go create mode 100644 netbox/netbox.go create mode 100644 netbox/netbox_test.go diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..09dfde5 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,19 @@ +name: Test + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: gofmt + run: test -z "$(gofmt -l .)" + - run: go vet ./... + - run: go test -race ./... diff --git a/README.md b/README.md index 4565662..a510e24 100644 --- a/README.md +++ b/README.md @@ -1,108 +1,119 @@ # sm-cli -sm-cli is a command-line tool written in Go for managing server operations via Redfish API. It provides commands to interact with BMC (Baseboard Management Controller) systems such as powering on/off servers, managing virtual media, and setting boot options. +sm-cli is a command-line tool written in Go for managing Supermicro servers through their BMC (Baseboard Management Controller) using the Redfish API: power, boot overrides, virtual media, users, IP access control, and read-only diagnostics (system info, sensors, event log). -I started using this tool because it takes a lot of time to log in to all IPMIs and adjust certain things, especially when you are dealing with 100+ servers. This tool allows you (with a simple bash statement) to walk over all hosts and run tasks in batches. +I started using this tool because it takes a lot of time to log in to all IPMIs and adjust certain things, especially when you are dealing with 100+ servers. sm-cli works on many hosts at once, in parallel. ## Features -- Power Management: Control server power state (on, off, restart). -- Virtual Media: Insert and eject virtual media. -- Boot Options: Set boot order to CD or PXE once. -- Add new users -- Enable ACL and add rules - -I am adding more features to this tool as I continue to work on it. Feel free to contribute or suggest new features! +- Power: status, on, off, restart, graceful shutdown. +- Diagnostics: system info (model, serial, BIOS/BMC firmware, health), sensors (temperatures, fans, PSUs, voltages), hardware event log. +- Virtual media: insert, eject, status. +- Boot: CD or PXE once (UEFI). +- Users: add an Administrator user. +- IP access control: enable, add rules. +- Hosts from an inventory file or from NetBox; select by name, rack, site, tag or role. +- Parallel execution, per-host results, text or JSON output, non-zero exit code when a host fails. +- Safety: `--dry-run` for changes, `--yes` required to change more than one host, passwords masked in verbose output. ## Installation -Clone the repository and build the binary: +Grab a pre-built binary from the [releases](https://github.com/Charlie-Root/smcli/releases) page, or build it: ``` git clone https://github.com/Charlie-Root/smcli.git -cd sm-cli -go build -o sm-cli main.go +cd smcli +go build -o sm-cli . ``` -Make sure to have Go installed on your system. -Or grab one of the pre-built binaries from the [releases](https://github.com/Charlie-Root/smcli/releases) page. +## Hosts -## Usage -The sm-cli tool supports the following commands: +### Inventory file (default) +Configure your servers in `inventory.yaml` (or pass `--inventory `, `-` reads it from stdin): -### Power Management -``` -./sm-cli power [on|off|restart] +```yaml +hosts: + - name: host1 + bmc_address: x.x.x.x + username_password: ADMIN:xxxxxx + iso_image: http://some.server/debian.iso ``` -#### Examples: +### NetBox +With `--source netbox` (or `SMCLI_SOURCE=netbox`), sm-cli looks hosts up in NetBox instead. Set `NETBOX_URL` and `NETBOX_TOKEN`. For each device it follows: -``` -# Power on a server -./sm-cli power on host1 +device → `oob_ip` → the interface that IP is assigned to → interface custom fields `ipmi_username` and `ipmi_password`. -# Power off a server -./sm-cli power off host2 +Credentials are only held in memory. Devices without an OOB IP or without credentials are reported as failed for that host. -# Restart a server -./sm-cli power restart host3 -``` +Select hosts by name, or with `--rack`, `--site`, `--tag` and `--role` (NetBox slugs; filters combine): -### Virtual Media Management ``` -./sm-cli media [insert|eject|status] +sm-cli --source netbox power status --rack A.R10 --role server ``` -#### Examples: - -``` -# Insert virtual media from ISO image -./sm-cli media insert host1 +## Usage -# Eject virtual media -./sm-cli media eject host2 +Hosts are given after the action. Every command accepts one or more hosts. -# Check virtual media status -./sm-cli media status host3 +### Diagnostics (read-only) +``` +sm-cli power status host1 host2 +sm-cli info host1 +sm-cli sensors host1 [--problems] +sm-cli sel host1 [--since 24h] [--limit 50] [--manager] +sm-cli media status host1 ``` +`sel --since 0` shows the whole log; `--manager` adds the BMC's own logs. -### Boot Options +### Power ``` -./sm-cli boot [cd|pxe] +sm-cli power [on|off|restart|shutdown] ``` +`off` and `restart` are forced; `shutdown` asks the OS to shut down (ACPI). -#### Examples: - +### Boot +``` +sm-cli boot [cd|pxe] ``` -# Set boot order to CD once -./sm-cli boot cd host1 -# Set boot order to PXE once -./sm-cli boot pxe host2 -``` +### Virtual media +``` +sm-cli media [insert|eject|status] [--iso http://some.server/debian.iso] +``` -## Configuration +### Users and access control +``` +sm-cli user +sm-cli acl enable +sm-cli acl add
+``` +Use `-` as password to read it from stdin, so it does not show up in the process list or shell history. -Configure your servers in inventory.yaml: +### Global flags +| Flag | Default | Meaning | +|---|---|---| +| `--source` | `file` (`SMCLI_SOURCE`) | `file` or `netbox` | +| `--inventory` | `inventory.yaml` | inventory file, `-` for stdin | +| `--rack`, `--site`, `--tag`, `--role` | | NetBox selection | +| `--parallel` | `5` | hosts at the same time | +| `--timeout` | `15s` | per request | +| `-o`, `--output` | `text` | `text` or `json` | +| `--dry-run` | | show changes without sending them | +| `-y`, `--yes` | | required to change more than one host | +| `-v`, `--verbose` | | log requests to stderr, passwords masked | -```yaml -hosts: - - name: host1 - bmc_address: x.x.x.x - username_password: ADMIN:xxxxxx - iso_image: http://some.server/debian.iso -``` -Ensure each server entry includes its hostname, BMC IP address, username/password for authentication, and ISO image URL for virtual media operations. +Exit code is 0 when every host succeeded, 1 otherwise. -## Additional Notes +## Notes -### SSL Verification: -By default, SSL verification is skipped for BMC connections. Modify main.go to change this behavior if required. +### TLS +Certificate verification is skipped for BMC connections (BMCs use self-signed certificates). NetBox connections are verified. -### Verbose Mode: -Use -v or --verbose flag to enable detailed output for requests. +### Compatibility +The command syntax of earlier versions still works (`sm-cli power on host1`, `sm-cli user `, `sm-cli acl add ...`). -# Contributing +## Contributing -Contributions are welcome! Fork the repository, create a new branch, make your changes, and submit a pull request. +Contributions are welcome! Fork the repository, create a new branch, make your changes, run `go test ./...`, and submit a pull request. -# References -https://www.supermicro.com/manuals/other/redfish-ref-guide-html/Content/general-content/available-apis.htm \ No newline at end of file +## References +https://www.supermicro.com/manuals/other/redfish-ref-guide-html/Content/general-content/available-apis.htm diff --git a/cmd/acl.go b/cmd/acl.go index 0ecf826..6d8624f 100644 --- a/cmd/acl.go +++ b/cmd/acl.go @@ -1,49 +1,40 @@ package cmd import ( + "fmt" + "strconv" + "github.com/Charlie-Root/smcli/host" "github.com/spf13/cobra" ) -// Parent ACL command (no direct functionality) var AclCmd = &cobra.Command{ Use: "acl", - Short: "Manage ACL settings", + Short: "Manage the BMC IP access control list (Supermicro)", } -// Subcommand to enable ACL on the host var EnableAclCmd = &cobra.Command{ - Use: "enable ", - Short: "Enable ACL on the specified host", - Args: cobra.ExactArgs(1), // Expect exactly one argument for the host - Run: func(cmd *cobra.Command, args []string) { - hostName := args[0] - host.SelectHost(hostName) - host.ServerEnableACL() // Call to enable ACL on the host + Use: "enable [host...]", + Short: "Enable IP access control", + RunE: func(cmd *cobra.Command, args []string) error { + return run(args, true, func(c *host.Client) (host.Result, error) { return c.EnableACL() }) }, } -// Subcommand to add an ACL entry var AddAclCmd = &cobra.Command{ - Use: "add
", - Short: "Add new ACL entry", - Args: cobra.ExactArgs(4), // Expect address, prefix, policy, and host as arguments - Run: func(cmd *cobra.Command, args []string) { - address := args[0] - prefix := args[1] - policy := args[2] - hostName := args[3] - - host.SelectHost(hostName) - host.ServerCreateACL(address, prefix, policy) // Call to add ACL entry + Use: "add
[host...]", + Short: "Add an IP access control rule", + Args: cobra.MinimumNArgs(3), + RunE: func(cmd *cobra.Command, args []string) error { + address, policy, hosts := args[0], args[2], args[3:] + prefix, err := strconv.Atoi(args[1]) + if err != nil || prefix < 0 || prefix > 128 { + return fmt.Errorf("invalid prefix length %q", args[1]) + } + return run(hosts, true, func(c *host.Client) (host.Result, error) { return c.AddACL(address, prefix, policy) }) }, } func init() { - // Register subcommands under the acl command - AclCmd.AddCommand(EnableAclCmd) - AclCmd.AddCommand(AddAclCmd) - - // Register acl command to the root command (assuming RootCmd is the root cobra command) - RootCmd.AddCommand(AclCmd) + AclCmd.AddCommand(EnableAclCmd, AddAclCmd) } diff --git a/cmd/boot.go b/cmd/boot.go index 422667c..302ad1d 100644 --- a/cmd/boot.go +++ b/cmd/boot.go @@ -8,23 +8,15 @@ import ( ) var BootCmd = &cobra.Command{ - Use: "boot [cd|pxe]", - Short: "Set boot order", - Args: cobra.ExactArgs(2), - Run: func(cmd *cobra.Command, args []string) { - action := args[0] - hostName := args[1] - - host.SelectHost(hostName) - host.GetRedfishPath() - - switch action { - case "cd": - host.ServerSetBootOnceCD() - case "pxe": - host.ServerSetBootOncePXE() - default: - fmt.Println("Unknown boot command:", action) + Use: "boot [cd|pxe] [host...]", + Short: "Boot from CD or PXE once (UEFI)", + Args: cobra.MinimumNArgs(1), + ValidArgs: []string{"cd", "pxe"}, + RunE: func(cmd *cobra.Command, args []string) error { + target, hosts := args[0], args[1:] + if target != "cd" && target != "pxe" { + return fmt.Errorf("unknown boot target %q (want cd or pxe)", target) } + return run(hosts, true, func(c *host.Client) (host.Result, error) { return c.BootOnce(target) }) }, } diff --git a/cmd/info.go b/cmd/info.go new file mode 100644 index 0000000..2a83f43 --- /dev/null +++ b/cmd/info.go @@ -0,0 +1,47 @@ +package cmd + +import ( + "time" + + "github.com/Charlie-Root/smcli/host" + "github.com/spf13/cobra" +) + +var InfoCmd = &cobra.Command{ + Use: "info [host...]", + Short: "Show model, serial, BIOS/BMC firmware, power state and health", + RunE: func(cmd *cobra.Command, args []string) error { + return run(args, false, func(c *host.Client) (host.Result, error) { return c.Info() }) + }, +} + +var sensorsProblemsOnly bool + +var SensorsCmd = &cobra.Command{ + Use: "sensors [host...]", + Short: "Show temperatures, fans, PSUs and voltages", + RunE: func(cmd *cobra.Command, args []string) error { + return run(args, false, func(c *host.Client) (host.Result, error) { return c.Sensors(sensorsProblemsOnly) }) + }, +} + +var ( + selSince time.Duration + selLimit int + selManager bool +) + +var SelCmd = &cobra.Command{ + Use: "sel [host...]", + Short: "Show the hardware event log, newest first", + RunE: func(cmd *cobra.Command, args []string) error { + return run(args, false, func(c *host.Client) (host.Result, error) { return c.SEL(selSince, selLimit, selManager) }) + }, +} + +func init() { + SensorsCmd.Flags().BoolVar(&sensorsProblemsOnly, "problems", false, "only show sensors that are not OK") + SelCmd.Flags().DurationVar(&selSince, "since", 24*time.Hour, "only entries newer than this (0 = all)") + SelCmd.Flags().IntVar(&selLimit, "limit", 50, "maximum entries per host (0 = all)") + SelCmd.Flags().BoolVar(&selManager, "manager", false, "also read the BMC's own logs") +} diff --git a/cmd/media.go b/cmd/media.go index 28cf80d..724a241 100644 --- a/cmd/media.go +++ b/cmd/media.go @@ -7,26 +7,33 @@ import ( "github.com/spf13/cobra" ) -var MediaCmd = &cobra.Command{ - Use: "media [insert|eject|status]", - Short: "Manage virtual media", - Args: cobra.ExactArgs(2), - Run: func(cmd *cobra.Command, args []string) { - action := args[0] - hostName := args[1] - - host.SelectHost(hostName) - host.GetRedfishPath() +var mediaISO string +var MediaCmd = &cobra.Command{ + Use: "media [insert|eject|status] [host...]", + Short: "Manage virtual media", + Args: cobra.MinimumNArgs(1), + ValidArgs: []string{"insert", "eject", "status"}, + RunE: func(cmd *cobra.Command, args []string) error { + action, hosts := args[0], args[1:] switch action { - case "insert": - host.ServerVirtualMediaInsert() - case "eject": - host.ServerVirtualMediaEject() case "status": - host.ServerVirtualMediaStatus() - default: - fmt.Println("Unknown media command:", action) + return run(hosts, false, func(c *host.Client) (host.Result, error) { return c.MediaStatus() }) + case "eject": + return run(hosts, true, func(c *host.Client) (host.Result, error) { return c.MediaEject() }) + case "insert": + return run(hosts, true, func(c *host.Client) (host.Result, error) { + iso := c.Host.ISOImage + if mediaISO != "" { + iso = mediaISO + } + return c.MediaInsert(iso) + }) } + return fmt.Errorf("unknown media action %q (want insert, eject or status)", action) }, } + +func init() { + MediaCmd.Flags().StringVar(&mediaISO, "iso", "", "ISO image URL (overrides iso_image from the inventory)") +} diff --git a/cmd/power.go b/cmd/power.go index 39f8e04..276df04 100644 --- a/cmd/power.go +++ b/cmd/power.go @@ -8,25 +8,25 @@ import ( ) var PowerCmd = &cobra.Command{ - Use: "power [on|off|restart]", - Short: "Manage power", - Args: cobra.ExactArgs(2), - Run: func(cmd *cobra.Command, args []string) { - action := args[0] - hostName := args[1] - - host.SelectHost(hostName) - host.GetRedfishPath() + Use: "power [on|off|restart|shutdown|status] [host...]", + Short: "Show or change the power state", + Long: `Show or change the power state. + on power on + off force power off + restart force restart + shutdown graceful shutdown (ACPI, the OS shuts down) + status show the power state`, + Args: cobra.MinimumNArgs(1), + ValidArgs: []string{"on", "off", "restart", "shutdown", "status"}, + RunE: func(cmd *cobra.Command, args []string) error { + action, hosts := args[0], args[1:] switch action { - case "on": - host.ServerPowerOn() - case "off": - host.ServerPowerOff() - case "restart": - host.ServerRestart() - default: - fmt.Println("Unknown power command:", action) + case "status": + return run(hosts, false, func(c *host.Client) (host.Result, error) { return c.PowerStatus() }) + case "on", "off", "restart", "shutdown": + return run(hosts, true, func(c *host.Client) (host.Result, error) { return c.Reset(action) }) } + return fmt.Errorf("unknown power action %q (want on, off, restart, shutdown or status)", action) }, } diff --git a/cmd/root.go b/cmd/root.go index 75c2816..fa6e553 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,11 +1,79 @@ package cmd import ( + "errors" + "fmt" + "os" + "time" + "github.com/spf13/cobra" ) -var Verbose bool +type options struct { + inventory string + source string + rack string + site string + tag string + role string + parallel int + timeout time.Duration + output string + dryRun bool + yes bool + verbose bool +} + +var opts options var RootCmd = &cobra.Command{ - Use: "sm-cli", + Use: "sm-cli", + Short: "Manage Supermicro servers through their BMC (Redfish)", + Long: `Manage Supermicro servers through their BMC (Redfish). + +Hosts come from an inventory file (--source file, default) or from NetBox +(--source netbox, needs NETBOX_URL and NETBOX_TOKEN). Name hosts as arguments, +or select them from NetBox with --rack, --site, --tag or --role.`, + SilenceUsage: true, + SilenceErrors: true, +} + +// ErrFailed means one or more hosts failed; the details were already printed. +var ErrFailed = errors.New("one or more hosts failed") + +func init() { + f := RootCmd.PersistentFlags() + f.StringVar(&opts.inventory, "inventory", "inventory.yaml", `inventory file, "-" for stdin (--source file)`) + f.StringVar(&opts.source, "source", envOr("SMCLI_SOURCE", "file"), "where hosts come from: file or netbox (env SMCLI_SOURCE)") + f.StringVar(&opts.rack, "rack", "", "netbox: select devices in this rack") + f.StringVar(&opts.site, "site", "", "netbox: select devices at this site (slug)") + f.StringVar(&opts.tag, "tag", "", "netbox: select devices with this tag (slug)") + f.StringVar(&opts.role, "role", "", "netbox: select devices with this role (slug)") + f.IntVar(&opts.parallel, "parallel", 5, "hosts to work on at the same time") + f.DurationVar(&opts.timeout, "timeout", 15*time.Second, "timeout per request") + f.StringVarP(&opts.output, "output", "o", "text", "output format: text or json") + f.BoolVar(&opts.dryRun, "dry-run", false, "show changes without sending them") + f.BoolVarP(&opts.yes, "yes", "y", false, "allow changes on more than one host") + f.BoolVarP(&opts.verbose, "verbose", "v", false, "log requests to stderr (passwords masked)") + + RootCmd.AddCommand(PowerCmd, BootCmd, MediaCmd, UserCmd, AclCmd, InfoCmd, SensorsCmd, SelCmd) +} + +func envOr(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +// Execute runs the CLI and returns the process exit code. +func Execute(version string) int { + RootCmd.Version = version + if err := RootCmd.Execute(); err != nil { + if !errors.Is(err, ErrFailed) { + fmt.Fprintln(os.Stderr, "Error:", err) + } + return 1 + } + return 0 } diff --git a/cmd/run.go b/cmd/run.go new file mode 100644 index 0000000..d2fa9a0 --- /dev/null +++ b/cmd/run.go @@ -0,0 +1,113 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "io" + "os" + "strings" + "sync" + + "github.com/Charlie-Root/smcli/host" +) + +type opFunc func(c *host.Client) (host.Result, error) + +type outcome struct { + Host string `json:"host"` + BMC string `json:"bmc_address,omitempty"` + OK bool `json:"ok"` + Error string `json:"error,omitempty"` + Message string `json:"message,omitempty"` + Data interface{} `json:"data,omitempty"` +} + +var stdout io.Writer = os.Stdout + +// run resolves the hosts and applies op to each, in parallel. write marks operations that change a host. +func run(names []string, write bool, op opFunc) error { + if opts.output != "text" && opts.output != "json" { + return fmt.Errorf("unknown --output %q (want text or json)", opts.output) + } + targets, err := resolveTargets(names) + if err != nil { + return err + } + if len(targets) == 0 { + return fmt.Errorf("no hosts selected") + } + if write && !opts.dryRun && len(targets) > 1 && !opts.yes { + return fmt.Errorf("refusing to change %d hosts without --yes (use --dry-run to preview)", len(targets)) + } + + results := make([]outcome, len(targets)) + sem := make(chan struct{}, max(1, opts.parallel)) + var wg sync.WaitGroup + for i, t := range targets { + wg.Add(1) + go func(i int, t target) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + results[i] = execute(t, op) + }(i, t) + } + wg.Wait() + + failed := 0 + for _, r := range results { + if !r.OK { + failed++ + } + } + if opts.output == "json" { + enc := json.NewEncoder(stdout) + enc.SetIndent("", " ") + if err := enc.Encode(results); err != nil { + return err + } + } else { + printText(results) + } + if len(results) > 1 { + fmt.Fprintf(os.Stderr, "%d ok, %d failed\n", len(results)-failed, failed) + } + if failed > 0 { + return ErrFailed + } + return nil +} + +func execute(t target, op opFunc) outcome { + o := outcome{Host: t.host.Name, BMC: t.host.BMCAddress} + if t.err != nil { + o.Error = t.err.Error() + return o + } + c, err := host.New(t.host, opts.timeout) + if err != nil { + o.Error = err.Error() + return o + } + c.Verbose, c.DryRun, c.Log = opts.verbose, opts.dryRun, os.Stderr + res, err := op(c) + if err != nil { + o.Error = err.Error() + return o + } + o.OK, o.Message, o.Data = true, res.Text, res.Data + return o +} + +func printText(results []outcome) { + for _, r := range results { + switch { + case !r.OK: + fmt.Fprintf(stdout, "%s: ERROR: %s\n", r.Host, r.Error) + case strings.Contains(r.Message, "\n"): + fmt.Fprintf(stdout, "%s:\n %s\n", r.Host, strings.ReplaceAll(r.Message, "\n", "\n ")) + default: + fmt.Fprintf(stdout, "%s: %s\n", r.Host, r.Message) + } + } +} diff --git a/cmd/run_test.go b/cmd/run_test.go new file mode 100644 index 0000000..0fb1973 --- /dev/null +++ b/cmd/run_test.go @@ -0,0 +1,64 @@ +package cmd + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Charlie-Root/smcli/host" +) + +func writeInventory(t *testing.T) string { + t.Helper() + p := filepath.Join(t.TempDir(), "inventory.yaml") + inv := "hosts:\n - name: a\n bmc_address: 192.0.2.1\n username_password: ADMIN:x\n - name: b\n bmc_address: 192.0.2.2\n username_password: ADMIN:y\n" + if err := os.WriteFile(p, []byte(inv), 0o600); err != nil { + t.Fatal(err) + } + return p +} + +func resetOpts(inventory string) { + opts = options{inventory: inventory, source: "file", parallel: 2, output: "text", timeout: 1} +} + +func TestWriteOnManyHostsNeedsYes(t *testing.T) { + resetOpts(writeInventory(t)) + called := false + err := run([]string{"a", "b"}, true, func(c *host.Client) (host.Result, error) { + called = true + return host.Result{}, nil + }) + if err == nil || !strings.Contains(err.Error(), "--yes") || called { + t.Fatalf("err = %v, called = %v", err, called) + } +} + +func TestSelectionFlagsNeedNetBox(t *testing.T) { + resetOpts(writeInventory(t)) + opts.rack = "A.R10" + if _, err := resolveTargets(nil); err == nil || !strings.Contains(err.Error(), "--source netbox") { + t.Fatalf("err = %v", err) + } +} + +func TestUnknownHost(t *testing.T) { + resetOpts(writeInventory(t)) + if _, err := resolveTargets([]string{"a", "zzz"}); err == nil || !strings.Contains(err.Error(), "zzz") { + t.Fatalf("err = %v", err) + } +} + +func TestFailedHostGivesErrFailed(t *testing.T) { + resetOpts(writeInventory(t)) + var sb strings.Builder + stdout = &sb + defer func() { stdout = os.Stdout }() + err := run([]string{"a"}, false, func(c *host.Client) (host.Result, error) { + return host.Result{}, os.ErrDeadlineExceeded + }) + if err != ErrFailed || !strings.Contains(sb.String(), "a: ERROR:") { + t.Fatalf("err = %v, out = %q", err, sb.String()) + } +} diff --git a/cmd/targets.go b/cmd/targets.go new file mode 100644 index 0000000..1f41c1b --- /dev/null +++ b/cmd/targets.go @@ -0,0 +1,61 @@ +package cmd + +import ( + "fmt" + + "github.com/Charlie-Root/smcli/config" + "github.com/Charlie-Root/smcli/netbox" +) + +type target struct { + host config.Host + err error +} + +func selecting() bool { + return opts.rack != "" || opts.site != "" || opts.tag != "" || opts.role != "" +} + +func resolveTargets(names []string) ([]target, error) { + if len(names) > 0 && selecting() { + return nil, fmt.Errorf("give host names or --rack/--site/--tag/--role, not both") + } + if len(names) == 0 && !selecting() { + return nil, fmt.Errorf("no hosts given") + } + switch opts.source { + case "file": + if selecting() { + return nil, fmt.Errorf("--rack/--site/--tag/--role need --source netbox") + } + hosts, err := config.LoadFile(opts.inventory) + if err != nil { + return nil, err + } + found, err := config.Find(hosts, names) + if err != nil { + return nil, err + } + out := make([]target, len(found)) + for i, h := range found { + out[i] = target{host: h} + } + return out, nil + case "netbox": + nb, err := netbox.NewFromEnv(opts.timeout) + if err != nil { + return nil, err + } + res, err := nb.Resolve(netbox.Filter{Names: names, Rack: opts.rack, Site: opts.site, Tag: opts.tag, Role: opts.role}) + if err != nil { + return nil, err + } + out := make([]target, len(res)) + for i, r := range res { + out[i] = target{host: r.Host, err: r.Err} + } + return out, nil + default: + return nil, fmt.Errorf("unknown --source %q (want file or netbox)", opts.source) + } +} diff --git a/cmd/user.go b/cmd/user.go index b973372..4f43ee8 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -1,19 +1,32 @@ package cmd import ( + "bufio" + "fmt" + "os" + "strings" + "github.com/Charlie-Root/smcli/host" "github.com/spf13/cobra" ) var UserCmd = &cobra.Command{ - Use: "user ", - Short: "Add new user", - Args: cobra.ExactArgs(3), - Run: func(cmd *cobra.Command, args []string) { - - hostName := args[2] - - host.SelectHost(hostName) - host.ServerCreateUser(args[0], args[1]) + Use: "user [host...]", + Short: "Add an Administrator user", + Long: `Add an Administrator user. Pass "-" as password to read it from stdin, which keeps it out of the process list.`, + Args: cobra.MinimumNArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + username, password, hosts := args[0], args[1], args[2:] + if password == "-" { + line, err := bufio.NewReader(os.Stdin).ReadString('\n') + if err != nil && line == "" { + return fmt.Errorf("reading password from stdin: %w", err) + } + password = strings.TrimRight(line, "\r\n") + } + if password == "" { + return fmt.Errorf("empty password") + } + return run(hosts, true, func(c *host.Client) (host.Result, error) { return c.CreateUser(username, password) }) }, } diff --git a/config/config.go b/config/config.go index 1839b74..aaf3eac 100644 --- a/config/config.go +++ b/config/config.go @@ -1,35 +1,73 @@ package config import ( - "log" + "errors" + "fmt" + "io" "os" + "strings" "gopkg.in/yaml.v2" ) type Host struct { - Name string `yaml:"name"` - BMCAddress string `yaml:"bmc_address"` - UsernamePassword string `yaml:"username_password"` - ISOImage string `yaml:"iso_image"` + Name string `yaml:"name" json:"name"` + BMCAddress string `yaml:"bmc_address" json:"bmc_address"` + UsernamePassword string `yaml:"username_password" json:"-"` + ISOImage string `yaml:"iso_image" json:"iso_image,omitempty"` } type Config struct { Hosts []Host `yaml:"hosts"` } -var ConfigData Config +// Credentials splits the "user:password" field. +func (h Host) Credentials() (string, string, error) { + parts := strings.SplitN(h.UsernamePassword, ":", 2) + if len(parts) != 2 || parts[0] == "" { + return "", "", fmt.Errorf("host %s: username_password must be \"user:password\"", h.Name) + } + return parts[0], parts[1], nil +} -func LoadConfig(filename string) { - file, err := os.Open(filename) - if err != nil { - log.Fatalf("Error opening config file: %v", err) +// LoadFile reads an inventory file; "-" reads it from stdin. +func LoadFile(path string) ([]Host, error) { + var r io.Reader + if path == "-" { + r = os.Stdin + } else { + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("opening inventory: %w", err) + } + defer f.Close() + r = f + } + var c Config + if err := yaml.NewDecoder(r).Decode(&c); err != nil && !errors.Is(err, io.EOF) { + return nil, fmt.Errorf("decoding inventory %s: %w", path, err) } - defer file.Close() + return c.Hosts, nil +} - decoder := yaml.NewDecoder(file) - err = decoder.Decode(&ConfigData) - if err != nil { - log.Fatalf("Error decoding config file: %v", err) +// Find returns the named hosts, in the requested order. +func Find(hosts []Host, names []string) ([]Host, error) { + idx := make(map[string]Host, len(hosts)) + for _, h := range hosts { + idx[h.Name] = h + } + out := make([]Host, 0, len(names)) + var missing []string + for _, n := range names { + h, ok := idx[n] + if !ok { + missing = append(missing, n) + continue + } + out = append(out, h) + } + if len(missing) > 0 { + return nil, fmt.Errorf("host(s) not found in inventory: %s", strings.Join(missing, ", ")) } + return out, nil } diff --git a/host/client.go b/host/client.go new file mode 100644 index 0000000..8d0800a --- /dev/null +++ b/host/client.go @@ -0,0 +1,248 @@ +package host + +import ( + "bytes" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "strings" + "time" + + "github.com/Charlie-Root/smcli/config" +) + +// Client talks Redfish to a single BMC. Use one Client per host; it is not shared between goroutines. +type Client struct { + Host config.Host + Verbose bool + DryRun bool + Log io.Writer + + http *http.Client + user, pass string + systemPath string + managerPath string + chassisPath string +} + +// Result is what an operation returns: a human-readable text and structured data for JSON output. +type Result struct { + Text string + Data interface{} +} + +func New(h config.Host, timeout time.Duration) (*Client, error) { + user, pass, err := h.Credentials() + if err != nil { + return nil, err + } + if h.BMCAddress == "" { + return nil, fmt.Errorf("host %s has no BMC address", h.Name) + } + tr := &http.Transport{ + // BMCs ship self-signed certificates. + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + } + return &Client{ + Host: h, + Log: io.Discard, + http: &http.Client{Transport: tr, Timeout: timeout}, + user: user, + pass: pass, + }, nil +} + +// RedfishError is a non-2xx answer from the BMC. +type RedfishError struct { + Method string + Path string + Status int + Message string + Details []string +} + +func (e *RedfishError) Error() string { + msg := fmt.Sprintf("%s %s: HTTP %d", e.Method, e.Path, e.Status) + if e.Message != "" { + msg += ": " + e.Message + } + if len(e.Details) > 0 { + msg += " (" + strings.Join(e.Details, "; ") + ")" + } + return msg +} + +func parseError(method, path string, status int, body []byte) error { + e := &RedfishError{Method: method, Path: path, Status: status} + var r struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + Extended []struct { + Message string `json:"Message"` + Resolution string `json:"Resolution"` + } `json:"@Message.ExtendedInfo"` + } `json:"error"` + } + if json.Unmarshal(body, &r) == nil && (r.Error.Message != "" || r.Error.Code != "") { + e.Message = r.Error.Message + if e.Message == "" { + e.Message = r.Error.Code + } + for _, x := range r.Error.Extended { + d := x.Message + if x.Resolution != "" { + d += " Resolution: " + x.Resolution + } + e.Details = append(e.Details, d) + } + } else if s := strings.TrimSpace(string(body)); s != "" && len(s) < 300 { + e.Message = s + } + return e +} + +func (c *Client) logf(format string, a ...interface{}) { + if c.Verbose { + fmt.Fprintf(c.Log, "[%s] "+format+"\n", append([]interface{}{c.Host.Name}, a...)...) + } +} + +func (c *Client) baseURL() string { + addr := c.Host.BMCAddress + if ip := net.ParseIP(addr); ip != nil && ip.To4() == nil { + addr = "[" + addr + "]" + } + return "https://" + addr +} + +func (c *Client) do(method, path string, payload interface{}) ([]byte, error) { + var body []byte + if payload != nil { + b, err := json.Marshal(payload) + if err != nil { + return nil, err + } + body = b + } + url := c.baseURL() + path + c.logf("> %s %s %s", method, url, MaskSecrets(body)) + req, err := http.NewRequest(method, url, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.SetBasicAuth(c.user, c.pass) + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("%s %s: %w", method, path, err) + } + defer resp.Body.Close() + respBody, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20)) + if err != nil { + return nil, fmt.Errorf("%s %s: reading response: %w", method, path, err) + } + c.logf("< %s (%d bytes)", resp.Status, len(respBody)) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return respBody, parseError(method, path, resp.StatusCode, respBody) + } + return respBody, nil +} + +func (c *Client) getJSON(path string, v interface{}) error { + b, err := c.do("GET", path, nil) + if err != nil { + return err + } + if err := json.Unmarshal(b, v); err != nil { + return fmt.Errorf("GET %s: decoding response: %w", path, err) + } + return nil +} + +// write sends a changing request, or only describes it in dry-run mode. +func (c *Client) write(method, path string, payload interface{}, done string) (Result, error) { + if c.DryRun { + b, _ := json.Marshal(payload) + return Result{ + Text: fmt.Sprintf("dry run: would %s %s %s", method, path, MaskSecrets(b)), + Data: map[string]interface{}{"dry_run": true, "method": method, "path": path}, + }, nil + } + if _, err := c.do(method, path, payload); err != nil { + return Result{}, err + } + return Result{Text: done, Data: map[string]interface{}{"method": method, "path": path}}, nil +} + +// MaskSecrets hides any JSON field whose name contains "password". +func MaskSecrets(b []byte) string { + if len(b) == 0 { + return "" + } + var m map[string]interface{} + if json.Unmarshal(b, &m) != nil { + return string(b) + } + for k := range m { + if strings.Contains(strings.ToLower(k), "password") { + m[k] = "****" + } + } + out, _ := json.Marshal(m) + return string(out) +} + +func (c *Client) firstMember(collection string) (string, error) { + var col struct { + Members []struct { + ID string `json:"@odata.id"` + } `json:"Members"` + } + if err := c.getJSON(collection, &col); err != nil { + return "", err + } + if len(col.Members) == 0 || col.Members[0].ID == "" { + return "", fmt.Errorf("%s has no members", collection) + } + return col.Members[0].ID, nil +} + +func (c *Client) system() (string, error) { + if c.systemPath == "" { + p, err := c.firstMember("/redfish/v1/Systems") + if err != nil { + return "", err + } + c.systemPath = p + } + return c.systemPath, nil +} + +func (c *Client) manager() (string, error) { + if c.managerPath == "" { + p, err := c.firstMember("/redfish/v1/Managers") + if err != nil { + return "", err + } + c.managerPath = p + } + return c.managerPath, nil +} + +func (c *Client) chassis() (string, error) { + if c.chassisPath == "" { + p, err := c.firstMember("/redfish/v1/Chassis") + if err != nil { + return "", err + } + c.chassisPath = p + } + return c.chassisPath, nil +} diff --git a/host/client_test.go b/host/client_test.go new file mode 100644 index 0000000..9711e03 --- /dev/null +++ b/host/client_test.go @@ -0,0 +1,155 @@ +package host + +import ( + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/Charlie-Root/smcli/config" +) + +type fakeBMC struct { + mu sync.Mutex + requests []string + routes map[string]string +} + +func (f *fakeBMC) ServeHTTP(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + f.requests = append(f.requests, r.Method+" "+r.URL.Path) + f.mu.Unlock() + if u, p, ok := r.BasicAuth(); !ok || u != "ADMIN" || p != "secret" { + w.WriteHeader(http.StatusUnauthorized) + return + } + if r.Method != "GET" { + if strings.HasSuffix(r.URL.Path, "/fail") { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"error":{"code":"Base.1.4.GeneralError","message":"bad request","@Message.ExtendedInfo":[{"Message":"Property missing","Resolution":"Add it"}]}}`)) + return + } + w.WriteHeader(http.StatusNoContent) + return + } + body, ok := f.routes[r.URL.Path] + if !ok { + w.WriteHeader(http.StatusNotFound) + return + } + w.Write([]byte(body)) +} + +func newTestClient(t *testing.T, routes map[string]string) (*Client, *fakeBMC) { + t.Helper() + f := &fakeBMC{routes: routes} + srv := httptest.NewTLSServer(f) + t.Cleanup(srv.Close) + h := config.Host{Name: "h1", BMCAddress: strings.TrimPrefix(srv.URL, "https://"), UsernamePassword: "ADMIN:secret"} + c, err := New(h, 5*time.Second) + if err != nil { + t.Fatal(err) + } + return c, f +} + +var systems = map[string]string{ + "/redfish/v1/Systems": `{"Members":[{"@odata.id":"/redfish/v1/Systems/1"}]}`, + "/redfish/v1/Systems/1": `{"Manufacturer":"Supermicro","Model":"X","SerialNumber":"S1","BiosVersion":"2.0", + "PowerState":"On","Status":{"Health":"OK","HealthRollup":"OK"}, + "ProcessorSummary":{"Count":1,"Model":"AMD"},"MemorySummary":{"TotalSystemMemoryGiB":64}}`, + "/redfish/v1/Managers": `{"Members":[{"@odata.id":"/redfish/v1/Managers/1"}]}`, + "/redfish/v1/Managers/1": `{"FirmwareVersion":"1.2.3","Model":"BMC"}`, +} + +func TestPowerStatusAndInfo(t *testing.T) { + c, _ := newTestClient(t, systems) + r, err := c.PowerStatus() + if err != nil || r.Text != "power On" { + t.Fatalf("PowerStatus = %q, %v", r.Text, err) + } + r, err = c.Info() + if err != nil || !strings.Contains(r.Text, "BMC firmware 1.2.3") || !strings.Contains(r.Text, "serial S1") { + t.Fatalf("Info = %q, %v", r.Text, err) + } +} + +func TestDryRunSendsNoWrite(t *testing.T) { + c, f := newTestClient(t, systems) + c.DryRun = true + r, err := c.Reset("restart") + if err != nil || !strings.HasPrefix(r.Text, "dry run: would POST /redfish/v1/Systems/1/Actions/ComputerSystem.Reset") { + t.Fatalf("Reset dry run = %q, %v", r.Text, err) + } + for _, req := range f.requests { + if !strings.HasPrefix(req, "GET ") { + t.Fatalf("dry run sent %s", req) + } + } +} + +func TestWriteAndError(t *testing.T) { + c, f := newTestClient(t, systems) + if _, err := c.Reset("on"); err != nil { + t.Fatal(err) + } + if got := f.requests[len(f.requests)-1]; got != "POST /redfish/v1/Systems/1/Actions/ComputerSystem.Reset" { + t.Fatalf("last request = %s", got) + } + _, err := c.write("POST", "/fail", map[string]string{}, "") + if err == nil || !strings.Contains(err.Error(), "HTTP 400: bad request (Property missing Resolution: Add it)") { + t.Fatalf("error = %v", err) + } + if _, err := c.Reset("explode"); err == nil { + t.Fatal("unknown action accepted") + } +} + +func TestMaskSecrets(t *testing.T) { + got := MaskSecrets([]byte(`{"UserName":"u","Password":"p","NewPassword":"x"}`)) + if strings.Contains(got, `"p"`) || strings.Contains(got, `"x"`) || !strings.Contains(got, `"u"`) { + t.Fatalf("MaskSecrets = %s", got) + } +} + +func TestSensors(t *testing.T) { + routes := map[string]string{ + "/redfish/v1/Chassis": `{"Members":[{"@odata.id":"/redfish/v1/Chassis/1"}]}`, + "/redfish/v1/Chassis/1/Thermal": `{"Temperatures":[{"Name":"CPU","ReadingCelsius":90,"UpperThresholdCritical":95,"Status":{"Health":"Warning","State":"Enabled"}}, + {"Name":"Gone","Status":{"State":"Absent"}}],"Fans":[{"FanName":"FAN1","Reading":3000,"ReadingUnits":"RPM","Status":{"Health":"OK","State":"Enabled"}}]}`, + "/redfish/v1/Chassis/1/Power": `{"PowerSupplies":[{"Name":"PSU1","PowerInputWatts":120,"Status":{"Health":"OK","State":"Enabled"}}]}`, + } + c, _ := newTestClient(t, routes) + r, err := c.Sensors(true) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(r.Text, "4 sensors, 1 not OK") || !strings.Contains(r.Text, "! temperature") || strings.Contains(r.Text, "FAN1") { + t.Fatalf("Sensors = %q", r.Text) + } +} + +func TestSELPagingAndSince(t *testing.T) { + recent := time.Now().UTC().Add(-time.Hour).Format(time.RFC3339) + routes := map[string]string{ + "/redfish/v1/Systems": systems["/redfish/v1/Systems"], + "/redfish/v1/Systems/1/LogServices": `{"Members":[{"@odata.id":"/redfish/v1/Systems/1/LogServices/Log1"}]}`, + "/redfish/v1/Systems/1/LogServices/Log1/Entries": `{"Members":[{"Id":"1","Created":"2020-01-01T00:00:00Z","Severity":"OK","Message":"old"}], + "Members@odata.nextLink":"/redfish/v1/Systems/1/LogServices/Log1/Entries2"}`, + "/redfish/v1/Systems/1/LogServices/Log1/Entries2": `{"Members":[{"Id":"2","Created":"` + recent + `","Severity":"Critical","Message":"ECC error"}]}`, + } + c, _ := newTestClient(t, routes) + r, err := c.SEL(24*time.Hour, 10, false) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(r.Text, "1 entries") || !strings.Contains(r.Text, "ECC error") || strings.Contains(r.Text, "old") { + t.Fatalf("SEL = %q", r.Text) + } + r, _ = c.SEL(0, 0, false) + if !strings.HasPrefix(r.Text, "2 entries") || strings.Index(r.Text, "ECC error") > strings.Index(r.Text, "old") { + t.Fatalf("SEL all = %q", r.Text) + } +} diff --git a/host/host.go b/host/host.go deleted file mode 100644 index 2faa403..0000000 --- a/host/host.go +++ /dev/null @@ -1,27 +0,0 @@ -package host - -import ( - "log" - - "github.com/Charlie-Root/smcli/config" -) - -var ( - CurrentHost config.Host - RedfishPath string - verbose bool -) - -func SetVerbose(v bool) { - verbose = v -} - -func SelectHost(name string) { - for _, host := range config.ConfigData.Hosts { - if host.Name == name { - CurrentHost = host - return - } - } - log.Fatalf("Host with name %s not found", name) -} diff --git a/host/ops.go b/host/ops.go new file mode 100644 index 0000000..e422137 --- /dev/null +++ b/host/ops.go @@ -0,0 +1,375 @@ +package host + +import ( + "fmt" + "sort" + "strings" + "time" +) + +var resetTypes = map[string]string{ + "on": "On", + "off": "ForceOff", + "restart": "ForceRestart", + "shutdown": "GracefulShutdown", +} + +// Reset changes the power state: on, off (forced), restart (forced) or shutdown (graceful). +func (c *Client) Reset(action string) (Result, error) { + t, ok := resetTypes[action] + if !ok { + return Result{}, fmt.Errorf("unknown power action %q", action) + } + sys, err := c.system() + if err != nil { + return Result{}, err + } + return c.write("POST", sys+"/Actions/ComputerSystem.Reset", map[string]string{"ResetType": t}, "power "+t+" accepted") +} + +func (c *Client) PowerStatus() (Result, error) { + sys, err := c.system() + if err != nil { + return Result{}, err + } + var s struct{ PowerState string } + if err := c.getJSON(sys, &s); err != nil { + return Result{}, err + } + return Result{Text: "power " + s.PowerState, Data: map[string]string{"power_state": s.PowerState}}, nil +} + +var bootTargets = map[string]string{"cd": "Cd", "pxe": "Pxe"} + +// BootOnce sets a one-time UEFI boot override. +func (c *Client) BootOnce(target string) (Result, error) { + t, ok := bootTargets[target] + if !ok { + return Result{}, fmt.Errorf("unknown boot target %q", target) + } + sys, err := c.system() + if err != nil { + return Result{}, err + } + payload := map[string]interface{}{"Boot": map[string]string{ + "BootSourceOverrideEnabled": "Once", + "BootSourceOverrideTarget": t, + "BootSourceOverrideMode": "UEFI", + }} + return c.write("PATCH", sys, payload, "next boot "+t+" (once, UEFI)") +} + +const virtualMediaCD = "/redfish/v1/Managers/1/VirtualMedia/CD1" + +func (c *Client) MediaInsert(image string) (Result, error) { + if image == "" { + return Result{}, fmt.Errorf("no ISO image: set iso_image in the inventory or pass --iso") + } + return c.write("POST", virtualMediaCD+"/Actions/VirtualMedia.InsertMedia", map[string]string{"Image": image}, "inserted "+image) +} + +func (c *Client) MediaEject() (Result, error) { + return c.write("POST", virtualMediaCD+"/Actions/VirtualMedia.EjectMedia", map[string]string{}, "ejected") +} + +func (c *Client) MediaStatus() (Result, error) { + var m map[string]interface{} + err := c.getJSON("/redfish/v1/Managers/1/VirtualMedia/1", &m) + if err != nil { + if err2 := c.getJSON(virtualMediaCD, &m); err2 != nil { + return Result{}, err + } + } + return Result{Text: fmt.Sprintf("inserted=%v image=%v", m["Inserted"], m["Image"]), Data: m}, nil +} + +// CreateUser adds an Administrator account. +func (c *Client) CreateUser(username, password string) (Result, error) { + payload := map[string]interface{}{"UserName": username, "Password": password, "RoleId": "Administrator", "Enabled": true} + return c.write("POST", "/redfish/v1/AccountService/Accounts", payload, "user "+username+" created") +} + +const ipAccessControl = "/redfish/v1/Managers/1/Oem/Supermicro/IPAccessControl" + +func (c *Client) EnableACL() (Result, error) { + return c.write("PATCH", ipAccessControl, map[string]bool{"ServiceEnabled": true}, "IP access control enabled") +} + +func (c *Client) AddACL(address string, prefix int, policy string) (Result, error) { + payload := map[string]interface{}{"Address": address, "PrefixLength": prefix, "Policy": policy} + return c.write("POST", ipAccessControl+"/FilterRules", payload, fmt.Sprintf("rule %s/%d %s added", address, prefix, policy)) +} + +type status struct { + Health string `json:"Health"` + HealthRollup string `json:"HealthRollup"` + State string `json:"State"` +} + +// Info summarises the system and its BMC. +func (c *Client) Info() (Result, error) { + sys, err := c.system() + if err != nil { + return Result{}, err + } + var s struct { + Manufacturer string + Model string + SerialNumber string + BiosVersion string + PowerState string + Status status + ProcessorSummary struct { + Count int + Model string + } + MemorySummary struct { + TotalSystemMemoryGiB float64 + } + } + if err := c.getJSON(sys, &s); err != nil { + return Result{}, err + } + data := map[string]interface{}{ + "manufacturer": s.Manufacturer, + "model": s.Model, + "serial": s.SerialNumber, + "bios_version": s.BiosVersion, + "power_state": s.PowerState, + "health": s.Status.Health, + "health_rollup": s.Status.HealthRollup, + "cpu_model": s.ProcessorSummary.Model, + "cpu_count": s.ProcessorSummary.Count, + "memory_gib": s.MemorySummary.TotalSystemMemoryGiB, + } + bmc := "unknown" + if mgr, err := c.manager(); err == nil { + var m struct{ FirmwareVersion, Model string } + if c.getJSON(mgr, &m) == nil { + bmc = m.FirmwareVersion + data["bmc_firmware"] = m.FirmwareVersion + data["bmc_model"] = m.Model + } + } + text := fmt.Sprintf("%s %s, serial %s\npower %s, health %s (rollup %s)\nBIOS %s, BMC firmware %s\nCPU %dx %s, memory %.0f GiB", + s.Manufacturer, s.Model, s.SerialNumber, s.PowerState, orDash(s.Status.Health), orDash(s.Status.HealthRollup), + s.BiosVersion, bmc, s.ProcessorSummary.Count, s.ProcessorSummary.Model, s.MemorySummary.TotalSystemMemoryGiB) + return Result{Text: text, Data: data}, nil +} + +// Sensor is one temperature, fan, PSU or voltage reading. +type Sensor struct { + Kind string `json:"kind"` + Name string `json:"name"` + Reading *float64 `json:"reading,omitempty"` + Unit string `json:"unit,omitempty"` + Critical *float64 `json:"critical,omitempty"` + Health string `json:"health,omitempty"` + State string `json:"state,omitempty"` +} + +func (s Sensor) ok() bool { return s.Health == "" || s.Health == "OK" } + +// Sensors reads the chassis Thermal and Power resources. +func (c *Client) Sensors(problemsOnly bool) (Result, error) { + ch, err := c.chassis() + if err != nil { + return Result{}, err + } + var sensors []Sensor + var errs []string + + var th struct { + Temperatures []struct { + Name string + ReadingCelsius *float64 + UpperThresholdCritical *float64 + Status status + } + Fans []struct { + Name string + FanName string + Reading *float64 + ReadingUnits string + Status status + } + } + if err := c.getJSON(ch+"/Thermal", &th); err != nil { + errs = append(errs, err.Error()) + } + for _, t := range th.Temperatures { + sensors = append(sensors, Sensor{"temperature", t.Name, t.ReadingCelsius, "C", t.UpperThresholdCritical, t.Status.Health, t.Status.State}) + } + for _, f := range th.Fans { + name := f.Name + if name == "" { + name = f.FanName + } + sensors = append(sensors, Sensor{"fan", name, f.Reading, f.ReadingUnits, nil, f.Status.Health, f.Status.State}) + } + + var pw struct { + PowerSupplies []struct { + Name string + PowerInputWatts *float64 + LineInputVoltage *float64 + Status status + } + Voltages []struct { + Name string + ReadingVolts *float64 + UpperThresholdCritical *float64 + Status status + } + } + if err := c.getJSON(ch+"/Power", &pw); err != nil { + errs = append(errs, err.Error()) + } + for _, p := range pw.PowerSupplies { + sensors = append(sensors, Sensor{"psu", p.Name, p.PowerInputWatts, "W", nil, p.Status.Health, p.Status.State}) + } + for _, v := range pw.Voltages { + sensors = append(sensors, Sensor{"voltage", v.Name, v.ReadingVolts, "V", v.UpperThresholdCritical, v.Status.Health, v.Status.State}) + } + if len(errs) == 2 { + return Result{}, fmt.Errorf("%s", strings.Join(errs, "; ")) + } + + var shown []Sensor + bad := 0 + for _, s := range sensors { + if s.State == "Absent" { + continue + } + if !s.ok() { + bad++ + } + if problemsOnly && s.ok() { + continue + } + shown = append(shown, s) + } + var b strings.Builder + fmt.Fprintf(&b, "%d sensors, %d not OK", len(sensors), bad) + for _, s := range shown { + mark := " " + if !s.ok() { + mark = "!" + } + reading := "n/a" + if s.Reading != nil { + reading = fmt.Sprintf("%.1f %s", *s.Reading, s.Unit) + } + line := fmt.Sprintf("\n%s %-11s %-28s %s", mark, s.Kind, s.Name, reading) + if s.Critical != nil { + line += fmt.Sprintf(" (crit %.1f)", *s.Critical) + } + if !s.ok() { + line += " " + s.Health + } + b.WriteString(line) + } + for _, e := range errs { + b.WriteString("\nwarning: " + e) + } + return Result{Text: b.String(), Data: map[string]interface{}{"not_ok": bad, "sensors": shown, "warnings": errs}}, nil +} + +// LogEntry is one hardware/event log entry. +type LogEntry struct { + Log string `json:"log"` + ID string `json:"id"` + Created string `json:"created"` + Severity string `json:"severity"` + Message string `json:"message"` + created time.Time +} + +const maxLogEntries = 5000 + +// SEL reads the system event logs (and optionally the BMC's own logs), newest first. +func (c *Client) SEL(since time.Duration, limit int, includeManager bool) (Result, error) { + sys, err := c.system() + if err != nil { + return Result{}, err + } + collections := []string{sys + "/LogServices"} + if includeManager { + if mgr, err := c.manager(); err == nil { + collections = append(collections, mgr+"/LogServices") + } + } + var services []string + for _, col := range collections { + var l struct { + Members []struct { + ID string `json:"@odata.id"` + } `json:"Members"` + } + if err := c.getJSON(col, &l); err != nil { + return Result{}, err + } + for _, m := range l.Members { + services = append(services, m.ID) + } + } + if len(services) == 0 { + return Result{}, fmt.Errorf("no log services found") + } + + cutoff := time.Time{} + if since > 0 { + cutoff = time.Now().Add(-since) + } + var entries []LogEntry + for _, svc := range services { + next := svc + "/Entries" + for next != "" && len(entries) < maxLogEntries { + var page struct { + Members []struct { + ID string `json:"Id"` + Created string `json:"Created"` + Severity string `json:"Severity"` + Message string `json:"Message"` + } `json:"Members"` + NextLink string `json:"Members@odata.nextLink"` + } + if err := c.getJSON(next, &page); err != nil { + return Result{}, err + } + for _, m := range page.Members { + e := LogEntry{Log: svc[strings.LastIndex(svc, "/")+1:], ID: m.ID, Created: m.Created, Severity: m.Severity, Message: m.Message} + if t, err := time.Parse(time.RFC3339, m.Created); err == nil { + e.created = t + if !cutoff.IsZero() && t.Before(cutoff) { + continue + } + } + entries = append(entries, e) + } + next = page.NextLink + } + } + sort.SliceStable(entries, func(i, j int) bool { return entries[i].created.After(entries[j].created) }) + total := len(entries) + if limit > 0 && len(entries) > limit { + entries = entries[:limit] + } + var b strings.Builder + window := "all time" + if since > 0 { + window = "last " + since.String() + } + fmt.Fprintf(&b, "%d entries (%s), showing %d", total, window, len(entries)) + for _, e := range entries { + fmt.Fprintf(&b, "\n%s %-8s %s", e.Created, orDash(e.Severity), strings.TrimSpace(e.Message)) + } + return Result{Text: b.String(), Data: map[string]interface{}{"total": total, "entries": entries}}, nil +} + +func orDash(s string) string { + if s == "" { + return "-" + } + return s +} diff --git a/host/redfish.go b/host/redfish.go deleted file mode 100644 index fb62594..0000000 --- a/host/redfish.go +++ /dev/null @@ -1,23 +0,0 @@ -package host - -import ( - "encoding/json" - "fmt" - "log" -) - -func GetRedfishPath() { - url := fmt.Sprintf("https://%s/redfish/v1/Systems", CurrentHost.BMCAddress) - respBody := MakeRequest("GET", url, nil) - - var data map[string]interface{} - if err := json.Unmarshal(respBody, &data); err != nil { - log.Fatal(err) - } - - if members, ok := data["Members"].([]interface{}); ok && len(members) > 0 { - if member, ok := members[0].(map[string]interface{}); ok { - RedfishPath = member["@odata.id"].(string) - } - } -} diff --git a/host/request.go b/host/request.go deleted file mode 100644 index f32e3b1..0000000 --- a/host/request.go +++ /dev/null @@ -1,83 +0,0 @@ -package host - -import ( - "bytes" - "crypto/tls" - "encoding/json" - "fmt" - "io/ioutil" - "log" - "net/http" - "strings" -) - -func MakeRequest(method, url string, body []byte) []byte { - tr := &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, - } - client := &http.Client{Transport: tr} - - req, err := http.NewRequest(method, url, bytes.NewBuffer(body)) - if err != nil { - log.Fatal(err) - } - authParts := strings.SplitN(CurrentHost.UsernamePassword, ":", 2) - req.SetBasicAuth(authParts[0], authParts[1]) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") - - resp, err := client.Do(req) - if err != nil { - log.Fatal(err) - } - defer resp.Body.Close() - - respBody, err := ioutil.ReadAll(resp.Body) - if err != nil { - log.Fatal(err) - } - - if resp.StatusCode >= 200 && resp.StatusCode < 300 { - var success struct { - Message string `json:"message"` - } - if err := json.Unmarshal(respBody, &success); err == nil { - if verbose { - fmt.Println("Success:", success.Message) - } - } else { - if verbose { - fmt.Println("Success:", string(respBody)) - } - } - } else { - var errorResp struct { - Error struct { - Code string `json:"code"` - Message string `json:"message"` - MessageExtended []struct { - MessageId string `json:"MessageId"` - Severity string `json:"Severity"` - Resolution string `json:"Resolution"` - Message string `json:"Message"` - MessageArgs []string `json:"MessageArgs"` - RelatedProperties []string `json:"RelatedProperties"` - } `json:"@Message.ExtendedInfo"` - } `json:"error"` - } - if err := json.Unmarshal(respBody, &errorResp); err == nil { - fmt.Printf("Error: %s - %s\n", errorResp.Error.Code, errorResp.Error.Message) - for _, info := range errorResp.Error.MessageExtended { - fmt.Printf(" %s: %s\n", info.Severity, info.Message) - if info.Resolution != "" { - fmt.Printf(" Resolution: %s\n", info.Resolution) - } - } - } else { - fmt.Printf("Error: %s\n", resp.Status) - fmt.Println(string(respBody)) - } - } - - return respBody -} diff --git a/host/server.go b/host/server.go deleted file mode 100644 index 587c113..0000000 --- a/host/server.go +++ /dev/null @@ -1,72 +0,0 @@ -package host - -import ( - "fmt" -) - -func ServerPowerOff() { - url := fmt.Sprintf("https://%s%s/Actions/ComputerSystem.Reset", CurrentHost.BMCAddress, RedfishPath) - body := []byte(`{"ResetType": "ForceOff"}`) - MakeRequest("POST", url, body) -} - -func ServerPowerOn() { - url := fmt.Sprintf("https://%s%s/Actions/ComputerSystem.Reset", CurrentHost.BMCAddress, RedfishPath) - body := []byte(`{"ResetType": "On"}`) - MakeRequest("POST", url, body) -} - -func ServerRestart() { - url := fmt.Sprintf("https://%s%s/Actions/ComputerSystem.Reset", CurrentHost.BMCAddress, RedfishPath) - body := []byte(`{"ResetType": "ForceRestart"}`) - MakeRequest("POST", url, body) -} - -func ServerVirtualMediaInsert() { - url := fmt.Sprintf("https://%s/redfish/v1/Managers/1/VirtualMedia/CD1/Actions/VirtualMedia.InsertMedia", CurrentHost.BMCAddress) - body := []byte(fmt.Sprintf(`{"Image": "%s"}`, CurrentHost.ISOImage)) - MakeRequest("POST", url, body) -} - -func ServerVirtualMediaEject() { - url := fmt.Sprintf("https://%s/redfish/v1/Managers/1/VirtualMedia/CD1/Actions/VirtualMedia.EjectMedia", CurrentHost.BMCAddress) - body := []byte(`{}`) - MakeRequest("POST", url, body) -} - -func ServerVirtualMediaStatus() { - url := fmt.Sprintf("https://%s/redfish/v1/Managers/1/VirtualMedia/1", CurrentHost.BMCAddress) - MakeRequest("GET", url, nil) -} - -func ServerSetBootOnceCD() { - url := fmt.Sprintf("https://%s%s", CurrentHost.BMCAddress, RedfishPath) - body := []byte(`{"Boot":{ "BootSourceOverrideEnabled": "Once", "BootSourceOverrideTarget": "Cd", "BootSourceOverrideMode": "UEFI"}}`) - MakeRequest("PATCH", url, body) -} - -func ServerSetBootOncePXE() { - url := fmt.Sprintf("https://%s%s", CurrentHost.BMCAddress, RedfishPath) - body := []byte(`{"Boot":{ "BootSourceOverrideEnabled": "Once", "BootSourceOverrideTarget": "Pxe", "BootSourceOverrideMode": "UEFI"}}`) - MakeRequest("PATCH", url, body) -} - -func ServerCreateUser(username string, password string) { - url := fmt.Sprintf("https://%s/redfish/v1/AccountService/Accounts", CurrentHost.BMCAddress) - body := []byte(fmt.Sprintf(`{"UserName": "%s", "Password": "%s", "RoleId": "Administrator", "Enabled" : true}`, username, password)) - - MakeRequest("POST", url, body) -} - -func ServerEnableACL() { - url := fmt.Sprintf("https://%s/redfish/v1/Managers/1/Oem/Supermicro/IPAccessControl", CurrentHost.BMCAddress) - body := []byte(`{"ServiceEnabled": true}`) - - MakeRequest("PATCH", url, body) -} -func ServerCreateACL(address string, netmask string, policy string) { - url := fmt.Sprintf("https://%s/redfish/v1/Managers/1/Oem/Supermicro/IPAccessControl/FilterRules", CurrentHost.BMCAddress) - body := []byte(fmt.Sprintf(`{"Address": "%s", "PrefixLength": %s, "Policy": "%s"}`, address, netmask, policy)) - - MakeRequest("POST", url, body) -} diff --git a/main.go b/main.go index df64cb7..8ffbf1f 100644 --- a/main.go +++ b/main.go @@ -1,29 +1,14 @@ package main import ( - "log" + "os" "github.com/Charlie-Root/smcli/cmd" - "github.com/Charlie-Root/smcli/config" - "github.com/Charlie-Root/smcli/host" ) -func main() { - config.LoadConfig("inventory.yaml") - - rootCmd := cmd.RootCmd - rootCmd.PersistentFlags().BoolVarP(&cmd.Verbose, "verbose", "v", false, "verbose output") - - host.SetVerbose(cmd.Verbose) +// version is set at build time by goreleaser (-X main.version). +var version = "dev" - rootCmd.AddCommand(cmd.PowerCmd) - rootCmd.AddCommand(cmd.MediaCmd) - rootCmd.AddCommand(cmd.BootCmd) - rootCmd.AddCommand(cmd.UserCmd) - rootCmd.AddCommand(cmd.AclCmd) - - - if err := rootCmd.Execute(); err != nil { - log.Fatal(err) - } +func main() { + os.Exit(cmd.Execute(version)) } diff --git a/netbox/netbox.go b/netbox/netbox.go new file mode 100644 index 0000000..b1719b7 --- /dev/null +++ b/netbox/netbox.go @@ -0,0 +1,222 @@ +// Package netbox resolves BMC address and credentials from NetBox: +// device -> oob_ip -> assigned interface -> custom fields ipmi_username / ipmi_password. +package netbox + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "time" + + "github.com/Charlie-Root/smcli/config" +) + +type Client struct { + BaseURL string + Token string + HTTP *http.Client +} + +// NewFromEnv uses NETBOX_URL and NETBOX_TOKEN. +func NewFromEnv(timeout time.Duration) (*Client, error) { + u := strings.TrimRight(os.Getenv("NETBOX_URL"), "/") + t := os.Getenv("NETBOX_TOKEN") + if u == "" || t == "" { + return nil, fmt.Errorf("NETBOX_URL and NETBOX_TOKEN must be set for --source netbox") + } + return New(u, t, timeout), nil +} + +func New(baseURL, token string, timeout time.Duration) *Client { + // No keep-alive: long-lived connections through the NetBox load balancer can stall. + tr := &http.Transport{Proxy: http.ProxyFromEnvironment, DisableKeepAlives: true} + return &Client{BaseURL: strings.TrimRight(baseURL, "/"), Token: token, HTTP: &http.Client{Transport: tr, Timeout: timeout}} +} + +// Filter selects devices either by name or by rack/site/tag/role. +type Filter struct { + Names []string + Rack string + Site string + Tag string + Role string +} + +// Target is one selected device; Err is set when its BMC cannot be resolved. +type Target struct { + Host config.Host + Err error +} + +type device struct { + ID int `json:"id"` + Name string `json:"name"` + OOBIP *struct { + ID int `json:"id"` + } `json:"oob_ip"` +} + +type page struct { + Next string `json:"next"` + Results json.RawMessage `json:"results"` +} + +func (c *Client) get(path string, q url.Values, v interface{}) error { + u := path + if !strings.HasPrefix(path, "http") { + u = c.BaseURL + path + if len(q) > 0 { + u += "?" + q.Encode() + } + } + req, err := http.NewRequest("GET", u, nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "Token "+c.Token) + req.Header.Set("Accept", "application/json") + resp, err := c.HTTP.Do(req) + if err != nil { + return fmt.Errorf("netbox: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 16<<20)) + if err != nil { + return fmt.Errorf("netbox: %w", err) + } + if resp.StatusCode != http.StatusOK { + msg := strings.TrimSpace(string(body)) + if len(msg) > 200 { + msg = msg[:200] + } + return fmt.Errorf("netbox: GET %s: HTTP %d %s", path, resp.StatusCode, msg) + } + return json.Unmarshal(body, v) +} + +func (c *Client) devices(q url.Values) ([]device, error) { + q.Set("limit", "100") + q.Set("fields", "id,name,oob_ip") + var out []device + next := "/api/dcim/devices/" + for next != "" { + var p page + if err := c.get(next, q, &p); err != nil { + return nil, err + } + var ds []device + if err := json.Unmarshal(p.Results, &ds); err != nil { + return nil, err + } + out = append(out, ds...) + next, q = p.Next, nil + } + return out, nil +} + +// Resolve returns one Target per selected device, in NetBox order (or the order of Names). +func (c *Client) Resolve(f Filter) ([]Target, error) { + var devs []device + if len(f.Names) > 0 { + for _, n := range f.Names { + ds, err := c.devices(url.Values{"name": {n}}) + if err != nil { + return nil, err + } + if len(ds) == 0 { + devs = append(devs, device{Name: n, ID: -1}) + continue + } + devs = append(devs, ds[0]) + } + } else { + q := url.Values{} + if f.Site != "" { + q.Set("site", f.Site) + } + if f.Tag != "" { + q.Set("tag", f.Tag) + } + if f.Role != "" { + q.Set("role", f.Role) + } + if f.Rack != "" { + rq := url.Values{"name": {f.Rack}, "fields": {"id"}} + if f.Site != "" { + rq.Set("site", f.Site) + } + var p page + if err := c.get("/api/dcim/racks/", rq, &p); err != nil { + return nil, err + } + var racks []struct { + ID int `json:"id"` + } + if err := json.Unmarshal(p.Results, &racks); err != nil { + return nil, err + } + if len(racks) == 0 { + return nil, fmt.Errorf("netbox: rack %q not found", f.Rack) + } + for _, r := range racks { + q.Add("rack_id", fmt.Sprint(r.ID)) + } + } + if len(q) == 0 { + return nil, fmt.Errorf("netbox: no selection given") + } + var err error + if devs, err = c.devices(q); err != nil { + return nil, err + } + } + + out := make([]Target, 0, len(devs)) + for _, d := range devs { + t := Target{Host: config.Host{Name: d.Name}} + if d.ID < 0 { + t.Err = fmt.Errorf("device not found in NetBox") + } else { + t.Host, t.Err = c.bmc(d) + } + out = append(out, t) + } + return out, nil +} + +func (c *Client) bmc(d device) (config.Host, error) { + h := config.Host{Name: d.Name} + if d.OOBIP == nil { + return h, fmt.Errorf("no OOB IP set in NetBox") + } + var ip struct { + Address string `json:"address"` + AssignedObjectType string `json:"assigned_object_type"` + AssignedObjectID *int `json:"assigned_object_id"` + } + if err := c.get(fmt.Sprintf("/api/ipam/ip-addresses/%d/", d.OOBIP.ID), url.Values{"fields": {"address,assigned_object_type,assigned_object_id"}}, &ip); err != nil { + return h, err + } + h.BMCAddress = strings.SplitN(ip.Address, "/", 2)[0] + if ip.AssignedObjectType != "dcim.interface" || ip.AssignedObjectID == nil { + return h, fmt.Errorf("OOB IP %s is not assigned to an interface", ip.Address) + } + var iface struct { + Name string `json:"name"` + CustomFields map[string]interface{} `json:"custom_fields"` + } + if err := c.get(fmt.Sprintf("/api/dcim/interfaces/%d/", *ip.AssignedObjectID), url.Values{"fields": {"name,custom_fields"}}, &iface); err != nil { + return h, err + } + user, _ := iface.CustomFields["ipmi_username"].(string) + pass, _ := iface.CustomFields["ipmi_password"].(string) + if user == "" || pass == "" { + return h, fmt.Errorf("no IPMI credentials on interface %s", iface.Name) + } + h.UsernamePassword = user + ":" + pass + return h, nil +} diff --git a/netbox/netbox_test.go b/netbox/netbox_test.go new file mode 100644 index 0000000..493602d --- /dev/null +++ b/netbox/netbox_test.go @@ -0,0 +1,82 @@ +package netbox + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func fakeNetBox(t *testing.T) *Client { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("/api/dcim/devices/", func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Token tok" { + w.WriteHeader(http.StatusForbidden) + return + } + q := r.URL.Query() + switch { + case q.Get("name") == "srv1": + w.Write([]byte(`{"next":null,"results":[{"id":1,"name":"srv1","oob_ip":{"id":10}}]}`)) + case q.Get("name") == "nooob": + w.Write([]byte(`{"next":null,"results":[{"id":2,"name":"nooob","oob_ip":null}]}`)) + case q.Get("rack_id") == "5" && q.Get("offset") == "": + w.Write([]byte(`{"next":"http://` + r.Host + `/api/dcim/devices/?rack_id=5&offset=1","results":[{"id":1,"name":"srv1","oob_ip":{"id":10}}]}`)) + case q.Get("rack_id") == "5": + w.Write([]byte(`{"next":null,"results":[{"id":2,"name":"nooob","oob_ip":null}]}`)) + default: + w.Write([]byte(`{"next":null,"results":[]}`)) + } + }) + mux.HandleFunc("/api/dcim/racks/", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("name") == "A.R10" { + w.Write([]byte(`{"next":null,"results":[{"id":5}]}`)) + return + } + w.Write([]byte(`{"next":null,"results":[]}`)) + }) + mux.HandleFunc("/api/ipam/ip-addresses/10/", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"address":"192.0.2.10/24","assigned_object_type":"dcim.interface","assigned_object_id":100}`)) + }) + mux.HandleFunc("/api/dcim/interfaces/100/", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"name":"IPMI","custom_fields":{"ipmi_username":"ADMIN","ipmi_password":"secret"}}`)) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return New(srv.URL, "tok", 5*time.Second) +} + +func TestResolveByName(t *testing.T) { + c := fakeNetBox(t) + got, err := c.Resolve(Filter{Names: []string{"srv1", "nooob", "missing"}}) + if err != nil { + t.Fatal(err) + } + if len(got) != 3 { + t.Fatalf("got %d targets", len(got)) + } + if got[0].Err != nil || got[0].Host.BMCAddress != "192.0.2.10" || got[0].Host.UsernamePassword != "ADMIN:secret" { + t.Fatalf("srv1 = %+v", got[0]) + } + if got[1].Err == nil || got[1].Err.Error() != "no OOB IP set in NetBox" { + t.Fatalf("nooob err = %v", got[1].Err) + } + if got[2].Err == nil || got[2].Host.Name != "missing" { + t.Fatalf("missing = %+v", got[2]) + } +} + +func TestResolveByRackPaginates(t *testing.T) { + c := fakeNetBox(t) + got, err := c.Resolve(Filter{Rack: "A.R10"}) + if err != nil { + t.Fatal(err) + } + if len(got) != 2 || got[0].Host.Name != "srv1" || got[1].Host.Name != "nooob" { + t.Fatalf("got %+v", got) + } + if _, err := c.Resolve(Filter{Rack: "nope"}); err == nil { + t.Fatal("unknown rack accepted") + } +}