feat(oci): Step 13 part 2 - operations glue and health sweep (S16 plan: 13/29)
What this adds
The handler-independent operations glue for one resolved remote (kind=2) container repository, and the format's contribution to the scheduled health sweep. Part 2 of 2 for S16 Step 13.
Part 1 (!1784 (merged)) has merged and this branch now sits directly on main, so nothing gates it. Part 1 defines ErrUnclassifiableManifestPayload and MapFetchOutcome, which this part's comments name and which the read arms will map through.
The Step 13 entry corrections found while implementing this part are not in this diff. Guardrail 4 keeps plan-file edits off step MRs, so they moved to !1818 (merged) with the Status row fill.
NewRemoteOperations composes the cache-table seam, the request builder, and the hardened upstream client into a remote.Lookuper and a remote.Fetcher behind one remote.Operations, over an embedded remote.Stub for the Probe this slice composes none of. Nothing constructs it yet: the read arms 11b landed serve cached content and answer a 501 on a miss, so the glue waits for the miss-fill arms (Steps 14 and 15).
The body bounds, and one defect
The two Fetch helpers part company on the bound. A blob fetch runs under the blob size-cap class with no override, and takes no validator at all — a blob is content-addressed and never revalidated, so the ifNoneMatch parameter it used to accept and never document is gone.
A manifest fetch carries an explicit MaxBodyBytes. That replaces the metadata class's configured cap rather than tightening it (internal/remote/operations.go documents this), so handing over container.manifest_max_payload alone removed body_size_cap_metadata from the manifest path whenever an operator raised the former above the latter — which nothing in configuration prevents, since the manifest cap is validated against container.blob_max_size and never against the metadata cap. NewRemoteOperations now takes both and hands the fetch their minimum. Clamping the fetch downwards is the safe direction: less can commit than the read-back would admit.
Two deliberate absences
Probe, and remote.SingleFlight. The second costs more than the coalescing the plan already records as deferred: resolution.max_concurrent_fills_per_repo is enforced by a semaphore inside SingleFlight and nowhere else, so composing Fetcher directly leaves the container fill path with no per-repository in-flight bound. Unreachable while nothing fetches. #752 carries it and the miss-fill arms own closing it; the file comment and the plan both point there.
The health sweep — the one runtime-reachable change
Container remotes now join maven and npm in the scheduled sweep, so last_health_status and last_health_checked_at on container_remote_repositories stop being written by nothing. The probe is an unauthenticated HEAD of the remote's base URL, so the source takes no credential seam: an auth challenge counts as reachable, so a token buys the probe nothing and would put a credential on a request that exists only to observe reachability.
buildHealthSweepSources moves to a builder table, and its per-format skip message becomes a literal with the format in the existing source field. Built by concatenation it appeared in no source line, so the string an operator copies out of a log matched nothing in the tree. Note for anyone holding a log query: composition: building maven health sweep source; skipping and its npm twin no longer exist as literals; filter on source instead.
The container builder reads the audit sink through w.mustAuditSink(), like maven and npm. It briefly did not — a rebase over the audit-sink logger change left the inline construction behind with no git conflict, and the aggregate-level panic test could not catch it because maven panics first. TestBuildContainerHealthSweepSource_PanicsOnUnbuiltAuditSink calls the container builder directly and closes that gap.
Operational note: the sweep's budget is shared, not extended. One concurrency cap and one interval window now cover three formats' remotes, so a large fleet takes proportionally longer to flip a down remote to unhealthy.
Testing
Driven end to end against a live service, not just unit-tested: the sweep issues the probe, a reachable upstream records healthy, an unreachable one records unhealthy after the configured threshold, a credentialed remote is still probed anonymously, and a remote whose URL cannot root a request is skipped per-row with a WARN while the same sweep probes every other remote.
Unit side: eight constructor panic rows, a three-way min-clamp table covering both orderings and equality, an explicit zero-value contract test, end-to-end fill assertions for both the manifest and the blob helper, and the aggregate's membership pinned in both the unit and the integration suite.
Reviewable LOC
1424 total: production 423 (remote_operations.go 305, wire_remote.go 118), tests 1001. Production is under the 500 ceiling; the total is over it because the two suites are large. Splitting further would separate the glue from the wiring that constructs its health-sweep source, which is the one part of it a reviewer can actually run.
e2e scenarios
No docs/testing/ scenario is added or affected. The health sweep is a background job with no request surface, and the operations glue has no caller. The proxy scenarios land with the miss-fill arms.
Related to #288