monolith/S05 virtual: expose upstream visibility, size, URL, and health in the client and GraphQL

What this does

Brings the widened upstream repository summary through the Ruby client and the GraphQL schema, so the virtual repository detail page can read each upstream's visibility, size, URL, and connection status from the one upstream-list call instead of one detail read per upstream. The UI that renders them is #631075 (closed).

Artifact Registry serves these fields on every upstream_repository summary on the upstream-list route since gitlab-org/ops/artifact-registry!2959 (merged) merged: visibility and size_bytes, plus settings (holding url and last_health_status) on a remote upstream only. Artifact Registry sends the three fields only when the caller holds read_repository on the upstream repository itself. For an upstream the caller cannot read, the summary keeps just id, name, format, and kind, and one list can mix both kinds of row.

That is why visibility, sizeBytes, and settings are nullable: each resolves null for an upstream the current user cannot read, while the rest of the row still renders. The surface is dark behind the artifact_registry_ui feature flag. All new fields are experiments at 19.5, so there is no changelog entry.

Closes #631074 (closed)

Changes

  • ArtifactRegistry::UpstreamRepositorySummary: add visibility, size_bytes, and settings readers. settings returns the hash, or nil when the key is absent, empty, or not a hash, as RepositoryType#settings treats an empty one.
  • Types::ArtifactRegistry::UpstreamRepositorySummaryType (ArtifactRegistryUpstreamRepositorySummary): add visibility (ArtifactRegistryRepositoryVisibility), sizeBytes (BigInt), and settings, all null: true, next to the userPermissions field that master added. settings is null on a hosted upstream. All three are null when the current user cannot read the upstream repository.
  • Add Types::ArtifactRegistry::UpstreamRepositorySummarySettingsType (ArtifactRegistryUpstreamRepositorySummarySettings) with url and lastHealthStatus, the latter through HealthStatusEnum.recognized_or_unknown, the same mapping RemoteSettingsType uses. It is a type of its own rather than ArtifactRegistryRemoteSettings, because the summary carries no cache windows, has_credentials, or last_health_checked_at, and reusing that type would declare fields that always resolve null.
  • Regenerate doc/api/graphql/reference/_index.md and public/-/graphql/introspection_result.json.
  • Specs: the summary value object, the summary type (exact field list), the new settings type, the client spec's upstream fixture, and the upstream-list request spec (the new fields across all four formats, a list where the user can read no upstream, a list mixing a readable upstream with one the user cannot read, an unrecognized health status, one Artifact Registry call for a 20-upstream list with every new field selected, and granular token access to the settings).

References

How to set up and validate locally

Step 1 - RuboCop is clean on the changed Ruby

bundle exec rubocop --no-server \
  ee/app/graphql/types/artifact_registry/upstream_repository_summary_settings_type.rb \
  ee/app/graphql/types/artifact_registry/upstream_repository_summary_type.rb \
  ee/lib/artifact_registry/upstream_repository_summary.rb \
  ee/spec/graphql/types/artifact_registry/upstream_repository_summary_settings_type_spec.rb \
  ee/spec/graphql/types/artifact_registry/upstream_repository_summary_type_spec.rb \
  ee/spec/lib/artifact_registry/client_spec.rb \
  ee/spec/lib/artifact_registry/upstream_repository_summary_spec.rb \
  ee/spec/requests/api/graphql/organizations/artifact_registry_repository_upstream_repositories_spec.rb
Output (✅)
8 files inspected, no offenses detected

Step 2 - The value object and type specs pass

The value object spec uses fast_spec_helper, so it runs on its own.

bin/rspec ee/spec/lib/artifact_registry/upstream_repository_summary_spec.rb
bin/rspec \
  ee/spec/graphql/types/artifact_registry/upstream_repository_summary_type_spec.rb \
  ee/spec/graphql/types/artifact_registry/upstream_repository_summary_settings_type_spec.rb
