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
EmptyObjectschema, and the handler rejects unknown fields with400. 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_namespaceequivalents exist, so adding one becomes a deliberate change. - No nil-on-missing here. Unlike
#namespace, a404on a condition endpoint raisesApiErrorcarrying 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 asnil.
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
- Plan: monolith/S02 GitLab API namespace client, Step 3
- Spec: monolith/S02 AR Ruby client
- Depends on !250031 (merged) (Step 2), which depends on !249808 (merged) (Step 1). This MR targets Step 2's branch.
- Consuming slice: monolith/S10, whose disable and enable mutations call these methods.
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.
- Save the script below as
/tmp/validate_ar_step3.rb. - Run it:
bundle exec rails runner /tmp/validate_ar_step3.rb - 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 passedSpecs: 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