Add specs for the artifact registry provisioning service
What does this MR do and why?
This adds the artifact registry provisioning service, the monolith S10 Step 2 piece that turns an organization plus a requested slug into a provisioned AR namespace and a persisted mapping row.
ArtifactRegistry::ProvisionNamespaceService runs the provisioning flow below
the entry point:
- Validates the slug against the contract's syntactic rules (
SlugValidator), refusing before any client call. - Derives the billing anchor from the organization's single top-level group, refusing when the organization holds zero or several.
- Resolves an organization that already has a mapping to its existing row without calling AR, so a repeat request is idempotent.
- Otherwise logs the request (including the slug, the one wire field Rails never
stores) and the returned UUID, calls
POST /api/gitlab/v1/namespacesthrough the service-authenticated client, and writes the mapping row. The owner anchor sendsentity_idas the organization UUID; the billing anchor sends the top-level group id.
The organization uniqueness constraint is the concurrency guard, as the S10 spec
specifies: a losing racer's create! (a RecordInvalid from the model
validation, or a RecordNotUnique from the DB index for the taken organization)
is rescued and resolved to the winning row, so two same-slug requests produce one
row and both succeed. Any other RecordInvalid (for example a blank
ar_namespace_id from a malformed AR id) re-raises rather than being misread as a
concurrency resolution.
AR failures map to ServiceResponse reasons: 409 to :conflict, 422 to
:unprocessable, other client ApiError statuses to :api_error, and
unavailability to :service_unavailable. A Client::AuthorizationError (401/403)
is left to propagate as a top-level error rather than a payload reason, so the
Step 5 mutation renders resource-not-available per the S03 convention; a spec
locks that deliberate propagation.
Two collaborators ship alongside the service: SlugValidator (the contract's
slug rules transcribed once) and ArtifactRegistry::Logger (a
Gitlab::JsonLogger subclass tagged with the artifact_registry feature
category). The provisioning anchor constants live on ArtifactRegistry::Client,
which owns the wire contract.
The service has no caller yet; the activate mutation that drives it lands in a
later step. The artifact_registry_ui flag is enforced at the caller layer, so
there is no gate in these files and no changelog.
Note on entity_id: it carries the organization UUID, matching the merged S02
#provision_namespace guard, which requires a UUID for that field. The billing
anchor id is the top-level group id and is not UUID-guarded.
References
Work item: #608391 (closed)
Depends on the merged S02 Step 2 client method
ArtifactRegistry::Client#provision_namespace (!250031 (merged)).
Database changes
No schema changes. The service writes to artifact_registry_namespace_mappings
(one INSERT per provisioning) through the existing
ArtifactRegistry::NamespaceMapping model. Reads and the concurrency handling go
through the organization uniqueness constraint and the per-organization lease.
Screenshots or screen recordings
N/A. No UI in this MR; the service has no caller until a later step.
How to set up and validate locally
This validates the service against a doubled client, so no running AR service is required. It exercises the happy path, idempotent re-resolution, the pre-request refusals, and the AR error mappings, asserting each outcome.
-
Save the script below and run it:
bundle exec rails runner /tmp/ar_s10_step2_validate.rbOr paste it into
bundle exec rails console.require 'rspec/mocks/standalone' def check(label) ok = yield puts "#{ok ? 'PASS' : 'FAIL'}: #{label}" raise "assertion failed: #{label}" unless ok end org = FactoryBot.create(:organization) group = FactoryBot.create(:group, organization: org) slug = 'my-slug' ar_uuid = 'a1b2c3d4-0000-0000-0000-000000000000' ar_namespace = Struct.new(:id).new(ar_uuid) client = instance_double(ArtifactRegistry::Client) allow(ArtifactRegistry::Client).to receive(:new).and_return(client) credential = ArtifactRegistry::ServiceCredential.new # 1. Happy path: valid slug + single top-level group provisions and writes the row. allow(client).to receive(:provision_namespace).and_return(ar_namespace) result = ArtifactRegistry::ProvisionNamespaceService.new(organization: org, slug: slug, service_credential: credential).execute check('valid slug provisions and returns success') { result.success? } check('mapping row written with returned UUID') do org.reset.artifact_registry_namespace_mapping&.ar_namespace_id == ar_uuid end check('client called with UUID owner id and derived group billing anchor') do expect(client).to have_received(:provision_namespace).with( slug: slug, platform: 'gitlab', entity_type: 'organization', entity_id: org.uuid, billing_entity_type: 'group', billing_entity_id: group.id ) true end # 2. Idempotent: a repeat request resolves to the existing row without calling AR. repeat = ArtifactRegistry::ProvisionNamespaceService.new(organization: org.reset, slug: slug, service_credential: credential).execute check('repeat request succeeds') { repeat.success? } check('repeat request does not call AR again') do expect(client).to have_received(:provision_namespace).once true end # 3. Invalid slug is refused before any client call. org2 = FactoryBot.create(:organization) FactoryBot.create(:group, organization: org2) bad = ArtifactRegistry::ProvisionNamespaceService.new(organization: org2, slug: 'Bad.Slug', service_credential: credential).execute check('invalid slug refused with :invalid_slug') { bad.error? && bad.reason == :invalid_slug } # 4. Zero top-level groups is refused before any client call. org3 = FactoryBot.create(:organization) none = ArtifactRegistry::ProvisionNamespaceService.new(organization: org3, slug: slug, service_credential: credential).execute check('no billing anchor refused with :no_billing_anchor') { none.error? && none.reason == :no_billing_anchor } # 5. AR 409 surfaces as :conflict and writes no row. org4 = FactoryBot.create(:organization) FactoryBot.create(:group, organization: org4) allow(client).to receive(:provision_namespace) .and_raise(ArtifactRegistry::Client::ApiError.new('slug taken', status: 409, code: 'conflict')) conflict = ArtifactRegistry::ProvisionNamespaceService.new(organization: org4, slug: slug, service_credential: credential).execute check('AR 409 surfaces :conflict') { conflict.error? && conflict.reason == :conflict } check('no row written on 409') { org4.reset.artifact_registry_namespace_mapping.nil? } # 6. AR unavailability surfaces as :service_unavailable. org5 = FactoryBot.create(:organization) FactoryBot.create(:group, organization: org5) allow(client).to receive(:provision_namespace) .and_raise(ArtifactRegistry::Client::UnavailableError.new('service unavailable', status: 503)) unavail = ArtifactRegistry::ProvisionNamespaceService.new(organization: org5, slug: slug, service_credential: credential).execute check('AR 503 surfaces :service_unavailable') { unavail.error? && unavail.reason == :service_unavailable } puts 'ALL CHECKS PASSED' -
Expected output:
PASS: valid slug provisions and returns success PASS: mapping row written with returned UUID PASS: client called with UUID owner id and derived group billing anchor PASS: repeat request succeeds PASS: repeat request does not call AR again PASS: invalid slug refused with :invalid_slug PASS: no billing anchor refused with :no_billing_anchor PASS: AR 409 surfaces :conflict PASS: no row written on 409 PASS: AR 503 surfaces :service_unavailable ALL CHECKS PASSED -
Run the specs directly:
bundle exec rspec \ ee/spec/services/artifact_registry/provision_namespace_service_spec.rb \ ee/spec/lib/artifact_registry/slug_validator_spec.rb \ ee/spec/lib/artifact_registry/logger_spec.rb
MR acceptance checklist
Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.
Suggested labels
~"type::feature" ~backend