Output (✅)
9 examples, 0 failures
23 examples, 0 failures

Step 3 - The client and request specs pass

bin/rspec ee/spec/lib/artifact_registry/client_spec.rb -e '#upstream_repositories'
bin/rspec ee/spec/requests/api/graphql/organizations/artifact_registry_repository_upstream_repositories_spec.rb
Output (✅)
35 examples, 0 failures
30 examples, 0 failures

The request spec covers the new fields for maven, npm, docker, and oci (a hosted row with sizeBytes 0 and null settings, and a remote row with settings), a list where the user can read no upstream so every row's visibility, sizeBytes, and settings resolve null while id, name, format, and kind stay, a list where the user cannot read the second upstream so its visibility, sizeBytes, and settings resolve null while the readable first row keeps them, a last_health_status of degraded resolving UNKNOWN, and a 20-upstream list with every new field selected costing exactly one Artifact Registry request.

Step 4 - The graphql-verify steps pass, one rake invocation each

bundle exec rake gitlab:graphql:validate
bundle exec rake gitlab:graphql:check_docs
bundle exec rake gitlab:graphql:check_introspection_sync
bundle exec rake gitlab:graphql:schema:dump
node scripts/frontend/graphql_possible_types_extraction.js --check
Output (✅)
### validate
OK    ee/app/graphql/queries/mcp/security/save_vulnerability_update_severity.mutation.graphql
OK    ee/app/graphql/queries/mcp/wikis/list_group_wiki_pages.query.graphql
OK    ee/app/graphql/queries/mcp/work_items/get_work_item.query.graphql
(one OK line per operation file, last three shown, no failures)

### check_docs
GraphQL documentation is up to date

### check_introspection_sync
✅ All GraphQL introspection schemas are up to date

### schema:dump
Schema IDL dumped into tmp/tests/graphql/gitlab_schema.graphql
Schema JSON dumped into tmp/tests/graphql/gitlab_schema.json

### possible_types
(no output, exit 0)

Step 5 - Against Artifact Registry main, the new fields render

This needs the GDK wired to a local Artifact Registry built from main at or after 38225886d (the merge of gitlab-org/ops/artifact-registry!2959 (merged)), with a virtual npm repository that has upstreams (here npm-virtual-demo, with the remote npmjs-proxy and the hosted team-internal-npm). One package was pulled through npmjs-proxy first so its size is not zero. A local Artifact Registry runs with authorization.unenforced: true, so every upstream is readable here.

Run this in GraphiQL at http://gdk.test:3333/-/graphql-explorer:

query {
  organization(id: "gid://gitlab/Organizations::Organization/1") {
    artifactRegistryRepository(name: "npm-virtual-demo") {
      name
      upstreamRepositories {
        position
        upstreamRepository {
          name
          kind
          visibility
          sizeBytes
          settings {
            url
            lastHealthStatus
          }
          userPermissions {
            readRepository
          }
        }
      }
    }
  }
}
Output (✅)
{"data":{"organization":{"artifactRegistryRepository":{"name":"npm-virtual-demo","upstreamRepositories":[
  {"position":1,"upstreamRepository":{"name":"npmjs-proxy","kind":"REMOTE","visibility":"PRIVATE","sizeBytes":"40303","settings":{"url":"https://registry.npmjs.org","lastHealthStatus":"HEALTHY"},"userPermissions":{"readRepository":true}}},
  {"position":2,"upstreamRepository":{"name":"team-internal-npm","kind":"HOSTED","visibility":"PRIVATE","sizeBytes":"0","settings":null,"userPermissions":{"readRepository":true}}}
]}}}}

No errors key. sizeBytes is a BigInt, so it serializes as a string. The Artifact Registry access log shows the list reaching the wire once:

method=GET uri="/api/v1/gdk-local-test/repositories/npm-virtual-demo/npm/upstream_repositories?include_permissions=true" status=200

Step 6 - An upstream the user cannot read, and an unrecognized health status

