diff --git a/CHANGELOG.md b/CHANGELOG.md index a2453a64..7f4ed0c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 0.5.2 + +- Fixed issue with secrets file being closed too early +- Added documentation about using `--insecure` with `scan` +- Improved overall logging messages and consistency + ## 0.5.1 ### Added diff --git a/README.md b/README.md index 10fabcce..e6755fd2 100644 --- a/README.md +++ b/README.md @@ -8,25 +8,26 @@ The `magellan` CLI tool is a Redfish-based, board management controller (BMC) di - [OpenCHAMI Magellan](#openchami-magellan) - - [Main Features](#main-features) - - [Getting Started](#getting-started) - - [Building the Executable](#building-the-executable) - - [Building on Debian 12 (Bookworm)](#building-on-debian-12-bookworm) - - [Docker](#docker) - - [Arch Linux (AUR)](#arch-linux-aur) - - [Usage](#usage) - - [Checking for Redfish](#checking-for-redfish) - - [BMC ID Mapping](#bmc-id-mapping) - - [Running the Tool](#running-the-tool) - - [Managing Secrets](#managing-secrets) - - [Starting the Emulator](#starting-the-emulator) - - [Updating Firmware](#updating-firmware) - - [Managing Power](#managing-power) - - [Getting an Access Token (WIP)](#getting-an-access-token-wip) - - [Running with Docker](#running-with-docker) - - [How It Works](#how-it-works) - - [TODO](#todo) - - [Copyright](#copyright) + - [Main Features](#main-features) + - [Getting Started](#getting-started) + - [Documentation](#documentation) + - [Building the Executable](#building-the-executable) + - [Building on Debian 12 (Bookworm)](#building-on-debian-12-bookworm) + - [Docker](#docker) + - [Arch Linux (AUR)](#arch-linux-aur) + - [Usage](#usage) + - [Checking for Redfish](#checking-for-redfish) + - [BMC ID Mapping](#bmc-id-mapping) + - [Running the Tool](#running-the-tool) + - [PDU Inventory Collection](#pdu-inventory-collection) + - [Starting the Emulator](#starting-the-emulator) + - [Updating Firmware](#updating-firmware) + - [Managing Power](#managing-power) + - [Getting an Access Token (WIP)](#getting-an-access-token-wip) + - [Running with Docker](#running-with-docker) + - [How It Works](#how-it-works) + - [TODO](#todo) + - [Copyright](#copyright) @@ -50,6 +51,20 @@ See the [TODO](#todo) section for a list of soon-ish goals planned. [Build](#building) and [run on bare metal](#running-the-tool) or run and test with Docker using the [latest prebuilt image](#running-with-docker). For quick testing, the repository integrates a Redfish emulator that can be run by executing the `emulator/setup.sh` script or running `make emulator`. +## Documentation + +There is detailed documentation included with the project's repository that can be built using `scdoc` and `go doc`. + +To build the documentation, invoke the following commands. + +```bash +# man page documentation +make man + +# API reference documentation +make docs +``` + ## Building the Executable The `magellan` tool can be built to run on bare metal. Install the required Go tools, clone the repo, and then build the binary in the root directory with the following: @@ -158,7 +173,6 @@ Where the `map_key` is the name of the attribute known to `magellan` that identi xcsb ``` - where `` is a cabinet number in the cluster, `` is a chassis within the cabinet, `` is the shelf within the chassis and `` is the blade within a shelf where the BMC is located. The above mapping file (minus the elipsis) will work with the example described in the [Starting the Emulator](#starting-the-emulator) section. If you are using `magellan` within a system deployed using RIE in the [Quickstart Deployment Recipe](https://github.com/OpenCHAMI/deployment-recipes/blob/main/quickstart/README.md) you can generate a BMC ID Map with XNAMEs that match the RIE configured XNAMEs from the RIE instances running under `docker-compose`. You can do this outside of the docker containers by running this script: @@ -240,6 +254,9 @@ To start a network scan for BMC nodes, use the `scan` command. If the port is no --cache data/assets.db ``` +> [!NOTE] +> Make sure to include the `--insecure` flag if the BMC does not require TLS verification when using HTTPS. + This will scan the `172.16.0.0` subnet returning the host and port that return a response and store the results in a local cache with at the `data/assets.db` path. Additional flags can be set such as `--host` to add more hosts to scan that are not included on the subnet, `--timeout` to set how long to wait for a response from the BMC node, or `--concurrency` to set the number of requests to make concurrently with goroutines. Try using `./magellan help scan` for a complete set of options this subcommand. Alternatively, the same scan can be started using CIDR notation and with additional hosts: ```bash diff --git a/cmd/collect.go b/cmd/collect.go index 5a24a53d..6fd1bbd5 100644 --- a/cmd/collect.go +++ b/cmd/collect.go @@ -117,12 +117,10 @@ var CollectCmd = &cobra.Command{ params := &magellan.CollectParams{ Timeout: timeout, Concurrency: concurrency, - CaCertPath: cacertPath, OutputPath: outputPath, OutputDir: outputDir, + Insecure: insecure, Format: collectOutputFormat, - ForceUpdate: forceUpdate, - AccessToken: accessToken, SecretStore: store, BMCIDMap: idMap, } @@ -153,9 +151,8 @@ func init() { CollectCmd.Flags().StringVar(&protocol, "protocol", "tcp", "Set the protocol used to query") CollectCmd.Flags().StringVarP(&outputPath, "output-file", "o", "", "Set the path to store collection data in a single file") CollectCmd.Flags().StringVarP(&outputDir, "output-dir", "O", "", "Set the path to store collection data using HIVE partitioning") + CollectCmd.Flags().BoolVarP(&insecure, "insecure", "i", false, "Skip TLS certificate verification during probe") CollectCmd.Flags().BoolVar(&showOutput, "show", false, "Show the output of a collect run") - CollectCmd.Flags().BoolVar(&forceUpdate, "force-update", false, "Set flag to force update data sent to SMD") - CollectCmd.Flags().StringVar(&cacertPath, "cacert", "", "Set the path to CA cert file (defaults to system CAs when blank)") CollectCmd.Flags().VarP(&collectOutputFormat, "format", "F", "Set the default output data format (json|yaml; can be overridden by file extensions)") CollectCmd.Flags().StringVarP(&idMap, "bmc-id-map", "m", "", "Set the BMC ID mapping from raw json data or use @ to specify a file path (json or yaml input)") @@ -168,8 +165,8 @@ func init() { checkBindFlagError(viper.BindPFlag("collect.protocol", CollectCmd.Flags().Lookup("protocol"))) checkBindFlagError(viper.BindPFlag("collect.output-file", CollectCmd.Flags().Lookup("output-file"))) checkBindFlagError(viper.BindPFlag("collect.output-dir", CollectCmd.Flags().Lookup("output-dir"))) - checkBindFlagError(viper.BindPFlag("collect.force-update", CollectCmd.Flags().Lookup("force-update"))) - checkBindFlagError(viper.BindPFlag("collect.cacert", CollectCmd.Flags().Lookup("cacert"))) + // checkBindFlagError(viper.BindPFlag("collect.force-update", CollectCmd.Flags().Lookup("force-update"))) + // checkBindFlagError(viper.BindPFlag("collect.cacert", CollectCmd.Flags().Lookup("cacert"))) checkBindFlagError(viper.BindPFlags(CollectCmd.Flags())) rootCmd.AddCommand(CollectCmd) diff --git a/cmd/crawl.go b/cmd/crawl.go index 71382061..01358e01 100644 --- a/cmd/crawl.go +++ b/cmd/crawl.go @@ -3,7 +3,6 @@ package cmd import ( "encoding/json" "fmt" - "os" "github.com/rs/zerolog/log" @@ -57,7 +56,7 @@ var CrawlCmd = &cobra.Command{ log.Debug().Str("uri", uri).Msgf("one or both of --username and --password NOT passed, attempting to obtain missing credentials from secret store at %s", secretsFile) if store, err = secrets.OpenStore(secretsFile); err != nil { log.Error().Str("uri", uri).Err(err).Msg("failed to open local secrets store") - os.Exit(1) + return } // Either none of the flags were passed or only one of them were; get @@ -137,7 +136,7 @@ var CrawlCmd = &cobra.Command{ }, crawlOutputFormat) if err != nil { log.Error().Err(err).Msg("failed to marshal output JSON") - os.Exit(1) + return } if showOutput { fmt.Println(string(output)) diff --git a/cmd/root.go b/cmd/root.go index 1d7ef8ca..67aee6b4 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -65,7 +65,7 @@ var rootCmd = &cobra.Command{ } }, PostRun: func(cmd *cobra.Command, args []string) { - log.Debug().Msg("closing log file") + log.Debug().Str("path", logFile).Msg("closing log file") err := logger.LogFile.Close() if err != nil { log.Error().Err(err).Msg("failed to close log file") @@ -147,12 +147,7 @@ func InitializeConfig() { viper.SetConfigFile(configPath) } if err := viper.ReadInConfig(); err != nil { - if _, ok := err.(viper.ConfigFileNotFoundError); ok { - err = fmt.Errorf("config file not found: %w", err) - } else { - err = fmt.Errorf("failed to load config file: %w", err) - } - log.Warn().Err(err).Msg("failed to load config") + log.Debug().Err(err).Msg("failed to load config") } } diff --git a/cmd/scan.go b/cmd/scan.go index c9f401e3..01efdf95 100644 --- a/cmd/scan.go +++ b/cmd/scan.go @@ -38,7 +38,7 @@ var ScanCmd = &cobra.Command{ Use: "scan urls...", Example: ` // assumes host https://10.0.0.101:443 - magellan scan 10.0.0.101 + magellan scan 10.0.0.101 --insecure // assumes subnet using HTTPS and port 443 except for specified host magellan scan http://10.0.0.101:80 https://user:password@10.0.0.102:443 http://172.16.0.105:8080 --subnet 172.16.0.0/24 @@ -47,10 +47,10 @@ var ScanCmd = &cobra.Command{ magellan scan 10.0.0.101 10.0.0.102 https://172.16.0.10:443 --port 8080 --protocol tcp // assumes subnet using default unspecified subnet-masks - magellan scan --subnet 10.0.0.0 + magellan scan --subnet 10.0.0.0 -i // assumes subnet using HTTPS and port 443 with specified CIDR - magellan scan --subnet 10.0.0.0/16 + magellan scan --subnet 10.0.0.0/16 -i // assumes subnet using HTTP and port 5000 similar to 192.168.0.0/16 magellan scan --subnet 192.168.0.0 --protocol tcp --scheme https --port 5000 --subnet-mask 255.255.0.0 @@ -140,7 +140,6 @@ var ScanCmd = &cobra.Command{ log.Trace().Any("assets", foundAssets).Msgf("found assets from scan") } else { log.Warn().Msg("no responsive assets found") - // return instead of exit to close log file return } diff --git a/cmd/secrets.go b/cmd/secrets.go index 7e742c83..070f0c61 100644 --- a/cmd/secrets.go +++ b/cmd/secrets.go @@ -28,7 +28,7 @@ var secretsCmd = &cobra.Command{ magellan secrets store $bmc_host $bmc_creds // retrieve creds from secrets store - magellan secrets retrieve $bmc_host -f nodes.json + magellan secrets retrieve $bmc_host -f secrets.json // list creds from specific secrets magellan secrets list -f nodes.json`, @@ -43,8 +43,8 @@ var secretsGenerateKeyCmd = &cobra.Command{ Run: func(cmd *cobra.Command, args []string) { key, err := secrets.GenerateMasterKey() if err != nil { - fmt.Printf("Error generating master key: %v\n", err) - os.Exit(1) + log.Error().Err(err).Msg("failed to generate master key") + return } fmt.Printf("%s\n", key) }, @@ -65,8 +65,8 @@ var secretsStoreCmd = &cobra.Command{ // require either the args or input file if len(args) < 1 && secretsStoreInputFile == "" { - log.Error().Msg("no input data or file") - os.Exit(1) + log.Error().Msg("requires input data or file") + return } else if len(args) > 1 && secretsStoreInputFile == "" { // use args[1] here because args[0] is the secretID secretValue = args[1] @@ -80,6 +80,7 @@ var secretsStoreCmd = &cobra.Command{ username string password string ) + // seperate username and password provided values = strings.Split(secretValue, ":") if len(values) != 2 { @@ -104,19 +105,28 @@ var secretsStoreCmd = &cobra.Command{ case "base64": // format: ($encoded_base64_string) decoded, err := base64.StdEncoding.DecodeString(secretValue) if err != nil { - log.Error().Err(err).Msg("error decoding base64 data") + log.Error(). + Err(err). + Str("path", secretsFile). + Msg("failed to decode base64 data") return } // check the decoded string if it's a valid JSON and has creds if !isValidCredsJSON(string(decoded)) { - log.Error().Err(err).Msg("value is not a valid JSON or is missing credentials") + log.Error(). + Err(err). + Str("path", secretsFile). + Msg("invalid JSON value or is missing credentials") return } store, err = secrets.OpenStore(secretsFile) if err != nil { - log.Error().Err(err).Msg("failed to open secrets store") + log.Error(). + Err(err). + Str("path", secretsFile). + Msg("failed to open secrets store") os.Exit(1) } secretValue = string(decoded) @@ -124,12 +134,16 @@ var secretsStoreCmd = &cobra.Command{ // read input from file if set and override if secretsStoreInputFile != "" { if secretValue != "" { - log.Error().Msg("cannot use -i/--input-file with positional argument") + log.Error(). + Str("input-file", secretsStoreInputFile). + Msg("cannot use -i/--input-file with positional argument") return } inputFileBytes, err = os.ReadFile(secretsStoreInputFile) if err != nil { - log.Error().Err(err).Msg("failed to read input file") + log.Error(). + Err(err). + Msg("failed to read input file") return } secretValue = string(inputFileBytes) @@ -137,22 +151,30 @@ var secretsStoreCmd = &cobra.Command{ // make sure we have valid JSON with "username" and "password" properties if !isValidCredsJSON(secretValue) { - log.Error().Err(err).Msg("not a valid JSON or creds") + log.Error(). + Err(err). + Msg("invalid JSON value or creds") os.Exit(1) } store, err = secrets.OpenStore(secretsFile) if err != nil { - fmt.Println(err) - log.Error().Err(err).Msg("failed to open secret store") + log.Error(). + Err(err). + Str("path", secretsFile). + Msg("failed to open secret store") os.Exit(1) } default: - log.Error().Msg("no input format set") + log.Error().Msg("invalid format (see --format flag for options)") os.Exit(1) } if err := store.StoreSecretByID(secretID, secretValue); err != nil { - log.Error().Err(err).Msg("failed to store secret by ID") + log.Error(). + Err(err). + Str("id", secretID). + Str("path", secretsFile). + Msg("failed to store secret by ID") os.Exit(1) } }, @@ -224,7 +246,7 @@ var secretsListCmd = &cobra.Command{ } var secretsRemoveCmd = &cobra.Command{ - Use: "remove secretIDs...", + Use: "remove secret_ids...", Args: cobra.MinimumNArgs(1), Short: "Remove secrets by IDs from secret store.", Run: func(cmd *cobra.Command, args []string) { @@ -232,21 +254,32 @@ var secretsRemoveCmd = &cobra.Command{ // open secret store from file store, err := secrets.OpenStore(secretsFile) if err != nil { - fmt.Println(err) - os.Exit(1) + log.Error(). + Err(err). + Str("path", secretsFile). + Msg("failed to open secret store") + return } // remove secret from store by it's ID err = store.RemoveSecretByID(secretID) if err != nil { - fmt.Println("failed to remove secret: ", err) - os.Exit(1) + log.Error(). + Err(err). + Str("id", secretID). + Str("path", secretsFile). + Msg("failed to remove secret") + return } // update store by saving to original file err = secrets.SaveSecrets(secretsFile, store.(*secrets.LocalSecretStore).Secrets) if err != nil { - log.Error().Err(err).Str("path", secretsFile).Msg("failed to save secrets to file") + log.Error(). + Err(err). + Str("path", secretsFile). + Msg("failed to save secrets to file") + return } } }, diff --git a/cmd/send.go b/cmd/send.go index 8e161606..03452104 100644 --- a/cmd/send.go +++ b/cmd/send.go @@ -71,7 +71,8 @@ var sendCmd = &cobra.Command{ } // show the data that was just loaded as input - log.Debug().Any("input", inputData).Send() + // inputRaw, _ := json.MarshalIndent(inputData, "", " ") + log.Debug().Int("endpoint_count", len(inputData)).Send() for _, host := range args { var ( @@ -83,6 +84,7 @@ var sendCmd = &cobra.Command{ for _, dataObject := range inputData { // skip on to the next thing if it's does not exist if dataObject == nil { + log.Warn().Str("host", host).Msg("skipping request to host") continue } @@ -93,16 +95,23 @@ var sendCmd = &cobra.Command{ host, err = urlx.Sanitize(host) if err != nil { - log.Warn().Err(err).Str("host", host).Msg("could not sanitize host") + log.Warn(). + Err(err). + Str("host", host). + Msg("could not sanitize host") } // convert to JSON to send data body, err = json.MarshalIndent(dataObject, "", " ") if err != nil { - log.Error().Err(err).Msg("failed to marshal request data") + log.Error(). + Err(err). + Msg("failed to marshal request data") continue } + log.Debug().Str("host", host).RawJSON("data", body).Send() + // make request to remote host err = smdClient.Add(body, headers) if err != nil { // try updating instead @@ -110,10 +119,18 @@ var sendCmd = &cobra.Command{ smdClient.Xname = dataObject["ID"].(string) err = smdClient.Update(body, headers) if err != nil { - log.Error().Err(err).Msgf("failed to forcibly update Redfish endpoint with ID %s", smdClient.Xname) + log.Error(). + Err(err). + Str("host", host). + Str("ID", smdClient.Xname). + Msgf("failed to forcibly update Redfish endpoint") } } else { - log.Error().Err(err).Msgf("failed to add Redfish endpoint with ID %s", smdClient.Xname) + log.Error(). + Err(err). + Str("host", host). + Str("ID", smdClient.Xname). + Msgf("failed to add Redfish endpoint") } } } diff --git a/go.mod b/go.mod index ecc303b5..aaeb9977 100644 --- a/go.mod +++ b/go.mod @@ -18,9 +18,9 @@ require ( ) require ( - dario.cat/mergo v1.0.2 github.com/Cray-HPE/hms-xname v1.4.0 github.com/rs/zerolog v1.33.0 + github.com/stretchr/testify v1.9.0 golang.org/x/crypto v0.32.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -32,6 +32,7 @@ require ( ) require ( + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/goccy/go-json v0.10.2 // indirect @@ -46,6 +47,7 @@ require ( github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect diff --git a/go.sum b/go.sum index 51b6722d..edef927f 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,3 @@ -dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= -dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/Cray-HPE/hms-xname v1.4.0 h1:i47YmE8rbSfJ64simKCCC6ZVcGid3rDIX6/jfVbISAM= diff --git a/internal/util/path.go b/internal/util/path.go index 2541cc60..de639540 100644 --- a/internal/util/path.go +++ b/internal/util/path.go @@ -4,6 +4,8 @@ import ( "fmt" "io/fs" "os" + "path/filepath" + "strings" "time" ) @@ -17,6 +19,21 @@ func PathExists(path string) (fs.FileInfo, bool) { return fi, !os.IsNotExist(err) } +// SplitPathForViper() is an utility function to split a path into 3 parts: +// - directory +// - filename +// - extension +// The intent was to break a path into a format that's more easily consumable +// by spf13/viper's API. See the "LoadConfig()" function in internal/config.go +// for more details. +// +// TODO: Rename function to something more generalized. +func SplitPathForViper(path string) (string, string, string) { + filename := filepath.Base(path) + ext := filepath.Ext(filename) + return filepath.Dir(path), strings.TrimSuffix(filename, ext), strings.TrimPrefix(ext, ".") +} + // MakeOutputDirectory() creates a new directory at the path argument if // the path does not exist. // diff --git a/man/magellan-scan.1.sc b/man/magellan-scan.1.sc index e9c0c033..9b8e8118 100644 --- a/man/magellan-scan.1.sc +++ b/man/magellan-scan.1.sc @@ -11,7 +11,7 @@ magellan scan [OPTIONS] _host_... # EXAMPLES // assumes host https://10.0.0.101:443++ -magellan scan 10.0.0.101 +magellan scan 10.0.0.101 --insecure // assumes subnet using HTTPS and port 443 except for specified host++ magellan scan http://10.0.0.101:80 https://$user:$password@10.0.0.102:443 http://172.16.0.105:8080 --subnet 172.16.0.0/24 @@ -20,10 +20,10 @@ magellan scan http://10.0.0.101:80 https://$user:$password@10.0.0.102:443 http:/ magellan scan 10.0.0.101 10.0.0.102 https://172.16.0.10:443 --port 8080 --protocol tcp // assumes subnet using default unspecified subnet-masks++ -magellan scan --subnet 10.0.0.0 +magellan scan --subnet 10.0.0.0 -i // assumes subnet using HTTPS and port 443 with specified CIDR++ -magellan scan --subnet 10.0.0.0/16 +magellan scan --subnet 10.0.0.0/16 -i // assumes subnet using HTTP and port 5000 similar to 192.168.0.0/16++ magellan scan --subnet 192.168.0.0 --protocol tcp --scheme https --port 5000 --subnet-mask 255.255.0.0 @@ -83,7 +83,6 @@ magellan scan --subnet 10.0.0.0 --subnet 172.16.0.0 --subnet-mask 255.255.0.0 -- It is recommended that the *--insecure* flag be set to *true* in when the BMC does not require TLS verification for HTTPS requests. - *-o, --output* _path_ Output file path (for json/yaml formats) diff --git a/pkg/client/smd.go b/pkg/client/smd.go index 0f2fbb23..b6653ec2 100644 --- a/pkg/client/smd.go +++ b/pkg/client/smd.go @@ -59,7 +59,12 @@ func (c *SmdClient) Add(data HTTPBody, headers HTTPHeader) error { return fmt.Errorf("returned status code %d when adding endpoint", res.StatusCode) } } - log.Debug().Msgf("%v (%v)\n%s\n", url, res.Status, string(body)) + log.Debug(). + Str("url", url). + Str("status", res.Status). + Int("status_code", res.StatusCode). + RawJSON("body", body). + Send() } return err } diff --git a/pkg/collect.go b/pkg/collect.go index e932375b..3ccb8a5d 100644 --- a/pkg/collect.go +++ b/pkg/collect.go @@ -14,7 +14,6 @@ import ( "github.com/OpenCHAMI/magellan/internal/format" "github.com/OpenCHAMI/magellan/internal/util" "github.com/OpenCHAMI/magellan/pkg/bmc" - "github.com/OpenCHAMI/magellan/pkg/client" "github.com/OpenCHAMI/magellan/pkg/crawler" "github.com/OpenCHAMI/magellan/pkg/idmap" "github.com/OpenCHAMI/magellan/pkg/secrets" @@ -32,12 +31,10 @@ import ( type CollectParams struct { Concurrency int // set the of concurrent jobs with the 'concurrency' flag Timeout int // set the timeout with the 'timeout' flag - CaCertPath string // set the cert path with the 'cacert' flag + Insecure bool // set whether to ignore TLS verification OutputPath string // set the path to save output with 'output' flag OutputDir string // set the directory path to save output with `output-dir` flag Format format.DataFormat // set the output format - ForceUpdate bool // set whether to force updating SMD with 'force-update' flag - AccessToken string // set the access token to include in request with 'access-token' flag BMCIDMap string // Set the path to the BMC ID mapping YAML or JSON data or file name (if any) SecretStore secrets.SecretStore // set BMC credentials } @@ -103,7 +100,7 @@ func CollectInventory(assets *[]RemoteAsset, params *CollectParams) ([]map[strin config = crawler.CrawlerConfig{ URI: uri, CredentialStore: params.SecretStore, - Insecure: true, + Insecure: params.Insecure, UseDefault: true, } ) @@ -159,11 +156,6 @@ func CollectInventory(assets *[]RemoteAsset, params *CollectParams) ([]map[strin data["MACAddr"] = mac } - // create and set headers for request - headers := client.HTTPHeader{} - headers.Authorization(params.AccessToken) - headers.ContentType("application/json") - // add data output to collections collection = append(collection, data) diff --git a/pkg/collect_test.go b/pkg/collect_test.go new file mode 100644 index 00000000..73b51f31 --- /dev/null +++ b/pkg/collect_test.go @@ -0,0 +1,108 @@ +package magellan + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/OpenCHAMI/magellan/internal/format" + "github.com/stretchr/testify/assert" +) + +type CollectTestClient struct { + assets *[]RemoteAsset + params *CollectParams +} + +func NewCollectTestClient() *CollectTestClient { + return &CollectTestClient{} +} + +func (c *CollectTestClient) PerformCollect() ([]map[string]any, error) { + return CollectInventory(c.assets, c.params) +} + +func TestCollect(t *testing.T) { + t.Parallel() + cases := []struct { + name string + assets []RemoteAsset + params *CollectParams + want int + }{ + { + name: "basic", + assets: []RemoteAsset{ + { + Host: "", + Port: 443, + State: true, + Protocol: "tcp", + ServiceType: BMC, + }, + }, + params: &CollectParams{ + Concurrency: 1, + Timeout: timeout, + Insecure: true, + Format: format.FORMAT_JSON, + SecretStore: nil, + }, + want: 0, + }, + { + name: "", + assets: []RemoteAsset{ + { + Host: "", + Port: 443, + State: true, + Protocol: "tcp", + ServiceType: BMC, + }, + }, + params: &CollectParams{}, + }, + { + name: "", + assets: []RemoteAsset{ + {}, + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + + // create mock Redfish servers + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "" { + + } + + w.WriteHeader(http.StatusOK) + w.Write([]byte("")) + })) + defer mockServer.Close() + + var ( + collection []map[string]any + err error + ) + + c := NewCollectTestClient() + c.params = tc.params + + // add mock Redfish services + // for _, mockServer := range servers { + // c.assets = append(c.assets) + // } + + collection, err = c.PerformCollect() + + assert.Error(t, nil, err) + assert.Len(t, collection, tc.want) + + }) + } +} diff --git a/pkg/crawl_test.go b/pkg/crawl_test.go new file mode 100644 index 00000000..1cbca9b8 --- /dev/null +++ b/pkg/crawl_test.go @@ -0,0 +1,67 @@ +package magellan + +import ( + "net/http/httptest" + "testing" + + "github.com/OpenCHAMI/magellan/pkg/crawler" + "github.com/OpenCHAMI/magellan/pkg/secrets" + "github.com/OpenCHAMI/magellan/pkg/test" + "github.com/go-chi/chi/v5" +) + +type CrawlTestClient struct { + config *crawler.CrawlerConfig +} + +func NewCrawlTestClient() *CrawlTestClient { + store := secrets.NewStaticStore("test", "test") + return &CrawlTestClient{ + config: &crawler.CrawlerConfig{ + URI: "http://localhost:5000", + Insecure: true, + CredentialStore: store, + }, + } +} + +func TestCrawl(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + config *crawler.CrawlerConfig + }{ + { + name: "basic", + config: &crawler.CrawlerConfig{ + UseDefault: true, + }, + }, + { + name: "no_default", + config: &crawler.CrawlerConfig{ + UseDefault: false, + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + mux := chi.NewMux() + mux.HandleFunc("/redfish/v1", test.Make(test.RESPONSE_ServiceRoot)) + mux.HandleFunc("/redfish/v1/Systems", test.Make(test.RESPONSE_Systems)) + mux.HandleFunc("/redfish/v1/Node0/EthernetInterfaces", test.Make(test.RESPONSE_EthernetInterface)) + + // create a mock server to simulator a Redfish service + mockServer := httptest.NewServer(mux) + defer mockServer.Close() + + c := NewCrawlTestClient() + c.config = tc.config + + crawler.CrawlBMCForSystems(*c.config) + crawler.CrawlBMCForManagers(*c.config) + }) + } +} diff --git a/pkg/crawler/main.go b/pkg/crawler/main.go index 681af718..28d519ef 100644 --- a/pkg/crawler/main.go +++ b/pkg/crawler/main.go @@ -4,7 +4,6 @@ import ( "fmt" "strings" - "dario.cat/mergo" "github.com/OpenCHAMI/magellan/internal/util" "github.com/OpenCHAMI/magellan/pkg/bmc" "github.com/OpenCHAMI/magellan/pkg/secrets" @@ -50,14 +49,16 @@ type NetworkInterface struct { } type Manager struct { - URI string `json:"uri,omitempty"` - UUID string `json:"uuid,omitempty"` - Name string `json:"name,omitempty"` - Description string `json:"description,omitempty"` - Model string `json:"model,omitempty"` - Type string `json:"type,omitempty"` - FirmwareVersion string `json:"firmware_version,omitempty"` - EthernetInterfaces []EthernetInterface `json:"ethernet_interfaces,omitempty"` + URI string `json:"uri,omitempty"` + UUID string `json:"uuid,omitempty"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Model string `json:"model,omitempty"` + Type string `json:"type,omitempty"` + FirmwareVersion string `json:"firmware_version,omitempty"` + EthernetInterfaces []EthernetInterface `json:"ethernet_interfaces,omitempty"` + SerialConsoleSupported []string `json:"serial_console"` + CommandShellSupported []string `json:"command_shell"` } type Links struct { @@ -72,14 +73,26 @@ type Power struct { PowerControlIDs []string `json:"power_control_ids,omitempty"` } +type SerialConsoleConfig struct { + Port int `json:"port,omitempty"` + Enabled bool `json:"enabled,omitempty"` +} + +type SerialConsole struct { + IPMI SerialConsoleConfig `json:"impi,omitempty"` + Telnet SerialConsoleConfig `json:"telnet,omitempty"` + SSH SerialConsoleConfig `json:"ssh,omitempty"` +} + type InventoryDetail struct { URI string `json:"uri,omitempty"` // URI of the BMC UUID string `json:"uuid,omitempty"` // UUID of Node Manufacturer string `json:"manufacturer,omitempty"` // Manufacturer of the Node SystemType string `json:"system_type,omitempty"` // System type of the Node Name string `json:"name,omitempty"` // Name of the Node - Model string `json:"model,omitempty"` // Model of the Node - Serial string `json:"serial,omitempty"` // Serial number of the Node + ModelNumber string `json:"model,omitempty"` // Model of the Node + SerialNumber string `json:"serial,omitempty"` // Serial number of the Node + SerialConsole SerialConsole `json:"serial_console,omitempty"` // Supported serial console types of the Node BiosVersion string `json:"bios_version,omitempty"` // Version of the BIOS EthernetInterfaces []EthernetInterface `json:"ethernet_interfaces,omitempty"` // Ethernet interfaces of the Node NetworkInterfaces []NetworkInterface `json:"network_interfaces,omitempty"` // Network interfaces of the Node @@ -147,7 +160,8 @@ func GetBMCClient(config CrawlerConfig) (*gofish.APIClient, error) { return client, nil } -// CrawlBMCForSystems pulls all pertinent information from a BMC. It accepts a CrawlerConfig and returns a list of InventoryDetail structs. +// CrawlBMCForSystems pulls all pertinent information from a BMC. +// It accepts a CrawlerConfig and returns a list of InventoryDetail structs. func CrawlBMCForSystems(config CrawlerConfig) ([]InventoryDetail, error) { var ( systems = make(map[string]*InventoryDetail) @@ -200,15 +214,12 @@ func CrawlBMCForSystems(config CrawlerConfig) ([]InventoryDetail, error) { rf_systems = append(rf_systems, rf_root_systems...) newSystems, err := walkSystems(rf_systems, nil, config.URI) if err != nil { - return extract_ptr_map_values(systems), fmt.Errorf("failed to get systems: %v", err) + return extractPtrMapValues(systems), fmt.Errorf("failed to get systems: %v", err) } // If nodes are found under both Chassis and Systems, Systems is assumed to be "more definitive" // and will override corresponding fields from the Chassis version. - err = mergo.Merge(&systems, newSystems, mergo.WithOverride) - if err != nil { - return extract_ptr_map_values(systems), fmt.Errorf("failed to merge systems from Chassis and Systems endpoints: %v", err) - } - return extract_ptr_map_values(systems), nil + systems = merge(systems, newSystems) + return extractPtrMapValues(systems), nil } // CrawlBMCForManagers connects to a BMC (Baseboard Management Controller) using the provided configuration, @@ -237,11 +248,14 @@ func CrawlBMCForManagers(config CrawlerConfig) ([]Manager, error) { // Obtain the ServiceRoot rf_service := client.GetService() - log.Debug().Msgf("found ServiceRoot %s. Redfish Version %s", rf_service.ID, rf_service.RedfishVersion) + log.Debug(). + Msgf("found ServiceRoot %s. Redfish Version %s", rf_service.ID, rf_service.RedfishVersion) rf_managers, err := rf_service.Managers() if err != nil { - log.Error().Err(err).Msg("failed to get managers from ServiceRoot") + log.Error(). + Err(err). + Msg("failed to get managers from ServiceRoot") } return walkManagers(rf_managers, config.URI) } @@ -280,7 +294,9 @@ func walkSystems(rf_systems []*redfish.ComputerSystem, rf_chassis *redfish.Chass // get all of the links to managers rf_managers, err := rf_computersystem.ManagedBy() if err != nil { - log.Warn().Err(err).Msg("failed to get system managers") + log.Warn(). + Err(err). + Msg("failed to get system managers") log.Error(). Err(err). Str("id", rf_computersystem.ID). @@ -323,9 +339,23 @@ func walkSystems(rf_systems []*redfish.ComputerSystem, rf_chassis *redfish.Chass Name: rf_computersystem.Name, Manufacturer: rf_computersystem.Manufacturer, SystemType: string(rf_computersystem.SystemType), - Model: rf_computersystem.Model, - Serial: rf_computersystem.SerialNumber, - BiosVersion: rf_computersystem.BIOSVersion, + ModelNumber: rf_computersystem.Model, + SerialNumber: rf_computersystem.SerialNumber, + SerialConsole: SerialConsole{ + IPMI: SerialConsoleConfig{ + Port: rf_computersystem.SerialConsole.IPMI.Port, + Enabled: rf_computersystem.SerialConsole.IPMI.ServiceEnabled, + }, + SSH: SerialConsoleConfig{ + Port: rf_computersystem.SerialConsole.SSH.Port, + Enabled: rf_computersystem.SerialConsole.SSH.ServiceEnabled, + }, + Telnet: SerialConsoleConfig{ + Port: rf_computersystem.SerialConsole.Telnet.Port, + Enabled: rf_computersystem.SerialConsole.Telnet.ServiceEnabled, + }, + }, + BiosVersion: rf_computersystem.BIOSVersion, Links: Links{ Managers: managerLinks, Chassis: chassisLinks, @@ -350,6 +380,7 @@ func walkSystems(rf_systems []*redfish.ComputerSystem, rf_chassis *redfish.Chass system.Chassis_Model = rf_chassis.Model } + // add ethernet interfaces rf_ethernetinterfaces, err := rf_computersystem.EthernetInterfaces() if err != nil { log.Error().Err(err).Msg("failed to get ethernet interfaces from computer system") @@ -375,6 +406,7 @@ func walkSystems(rf_systems []*redfish.ComputerSystem, rf_chassis *redfish.Chass return systems, err } + // add network interfaces for _, rf_networkInterface := range rf_networkInterfaces { rf_networkAdapter, err := rf_networkInterface.NetworkAdapter() if err != nil { @@ -451,15 +483,26 @@ func walkManagers(rf_managers []*redfish.Manager, baseURI string) ([]Manager, er IP: rf_ethernetinterface.IPv4Addresses[0].Address, }) } + + var supported_serial_console []string + for _, console_type := range rf_manager.SerialConsole.ConnectTypesSupported { + supported_serial_console = append(supported_serial_console, string(console_type)) + } + var supported_command_shell []string + for _, shell_type := range rf_manager.CommandShell.ConnectTypesSupported { + supported_command_shell = append(supported_command_shell, string(shell_type)) + } managers = append(managers, Manager{ - URI: baseURI + "/redfish/v1/Managers/" + rf_manager.ID, - UUID: rf_manager.UUID, - Name: rf_manager.Name, - Description: rf_manager.Description, - Model: rf_manager.Model, - Type: string(rf_manager.ManagerType), - FirmwareVersion: rf_manager.FirmwareVersion, - EthernetInterfaces: ethernet_interfaces, + URI: baseURI + "/redfish/v1/Managers/" + rf_manager.ID, + UUID: rf_manager.UUID, + Name: rf_manager.Name, + Description: rf_manager.Description, + Model: rf_manager.Model, + Type: string(rf_manager.ManagerType), + FirmwareVersion: rf_manager.FirmwareVersion, + EthernetInterfaces: ethernet_interfaces, + SerialConsoleSupported: supported_serial_console, + CommandShellSupported: supported_command_shell, }) } return managers, nil @@ -514,10 +557,18 @@ func loadBMCCreds(config CrawlerConfig) (bmc.BMCCredentials, error) { } } -func extract_ptr_map_values[T any](m map[string]*T) []T { +func extractPtrMapValues[T any](m map[string]*T) []T { slice := make([]T, 0, len(m)) for i := range m { slice = append(slice, *m[i]) } return slice } + +func merge(systems map[string]*InventoryDetail, newSystems []InventoryDetail) map[string]*InventoryDetail { + // add and replace values in systems with values from newSystems + for _, system := range newSystems { + systems[system.URI] = &system + } + return systems +} diff --git a/pkg/scan.go b/pkg/scan.go index 603cb0a5..1501ec61 100644 --- a/pkg/scan.go +++ b/pkg/scan.go @@ -22,7 +22,18 @@ type RemoteAsset struct { Protocol string `json:"protocol"` State bool `json:"state"` Timestamp time.Time `json:"timestamp"` - ServiceType string `json:"service_type,omitempty"` + ServiceType Scanner `json:"service_type,omitempty"` +} + +type Scanner string + +const ( + BMC Scanner = "bmcs" + PDU Scanner = "pdus" +) + +func (st Scanner) String() string { + return string(st) } func (ra *RemoteAsset) String() string { @@ -42,8 +53,6 @@ type ScanParams struct { Concurrency int Timeout int DisableProbing bool - Verbose bool - Debug bool Insecure bool Include []string } @@ -65,11 +74,15 @@ type ScanParams struct { // Returns a list of scanned results to be stored in cache (but isn't doing here). func ScanForAssets(params *ScanParams) []RemoteAsset { var ( - results = make([]RemoteAsset, 0, len(params.TargetHosts)) + results []RemoteAsset done = make(chan struct{}, params.Concurrency+1) chanHosts = make(chan []string, params.Concurrency+1) ) + if len(params.TargetHosts) == 0 { + return []RemoteAsset{} + } + log.Trace().Any("hosts", params.TargetHosts).Msg("starting scan...") probesToRun := []struct { @@ -106,9 +119,7 @@ func ScanForAssets(params *ScanParams) []RemoteAsset { foundAssets, err := rawConnect(host, params.Protocol, params.Timeout, true) // if we failed to connect, exit from the function if err != nil { - if params.Verbose { - log.Debug().Err(err).Msgf("failed to connect to host") - } + log.Trace().Err(err).Msgf("failed to connect to host") continue } if !params.DisableProbing { @@ -116,29 +127,50 @@ func ScanForAssets(params *ScanParams) []RemoteAsset { for _, foundAsset := range foundAssets { for _, probe := range probesToRun { probeURL := fmt.Sprintf("%s:%d%s", foundAsset.Host, foundAsset.Port, probe.Path) - req, err := http.NewRequest("GET", probeURL, nil) + req, err := http.NewRequest(http.MethodGet, probeURL, nil) if err != nil { + log.Warn(). + Err(err). + Str("uri", probeURL). + Msg("could not make probing request") continue } res, err := probeClient.Do(req) if err == nil && res != nil && res.StatusCode == http.StatusOK { if err := res.Body.Close(); err != nil { - log.Warn().Err(err).Msg("could not close response resource") + log.Warn(). + Err(err). + Str("url", probeURL). + Msg("could not close response resource") } - foundAsset.ServiceType = probe.Type + foundAsset.ServiceType = Scanner(probe.Type) assetsToAdd = append(assetsToAdd, foundAsset) + log.Debug(). + Str("host", foundAsset.Host). + Msg("adding found asset to results after probing") + break // Found a valid service, no need to probe other types + } else if err != nil { + log.Error(). + Err(err). + Str("url", probeURL). + Msg("failed to perform request") } if res != nil { if err := res.Body.Close(); err != nil { - log.Warn().Err(err).Msg("could not close response resource") + log.Warn(). + Err(err). + Msg("could not close response resource") } } } } results = append(results, assetsToAdd...) } else { + log.Debug(). + Int("count", len(foundAssets)). + Msg("adding found assets to results without probing") results = append(results, foundAssets...) } } @@ -162,7 +194,7 @@ func ScanForAssets(params *ScanParams) []RemoteAsset { wg.Wait() close(done) - log.Trace().Msg("scan complete") + log.Debug().Int("asset_count", len(results)).Msg("scan complete") return results } diff --git a/pkg/scan_test.go b/pkg/scan_test.go new file mode 100644 index 00000000..260eb44d --- /dev/null +++ b/pkg/scan_test.go @@ -0,0 +1,233 @@ +package magellan + +import ( + "fmt" + "math" + "net" + "net/http" + "net/http/httptest" + "strconv" + "testing" + + "github.com/OpenCHAMI/magellan/pkg/test" + "github.com/stretchr/testify/assert" +) + +const ( + scheme = "https" + protocol = "tcp" + timeout = 10 +) + +type ScanTestClient struct { + params *ScanParams +} + +func NewScanTestClient() *ScanTestClient { + return &ScanTestClient{} +} + +func ServiceRoot(baseURI string) string { + return fmt.Sprintf("%s/redfish/v1", baseURI) +} + +func (c *ScanTestClient) PerformScan() []RemoteAsset { + return ScanForAssets(c.params) +} + +func TestScan(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + params *ScanParams + wantFound int + }{ + { + name: "no_hosts_found", + params: &ScanParams{ + Scheme: scheme, + Protocol: protocol, + Concurrency: 1, + Timeout: timeout, + DisableProbing: false, + Insecure: true, + Include: []string{ + "bmcs", + }, + }, + wantFound: 0, + }, + { + name: "single_host", + params: &ScanParams{ + Scheme: scheme, + Protocol: protocol, + Concurrency: 1, + Timeout: timeout, + DisableProbing: false, + Insecure: true, + Include: []string{ + "bmcs", + }, + }, + wantFound: 1, + }, + { + name: "multiple_hosts", + params: &ScanParams{ + Scheme: scheme, + Protocol: protocol, + Concurrency: 1, + Timeout: timeout, + DisableProbing: false, + Insecure: true, + Include: []string{ + "bmcs", + }, + }, + }, + { + name: "subnet_scan", + params: &ScanParams{ + Scheme: scheme, + Protocol: protocol, + Concurrency: 1, + Timeout: timeout, + DisableProbing: false, + Insecure: true, + Include: []string{ + "bmcs", + }, + }, + wantFound: 5, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + + // Create a mock server + var servers []*httptest.Server + for range tc.wantFound { + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/redfish/v1/" { + t.Fatalf("Expected to request '%s', got: %s", ServiceRoot("/"), r.URL.Path) + } + if r.Method != http.MethodGet { + t.Fatalf("Expected GET request, got: %s", r.Method) + } + w.WriteHeader(http.StatusOK) + w.Write([]byte(test.RESPONSE_ServiceRoot)) + })) + defer mockServer.Close() // Close the server when the test finishes + servers = append(servers, mockServer) + } + + c := NewScanTestClient() + c.params = tc.params + + // add target hosts from mock servers created + for _, mockServer := range servers { + c.params.TargetHosts = append(c.params.TargetHosts, []string{mockServer.URL}) + } + + // set the number of expected responses + tc.wantFound = len(servers) + + found := c.PerformScan() + + assert.Len(t, found, tc.wantFound) + }) + } +} + +func TestGenerateHostsFromSubnet(t *testing.T) { + t.Parallel() + + var ( + defaultSubnetMask = net.IPMask{255, 255, 255, 0} + defaultPorts = []int{443} + ) + + type TestCase struct { + name string + subnet string + subnetMask *net.IPMask + ports []int + scheme string + wantTotalHosts int + } + + var getExpectedServiceCount = func(tc TestCase) int { + v, err := strconv.ParseInt(tc.subnetMask.String(), 16, 0) + if err != nil { + return -1 + } + return ((int(math.Pow(2, 32)) - int(v)) * len(tc.ports)) - 1 + } + + cases := []TestCase{ + { + name: "basic", + subnet: "172.21.0.0", + subnetMask: &defaultSubnetMask, + ports: defaultPorts, + scheme: scheme, + }, + { + name: "none", + subnet: "10.0.0.0", + subnetMask: &defaultSubnetMask, + ports: defaultPorts, + scheme: scheme, + }, + { + name: "invalid subnet", + subnet: "invalid", + subnetMask: &defaultSubnetMask, + ports: defaultPorts, + scheme: scheme, + }, + { + name: "no subnet", + subnet: "", + subnetMask: &defaultSubnetMask, + ports: defaultPorts, + scheme: scheme, + }, + { + name: "cidr", + subnet: "172.21.0.0/24", + subnetMask: &defaultSubnetMask, + ports: defaultPorts, + scheme: scheme, + }, + { + name: "additional ports", + subnet: "172.21.0.0/24", + subnetMask: &defaultSubnetMask, + ports: []int{443, 5000}, + scheme: scheme, + }, + { + name: "different scheme", + subnet: "172.21.0.0/24", + subnetMask: &defaultSubnetMask, + ports: defaultPorts, + scheme: "http", + }, + } + + for _, tc := range cases { + tc.wantTotalHosts = getExpectedServiceCount(tc) + hosts := GenerateHostsWithSubnet( + tc.subnet, + tc.subnetMask, + tc.ports, + tc.scheme, + ) + + assert.Len(t, hosts, tc.wantTotalHosts) + } +} diff --git a/pkg/secrets/localstore.go b/pkg/secrets/localstore.go index b021f671..619bb652 100644 --- a/pkg/secrets/localstore.go +++ b/pkg/secrets/localstore.go @@ -24,7 +24,7 @@ func NewLocalSecretStore(masterKeyHex, filename string, create bool) (*LocalSecr masterKey, err := hex.DecodeString(masterKeyHex) if err != nil { - return nil, fmt.Errorf("unable to generate masterkey from hex representation: %v", err) + return nil, fmt.Errorf("failed to generate masterkey from hex representation: %v", err) } if _, err := os.Stat(filename); os.IsNotExist(err) { @@ -33,11 +33,13 @@ func NewLocalSecretStore(masterKeyHex, filename string, create bool) (*LocalSecr } file, err := os.Create(filename) if err != nil { - return nil, fmt.Errorf("unable to create file %s: %v", filename, err) - } - if err = file.Close(); err != nil { - log.Warn().Err(err).Msg("could not close file") + return nil, fmt.Errorf("failed to create file %s: %v", filename, err) } + defer func() { + if err = file.Close(); err != nil { + log.Warn().Err(err).Msg("could not close file") + } + }() secrets = make(map[string]string) } @@ -45,7 +47,7 @@ func NewLocalSecretStore(masterKeyHex, filename string, create bool) (*LocalSecr if secrets == nil { secrets, err = loadSecrets(filename) if err != nil { - return nil, fmt.Errorf("unable to load secrets from file: %v", err) + return nil, fmt.Errorf("failed to load secrets from file: %v", err) } } @@ -144,12 +146,16 @@ func SaveSecrets(jsonFile string, store map[string]string) error { if err != nil { return err } - + defer func() { + if err := file.Close(); err != nil { + log.Warn(). + Err(err). + Str("path", jsonFile). + Msg("could not close file") + } + }() encoder := json.NewEncoder(file) encoder.SetIndent("", " ") - if err := file.Close(); err != nil { - log.Warn().Err(err).Msg("could not close file") - } return encoder.Encode(store) } @@ -157,14 +163,22 @@ func SaveSecrets(jsonFile string, store map[string]string) error { func loadSecrets(jsonFile string) (map[string]string, error) { file, err := os.Open(jsonFile) if err != nil { - return nil, fmt.Errorf("unable to open secret file %s:%v", jsonFile, err) + return nil, fmt.Errorf("failed to open secret file %s:%v", jsonFile, err) } + defer func() { + if err := file.Close(); err != nil { + log.Warn(). + Err(err). + Str("path", jsonFile). + Msg("could not close file") + } + }() store := make(map[string]string) decoder := json.NewDecoder(file) - if err = file.Close(); err != nil { - log.Warn().Err(err).Msg("could not close file") - } err = decoder.Decode(&store) + if err != nil { + return nil, fmt.Errorf("failed to decode file: %v", err) + } return store, err } diff --git a/pkg/test/constants.go b/pkg/test/constants.go new file mode 100644 index 00000000..7c4ad4c9 --- /dev/null +++ b/pkg/test/constants.go @@ -0,0 +1,130 @@ +package test + +const ( + RESPONSE_ServiceRoot = `{ + "@odata.etag": "W/\"1646860561\"", + "@odata.id": "/redfish/v1/", + "@odata.type": "#ServiceRoot.v1_2_0.ServiceRoot", + "AccountService": { + "@odata.id": "/redfish/v1/AccountService" + }, + "CertificateService": { + "@odata.id": "/redfish/v1/CertificateService" + }, + "Chassis": { + "@odata.id": "/redfish/v1/Chassis" + }, + "Description": "The service root for all Redfish requests on this host", + "EventService": { + "@odata.id": "/redfish/v1/EventService" + }, + "Id": "RootService", + "JsonSchemas": { + "@odata.id": "/redfish/v1/JsonSchemas" + }, + "Links": { + "Sessions": { + "@odata.id": "/redfish/v1/SessionService/Sessions" + } + }, + "Managers": { + "@odata.id": "/redfish/v1/Managers" + }, + "Name": "Root Service", + "RedfishVersion": "1.2.0", + "Registries": { + "@odata.id": "/redfish/v1/Registries" + }, + "SessionService": { + "@odata.id": "/redfish/v1/SessionService" + }, + "Systems": { + "@odata.id": "/redfish/v1/Systems" + }, + "Tasks": { + "@odata.id": "/redfish/v1/TaskService" + }, + "UpdateService": { + "@odata.id": "/redfish/v1/UpdateService" + } + }` + RESPONSE_EthernetInterface = `{ + "@odata.etag": "W/\"1646792654\"", + "@odata.id": "/redfish/v1/Systems/Node0", + "@odata.type": "#ComputerSystem.v1_5_0.ComputerSystem", + "Actions": { + "#ComputerSystem.Reset": { + "@Redfish.ActionInfo": "/redfish/v1/Systems/Node0/ResetActionInfo", + "target": "/redfish/v1/Systems/Node0/Actions/ComputerSystem.Reset" + }, + "#ComputerSystem.SetDefaultBootOrder": { + "@Redfish.ActionInfo": "/redfish/v1/Systems/Node0/SetDefaultBootOrderActionInfo", + "target": "/redfish/v1/Systems/Node0/Actions/ComputerSystem.SetDefaultBootOrder" + } + }, + "Bios": { + "@odata.id": "/redfish/v1/Systems/Node0/Bios" + }, + "BiosVersion": "ex235a.bios-1.3.6", + "Boot": { + "BootOptions": { + "@odata.id": "/redfish/v1/Systems/Node0/BootOptions" + }, + "BootOrder": [ + "ME0-PXE-IP4", + "ME0-PXE-IP6", + "HSN0-PXE-IP4", + "HSN0-PXE-IP6", + "HSN1-PXE-IP4", + "HSN1-PXE-IP6", + "HSN2-PXE-IP4", + "HSN3-PXE-IP4", + "HSN2-PXE-IP6", + "HSN3-PXE-IP6", + "Boot000B" + ] + }, + "Description": "BardPeakNC", + "EthernetInterfaces": { + "@odata.id": "/redfish/v1/Systems/Node0/EthernetInterfaces" + }, + "Id": "Node0", + "Manufacturer": "HPE", + "Memory": { + "@odata.id": "/redfish/v1/Systems/Node0/Memory" + }, + "MemorySummary": { + "TotalSystemMemoryGiB": 512 + }, + "Model": "HPE CRAY EX235a", + "Name": "Node0", + "PartNumber": "P37085-001.A", + "PowerState": "On", + "ProcessorSummary": { + "Count": 9, + "Model": "AMD INSTINCT MI200 (MCM) OAM LC" + }, + "Processors": { + "@odata.id": "/redfish/v1/Systems/Node0/Processors" + }, + "SerialNumber": "GHU4464825942", + "Status": { + "Health": "OK", + "State": "Enabled" + }, + "SystemType": "Physical" +}` + RESPONSE_Systems = `{ + "@odata.etag": "W/\"1646792654\"", + "@odata.id": "/redfish/v1/Systems", + "@odata.type": "#ComputerSystemCollection.ComputerSystemCollection", + "Description": "Collection of Computer Systems", + "Members": [ + { + "@odata.id": "/redfish/v1/Systems/Node0" + } + ], + "Members@odata.count": 1, + "Name": "Systems Collection" +}` +) diff --git a/pkg/test/test.go b/pkg/test/test.go new file mode 100644 index 00000000..2f56fc00 --- /dev/null +++ b/pkg/test/test.go @@ -0,0 +1,20 @@ +package test + +import ( + "net/http" +) + +func Make(response string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // if r.URL.Path != path { + // http.Error( + // w, + // fmt.Sprintf("expected path '%s' but got '%s' instead", path, r.URL.Path), + // http.StatusInternalServerError, + // ) + // return + // } + w.WriteHeader(http.StatusOK) + w.Write([]byte(response)) + } +}