feat(oci): Step 16 part 2 - shared live list relay and Link rewriting (S16 plan: 16-2/29)

What

Step 16 was one 3,924-LOC MR. It now ships as four, and this is part 2: the half both live-proxied container lists share.

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

Step 16 stays one step; only its MRs are split. Targets main: !1547 (merged) merged on 2026-08-21, so the writers this layer calls are already there.

What part 2 delivers

remote_list.go: the per-request binding a list read is served against (RemoteListRequest), the relayed response head and its closed header allowlist, the Link rewrite and the cursor rules it applies, the upstream status mapping, and the error and log writers both routes answer through.

One layer rather than a copy per route, because the two lists are one shape answered twice: tags/list and referrers differ in the document they carry and in nothing about how a page is relayed.

Three behaviors are worth a reviewer's attention:

  1. The rewrite keeps the cursor and nothing else. The upstream's URL is replaced whole, so a client's follow-up cannot be pointed past this repository or past its auth, and the upstream host reaches no client.
  2. A cursor is relayed only off a rel="next" link-value, never one carrying a comma. The rewritten value is emitted as rel="next" into a header field where a comma separates link-values, so a cursor taken off a prev link would advertise an earlier page as the next one, and a comma would let the upstream add a second link-value of its own to a header this service signs its name to.
  3. An upstream status below 400 is answered, not propagated. Only 200 is relayed, so what arrives under 400 is a 2xx this service cannot frame or a 3xx carrying no Location (the upstream client follows every redirect that has one). Either one handed to a client as its own status gives it a response it cannot act on, so both answer 503 UNAVAILABLE.

No production caller yet, and why that is the shape

The routes that compose this layer land in parts 3 and 4, so the tests here are in-package (remote_list_internal_test.go, package oci) and assert each writer directly. The routes then assert the same properties end to end.

unused is what proves every symbol is reached. Measured with --max-same-issues=0 --max-issues-per-linter=0: the file alone reports 21 findings, and with this suite ./internal/format/oci/ reports 0 issues. The constants this layer is the first caller of (codeUnauthorized, codeTooManyRequests, remoteUpstreamRejectedMessage) moved here from part 1 for the same reason.

The plan already accepts this shape for the sibling step: 11a "ships a seam with no caller, on purpose".

Size

1,894 reviewable LOC, past the 500 in development-model.md. Counted with git diff --numstat origin/main...HEAD at 81f0313a, after review round 2.

LOC
Production (remote_list.go 796, remote_serve.go 9, remote_errors.go 11, remote_relay.go 5, remote_nametransform.go 5) 826
Tests (remote_list_internal_test.go 1,059, remote_relay_internal_test.go 9) 1,068

Two corrections to earlier counts. An errors.go row named a file no revision of this branch ever carried; --numstat reports no change to it. And a Plan table row is gone with the plan-file change itself, which 81f0313a took off this branch under Guardrail 4. The previous total also did not match its own rows: it added the code files' additions to the plan row's additions and deletions. Every number above is additions plus deletions, the way the rest of this table has always read them.

Splitting further would separate the writers from the only tests that reach them: they are unexported, so a production-half MR reports 21 unused findings on its own.

Test coverage

