feat(managementapi): serve the raw manifest payload (S17 Container Manifest Reads plan: 20/20)

Why

Step 20 of the S17 container manifest reads plan, the last of twenty. The redesigned manifest-detail page has a Manifest tab, and .../manifests/{digest} answered only a JSON resource and a DELETE, so the stored bytes were unreachable. This serves GET and HEAD on .../manifests/{digest}/raw, hosted and remote, the management API's first non-JSON response. It closes AC #136 (closed) and AC #137 (closed)'s raw clause.

What is not obvious

Content-Length is the payload blob row's length, never the manifest's size. A hosted size is the push-time tree total, so only a hosted fixture catches an implementation that reaches for it. Measured on a real pushed manifest: size 40567 against Content-Length 404.

Each method reads that length once, from a different call. HEAD has no stream to take a length from, so it pays BlobInfo. GET takes content.Size() off the OpenBlob it already runs, because blobLocation and BlobInfo select the same blob_storage_blobs.size for the same row. Both metadata refusals are therefore HEAD-only, and they live in their own table beside GET's three open refusals.

setManifestPayloadHeaders and closeManifestPayload are mirrored from internal/format/oci rather than imported (D5, following the tag-grammar precedent at bulk_selector.go:499). The first is OCI's setManifestHeaders minus Docker-Content-Digest.

D5's third mirrored mechanism, headResponseWriter, is deliberately absent. Mirroring it is safe in internal/format/oci only because errors.go:152 sets Content-Length explicitly. transport.WriteJSON relies on net/http deriving it, which is gated on (!isHEAD || len(p) > 0) (server.go:1382), so hiding the envelope bytes suppressed the header: a HEAD 404 here carried no Content-Length where the detail route's carried 114. net/http already discards HEAD bodies, so the wrapper only did harm, and the handler still branches on r.Method. The plan names the symbol at four sites (:900, :2935, :2969, :3160 on main today), and guardrail 4 leaves that correction to the plan's single writer. It is recorded for the corrections MR.

The 200 content map names the four manifest media types. application/octet-stream was wrong rather than narrow: kin-openapi's Content.Get resolves exact, params-stripped, type/*, then */*, so it matched none of the four application/vnd...+json types this route serves and rejected every real response. The four are the complete set the handler can serve, because IsAcceptedManifestType gates a push (internal/format/oci/mediatype.go:182, :202) and classifyCommittedManifest gates a cache fill (remote_cache_store.go:757), and every non-error return from DetectManifestType is one of them. The contract case asserts the key set exactly, so a wildcard beside them or a fifth accepted type added without the contract fails it.

The copy is judged on its count as well as its error, mirroring streamCachedBlob. Content-Length is committed from the blob row before the copy runs and io.Copy reports an early EOF as a nil error, so a short object would otherwise book a clean 200 with nothing logged. Three arms: short copy and http.ErrContentLength at Error, every other copy error at Warn, each carrying both counts. ADR-005 governs the force-stream open, which keeps a redirect-mode instance from handing out a URL to the backend's octet-stream. S12 is the listing ADR-005's metadata-document exemption requires, at its "Manifests are always proxied (force-streamed), never redirected" line.

Named residual. The copy fault books no series of its own. http_response_size_bytes moves and the Error line lands, but this package has no wide-event record to mark, so an on-call watching a fault family sees nothing. The mirror's counter does not transfer: recordRemoteProxyFaulted lives in internal/format/oci, and this package imports no format package, which is the same boundary D5 invokes for the header setter. The status cannot carry it either, because the 200 and its headers are committed before the copy starts. A client does see a short copy, the declared Content-Length going unmet, so the gap is the dashboard rather than the response. The series that closes it is tracked in Book a payload-copy failure counter for the raw... (#1341) • Hayley Swimelar • 19.5.

One comment shrank to meet a cap this branch did not set. main tightened scripts/ci/check-comment-caps.sh after this branch's previous push, and the 2026-09-14 rebase brought it in, failing lint:comment-caps on the pre-fix head. reportManifestPayloadCopy's banner and doc comment are now one line inside both that cap and lll.

Spec coverage

Requirement Tests
AC #136 (closed) stored bytes, hosted and remote Hosted_ServesStoredBytes, Remote_ServesCachedPayload, Integration_HostedServesTheStoredBytes, Integration_RemoteServesTheCachedBytes
AC #136 (closed) Content-Length from the blob row, not size Hosted_ContentLengthComesFromTheBlobRow
AC #136 (closed) Content-Type is the stored media_type ContentTypeComesFromTheStoredMediaType
AC #136 (closed) nosniff, and no Docker-Content-Digest asserted in every header case above
AC #136 (closed) Accept negotiates nothing AcceptIsNotNegotiated
AC #136 (closed) HEAD: the same headers, no body, no OpenBlob Head_SendsTheHeadersAndOpensNoStream, HeadError_DeclaresTheSameContentLengthAsGet
AC #136 (closed) POST is 405 with Allow: GET, HEAD PostReturns405
AC #137 (closed) remote arm, and both formats PerFormat, Remote_EvictedRowIs404, the remote route-list row in TestContainerRemoteReadsIntegration_AllRoutesServeTheChain
S17:702 a referrer row is served like any other manifest Hosted_ReferrerRowIsServed
The declared 200 and 404, against the contract ResponsesMatchOpenAPIContract
The 200's content map is exactly the four servable types the raw row in TestContract_ContainerManifestReads_DeclareTheirResponses
404: malformed digest, absent, evicted, virtual kind, cross-image, foreign namespace, removal marker DigestSpellings_Return404, UnknownDigest_Returns404, MalformedAndMissingAreIndistinguishable, Remote_EvictedRowIs404, VirtualRepositoryIs404, CrossImageIsolation, ParentResolution
500 on GET: OpenBlob, the blob missing, a redirect despite force-stream PayloadFailures_Return500, Integration_UnstagedPayloadIs500
500 on HEAD: the metadata read, and an absent blob row Head_MetadataFailures_Return500
500: manifest read, and a blob_sha256 that is not 32 bytes wide ManifestReadFailure_Returns500, CorruptPayloadAddress_Returns500
D5 short copy at Error, both counts ShortCopy_LogsBothCountsAtError
D5 overlong at Error, written_bytes 3997696 OverlongPayload_LogsTheBufferedCount
D5 every other copy error at Warn ClientHangup_LogsAtWarn, CloseFailure_LogsAtWarn
The route binds read_artifact at repository scope the wantRouteBindings row in binding_test.go

