diff --git a/.editorconfig b/.editorconfig index 2d5a9b7..78b3468 100644 --- a/.editorconfig +++ b/.editorconfig @@ -7,3 +7,10 @@ indent_size = 2 end_of_line = lf #insert_final_newline = true trim_trailing_whitespace = true + +# gofmt mandates tabs; the default above would fight it. +[*.go] +indent_style = tab + +[Makefile] +indent_style = tab diff --git a/.github/scripts/publish-packages.sh b/.github/scripts/publish-packages.sh deleted file mode 100755 index 86608aa..0000000 --- a/.github/scripts/publish-packages.sh +++ /dev/null @@ -1,91 +0,0 @@ -#!/usr/bin/env bash -# Renders the Homebrew formula and Scoop manifest for a release. -# -# Usage: publish-packages.sh -set -euo pipefail - -tag=$1 -checksums=$2 -tap_dir=$3 -bucket_dir=$4 - -version=${tag#v} -base_url="https://github.com/rackerlabs/understackctl/releases/download/${tag}" - -sha() { - local sum - sum=$(awk -v f="understackctl_$1" '$2 == f { print $1 }' "$checksums") - if [ -z "$sum" ]; then - echo "no checksum found for understackctl_$1" >&2 - exit 1 - fi - echo "$sum" -} - -mkdir -p "$tap_dir/Formula" "$bucket_dir/bucket" - -cat > "$tap_dir/Formula/understackctl.rb" < "$bucket_dir/bucket/understackctl.json" < v2" + args: check diff --git a/.github/workflows/go-test.yaml b/.github/workflows/go-test.yaml index 5a080c6..c287d14 100644 --- a/.github/workflows/go-test.yaml +++ b/.github/workflows/go-test.yaml @@ -12,8 +12,15 @@ permissions: contents: read jobs: + # Runs on every platform we publish a binary for. This tool does a lot of + # path manipulation and shells out to kubeseal/argo/helm/git, which is + # exactly where cross-platform bugs hide. test: - runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 513054d..3cf5e00 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -2,7 +2,10 @@ name: Releases on: push: tags: + # Matches v0.1.0 and pre-releases such as v0.1.0-rc.1. GoReleaser's + # `prerelease: auto` keeps the latter from being marked "latest". - "v[0-9]+.[0-9]+.[0-9]+" + - "v[0-9]+.[0-9]+.[0-9]+-*" permissions: contents: read @@ -12,49 +15,27 @@ jobs: runs-on: ubuntu-latest permissions: contents: write + # Required by actions/attest: id-token to mint the Sigstore OIDC token, + # attestations to persist the result. artifact-metadata is not needed + # because we do not push to a registry, so no storage record is created. id-token: write - packages: write + attestations: write steps: - name: Checkout uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: + # GoReleaser needs the full history and tags to build the changelog. fetch-depth: 0 - name: Setup Go uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: - go-version-file: 'go.mod' - cache-dependency-path: 'go.sum' - cache: true - - - name: Install dependencies - run: sudo apt-get install -y sed grep - - - name: Build and Package - run: | - make build-all package-all checksums - - - name: Upload release artifacts - uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 - with: - make_latest: true - generate_release_notes: true - files: | - build/*.zip - build/*.tar.gz - build/checksums.txt - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - publish-packages: - needs: goreleaser - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - name: Checkout - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + go-version-file: go.mod + cache-dependency-path: go.sum + # The default GITHUB_TOKEN cannot push to other repositories, so mint a + # short-lived token from the publisher GitHub App, scoped to the Homebrew + # tap and Scoop bucket only. - name: Create publisher token id: app-token uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 @@ -66,34 +47,21 @@ jobs: homebrew-tap scoop-bucket - - name: Checkout Homebrew tap - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - with: - repository: rackerlabs/homebrew-tap - token: ${{ steps.app-token.outputs.token }} - path: homebrew-tap - - - name: Checkout Scoop bucket - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3 with: - repository: rackerlabs/scoop-bucket - token: ${{ steps.app-token.outputs.token }} - path: scoop-bucket - - - name: Download checksums - run: gh release download "$GITHUB_REF_NAME" --pattern checksums.txt + version: "~> v2" + args: release --clean env: - GH_TOKEN: ${{ github.token }} - - - name: Render formula and manifest - run: .github/scripts/publish-packages.sh "$GITHUB_REF_NAME" checksums.txt homebrew-tap scoop-bucket + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + HOMEBREW_TAP_TOKEN: ${{ steps.app-token.outputs.token }} + SCOOP_BUCKET_TOKEN: ${{ steps.app-token.outputs.token }} - - name: Push to tap and bucket - run: | - for repo in homebrew-tap scoop-bucket; do - git -C "$repo" config user.name "rackerlabs-package-publisher[bot]" - git -C "$repo" config user.email "noreply@github.com" - git -C "$repo" add -A - git -C "$repo" commit -m "understackctl ${GITHUB_REF_NAME}" - git -C "$repo" push - done + # Generates SLSA build provenance for every artifact listed in + # checksums.txt, so anyone can confirm an archive was built by this + # workflow from this commit: + # gh attestation verify --repo rackerlabs/understackctl + - name: Attest build provenance + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-checksums: dist/checksums.txt diff --git a/.gitignore b/.gitignore index ef62700..061b3ca 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,9 @@ vendor/ dist/ build/ +# generated by scripts/completions.sh for release archives +completions/ + # IntelliJ .idea *.iml diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..ad6457c --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,170 @@ +# yaml-language-server: $schema=https://goreleaser.com/static/schema.json +# +# Release configuration for understackctl. +# +# Local use: +# goreleaser check validate this file +# goreleaser build --snapshot --clean cross-compile without releasing +# goreleaser release --snapshot --clean build archives + checksums locally +# +# Releases are cut by .github/workflows/release.yaml on a v* tag. +version: 2 + +project_name: understackctl + +before: + hooks: + - go mod download + # Generates completions/ for inclusion in the archives. + - sh ./scripts/completions.sh + +builds: + - id: understackctl + binary: understackctl + main: . + env: + - CGO_ENABLED=0 + flags: + # -trimpath keeps absolute build paths out of the binary, which is what + # makes the output reproducible. + - -trimpath + ldflags: + - -s -w + - -X github.com/rackerlabs/understackctl/internal/version.version={{ .Version }} + - -X github.com/rackerlabs/understackctl/internal/version.commit={{ .ShortCommit }} + - -X github.com/rackerlabs/understackctl/internal/version.date={{ .CommitDate }} + goos: + - linux + - darwin + - windows + goarch: + - amd64 + - arm64 + # Pin file timestamps to the commit so repeated builds of the same tag + # produce byte-identical archives. + mod_timestamp: "{{ .CommitTimestamp }}" + +# A single darwin_all artifact that runs on both Intel and Apple Silicon, so a +# macOS user who is unsure which they have cannot pick wrong. replace: false +# keeps the smaller per-arch archives too; Homebrew uses those. +universal_binaries: + - id: understackctl-universal + ids: + - understackctl + name_template: understackctl + replace: false + +archives: + - id: archives + ids: + - understackctl + - understackctl-universal + # No wrap_in_directory: the binary sits at the archive root, so + # `tar xzf understackctl_*.tar.gz` yields a runnable binary rather than a + # directory to go hunting through. + name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + formats: + - tar.gz + format_overrides: + - goos: windows + formats: + - zip + files: + - LICENSE + - README.md + - src: completions/* + dst: completions + +checksum: + name_template: checksums.txt + algorithm: sha256 + +snapshot: + version_template: "{{ incpatch .Version }}-snapshot-{{ .ShortCommit }}" + +changelog: + use: github + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" + - "^chore:" + - "^ci:" + - "^style:" + - Merge pull request + - Merge branch + groups: + - title: Features + regexp: '^.*?feat(\([[:word:]]+\))??!?:.+$' + order: 0 + - title: Bug fixes + regexp: '^.*?fix(\([[:word:]]+\))??!?:.+$' + order: 1 + - title: Dependency updates + regexp: '^.*?(deps|build)(\([[:word:]]+\))??!?:.+$' + order: 2 + - title: Other work + order: 999 + +release: + github: + owner: rackerlabs + name: understackctl + # A -rc/-beta tag is published as a pre-release and, because of that, is not + # marked "latest" even though make_latest is true. + prerelease: auto + make_latest: true + footer: | + ## Installing + + Download the archive for your platform below and move `understackctl` + onto your `PATH`. Verify what you downloaded first: + + ```bash + sha256sum --check --ignore-missing checksums.txt + gh attestation verify understackctl_*_linux_amd64.tar.gz --repo rackerlabs/understackctl + ``` + + See the [installation guide](https://github.com/rackerlabs/understackctl#installation) + for Homebrew, Scoop, `go install` and shell completion setup. + +# --- Package managers ------------------------------------------------------- +# +# Both blocks are published by the release workflow, which mints a token from +# the rackerlabs-package-publisher GitHub App (secrets PUBLISHER_APP_ID and +# PUBLISHER_APP_PRIVATE_KEY) scoped to rackerlabs/homebrew-tap and +# rackerlabs/scoop-bucket, and passes it in as HOMEBREW_TAP_TOKEN and +# SCOOP_BUCKET_TOKEN. The default GITHUB_TOKEN cannot push to another +# repository. Set skip_upload: true on a block to build its artifact without +# pushing it, e.g. while testing. + +homebrew_casks: + - name: understackctl + skip_upload: auto + repository: + owner: rackerlabs + name: homebrew-tap + token: "{{ if index .Env \"HOMEBREW_TAP_TOKEN\" }}{{ .Env.HOMEBREW_TAP_TOKEN }}{{ end }}" + homepage: https://github.com/rackerlabs/understackctl + description: CLI tool for managing UnderStack deployments + license: apache-2.0 + completions: + bash: completions/understackctl.bash + zsh: completions/understackctl.zsh + fish: completions/understackctl.fish + hooks: + post: + install_steps: | + run "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "{{ .StagedPath }}/understackctl"] + +scoops: + - name: understackctl + skip_upload: auto + repository: + owner: rackerlabs + name: scoop-bucket + token: "{{ if index .Env \"SCOOP_BUCKET_TOKEN\" }}{{ .Env.SCOOP_BUCKET_TOKEN }}{{ end }}" + homepage: https://github.com/rackerlabs/understackctl + description: CLI tool for managing UnderStack deployments + license: Apache-2.0 diff --git a/Makefile b/Makefile index bec4f7a..a4ce5be 100644 --- a/Makefile +++ b/Makefile @@ -1,85 +1,75 @@ -# Makefile will understackctl go binary for multiple GOOS and GOARCH +# Developer convenience targets. # -# Output will be like this: -# understackctl_darwin_amd64/ -# └── understackctl -# understackctl_windows_amd64/ -# └── understackctl.exe -# understackctl_darwin_amd64.tar.gz -# understackctl_windows_amd64.zip -# -# package-all: will package folders into .tar.gz and for windows .zip +# Release artifacts are built by GoReleaser, not by this file -- see +# .goreleaser.yaml and .github/workflows/release.yaml. `make snapshot` +# reproduces a full release locally without publishing anything. -BINARY_NAME=understackctl +BINARY_NAME := understackctl +BUILD_DIR := build +GORELEASER ?= goreleaser -GOOS_LIST=linux darwin windows -GOARCH_LIST=386 amd64 arm64 +VERSION_PKG := github.com/rackerlabs/understackctl/internal/version +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) +COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) +DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ) -BUILD_DIR=build +LDFLAGS := -s -w \ + -X $(VERSION_PKG).version=$(VERSION) \ + -X $(VERSION_PKG).commit=$(COMMIT) \ + -X $(VERSION_PKG).date=$(DATE) -VERSION := $(shell git describe --tags --abbrev=0 --match "v[0-9]*.[0-9]*.[0-9]*" 2>/dev/null || echo "dev") -COMMIT := $(shell git rev-parse --short HEAD) -LDFLAGS := -ldflags="-s -w -X 'main.version=$(VERSION)' -X 'main.commit=$(COMMIT)'" +.DEFAULT_GOAL := help -.PHONY: all -all: build build-all package-all +.PHONY: help +help: ## Show this help + @grep -hE '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) \ + | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-14s\033[0m %s\n", $$1, $$2}' .PHONY: build -build: - @echo "Building for current OS/Arch..." - @mkdir -p $(BUILD_DIR)/$(BINARY_NAME) - CGO_ENABLED=0 GOOS=$(shell go env GOOS) GOARCH=$(shell go env GOARCH) go build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)/$(BINARY_NAME) . - -.PHONY: build-all -build-all: +build: ## Build for the current platform into build/ @mkdir -p $(BUILD_DIR) - @for GOOS in $(GOOS_LIST); do \ - for GOARCH in $(GOARCH_LIST); do \ - if [ "$$GOOS" = "darwin" ] && [ "$$GOARCH" = "386" ]; then \ - continue; \ - fi; \ - DIR=$(BUILD_DIR)/$(BINARY_NAME)_$$GOOS\_$$GOARCH; \ - EXT=$$([ "$$GOOS" = "windows" ] && echo ".exe" || echo ""); \ - OUTFILE=$$DIR/$(BINARY_NAME)$$EXT; \ - mkdir -p $$DIR; \ - echo "Building $$OUTFILE..."; \ - CGO_ENABLED=0 GOOS=$$GOOS GOARCH=$$GOARCH go build $(LDFLAGS) -o $$OUTFILE . || echo "Failed to build $$GOOS/$$GOARCH"; \ - done \ - done - -# Loops over all the dirs in build/ folder -# for windows build zip -# else use .tar.gz -.PHONY: package-all -package-all: - @echo "Packaging builds..." - @cd $(BUILD_DIR) && for d in $(BINARY_NAME)_*; do \ - if echo $$d | grep -q "windows"; then \ - zip -qr "$$d.zip" "$$d"; \ - else \ - tar -czf "$$d.tar.gz" "$$d"; \ - fi \ - done - - -# Loops over *.zip and *.tar.gz in build/ -# Uses sha256sum if available (Linux) -# Falls back to shasum -a 256 on macOS -# Outputs clean filenames without ./ -# Result is stored in build/checksums.txt -.PHONY: checksums -checksums: - @echo "Generating checksums..." - @cd $(BUILD_DIR) && \ - for f in *.zip *.tar.gz; do \ - if command -v sha256sum >/dev/null 2>&1; then \ - sha256sum "$$f"; \ - else \ - shasum -a 256 "$$f"; \ - fi; \ - done | sort > checksums.txt - -# remove build dir + CGO_ENABLED=0 go build -trimpath -ldflags '$(LDFLAGS)' -o $(BUILD_DIR)/$(BINARY_NAME) . + @echo "built $(BUILD_DIR)/$(BINARY_NAME) ($(VERSION))" + +.PHONY: install +install: ## Install into $$GOBIN (or $$GOPATH/bin) + CGO_ENABLED=0 go install -trimpath -ldflags '$(LDFLAGS)' . + +.PHONY: test +test: ## Run tests + go test ./... + +.PHONY: lint +lint: ## Run golangci-lint + golangci-lint run + +.PHONY: fmt +fmt: ## Format all Go sources + gofmt -w -l . + +.PHONY: fmt-check +fmt-check: ## Fail if any Go source is not gofmt-clean + @out=$$(gofmt -l .); \ + if [ -n "$$out" ]; then \ + echo "not gofmt-clean:"; echo "$$out"; exit 1; \ + fi + +.PHONY: tidy +tidy: ## Tidy go.mod / go.sum + go mod tidy + +.PHONY: completions +completions: ## Generate shell completions into completions/ + sh ./scripts/completions.sh + +.PHONY: check +check: ## Validate .goreleaser.yaml + $(GORELEASER) check + +.PHONY: snapshot +snapshot: ## Build all release artifacts locally into dist/ (no publishing) + $(GORELEASER) release --snapshot --clean --skip=validate + .PHONY: clean -clean: - rm -rf $(BUILD_DIR) +clean: ## Remove build output + rm -rf $(BUILD_DIR) dist completions diff --git a/README.md b/README.md index 5f308c2..333d92a 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,166 @@ # understackctl -`understackctl` is the CLI tool for managing UnderStack deployments. +`understackctl` is the CLI tool for managing [UnderStack](https://github.com/rackerlabs/understack) +deployments. It handles deployment repository scaffolding, secret generation, +hardware definitions and node lifecycle workflows. -For installation, usage, and full command reference, see the [documentation](https://rackerlabs.github.io/understack/operator-guide/understackctl/). +For the full command reference, see the +[documentation](https://rackerlabs.github.io/understack/operator-guide/understackctl/). + +## Installation + +Every release publishes binaries for Linux, macOS and Windows on amd64 and +arm64. Pick whichever method suits you. + +### macOS — Homebrew + +```bash +brew install rackerlabs/tap/understackctl +``` + +### Windows — Scoop + +```powershell +scoop bucket add rackerlabs https://github.com/rackerlabs/scoop-bucket +scoop install understackctl +``` + +### Manual download + +Grab the archive for your platform from the +[releases page](https://github.com/rackerlabs/understackctl/releases): + +| Platform | Archive | +|---|---| +| Linux | `understackctl__linux_{amd64,arm64}.tar.gz` | +| macOS | `understackctl__darwin_all.tar.gz` (runs on both Intel and Apple Silicon) | +| Windows | `understackctl__windows_{amd64,arm64}.zip` | + +The binary sits at the root of the archive, alongside `LICENSE`, `README.md` +and a `completions/` directory. + +```bash +tar xzf understackctl_*.tar.gz +sudo mv understackctl /usr/local/bin/ +``` + +Verify what you downloaded. Checksums: + +```bash +sha256sum --check --ignore-missing checksums.txt +``` + +Every archive also carries a GitHub build provenance attestation, which proves +it was built by this repository's release workflow from a specific commit: + +```bash +gh attestation verify understackctl_0.1.0_linux_amd64.tar.gz --repo rackerlabs/understackctl +``` + +### go install + +Requires Go 1.24+. + +```bash +go install github.com/rackerlabs/understackctl@latest +``` + +This puts the binary in `$GOPATH/bin` (`$HOME/go/bin` by default); make sure +that directory is on your `PATH`. + +### From source + +```bash +git clone https://github.com/rackerlabs/understackctl.git +cd understackctl +make build # -> build/understackctl +make install # -> $GOBIN +``` + +## Verifying your install + +```console +$ understackctl version +Version: v0.1.0 +Commit: 630c1e9 +Built: 2026-09-06T03:29:01Z +Go version: go1.24.7 +Platform: linux/amd64 +``` + +Include this output when reporting a bug. `understackctl version -o json` emits +the same information as JSON. + +## Shell completion + +Release archives include pre-generated completions under `completions/`, or +generate them yourself: + +```bash +# bash (current shell) +source <(understackctl completion bash) +# bash (permanent, Linux) +understackctl completion bash | sudo tee /etc/bash_completion.d/understackctl + +# zsh — ensure `autoload -U compinit && compinit` is in your ~/.zshrc first +understackctl completion zsh > "${fpath[1]}/_understackctl" + +# fish +understackctl completion fish > ~/.config/fish/completions/understackctl.fish + +# PowerShell — append to your profile to make it permanent +understackctl completion powershell | Out-String | Invoke-Expression +``` + +## External dependencies + +`understackctl` shells out to other tools. You only need the ones used by the +commands you run: + +| Tool | Needed by | +|---|---| +| `kubeseal` | all secret-generating commands, `quickstart` | +| `helm` (3.8+) | `deploy render` | +| `git` | `deploy init`, `deploy render` | +| `argo` | `node enroll-server`, `node inspect-server` | +| `kubectl` / a valid kubeconfig | anything that talks to a cluster | + +## Development + +```bash +make help # list targets +make test # go test ./... +make lint # golangci-lint +make fmt-check # fail if not gofmt-clean +make snapshot # build all release artifacts into dist/, publishing nothing +make check # validate .goreleaser.yaml +``` + +`make snapshot` and `make check` need +[GoReleaser](https://goreleaser.com/install/) on your `PATH`. `make check` also +runs in CI on every pull request. Run `make snapshot` yourself before tagging +if you have changed the release configuration. + +### Releasing + +Releases are cut by pushing a tag; everything else is automated by +[`.github/workflows/release.yaml`](.github/workflows/release.yaml). + +```bash +git tag -a v0.1.0 -m "v0.1.0" +git push origin v0.1.0 +``` + +Pre-release tags (`v0.1.0-rc.1`) are published as GitHub pre-releases and are +not marked "latest", so they can be tested before a real release. + +## Contributing + +Please open an issue or pull request on +[GitHub](https://github.com/rackerlabs/understackctl). Commit messages follow +[Conventional Commits](https://www.conventionalcommits.org/) — the release +changelog is generated from them. + +## License + +Apache 2.0. See [LICENSE](LICENSE). diff --git a/cmd/root/root.go b/cmd/root/root.go index ace6856..c539d88 100644 --- a/cmd/root/root.go +++ b/cmd/root/root.go @@ -15,13 +15,16 @@ import ( "github.com/rackerlabs/understackctl/cmd/openstack" "github.com/rackerlabs/understackctl/cmd/other" "github.com/rackerlabs/understackctl/cmd/quickstart" + cmdversion "github.com/rackerlabs/understackctl/cmd/version" + "github.com/rackerlabs/understackctl/internal/version" "github.com/spf13/cobra" ) var rootCmd = &cobra.Command{ - Use: "understackctl SUBCOMMAND ...", - Short: "UnderStack CLI", - Long: ``, + Use: "understackctl SUBCOMMAND ...", + Short: "UnderStack CLI", + Long: ``, + Version: version.Get().String(), RunE: func(cmd *cobra.Command, args []string) error { // If no subcommand, show help return fmt.Errorf("a subcommand is required") @@ -41,11 +44,7 @@ func init() { rootCmd.AddCommand(openstack.NewCmdOpenstackSecrets()) rootCmd.AddCommand(quickstart.NewCmdQuickStart()) rootCmd.AddCommand(other.NewCmdOtherSecrets()) -} - -// SetVersion sets the version reported by `understackctl --version`. -func SetVersion(version, commit string) { - rootCmd.Version = fmt.Sprintf("%s (%s)", version, commit) + rootCmd.AddCommand(cmdversion.NewCmdVersion()) } // Execute will execute the root command diff --git a/cmd/version/version.go b/cmd/version/version.go new file mode 100644 index 0000000..f51c516 --- /dev/null +++ b/cmd/version/version.go @@ -0,0 +1,49 @@ +package version + +import ( + "encoding/json" + "fmt" + + "github.com/rackerlabs/understackctl/internal/version" + "github.com/spf13/cobra" +) + +// NewCmdVersion returns the "version" command. +func NewCmdVersion() *cobra.Command { + var output string + + cmd := &cobra.Command{ + Use: "version", + Short: "Print version, commit and build information", + Long: `Print the version of understackctl along with the commit it was built +from, the build date, and the Go toolchain and platform it was built for. + +Include this output when reporting a bug.`, + Args: cobra.NoArgs, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + info := version.Get() + + switch output { + case "text": + if _, err := fmt.Fprint(cmd.OutOrStdout(), info.Multiline()); err != nil { + return fmt.Errorf("failed to write version info: %w", err) + } + case "json": + enc := json.NewEncoder(cmd.OutOrStdout()) + enc.SetIndent("", " ") + if err := enc.Encode(info); err != nil { + return fmt.Errorf("failed to encode version info: %w", err) + } + default: + return fmt.Errorf("unsupported output format %q: want text or json", output) + } + + return nil + }, + } + + cmd.Flags().StringVarP(&output, "output", "o", "text", "Output format: text or json") + + return cmd +} diff --git a/helpers/kubeseal.go b/helpers/kubeseal.go index acec4f9..e076eb4 100644 --- a/helpers/kubeseal.go +++ b/helpers/kubeseal.go @@ -55,7 +55,7 @@ func KubeSeal(inputData []byte, outputPath string) error { } func removeTempFile(file *os.File) { - if err := os.Remove(file.Name()); err != nil { - log.Printf("Failed to remove temporary file: %v", err) - } + if err := os.Remove(file.Name()); err != nil { + log.Printf("Failed to remove temporary file: %v", err) + } } diff --git a/helpers/kustomization.go b/helpers/kustomization.go index 9de3e24..3bfd06b 100644 --- a/helpers/kustomization.go +++ b/helpers/kustomization.go @@ -39,15 +39,15 @@ func UpdateKustomizeFile(dir string) { func scanYamlFiles(dir string) ([]string, error) { fileSet := make(map[string]bool) - err := fsutil.FindInDir(dir, func(filePath string, de fs.DirEntry) error { + err := fsutil.FindInDir(dir, func(filePath string, de fs.DirEntry) error { fileSet[de.Name()] = true return nil }, fsutil.IncludeSuffix(".yaml", ".yml"), fsutil.ExcludeDotFile, fsutil.ExcludeNames(kustomizationFile)) - if err != nil { - return nil, err - } + if err != nil { + return nil, err + } var uniqueFiles []string for f := range fileSet { diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..8ca2098 --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,134 @@ +// Package version reports the build version of understackctl. +// +// Release builds have these values stamped in by the linker (see +// .goreleaser.yaml). Builds produced any other way -- `go install +// github.com/rackerlabs/understackctl@v0.1.0`, or `go build` inside a +// checkout -- fall back to the module and VCS metadata that the Go toolchain +// records in the binary, so that `understackctl version` is useful in a bug +// report no matter how the binary was obtained. +package version + +import ( + "fmt" + "regexp" + "runtime" + "runtime/debug" + "strings" +) + +// Set via -ldflags "-X github.com/rackerlabs/understackctl/internal/version.=...". +var ( + version string + commit string + date string +) + +const unknown = "unknown" + +// Info describes the running binary. +type Info struct { + Version string `json:"version"` + Commit string `json:"commit"` + Date string `json:"date"` + GoVersion string `json:"goVersion"` + Platform string `json:"platform"` +} + +// Get resolves build information, preferring linker-stamped values and +// falling back to the toolchain's embedded build info. +func Get() Info { + info := Info{ + Version: version, + Commit: commit, + Date: date, + GoVersion: runtime.Version(), + Platform: runtime.GOOS + "/" + runtime.GOARCH, + } + + if bi, ok := debug.ReadBuildInfo(); ok { + info.fillFromBuildInfo(bi) + } + + if info.Version == "" { + info.Version = "dev" + } + if info.Commit == "" { + info.Commit = unknown + } + if info.Date == "" { + info.Date = unknown + } + info.Version = normalize(info.Version) + + return info +} + +// semverish matches a leading MAJOR.MINOR, which is enough to tell a version +// from the bare commit SHA that `git describe --always` produces when the +// repository has no tags. +var semverish = regexp.MustCompile(`^[0-9]+\.[0-9]+`) + +// normalize adds the "v" prefix that GoReleaser's .Version template strips, so +// that a release build and `go install ...@v0.1.0` report the same string. +// Anything that is not recognisably a version -- "dev", a commit SHA -- is +// left alone. +func normalize(v string) string { + if semverish.MatchString(v) { + return "v" + v + } + return v +} + +// fillFromBuildInfo populates any field that the linker did not set. +func (i *Info) fillFromBuildInfo(bi *debug.BuildInfo) { + // bi.Main.Version is the module version for `go install pkg@version`, and + // "(devel)" for a build from a local checkout. Only the former is useful. + if i.Version == "" && bi.Main.Version != "" && bi.Main.Version != "(devel)" { + i.Version = bi.Main.Version + } + + var revision, modified string + for _, setting := range bi.Settings { + switch setting.Key { + case "vcs.revision": + revision = setting.Value + case "vcs.time": + if i.Date == "" { + i.Date = setting.Value + } + case "vcs.modified": + modified = setting.Value + } + } + + if i.Commit == "" && revision != "" { + i.Commit = shortCommit(revision) + if modified == "true" { + i.Commit += "-dirty" + } + } +} + +func shortCommit(revision string) string { + if len(revision) > 7 { + return revision[:7] + } + return revision +} + +// String renders the one-line form used by `understackctl --version`. +func (i Info) String() string { + return fmt.Sprintf("%s (commit %s, built %s, %s %s)", + i.Version, i.Commit, i.Date, i.GoVersion, i.Platform) +} + +// Multiline renders the block form used by `understackctl version`. +func (i Info) Multiline() string { + var sb strings.Builder + fmt.Fprintf(&sb, "Version: %s\n", i.Version) + fmt.Fprintf(&sb, "Commit: %s\n", i.Commit) + fmt.Fprintf(&sb, "Built: %s\n", i.Date) + fmt.Fprintf(&sb, "Go version: %s\n", i.GoVersion) + fmt.Fprintf(&sb, "Platform: %s\n", i.Platform) + return sb.String() +} diff --git a/internal/version/version_test.go b/internal/version/version_test.go new file mode 100644 index 0000000..af7eb52 --- /dev/null +++ b/internal/version/version_test.go @@ -0,0 +1,119 @@ +package version + +import ( + "runtime/debug" + "testing" +) + +func TestNormalize(t *testing.T) { + tests := map[string]string{ + "0.1.0": "v0.1.0", + "v0.1.0": "v0.1.0", + "0.1.0-rc.1": "v0.1.0-rc.1", + "0.0.1-snapshot-630c1e9": "v0.0.1-snapshot-630c1e9", + "dev": "dev", + "": "", + "v0.0.0-20260906032901-abcd": "v0.0.0-20260906032901-abcd", + // `git describe --always` in a repo with no tags yields a bare SHA, + // which must not be dressed up as a version. + "630c1e9": "630c1e9", + "630c1e9-dirty": "630c1e9-dirty", + "1234567": "1234567", + } + + for in, want := range tests { + if got := normalize(in); got != want { + t.Errorf("normalize(%q) = %q, want %q", in, got, want) + } + } +} + +func TestGetFallsBackToDefaults(t *testing.T) { + // The test binary has no linker-stamped values, so Get must still return + // something usable for every field. + info := Get() + + if info.Version == "" { + t.Error("Version is empty") + } + if info.Commit == "" { + t.Error("Commit is empty") + } + if info.Date == "" { + t.Error("Date is empty") + } + if info.GoVersion == "" { + t.Error("GoVersion is empty") + } + if info.Platform == "" { + t.Error("Platform is empty") + } +} + +func TestFillFromBuildInfoUsesModuleVersion(t *testing.T) { + bi := &debug.BuildInfo{} + bi.Main.Version = "v0.1.0" + + info := Info{} + info.fillFromBuildInfo(bi) + + if info.Version != "v0.1.0" { + t.Errorf("Version = %q, want v0.1.0", info.Version) + } +} + +func TestFillFromBuildInfoIgnoresDevelPlaceholder(t *testing.T) { + bi := &debug.BuildInfo{} + bi.Main.Version = "(devel)" + + info := Info{} + info.fillFromBuildInfo(bi) + + if info.Version != "" { + t.Errorf("Version = %q, want empty so the caller falls back to dev", info.Version) + } +} + +func TestFillFromBuildInfoVCSSettings(t *testing.T) { + bi := &debug.BuildInfo{ + Settings: []debug.BuildSetting{ + {Key: "vcs.revision", Value: "630c1e90354d1234567890"}, + {Key: "vcs.time", Value: "2026-09-06T03:29:01Z"}, + {Key: "vcs.modified", Value: "true"}, + }, + } + + info := Info{} + info.fillFromBuildInfo(bi) + + if info.Commit != "630c1e9-dirty" { + t.Errorf("Commit = %q, want 630c1e9-dirty", info.Commit) + } + if info.Date != "2026-09-06T03:29:01Z" { + t.Errorf("Date = %q, want 2026-09-06T03:29:01Z", info.Date) + } +} + +func TestFillFromBuildInfoDoesNotOverrideLinkerValues(t *testing.T) { + bi := &debug.BuildInfo{ + Settings: []debug.BuildSetting{ + {Key: "vcs.revision", Value: "aaaaaaaaaaaa"}, + {Key: "vcs.time", Value: "2020-01-01T00:00:00Z"}, + }, + } + bi.Main.Version = "v9.9.9" + + // Simulates a release build where the linker set everything. + info := Info{Version: "0.1.0", Commit: "630c1e9", Date: "2026-09-06T03:29:01Z"} + info.fillFromBuildInfo(bi) + + if info.Version != "0.1.0" { + t.Errorf("Version = %q, want the linker value 0.1.0", info.Version) + } + if info.Commit != "630c1e9" { + t.Errorf("Commit = %q, want the linker value 630c1e9", info.Commit) + } + if info.Date != "2026-09-06T03:29:01Z" { + t.Errorf("Date = %q, want the linker value 2026-09-06T03:29:01Z", info.Date) + } +} diff --git a/main.go b/main.go index 4d4a592..73f6406 100644 --- a/main.go +++ b/main.go @@ -6,17 +6,8 @@ import ( "github.com/rackerlabs/understackctl/cmd/root" ) -// Populated at build time by the -X linker flags set in the Makefile. -var ( - version = "dev" - commit = "unknown" -) - func main() { - root.SetVersion(version, commit) - - err := root.Execute() - if err != nil { + if err := root.Execute(); err != nil { os.Exit(1) } } diff --git a/scripts/completions.sh b/scripts/completions.sh new file mode 100755 index 0000000..11cc48b --- /dev/null +++ b/scripts/completions.sh @@ -0,0 +1,11 @@ +#!/bin/sh +# Generate shell completions into completions/ for inclusion in release +# archives. Run by the GoReleaser `before` hook; safe to run by hand. +set -eu + +rm -rf completions +mkdir completions + +for sh in bash zsh fish powershell; do + go run . completion "$sh" >"completions/understackctl.$sh" +done