Skip to content

fix(cli): a failed command exits non-zero - #94

Open
42-v wants to merge 3 commits into
mainfrom
fix/cli-exit-code
Open

fix(cli): a failed command exits non-zero#94
42-v wants to merge 3 commits into
mainfrom
fix/cli-exit-code

Conversation

@42-v

@42-v 42-v commented Aug 26, 2026

Copy link
Copy Markdown
Owner

Every error the CLI printed exited 0. All thirty-four error paths in internal/cli did it.

CLI.Run returns whether a command was recognized — and cmd/vault turned that straight into a bare return. So the message reached whoever was reading stderr, and the status reached whoever was not.

That is worst where nothing reads stderr at all. vault seed is the documented way to seed declaratively from an init container, and an init container gates on the exit status: it saw success while nothing had been seeded, and the workload started against an empty database. Every other subcommand behaved the same way — this is not a seed-specific fix, and the filed issue only named seed because that is where it was noticed.

Why a flag rather than exiting in place

exitProcess must not return — its own comment says so, and the test stub panics to honour it. Calling it from thirty-four sites would turn every test that drives an error path into a panic. Recording the failure leaves Run's bool contract alone and lets one caller decide the process's fate. All existing CLI tests pass unchanged.

Drained before exiting. os.Exit runs no defers, and the CLI writes audit rows — add-client and rotate-admin-token among them. Exiting straight from there would drop the record of the very command being reported as failed, which is the same defect one layer down. The seed-file failure below already does this, and this follows its //nolint:gocritic precedent.

The gate

It holds the shape, so a subcommand written in the old style fails here instead of shipping. It matches the defect exactly — an ERROR: or Usage: write followed by a bare return true — and deliberately not every stderr write: this file also prints warnings and notes the command continues past (the argv disclosure warning, the JWKS note, the admin-token mismatch), and the admin-authentication refusal already calls exitProcess(1). An earlier version of the gate flagged all five of those. A second test checks cmd/vault actually consults the flag — recording a failure nobody reads is the same silence with more code.

Mutation-verified: reverting one path to the old style fails it, naming file and line.

Verified: go build ./..., go vet ./..., go test -race ./internal/cli/, tests/spec + tests/compliance green, golangci-lint run ./... 0 issues on a cleaned cache.

fix(cli): a failed command exits non-zero

42-v added 3 commits August 26, 2026 22:47
CLI.Run returns whether a command was RECOGNIZED, and cmd/vault turned that
straight into a bare return -- so every error the CLI printed exited 0. All
thirty-four error paths in internal/cli did it: the message reached whoever was
reading stderr, and the status reached whoever was not.

That is worst where nothing reads stderr at all. `vault seed` is the documented
way to seed declaratively from an init container, and an init container gates on
the exit status: it saw success while nothing had been seeded and the workload
started against an empty database. Every other subcommand behaved the same way,
so this is not a seed-specific fix.

Recorded on the CLI and read once by cmd/vault, rather than exiting inside each
command. exitProcess must not return -- its own comment says so and the test
stub panics to honour it -- so calling it from thirty-four sites would turn
every test that drives an error path into a panic. A flag leaves Run's bool
contract alone and lets one caller decide the process's fate.

Drained before exiting, because os.Exit runs no defers and the CLI writes audit
rows: add-client and rotate-admin-token among them. Exiting straight from there
would drop the record of the very command being reported as failed, which is the
same defect one layer down. The seed-file failure below already does this.

The gate holds the shape rather than a list of commands, so a subcommand written
in the old style fails here instead of shipping. It matches the defect exactly
-- an ERROR: or Usage: write followed by a bare return true -- and deliberately
not every stderr write: this file also prints warnings and notes the command
continues past, and the admin-authentication refusal already calls exitProcess.
An earlier version flagged all five of those.
TestAuthenticatedCommandWithMissingArgumentsPrintsUsage asserted exit 0 for a
subcommand invoked without a required flag. That assertion was the defect
written down: handled is not the same as succeeded, and the two were conflated,
so `vault seed` with no --file printed its usage and returned success.

The half of that test worth keeping is untouched -- nothing starts listening,
which is what the comment was really protecting. Only the status assertion
changed direction.
The coverage gate reported five statements neither covered nor excluded, and the
one this branch owned was a drain that should never have been there.

I added _ = auditLogger.Close(ctx) before the exit on the theory that os.Exit
runs no defers and the CLI writes audit rows. It does not write any: internal/cli
holds repository.AuditRepository and uses it once, for a Query in export-audit.
There is no audit.Logger in that package at all, so there was nothing buffered to
drain and the line was both uncovered and untrue. The claim in the previous
commit message was wrong.

Also tried and reverted: moving the exit into Run, where exitProcess is a
stubbable seam and the path would be ordinary covered code rather than an
os.Exit in main. It does not work, and the reason is worth leaving in the source.
Retired subcommands are failures, so every test that drives one through Run --
TestRun_RoutesRetiredCleanupAudit among them -- reaches the real os.Exit and
takes the test binary with it. A seam only helps a test that knows to stub it,
and those do not have to.

No exclusion was needed either way, which is the part I had wrong going in: the
remaining os.Exit(1) measures as covered (block 237.26,239.4 executes once), so
the assumption that an os.Exit in main is inherently unreachable by the profile
was false for this one.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant