diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 2629cfd..1136387 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -14,21 +14,36 @@ jobs: runs-on: ubuntu-latest steps: - - name: Set up Go 1.21 + - name: Set up latest stable Go uses: actions/setup-go@v5 with: - go-version: 1.21 + go-version: stable + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-tags: 1 + fetch-depth: 1 + + # Set environment variables required by GoReleaser + - name: Set build environment variables + run: | + echo "GIT_STATE=$(if git diff-index --quiet HEAD --; then echo 'clean'; else echo 'dirty'; fi)" >> $GITHUB_ENV + echo "BUILD_HOST=$(hostname)" >> $GITHUB_ENV + echo "GO_VERSION=$(go version | awk '{print $3}')" >> $GITHUB_ENV + echo "BUILD_USER=$(whoami)" >> $GITHUB_ENV + echo "CGO_ENABLED=1" >> $GITHUB_ENV + - name: Docker Login uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-tags: 1 - fetch-depth: 0 + - name: Release with goreleaser uses: goreleaser/goreleaser-action@v6 env: @@ -40,4 +55,4 @@ jobs: - name: Attest Binaries uses: actions/attest-build-provenance@v1 with: - subject-path: '${{ github.workspace }}/dist/magellan_linux_amd64_v1/magellan' + subject-path: '${{ github.workspace }}/dist/magellan_linux_amd64_v3/magellan' diff --git a/.github/workflows/prbuild.yml b/.github/workflows/prbuild.yml new file mode 100644 index 0000000..476ba60 --- /dev/null +++ b/.github/workflows/prbuild.yml @@ -0,0 +1,44 @@ +name: Build PR with goreleaser + +on: + pull_request: + branches: + - main + types: [opened, synchronize, reopened, edited] + workflow_dispatch: + +jobs: + prbuild: + runs-on: ubuntu-latest + steps: + + - name: Set up latest stable Go + uses: actions/setup-go@v5 + with: + go-version: stable + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-tags: 1 + fetch-depth: 1 + + # Set environment variables required by GoReleaser + - name: Set build environment variables + run: | + echo "GIT_STATE=$(if git diff-index --quiet HEAD --; then echo 'clean'; else echo 'dirty'; fi)" >> $GITHUB_ENV + echo "BUILD_HOST=$(hostname)" >> $GITHUB_ENV + echo "GO_VERSION=$(go version | awk '{print $3}')" >> $GITHUB_ENV + echo "BUILD_USER=$(whoami)" >> $GITHUB_ENV + echo "CGO_ENABLED=1" >> $GITHUB_ENV + + - name: Build with goreleaser + uses: goreleaser/goreleaser-action@v6 + + with: + version: '~> v2' + args: release --snapshot + id: goreleaser \ No newline at end of file diff --git a/.gitignore b/.gitignore index 96c3c6e..aeafd79 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ emulator/rf-emulator **.tar.gz **.tar.zst **.part +dist/* +**coverage.out** +magellan.1 \ No newline at end of file diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 56af355..d8552b6 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -4,15 +4,39 @@ before: hooks: - go mod download - go install github.com/cpuguy83/go-md2man/v2@latest - - go-md2man -in README.md -out manpage.1 + - go-md2man -in README.md -out magellan.1 builds: - - env: - - CGO_ENABLED=1 + - binary: magellan + main: ./main.go + # export GIT_STATE=$(if git diff-index --quiet HEAD --; then echo 'clean'; else echo 'dirty'; fi) + # export BUILD_HOST=$(hostname) + # export GO_VERSION=$(go version | awk '{print $3}') + # export BUILD_USER=$(whoami) + ldflags: + - "-X github.com/OpenCHAMI/magellan/internal/version.GitCommit={{ .Commit }} \ + -X github.com/OpenCHAMI/magellan/internal/version.BuildTime={{ .Timestamp }} \ + -X github.com/OpenCHAMI/magellan/internal/version.Version={{ .Version }} \ + -X github.com/OpenCHAMI/magellan/internal/version.GitBranch={{ .Branch }} \ + -X github.com/OpenCHAMI/magellan/internal/version.GitTag={{ .Tag }} \ + -X github.com/OpenCHAMI/magellan/internal/version.GitState={{ .Env.GIT_STATE }} \ + -X github.com/OpenCHAMI/magellan/internal/version.BuildHost={{ .Env.BUILD_HOST }} \ + -X github.com/OpenCHAMI/magellan/internal/version.GoVersion={{ .Env.GO_VERSION }} \ + -X github.com/OpenCHAMI/magellan/internal/version.BuildUser={{ .Env.BUILD_USER }} " + tags: + - version goos: - linux + - darwin + - windows goarch: - amd64 + - arm64 + goamd64: + - v3 + env: + - CGO_ENABLED=0 + archives: - format: tar.gz # this name template makes the OS and Arch compatible with the results of uname. @@ -27,17 +51,55 @@ archives: - LICENSE - CHANGELOG.md - README.md - - bin/magellan.sh - magellan.1 + +nfpms: + - id: magellan + formats: + - deb + - rpm + - apk + - archlinux + maintainer: "David J. Allen " + description: "Magellan is a discovery tool for BMCs." + homepage: "https://www.openchami.org" + license: MIT + section: utils + priority: optional + contents: + - src: dist/magellan_{{ .Os }}_{{ if eq .Arch "amd64" }}{{ .Arch }}_{{ .Amd64 }}{{ else }}{{ .Arch }}{{ end }}/magellan + dst: /usr/local/bin/magellan + - src: magellan.1 + dst: /usr/share/man/man1/ + + dockers: - - - image_templates: - - ghcr.io/openchami/{{.ProjectName}}:latest - - ghcr.io/openchami/{{.ProjectName}}:{{ .Tag }} - - ghcr.io/openchami/{{.ProjectName}}:v{{ .Major }} - - ghcr.io/openchami/{{.ProjectName}}:v{{ .Major }}.{{ .Minor }} + - image_templates: + - &amd64_linux_image ghcr.io/openchami/{{.ProjectName}}:{{ .Tag }}-amd64 + - ghcr.io/openchami/{{.ProjectName}}:{{ .Major }}-amd64 + - ghcr.io/openchami/{{.ProjectName}}:{{ .Major }}.{{ .Minor }}-amd64 + use: buildx build_flag_templates: - "--pull" + - "--platform=linux/amd64" + - "--label=org.opencontainers.image.created={{.Date}}" + - "--label=org.opencontainers.image.title={{.ProjectName}}" + - "--label=org.opencontainers.image.revision={{.FullCommit}}" + - "--label=org.opencontainers.image.version={{.Version}}" + goarch: amd64 + goamd64: v3 + extra_files: + - LICENSE + - CHANGELOG.md + - README.md + - image_templates: + - &arm64v8_linux_image ghcr.io/openchami/{{.ProjectName}}:{{ .Tag }}-arm64 + - ghcr.io/openchami/{{.ProjectName}}:{{ .Major }}-arm64 + - ghcr.io/openchami/{{.ProjectName}}:{{ .Major }}.{{ .Minor }}-arm64 + use: buildx + build_flag_templates: + - "--pull" + - "--platform=linux/arm64" - "--label=org.opencontainers.image.created={{.Date}}" - "--label=org.opencontainers.image.title={{.ProjectName}}" - "--label=org.opencontainers.image.revision={{.FullCommit}}" @@ -46,7 +108,32 @@ dockers: - LICENSE - CHANGELOG.md - README.md - - bin/magellan.sh + goarch: arm64 + +docker_manifests: + - name_template: "ghcr.io/openchami/{{.ProjectName}}:latest" + image_templates: + - *amd64_linux_image + - *arm64v8_linux_image + + - name_template: "ghcr.io/openchami/{{.ProjectName}}:{{ .Tag }}" + image_templates: + - *amd64_linux_image + - *arm64v8_linux_image + + - name_template: "ghcr.io/openchami/{{.ProjectName}}:{{ .Major }}" + image_templates: + - *amd64_linux_image + - *arm64v8_linux_image + + - name_template: "ghcr.io/openchami/{{.ProjectName}}:{{ .Major }}.{{ .Minor }}" + image_templates: + - *amd64_linux_image + - *arm64v8_linux_image + + + + checksum: name_template: 'checksums.txt' snapshot: @@ -57,8 +144,3 @@ changelog: exclude: - '^docs:' - '^test:' -release: - github: - name_template: "{{.Version}}" - prerelease: auto - mode: append diff --git a/CHANGELOG.md b/CHANGELOG.md index ebc8586..a6a8e92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,265 @@ 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.1.5] +### Added + + * Added Init() to Client interface + * Added temporary solution for creating new clients + +### Changed + + * Changed interface func from GetClient() to GetInternalClient() + +### Fixed + + * Fixed field tag in crawler + * Fixed panic when setting --cacert from invalid client + +### Updated + + * Updated warning message and changed SMD client to use pointer receivers + +### Miscellaneous + + * Merge pull request #55 from OpenCHAMI/cacert-hotfix + +## [0.1.4] + +### Added + + * Added response body into error messages + * Added schema version to output + +### Changed + + * Changed collect messages to using JSON format + +### Miscellaneous + + * Merge branch 'main' into minor-changes + * Merge pull request #50 from OpenCHAMI/container-build + * Merge pull request #51 from OpenCHAMI/minor-changes + * Merge pull request #52 from OpenCHAMI/minor-changes + * Merge pull request #53 from OpenCHAMI/minor-changes + * Merge pull request #54 from OpenCHAMI/update-readme + * Rearranged collect error to only show when not force updating + * Updated README.md and fixed outdated info + * magellan.sh: remove unused build helper function + * release: prefix all version tags with "v" + +## [0.1.3] + +### Fixed + + * Fixed automatic builds with docker container + * Fixed deprecation warning in goreleaser + * Fixed permissions in workflow + * Fixed typo in workflow + +## [0.1.2] + +### Fixed + + * Fixed automatic builds with docker container + * Fixed typo in workflow + +## [0.1.1] + +### Added + + * Added container building working + * Added more information to crawler output + +### Removed + + * Removed copying script in container + +### Miscellaneous + + * Merge pull request #49 from OpenCHAMI/add-types + +## [0.1.0] + +### Added + + * Added TODO comments to tests and other minor change + * Added URL sanitization for SMD host and moved auth from util + * Added check for output directory for collect + * Added disclaimer about incompatibility with SMD + * Added flag to show cache info with list command and other minor changes + +### Changed + + * Changed 'docker' rule to 'container' + * Changed build rule and added release rule to Makefile + * Changed firmware.* back to firmware-* + * Changed host to hostname being stored in cache + * Changed how arguments are passed to update command + * Changed how based URL is derived in update functions + * Changed order of adding default ports to add host correctly + * Changed saving host to include scheme for collect + * Changed short help message for root command + * Changed showing target host to use debug instead of verbose flag + * Changed transfer-protocol flag to scheme to match other commands + * Changed the username/password flag names + +### Fixed + + * Fixed '--subnet' flag not adding hosts to scan + * Fixed crawl command help string + * Fixed error message format for list command + * Fixed getting ethernet interfaces in CollectEthernetInterfaces() + * Fixed imports and removed unused query params + * Fixed issue with collect requests and other minor changes + * Fixed issue with host string and added internal url package + * Fixed lint errors + * Fixed passing the correct argument in Sanitize() + * Fixed port not being added to probing request + * Fixed root persistent flags not binding correctly + * Fixed scan not probing the host correctly + * Fixed small issue with command string + * Fixed typo errors in changelog and readme + * Fixed viper flag binding in collect cmd + +### Removed + + * Removed 'dora' API + * Removed commented out code + * Removed extra print statement + * Removed files from util + * Removed magellan's internal logger for zerolog + * Removed storage file + * Removed unused code, rename vars, and changed output to use hive partitioning strategy + * Removed unused functions in collect.go + * Removed unused port and clarified default in README.md + * Removed unused query params + * Removed unused updating code and bmclib dependency and other minor changes + * Removed unused variables in client package + +### Updated + + * Updated 'cmd' package + * Updated .gitignore + * Updated Makefile to include GOPATH in some targets + * Updated README.md with features section + * Updated example config + * Updated go dependencies + * Updated tests to reflect new API changes + +### Renamed + + * Renamed smd package to client + * Renamed struct + * Renamed vars and switched to use zerolog + +### Miscellaneous + + * Minor changes and improvements + * Minor changes to fix lint errors + * Minor changes to tests + * More minor changes + * Moved SMD-related API to pkg + * Refactored how clients work to reduce hard-coded dependencies + * Refactored/reorganized utils + * Reformatted scan help message + * Separated auth from util and fixed help strings + +## [0.0.20] + + * Updated workflows to publish container + +## [0.0.19] + +### Added + + * Added 'docs' rule to Makefile + * Added initial round of comments for API documentation + * Added initial tests for API and compatibiilty coverage + * Added more API documentation + * Added more documentation and changed param names + +### Changed + + * Changed Dockerfile to use binary instead of script + +### Fixed + + * Fixed issue with required param + * Fixed small typo + * Fixed syntax error with command description + +### Removed + + * Removed unused code that used bmclib + +### Updated + + * Updated README to include information about building on Debian + * Updated go dependencies removing bmclib + * Updated dependencies + +### Miscellaneous + +Minor changes to README.md +Tidied up CLI flag names + +## [0.0.18] + +### Fixed + + * Fixed formatting error in workflow + +## [0.0.17] + + * Addressed x/net dependabot issue + +## [0.0.16] + + * Updated attestation path + +## [0.0.15] + +### Removed + + * Removed unnecessary attestation support script + +## [0.0.14] + + * Updated to goreleaser v2 + +## [0.0.13] + + * Updated to goreleaser v2 + +## [0.0.12] + + * Removed attestation of non-existent container + +## [0.0.11] + +### Removed + + * Removed docker container from goreleaser to address build errors + +## [0.0.10] + + * Updated .goreleaser.yaml + +## [0.0.9] + + * Included Checkout in workflow + +## [0.0.8] +## [0.0.7] + +## [0.0.6] + +### Added + + * Adding dev container to standardize Linux build + * Merge pull request #1 from OpenCHAMI/rehome ## [0.0.5] - 2023-11-02 diff --git a/Dockerfile b/Dockerfile index 510a291..d9632fe 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,13 +1,17 @@ -FROM cgr.dev/chainguard/wolfi-base +FROM chainguard/wolfi-base:latest -RUN apk add --no-cache tini bash +# Include curl in the final image for manual checks of the Redfish urls +RUN set -ex \ + && apk update \ + && apk add --no-cache curl tini \ + && rm -rf /var/cache/apk/* \ + && rm -rf /tmp/* # nobody 65534:65534 USER 65534:65534 COPY magellan /magellan -COPY /bin/magellan.sh /magellan.sh CMD [ "/magellan" ] diff --git a/Makefile b/Makefile index 3285e00..8b5f3cb 100644 --- a/Makefile +++ b/Makefile @@ -52,14 +52,17 @@ inst: ## go install tools $(call print-target) go install github.com/client9/misspell/cmd/misspell@v0.3.4 go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.52.2 - go install github.com/goreleaser/goreleaser@v1.18.2 + go install github.com/goreleaser/goreleaser/v2@v2.3.2 go install github.com/cpuguy83/go-md2man/v2@latest -.PHONY: goreleaser +.PHONY: release release: ## goreleaser build $(call print-target) $(GOPATH)/bin/goreleaser build --clean --single-target --snapshot +.PHONY: binaries +binaries: build + .PHONY: build build: ## go build go build -v --tags=all -ldflags=$(LDFLAGS) -o $(NAME) main.go @@ -83,7 +86,9 @@ lint: ## golangci-lint .PHONY: test test: ## go test $(call print-target) - go test -race -covermode=atomic -coverprofile=coverage.out -coverpkg=./... ./... + ./emulator/setup.sh & + sleep 10 + go test -race -covermode=atomic -coverprofile=coverage.out -coverpkg=./... tests/api_test.go tests/compatibility_test.go go tool cover -html=coverage.out -o coverage.html .PHONY: diff diff --git a/README.md b/README.md index 1f94e67..6cb34be 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ This should return a JSON response with general information. The output below ha ### Running the Tool -There are three main commands to use with the tool: `scan`, `list`, and `collect`. To see all of the available commands, run `magellan` with the `help` subcommand: +There are three main commands to use with the tool: `scan`, `list`, and `collect`. To see all of the available commands, run `magellan` with the `help` subcommand which will print this output: ```bash Redfish-based BMC discovery tool @@ -119,16 +119,17 @@ Available Commands: login Log in with identity provider for access token scan Scan to discover BMC nodes on a network update Update BMC node firmware + version Print version info and exit Flags: - --access-token string set the access token - --cache string set the scanning result cache path (default "/tmp/allend/magellan/assets.db") - --concurrency int set the number of concurrent processes (default -1) - -c, --config string set the config file path - -d, --debug set to enable/disable debug messages + --access-token string Set the access token + --cache string Set the scanning result cache path (default "/tmp/allend/magellan/assets.db") + --concurrency int Set the number of concurrent processes (default -1) + -c, --config string Set the config file path + -d, --debug Set to enable/disable debug messages -h, --help help for magellan - --timeout int set the timeout (default 5) - -v, --verbose set to enable/disable verbose output + --timeout int Set the timeout for requests (default 5) + -v, --verbose Set to enable/disable verbose output Use "magellan [command] --help" for more information about a command. ``` @@ -143,23 +144,19 @@ To start a network scan for BMC nodes, use the `scan` command. If the port is no --cache data/assets.db \ ``` -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 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. Setting the `--format=json` will format the output in JSON. 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: +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 ./magellan scan https://10.0.0.100:5000 --subnet 172.16.0.0/24 ``` -Check the help for each subcommand for more examples for specifying arguments. - -To inspect the cache, use the `list` command. Make sure to point to the same database used before: +Once the scan is complete, inspect the cache to see a list of found hosts with the `list` command. Make sure to point to the same database used before if you set the `--cache` flag. ```bash -./magellan list --cache data/assets.db --format json +./magellan list --cache data/assets.db ``` -This will print a list of node info found and stored from the scan. Like the `scan` subcommand, the output format can be set using the `--format` flag. - -Finally, set the `ACCESS_TOKEN`run the `collect` command to query the node from cache and send the info to be stored into SMD: +This will print a list of host information needed for the `collect` step. Set the `ACCESS_TOKEN` if necessary and invoke `magellan` again with the `collect` subcommand to query the node BMCs stored in cache. If the `--host` flag is set, then an additional request will be made to send the output to the specified URL. The `--userame` and `--password` flags must be set if the BMC requires basic authentication. ```bash ./magellan collect \ @@ -167,14 +164,14 @@ Finally, set the `ACCESS_TOKEN`run the `collect` command to query the node from --timeout 5 \ --username $USERNAME \ --password $PASSWORD \ - --host https://example.openchami.cluster:27779 \ + --host https://example.openchami.cluster:8443 \ --output logs/ --cacert cacert.pem ``` -This uses the info stored in cache to request information about each BMC node if possible. Like with the scan, the time to wait for a response can be set with the `--timeout` flag as well. This command also requires the `--user` and `--pass` flags to be set if access the Redfish service requires basic authentication. Additionally, it may be necessary to set the `--host` and `--port` flags for `magellan` to find the SMD API (not the root API endpoint "/hsm/v2"). The output of the `collect` can be saved by using the `--output` +This will initiate a crawler that will find as much inventory data as possible. The data can be viewed from standard output by setting the `--verbose` flag. This output can also be saved by using the `--output` flag and providing a path argument. -Note: If the `cache` flag is not set, `magellan` will use "/tmp/$USER/magellan.db" by default. +Note: If the `cache` flag is not set, `magellan` will use `/tmp/$USER/magellan.db` by default. ### Updating Firmware diff --git a/bin/magellan.sh b/bin/magellan.sh deleted file mode 100755 index c91d922..0000000 --- a/bin/magellan.sh +++ /dev/null @@ -1,122 +0,0 @@ -#!/bin/bash - -EXE=./magellan -SUBNETS="" -PORTS="" -USER="" -PASS="" -SMD_HOST="" -SMD_PORT="" -THREADS="1" -TIMEOUT="30" -ARGS="" -FORCE_UPDATE=false -SCAN_PARAMS="" -COLLECT_PARAMS="" - - -function scan() { - # ./magellan scan --subnet 172.16.0.0 --port 443 - ${EXE} scan ${SCAN_PARAMS} - # --subnet ${SUBNETS} \ - # --port ${PORTS} \ - # --timeout ${TIMEOUT} \ - # --threads ${THREADS} -} - -function list(){ - # ./magellan list - ${EXE} list -} - -function collect() { - # ./magellan collect --user admin --pass password - ${EXE} collect ${COLLECT_PARAMS} - # --user ${USER} \ - # --pass ${PASS} \ - # --timeout ${TIMEOUT} \ - # --threads ${THREADS} \ - # --host ${SMD_HOST} \ - # --port ${SMD_PORT} \ - # --force-update ${FORCE_UPDATE} -} - - -# parse incoming arguments to set variables -while [[ $# -gt 0 ]]; do - case $1 in - --scan) - SCAN_PARAMS="$2" - shift - shift - ;; - --collect) - COLLECT_PARAMS="$2" - shift - shift - ;; - --subnet) - SUBNETS="$2" - shift # past argument - shift # past value - ;; - -p|--port) - PORTS="$2" - shift # past argument - shift # past value - ;; - --user) - USER="$2" - shift # past argument - shift # past value - ;; - --pass|--password) - PASS="$2" - shift - shift - ;; - --smd-host) - SMD_HOST="$2" - shift - shift - ;; - --smd-port) - SMD_PORT="$2" - shift - shift - ;; - --timeout) - TIMEOUT="$2" - shift - shift - ;; - --threads) - THREADS="$2" - shift - shift - ;; - -*|--*) - echo "Unknown option $1" - exit 1 - ;; - *) - ARGS+=("$1") # save positional arg - shift # past argument - ;; - esac -done - -set -- "${POSITIONAL_ARGS[@]}" # restore positional parameters - -if [[ -n $1 ]]; then - echo "Last line of file specified as non-opt/last argument:" - tail -1 "$1" -fi - -scan -collect - -# run with docker -# docker run magellan:latest magellan.sh \ -# --scan "--subnet 127.16.0.0 --port 443" \ -# --collect "--user admin --pass password --timeout 300 --threads 1 --smd-host host --smd-port port" \ No newline at end of file diff --git a/cmd/crawl.go b/cmd/crawl.go index 8069c9b..2611ed3 100644 --- a/cmd/crawl.go +++ b/cmd/crawl.go @@ -35,7 +35,7 @@ var crawlCmd = &cobra.Command{ return nil }, Run: func(cmd *cobra.Command, args []string) { - systems, err := crawler.CrawlBMC(crawler.CrawlerConfig{ + systems, err := crawler.CrawlBMCForSystems(crawler.CrawlerConfig{ URI: args[0], Username: cmd.Flag("username").Value.String(), Password: cmd.Flag("password").Value.String(), diff --git a/cmd/root.go b/cmd/root.go index b21e22d..3b0d4f0 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -74,13 +74,13 @@ func Execute() { func init() { currentUser, _ = user.Current() cobra.OnInitialize(InitializeConfig) - rootCmd.PersistentFlags().IntVar(&concurrency, "concurrency", -1, "set the number of concurrent processes") - rootCmd.PersistentFlags().IntVar(&timeout, "timeout", 5, "set the timeout") - rootCmd.PersistentFlags().StringVarP(&configPath, "config", "c", "", "set the config file path") - rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "set to enable/disable verbose output") - rootCmd.PersistentFlags().BoolVarP(&debug, "debug", "d", false, "set to enable/disable debug messages") - rootCmd.PersistentFlags().StringVar(&accessToken, "access-token", "", "set the access token") - rootCmd.PersistentFlags().StringVar(&cachePath, "cache", fmt.Sprintf("/tmp/%s/magellan/assets.db", currentUser.Username), "set the scanning result cache path") + rootCmd.PersistentFlags().IntVar(&concurrency, "concurrency", -1, "Set the number of concurrent processes") + rootCmd.PersistentFlags().IntVar(&timeout, "timeout", 5, "Set the timeout for requests") + rootCmd.PersistentFlags().StringVarP(&configPath, "config", "c", "", "Set the config file path") + rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "Set to enable/disable verbose output") + rootCmd.PersistentFlags().BoolVarP(&debug, "debug", "d", false, "Set to enable/disable debug messages") + rootCmd.PersistentFlags().StringVar(&accessToken, "access-token", "", "Set the access token") + rootCmd.PersistentFlags().StringVar(&cachePath, "cache", fmt.Sprintf("/tmp/%s/magellan/assets.db", currentUser.Username), "Set the scanning result cache path") // bind viper config flags with cobra checkBindFlagError(viper.BindPFlag("concurrency", rootCmd.PersistentFlags().Lookup("concurrency"))) diff --git a/cmd/version.go b/cmd/version.go index 3ffff17..87b7596 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -1,40 +1,18 @@ package cmd import ( - "fmt" - + "github.com/OpenCHAMI/magellan/internal/version" "github.com/spf13/cobra" ) -var ( - version string - commit string - date string - output string -) - var versionCmd = &cobra.Command{ - Use: "version", + Use: "version", + Short: "Print version info and exit", Run: func(cmd *cobra.Command, args []string) { - if cmd.Flag("commit").Value.String() == "true" { - output = commit - if date != "" { - output += " built @ " + date - } - fmt.Println(output) - } else { - fmt.Println(version) - } + version.PrintVersionInfo() }, } func init() { - versionCmd.Flags().Bool("commit", false, "show the version commit") rootCmd.AddCommand(versionCmd) } - -func SetVersionInfo(buildVersion string, buildCommit string, buildDate string) { - version = buildVersion - commit = buildCommit - date = buildDate -} diff --git a/dist/archlinux/PKGBUILD b/dist/archlinux/PKGBUILD deleted file mode 100644 index 23e69b7..0000000 --- a/dist/archlinux/PKGBUILD +++ /dev/null @@ -1,29 +0,0 @@ -# Maintainer: David J. Allen -pkgname=magellan -pkgver=v0.1.5 -pkgrel=1 -pkgdesc="Redfish-based BMC discovery tool written in Go" -arch=("x86_64") -url="https://github.com/OpenCHAMI/magellan" -license=('MIT') -groups=("openchami") -provides=('magellan') -conflicts=('magellan') -source_x86_64=("${url}/releases/download/${pkgver}/${pkgname}_Linux_x86_64.tar.gz") -sha256sums_x86_64=('1bb028d592d5389b519362e6aa7021f27443f0b36471e09ee7f47ab5cb6d4d7f') - -# Please refer to the 'USING VCS SOURCES' section of the PKGBUILD man page for -# a description of each element in the source array. - -pkgver() { - cd "$srcdir" || exit - printf "%s" "$(git describe --tags --abbrev=0)" -} - -package() { - cd "$srcdir/" || exit - - # install the binary to /usr/bin - mkdir -p "${pkgdir}/usr/bin" - install -m755 magellan "${pkgdir}/usr/bin/magellan" -} diff --git a/go.mod b/go.mod index e00981d..e153625 100644 --- a/go.mod +++ b/go.mod @@ -22,7 +22,6 @@ require ( github.com/google/go-cmp v0.6.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - ) require ( diff --git a/internal/cache/sqlite/sqlite.go b/internal/cache/sqlite/sqlite.go index 3e72676..6bceab4 100644 --- a/internal/cache/sqlite/sqlite.go +++ b/internal/cache/sqlite/sqlite.go @@ -106,11 +106,9 @@ func DeleteScannedAssets(path string, assets ...magellan.RemoteAsset) error { func GetScannedAssets(path string) ([]magellan.RemoteAsset, error) { // check if path exists first to prevent creating the database - exists, err := util.PathExists(path) + _, exists := util.PathExists(path) if !exists { return nil, fmt.Errorf("no file found") - } else if err != nil { - return nil, err } // now check if the file is the SQLite database diff --git a/internal/collect.go b/internal/collect.go index 8c03050..e52bc60 100644 --- a/internal/collect.go +++ b/internal/collect.go @@ -10,18 +10,20 @@ import ( "net/http" "os" "path" + "strings" + "path/filepath" "sync" "time" "github.com/OpenCHAMI/magellan/pkg/client" "github.com/OpenCHAMI/magellan/pkg/crawler" - "github.com/OpenCHAMI/magellan/internal/util" "github.com/rs/zerolog/log" "github.com/Cray-HPE/hms-xname/xnames" _ "github.com/mattn/go-sqlite3" - _ "github.com/stmcginnis/gofish" + "github.com/stmcginnis/gofish" + "github.com/stmcginnis/gofish/redfish" "golang.org/x/exp/slices" ) @@ -41,12 +43,13 @@ type CollectParams struct { } // This is the main function used to collect information from the BMC nodes via Redfish. +// The results of the collect are stored in a cache specified with the `--cache` flag. // The function expects a list of hosts found using the `ScanForAssets()` function. // // Requests can be made to several of the nodes using a goroutine by setting the q.Concurrency -// property value between 1 and 255. +// property value between 1 and 10000. func CollectInventory(assets *[]RemoteAsset, params *CollectParams) error { - // check for available probe states + // check for available remote assets found from scan if assets == nil { return fmt.Errorf("no assets found") } @@ -109,14 +112,23 @@ func CollectInventory(assets *[]RemoteAsset, params *CollectParams) error { offset += 1 // crawl BMC node to fetch inventory data via Redfish - systems, err := crawler.CrawlBMC(crawler.CrawlerConfig{ - URI: fmt.Sprintf("%s:%d", sr.Host, sr.Port), - Username: params.Username, - Password: params.Password, - Insecure: true, - }) + var ( + systems []crawler.InventoryDetail + managers []crawler.Manager + config = crawler.CrawlerConfig{ + URI: fmt.Sprintf("%s:%d", sr.Host, sr.Port), + Username: params.Username, + Password: params.Password, + Insecure: true, + } + ) + systems, err := crawler.CrawlBMCForSystems(config) if err != nil { - log.Error().Err(err).Msgf("failed to crawl BMC") + log.Error().Err(err).Msg("failed to crawl BMC for systems") + } + managers, err = crawler.CrawlBMCForManagers(config) + if err != nil { + log.Error().Err(err).Msg("failed to crawl BMC for managers") } // data to be sent to smd @@ -129,9 +141,20 @@ func CollectInventory(assets *[]RemoteAsset, params *CollectParams) error { "MACRequired": true, "RediscoverOnUpdate": false, "Systems": systems, + "Managers": managers, "SchemaVersion": 1, } + // optionally, add the MACAddr property if we find a matching IP + // from the correct ethernet interface + mac, err := FindMACAddressWithIP(config, net.ParseIP(sr.Host)) + if err != nil { + log.Warn().Err(err).Msgf("failed to find MAC address with IP '%s'", sr.Host) + } + if mac != "" { + data["MACAddr"] = mac + } + // create and set headers for request headers := client.HTTPHeader{} headers.Authorization(params.AccessToken) @@ -148,25 +171,20 @@ func CollectInventory(assets *[]RemoteAsset, params *CollectParams) error { // write JSON data to file if output path is set using hive partitioning strategy if outputPath != "" { - // make directory if it does exists - exists, err := util.PathExists(outputPath) - if err == nil && !exists { - err = os.MkdirAll(outputPath, 0o644) + var ( + finalPath = fmt.Sprintf("./%s/%s/%d.json", outputPath, data["ID"], time.Now().Unix()) + finalDir = filepath.Dir(finalPath) + ) + // if it doesn't, make the directory and write file + err = os.MkdirAll(finalDir, 0o777) + if err == nil { // no error + err = os.WriteFile(path.Clean(finalPath), body, os.ModePerm) if err != nil { - log.Error().Err(err).Msg("failed to make directory for output") - } else { - // make the output directory to store files - outputPath, err := util.MakeOutputDirectory(outputPath, false) - if err != nil { - log.Error().Err(err).Msg("failed to make output directory") - } else { - // write the output to the final path - err = os.WriteFile(path.Clean(fmt.Sprintf("%s/%s/%d.json", params.URI, outputPath, time.Now().Unix())), body, os.ModePerm) - if err != nil { - log.Error().Err(err).Msgf("failed to write data to file") - } - } + log.Error().Err(err).Msgf("failed to write collect output to file") } + + } else { // error is set + log.Error().Err(err).Msg("failed to make directory for collect output") } } @@ -225,3 +243,75 @@ func CollectInventory(assets *[]RemoteAsset, params *CollectParams) error { return nil } + +// FindMACAddressWithIP() returns the MAC address of an ethernet interface with +// a matching IPv4Address. Returns an empty string and error if there are no matches +// found. +func FindMACAddressWithIP(config crawler.CrawlerConfig, targetIP net.IP) (string, error) { + // get the managers to find the BMC MAC address compared with IP + // + // NOTE: Since we don't have a RedfishEndpoint type abstraction in + // magellan and the crawler crawls for systems information, it + // may just make more sense to get the managers directly via + // gofish (at least for now). If there's a need for grabbing more + // manager information in the future, we can move the logic into + // the crawler. + client, err := gofish.Connect(gofish.ClientConfig{ + Endpoint: config.URI, + Username: config.Username, + Password: config.Password, + Insecure: config.Insecure, + BasicAuth: true, + }) + if err != nil { + if strings.HasPrefix(err.Error(), "404:") { + err = fmt.Errorf("no ServiceRoot found. This is probably not a BMC: %s", config.URI) + } + if strings.HasPrefix(err.Error(), "401:") { + err = fmt.Errorf("authentication failed. Check your username and password: %s", config.URI) + } + event := log.Error() + event.Err(err) + event.Msg("failed to connect to BMC") + return "", err + } + defer client.Logout() + + var ( + rf_service = client.GetService() + rf_managers []*redfish.Manager + ) + rf_managers, err = rf_service.Managers() + if err != nil { + return "", fmt.Errorf("failed to get managers: %v", err) + } + + // find the manager with the same IP address of the BMC to get + // it's MAC address from its EthernetInterface + for _, manager := range rf_managers { + eths, err := manager.EthernetInterfaces() + if err != nil { + log.Error().Err(err).Msgf("failed to get ethernet interfaces from manager '%s'", manager.Name) + continue + } + for _, eth := range eths { + // compare the ethernet interface IP with argument + for _, ip := range eth.IPv4Addresses { + if ip.Address == targetIP.String() { + // we found matching IP address so return the ethernet interface MAC + return eth.MACAddress, nil + } + } + // do the same thing as above, but with static IP addresses + for _, ip := range eth.IPv4StaticAddresses { + if ip.Address == targetIP.String() { + return eth.MACAddress, nil + } + } + // no matches found, so go to next ethernet interface + continue + } + } + // no matches found, so return an empty string + return "", fmt.Errorf("no ethernet interfaces found with IP address") +} diff --git a/internal/util/path.go b/internal/util/path.go index c2e3e58..de63954 100644 --- a/internal/util/path.go +++ b/internal/util/path.go @@ -2,6 +2,7 @@ package util import ( "fmt" + "io/fs" "os" "path/filepath" "strings" @@ -13,15 +14,9 @@ import ( // // Returns whether the path exists and no error if successful, // otherwise, it returns false with an error. -func PathExists(path string) (bool, error) { - _, err := os.Stat(path) - if err == nil { - return true, nil - } - if os.IsNotExist(err) { - return false, nil - } - return false, err +func PathExists(path string) (fs.FileInfo, bool) { + fi, err := os.Stat(path) + return fi, !os.IsNotExist(err) } // SplitPathForViper() is an utility function to split a path into 3 parts: @@ -51,17 +46,14 @@ func MakeOutputDirectory(path string, overwrite bool) (string, error) { final := path + "/" + dirname // check if path is valid and directory - pathExists, err := PathExists(final) - if err != nil { - return "", fmt.Errorf("failed to check for existing path: %v", err) - } + _, pathExists := PathExists(final) if pathExists && !overwrite { // make sure it is directory with 0o644 permissions return "", fmt.Errorf("found existing path: %v", final) } // create directory with data + time - err = os.MkdirAll(final, 0766) + err := os.MkdirAll(final, 0766) if err != nil { return "", fmt.Errorf("failed to make directory: %v", err) } diff --git a/internal/util/util.go b/internal/util/util.go new file mode 100644 index 0000000..3edeeff --- /dev/null +++ b/internal/util/util.go @@ -0,0 +1,27 @@ +package util + +import ( + "fmt" + "time" +) + +// CheckUntil regularly check a predicate until it's true or time out is reached. +func CheckUntil(interval time.Duration, timeout time.Duration, predicate func() (bool, error)) error { + timeoutCh := time.After(timeout) + + for { + select { + case <-time.After(interval): + predTrue, err := predicate() + if predTrue { + return nil + } + + if err != nil { + return err + } + case <-timeoutCh: + return fmt.Errorf("timeout of %ds reached", int64(timeout/time.Second)) + } + } +} diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..7d9d231 --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,60 @@ +package version + +import ( + "fmt" +) + +// GitCommit stores the latest Git commit hash. +// Set via -ldflags "-X github.com/OpenCHAMI/magellan/internal/version.GitCommit=$(git rev-parse HEAD)" +var GitCommit string + +// BuildTime stores the build timestamp in UTC. +// Set via -ldflags "-X github.com/OpenCHAMI/magellan/internal/version.BuildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ)" +var BuildTime string + +// Version indicates the version of the binary, such as a release number or semantic version. +// Set via -ldflags "-X github.com/OpenCHAMI/magellan/internal/version.Version=v1.0.0" +var Version string + +// GitBranch holds the name of the Git branch from which the build was created. +// Set via -ldflags "-X github.com/OpenCHAMI/magellan/internal/version.GitBranch=$(git rev-parse --abbrev-ref HEAD)" +var GitBranch string + +// GitTag represents the most recent Git tag at build time, if any. +// Set via -ldflags "-X github.com/OpenCHAMI/magellan/internal/version.GitTag=$(git describe --tags --abbrev=0)" +var GitTag string + +// GitState indicates whether the working directory was "clean" or "dirty" (i.e., with uncommitted changes). +// Set via -ldflags "-X github.com/OpenCHAMI/magellan/internal/version.GitState=$(if git diff-index --quiet HEAD --; then echo 'clean'; else echo 'dirty'; fi)" +var GitState string + +// BuildHost stores the hostname of the machine where the binary was built. +// Set via -ldflags "-X github.com/OpenCHAMI/magellan/internal/version.BuildHost=$(hostname)" +var BuildHost string + +// GoVersion captures the Go version used to build the binary. +// Typically, this can be obtained automatically with runtime.Version(), but you can set it manually. +// Set via -ldflags "-X github.com/OpenCHAMI/magellan/internal/version.GoVersion=$(go version | awk '{print $3}')" +var GoVersion string + +// BuildUser is the username of the person or system that initiated the build process. +// Set via -ldflags "-X github.com/OpenCHAMI/magellan/internal/version.BuildUser=$(whoami)" +var BuildUser string + +// PrintVersionInfo outputs all versioning information for troubleshooting or version checks. +func PrintVersionInfo() { + fmt.Printf("Version: %s\n", Version) + fmt.Printf("Git Commit: %s\n", GitCommit) + fmt.Printf("Build Time: %s\n", BuildTime) + fmt.Printf("Git Branch: %s\n", GitBranch) + fmt.Printf("Git Tag: %s\n", GitTag) + fmt.Printf("Git State: %s\n", GitState) + fmt.Printf("Build Host: %s\n", BuildHost) + fmt.Printf("Go Version: %s\n", GoVersion) + fmt.Printf("Build User: %s\n", BuildUser) +} + +func VersionInfo() string { + return fmt.Sprintf("Version: %s, Git Commit: %s, Build Time: %s, Git Branch: %s, Git Tag: %s, Git State: %s, Build Host: %s, Go Version: %s, Build User: %s", + Version, GitCommit, BuildTime, GitBranch, GitTag, GitState, BuildHost, GoVersion, BuildUser) +} diff --git a/main.go b/main.go index 0d248c4..ebe2d95 100644 --- a/main.go +++ b/main.go @@ -4,13 +4,6 @@ import ( "github.com/OpenCHAMI/magellan/cmd" ) -var ( - version string - commit string - date string -) - func main() { - cmd.SetVersionInfo(version, commit, date) cmd.Execute() } diff --git a/pkg/crawler/main.go b/pkg/crawler/main.go index e28cb1b..771efb9 100644 --- a/pkg/crawler/main.go +++ b/pkg/crawler/main.go @@ -41,6 +41,17 @@ type NetworkInterface struct { Adapter NetworkAdapter `json:"adapter,omitempty"` // Adapter of the interface } +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"` +} + type InventoryDetail struct { URI string `json:"uri,omitempty"` // URI of the BMC UUID string `json:"uuid,omitempty"` // UUID of Node @@ -65,9 +76,12 @@ type InventoryDetail struct { Chassis_Model string `json:"chassis_model,omitempty"` // Model of the Chassis } -// CrawlBMC pulls all pertinent information from a BMC. It accepts a CrawlerConfig and returns a list of InventoryDetail structs. -func CrawlBMC(config CrawlerConfig) ([]InventoryDetail, error) { - var systems []InventoryDetail +// 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 []InventoryDetail + rf_systems []*redfish.ComputerSystem + ) // initialize gofish client client, err := gofish.Connect(gofish.ClientConfig{ Endpoint: config.URI, @@ -94,8 +108,6 @@ func CrawlBMC(config CrawlerConfig) ([]InventoryDetail, error) { rf_service := client.GetService() log.Info().Msgf("found ServiceRoot %s. Redfish Version %s", rf_service.ID, rf_service.RedfishVersion) - var rf_systems []*redfish.ComputerSystem - // Nodes are sometimes only found under Chassis, but they should be found under Systems. rf_chassis, err := rf_service.Chassis() if err == nil { @@ -114,8 +126,43 @@ func CrawlBMC(config CrawlerConfig) ([]InventoryDetail, error) { } log.Info().Msgf("found %d systems in ServiceRoot", len(rf_root_systems)) rf_systems = append(rf_systems, rf_root_systems...) - systems, err = walkSystems(rf_systems, nil, config.URI) - return systems, err + return walkSystems(rf_systems, nil, config.URI) +} + +// CrawlBMCForSystems pulls BMC manager information. +func CrawlBMCForManagers(config CrawlerConfig) ([]Manager, error) { + // initialize gofish client + var managers []Manager + client, err := gofish.Connect(gofish.ClientConfig{ + Endpoint: config.URI, + Username: config.Username, + Password: config.Password, + Insecure: config.Insecure, + BasicAuth: true, + }) + if err != nil { + if strings.HasPrefix(err.Error(), "404:") { + err = fmt.Errorf("no ServiceRoot found. This is probably not a BMC: %s", config.URI) + } + if strings.HasPrefix(err.Error(), "401:") { + err = fmt.Errorf("authentication failed. Check your username and password: %s", config.URI) + } + event := log.Error() + event.Err(err) + event.Msg("failed to connect to BMC") + return managers, err + } + defer client.Logout() + + // Obtain the ServiceRoot + rf_service := client.GetService() + log.Info().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") + } + return walkManagers(rf_managers, config.URI) } func walkSystems(rf_systems []*redfish.ComputerSystem, rf_chassis *redfish.Chassis, baseURI string) ([]InventoryDetail, error) { @@ -200,7 +247,44 @@ func walkSystems(rf_systems []*redfish.ComputerSystem, rf_chassis *redfish.Chass for _, rf_trustedmodule := range rf_computersystem.TrustedModules { system.TrustedModules = append(system.TrustedModules, fmt.Sprintf("%s %s", rf_trustedmodule.InterfaceType, rf_trustedmodule.FirmwareVersion)) } + systems = append(systems, system) } return systems, nil } + +func walkManagers(rf_managers []*redfish.Manager, baseURI string) ([]Manager, error) { + var managers []Manager + for _, rf_manager := range rf_managers { + rf_ethernetinterfaces, err := rf_manager.EthernetInterfaces() + if err != nil { + log.Error().Err(err).Msg("failed to get ethernet interfaces from manager") + return managers, err + } + var ethernet_interfaces []EthernetInterface + for _, rf_ethernetinterface := range rf_ethernetinterfaces { + if len(rf_ethernetinterface.IPv4Addresses) <= 0 { + continue + } + ethernet_interfaces = append(ethernet_interfaces, EthernetInterface{ + URI: baseURI + rf_ethernetinterface.ODataID, + MAC: rf_ethernetinterface.MACAddress, + Name: rf_ethernetinterface.Name, + Description: rf_ethernetinterface.Description, + Enabled: rf_ethernetinterface.InterfaceEnabled, + IP: rf_ethernetinterface.IPv4Addresses[0].Address, + }) + } + 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, + }) + } + return managers, nil +} diff --git a/tests/api_test.go b/tests/api_test.go index c213451..e823bfa 100644 --- a/tests/api_test.go +++ b/tests/api_test.go @@ -8,59 +8,189 @@ package tests import ( + "bytes" + "crypto/tls" + "fmt" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" "testing" + "time" + + "flag" magellan "github.com/OpenCHAMI/magellan/internal" + "github.com/OpenCHAMI/magellan/internal/util" + "github.com/OpenCHAMI/magellan/pkg/client" "github.com/rs/zerolog/log" ) var ( - scanParams = &magellan.ScanParams{ - TargetHosts: [][]string{ - []string{ - "http://127.0.0.1:443", - "http://127.0.0.1:5000", - }, - }, - Scheme: "https", - Protocol: "tcp", - Concurrency: 1, - Timeout: 30, - DisableProbing: false, - Verbose: false, - } + exePath = flag.String("exe", "../magellan", "path to 'magellan' binary executable") + emuPath = flag.String("emu", "./emulator/setup.sh", "path to emulator 'setup.sh' script") ) func TestScanAndCollect(t *testing.T) { - // do a scan on the emulator cluster with probing disabled and check results - results := magellan.ScanForAssets(scanParams) - if len(results) <= 0 { - t.Fatal("expected to find at least one BMC node, but found none") - } - // do a scan on the emulator cluster with probing enabled - results = magellan.ScanForAssets(scanParams) - if len(results) <= 0 { - t.Fatal("expected to find at least one BMC node, but found none") + var ( + err error + // tempDir = t.TempDir() + path string + command []string + cwd string + cmd *exec.Cmd + bufout bytes.Buffer + buferr bytes.Buffer + ) + + // set up the emulator to run before test + err = waitUntilEmulatorIsReady() + if err != nil { + t.Fatalf("failed while waiting for emulator: %v", err) } - // do a collect on the emulator cluster to collect Redfish info - err := magellan.CollectInventory(&results, &magellan.CollectParams{}) + // get the current working directory and print + cwd, err = os.Getwd() if err != nil { - log.Error().Err(err).Msg("failed to collect inventory") + t.Fatalf("failed to get working directory: %v", err) } + fmt.Printf("cwd: %s\n", cwd) + + // path, err := exec.LookPath("dexdump") + // if err != nil { + // log.Fatal(err) + // } + + // try and run a "scan" with the emulator + // set up the emulator to run before test + path, err = filepath.Abs(*exePath) + if err != nil { + t.Fatalf("failed to get absolute path: %v", err) + } + command = strings.Split("scan https://127.0.0.1 --port 5000 --verbose", " ") + cmd = exec.Command(path, command...) + cmd.Stdout = &bufout + cmd.Stderr = &buferr + err = cmd.Run() + fmt.Printf("out:\n%s\nerr:\n%s\n", bufout.String(), buferr.String()) + if err != nil { + t.Fatalf("failed to run 'scan' command: %v", err) + } + + // make sure that the expected output is not empty + if len(buferr.Bytes()) <= 0 { + t.Fatalf("expected the 'scan' output to not be empty") + } + + // try and run a "collect" with the emulator + + command = strings.Split("collect --username root --password root_password --verbose", " ") + cmd = exec.Command(path, command...) + cmd.Stdout = &bufout + cmd.Stderr = &buferr + err = cmd.Run() + fmt.Printf("out:\n%s\nerr:\n%s\n", bufout.String(), buferr.String()) + if err != nil { + t.Fatalf("failed to run 'collect' command: %v", err) + } + + // make sure that the output is not empty + if len(bufout.Bytes()) <= 0 { + t.Fatalf("expected the 'collect' output to not be empty") + } + + // TODO: check for at least one System/EthernetInterface that we know should exist } func TestCrawlCommand(t *testing.T) { - // TODO: add test to check the crawl command's behavior + var ( + err error + command []string + cmd *exec.Cmd + bufout bytes.Buffer + buferr bytes.Buffer + path string + ) + + // set up the emulator to run before test + path, err = filepath.Abs(*exePath) + if err != nil { + t.Fatalf("failed to get absolute path: %v", err) + } + fmt.Printf("path: %s\n", path) + err = waitUntilEmulatorIsReady() + if err != nil { + t.Fatalf("failed while waiting for emulator: %v", err) + } + + // try and run a "collect" with the emulator + command = strings.Split("crawl --username root --password root_password -i https://127.0.0.1:5000", " ") + cmd = exec.Command(path, command...) + cmd.Stdout = &bufout + cmd.Stderr = &buferr + err = cmd.Run() + fmt.Printf("out:\n%s\nerr:\n%s\n", bufout.String(), buferr.String()) + if err != nil { + t.Fatalf("failed to run 'crawl' command: %v", err) + } + + // err = cmd.Wait() + // if err != nil { + // t.Fatalf("failed to call 'wait' for crawl: %v", err) + // } + + // make sure that the output is not empty + if len(bufout.Bytes()) <= 0 { + t.Fatalf("expected the 'crawl' output to not be empty") + } + } func TestListCommand(t *testing.T) { - // TODO: add test to check the list command's output + var ( + err error + cmd *exec.Cmd + ) + + // set up the emulator to run before test + err = waitUntilEmulatorIsReady() + if err != nil { + t.Fatalf("failed while waiting for emulator: %v", err) + } + + // set up temporary directory + cmd = exec.Command("bash", "-c", fmt.Sprintf("%s list", *exePath)) + err = cmd.Start() + if err != nil { + t.Fatalf("failed to run 'list' command: %v", err) + } + // NOTE: the output of `list` can be empty if no scan has been performed + } func TestUpdateCommand(t *testing.T) { // TODO: add test that does a Redfish simple update checking it success and // failure points + var ( + cmd *exec.Cmd + err error + ) + + // set up the emulator to run before test + err = waitUntilEmulatorIsReady() + if err != nil { + t.Fatalf("failed while waiting for emulator: %v", err) + } + + // set up temporary directory + cmd = exec.Command("bash", "-c", fmt.Sprintf("%s update", *exePath)) + err = cmd.Start() + if err != nil { + t.Fatalf("failed to run 'update' command: %v", err) + } + } func TestGofishFunctions(t *testing.T) { @@ -68,6 +198,115 @@ func TestGofishFunctions(t *testing.T) { // gofish's output isn't changing spontaneously and remains predictable } +// TestGenerateHosts() tests creating a collection of hosts by changing arguments +// and calling GenerateHostsWithSubnet(). func TestGenerateHosts(t *testing.T) { - // TODO: add test to generate hosts using a collection of subnets/masks + var ( + subnet = "127.0.0.1" + subnetMask = &net.IPMask{255, 255, 255, 0} + ports = []int{443} + scheme = "https" + hosts = [][]string{} + ) + t.Run("generate-hosts", func(t *testing.T) { + hosts = magellan.GenerateHostsWithSubnet(subnet, subnetMask, ports, scheme) + + // check for at least one host to be generated + if len(hosts) <= 0 { + t.Fatalf("expected at least one host to be generated for subnet %s", subnet) + } + }) + + t.Run("generate-hosts-with-multiple-ports", func(t *testing.T) { + ports = []int{443, 5000} + hosts = magellan.GenerateHostsWithSubnet(subnet, subnetMask, ports, scheme) + + // check for at least one host to be generated + if len(hosts) <= 0 { + t.Fatalf("expected at least one host to be generated for subnet %s", subnet) + } + }) + + t.Run("generate-hosts-with-subnet-mask", func(t *testing.T) { + subnetMask = &net.IPMask{255, 255, 0, 0} + hosts = magellan.GenerateHostsWithSubnet(subnet, subnetMask, ports, scheme) + + // check for at least one host to be generated + if len(hosts) <= 0 { + t.Fatalf("expected at least one host to be generated for subnet %s", subnet) + } + }) + +} + +func startEmulatorInBackground(path string) (int, error) { + // try and start the emulator in the background if arg passed + var ( + cmd *exec.Cmd + err error + ) + if path != "" { + cmd = exec.Command("bash", "-c", path) + err = cmd.Start() + if err != nil { + return -1, fmt.Errorf("failed while executing emulator startup script: %v", err) + } + } else { + return -1, fmt.Errorf("path to emulator start up script is required") + } + return cmd.Process.Pid, nil +} + +// waitUntilEmulatorIsReady() polls with +func waitUntilEmulatorIsReady() error { + var ( + interval = time.Second * 2 + timeout = time.Second * 6 + testClient = &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + }, + } + body client.HTTPBody + header client.HTTPHeader + err error + ) + err = util.CheckUntil(interval, timeout, func() (bool, error) { + // send request to host until we get expected response + res, _, err := client.MakeRequest(testClient, "https://127.0.0.1:5000/redfish/v1/", http.MethodGet, body, header) + if err != nil { + return false, fmt.Errorf("failed to make request to emulator: %w", err) + } + if res == nil { + return false, fmt.Errorf("invalid response from emulator (response is nil)") + } + if res.StatusCode == http.StatusOK { + return true, nil + } else { + return false, fmt.Errorf("unexpected status code %d", res.StatusCode) + } + + }) + return err +} + +func init() { + var ( + cwd string + err error + ) + // get the current working directory + cwd, err = os.Getwd() + if err != nil { + log.Error().Err(err).Msg("failed to get working directory") + } + fmt.Printf("cwd: %s\n", cwd) + + // start emulator in the background before running tests + pid, err := startEmulatorInBackground(*emuPath) + if err != nil { + log.Error().Err(err).Msg("failed to start emulator in background") + os.Exit(1) + } + _ = pid } diff --git a/tests/compatibility_test.go b/tests/compatibility_test.go index 86e3a4f..ce2e876 100644 --- a/tests/compatibility_test.go +++ b/tests/compatibility_test.go @@ -7,6 +7,7 @@ package tests import ( + "crypto/tls" "encoding/json" "flag" "fmt" @@ -15,56 +16,97 @@ import ( "github.com/OpenCHAMI/magellan/pkg/client" "github.com/OpenCHAMI/magellan/pkg/crawler" - "github.com/rs/zerolog/log" ) var ( - host = flag.String("host", "localhost", "set the BMC host") - username = flag.String("username", "", "set the BMC username used for the tests") - password = flag.String("password", "", "set the BMC password used for the tests") + host = flag.String("host", "https://127.0.0.1:5000", "set the BMC host") + username = flag.String("username", "root", "set the BMC username used for the tests") + password = flag.String("password", "root_password", "set the BMC password used for the tests") ) -// Simple test to fetch the base Redfish URL and assert a 200 OK response. -func TestRedfishV1Availability(t *testing.T) { - var ( - url = fmt.Sprintf("%s/redfish/v1", *host) - body = []byte{} - headers = map[string]string{} - ) - res, b, err := client.MakeRequest(nil, url, http.MethodGet, body, headers) - if err != nil { - t.Fatalf("failed to make request to BMC: %v", err) - } - +func checkResponse(res *http.Response, b []byte) error { // test for a 200 response code here if res.StatusCode != http.StatusOK { - t.Fatalf("expected response code to return status code 200") + return fmt.Errorf("expected response code to return status code 200") } // make sure the response body is not empty if len(b) <= 0 { - t.Fatalf("expected response body to not be empty") + return fmt.Errorf("expected response body to not be empty") } - // make sure the response body is in a JSON format - if json.Valid(b) { - t.Fatalf("expected response body to be valid JSON") + // make sure the response body is in a valid JSON format + if !json.Valid(b) { + return fmt.Errorf("expected response body to be valid JSON") + } + return nil +} + +// Simple test to fetch the base Redfish URL and assert a 200 OK response. +func TestRedfishV1ServiceRootAvailability(t *testing.T) { + var ( + url = fmt.Sprintf("%s/redfish/v1/", *host) + body = []byte{} + headers = map[string]string{} + testClient = &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + }, + } + err error + ) + + // set up the emulator to run before test + err = waitUntilEmulatorIsReady() + if err != nil { + t.Fatalf("failed while waiting for emulator: %v", err) + } + + res, b, err := client.MakeRequest(testClient, url, http.MethodGet, body, headers) + if err != nil { + t.Fatalf("failed to make request to BMC node: %v", err) + } + + err = checkResponse(res, b) + if err != nil { + t.Fatalf("failed to check response for redfish service root: %v", err) } } // Simple test to ensure an expected Redfish version minimum requirement. -func TestRedfishVersion(t *testing.T) { +func TestRedfishV1Version(t *testing.T) { var ( - url string = fmt.Sprintf("%s/redfish/v1", *host) - body client.HTTPBody = []byte{} - headers client.HTTPHeader = map[string]string{} - err error + url string = fmt.Sprintf("%s/redfish/v1/", *host) + body client.HTTPBody = []byte{} + headers client.HTTPHeader = map[string]string{} + testClient = &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + }, + } + root map[string]any + err error ) - _, _, err = client.MakeRequest(nil, url, http.MethodGet, body, headers) + res, b, err := client.MakeRequest(testClient, url, http.MethodGet, body, headers) if err != nil { - log.Error().Err(err).Msg("failed to make request") + t.Fatalf("failed to make request to BMC node: %v", err) + } + err = checkResponse(res, b) + if err != nil { + t.Fatalf("failed to check response for redfish version: %v", err) + } + + // check the "RedfishVersion" from service root + err = json.Unmarshal(b, &root) + if err != nil { + t.Fatalf("failed to unmarshal redfish response: %v", err) + } + + _, ok := root["RedfishVersion"] + if !ok { + t.Fatalf("failed to get 'RedfishVersion' from service root") } } @@ -72,13 +114,19 @@ func TestRedfishVersion(t *testing.T) { // that we need for Magellan to run correctly. This test differs from the // `TestCrawlCommand` testing function as it is not checking specifically // for functionality. -func TestExpectedProperties(t *testing.T) { +func TestExpectedOutput(t *testing.T) { // make sure what have a valid host if host == nil { t.Fatal("invalid host (host is nil)") } - systems, err := crawler.CrawlBMC( + // set up the emulator to run before test + err := waitUntilEmulatorIsReady() + if err != nil { + t.Fatalf("failed while waiting for emulator: %v", err) + } + + systems, err := crawler.CrawlBMCForSystems( crawler.CrawlerConfig{ URI: *host, Username: *username, @@ -106,8 +154,5 @@ func TestExpectedProperties(t *testing.T) { if len(system.EthernetInterfaces) <= 0 { t.Errorf("no ethernet interfaces found for system '%s'", system.Name) } - if len(system.NetworkInterfaces) <= 0 { - t.Errorf("no network interfaces found for system '%s'", system.Name) - } } }