AR client manifest list read (monolith/S14 plan: 3/25)

What does this MR do and why?

Adds #manifests to ArtifactRegistry::Client for monolith/S14, Step 3 (the container side of the version-list slice). It reads the AR manifests sub-collection (GET .../:format/images/:image_id/manifests) and returns a keyset page of manifest rows plus both Link-header cursors, so a later GraphQL step can page an image's manifests.

The read routes through the shared #artifact_page helper the same way #versions does, rather than repeating the success-body check, the all?(Hash) guard, the cursor parsing and the Page construction. A manifest is a new Manifest value object; created_at is coerced through the shared TimeCoercion, while size, artifact_type and subject_digest pass through as-is (size is a push-time tree total, and subject_digest is only set on a referrer row). include_referrers is always sent and coerced with Gitlab::Utils.to_boolean(default: false), so a nil sends false (rather than a bare ?include_referrers= that AR rejects) and a "false" string reads as false instead of flipping referrers on. A 404 resolves to nil (the image was removed between reads) and logs the failing image id for drift diagnosis, matching #versions; every other outcome maps through the Client::Error hierarchy.

Database changes

None. This is a client HTTP read; no migrations, queries, or indexes.

Screenshots or screen recordings

N/A. No user-facing change; the client is not reachable from the UI until later steps, and the feature is behind a disabled flag.

How to set up and validate locally

The client talks to Artifact Registry over HTTP, so the script below stubs the endpoint with a Faraday test adapter and a fake token. It runs offline with no real AR and no feature flag. Paste it into rails console (or save it and run bundle exec rails runner <file>):

# Validation for ArtifactRegistry::Client#manifests (monolith/S14 Step 3)
# Stubs the AR HTTP endpoint with a Faraday test adapter and a fake token, so it runs
# offline with no real Artifact Registry.
require 'faraday'

current_user = FactoryBot.build(:user)

slug            = 'my-group'
repository_name = 'container-images'
image_id        = 'a1b2c3d4-0000-0000-0000-000000000000'
base_url        = 'https://artifact-registry.example.test'
path            = "/api/v1/#{slug}/repositories/#{repository_name}/docker/images/#{image_id}/manifests"

manifest_rows = [
  { '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' },
  { 'id' => 'm2', 'digest' => 'sha256:bbbb', 'media_type' => 'application/vnd.oci.image.manifest.v1+json',
    'artifact_type' => nil, 'subject_digest' => nil, 'size' => 512, 'created_at' => '2026-07-02T09:15:00Z' }
]
next_cursor = 'eyJpZCI6Mn0'
prev_cursor = 'eyJpZCI6MX0'
link_header = %(<#{base_url}#{path}?cursor=#{next_cursor}>; rel="next", <#{base_url}#{path}?cursor=#{prev_cursor}>; rel="prev")

def build_client(base_url, current_user, stubs)
  conn = Faraday.new(base_url) do |c|
    c.request :json
    c.response :json, content_type: 'application/json'
    c.adapter :test, stubs
  end
  client = ArtifactRegistry::Client.new(
    base_url: base_url,
    current_user: current_user,
    token_exchange: Struct.new(:t) { def token_for(*) = 'fake-token' }.new,
    service_credential: Struct.new(:t) { def token = 'fake-token' }.new
  )
  client.define_singleton_method(:connection) { conn }
  client
end

failures = []

# 1. Happy path: default include_referrers=false, returns a Page of Manifest rows + cursors.
stubs = Faraday::Adapter::Test::Stubs.new do |stub|
  stub.get(path) do |env|
    unless env.params['include_referrers'] == 'false'
      raise "include_referrers not defaulted to false: #{env.params.inspect}"
    end

    [200, { 'Content-Type' => 'application/json', 'Link' => link_header }, manifest_rows.to_json]
  end
end
page = build_client(base_url, current_user, stubs)
  .manifests(slug: slug, repository_name: repository_name, format: 'docker', image_id: image_id)

failures << "expected Page, got #{page.class}" unless page.is_a?(ArtifactRegistry::Page)
failures << "rows should be Manifest VOs" unless page.nodes.all?(ArtifactRegistry::Manifest)
failures << "digests wrong: #{page.nodes.map(&:digest)}" unless page.nodes.map(&:digest) == %w[sha256:aaaa sha256:bbbb]
failures << "next_cursor wrong: #{page.next_cursor}" unless page.next_cursor == next_cursor
failures << "prev_cursor wrong: #{page.prev_cursor}" unless page.prev_cursor == prev_cursor
m = page.nodes.first
failures << "size not read: #{m.size.inspect}" unless m.size == 2048
failures << "created_at not coerced: #{m.created_at.class}" unless m.created_at.is_a?(DateTime)
failures << "nullable subject_digest not nil: #{m.subject_digest.inspect}" unless m.subject_digest.nil?

# 2. include_referrers coercion: a "false" string reads as false, not truthy-true.
seen = {}
stubs2 = Faraday::Adapter::Test::Stubs.new do |stub|
  stub.get(path) do |env|
    seen[:ref] = env.params['include_referrers']
    [200, { 'Content-Type' => 'application/json' }, manifest_rows.to_json]
  end
end
build_client(base_url, current_user, stubs2)
  .manifests(slug: slug, repository_name: repository_name, format: 'docker', image_id: image_id,
             include_referrers: 'false')
failures << "\"false\" string flipped referrers on: #{seen[:ref].inspect}" unless seen[:ref] == 'false'

# 3. 404 resolves nil (image removed between reads), not an error.
stubs3 = Faraday::Adapter::Test::Stubs.new { |s| s.get(path) { [404, { 'Content-Type' => 'application/json' }, '{}'] } }
nf = build_client(base_url, current_user, stubs3)
  .manifests(slug: slug, repository_name: repository_name, format: 'docker', image_id: image_id)
failures << "404 should resolve nil, got #{nf.inspect}" unless nf.nil?

# 4. A blank image_id raises before any request (segment guard inside artifact_page).
begin
  build_client(base_url, current_user, Faraday::Adapter::Test::Stubs.new)
    .manifests(slug: slug, repository_name: repository_name, format: 'docker', image_id: '')
  failures << 'blank image_id should have raised ArgumentError'
rescue ArgumentError
  # expected
end

# 5. A non-image format raises before any request (format guard).
begin
  build_client(base_url, current_user, Faraday::Adapter::Test::Stubs.new)
    .manifests(slug: slug, repository_name: repository_name, format: 'maven', image_id: image_id)
  failures << 'maven format should have raised ArgumentError'
rescue ArgumentError
  # expected
end

if failures.empty?
  puts 'VALIDATION PASSED'
else
  puts 'VALIDATION FAILED:'
  failures.each { |f| puts "  - #{f}" }
end

Expected output:

VALIDATION PASSED

You can also run the specs directly:

bundle exec rspec ee/spec/lib/artifact_registry/client_spec.rb ee/spec/lib/artifact_registry/manifest_spec.rb ee/spec/lib/artifact_registry/page_spec.rb

Suggested labels

~"type::feature", ~backend, ~"Category:Artifact Registry"

MR acceptance checklist

Evaluate this MR against the MR acceptance checklist.

References

Feature flag artifact_registry_ui is dark; the client is not flag-gated. No changelog (dark), no i18n (library code). Rebased onto master after Steps 1, 2 and 4 merged, so #manifests now reuses the shared artifact_page those steps landed.

Edited by Narendran

Merge request reports

Loading
Loading