feat(oci): classify container remote cache fills and gate by digest
Why
A container remote's cache fill has to decide what media type it is storing before any SQL runs,
and it has to refuse a fill whose bytes disagree with the digest the request named. Neither can
live in internal/datastore: both need an object-store read and a JSON parse, and ADR-023's
no-reverse-dependency rule forbids that package from importing internal/format.
This MR adds the internal/format/oci wrapper that does both and delegates the write.
Step 8 of the S16 container remote plan, specified by S16.
Depends on
Nothing open. The internal/datastore cache-fill half of this step has merged, so this MR targets
main directly and the merge order it needed is satisfied: it requires
ContainerRemoteCacheStore.UpsertCacheFill to exist with its cache-key-taking signature, and the
compile-time anchor var _ remoteCacheBacking = (*datastore.ContainerRemoteCacheStore)(nil) fails
against either its absence or the earlier signature.
The branch was rebased onto main once the predecessor merged, so the merge commit an earlier
revision of this description explained is gone. The rebase had to be checked rather than trusted: the
predecessor merged squashed, so its individual commits are not on main, and the merge commit
being dropped took with it edits that lived in neither of its parents — the absorption of the
predecessor's signature change. A plain replay silently reproduced the earlier
UpsertCacheFill(ctx, params) shape, which does not compile against main. Both internal/format/oci
files were reconciled against the pre-rebase tree and verified byte-identical to the reviewed state.
What (the non-obvious parts)
- The read-back forces a stream.
OpenBlobanswers under the namespace's configured delivery mode and hands back a signed URL when that mode is redirect, which a server-side caller cannot follow. That failure is environment-dependent rather than deterministic: a dev or CI instance on proxy mode classifies fine while production on redirect mode fails every manifest fill. A redirect arriving despite the override is refused rather than classified from bytes nobody holds.internal/format/maven/reconciler.gorefuses one the same way. Notedelivery_modehas no configured default — an operator must set it — so the override is not a hedge against a default. - Every refusal that holds a stream closes it.
OpenBlobissues the object-store GET before it returns, so the stream is live by the time the classification inspects it — the reader is taken and itsClosedeferred ahead of the size check for that reason, not next to the read it feeds. Ordering the cap first abandons the connection on that arm.remoteCacheFakeBlobscounts its closes so the suite can see it: the sentinel and the absent delegation both stay green when a refusal leaks, which is how it went unnoticed until a Close-recording fake existed. The redirect refusal is the one path out after a successful open that closes nothing, and it is not an exception to the rule: a redirect open issues no GET and hands back no reader, which is why its subtest pins zero closes rather than one. The claim is scoped to the stream on purpose — stated absolutely, 30 lines from its own exception, it invites a later "fix" that closes a nil reader. - Three refusals name the blob they are about, in the log. Each of them reports a fault in one specific object: a redirect despite the stream override, a byte count disagreeing with the blob row, and a payload that is not manifest JSON at all. None of the three sentinels can carry the coordinates — and on a by-tag fill nothing in the caller's scope recovers the digest either, since the cache key names a tag — so all three log the namespace, the remote repository id, and the digest through one logger bound for that purpose. A namespace holds many remote container repositories, so the namespace alone does not identify the row whose fills are failing. The Maven reconciler's read-back logs its own redirect refusal on the same reasoning, carrying its repository and package ids alongside the namespace. The logger is built per refusal rather than once per fill, so the success path pays no allocation for paths that do not run. The suite asserts all three fields on each line; dropping any one of the three log calls turns its own test red. The refusals the caller maps are deliberately not logged here — the two storage errors travel with their own wrapped cause, and a content verdict on what the upstream chose belongs to the frame that answers the client.
- The read-back is bounded from both directions, and the length guard covers both. The size
OpenBlobreports is refused ahead of any read, so an oversize payload costs no read and the error can name the real number; the read itself then runs underio.LimitReaderat the same ceiling, because a driver reporting a size it does not honour would otherwise stream past it. A remaining disagreement in either direction is then refused — the guard is a!=, not a<, because a stream running past the row's size while stopping short of the ceiling passes both bounds above and reaches only that comparison, and it writes a row whose size and digest describe different byte strings exactly as a truncated one does. Both rows are in the table; a rewrite to<goes red. A payload of exactly the ceiling is covered too, because both guards are>: either one turned into>=refuses a legal manifest with every other assertion still green, and it would diverge from what a hosted push admits, wherereadManifestBody'shttp.MaxBytesReadertakes exactly the cap. - The read-back buffer is sized from the blob row rather than grown. The exact length is
validated on the line above, so
io.ReadAll's 512-byte start and its doubling were paying for a length already in hand — and its final doubling holds the old buffer alongside the new one, so a payload near the ceiling peaked at roughly twice its own size for the whole read.internal/remotesizes the fill's own staging buffer off the response's declared length for exactly that reason, and this read happens on the same request, so growing here gave back part of what that sizing bought. The size is floored at zero:blob_storage_blobs.sizeis non-negative by a CHECK constraint, but the blob reader is an interface andmakepanics on a negative capacity, so the floor keeps a bad implementation on the refusal path instead of taking the process down. A negative reported size reaches the length comparison, which is where a disagreement between a row and its object belongs. - The ceiling is
defaultManifestMaxPayload, which is a gap this MR names rather than closes. A constant stands here because an unbounded read is not an option, not because a fixed bound is the right policy. It agrees withcontainer.manifest_max_payloadon a default configuration and diverges above a raised cap, where a manifest between them is fetched, committed, and then refused here on every attempt. The plan's Step 13 entry previously said this read-back would inherit the fetch's bound rather than repeat it; it repeats it, so that entry now assigns Step 13 the job of threading the configured value into this store as well — and namesinternal/format/oci/remote_cache_store.goin its Files list, since an obligation recorded only in prose is one the step's checklist does not carry. See the Step 13 section below for why that is the resolution rather than an error mapping. - The detection chain runs first, the unaccepted-type refusal second. A payload naming Docker
Schema 1 fails as Schema 1; refusing unaccepted types ahead of the chain would collapse that case
into this one. The order is also what makes the refusal reachable: with no header supplied, an
unaccepted
mediaTypeyields no tentative type and no conflict, so the chain falls through to structural detection and returns an OCI type the manifest never claimed. - The refusal is here, not in
DetectManifestType, because the chain has a second caller. The hosted push path reaches the same headerless fallthrough —enforceRepositoryContent's doc inmanifest_push.gosays so outright — so refusing inside the chain would change what a hosted PUT accepts. Whether the hosted path wants the same refusal is S12's call on S12's error vocabulary. That is a live gap on the hosted push path, worth its own issue, and it is not this MR's to close. - A genuine Docker Schema 1 manifest lands on the ambiguity refusal, not the Schema 1 one.
Schema 1 predates
mediaType, so a real payload carries none and its layer list isfsLayers. With no header there is nothing left to carry the Schema 1 signal, so the chain reaches structural detection with neither array present and stops on the ambiguity guard ahead of the schema-version gate. The fixture named for that case asserts ambiguity for exactly that reason. - Four sentinels are exported, because the frame that will map a refused fill lands outside this
file.
ErrUnacceptedManifestMediaTypeandErrCacheFillDigestMismatchwere already; this MR addsErrUnparseableManifestPayloadandErrManifestPayloadTooLarge. The first of the two is a condition an upstream causes — a non-manifest body under a200, such as an HTML error page from a captive portal — so a caller that cannot tell it from an infrastructure failure files it under its datastore-failure default and pages as if AR's storage were broken, and unexported its only handle was a wrapped*json.SyntaxError. Their doc comments say the mapping frame will land rather than asserting it already sits there: nothing outside this file references any of the four yet, and the remote read arms still answer the interim 501 inremote_stub.go, so a present-tense claim is one a reviewer who greps can falsify. - S16 gains two Error Cases rows and a corrected
outcomedefinition. One row for a manifest payload that is not a JSON object at all, which the table did not have, plus its own acceptance criterion carrying the top-level-nullasymmetry. One row for the three read-back faults that belong to this service rather than the upstream, at500 INTERNALon the reasoning the half-set-credential row gives. And the paragraph definingoutcome="unsupported_content"said all four refused-payload cases take it and that they differ only in which part of the payload the service will not represent — the new non-object row is explicitly not one of the four, so the definition undercounted the value it defines, and an alert built from it missed the case whose likeliest cause is an upstream serving an HTML error page under a200. The section also records that nooutcomevalue fits an internal read-back fault, rather than inventing a tenth: the upstream response was a clean200, sookreports a success that did not happen,unsupported_contentblames content the upstream was entitled to serve, andserver_errorandtransport_errorboth name upstream conditions in a metric scoped to upstream responses. Naming the gap is how that section already handles the missing tag-list transform outcome. - The cache key is handed to the fill whole rather than split into coordinates.
UpsertCacheFilltakes the key and derives the image name, the content table, and the tag name from it, soContainerRemoteUpsertParamscarries none of the three and this store passesCacheEntry.Pathstraight through. Its own parse keeps only what the format side decides with — a manifests-or-blobs discriminator and the digest a by-digest key names — and still checks the image name before dropping it. One grammar writes the rows, so a caller cannot pass coordinates that disagree with the key they came from. - Two key shapes this parse admits are refused by the fill rather than written. A
blobsreference that is not a digest, and amanifestsreference carrying a malformed digest, both get past this parse by design: the serving caller has already held the reference to the sha256 pattern or to the tag grammar, so refusing them here would be dead code. Under a signature that took pre-split coordinates this store decided their rows itself, and both landed as rows — the malformed digest as a tag name, whichcontainer_remote_tags.nameaccepts, carrying a length CHECK and no grammar CHECK. Now the key travels instead,parseContainerRemoteCacheKeyrejects both, and neither writes anything. The only cost is on the manifests shape: a read-back and a classification spent ahead of the refusal.TestRemoteCacheStore_ShapesTheParseAdmitsasserts what this store does with each and points at the datastore's rejection table for the refusal itself. - The by-digest comparison runs ahead of the read-back as well as the delegation, so a mismatch writes no content row and no image parent and spends no object-store open. The hex body is compared case-insensitively, so a reference spelling the same bytes in upper case commits rather than missing.
- A blob fill takes neither the read-back nor the classification:
container_remote_blobscarries nomedia_typeand nosizecolumn, and the bytes are a layer rather than JSON. The zero open count is asserted, not assumed. - The type is one method short of
remote.CacheStoreuntilBumpLastDownloadedAtlands, so it carries no compile-time anchor against that interface and every comment about satisfying it is in the future tense. The anchor it does carry is against the narrower backing surface it composes over. Adding theremote.CacheStoreanchor now would not compile. - The embedding promotes two methods, the write is not one of them, and the promotion is now
pinned. The embedded interface is narrowed to
remoteCachePromoted—LookupandBumpUpstreamCheckedAt— soUpsertCacheFillstays off this type's method set, and the gatesUpsertCacheEntryapplies cannot be stepped around by calling the write on the wrapper. The write is reached through an unexportedfillfield instead, which is package-private by construction. Embedding is kept for the two reads rather than replaced by explicit delegation, because a hand-written forwarder is whereBumpUpstreamCheckedAt'sErrCacheEntryNotFoundcontract gets flattened — and that was a stated contract with nothing asserting it, since the fake panicked on both promoted methods and the method-set test only checked that the names resolve. It now has a test, and the fake keeps its panic as the default so every fill subtest still fails loudly if a fill reaches either method. ETagandUpstreamCheckedAtare passed through unvalidated on purpose. The predecessor degrades an unstorable ETag toNULL— its comment explains that the guard belongs to the write that owns the constraint — and a zeroUpstreamCheckedAtis unreachable, since the only production producer sets it fromtime.Now().- Nothing here belongs to one request, so one instance serves every fill against the repository it
was built for. The wrapper itself reads two fields of the row it holds — the namespace the
read-back opens under and the repository id its refusals log — neither of which a repository can
change. That is not the whole picture, because
Lookupis on its method set and the freshness verdictLookuppromotes comes from thecache_validity_hoursthe backing store snapshotted, so a shared instance has to be rebuilt when the repository row changes.datastore.ContainerRemoteCacheStoredocuments that on its own type; it reaches callers through this one, so it is stated on both.
Reviewable size
2,751 reviewable LOC (added + removed, the rule
development-model.md
applies at the 500 gate), past the 500 that asks an author to justify. By file group: production 863
(remote_cache_store.go), tests 1,758 (remote_cache_store_test.go), docs 130 (111 in the plan's
Step 8 and Step 13 entries and its criterion-ownership table, 19 in S16's Error Cases table,
acceptance criteria, Observability section, and Follow-ups).
The plan's Est. cell reports the same work under a different rule — added .go lines with blank and
comment-only lines dropped — and names which count it is, so the two can be reconciled. This half
measures 232 production and 1,045 test lines on that rule; the plan's Step 8 entry carries both halves
and the step's total.
Splitting further would not help: the wrapper is a struct, a constructor, and one overridden method,
and it cannot land without the suite that pins its two orderings. The suite covers all four accepted
media types as positive hits plus two rows where the declared type disagrees with the payload's
structure, all four refusals with the reason each carries, six digest-gate cases, the redirect guard
and the three fields it logs, the size bound from both directions plus the at-cap boundary, the
length disagreement from both directions plus a negative reported size, the read-back faults
including a stream that fails partway through, the Close count on the committing path and on every
refusal that was handed a stream, the key reaching the fill unchanged, both promoted methods and the
sentinel one of them has to keep matchable, and the exported type's zero-value behaviour — all
against fakes, with no database and no object store.
Test plan
go test -count=1 -race ./internal/format/oci/
golangci-lint run --max-same-issues=0 --max-issues-per-linter=0 ./internal/format/oci/Both are clean; the suite is untagged, so it runs in the ordinary unit job. No //nolint directive
appears in either file.
Every assertion worth not trusting on sight was mutation-checked, production mutated and restored by
checksum. Reverting the reader hoist so the size check precedes content.Reader() fails only
RefusesOversizeManifestReadBack/a_reported_size_past_the_ceiling_is_refused_before_the_read, on the
Close count. Narrowing the length guard to < fails only
RefusesReadBackLengthMismatch/a_stream_that_runs_past_the_reported_size_but_under_the_ceiling.
Turning the ceiling guard from > to >= fails only the at-cap subtest. Dropping either of the two
new log calls fails only that refusal's own test, on the digest field. Shadowing the promoted methods
with forwarders that flatten the error chain fails only the not-found-sentinel subtest, while the
method-set test stays green — which is the gap that subtest exists to close. Each discriminates the
case it is there for and nothing else.
Dropping the buffer's zero floor is the one mutation that did not fail on the first attempt, and
the fixture was wrong rather than the floor: at a reported size of -1 the bytes.MinRead headroom
makes the capacity 511, so make never sees a negative and the refusal lands either way. The subtest
is now a two-case table naming which case depends on the floor — a small negative the headroom
absorbs on its own, and one past the headroom that panics without it.
The assertion that replaced the dropped coordinate checks was measured the same way. Handing the
fill a key other than CacheEntry.Path fails 14 subtests across 3 tests — every table that asserts
the delegated key. Flipping the manifests discriminator fails 31 across 8, since it moves the
read-back onto the blob route and off the manifest one.
No e2e scenario is added or affected: nothing constructs this type until the composition root wires it, so there is no HTTP surface to exercise and docs/testing/ has nothing to add yet.
Known and deliberate, so review need not rediscover them
classifyRemoteCacheManifestunmarshals the fullparsedManifestenvelope to read two scalar fields,MediaTypeandSchemaVersion;detectStructuralKeyssupplies the structural signals from a separate token scan. A narrower struct is measurably cheaper — on a 2,000-child index, 1,028,830 B/op and 8,065 allocs against 25 B/op and 1 — but it is not a safe local change and is deliberately not made here.parsedManifest's typed fields are what reject a payload whose unused fields have the wrong shape:{"schemaVersion":2,"layers":"nope"}fails the unmarshal today, while against a two-field struct it would be ignored,detectStructuralKeyswould report thelayerskey present, and the payload would be classified and stored. That would also split what a remote fill accepts from what a hosted push accepts, since the hosted path unmarshals the same envelope. Worth doing for both paths at once, preserving the type validation; not worth doing for one.- A refused fill re-pays its whole cost on every client retry, and this MR adds one object-store GET
to that cost. Classification runs after the storage session has committed, so a deterministic
content refusal spends an upstream GET, a staging write and move, and now a read-back, and leaves an
unreferenced blob. S16 accepts that for the classified refusals and the object dedups on its content
address, so nothing accumulates unboundedly. What is new is that the extra GET is synchronous inside
the fill, so it lands on the tail latency of every remote manifest cache miss — worth knowing when
cache_fill_duration_secondsgets sized. - The read-back's object-store GET and its read take no timeout of their own, so once a serving
caller is wired up they will both come out of whatever budget bounds
UpsertCacheEntry.internal/remote's fill bounds that call with a single timeout, and for the Maven and npm slices it had only cache-row SQL inside it — so whoever tunes that bound will be sizing object-store latency alongside datastore latency. Recorded onclassifyCommittedManifestrather than fixed here: the store has no production caller yet, so there is no budget to split. PgBlobStore.OpenBlobobservesblob_download_bytesand emits theblob_servedwide event at Info unconditionally, and no option suppresses it, so this server-side classification read counts bytes nobody downloaded and logs a line claiming a blob was served. Not introduced here and not fixed here: four other non-test internal callers already do the same, includinginternal/remote/singleflight.go, which is on the remote read path too. It belongs tointernal/storageas its own change.
An obligation this MR records for Step 13
ErrManifestPayloadTooLarge is exported so a caller can classify it, and the condition it reports
should not survive Step 13.
This read-back's ceiling is oci.defaultManifestMaxPayload. Step 13 threads
container.manifest_max_payload into the manifest Fetch as its body bound. The two hold the same
value on a default configuration and diverge above a raised cap, and while they diverge a manifest
between them is fetched, committed, and then refused here on every attempt — that image never caches,
and the refusal blames the upstream for content AR's own configuration accepted. So Step 13 threads
the same value into oci.NewRemoteCacheStore too, in the same MR, because equal bounds is the point
and splitting them is how they drift apart. With them equal, an oversize manifest is refused at the
fetch before any commit, which is a body-cap overrun S16's Error Cases table already covers.
What remains of the sentinel once the bounds are equal is the two disagreeing, which is an internal
fault in the same family as a read-back whose byte count disagrees with the blob row. S16's new
500 INTERNAL row covers all three of those faults together, on the reasoning its
half-set-credential row already gives: a 503 is retryable to an OCI client while none of these
clears on a retry of the same bytes. An earlier revision of this description argued the row should
not exist at all while the bounds could still diverge — that was wrong in a way worth naming, because
Step 13's acceptance is a table over every row of the Error Cases table, so a sentinel with no row is
a sentinel with no acceptance test. The row is written for the post-Step-13 world it will be
implemented in; note that the half-set-credential row's first leg ("a read-side defect rather than
operator configuration") only fits after the bounds are equalized, which is another reason to
remove the divergence rather than settle for describing it.
Keeping the constant as allocation defense in depth was considered and rejected. The hosted push path
already buffers to the configured cap — readManifestBody in manifest_push.go wraps the request
body in an http.MaxBytesReader at h.maxPayload — so the constant holds the remote path to a bound
hosted does not apply to the same artifact type, and S13's body_size_cap_metadata independently
bounds what a fetch can commit, so threading the configured value hands nobody an unbounded read.
Adjacent, genuinely open, and not this step's: whether manifest_max_payload should be validated
against body_size_cap_metadata at startup, since a manifest cap above the metadata cap is dead on
the remote path the way one above blob_max_size is dead on the hosted one. That is config
validation on the S13/S16 boundary, not error mapping.
Related to #288