feat(oci): serve manifest reads on a virtual container repository (S32 plan: 15/20)
What does this MR do?
Serves GET and HEAD on a virtual (kind=1) container repository's manifest route, replacing the interim 501 with a real resolution. This is Step 15 of the S32 container virtual plan, per S32: Manifest proxy and Client HEAD requests.
oci.VirtualManifestHandler(internal/format/oci/virtual_manifest.go) maps the request'srepositories.idto itscontainer_virtual_repositories.idthrough the finder Step 16 landed. It resolves once and serves the winner through the Step 12 serve surface.- Write deadline, two budgets. The route arms before
Resolve, so it cannot tell a tag miss from a tag revalidation. The reference picks the budget: a tag read arms the revalidation expression and a digest read arms the miss-path sum. OnceResolvereturns, every answer writes underremote.FillTailBudget, floored atserver.timeouts.writeas on the blob route.docs/dev/remote-read-budgets.mdprices both. - A failed drain falls back to the winner's own copy. The serve now hands a drain that fails before any header to
virtual.Resolver.ResolveDrainFailure(#1434 (closed)). A stale tag whose changed body fails its drain serves the copy it had, as S32 requires. This also applies to the blob route, whose serve path it shares. On the blob route this changes two answers: a transport failure where the winner holds a copy now answers200from that copy instead of503, and an unmarked local drain fault (a staging-writenet.Error) now answers500instead of503withRetry-After: 300. - The manifest route's refusals carry
Cache-Control: no-store, including the400, the negotiation404, and every500. The dispatcher's405does not, because it is a permanent answer. The blob route's400and416keep the remote route's headers. cmd/artifact-registry/wire_oci_virtual.gofills the manifest slot at the composition root, over the shared flight registry and the process'sCounterSink.- The run recipe drives the filled route.
driver.sh smoke: 80 passed, 0 failed.
The branch merges main after Step 16 (!2984 (merged)) landed. Step 16 carried the finder seam, its helper, and the cmd adapter, as the plan's shared-seam rule says the first sibling does, so this MR uses those. The merge commit message lists each resolution.
Plan and spec amendments
- Step 15 budget rule. The plan says a revalidating read arms the revalidation expression and a miss arms the miss-path sum. The code picks by reference instead, as described above. The batch plan MR !2941 (merged) already carries that correction across Step 15's Scope, Files, Acceptance, and Tests, so this MR does not repeat it and the two do not conflict.
- Step 15 tail floor. The plan re-arms the tail to
remote.FillTailBudgetalone. The code floors it atserver.timeouts.write, asVirtualBlobHandlerdoes, because a warm serve re-arms too and would otherwise get less write time than a hosted read once an operator raisesserver.timeouts.writeabove75s. !2941 (merged) owes that correction in two places: the research paragraph's "Step 15's manifest route does not" clause, and Step 15's Files entry, which names the re-arm toremote.FillTailBudgetalone. - Plan
## Dependencies. The tag-addressed digest-mismatch gate pointed to #433. It now points to #918, which the spec and the skipped subtest name.
Acceptance coverage
| Criterion | Test |
|---|---|
| The list is read by the finder's id, on a fixture whose two ids differ | TestVirtualManifest_ResolvesTheListByTheFinderID, TestWireOCIWithStore_VirtualManifestRouteServesThroughTheFinder, TestOCIVirtualRepositoryFinderAdapter_MapsTheFinderStore |
A kind=1 repository with no container_virtual_repositories row answers 500 |
TestVirtualManifest_FinderFailureIsInternal, TestWireOCIWithStore_VirtualManifestRouteAnswers500WithoutTheFormatRow, TestResolveVirtualRepository_AnUnansweredFinderErrorAnswers500WithNoStore |
A finder error behind a departed client answers 499 with no Error record |
TestVirtualManifest_FinderFailureBehindADepartedClientIsAbandonment |
| A stale remote outranks a fresh hosted Hit | TestVirtualManifest_StaleRemoteOutranksFreshHosted |
| A warm digest pull short-circuits at Phase 1 | TestVirtualManifest_WarmDigestPullShortCircuits |
| A cold tag pull resolves through Phase 2 | TestVirtualManifest_ColdTagPullServesTheWinner |
An index is served without its children; a child resolves independently; an unresolvable child answers 404 while the index stays 200 |
TestVirtualManifest_IndexIsServedWithoutConsultingItsChildren, TestVirtualManifest_ChildResolvesFromAnotherPosition, TestVirtualManifest_UnresolvableChildDoesNotFailTheIndex |
A cold HEAD reports the fetched resource, never the Probe's |
TestVirtualManifest_ColdHEADFillsAndReportsTheFetchedResource |
Each no-winner branch answers its Error Cases row, resolve_timeout expiry, a failed Lookup, and the veto's 200 half included |
TestVirtualManifest_NoWinnerBranches |
| A winner's non-2xx is relayed with no fall-through | TestVirtualManifest_WinnerRefusalIsRelayedWithoutFallThrough |
| A winner's transport failure or failed drain serves its own copy, never a higher or lower position's | TestVirtualManifest_WinnerTransportFailureFallsToItsOwnCopy, TestVirtualManifest_FailedDrainServesTheWinnersStaleCopy (GET and HEAD), TestVirtualServe_FailedDrainServesTheResolversFallback, TestVirtualResolution_ADrainFailureResolvesOverTheSameRequest |
| A by-digest mismatch serves and caches nothing | TestVirtualManifest_ByDigestMismatchServesNothing |
| The tag-addressed mismatch, written and skipped behind #918 | TestVirtualManifest_TagPullDisagreeingWithTheUpstreamDigestIsRefused |
Accept is negotiated against the stored media_type on every pull, warm and cold |
TestVirtualManifest_NegotiatesOnEveryPull, TestVirtualManifest_ConcurrentPullsNegotiateTheirOwnAccept |
A malformed sha256 reference answers 400 DIGEST_INVALID before resolution |
TestVirtualManifest_MalformedDigestIsRefusedBeforeResolution |
A cold resolution outliving server.timeouts.write still delivers a whole body |
TestVirtualManifest_ColdResolutionOutlivingTheWriteTimeoutDelivers |
| The armed instant: a tag read arms the revalidation expression, a digest read the miss-path sum; every answer re-arms to the tail | TestVirtualManifest_ArmsTheBudgetItsPathCanSpend, TestVirtualManifest_ReArmsToTheTailOnEveryAnswer, TestVirtualManifest_FailedFirstArmSkipsTheReArm, TestVirtualManifest_FloorsTheTailAtTheServerWriteTimeout, TestVirtualManifest_RearmReturnsTheInstantTheConnectionHolds |
| A served read records its download on the supplying position, the cache fallback included | TestVirtualManifest_RecordsTheDownloadOnTheSupplyingPosition, TestWireOCIWithStore_VirtualManifestReadBooksTheHostedDownload, TestVirtualManifest_FailedDrainServesTheWinnersStaleCopy |
N concurrent cold reads issue one upstream GET |
TestWireOCIWithStore_ConcurrentVirtualColdReadsOfOneManifestIssueOneUpstreamGET, TestVirtualManifest_ConcurrentColdReadsFetchOnce |
Two virtual repositories and a direct read of one remote share one upstream GET |
TestWireOCIWithStore_VirtualAndDirectReadsOfOneRemoteShareOneGET, TestVirtualManifest_CoalescesAcrossVirtualRepositoriesAndTheDirectRead |
| A virtual cold fill and a standalone cold fill write byte-identical cache rows | TestWireOCIWithStore_VirtualAndStandaloneColdFillsWriteTheSameCacheRows, TestVirtualManifest_ColdFillWritesTheRowAStandaloneFillWrites |
| A client leaving mid-fetch is abandonment | TestVirtualManifest_ClientLeavingMidFetchIsAbandonment |
Size
The diff is past the 500-line ceiling. Most of it is tests:
| Group | Added | Removed |
|---|---|---|
| Go source | 381 | 46 |
| Go tests | 3,504 | 43 |
| Docs and config reference | 62 | 13 |
Run recipe (SKILL.md, driver.sh) |
90 | 25 |
Splitting would not help. The source is one handler, its wiring, and the drain fallback the route's own acceptance criteria require. The tests cover both winner kinds across every resolution branch, plus the three composition contracts the plan assigns to this step. Tests cannot move to a later MR, because they ship with the code they cover.
e2e scenarios and user documentation
- e2e catalog: no scenario added or affected, because both catalog sections are owned by Step 18.
docs/testing/e2e/oci.mdkeeps virtual client journeys out of scope until then. - User documentation:
docs/user/formats/oci.mdstill says pulling through a virtual OCI repository is not available. !2941 (merged) changes that line, so it has to merge after this MR.
Run recipe
.claude/skills/run-artifact-registry/ changes because the manifest route now resolves. The evidence is the smoke re-run above (80 passed, 0 failed). No /skill-creator benchmark applies: the change updates what the recipe expects from the route, not how the skill triggers.
Related to #291 (closed)