feat(oci): Step 16 part 4 - live-proxied referrers (S16 plan: 16/26)

Note

Step 10.5 (!1569 (merged)) has merged, so WithRemoteReferrers exists and this MR now fills it: the route stops answering the interim 501. See Wiring below.

What

Step 16 was one 3,924-LOC MR. It now ships as four, and this is the last: the live-proxied referrers listing.

Part What MR State
1 relay writers for remote read responses !1547 (merged) merged
2 shared list relay and Link rewriting !1716 (merged) merged
3 live-proxied tag listing !1717 (merged) merged
4 live-proxied referrers this MR open

Step 16 stays one step; only its MRs are split. Parts 1 through 3 are on main, so this MR targets main and reads on its own.

What part 4 delivers

One upstream GET carrying the client's own query string, the answer relayed byte for byte, and the Link URL repointed at this repository.

Nothing in a referrers document names the repository, so there is no field to rewrite. Two consequences separate this route from tag listing: it relays the upstream's Content-Length, which tag listing cannot, and it relays OCI-Filters-Applied where the upstream sent one, producing none where it did not.

A subject the upstream does not recognize is the upstream's own 200 to relay, never a 404 of this service's own. The referrers endpoint answers an empty index for an unknown subject, so a 404 here would report the repository missing. Asserting a 404 on that case fails a correct implementation, which is why the suite pins the opposite.

The forwarded query is bounded on both routes

This MR forwards the client's raw query string to the upstream, and refuses one above 8 KiB with 400 QUERY_LIMIT_EXCEEDED before any upstream request goes out. Without a bound, Go's default Server.MaxHeaderBytes (1 MiB) is the only ceiling: one inbound request drives an upstream GET of the same size, at no cost to the client beyond its own connection.

An earlier revision of this MR deliberately omitted the bound, on the grounds that @radbatnag's escalate: thread on !1717 (merged) argued part 3's version of it was a rejection S16 did not authorize. That thread is resolved and it resolved the other way: the bound was authorized rather than removed. !1891 (merged) carved the size bound out of the pagination exception, gave the refusal its own QUERY_LIMIT_EXCEEDED so it cannot be read as a propagated upstream 400, and extended the contract to both live lists. S16 §API Contracts now says it outright:

The bound is owed by both routes, because both forward a client query the shared relay layer never reads. An arm that forwards a client query without the check does not meet this contract, whichever of the two it serves.

So maxForwardedListQueryBytes and the refusal move out of remote_taglist.go into remote_list.go as refuseOversizedForwardedQuery, which both proxies call. One gate rather than a copy per route, because the spec frames the bound as belonging to the shared layer neither route owns alone, and two copies can drift apart on a value with one contract. TestRemoteListProxies_BoundTheQueryTheyForward drives the on-bound and one-byte-past rows through both.

Two other answers this route brings into line

DIGEST_INVALID now carries digestInvalidReason's verdict in detail.reason, matching gateRemoteBlobDigest — which remote_list.go already cites as this gate's model — and the hosted route. Without it a client sending sha512:... and a client sending malformed hex get byte-identical answers, and S16's Error Cases row for this condition says it matches S12, whose envelope carries the reason.

upstreamContentTypeOr falls back to the endpoint's contract type past maxRelayedHeaderValue. writeRemoteListHead sets Content-Type outside the per-response budget it applies to the endpoint headers, because the endpoint always owes the client a value there, so this is the only place an upstream-controlled type is bounded at all. This MR is the first production caller of that helper: before it, both list routes pinned their own constant. Substituted rather than truncated, because a truncated media type is a different media type.

Wiring

WithRemoteReferrers is filled from the same append as the tag-list slot in cmd/artifact-registry/wire_oci.go, so a boot with an upstream doer answers both live lists.

RemoteReferrersHandler is part 3's handler's sibling: same row resolution through the same frame, its own proxy behind it. The subject digest is not gated there. It reaches the proxy as the route carried it, and the proxy refuses an unusable one before any upstream call, which is also where the canonical spelling the outbound path and the rewritten Link both use is produced.

