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:
- 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.
- A cursor is relayed only off a
rel="next"link-value, never one carrying a comma. The rewritten value is emitted asrel="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. - An upstream status below 400 is answered, not propagated. Only
200is relayed, so what arrives under 400 is a2xxthis service cannot frame or a3xxcarrying noLocation(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 answer503 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
Linkscan is bounded.maxRelayedLinkQueryBytesbounds the cursor, which is read out after the split, so it bounded nothing about reaching it:upstreamhttp.newTransportleaves net/http's 10 MiB defaultMaxResponseHeaderBytesin place, and oneLinkfield line at that size cost 316 ms and 1.03 GB per request.remote_challenge.gohad already settled this input class on the same transport, so its per-value and aggregate caps are taken here by shape and number, andsplitLinkValuesyields 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 rewrittenLink. A cursor is now held to RFC 3986's query production, and an empty query is no longer reported as relayable. writeRemoteListHeadrefusesLinkwhatever an endpoint names, sincerelayableUpstreamHeadersadmits it and the rewrittenSetoverwrites a relayed value only when the rewrite produced one.- Every log line carries
namespace_idandrepository_idalongsideremote_repository_id, which names a different entity oninternal/remote's records.writeRemoteListTargetErrorlogs 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