AR GraphQL manifests connection (monolith/S14 step 7)

What does this MR do and why?

This MR adds the manifests keyset connection on the existing ArtifactRegistryImage GraphQL type (monolith/S14 Step 7). The ArtifactRegistryImage type and the ImagesResolver already exist on master; this MR mounts the new connection on that existing type.

It adds a new ArtifactRegistryManifest type with these fields:

  • id
  • digest
  • mediaType
  • artifactType (nullable)
  • subjectDigest (nullable)
  • size (BigInt, so a size above 2 GB does not overflow)
  • createdAt (nullable)

The ManifestsResolver reads the repository, format, organization, and image ID off the artifact presenter and calls the Artifact Registry client #manifests through PaginatesLists, returning an externally-paginated connection whose pageInfo mirrors the Link-header cursors. A non-image repository format is guarded before the read, mirroring the images resolver.

The read leaves the client on its include_referrers=false default (referrer inclusion is a later step, Step 10). Because of that default, subjectDigest is always null on this connection today, and for a remote repository artifactType is also null.

size semantics:

  • Hosted repository: the push-time manifest-tree total. An index total already contains its platform children, so it does not sum across sibling rows.
  • Remote repository: the cached manifest's own payload bytes.

Fan-out bound

The manifests field carries FieldCallCount with limit: 20. The connection hangs off an image element, so a list of N images resolves it once per row. The limit of 20 matches the parent images connection's max_page_size, so a full page of 20 images still resolves the field for every row, while aliases cannot multiply the outbound fan-out past the row count. FieldCallCount counts total resolutions per operation (keyed on operation fingerprint and field), not per row. This corrects an earlier version that shipped with no budget.

Screenshots or screen recordings

N/A. No UI: the artifact_registry_ui feature flag is dark. Field descriptions are schema text, not i18n. No changelog because the feature is dark.

How to setup and validate locally

Run the script below with bundle exec rails runner <file> (or paste it into rails console). It stubs the Artifact Registry HTTP layer, sets up an organization and member, and asserts once-per-row fan-out, one request per row, correct field mapping, and the alias budget. It has been run and passes with output 1 OK / 2 OK / 3 OK / 4 OK / ALL OK.

require 'webmock'
require 'rspec/mocks/standalone'
include WebMock::API
WebMock.enable!
WebMock.disable_net_connect!(allow: ['gdk.test', '127.0.0.1', 'localhost'])

