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 201 on a create and 200 on 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 as UnavailableError and 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) and 422 both raise ApiError carrying the status, envelope code and request_id; the status is what separates them, since both 409 causes share the one conflict code. Across a transport failure, a malformed success body, 429 and 5xx, a sentinel planted in each of the six fields must appear on none of the report surfaces, including each exception's cause chain. 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

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.

  1. Save the script below as /tmp/validate_ar_step2.rb.
  2. Run it: bundle exec rails runner /tmp/validate_ar_step2.rb
  3. 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 passed

Specs: 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
Edited by Narendran

Merge request reports

Loading
Loading