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