ActiveRecord::Base.transaction do
  s = SecureRandom.hex(4)
  org  = FactoryBot.create(:organization, path: "arm-#{s}", name: "AR #{s}")
  user = FactoryBot.create(:user, username: "arm-#{s}", email: "arm-#{s}@example.com")
  FactoryBot.create(:organization_user, organization: org, user: user)
  slug = Organizations::ArtifactRegistry::STUB_SLUG
  base = 'http://artifact-registry.test'
  Gitlab.config.artifact_registry['api_url'] = base
  Feature.enable(:artifact_registry_ui, org)
  allow_any_instance_of(ArtifactRegistry::TokenExchange).to receive(:token_for).and_return('tok')

  repo_name  = 'container-images'
  image_id   = 'e5f6a7b8-0000-0000-0000-000000000000'
  other_id   = 'f6a7b8c9-0000-0000-0000-000000000000'
  repo_url   = "#{base}/api/v1/#{slug}/repositories/#{repo_name}"
  images_url = "#{repo_url}/docker/images"
  man_url    = "#{images_url}/#{image_id}/manifests"
  other_url  = "#{images_url}/#{other_id}/manifests"
  jh = { 'Content-Type' => 'application/json' }

  stub_request(:get, repo_url).to_return(status: 200, headers: jh,
    body: { 'id' => 'r1', 'name' => repo_name, 'format' => 'docker', 'kind' => 'hosted',
            'visibility' => 'private', 'downloads_count' => 0, 'size_bytes' => 0, 'settings' => {} }.to_json)
  stub_request(:get, images_url).with(query: { limit: '20' }).to_return(status: 200, headers: jh,
    body: [{ 'id' => image_id, 'name' => 'api-gateway' }, { 'id' => other_id, 'name' => 'payment-core' }].to_json)

  manifest = { 'id' => 'm1', 'digest' => 'sha256:aaaa',
               'media_type' => 'application/vnd.oci.image.manifest.v1+json',
               'artifact_type' => nil, 'subject_digest' => nil, 'size' => 2048,
               'created_at' => '2026-07-03T09:15:00Z' }
  stub_request(:get, man_url).with(query: { limit: '20', include_referrers: 'false' })
    .to_return(status: 200, headers: jh, body: [manifest].to_json)
  stub_request(:get, other_url).with(query: { limit: '20', include_referrers: 'false' })
    .to_return(status: 200, headers: jh, body: [manifest.merge('id' => 'm2', 'digest' => 'sha256:cccc')].to_json)

  query = <<~GQL
    query($id: OrganizationsOrganizationID!, $name: String!) {
      organization(id: $id) {
        artifactRegistryRepository(name: $name) {
          images(first: 20) {
            nodes {
              id
              manifests(first: 20) {
                nodes { id digest mediaType artifactType subjectDigest size createdAt }
                pageInfo { hasNextPage hasPreviousPage }
              }
            }
          }
        }
      }
    }
  GQL
  vars = { 'id' => org.to_global_id.to_s, 'name' => repo_name }
  res  = GitlabSchema.execute(query, context: { current_user: user }, variables: vars)
  raise "errors: #{res['errors']}" if res['errors']

  image_nodes = res.dig('data', 'organization', 'artifactRegistryRepository', 'images', 'nodes')
  ids = image_nodes.map { |n| n.dig('manifests', 'nodes').map { |m| m['id'] } }
  raise "1 FAIL: fan-out #{ids.inspect}" unless ids == [['m1'], ['m2']]
  puts '1 OK: an image list resolves manifests once per row, distinct results per image'

  assert_requested(:get, man_url, query: { limit: '20', include_referrers: 'false' }, times: 1)
  assert_requested(:get, other_url, query: { limit: '20', include_referrers: 'false' }, times: 1)
  puts '2 OK: one manifests request per image row, no per-row multiplication'

  m = image_nodes.first.dig('manifests', 'nodes').first
  raise '3 FAIL: field mapping' unless m['digest'] == 'sha256:aaaa' && m['size'] == '2048' &&
    m['subjectDigest'].nil? && m['artifactType'].nil?
  puts '3 OK: manifest fields map, size is BigInt string, referrer fields null on the default read'

  alias_query = <<~GQL
    query($id: OrganizationsOrganizationID!, $name: String!) {
      organization(id: $id) {
        artifactRegistryRepository(name: $name) {
          images(first: 20) {
            nodes {
              id
              #{(1..21).map { |n| "a#{n}: manifests(first: 1) { nodes { id } }" }.join("\n")}
            }
          }
        }
      }
    }
  GQL
  stub_request(:get, man_url).with(query: { limit: '1', include_referrers: 'false' })
    .to_return(status: 200, headers: jh, body: [manifest].to_json)
  stub_request(:get, other_url).with(query: { limit: '1', include_referrers: 'false' })
    .to_return(status: 200, headers: jh, body: [manifest].to_json)
  alias_res = GitlabSchema.execute(alias_query, context: { current_user: user }, variables: vars)
  msgs = (alias_res['errors'] || []).map { |e| e['message'] }.join(' ')
  raise "4 FAIL: no budget error, got #{msgs.inspect}" unless msgs.include?('can be requested only for 20')
  puts '4 OK: aliasing the field past 20 per operation is rejected by the call-count budget'

  raise ActiveRecord::Rollback
end
WebMock.disable!
puts 'ALL OK'

Automated tests cover the same ground:

  • ArtifactRegistryManifest type spec: field list, types, nullability, BigInt size.
  • Manifests resolver spec: limit-20 budget, format guard, page-size cap below and above the max, forward and backward cursors, no include_referrers, null-on-missing.
  • Image-type field-list spec.
  • End-to-end request spec reaching manifests through images(first: 20): two image rows each resolving the field once (one request per row), per-format listing, empty connection rendering [], the include_referrers=false default, pageInfo cursors, page-size cap, silent 401/403/404, 5xx top-level error, transport failure and timeout, alias-per-row within budget, aliasing past the budget rejected, and non-member and anonymous callers hidden.

All specs pass locally; RuboCop clean; GraphQL schema artifacts regenerated.

MR acceptance checklist

Please refer to the project's MR acceptance checklist when reviewing this change.

Dependencies (branch stacking)

Stacked on:

These transitively carry Steps 1 and 4. Until they merge, this MR's diff also contains their commits.

Review/merge order: 1, then 3, then 4, then 5, then 7.

References

Edited by Narendran

Merge request reports

Loading
Loading