Neither state can be reached against a local Artifact Registry. With authorization unenforced, every upstream is readable. And Artifact Registry answers 500 rather than serve a last_health_status outside its own enum, so the UNKNOWN fallback only matters once Artifact Registry adds a status this schema does not know yet. A small stub serves both shapes. Start it, point api_url under artifact_registry: in config/gitlab.yml at http://127.0.0.1:8099, and gdk restart rails-web. Pass your local namespace UUID and slug if they differ from the defaults. Restore api_url and restart rails-web afterwards.

fake_ar.rb
# frozen_string_literal: true

# Usage: ruby fake_ar.rb [port] [namespace_uuid] [slug]
require 'socket'
require 'json'
require 'time'

port = Integer(ARGV[0] || 8099)
namespace_id = ARGV[1] || '01a079a6-f247-7fef-8878-d5e63bb3f1bf'
slug = ARGV[2] || 'gdk-local-test'

READABLE = %w[read_repository update_repository delete_repository create_repository_upstream
  update_repository_upstream delete_repository_upstream read_artifact create_artifact delete_artifact]
  .to_h { |action| [action, action.start_with?('read_')] }.freeze
UNREADABLE = READABLE.transform_values { false }.freeze

def readable_remote(health)
  { 'id' => '01999999-0000-7000-8000-0000000000a1', 'name' => 'npmjs-proxy', 'format' => 'npm', 'kind' => 'remote',
    'visibility' => 'private', 'size_bytes' => 40_303,
    'settings' => { 'url' => 'https://registry.npmjs.org', 'last_health_status' => health } }
end

def unreadable_remote
  { 'id' => '01999999-0000-7000-8000-0000000000a3', 'name' => 'vendor-mirror', 'format' => 'npm', 'kind' => 'remote' }
end

def association(position, summary, permissions, include_permissions)
  summary = summary.merge('permissions' => permissions) if include_permissions
  { 'id' => "01999999-0000-7000-8000-00000000000#{position}", 'position' => position,
    'upstream_repository' => summary }
end

def upstreams(name, include_permissions)
  case name
  when 'npm-virtual-restricted'
    [association(1, readable_remote('healthy'), READABLE, include_permissions),
      association(2, unreadable_remote, UNREADABLE, include_permissions)]
  when 'npm-virtual-degraded'
    [association(1, readable_remote('degraded'), READABLE, include_permissions)]
  end
end

def repository(name)
  { 'id' => '01999999-0000-7000-8000-0000000000f0', 'name' => name, 'format' => 'npm', 'kind' => 'virtual',
    'visibility' => 'private', 'description' => 'Stubbed virtual repository', 'artifacts_count' => 0,
    'downloads_count' => 0, 'size_bytes' => 0, 'settings' => {} }
end

server = TCPServer.new('127.0.0.1', port)
warn "fake AR on :#{port}"

