From 630c1e90354dfd8fb85e25e09073ce50310c0d65 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 03:29:01 +0000 Subject: [PATCH 1/4] docs: add repo-split audit for packaging and distribution Audit of understackctl as a standalone CLI, covering migration blockers, cross-platform distribution and installation, platform correctness, CLI usability, code quality and repo hygiene. Intended to be triaged into issues and then removed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Tr9ob3LqrbsZL2rpJehEr3 --- AUDIT.md | 612 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 612 insertions(+) create mode 100644 AUDIT.md diff --git a/AUDIT.md b/AUDIT.md new file mode 100644 index 0000000..ea0117e --- /dev/null +++ b/AUDIT.md @@ -0,0 +1,612 @@ +# understackctl repo-split audit + +Assessment of `rackerlabs/understackctl` as a standalone, installable CLI for +Linux, macOS and Windows users. Ordered by priority: blockers first, then +distribution, then platform correctness, then everything else. + +This file is a working document meant to be triaged into issues and deleted. + +--- + +## 1. Migration blockers + +These break the moment the code stops living inside the understack monorepo. + +### 1.1 JSON schemas are loaded by relative path out of the monorepo — `device-type` and `flavor` validation cannot work from an installed binary + +`cmd/deviceType/deviceType.go:204-224` and `cmd/flavor/flavor.go` (same block) +resolve the validation schema like this: + +```go +possiblePaths := []string{ + filepath.Join(deployPath, "..", "..", "schema", "device-type.schema.json"), + "../../schema/device-type.schema.json", + "../../../schema/device-type.schema.json", +} +``` + +All three only resolve when the process's working directory is inside a +checkout of `rackerlabs/understack` at `go/understackctl/`. A user who +downloads a release archive, or runs `go install`, gets: + +``` +could not find device-type.schema.json in expected locations +``` + +`device-type add`, `device-type validate`, `flavor add` and `flavor validate` +— four of the documented commands — are unusable for every installed user +today, on every platform. + +**Fix:** vendor `schema/device-type.schema.json` and `schema/flavor.schema.json` +into this repo and `//go:embed` them. Keep an escape hatch +(`--schema `) for testing an unreleased schema, and add a +Renovate/CI check or a small sync script so the embedded copies do not drift +from `rackerlabs/understack/schema/`. This also removes the `readSchemaFile` +`log.Fatalf` (§5.1). + +### 1.2 Stale install instructions in the understack docs + +`docs/operator-guide/understackctl.md` in the monorepo still says: + +```bash +go install github.com/rackerlabs/understack/go/understackctl@latest +git clone https://github.com/rackerlabs/understack.git && cd understack/go/understackctl +``` + +Both are wrong post-split. The new path is +`go install github.com/rackerlabs/understackctl@latest`. The "From release +binaries" link also points at understack's releases page rather than +understackctl's. + +Decide where the docs live. Two workable options: + +- **Keep docs in understack** (the README already links there): update the + install/clone/contributing sections and the release link, and accept that + a CLI change and its doc change are two PRs in two repos. +- **Move the page into this repo** and publish it (GitHub Pages or just a + good `docs/` tree), leaving a stub + link in understack. + +Recommendation: move the command reference here so it versions with the +binary, and leave the deploy-guide narrative pages (`docs/deploy-guide/*.md`, +which reference `understackctl deploy ...`) in understack pointing at it. + +### 1.3 Monorepo leftovers to clean up + +- `go/understackctl/` still exists in understack alongside this repo. Until + it is deleted, there are two sources of truth. Delete it, plus + `.github/workflows/release-understackctl.yaml`, and drop the + `go/understackctl/**` entries from `.github/labeler.yml`. +- `.github/workflows/go-test.yaml` in understack hardcodes + `apps=["understackctl"]` — after removal that job tests nothing. It needs + to be re-pointed at the remaining `go/` projects (`dexop`, `nautobotop`, + `ironic-hardware-exporter`). +- `RELEASING.md` documents the `understackctl/vX.Y.Z` tag scheme and the + path-scoped release-note trick. That section should shrink to "released + from its own repo, see there". +- `scripts/gitops-deploy.sh` references understackctl — verify it resolves + the binary from `PATH` rather than a monorepo-relative build output. + +### 1.4 Versioning decision + +The repo has **no tags**. The monorepo shipped `understackctl/v0.0.5`. Pick +deliberately and write it down: + +- Continue the series at `v0.0.6` (cheap, but signals "still pre-alpha"), or +- Restart at `v0.1.0` to mark the split. + +The release workflow's tag filter is `v[0-9]+.[0-9]+.[0-9]+`, which excludes +pre-releases. Widen it to +`v[0-9]+.[0-9]+.[0-9]+*` (or `- "v*"`) so `v0.1.0-rc.1` can be cut and +tested before a `latest` release. Note the workflow also sets +`make_latest: true` unconditionally — a pre-release tag would wrongly become +"latest". + +--- + +## 2. Distribution and installation + +This is the largest opportunity. The current Makefile + `softprops/action-gh-release` +setup works but leaves users doing manual work on all three platforms. + +### 2.1 Replace the hand-rolled Makefile release with GoReleaser + +`.gitignore` already lists `dist/` — someone intended this. The +`Makefile` reimplements, in shell, what GoReleaser does declaratively: +cross-compilation, archive format per OS, checksums. Switching gets, in one +config file: + +- **Archive layout fix.** Archives currently contain a nested directory + (`understackctl_linux_amd64/understackctl`), so `tar xzf` gives you a folder, + not a binary, and no `LICENSE` or `README` ships. GoReleaser's default puts + the binary at the archive root and includes the license. +- **`-trimpath`** for reproducible builds (currently missing, so absolute + build paths are baked into every binary). +- **Homebrew tap** — `brew install rackerlabs/tap/understackctl` for macOS + and Linux users. This is the single highest-impact install improvement; + the target audience is heavily macOS. +- **Scoop bucket and/or WinGet manifest** for Windows. +- **`nfpm` `.deb`/`.rpm`** packages, which also lets you install shell + completions and a man page to the right system paths. +- **Shell completions and man pages as release artifacts** (§4.2). +- **Checksum file + cosign signing + `actions/attest-build-provenance`.** + Right now there is a `checksums.txt` but nothing signs it, and the job + already requests `id-token: write` without using it. Regulated-industry + operators increasingly want a verifiable provenance chain for a binary + that mints cluster credentials. +- **A `snapshot` build in PR CI**, so "does it still cross-compile for + windows/arm64" is caught on the PR and not at tag time. + +Keep the `Makefile` as a thin developer wrapper (`make build`, `make test`, +`make lint`, `make fmt`) that shells out to `goreleaser build --snapshot` +for the multi-platform case. + +### 2.2 Ship an install script + +Even with a Homebrew tap, a one-liner helps CI and bare Linux hosts: + +```bash +curl -fsSL https://raw.githubusercontent.com/rackerlabs/understackctl/main/install.sh | sh +``` + +It should detect OS/arch, fetch the matching archive from the GitHub +Releases API, **verify the checksum**, and install to `/usr/local/bin` or +`$XDG_BIN_HOME`. Support `UNDERSTACKCTL_VERSION` pinning. Document the +`curl | sh` risk and offer the manual path next to it. + +### 2.3 Trim and reconsider the build matrix + +`GOARCH_LIST=386 amd64 arm64` builds `linux/386` and `windows/386`. Nobody +runs a Kubernetes admin CLI on 32-bit x86; those two artifacts are noise in +the release list. Meanwhile the matrix is missing things people do have: + +| Add | Why | +|---|---| +| `linux/arm` (armv7) | only if you actually care about Pi-class hosts; probably skip | +| `darwin` universal binary | GoReleaser can lipo amd64+arm64 into one artifact | + +Recommendation: `linux/{amd64,arm64}`, `darwin/{amd64,arm64}` (+universal), +`windows/{amd64,arm64}`. Drop `386`. + +### 2.4 `go install` produces a binary that lies about its version + +`main.go` declares `version = "dev"` / `commit = "unknown"`, populated only by +the `Makefile`'s `-ldflags`. Anyone installing via +`go install github.com/rackerlabs/understackctl@v0.1.0` gets +`understackctl version dev (unknown)` — useless in a bug report. + +**Fix:** fall back to `runtime/debug.ReadBuildInfo()`. `bi.Main.Version` +carries the module version for `go install pkg@version`, and +`vcs.revision` / `vcs.time` / `vcs.modified` settings are stamped +automatically for `go build` inside a checkout. Use ldflags when present, +build info otherwise, `"dev"` last. + +### 2.5 Release workflow cleanups + +`.github/workflows/release.yaml`: + +- `sudo apt-get install -y sed grep` — both are in coreutils/base on every + GitHub runner. Delete the step; it only adds a network dependency that can + fail the release. +- `make build-all package-all checksums` depends on the `zip` binary being + present. It is on `ubuntu-latest`, but this makes the release + non-reproducible off a GitHub runner. GoReleaser (§2.1) removes the + dependency. +- Add `permissions: attestations: write` if you adopt build provenance. + +--- + +## 3. Cross-platform correctness + +### 3.1 `KUBECONFIG` is ignored + +`helpers/kube.go:13`: + +```go +config, err := clientcmd.BuildConfigFromFlags("", clientcmd.RecommendedHomeFile) +``` + +This hardcodes `~/.kube/config` and ignores the `KUBECONFIG` environment +variable, multi-file `KUBECONFIG` lists, and in-cluster config. Anyone who +keeps per-cluster kubeconfigs (i.e. anyone operating more than one UnderStack +site) silently targets the wrong cluster — for commands that *mint and seal +secrets*. This is the most dangerous non-blocker in the audit. + +Note `cmd/node/enroll.go:113` already does the right thing with +`clientcmd.NewDefaultPathOptions()`, so the codebase is inconsistent with +itself. + +**Fix:** one shared client constructor using +`clientcmd.NewNonInteractiveDeferredLoadingClientConfig` with +`NewDefaultClientConfigLoadingRules()`, plus global `--kubeconfig` and +`--context` persistent flags on the root command. `kubectl`-shaped behaviour +is what users expect, and it is correct on Windows too (the loading rules +handle `%USERPROFILE%`). + +### 3.2 Files are written world-readable/writable (`0777`) + +Nine call sites pass `os.ModePerm` (0777) to `fsutil.WriteFile`: + +- `helpers/kubeseal.go:50` — sealed secret output +- `cmd/other/other.go:106` — **`secret-openstack.yaml`, plaintext passwords** +- `cmd/other/other.go:59`, `cmd/argocd/argocd.go:102`, + `cmd/certManager/certManger.go:50`, `cmd/helmConfig/helmConfig.go:72,89,121,217` + +On Linux/macOS this yields `0755` after a typical umask — world-readable, and +group/world-writable under a permissive umask. `secret-openstack.yaml` +contains cleartext service passwords. Use `0600` for anything with secret +material and `0644` for the rest. (On Windows the mode is largely ignored, +so this is a Unix-user exposure.) + +### 3.3 Plaintext secrets written to disk + +Two separate issues beyond the file mode: + +- `helpers/kubeseal.go:14-30` marshals the unencrypted Secret to a temp file + and reopens it as `kubeseal` stdin. There is no reason to touch disk — + `cmd.Stdin = bytes.NewReader(inputData)` pipes it directly and removes the + window where plaintext credentials sit in `/tmp` (and the `defer` that + cleans up only on a normal return). +- `cmd/other/other.go:106` writes `secret-openstack.yaml` with cleartext + passwords into the deploy repo. That is a git repository. At minimum, warn + and ensure the deploy-repo scaffolding gitignores it; better, generate it + as a sealed secret like everything else. + +### 3.4 `--old-password` is echoed to the terminal and exposed in the process list + +`cmd/node/enroll.go:52`: + +```go +cmd.Printf("Running: %s\n", shellQuoteCommand("argo", argoArgs)) +``` + +`argoArgs` includes `-p old_password=`. The BMC password is printed +to stdout — into scrollback, CI logs, and `script`/tmux captures. It is also +visible to any local user via `ps` for the lifetime of the `argo` process, +because it is passed as an argv element. + +**Fix:** redact `old_password` (and any future `*password*` parameter) in the +echoed line, and read the value from a prompt (`term.ReadPassword`), a file +(`--old-password-file`), or an env var rather than a flag. Also mark the flag +so it never lands in shell history examples in the docs. + +### 3.5 Makefile is not usable by Windows contributors + +`build-all` and `package-all` are POSIX `sh` loops with `$$([ ... ])` +substitutions, `zip`, `tar`, `sha256sum`/`shasum`. A Windows contributor +without WSL cannot build. `goreleaser build --snapshot` (§2.1) works +natively on all three platforms and makes the Makefile optional rather than +required. + +### 3.6 Windows support is claimed but never exercised + +CI runs on `ubuntu-latest` only, for both lint and test. Windows and macOS +binaries are published without a single test having run on those platforms — +and this tool shells out to `kubeseal`, `argo`, `helm`, `git` and does a lot +of path manipulation, which is exactly where cross-platform bugs live. + +**Fix:** matrix the test job over +`[ubuntu-latest, macos-latest, windows-latest]`. It is nearly free (the test +suite runs in under a second) and it is the only thing that makes the +Windows artifacts a real claim rather than an aspirational one. + +Separately, be honest in the docs about what Windows users can actually do: +`kubeseal`, `argo` and `helm` all have Windows builds, but the +`quickstart`/secret-generation flow has almost certainly never been run +there. Either test it or document the support tier. + +### 3.7 Minor path portability + +Four sites build paths with `+ "/"` instead of `filepath.Join`: +`cmd/openstack/secrets.go:28`, `cmd/certManager/certManger.go:48`, +`cmd/other/other.go:59` (`namespacedName.String()` at +`cmd/nautobotOp/nautobotOp.go:47` is a legitimate `ns/name` identifier, not a +path). Go on Windows tolerates forward slashes, so these work, but they are +inconsistent and will bite when someone builds a path from them. + +Also, `shellQuoteCommand` in `cmd/node/enroll.go:135` emits POSIX quoting; on +PowerShell the printed "Running:" line is not copy-pasteable. Low priority, +but worth a note if the line survives §3.4. + +### 3.8 `.editorconfig` fights `gofmt` + +```ini +[*] +indent_style = space +indent_size = 2 +``` + +This applies to `*.go`, where gofmt mandates tabs. It has already caused +real damage: `helpers/kubeseal.go` and `helpers/kustomization.go` are +currently **not gofmt-clean** (space-indented lines at `kubeseal.go:56-58` +and `kustomization.go:40-47`). + +**Fix:** add a `[*.go]` section with `indent_style = tab`, and reformat the +two files. + +--- + +## 4. CLI usability + +### 4.1 No way to control log verbosity + +`cmd/deploy/image_set.go:127` calls `log.Debugf`, but there is no flag or env +var that raises `charmbracelet/log`'s level, so that line is dead code. There +is also no `--quiet`, and no `-o json|yaml` on the `list`/`show`/`versions` +commands that would make them scriptable. + +**Fix:** persistent `--log-level` (or `-v/-vv`) and `--output`. `NO_COLOR` is +honoured transitively by lipgloss/termenv, but an explicit `--no-color` is +cheap and expected. + +### 4.2 Completions and man pages are built but not shipped + +Cobra generates `understackctl completion {bash,zsh,fish,powershell}` for +free — it works today and is documented nowhere. Add: + +- A docs section, including the `powershell` variant for Windows users. +- Generated completion files in the release archives and installed to the + right paths by the `.deb`/`.rpm`/Homebrew formula (§2.1). +- `cobra.GenManTree` output as a release artifact, and a `docs/` generator + (`cobra.GenMarkdownTree`) so the command reference in §1.2 can be generated + from the code instead of hand-maintained — the current hand-written + reference in understack is already the kind of doc that drifts. + +### 4.3 Bare `understackctl` errors instead of printing help + +`cmd/root/root.go:22-28`: + +```go +RunE: func(cmd *cobra.Command, args []string) error { + // If no subcommand, show help + return fmt.Errorf("a subcommand is required") +}, +``` + +The comment says show help; the code returns an error, so cobra prints +`Error: a subcommand is required` followed by the full usage block. The +`deploy` command does the right thing (`return cmd.Help()`). Make the root +consistent: print help, exit non-zero. + +Also, `Long: ``` is empty and `Use: "understackctl SUBCOMMAND ..."` puts a +placeholder in the help title where cobra expects just the command name. + +### 4.4 No `SilenceUsage` on the root command + +Only `cmd/node/enroll.go:36` sets it. Everywhere else, a *runtime* failure +("could not reach cluster") prints the entire usage text after the error, +which buries the actual message. Set `SilenceUsage: true` on the root and let +usage print only for genuine argument errors. + +### 4.5 No config file, despite viper being a dependency + +Users are asked to set `UC_DEPLOY`, `DEPLOY_NAME`, `DNS_ZONE`, +`UC_DEPLOY_GIT_URL`, `UC_DEPLOY_SSH_FILE`, `UC_DEPLOY_EMAIL`, `UC_AIO`. +That is a lot of shell state to keep straight across several sites, and +env vars are the least Windows-friendly configuration mechanism there is +(no `export`, no `direnv`). + +Viper is already imported for exactly one flag (`--deploy-repo`). Use it +properly: read `understackctl.yaml` from `os.UserConfigDir()` (which is +`%AppData%` on Windows, `~/Library/Application Support` on macOS, +`$XDG_CONFIG_HOME` on Linux — all correct for free), with env vars as +override. Add `understackctl config show` to print the effective +configuration and where each value came from; that alone will cut support +questions. + +### 4.6 Preflight checks are inconsistent + +`quickstart` checks for `kubeseal` up front and reports all missing tools at +once (`cmd/quickstart/quickstart.go:41-55`) — good pattern. But +`argocd-secrets`, `dex-secrets`, `other-secrets` etc. invoked directly do not, +so they fail deep into the run after having already written some files. +`deploy render` checks for `helm` and `git` correctly. + +**Fix:** a shared `requireTools("kubeseal", "kubectl")` helper called from +each command's `PreRunE`, with an actionable message (install URL per +platform). Consider `understackctl doctor` that checks every external +dependency, the kubeconfig, and cluster reachability in one shot — very +cheap to write, disproportionately useful for a tool with five external +binary dependencies. + +### 4.7 Hardcoded values that should be flags + +- `cmd/node/enroll.go:15` — namespace `argo-events` is hardcoded for both + `enroll-server` and `inspect-server`. +- `helpers/kubeseal.go:31` — `--scope cluster-wide` is hardcoded, and no + `--controller-name`/`--controller-namespace` is passed, so non-default + sealed-secrets installs fail. +- `cmd/deploy/init.go:16` and `cmd/deploy/render.go:23` — the understack repo + URL, duplicated as two separate constants that must agree. +- `internal/chartvalues/parse.go:11` — `raw.githubusercontent.com` base URL, + with no proxy/mirror override. In an air-gapped or egress-restricted + environment (common for the regulated operators this targets) + `deploy init` simply fails. Add `--values-file` to read a local + `values.yaml`, and honour `HTTPS_PROXY`. + +### 4.8 No HTTP timeout on the network fetch + +`internal/chartvalues/parse.go:33` uses `http.Get`, i.e. +`http.DefaultClient`, which has **no timeout**. A hung TLS connection makes +`deploy init` hang forever with no output. Use an explicit +`&http.Client{Timeout: 30 * time.Second}` and a +`NewRequestWithContext` tied to the command's context so Ctrl-C works. +Same for the `git clone` and `helm template` subprocesses — pass +`exec.CommandContext`. + +--- + +## 5. Code quality and correctness + +### 5.1 `log.Fatal` / `os.Exit` scattered through library and command code + +23 call sites outside `main.go`. Consequences: `defer`red cleanup never runs +(including the temp-file removal in `helpers/kubeseal.go` and the chart temp +dir in `deploy render`), the code is untestable, and exit codes are +uncontrolled. + +Worth noting some are outright broken: + +- `helpers/random.go:23` — `log.Fatal("failed to generate random password", "err", err)` using **stdlib** `log`, which does not take key/value pairs, so it prints `failed to generate random passworderr`. The `os.Exit(1)` on the next line is unreachable. +- `helpers/kubeseal.go:52` — on a write failure, logs `"error in kustomization.yaml file"`. Wrong message entirely; this is the sealed-secret write path. Copy-pasted from `certManger.go:51`, which has the same wrong string. +- `cmd/argocd/argocd.go:37-38`, `cmd/deploy/deploy.go:49-50,63-64`, + `cmd/certManager/certManger.go:51-52` — `log.Fatal` followed by + `os.Exit(1)`; the second line is dead in every case. +- `helpers/kube.go` and `helpers/random.go` import stdlib `log` while the + rest of the codebase uses `charmbracelet/log`, so output formatting is + inconsistent between packages. + +**Fix:** return errors up to `RunE`; let `main` be the only place that exits. +This is a mechanical refactor and it is the prerequisite for testing +anything in `helpers/`. + +### 5.2 Silently swallowed error can rotate every OpenStack service password + +`cmd/other/other.go:113`: + +```go +client, _ := helpers.KubeClientSet().CoreV1().Secrets(namespace).Get(...) +encodedPassword, ok := client.Data["password"] +if ok { ...reuse... } +log.Warn("password not in cluster", ...) +return helpers.GenerateRandomString(32) +``` + +The error is discarded. A transient API error, an RBAC denial, or **the wrong +kubeconfig** (see §3.1) is indistinguishable from "secret does not exist", and +the fallback is to generate a brand-new password — for keystone, ironic, +placement, neutron, nova, glance and horizon. Re-running `other-secrets` +against an unreachable cluster silently rewrites every service credential in +the deploy repo, and the next ArgoCD sync breaks the control plane. + +**Fix:** distinguish `apierrors.IsNotFound(err)` (generate) from any other +error (abort with a clear message). This is the highest-severity correctness +bug in the audit. + +Related: the variable is named `client` but holds a `*corev1.Secret`. + +### 5.3 Test coverage is thin and concentrated + +Only 4 of 20 packages have tests; `cmd/deploy/deploy_test.go` (722 lines) and +`internal/chartvalues/parse_test.go` carry almost all of it. Nothing covers +`helpers/` (untestable as written, per §5.1), the secret generators, the +device-type/flavor schema validation, or the template rendering. `go test +./...` passes today. + +Given what this tool does — generating credentials and writing manifests that +ArgoCD applies to a control plane — the secret-generation paths deserve +tests with a fake clientset (`k8s.io/client-go/kubernetes/fake`) and a +`t.TempDir()` output directory. Start with §5.2's not-found-vs-error logic. + +### 5.4 No `.golangci.yml` in this repo + +`.github/workflows/go-lint.yaml` runs `golangci-lint v2.1.2` with no config +file, so it falls back to the v2 defaults (`errcheck`, `govet`, +`ineffassign`, `staticcheck`, `unused`) and — importantly — runs **no +formatters**, which is why the two unformatted files in §3.8 pass CI. + +The sibling projects (`go/dexop`, `go/nautobotop`, +`go/ironic-hardware-exporter`) all have a `.golangci.yml` enabling `dupl`, +`goconst`, `gocyclo`, `lll`, `misspell`, `nakedret`, `prealloc`, `unconvert`, +`unparam` plus `gofmt`/`goimports` formatters. Copy that config here for +consistency across Rackspace's Go projects. Expect a batch of findings on +first run (`gocyclo` on `cmd/flavor/flavor.go` and +`cmd/deviceType/deviceType.go`, `dupl` on those two which are near-identical +459/427-line files). + +Also pin the version via Renovate — the `# renovate: datasource=...` comment +is there, good. + +### 5.5 `deviceType` and `flavor` are near-duplicates + +459 and 427 lines with the same structure: `add`/`validate`/`delete`/ +`list`/`show`, the same schema-path search, the same kustomization +configMapGenerator update, the same `UC_DEPLOY` check. Worth extracting a +generic "schema-validated YAML resource in the deploy repo" helper once the +embedded-schema change (§1.1) touches both anyway. + +### 5.6 Package-level command state + +`cmd/root/root.go` uses a package-level `rootCmd` plus `init()`. Combined +with viper's globals in `cmd/deploy/deploy.go:47-66`, commands cannot be +constructed twice in one process, which makes end-to-end CLI tests +impossible. Switch to `NewRootCmd()` returning a fresh tree; every +subcommand already follows the `NewCmdX()` pattern, so the root is the +odd one out. + +### 5.7 `quickstart` ignores subcommand failures + +`cmd/quickstart/quickstart.go:57-78` calls `.Run(cmd, args)` on each +subcommand directly. `Run` (not `RunE`) returns nothing, so a failure in +step 3 does not stop steps 4 through 8, and `quickstart` exits 0 having +half-configured the deployment. Convert the chain to `RunE` and abort on +first error. + +It also passes the parent's `args` and `cmd` into each child, so any flags +the children define are never parsed — they only work by reading env vars. + +### 5.8 `enabledComponents` ordering + +`cmd/deploy/config.go:60-104` iterates `for key, val := range sectionMap` +over a Go map, so the `order` slice it builds is nondeterministic despite the +comment implying a stable merge. `extractComponents` in +`internal/chartvalues/parse.go:96` has the same issue. `deploy init` will +therefore emit `deploy.yaml` keys in random order (YAML marshalling of the +map sorts them, so the written file is stable — but any log or list output +derived from these slices is not). Sort explicitly. + +--- + +## 6. Repo hygiene + +The new repo is missing most of the standard GitHub furniture: + +| File | Why it matters here | +|---|---| +| `CONTRIBUTING.md` | build/test/lint commands, DCO or CLA, commit conventions (Renovate is set to `semanticCommits: enabled`, so conventional commits are expected but undocumented) | +| `CODEOWNERS` | nothing enforces review; the monorepo's ownership no longer applies | +| `SECURITY.md` | a credential-minting CLI needs a disclosure address | +| `.github/ISSUE_TEMPLATE/` | ask for `understackctl version` output, OS, and the external tool versions — see §2.4, this only works once version reporting is fixed | +| `.github/pull_request_template.md` | — | +| `.pre-commit-config.yaml` | understack has a good one; a reduced version (trailing-whitespace, end-of-file-fixer, check-yaml, typos, gofmt) gives contributors the same local loop | +| `CHANGELOG.md` | understack's `changelog.d/` towncrier setup does not come along; GoReleaser's changelog from conventional commits is the low-effort option | +| `dependabot`/Renovate for Go | `.github/renovate.json` is present and groups Go updates — verify the app is actually enabled on the new repo, it is per-repo | +| Branch protection | require `Go Tests` + `golangci-lint` before merge; the workflows already handle `merge_group`, which implies a merge queue is intended | +| `README.md` | currently 3 lines pointing at understack docs. It is the repo's landing page: it needs install instructions (all three platforms), a 30-second example, and the external-dependency list | + +`go.mod` declares `go 1.24.0` with no `toolchain` line — fine, and +`go-version-file: go.mod` in CI keeps the toolchain consistent. Leave it. + +--- + +## 7. Suggested sequencing + +**Before the first tag** (these are correctness/blocking): + +1. Embed the JSON schemas (§1.1) — four commands are broken without it. +2. Fix `KUBECONFIG` handling (§3.1) and the swallowed error in + `loadOrGenSecret` (§5.2) — both can damage a live deployment. +3. File permissions on secret output (§3.2) and stop echoing + `--old-password` (§3.4). +4. Fix `.editorconfig` + reformat, add `.golangci.yml` (§3.8, §5.4). +5. Decide versioning, widen the release tag filter, fix `make_latest` (§1.4). +6. Build-info version fallback (§2.4). +7. README with real install instructions (§6). + +**First release cycle:** + +8. GoReleaser: archive layout, `-trimpath`, checksums, Homebrew tap, Scoop, + `.deb`/`.rpm`, completions, signing/provenance (§2.1). +9. Test matrix across Linux/macOS/Windows (§3.6). +10. `install.sh` (§2.2), trim the `386` targets (§2.3). +11. Move/refresh the docs and clean up the monorepo leftovers (§1.2, §1.3). + +**Then:** + +12. Errors instead of `log.Fatal` (§5.1), which unblocks tests for + `helpers/` and the secret generators (§5.3). +13. `--log-level` / `--output` / `--kubeconfig` / `--context` globals + (§4.1, §3.1), `SilenceUsage`, root help behaviour (§4.3, §4.4). +14. Config file support (§4.5), `doctor` command and shared preflight + checks (§4.6). +15. `quickstart` error propagation (§5.7), deduplicate + `deviceType`/`flavor` (§5.5). From 63dc84cb52f4346678852e01ad49b638ef302a83 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 04:50:58 +0000 Subject: [PATCH 2/4] feat: release with GoReleaser and smooth out installation Replaces the hand-rolled Makefile release path with GoReleaser and makes installing understackctl a one-liner on Linux, macOS and Windows. Distribution: - .goreleaser.yaml builds linux/darwin/windows on amd64/arm64, plus a darwin_all universal binary so macOS users cannot pick the wrong archive. Drops the 386 targets nobody runs. - Archives now hold the binary at their root alongside LICENSE, README and generated shell completions. Previously `tar xzf` produced a directory and shipped no license. - Adds -trimpath and a commit-pinned mod_timestamp for reproducible builds. - Homebrew cask and Scoop manifest are generated but not published (skip_upload: true) until rackerlabs/homebrew-tap and rackerlabs/scoop-bucket exist; the enablement checklist is inline. Installation: - install.sh detects OS/arch, verifies the SHA-256 checksum before installing, picks a writable install dir, and warns when it is not on PATH. BASE_URL points it at an internal mirror for restricted-egress environments. - README documents every install path, checksum and provenance verification, shell completion setup, and the external tools each command group needs. Version reporting: - New internal/version resolves the version from linker flags, falling back to debug.ReadBuildInfo so `go install ...@v0.1.0` no longer reports "dev". Normalizes the v prefix that GoReleaser's .Version strips so all install methods report the same string. - New `understackctl version [-o json]` for bug reports; --version keeps working. CI: - Release workflow uses goreleaser-action and attests build provenance, so archives can be checked with `gh attestation verify`. It accepts pre-release tags, which prerelease: auto keeps from being marked latest, and no longer apt-installs sed and grep. - New Release Build workflow validates the config, cross-compiles every published target and asserts the archive layout on each pull request, so release breakage surfaces before tagging. - Go Tests now runs on Linux, macOS and Windows rather than Linux alone. Also reduces the Makefile to a developer wrapper, stops .editorconfig from imposing spaces on Go sources, and gofmts the two files that drift had already broken. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Tr9ob3LqrbsZL2rpJehEr3 --- .editorconfig | 7 ++ .github/workflows/build.yaml | 86 +++++++++++++ .github/workflows/go-test.yaml | 9 +- .github/workflows/release.yaml | 44 ++++--- .gitignore | 3 + .goreleaser.yaml | 183 ++++++++++++++++++++++++++++ AUDIT.md | 51 ++++++-- Makefile | 140 ++++++++++----------- README.md | 201 ++++++++++++++++++++++++++++++- cmd/root/root.go | 15 ++- cmd/version/version.go | 47 ++++++++ helpers/kubeseal.go | 6 +- helpers/kustomization.go | 8 +- install.sh | 181 ++++++++++++++++++++++++++++ internal/version/version.go | 134 +++++++++++++++++++++ internal/version/version_test.go | 119 ++++++++++++++++++ main.go | 11 +- scripts/completions.sh | 11 ++ 18 files changed, 1123 insertions(+), 133 deletions(-) create mode 100644 .github/workflows/build.yaml create mode 100644 .goreleaser.yaml create mode 100644 cmd/version/version.go create mode 100755 install.sh create mode 100644 internal/version/version.go create mode 100644 internal/version/version_test.go create mode 100755 scripts/completions.sh 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/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 0000000..7ebc9fc --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,86 @@ +name: Release Build +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + merge_group: + types: [checks_requested] + +permissions: + contents: read + +jobs: + # Validates .goreleaser.yaml and cross-compiles every published target, so a + # break in the release pipeline surfaces on the pull request rather than at + # tag time, when it can only be fixed with another tag. + snapshot: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + fetch-depth: 0 + + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 + with: + go-version-file: go.mod + cache-dependency-path: go.sum + + - name: Check GoReleaser config + uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3 + with: + version: "~> v2" + args: check + + - name: Build snapshot + uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3 + with: + version: "~> v2" + args: release --snapshot --clean --skip=validate + + # Asserts the properties install.sh and the docs depend on, against the + # archive a user actually downloads. + - name: Verify release archive + run: | + set -euo pipefail + + test -f dist/checksums.txt + + archive=$(ls dist/*_linux_amd64.tar.gz) + contents=$(tar tzf "$archive") + + # The binary must sit at the archive root. A nested directory means + # `tar xzf` hands the user a folder instead of something runnable. + grep -qx understackctl <<<"$contents" + grep -qx LICENSE <<<"$contents" + grep -qx README.md <<<"$contents" + grep -qx 'completions/understackctl.bash' <<<"$contents" + grep -qx 'completions/understackctl.powershell' <<<"$contents" + + # macOS users get one archive that runs on both architectures. + test -f dist/*_darwin_all.tar.gz + + # Windows archives are zips holding a .exe. + unzip -l dist/*_windows_amd64.zip | grep -q 'understackctl\.exe' + + mkdir -p /tmp/verify + tar xzf "$archive" -C /tmp/verify + /tmp/verify/understackctl version + + # A release binary must never fall back to the "dev" placeholder; + # that would mean the ldflags in .goreleaser.yaml stopped matching + # the variable paths in internal/version. + if /tmp/verify/understackctl version | grep -qx 'Version: dev'; then + echo "::error::version was not stamped into the binary" + exit 1 + fi + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: snapshot-archives + path: | + dist/*.tar.gz + dist/*.zip + dist/checksums.txt + retention-days: 7 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 4874826..c813743 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,35 +15,40 @@ jobs: runs-on: ubuntu-latest permissions: contents: write + # Required by actions/attest-build-provenance. 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 + go-version-file: go.mod + cache-dependency-path: go.sum - - 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 + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3 with: - make_latest: true - files: | - build/*.zip - build/*.tar.gz - build/checksums.txt + version: "~> v2" + args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Uncomment once the tap/bucket repositories and their PATs exist, + # and set skip_upload: false in .goreleaser.yaml. See the comments + # there for the full checklist. + # HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + # SCOOP_BUCKET_TOKEN: ${{ secrets.SCOOP_BUCKET_TOKEN }} + + # Lets anyone confirm an archive was built by this workflow from this + # commit: `gh attestation verify --repo rackerlabs/understackctl` + - name: Attest build provenance + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + with: + subject-path: | + dist/*.tar.gz + dist/*.zip 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..b77236e --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,183 @@ +# 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; install.sh and Homebrew use 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 + + ```bash + curl -fsSL https://raw.githubusercontent.com/rackerlabs/understackctl/main/install.sh | sh + ``` + + Or 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 below are configured but DISABLED (skip_upload: true) because +# publishing needs infrastructure that does not exist yet. To turn either on: +# +# 1. Create the target repository under the rackerlabs org: +# Homebrew -> rackerlabs/homebrew-tap +# Scoop -> rackerlabs/scoop-bucket +# 2. Create a PAT with `contents: write` on that repository and add it to +# this repo's Actions secrets as HOMEBREW_TAP_TOKEN / SCOOP_BUCKET_TOKEN. +# The default GITHUB_TOKEN cannot push to another repository. +# 3. Uncomment the matching `env:` line in .github/workflows/release.yaml. +# 4. Change skip_upload below to false. +# +# Until step 4, a release builds these artifacts and does not push them, so +# nothing here can break a release. + +homebrew_casks: + - name: understackctl + skip_upload: true + 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: | + if system_command("/usr/bin/xattr", args: ["-h"]).exit_status == 0 + system_command "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "#{staged_path}/understackctl"] + end + +scoops: + - name: understackctl + skip_upload: true + 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/AUDIT.md b/AUDIT.md index ea0117e..7fa8668 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -6,6 +6,31 @@ distribution, then platform correctness, then everything else. This file is a working document meant to be triaged into issues and deleted. +## Status + +**Done** — GoReleaser migration and install experience (§2.1–2.5, §3.6, §3.8, +§4.2 completions, §1.4 versioning): + +- `.goreleaser.yaml` replaces the Makefile release path: archives with the + binary at the root, `-trimpath`, reproducible `mod_timestamp`, a + `darwin_all` universal binary, completions/LICENSE/README in every archive, + sha256 checksums, changelog grouped from conventional commits. +- Homebrew cask and Scoop manifest are configured but `skip_upload: true` + until the tap/bucket repos and their PATs exist. Checklist is in the file. +- `install.sh` for Linux/macOS with checksum verification and a `BASE_URL` + mirror override. +- `internal/version` reports a real version for `go install` builds via + `debug.ReadBuildInfo`; new `understackctl version [-o json]`. +- Release workflow uses `goreleaser-action` + `actions/attest-build-provenance`, + accepts pre-release tags, and no longer installs `sed`/`grep`. +- New `Release Build` workflow cross-compiles every target and asserts the + archive layout on every PR. `Go Tests` now runs on Linux, macOS and Windows. +- `Makefile` reduced to a developer wrapper; `.editorconfig` no longer fights + gofmt and the two unformatted files are fixed. + +**Still open** — everything else below, most importantly the §1.1 embedded +schemas, §3.1 `KUBECONFIG` handling and §5.2 password-rotation bug. + --- ## 1. Migration blockers @@ -587,26 +612,28 @@ The new repo is missing most of the standard GitHub furniture: `loadOrGenSecret` (§5.2) — both can damage a live deployment. 3. File permissions on secret output (§3.2) and stop echoing `--old-password` (§3.4). -4. Fix `.editorconfig` + reformat, add `.golangci.yml` (§3.8, §5.4). -5. Decide versioning, widen the release tag filter, fix `make_latest` (§1.4). -6. Build-info version fallback (§2.4). -7. README with real install instructions (§6). +4. ~~Fix `.editorconfig` + reformat~~ (done), add `.golangci.yml` (§5.4). +5. ~~Decide versioning, widen the release tag filter, fix `make_latest`~~ (done). +6. ~~Build-info version fallback~~ (done). +7. ~~README with real install instructions~~ (done). **First release cycle:** -8. GoReleaser: archive layout, `-trimpath`, checksums, Homebrew tap, Scoop, - `.deb`/`.rpm`, completions, signing/provenance (§2.1). -9. Test matrix across Linux/macOS/Windows (§3.6). -10. `install.sh` (§2.2), trim the `386` targets (§2.3). +8. ~~GoReleaser: archive layout, `-trimpath`, checksums, Homebrew tap, Scoop, + completions, provenance~~ (done; `.deb`/`.rpm` deliberately skipped). +9. ~~Test matrix across Linux/macOS/Windows~~ (done). +10. ~~`install.sh`, trim the `386` targets~~ (done). 11. Move/refresh the docs and clean up the monorepo leftovers (§1.2, §1.3). +12. Create `rackerlabs/homebrew-tap` + `rackerlabs/scoop-bucket` and their + PATs, then flip `skip_upload` (§2.1). **Then:** -12. Errors instead of `log.Fatal` (§5.1), which unblocks tests for +13. Errors instead of `log.Fatal` (§5.1), which unblocks tests for `helpers/` and the secret generators (§5.3). -13. `--log-level` / `--output` / `--kubeconfig` / `--context` globals +14. `--log-level` / `--output` / `--kubeconfig` / `--context` globals (§4.1, §3.1), `SilenceUsage`, root help behaviour (§4.3, §4.4). -14. Config file support (§4.5), `doctor` command and shared preflight +15. Config file support (§4.5), `doctor` command and shared preflight checks (§4.6). -15. `quickstart` error propagation (§5.7), deduplicate +16. `quickstart` error propagation (§5.7), deduplicate `deviceType`/`flavor` (§5.5). 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..4df049c 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,202 @@ # 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. + +### Linux and macOS — install script + +```bash +curl -fsSL https://raw.githubusercontent.com/rackerlabs/understackctl/main/install.sh | sh +``` + +The script detects your OS and architecture, verifies the SHA-256 checksum +before installing, and puts the binary in `/usr/local/bin` (falling back to +`~/.local/bin` if that is not writable). + +Piping a script from the internet into a shell means trusting this repository +and the connection that fetched it. To read it first: + +```bash +curl -fsSLO https://raw.githubusercontent.com/rackerlabs/understackctl/main/install.sh +less install.sh && sh install.sh +``` + +It understands a few environment variables: + +| Variable | Purpose | +|---|---| +| `VERSION` | Install a specific version, e.g. `VERSION=v0.1.0` | +| `INSTALL_DIR` | Install somewhere else, e.g. `INSTALL_DIR=~/bin` | +| `BASE_URL` | Download from an internal mirror instead of GitHub (requires `VERSION`) | +| `NO_VERIFY` | Set to `1` to skip checksum verification — not recommended | + +### macOS and Linux — Homebrew + +> Not yet available. The release pipeline builds the cask but does not publish +> it until the `rackerlabs/homebrew-tap` repository exists; see the comments in +> [`.goreleaser.yaml`](.goreleaser.yaml). + +```bash +brew install rackerlabs/tap/understackctl +``` + +### Windows — Scoop + +> Not yet available, pending the `rackerlabs/scoop-bucket` repository. Until +> then, download the `.zip` from the +> [releases page](https://github.com/rackerlabs/understackctl/releases) and put +> `understackctl.exe` somewhere on your `PATH`. + +```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`. Both also run in +CI on every pull request, so a break in the release pipeline shows up before +you tag. + +### 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..9044d45 --- /dev/null +++ b/cmd/version/version.go @@ -0,0 +1,47 @@ +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": + fmt.Fprint(cmd.OutOrStdout(), info.Multiline()) + 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/install.sh b/install.sh new file mode 100755 index 0000000..0d6e4e4 --- /dev/null +++ b/install.sh @@ -0,0 +1,181 @@ +#!/bin/sh +# Install understackctl on Linux or macOS. +# +# curl -fsSL https://raw.githubusercontent.com/rackerlabs/understackctl/main/install.sh | sh +# +# Environment variables: +# VERSION version to install, e.g. v0.1.0 (default: latest release) +# INSTALL_DIR directory to install into (default: first writable of +# /usr/local/bin, $HOME/.local/bin) +# BASE_URL download artifacts from somewhere other than GitHub, for an +# internal mirror. Requires VERSION to be set, since there is +# no "latest" to resolve. Expects the release artifacts to sit +# directly under this URL. +# NO_VERIFY set to 1 to skip checksum verification (not recommended) +# +# Windows users: use Scoop, or download the .zip from the releases page. +# +# Piping a script from the internet into a shell means trusting this file and +# the connection that fetched it. To review it first: +# curl -fsSLO https://raw.githubusercontent.com/rackerlabs/understackctl/main/install.sh +# less install.sh && sh install.sh + +set -eu + +REPO="rackerlabs/understackctl" +BINARY="understackctl" + +info() { printf '==> %s\n' "$*" >&2; } +err() { printf 'error: %s\n' "$*" >&2; exit 1; } + +need() { + command -v "$1" >/dev/null 2>&1 || err "$1 is required but was not found in PATH" +} + +detect_platform() { + os=$(uname -s) + arch=$(uname -m) + + case "$os" in + Linux) os=linux ;; + Darwin) os=darwin ;; + MINGW* | MSYS* | CYGWIN*) + err "Windows is not supported by this script; use Scoop or download the .zip from https://github.com/$REPO/releases" + ;; + *) err "unsupported operating system: $os" ;; + esac + + case "$arch" in + x86_64 | amd64) arch=amd64 ;; + arm64 | aarch64) arch=arm64 ;; + *) err "unsupported architecture: $arch (understackctl publishes amd64 and arm64)" ;; + esac + + PLATFORM="${os}_${arch}" +} + +# Resolves the latest release tag by following the redirect on the +# /releases/latest URL, which avoids both an API token and a JSON parser. +latest_version() { + url=$(curl -fsSLI -o /dev/null -w '%{url_effective}' "https://github.com/$REPO/releases/latest") || + err "could not reach GitHub to determine the latest version" + + tag=${url##*/} + case "$tag" in + v*) printf '%s\n' "$tag" ;; + *) err "could not parse a version tag out of '$url'" ;; + esac +} + +# Picks the first writable directory, preferring a system-wide install. +choose_install_dir() { + if [ -n "${INSTALL_DIR:-}" ]; then + printf '%s\n' "$INSTALL_DIR" + return + fi + + for dir in /usr/local/bin "$HOME/.local/bin"; do + if [ -d "$dir" ] && [ -w "$dir" ]; then + printf '%s\n' "$dir" + return + fi + done + + # Nothing writable: fall back to a user directory and create it. + printf '%s\n' "$HOME/.local/bin" +} + +verify_checksum() { + archive=$1 + checksums=$2 + + if [ "${NO_VERIFY:-0}" = "1" ]; then + info "skipping checksum verification (NO_VERIFY=1)" + return + fi + + expected=$(awk -v name="$(basename "$archive")" '$2 == name { print $1 }' "$checksums") + [ -n "$expected" ] || err "$(basename "$archive") is not listed in checksums.txt" + + if command -v sha256sum >/dev/null 2>&1; then + actual=$(sha256sum "$archive" | cut -d' ' -f1) + elif command -v shasum >/dev/null 2>&1; then + actual=$(shasum -a 256 "$archive" | cut -d' ' -f1) + else + err "neither sha256sum nor shasum is available; re-run with NO_VERIFY=1 to install anyway" + fi + + [ "$actual" = "$expected" ] || + err "checksum mismatch for $(basename "$archive"): expected $expected, got $actual" + + info "checksum verified" +} + +main() { + need curl + need tar + need awk + + detect_platform + + if [ -n "${BASE_URL:-}" ] && [ -z "${VERSION:-}" ]; then + err "BASE_URL requires VERSION to be set; a mirror has no 'latest' to resolve" + fi + + version=${VERSION:-$(latest_version)} + case "$version" in + v*) ;; + *) version="v$version" ;; + esac + + # Archive names carry the version without the leading "v". + archive_name="${BINARY}_${version#v}_${PLATFORM}.tar.gz" + base_url=${BASE_URL:-"https://github.com/$REPO/releases/download/$version"} + + info "installing $BINARY $version ($PLATFORM)" + + tmp=$(mktemp -d) + # shellcheck disable=SC2064 # expand tmp now, not at trap time + trap "rm -rf '$tmp'" EXIT INT TERM + + curl -fsSL -o "$tmp/$archive_name" "$base_url/$archive_name" || + err "could not download $base_url/$archive_name (does version $version publish a $PLATFORM build?)" + + if [ "${NO_VERIFY:-0}" != "1" ]; then + curl -fsSL -o "$tmp/checksums.txt" "$base_url/checksums.txt" || + err "could not download checksums.txt from $base_url" + verify_checksum "$tmp/$archive_name" "$tmp/checksums.txt" + fi + + tar -xzf "$tmp/$archive_name" -C "$tmp" + [ -f "$tmp/$BINARY" ] || err "archive did not contain a $BINARY binary" + chmod 0755 "$tmp/$BINARY" + + dir=$(choose_install_dir) + mkdir -p "$dir" || err "could not create $dir" + + if [ -w "$dir" ]; then + mv "$tmp/$BINARY" "$dir/$BINARY" + elif command -v sudo >/dev/null 2>&1; then + info "$dir is not writable, escalating with sudo" + sudo mv "$tmp/$BINARY" "$dir/$BINARY" + else + err "$dir is not writable and sudo is unavailable; set INSTALL_DIR to somewhere you can write" + fi + + info "installed $dir/$BINARY" + + case ":$PATH:" in + *":$dir:"*) ;; + *) + info "note: $dir is not on your PATH; add it with" + info " export PATH=\"$dir:\$PATH\"" + ;; + esac + + "$dir/$BINARY" version || true + + info "shell completion: $BINARY completion --help" +} + +main "$@" 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 From 6b8bac4cfce9e2f15cc4a8449e954092776d42f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 04:52:35 +0000 Subject: [PATCH 3/4] ci: attest release artifacts with actions/attest As of v4, actions/attest-build-provenance is a thin wrapper over actions/attest and its README directs new implementations at the latter. Switches to actions/attest and feeds it dist/checksums.txt via subject-checksums, which attests every published archive in one step instead of globbing paths. Default behaviour is still SLSA build provenance, so `gh attestation verify` works unchanged. artifact-metadata: write is deliberately not granted; it is only needed for the storage record, which requires push-to-registry. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Tr9ob3LqrbsZL2rpJehEr3 --- .github/workflows/release.yaml | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index c813743..907aca0 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -15,7 +15,9 @@ jobs: runs-on: ubuntu-latest permissions: contents: write - # Required by actions/attest-build-provenance. + # 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 attestations: write steps: @@ -44,11 +46,11 @@ jobs: # HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} # SCOOP_BUCKET_TOKEN: ${{ secrets.SCOOP_BUCKET_TOKEN }} - # Lets anyone confirm an archive was built by this workflow from this - # commit: `gh attestation verify --repo rackerlabs/understackctl` + # 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-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 with: - subject-path: | - dist/*.tar.gz - dist/*.zip + subject-checksums: dist/checksums.txt From 28d92807cc41a28f40272a191abb962831ae01bd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 02:21:08 +0000 Subject: [PATCH 4/4] fix: check fmt.Fprint error in version command golangci-lint's errcheck flagged the text branch of `understackctl version`, which discarded the fmt.Fprint error while the json branch already checked its encoder error. Reproduced with the pinned golangci-lint v2.1.2 and confirmed clean after. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Tr9ob3LqrbsZL2rpJehEr3 --- cmd/version/version.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cmd/version/version.go b/cmd/version/version.go index 9044d45..f51c516 100644 --- a/cmd/version/version.go +++ b/cmd/version/version.go @@ -26,7 +26,9 @@ Include this output when reporting a bug.`, switch output { case "text": - fmt.Fprint(cmd.OutOrStdout(), info.Multiline()) + 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("", " ")