The boot guard asserts both routes off one wiring call rather than provisioning a second database, because what it covers is one statement in wireOCIWithStore. It is renamed TestWireOCIWithStore_FillsTheLiveListSlots, since it now covers two slots.

Both arms take the authenticator wireOCIWithStore builds once per boot, rather than building one each: oci.RemoteTokenCache is per-process by its own contract, so a second instance means two in-flight maps and two TTL entries for one key, and two arms reading one image would exchange twice against the upstream token endpoint. TestWireOCIWithStore_RemoteArmsShareOneTokenFlight now drives one concurrent read per arm rather than two down the tag list, which is what makes its exchanges == 1 assertion discriminating: two reads down one arm coalesce whether the boot shares an authenticator or not.

What it touches in part 3's files

The referrers arm goes back into every suite that asserts a property of the shared layer on both routes: the wiring-gap refusal, the unbuildable upstream URL, the zero-value and nil receivers, the unframeable upstream status, the log-versus-silence rule, and the forwarded-query bound. Part 3 carries the tag-list arm of each.

The last three of those were plural-named (TestRemoteListProxies_*) while driving only the tag list. The unframeable-status one matters most on this route, because this is the route that relays a Content-Length: a 206 escaping the mapping would hand a client a partial-content status under a length describing the whole resource. The log-versus-silence one matters because an upstream without the OCI 1.1 referrers API answers 404 to every referrers read, so the silent half is the common case here rather than the rare one.

The fixture methods that drive this route (referrersRequest, serveReferrers, referrersServer) live in this MR's own test file rather than in the tag-list one, so the proxy field, its construction, and those three arms are all this change touches there. Named as an accepted smell per CLAUDE.md: a later part modifying an earlier part's test file is the same shape the plan accepts for 11c modifying the remote_serve.go 11b creates, and the alternative (one fixture per route) would give the two routes separate views of the same upstream.

remote_list.go gains the shared query bound and its gate, a list_route discriminator on the log attrs both routes write through, and two comment corrections. writeRemoteListTargetError's doc said the referrers route will owe a ParseDigest gate ahead of ReferrersURL; this MR lands that gate, so the paragraph now names ServeReferrers as what keeps the claim true. The other forward references this MR falsifies (in remote_list.go, remote_taglist.go, remote_list_handler.go, and wire_oci.go) are converted to the present tense in the same pass, per the guardrail that makes the landing MR the one that closes them.

The list_route field is new. Every message this layer emits is shared by both lists, so before it a line about a propagated refusal or an unrelayed cursor named the repository but not which of the two reads produced it. Step 17's oci_remote_proxy event carries the same distinction under its own operation discriminator; until that lands this is what an operator filtering these lines has.

Size

1,363 reviewable LOC, past the 500 in development-model.md. Splitting again would cut below the composition seam: the proxy, the handler that resolves a row for it, and the wiring statement that fills its slot are one route, and a part carrying any two of them leaves a symbol with no caller.

LOC
Production — internal/format/oci/remote_referrers.go 183
Production — internal/format/oci/remote_list.go 109
Production — internal/format/oci/remote_list_handler.go 46
Production — internal/format/oci/remote_taglist.go 45
Production — cmd/artifact-registry/wire_oci_remote.go 30
Production — internal/format/oci/remote_relay.go 11
Production — cmd/artifact-registry/wire_oci.go 11
Tests — internal/format/oci/remote_referrers_test.go 673
Tests — internal/format/oci/remote_taglist_test.go (the second arms) 118
Tests — internal/format/oci/remote_list_handler_test.go 68
Tests — cmd/artifact-registry/wire_oci_boot_integration_test.go 69

Tests are 928 of the 1,363.

remote_taglist.go is net −27: the query bound and its refusal move from there into remote_list.go, so most of that file's count is the deletion.