Reviewable LOC

Group Added Removed
Production Go 344 3
Test Go 1994 54
OpenAPI contract 16 8
e2e catalogs and run recipe 31 0
Total 2385 65

Past guardrail 18, and past the plan's own ~820 estimate. Production Go is 344 and under the line on its own, so the overrun is test volume, 3.6x the plan's ~550. Every split breaks: the e2e rows and the run recipe would describe a route that does not exist, the golden-table, count, and re-export edits do not compile without the handler, deleting the two contractPendingOperations entries demands a JSON 404 the mux will not give an unregistered path, and HEAD cannot split from GET because Go's mux serves it off the GET registration. The plan already rejected splitting the hosted and remote arms, which share the seam, the header setter, and the remote arm's only end-to-end proof.

Test plan

go test ./internal/managementapi/...
ARTIFACT_REGISTRY_DATABASE_TEST_DSN=<dsn> go test -tags=integration ./internal/managementapi/...
golangci-lint run --build-tags=integration ./internal/managementapi/... \
  --max-same-issues=0 --max-issues-per-linter=0 --uniq-by-line=false
scripts/ci/check-comment-caps.sh --base $(git merge-base HEAD origin/main)
redocly lint --config api/openapi/redocly.yaml

Driven live with a real OCI manifest pushed through /v2/ (40,000-byte layer, 163-byte config, 404-byte payload): the body is byte-identical to the pushed file and to what /v2/ serves for the same digest, size reads 40567 against Content-Length 404, HEAD matches GET's headers with an empty body, POST answers 405 Allow: GET, HEAD, and an absent digest and an uppercased hex both answer 404 not_found. driver.sh smoke passes 73/0.

Context for LLM agents

Rejected alternatives. Buffering inside transport.WriteJSON to declare Content-Length instead of deleting headResponseWriter: it would add a copy to every list response to close a gap that does not exist, since the unwrapped detail route already answers HEAD correctly. Keeping BlobInfo on both methods for one length source: the two reads select the same column for the same row, so the second buys nothing, and the cost of dropping it is one log message on the GET arm's absent-blob 500, which moves from errManifestPayloadBlobRow to storage.ErrBlobNotFound out of the open.

Adopted after review. The 200's content map enumerates the four manifest media types rather than application/*. The earlier argument for the wildcard reasoned from the resource schema's unconstrained media_type string, which is a different declaration from this route's response content map, and the wildcard told a generated client less than the handler guarantees.

Deferred, contract-wide. No head: operation on this path. The document declares 28 get:, 10 post:, 9 delete:, 2 patch:, and 1 put: operations and zero head:, because Go's mux serves HEAD off every GET registration. This route is the first whose own contract text advertises HEAD, so it surfaces the gap, but declaring one here alone would make it the document's only such route. The Allow enum and the prose carry the method for clients meanwhile.

Non-goals. No configuration: container.manifest_max_payload bounds the write path, and S17 states nothing on the read path re-applies it. No wide-event record and no fault series for the copy arms, which is the residual named above rather than an oversight. No Bruno change: guardrail 15 fires on adding, renaming, or removing an operation, and this change moves a response content map only, with Step 4's get-container-manifest-raw.bru already describing the served behavior.

Known follow-up, pre-existing and unfiled. A HEAD request books its route metric label with a literal GET prefix. internal/metrics/http.go:147 strips r.Method+" ", but r.Pattern names the GET node for a HEAD request, which binding.go:69-70 documents. Reproduced on the manifest detail route already on main, so this MR does not introduce it, but this is the first route whose contract advertises HEAD. The fix is one line in shared middleware that labels every route in the service, so it needs its own MR and tests.

ADR trail. ADR-005 (:79-83) exempts format metadata documents whose clients verify the declared media type from redirect delivery, and grants the exemption only to documents their format's spec lists. S12 is that listing for manifests. ADR-009 leaves this area unreconciled rather than forbidden: its Management API category declares JSON responses while its own route list carries a raw-bytes file download. Checked against the upstream handbook, the local mirror being stale by one commit touching 009_api_design.md.

🤖 Generated with Claude Code

Related to #1150 (closed)

Edited by Hayley Swimelar

Merge request reports

Loading
Loading