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.go and internal/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

Merge request reports

Loading
Loading