Behavior Test
200 alone is relayed; 206, 3xx, and 204 are answered TestIsRelayedListStatus_RelaysA200AndNothingElse
only the endpoint's named headers cross; Set-Cookie, WWW-Authenticate, X-* do not TestWriteRemoteListHead_RelaysOnlyWhatTheEndpointNames
a rewritten body declares no length and an absent Link emits none TestWriteRemoteListHead_ANegativeLengthGoesOutChunkedAndAnAbsentLinkIsNotEmitted
the rewritten Link points at this repository and keeps only the cursor TestRewrittenListLink_PointsAtThisRepositoryAndKeepsOnlyTheCursor
a link with no cursor is dropped rather than emitted bare TestRewrittenListLink_DropsALinkWithNoCursorToRelay
the first relayable next across field lines wins; a prev-only header yields none TestNextPageCursor_TakesTheFirstRelayableNextAcrossFieldLines
a comma inside the URI-Reference does not split the field TestSplitLinkValues_LeavesACommaInsideAngleBracketsWhereItIs
brackets, fragment, comma, and the size bound TestRelayableNextCursor
the relation is read case-insensitively and among several TestDeclaresNextRelation
every propagated status takes this service's own code TestRemoteListStatusCode
a 429 relays a vouched-for hint; a 4xx carries none; a 5xx propagates with one TestWriteRemoteListUpstreamStatus_PropagatesTheStatusAndChoosesTheCode, _A4xxCarriesNoRetryHint, _A5xxIsPropagatedWithAHint
an unframeable upstream status answers 503 TestWriteRemoteListUpstreamStatus_AnUnframeableStatusAnswersUnavailable
a refused segment names the field, not its value, and logs TestWriteRemoteListTargetError_ARefusedSegmentNamesTheFieldNotItsValue
any other target cause is a fault on this side, cause unserved TestWriteRemoteListTargetError_AnyOtherCauseIsAFaultOnThisSide
a 401/403 verdict propagates, an inconclusive discovery status does not TestWriteRemoteListUpstreamError_PropagatesACredentialVerdictAndNothingElse
an oversized value is skipped and the aggregate ends the walk TestNextPageCursor_BoundsTheWorkAnUpstreamCanAskFor
an unterminated reference does not swallow the values behind it TestSplitLinkValues_AnUnterminatedReferenceDoesNotSwallowTheRest
no malformed upstream Link puts the upstream host on the response TestRewrittenListLink_NoMalformedUpstreamLinkReachesTheClient
an endpoint naming Link still cannot relay the upstream's TestWriteRemoteListHead_AnEndpointCannotRelayTheUpstreamLink
each log line carries all three identifiers, at its own level TestLogRemoteListUpstreamStatus_AttributesTheLineToTheRepository, TestLogRemoteListRelayFailure_AttributesTheLineToTheRepository, TestWriteRemoteListUnavailable_CarriesTheIdentifiersOntoThe503

Review round 1

Fixes pushed in 9029026d and f1ac59fa, all in the Link path and its log attribution. The SHA this paragraph named before was rewritten away and no ref carries it.

  • The Link scan is bounded. maxRelayedLinkQueryBytes bounds the cursor, which is read out after the split, so it bounded nothing about reaching it: upstreamhttp.newTransport leaves net/http's 10 MiB default MaxResponseHeaderBytes in place, and one Link field line at that size cost 316 ms and 1.03 GB per request. remote_challenge.go had already settled this input class on the same transport, so its per-value and aggregate caps are taken here by shape and number, and splitLinkValues yields rather than building a slice. Measured after: 762 ns and zero allocations on the same input, and zero allocations on a header that fills the aggregate cap.
  • An unterminated < no longer swallows the link-values behind it. It ended pagination a page early and carried the upstream's own URL into the rewritten Link. A cursor is now held to RFC 3986's query production, and an empty query is no longer reported as relayable.
  • writeRemoteListHead refuses Link whatever an endpoint names, since relayableUpstreamHeaders admits it and the rewritten Set overwrites a relayed value only when the rewrite produced one.
  • Every log line carries namespace_id and repository_id alongside remote_repository_id, which names a different entity on internal/remote's records. writeRemoteListTargetError logs both arms; its refused-segment arm answered a 400 for a skipped check on this side and left nothing behind.

Where the spec has to catch up

Three live-list behaviors here are not what S16 describes, and all three are tracked in #795: the sub-400 arm answering 503 where the Error Cases table says a non-2xx propagates; the 405/407/426 carve-out this MR adds, for the reason S16:978 already gives for a manifest or blob read; and the Link rewrite plus the Oci-Filters-Applied size bound being narrower than the relay ## API Contracts describes.

Merge order: the spec MR !1829 (merged) merges before this MR, or in the same batch. Never after. It carries S16's list-row carve-out and the ## Follow-ups bullet linking #795. Until it lands, the 405/407/426 arm in writeRemoteListUpstreamStatus contradicts S16's list row as written, so a reviewer reading the two together sees a conflict rather than a decision. !1829 (merged) has no code in it and does not depend on this MR.

The plan MR !1830 (merged) collapses S16's 16-1-16-4 Status rows into one 16 row and takes the table from 29 rows to 26. Whenever it merges, this MR's title marker has to become (S16 plan: 16/26): 16-2 stops naming a Status row, and the denominator moves. The retitle is this MR's to make, not !1830 (merged)'s.

Conformance and e2e

No docs/testing/ scenario changes: this part adds no route. Step 18 owns the e2e catalogs for this slice.

Verification

go build ./..., go vet, go test ./internal/format/oci/..., and golangci-lint run --max-same-issues=0 --max-issues-per-linter=0 --uniq-by-line=false ./internal/format/oci/ (0 issues) all clean.

Related to #288

Edited by Sylvia Shen

Merge request reports

Loading
Loading