loop do
  client = server.accept
  method, target = client.gets.to_s.split
  path, query = target.to_s.split('?', 2)
  include_permissions = query.to_s.split('&').include?('include_permissions=true')
  nil until client.gets.to_s.strip.empty?

  status, body =
    case path
    when "/api/gitlab/v1/namespaces/#{namespace_id}"
      [200, { 'id' => namespace_id, 'slug' => slug, 'status' => 'active', 'created_at' => '2026-09-01T00:00:00Z' }]
    when %r{\A/api/v1/#{slug}/repositories/([^/]+)/npm/upstream_repositories\z}
      list = upstreams(Regexp.last_match(1), include_permissions)
      list ? [200, list] : [404, nil]
    when %r{\A/api/v1/#{slug}/repositories/([^/]+)\z}
      upstreams(Regexp.last_match(1), false) ? [200, repository(Regexp.last_match(1))] : [404, nil]
    else
      [404, nil]
    end

  body ||= { 'error' => { 'code' => 'not_found', 'message' => 'not found', 'request_id' => 'fake-ar' } }
  payload = body.to_json
  $stdout.puts "#{Time.now.utc.iso8601} #{method} #{target} -> #{status}"
  $stdout.flush
  client.write("HTTP/1.1 #{status} X\r\nContent-Type: application/json\r\nContent-Length: #{payload.bytesize}\r\n" \
    "X-Request-Id: fake-ar\r\nConnection: close\r\n\r\n#{payload}")
  client.close
end
ruby fake_ar.rb 8099 <namespace_uuid> <slug>

Then run the Step 5 query once with name: "npm-virtual-restricted" and once with name: "npm-virtual-degraded".

Output (✅)
# npm-virtual-restricted: the user cannot read vendor-mirror
{"position":1,"upstreamRepository":{"name":"npmjs-proxy","kind":"REMOTE","visibility":"PRIVATE","sizeBytes":"40303","settings":{"url":"https://registry.npmjs.org","lastHealthStatus":"HEALTHY"},"userPermissions":{"readRepository":true}}},
{"position":2,"upstreamRepository":{"name":"vendor-mirror","kind":"REMOTE","visibility":null,"sizeBytes":null,"settings":null,"userPermissions":{"readRepository":false}}}

# npm-virtual-degraded: Artifact Registry sends last_health_status "degraded"
{"position":1,"upstreamRepository":{"name":"npmjs-proxy","kind":"REMOTE","visibility":"PRIVATE","sizeBytes":"40303","settings":{"url":"https://registry.npmjs.org","lastHealthStatus":"UNKNOWN"},"userPermissions":{"readRepository":true}}}

No errors key on either. The stub log shows each query reaching the wire once:

GET /api/v1/gdk-local-test/repositories/npm-virtual-restricted -> 200
GET /api/v1/gdk-local-test/repositories/npm-virtual-restricted/npm/upstream_repositories?include_permissions=true -> 200
GET /api/v1/gdk-local-test/repositories/npm-virtual-degraded -> 200
GET /api/v1/gdk-local-test/repositories/npm-virtual-degraded/npm/upstream_repositories?include_permissions=true -> 200

Evidence

Change types: client value object (ArtifactRegistry::UpstreamRepositorySummary) and GraphQL. No frontend file changed. Environment: local GDK plus Artifact Registry built from main at 38225886d, which includes gitlab-org/ops/artifact-registry!2959 (merged). The unreadable-upstream and unknown-status runs use the Step 6 stub, for the reasons given there. Everything above the wire is the real product.

Rails console

Live Artifact Registry built from main:

>> upstreams = client.upstream_repositories(slug: 'gdk-local-test', repository_name: 'npm-virtual-demo', format: 'npm'); upstreams.size
=> 2
>> upstreams.map { |a| s = a.upstream_repository; [a.position, s.name, s.kind, s.visibility, s.size_bytes, s.settings] }
=> [[1, "npmjs-proxy", "remote", "private", 40303, {"url"=>"https://registry.npmjs.org", "last_health_status"=>"healthy"}], [2, "team-internal-npm", "hosted", "private", 0, nil]]

GraphiQL

Live Artifact Registry. The remote upstream renders PRIVATE, sizeBytes "40303", and settings with its URL and HEALTHY. The hosted upstream renders PRIVATE, sizeBytes "0", and settings null.

An upstream the user cannot read (stubbed Artifact Registry). Artifact Registry sends only id, name, format, and kind for vendor-mirror, so its visibility, sizeBytes, and settings resolve null next to readRepository: false. The readable npmjs-proxy row in the same list keeps its fields, with no error.

Unrecognized health status (stubbed Artifact Registry). Artifact Registry sends last_health_status: "degraded", which resolves UNKNOWN with no error.

Screen recordings

N/A. No frontend files changed. Rendering the fields is #631075 (closed).

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.

🤖 Generated with Claude Code

Edited by Fiona McCawley

Merge request reports

Loading
Loading