feat(cmd): retire-key-version action
Implements plan step 5 of
docs/plans/2026-08-03-s04b-rotation-tooling.md:
the retire-key-version action, per the spec's "Key-version
retirement" section.
The command wraps Step 1's store contract in the operator surface: an
advisory pre-flight (referencing-row count, successor created_at and
age in seconds) logged at Info before the delete, the single-transaction
RetireKeyVersion store call, and the outcome contract — guard
refusals log one Warn line naming the condition and exit 1 surfacing
the store's sentinel-wrapping error; success ends with the Info summary
line carrying the pre-flight count. Every retirement line is stamped
with the target's namespace_id and key_version. Stray positional
arguments are refused as usage errors before any database access. The
FK and the store's guards — not the pre-flight — remain authoritative.
Stacking
Stacked on !1344 (merged) (sahmed/s04b-step2-composition-seam, carrying plan
steps 2–4). Targets that branch; GitLab retargets to main when !1344 (merged)
merges. Currently a fast-forward on its target.
Provisional spec reading (needs spec-author confirmation)
The spec's pre-flight paragraph says the successor lookup's
ErrKeyNotFound logs and proceeds, but does not name the outcome for
other pre-flight read errors. This MR reads them as the Error Cases
table's "Database unreachable" row: exit 1 without attempting the
delete (fail closed). Pinned in
TestRetireKeyVersion_PreflightReadErrorFailsClosed.
Changes beyond the plan's Step 5 file list
cmd/artifact-registry/operator_test.go: the log-field-key constant pin gains the six retire keys this MR introduces.cmd/artifact-registry/reencrypt_test.goandinternal/testutil/credential_fk_table.go: one-sentence doc corrections for prose this MR's landing would otherwise make stale (the FK helper's "will use it once the action lands" and a plan-step-scoped comment on the shared subcommand test helper).
Size
1,198 added lines against the ~500 guideline: the action itself is
~150 lines of retire.go; the rest is the unit matrix (fake store
with log-stream snapshots proving pre-flight-before-delete) and the
end-to-end integration suite against provisioned PostgreSQL, both of
which the plan assigns to this step.
E2E scenario catalogs
No scenario added or affected: the catalogs cover format-client journeys; this is operator-only rotation tooling with no user-facing surface.
Database review
No new migration or query-producing method — all queries are Step 1's store operations, reviewed in !1343 (merged).
Spec coverage
Spec: docs/specs/S04-b-encryption-key-rotation-tooling.md
Acceptance criteria
| # | Criterion | Tests |
|---|---|---|
| AC-1 | root scope re-wraps every live row; re-run processes zero | Step 4 (landed on this branch: reencrypt_root_integration_test.go). |
| AC-2 | single-key file: zero rows, still verifies | Step 4 (landed). |
| AC-3 | verification emptiness / offending root_key_uri |
read: Step 1; command: Step 4 (both landed). |
| AC-4 | root refuses on --active-key-uri mismatch, logs URIs |
Step 4 (landed). |
| AC-5 | namespace rotate creates one version; refusals | Step 8. |
| AC-6 | sweep loop semantics (order, tripwires, counts, dup TableName) | Step 7 (loop); Step 8 (command). |
| AC-7 | ErrStaleActiveKey aborts sweep, exit 1 |
Step 8. |
| AC-8 | RetireKeyVersion deletes only the target; sentinel matrix |
store: Step 1 (landed); command-level this MR: TestRetireKeyVersionIntegration_DeletesUnreferencedVersion, TestRetireKeyVersionIntegration_RefusalSentinels, TestRetireKeyVersion_RefusalSentinelsExitOneNamingCondition. |
| AC-9 | FK refusal (23001/23503) → ErrKeyStillReferenced |
store: Step 1 (landed); command-level this MR: TestRetireKeyVersionIntegration_ReferencedVersionRefusedThenRetires, TestRetireKeyVersion_NonZeroPreflightCountProceedsToOutcome. |
| AC-10 | retirement serializes vs shred/rotate | Step 6. |
| AC-11 | invalid invocations exit 1 before DB access | retire flags: TestRetireKeyVersionCommand_UsageErrorsExitBeforeDatabase (extended in this MR with the stray-positional refusal); re-encrypt flags: Step 3 (landed). |
| AC-12 | no encryption block fails at startup naming block | TestRetireKeyVersionCommand_MissingEncryptionBlockRefused (pre-existing, Step 3); composition: Step 2 (landed). |
| AC-13 | retire-key-version logs pre-flight count and successor age before the DELETE |
TestRetireKeyVersion_PreflightLogsBeforeDelete, TestRetireKeyVersionIntegration_DeletesUnreferencedVersion, TestRetireKeyVersionIntegration_SuccessorSkipsRetiredIntermediates; no-successor path: TestRetireKeyVersion_NoSuccessorLogsAndProceeds, TestRetireKeyVersionIntegration_NoSuccessorLogsAndProceeds. |
| AC-14 | captured logs carry no key material | retire flow: TestRetireKeyVersionIntegration_DeletesUnreferencedVersion (assertLogsCarryNoKeyMaterial over seeded wrapped-key bytes); other flows: Steps 2/4 (landed), 8. |
Error cases
| Condition | Tests |
|---|---|
| Usage errors (flags/scope/phase/uuid/version) | TestRetireKeyVersionCommand_UsageErrorsExitBeforeDatabase (retire); Step 3 (re-encrypt, landed). |
No encryption block (or non-local topology) |
TestRetireKeyVersionCommand_MissingEncryptionBlockRefused; Step 2 (composition, landed). |
| Root-key secret missing or undecodable | Step 2/4 (landed); retirement never loads a secret. |
| Root scope rows (transient failure, unwrap, duplicate, orientation, verification) | Step 4 (landed). |
| Interruption (signal/context cancellation) mid-run | composition cancellation: Step 2 (landed); retirement's single-transaction atomicity is the store's withTx envelope: Step 1 (landed), Step 6. |
| Rotate/sweep phase rows | Steps 7/8. |
| Retire: namespace carries any tombstone | TestRetireKeyVersionIntegration_RefusalSentinels ("namespace carrying any tombstone": exit 1, Warn, nothing deleted), TestRetireKeyVersion_RefusalSentinelsExitOneNamingCondition ("shredded namespace"); store: Step 1 (landed). |
| Retire: version at or above the active key | TestRetireKeyVersionIntegration_RefusalSentinels ("the active version itself", "a version above the active one"), TestRetireKeyVersionIntegration_NoSuccessorLogsAndProceeds, TestRetireKeyVersion_RefusalSentinelsExitOneNamingCondition ("active or above version"). |
| Retire: version does not exist below active / no key rows | TestRetireKeyVersionIntegration_RefusalSentinels ("a version that does not exist below the active key", "a namespace with no key rows"), TestRetireKeyVersion_RefusalSentinelsExitOneNamingCondition ("version not found"). |
| Retire: credential rows still reference the version | TestRetireKeyVersionIntegration_ReferencedVersionRefusedThenRetires (FK refusal, Warn, pre-flight count logged, then the non-referenced happy delete on re-run), TestRetireKeyVersion_NonZeroPreflightCountProceedsToOutcome. |
| Database unreachable | TestRunOperator_DatabaseUnreachableSurfacedAsClientStartError (pre-existing, Step 2); pre-flight query error: TestRetireKeyVersion_PreflightReadErrorFailsClosed (provisional reading, see above). |
Security considerations
| Concern | Tests |
|---|---|
| Two security reviews gate the delivery MRs (WI 6) | Process/delivery gate. Not a unit test. |
| Key material through process memory (Zeroize) | S04-A internal/crypto-owned; retirement composes without key material (retireKeyVersion doc pins the contract). |
| No log carries key material; log keys fixed constants | TestRetireKeyVersionIntegration_DeletesUnreferencedVersion (assertLogsCarryNoKeyMaterial); constants file-scope in retire.go. |
| Access control is the deployment boundary | Deployment-owned. Not code-testable. |
| Backup-retention window before retirement (runbook) | age visibility at the decision point: TestRetireKeyVersion_PreflightLogsBeforeDelete, TestRetireKeyVersionIntegration_SuccessorSkipsRetiredIntermediates; runbook page: Step 9. |
| Commands must run with the fleet's configuration file | Operational. Not code-testable. |
| FIPS posture logged at startup | TestRunOperator_LogsFIPSVersionAtStartup (pre-existing, Step 2). |
Related to https://gitlab.com/gitlab-org/ops/artifact-registry/-/issues/496