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:

  1. Validates the slug against the contract's syntactic rules (SlugValidator), refusing before any client call.
  2. Derives the billing anchor from the organization's single top-level group, refusing when the organization holds zero or several.
  3. Resolves an organization that already has a mapping to its existing row without calling AR, so a repeat request is idempotent.
  4. Otherwise logs the request (including the slug, the one wire field Rails never stores) and the returned UUID, calls POST /api/gitlab/v1/namespaces through the service-authenticated client, and writes the mapping row. The owner anchor sends entity_id as 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.

  1. Save the script below and run it:

    bundle exec rails runner /tmp/ar_s10_step2_validate.rb

    Or 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'
  2. 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
  3. 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

Edited by Narendran

Merge request reports

Loading
Loading