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: addvisibility,size_bytes, andsettingsreaders.settingsreturns the hash, ornilwhen the key is absent, empty, or not a hash, asRepositoryType#settingstreats an empty one.Types::ArtifactRegistry::UpstreamRepositorySummaryType(ArtifactRegistryUpstreamRepositorySummary): addvisibility(ArtifactRegistryRepositoryVisibility),sizeBytes(BigInt), andsettings, allnull: true, next to theuserPermissionsfield that master added.settingsis null on a hosted upstream. All three are null when the current user cannot read the upstream repository.- Add
Types::ArtifactRegistry::UpstreamRepositorySummarySettingsType(ArtifactRegistryUpstreamRepositorySummarySettings) withurlandlastHealthStatus, the latter throughHealthStatusEnum.recognized_or_unknown, the same mappingRemoteSettingsTypeuses. It is a type of its own rather thanArtifactRegistryRemoteSettings, because the summary carries no cache windows,has_credentials, orlast_health_checked_at, and reusing that type would declare fields that always resolve null. - Regenerate
doc/api/graphql/reference/_index.mdandpublic/-/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
- Work item: #631074 (closed)
- Artifact Registry contract and handler (merged): gitlab-org/ops/artifact-registry!2959 (merged)
- Spec and plan amendment, which adds this MR as Step 9 of the S05 virtual plan. This MR merges after it: gitlab-org/ops/artifact-registry!2998 (merged)
- UI follow-up: #631075 (closed)
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.rbOutput (✅ )
8 files inspected, no offenses detectedStep 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.rbOutput (✅ )
9 examples, 0 failures
23 examples, 0 failuresStep 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.rbOutput (✅ )
35 examples, 0 failures
30 examples, 0 failuresThe 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 --checkOutput (✅ )
### 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=200Step 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
endruby 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 -> 200Evidence
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.