Add artifact registry client namespace disable/enable (S02 Step 3)

What does this MR do and why?

Step 3 of the monolith/S02 GitLab API namespace client, and the last one: #disable_namespace and #enable_namespace.

Both are thin wrappers over one private helper that POSTs to /api/gitlab/v1/namespaces/:uuid/<action> on the service credential path, and both return the namespace with its recomputed status - disabled after a disable, active after an enable.

Three decisions a reviewer should check:

  • The helper sends no request body. AR's service-condition request body is optional with an EmptyObject schema, and the handler rejects unknown fields with 400. Sending nothing keeps that surface closed: a field added to this helper later cannot start being encoded silently, because there is no body to add it to. A spec asserts the POST goes out with an empty body.
  • Only this pair is exposed. AR serves six conditions from the same handler, but suspend and block are driven by billing and security tooling rather than by Rails, so the client does not expose them. A spec asserts no suspend_namespace / block_namespace equivalents exist, so adding one becomes a deliberate change.
  • No nil-on-missing here. Unlike #namespace, a 404 on a condition endpoint raises ApiError carrying the status. The caller is asking AR to change state, so a missing namespace is a failure to surface rather than an absence to report as nil.

Each method makes exactly one request: like provisioning, the POST is not retried, and a transport failure surfaces once as UnavailableError. The uuid is guarded the same way #namespace guards it - a blank or dot segment would rewrite the request path, so it is refused before a credential is obtained.

References

Screenshots or screen recordings

N/A. Library code with no user-facing surface.

How to set up and validate locally

The methods have no wired caller yet, so validation drives them directly with the AR HTTP responses stubbed. The action route set and the empty-body rule were checked against internal/gitlabapi/conditions.go and api/openapi/gitlab-v1.yaml in the artifact-registry repository.

  1. Save the script below as /tmp/validate_ar_step3.rb.
  2. Run it: bundle exec rails runner /tmp/validate_ar_step3.rb
  3. Expect 26/26 passed.
require 'webmock'
include WebMock::API
WebMock.enable!

BASE = 'https://artifact-registry.example.test'
UUID = 'a1b2c3d4-0000-0000-0000-000000000000'
JSON_HEADERS = { 'Content-Type' => 'application/json' }
service_credential = Class.new { def token = 'ar-service-token' }.new
results = []
check = ->(name, got, want) { results << [got == want, name, got, want] }
client = -> { ArtifactRegistry::Client.new(base_url: BASE, service_credential: service_credential) }
url = ->(action) { "#{BASE}/api/gitlab/v1/namespaces/#{UUID}/#{action}" }
body = ->(status) { { id: UUID, slug: 'my-group', platform: 'gitlab', entity_type: 'group',
                      entity_id: '42', status: status, created_at: '2026-07-01T10:00:00Z' }.to_json }

{ 'disable' => %w[disable_namespace disabled], 'enable' => %w[enable_namespace active] }.each do |action, (method, status)|
  # 1. Hits the action endpoint, sends NO body, returns the updated Namespace
  WebMock.reset!
  req = stub_request(:post, url.call(action))
    .with(headers: { 'Authorization' => 'Bearer ar-service-token' }) { |r| r.body.nil? || r.body.empty? }
    .to_return(status: 200, headers: JSON_HEADERS, body: body.call(status))
  ns = client.call.public_send(method, uuid: UUID)
  check.call("##{method} returns Namespace", ns.class.name, 'ArtifactRegistry::Namespace')
  check.call("##{method} derived status", ns.status, status)
  check.call("##{method} POSTs with no body",
    WebMock::RequestRegistry.instance.times_executed(req.request_pattern), 1)

  # 2. 404 and 400 raise ApiError carrying the status (no nil-on-missing here)
  [404, 400].each do |code|
    WebMock.reset!
    stub_request(:post, url.call(action)).to_return(status: code, headers: JSON_HEADERS,
      body: { error: { code: 'x', message: 'nope', request_id: "req-#{code}" } }.to_json)
    begin
      client.call.public_send(method, uuid: UUID)
      check.call("##{method} #{code} raises ApiError", 'no error', 'ApiError')
    rescue ArtifactRegistry::Client::ApiError => e
      check.call("##{method} #{code} raises ApiError with status", e.status, code)
    end
  end

  # 3. Exactly one request on a transport failure (no retry on the POST)
  WebMock.reset!
  t = stub_request(:post, url.call(action)).to_raise(Faraday::ConnectionFailed.new('boom'))
  begin
    client.call.public_send(method, uuid: UUID)
  rescue ArtifactRegistry::Client::UnavailableError
    nil
  end
  check.call("##{method} makes exactly one request",
    WebMock::RequestRegistry.instance.times_executed(t.request_pattern), 1)

  # 4. A uuid that would rewrite the path is refused before any credential
  WebMock.reset!
  guard = stub_request(:post, %r{/api/gitlab/v1/namespaces})
  [nil, '', '.', '..'].each do |bad|
    got = begin
      client.call.public_send(method, uuid: bad)
      :accepted
    rescue ArgumentError
      :rejected
    end
    check.call("##{method} refuses uuid #{bad.inspect}", got, :rejected)
  end
  check.call("##{method} guarded uuids issue no request",
    WebMock::RequestRegistry.instance.times_executed(guard.request_pattern), 0)
end

# 5. Only disable/enable are exposed: no suspend/block equivalents
%w[suspend_namespace unsuspend_namespace block_namespace unblock_namespace].each do |absent|
  check.call("does not expose ##{absent}", ArtifactRegistry::Client.public_method_defined?(absent), false)
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  #disable_namespace returns Namespace
PASS  #disable_namespace derived status
PASS  #disable_namespace POSTs with no body
PASS  #disable_namespace 404 raises ApiError with status
PASS  #disable_namespace 400 raises ApiError with status
PASS  #disable_namespace makes exactly one request
PASS  #disable_namespace refuses uuid nil
PASS  #disable_namespace refuses uuid ""
PASS  #disable_namespace refuses uuid "."
PASS  #disable_namespace refuses uuid ".."
PASS  #disable_namespace guarded uuids issue no request
PASS  #enable_namespace returns Namespace
PASS  #enable_namespace derived status
PASS  #enable_namespace POSTs with no body
PASS  #enable_namespace 404 raises ApiError with status
PASS  #enable_namespace 400 raises ApiError with status
PASS  #enable_namespace makes exactly one request
PASS  #enable_namespace refuses uuid nil
PASS  #enable_namespace refuses uuid ""
PASS  #enable_namespace refuses uuid "."
PASS  #enable_namespace refuses uuid ".."
PASS  #enable_namespace guarded uuids issue no request
PASS  does not expose #suspend_namespace
PASS  does not expose #unsuspend_namespace
PASS  does not expose #block_namespace
PASS  does not expose #unblock_namespace

26/26 passed

Specs: bundle exec rspec ee/spec/lib/artifact_registry/ - 346 examples, 0 failures.

Database changes

None. No migrations, models, or queries; this MR is library code under ee/lib.

MR acceptance checklist

  • Tests cover both methods, the empty body, the failure statuses, the one-request guarantee, the uuid guard, and the absence of suspend/block
  • RuboCop clean on the changed files
  • No feature flag: the methods have 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