Spec coverage

Criterion Tests
referrers proxies live, Link rewritten TestRemoteReferrersProxy_ProxiesLiveAndRewritesTheLink, _RewritesAnAbsoluteUpstreamLink, _DropsALinkCarryingNoCursor
the whole query string is forwarded verbatim _ForwardsTheWholeQueryString
an unknown subject relays the upstream 200 _UnknownSubjectRelaysTheUpstream200
OCI-Filters-Applied relayed, absence produces none _RelaysOCIFiltersApplied
the upstream's Content-Length is relayed _RelaysTheUpstreamContentLength
upstream status kept, AR code chosen _UpstreamNonSuccessKeepsItsStatusAndTakesAnARCode
header discipline holds, envelopes included _DiscardsEveryHeaderOutsideTheRelayAllowlist, _LeaksNoCredentialMaterial
a page above the metadata cap is relayed _StreamsUnderTheLargeBodyCap, _RelaysBeforeTheUpstreamBodyEnds
no cache row, cold auth_url still persists _WritesNoCacheRowAndPersistsAColdAuthURL
an unusable subject costs no upstream call _RefusesAnUnusableSubjectWithoutCallingTheUpstream
a handshake failure answers without an upstream response _HandshakeFailureAnswersWithoutAnUpstreamResponse
Content-Type is the upstream's, the contract default, or the default past the relay bound _ContentTypeIsTheUpstreamsOrTheContractDefault
a query string over 8 KiB is refused before any upstream request, on both routes TestRemoteListProxies_BoundTheQueryTheyForward
DIGEST_INVALID names which way the digest was unusable _RefusesAnUnusableSubjectWithoutCallingTheUpstream
an over-cap declared length relays chunked, never the upstream's number _AnOverCapDeclaredLengthRelaysChunked
a content coding is refused _RefusesToRelayAnEncodedBody
an inconclusive discovery status is not a verdict _InconclusiveChallengeDiscoveryAnswersUnavailable
the shared-layer properties on this route TestRemoteListProxies_ARouteTheUpstreamURLCannotBeBuiltFromCostsNoUpstreamCall, _ZeroValueAndNilReceiverAnswer500, _UnframeableUpstreamStatusAnswersUnavailable, _LogsAnUpstreamFaultAndStaysSilentOnAClientError, TestRemoteTagListProxy_RejectsAWiringGap
both live-list slots fill from one boot, sharing one token flight TestWireOCIWithStore_FillsTheLiveListSlots, _RemoteArmsShareOneTokenFlight

Conformance and e2e

No docs/testing/ scenario changes. Step 18 owns the e2e catalogs for this slice, and the external opencontainers/distribution-spec suite cannot drive a kind=2 repository: it pushes its own fixtures before pulling them, and every write verb on a remote repository answers 405.

Verification

go build ./..., go vet ./... and go vet -tags=integration ./..., go test ./internal/format/oci/... ./cmd/artifact-registry/... -count=1, and go test -race ./internal/format/oci/ all clean.

golangci-lint run --build-tags=integration --max-same-issues=0 --max-issues-per-linter=0 --uniq-by-line=false ./internal/format/oci/... ./cmd/artifact-registry/... reports 12 findings, every one of them pre-existing in a file this MR does not touch (wire_npm_*, wire_root_dispatcher_*, upload_finalize_integration_test.go, and a noinlineerr inside TestWireOCIWithStore_WiresManifestReadPath). Same count as before this MR's changes.

Rebased onto main now that part 3 has merged. Part 3's API moved during its review, so this branch takes two adaptations beyond the replay: buildOCIRemoteTagListHandler takes the shared *oci.UpstreamAuthenticator rather than the config, and the referrers builder moved to cmd/artifact-registry/wire_oci_remote.go to sit beside it.

No test function from the pre-split branch was dropped in the re-cut.

Related to #288

Edited by Sylvia Shen

Merge request reports

Loading
Loading