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's repositories.id to its container_virtual_repositories.id through 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. Once Resolve returns, every answer writes under remote.FillTailBudget, floored at server.timeouts.write as on the blob route. docs/dev/remote-read-budgets.md prices 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 answers 200 from that copy instead of 503, and an unmarked local drain fault (a staging-write net.Error) now answers 500 instead of 503 with Retry-After: 300.
  • The manifest route's refusals carry Cache-Control: no-store, including the 400, the negotiation 404, and every 500. The dispatcher's 405 does not, because it is a permanent answer. The blob route's 400 and 416 keep the remote route's headers.
  • cmd/artifact-registry/wire_oci_virtual.go fills the manifest slot at the composition root, over the shared flight registry and the process's CounterSink.
  • 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.FillTailBudget alone. The code floors it at server.timeouts.write, as VirtualBlobHandler does, because a warm serve re-arms too and would otherwise get less write time than a hosted read once an operator raises server.timeouts.write above 75s. !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 to remote.FillTailBudget alone.
  • 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.md keeps virtual client journeys out of scope until then.
  • User documentation: docs/user/formats/oci.md still 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)

🤖 Generated with Claude Code

Edited by Sylvia Shen

Merge request reports

Loading
Loading