Draft: Harden the artifact registry client transport and specs
What does this MR do and why?
Groups four small, non-blocking hardening follow-ups from the S02 artifact
registry client review. All touch ee/lib/artifact_registry/client.rb or its
spec, so they ship together rather than as four MRs racing on the same files.
-
An unreadable body no longer reclassifies an outcome, and no longer leaks a bare literal to the user (bug). The
Faraday::ParsingErrorrescue inperform_requestpreserved the status only for 404; every other status with an unreadable body becameUnavailableErrorcarrying the client's own string. That misread a terminal conflict as a retryable outage: a 409 means "this will never succeed as sent", so degrading it could drive aprovision_namespaceretry that can never win. The rescue now delegates to a sharederror_class_for(status)classifier that also drivesraise_error, so an unreadable body classifies identically to a parseable one of the same status: 401/403 stayAuthorizationError, 429/5xx (and a malformed 2xx or 3xx) stayUnavailableError, and every other 4xx (400/404/409/413/422) keepsApiErrorwith the status. The unreadable path now passes no message, so the resolver'sRendersErrorsfallback renders its translateds_()string instead of a bare English literal. The request id is read from the response headers on the rescue path too, so an unreadable 4xx still carries itsX-Request-Idand is reported to error tracking before raising.User-facing effect: previously an unreadable 409/422 surfaced as the service-unavailable string; it now surfaces as an
ApiErrorwith the real status and a translated fallback message, so the S10 provisioning caller branches on:conflict/:unprocessableinstead of:service_unavailable. No present caller retries provisioning, so this closes a latent hazard rather than a live incident. -
RETRY_OPTIONScannot silently gain a retry allowlist bypass. faraday-retry evaluatesmethods.include?(...) || retry_if.call(...), so a futureretry_ifkey would OR-bypass the idempotent-method allowlist that keeps the state-changing POSTs from being replayed. A spec now asserts that invariant directly (RETRY_OPTIONShas noretry_ifkey), so adding one is a failing test rather than a silent bypass, without pinning the full key set that harmless additions would break. -
The parse-rescue boundary and the single-artifact read path gain the coverage they lacked. The unreadable-body taxonomy is now table-driven across 401/403/409/413/302/429/500 and asserts each classifies by its status, with a dedicated example proving an unreadable 4xx carries its response
X-Request-Idand is reported before raising. Single-artifact reads gain the symbol dot-segment and reserved-character rows their list siblings carry, an assertion that a malformed id mints no token, and an Integer id reframed as a.to_scoercion (the client does not validate id shape) rather than a pinned contract. The conditionApiErrorexample gains:aggregate_failures. -
The service-token wire contract gets a permanent, reproducible home. A new spec drives the real Rails service client over HTTP against a live AR
servicetokenguard, gated behind an explicitAR_WIRE_CONTRACT=1opt-in alongsideAR_SERVICE_TOKEN_ENDPOINTandAR_SERVICE_TOKENso it never makes a real network call just because a token env var is set for another purpose. The accepted-path example asserts the returned namespace id (not merely that no error raised), which is what proves the guard served the request rather than the path drifting to a 404 the client swallows; the reject path asserts a wrong token fails closed withAuthorizationError. A committed README documents how to stand the guard up. The header literal and the emitted wire form are already pinned over WebMock inclient_spec.rb, so this file adds only the real-guard check rather than duplicating them.
No feature flag, no migration, no user-visible change (the consumer flag
artifact_registry_ui is default-off), so no changelog trailer.
Database changes
None.
Screenshots or screen recordings
N/A. No user-facing change.
How to set up and validate locally
Unit specs (no AR service needed). The wire-contract spec's real-guard group is skipped here and its two examples show as pending:
bundle exec rspec \
ee/spec/lib/artifact_registry/client_spec.rb \
ee/spec/lib/artifact_registry/service_token_wire_contract_spec.rbResult: 678 examples, 0 failures, 2 pending.
RuboCop on the touched files:
bundle exec rubocop \
ee/lib/artifact_registry/client.rb \
ee/spec/lib/artifact_registry/client_spec.rb \
ee/spec/lib/artifact_registry/service_token_wire_contract_spec.rbResult: no offenses detected.
Wire contract against a real AR guard (optional, needs the AR binary). Follow
ee/spec/lib/artifact_registry/README_service_token_wire_contract.md to build
and start the servicetoken harness, then:
AR_WIRE_CONTRACT=1 \
AR_SERVICE_TOKEN_ENDPOINT=http://127.0.0.1:8099 \
AR_SERVICE_TOKEN=ar-svc-token \
bundle exec rspec ee/spec/lib/artifact_registry/service_token_wire_contract_spec.rbResult: 2 examples, 0 failures (the real-guard examples now run). Validated
against the real merged AR servicetoken.Middleware (artifact-registry main at
98a7b261): the accepted request arrived carrying Gitlab-Artifact-Registry-Token
with an empty Authorization and resolved to the seeded namespace, and a wrong
token was rejected fail-closed with AuthorizationError.
Related issues
Resolves #617755 Resolves #624331 Related to #617828 Related to #621813
Notes on scope:
- Work item 617754 (compose the GitLab API version constant) is already on
master (
GITLAB_API_VERSION/NAMESPACES_PATH), so it needs no change here. - Work item 617828 Category A is covered (the retry-allowlist spec, the
:aggregate_failuresadd, and the 409-comment correction). Its Category B (observabilityaction:key) and Category C (S10 consumer-facing contract notes) are out of scope and remain on the issue.
MR acceptance checklist
Evaluate this MR against the MR acceptance checklist.