Add artifact registry client #provision_namespace (S02 Step 2)
What does this MR do and why?
Step 2 of the monolith/S02 GitLab API namespace client: #provision_namespace, which creates
a namespace in Artifact Registry.
It sends the six fields the AR contract requires - the handle (slug), the platform, the
owner anchor (entity_type / entity_id) and the billing anchor (billing_entity_type /
billing_entity_id) - to POST /api/gitlab/v1/namespaces on the service credential path
added in Step 1, and returns the created namespace as an ArtifactRegistry::Namespace.
Two behaviours are worth a reviewer's attention:
- The POST is never retried. AR answers
201on a create and200on an exact-anchor replay, and the client treats both the same way: a namespace came back, so it returns it. But a request whose response is lost must not be retried here, or provisioning double-applies. It surfaces once asUnavailableErrorand recovery belongs to the calling service, which can reproduce the original request safely. A spec asserts exactly one request reaches AR on a transport failure, so a future change that made this path retryable would fail rather than silently double-apply. - No body field may reach a report. This is the first method that sends a request body,
so the body-field redaction checks live here.
409(handle taken, or an anchor-replay mismatch) and422both raiseApiErrorcarrying the status, envelopecodeandrequest_id; the status is what separates them, since both409causes share the oneconflictcode. Across a transport failure, a malformed success body,429and5xx, a sentinel planted in each of the six fields must appear on none of the report surfaces, including each exception'scausechain. The report context stays the allowlist Step 1 established, so the body cannot arrive as an extra context key either.
Everything else follows the client's existing status taxonomy: 401/403 raise
AuthorizationError, 429 and any 5xx raise UnavailableError, and other non-2xx
statuses raise ApiError.
References
- Plan: monolith/S02 GitLab API namespace client, Step 2
- Spec: monolith/S02 AR Ruby client
- Depends on !249808 (merged) (Step 1: the service credential seam). This MR targets that branch.
- Consuming slice: monolith/S10, whose provisioning service calls this method and owns replay-safe recovery.
Screenshots or screen recordings
N/A. Library code with no user-facing surface.
How to set up and validate locally
The method has no wired caller yet, so validation drives it directly with the AR HTTP
responses stubbed. Route shape and the error envelope were checked against
internal/gitlabapi/handler.go and api/openapi/gitlab-v1.yaml in the artifact-registry
repository.
- Save the script below as
/tmp/validate_ar_step2.rb. - Run it:
bundle exec rails runner /tmp/validate_ar_step2.rb - Expect
22/22 passed.
require 'webmock'
include WebMock::API
WebMock.enable!
BASE = 'https://artifact-registry.example.test'
UUID = 'a1b2c3d4-0000-0000-0000-000000000000'
NS_URL = "#{BASE}/api/gitlab/v1/namespaces"
JSON_HEADERS = { 'Content-Type' => 'application/json' }
service_credential = Class.new { def token = 'ar-service-token' }.new
ARGS = { slug: 'my-group', platform: 'gitlab', entity_type: 'group', entity_id: '42',
billing_entity_type: 'group', billing_entity_id: '7' }.freeze
BODY = { id: UUID, slug: 'my-group', platform: 'gitlab', entity_type: 'group',
entity_id: '42', status: 'active', created_at: '2026-07-01T10:00:00Z' }.freeze
results = []
check = ->(name, got, want) { results << [got == want, name, got, want] }
client = -> { ArtifactRegistry::Client.new(base_url: BASE, service_credential: service_credential) }
# 1. 201 create returns the Namespace and sends all six body fields
WebMock.reset!
req = stub_request(:post, NS_URL).with(body: ARGS.to_json)
.to_return(status: 201, headers: JSON_HEADERS, body: BODY.to_json)
ns = client.call.provision_namespace(**ARGS)
check.call('201 returns Namespace', ns.class.name, 'ArtifactRegistry::Namespace')
check.call('201 sends all six body fields',
WebMock::RequestRegistry.instance.times_executed(req.request_pattern), 1)
# 2. 200 anchor replay is treated the same as a create
WebMock.reset!
stub_request(:post, NS_URL).to_return(status: 200, headers: JSON_HEADERS, body: BODY.to_json)
check.call('200 replay returns Namespace', client.call.provision_namespace(**ARGS).id, UUID)
# 3. 409 and 422 raise ApiError carrying status, code and request_id
[409, 422].each do |status|
WebMock.reset!
stub_request(:post, NS_URL).to_return(status: status, headers: JSON_HEADERS,
body: { error: { code: 'conflict', message: 'taken', request_id: "req-#{status}" } }.to_json)
begin
client.call.provision_namespace(**ARGS)
check.call("#{status} raises ApiError", 'no error', 'ApiError')
rescue ArtifactRegistry::Client::ApiError => e
check.call("#{status} raises ApiError", e.status, status)
check.call("#{status} carries code", e.code, 'conflict')
check.call("#{status} carries request_id", e.request_id, "req-#{status}")
end
end
# 4. The POST is never retried: one request, even on a transport failure
WebMock.reset!
transport = stub_request(:post, NS_URL).to_raise(Faraday::ConnectionFailed.new('boom'))
begin
client.call.provision_namespace(**ARGS)
rescue ArtifactRegistry::Client::UnavailableError
nil
end
check.call('POST is never retried',
WebMock::RequestRegistry.instance.times_executed(transport.request_pattern), 1)
# 5. No body field reaches any report surface, on every failure outcome
sentinels = ARGS.transform_values { |v| "SENTINEL-#{v}-xyz" }
outcomes = {
'transport failure' => -> { stub_request(:post, NS_URL).to_raise(Faraday::ConnectionFailed.new('boom')) },
'malformed success' => -> { stub_request(:post, NS_URL).to_return(status: 201, body: 'not-json', headers: JSON_HEADERS) },
'429' => -> { stub_request(:post, NS_URL).to_return(status: 429, headers: JSON_HEADERS, body: { error: { message: 'slow' } }.to_json) },
'5xx' => -> { stub_request(:post, NS_URL).to_return(status: 503, headers: JSON_HEADERS, body: { error: { message: 'down' } }.to_json) }
}
allowed_keys = %i[url method correlation_id status request_id]
outcomes.each do |name, stub|
WebMock.reset!
captured = []
Gitlab::ErrorTracking.singleton_class.prepend(Module.new do
define_method(:log_exception) { |e, c = {}| captured << [e, c] }
end)
stub.call
begin
client.call.provision_namespace(**sentinels)
rescue ArtifactRegistry::Client::Error
nil
end
haystack = captured.map { |e, c| [e.message, e.cause&.message, c].map(&:inspect).join(' ') }.join("\n")
leaked = sentinels.values.select { |v| haystack.include?(v) }
extra_keys = captured.filter_map { |_e, c| c }.flat_map(&:keys).uniq - allowed_keys
check.call("#{name}: reported at least once", captured.empty?, false)
check.call("#{name}: no body field leaked", leaked, [])
check.call("#{name}: no out-of-allowlist context key", extra_keys, [])
end
puts "\n=== RESULTS ==="
results.each { |ok, name, got, want| puts "#{ok ? 'PASS' : 'FAIL'} #{name}#{ok ? '' : " (got #{got.inspect}, want #{want.inspect})"}" }
puts "\n#{results.count { |r| r[0] }}/#{results.size} passed"Output from this branch:
=== RESULTS ===
PASS 201 returns Namespace
PASS 201 sends all six body fields
PASS 200 replay returns Namespace
PASS 409 raises ApiError
PASS 409 carries code
PASS 409 carries request_id
PASS 422 raises ApiError
PASS 422 carries code
PASS 422 carries request_id
PASS POST is never retried
PASS transport failure: reported at least once
PASS transport failure: no body field leaked
PASS transport failure: no out-of-allowlist context key
PASS malformed success: reported at least once
PASS malformed success: no body field leaked
PASS malformed success: no out-of-allowlist context key
PASS 429: reported at least once
PASS 429: no body field leaked
PASS 429: no out-of-allowlist context key
PASS 5xx: reported at least once
PASS 5xx: no body field leaked
PASS 5xx: no out-of-allowlist context key
22/22 passedSpecs: bundle exec rspec ee/spec/lib/artifact_registry/ - 307 examples, 0 failures.
Database changes
None. No migrations, models, or queries; this MR is library code under ee/lib.
MR acceptance checklist
- Tests cover the create, the replay, each failure status, the no-retry guarantee, and the body-field redaction across all four failure outcomes
- RuboCop clean on the changed files
- No feature flag: the method has no caller until S10, and library code carries no gate
- No changelog entry: no user-visible change ships in this MR