S16: Container remote implementation
Implement container remote repositories per the S16 spec (#283 (closed)). Depends on the S13 foundation implementation (#280 (closed)).
Spec and plan
Scope
Container remote (kind=2) vertical slice: the six container_remote_* cache tables, ContainerRemoteConfig, the WWW-Authenticate challenge parser and token-exchange handshake, the token cache, the read-path kind-dispatch seam, the cache-hit serve path, the manifest and blob proxy paths, the two live-proxied lists (tag listing and referrers), download signals, the health-probe contribution, and the oci_remote_* observability surface.
Delivered as 18 implementation steps across 22 MRs — Steps 1 and 2 each ship three sequential MRs, one table per MR; every other step is one MR. Two tracks, split at the first upstream byte: Track A (Steps 1-11) consumes merged S13 surface and can open today; Track B (Steps 12-18) is downstream of a real end-to-end Fetch and reaches its one external prerequisite (S13 Step 11) through Step 12. Execution follows the dependency DAG below — not numeric order; each step's blockers are its direct predecessors. Step numbers are stable references for MR titles and review.
All 110 of the spec's acceptance criteria have a named owning step; the plan's Acceptance-criterion ownership table maps them, and marks the ones that ship skipped-and-named against an external gate.
Reachability: no step here delivers a user-reachable capability until S17 Phase 6 (#314) lands. internal/managementapi/create.go answers 422 to any non-hosted kind, so no API creates a kind=2 container repository — every test and fixture in this plan, Step 18's harness included, seeds repository rows directly. The slice can merge complete and still serve no traffic. This is a scheduling fact for the S16 and S17 DRIs, not a blocker on any step.
Out of scope: S13 owns the format-agnostic foundation (Lookup/Probe/Fetch, the teed cache-fill pipeline, single-flight, cache freshness/fallback, the SSRF-hardened upstream client, credential management, health monitoring). S17 owns remote-repo REST CRUD and the connection test. S32 owns container virtual (kind=1); S29 cache eviction, S28 GC, S20 soft-delete cascade, S08/S09 real auth, S18 the buffered-counter pipeline.
Dependency DAG
graph TD
ST1["✅ Step 1: schema - repositories/images/manifests (Track A)"]
ST2["✅ Step 2: schema - relationships/blobs/tags (Track A)"]
ST3["✅ Step 3: building blocks - URL/segment safety/name transform (Track A)"]
ST4["✅ Step 4: WWW-Authenticate challenge parser (Track A)"]
ST5["✅ Step 5: token cache + ContainerRemoteConfig (Track A)"]
ST6["✅ Step 6: datastore read - finder + health (Track A)"]
ST7["✅ Step 7: datastore read - cache lookup + freshness (Track A)"]
ST8["✅ Step 8: datastore write - cache-fill + by-digest gate (Track A)"]
ST9["✅ Step 9: datastore - credentials/auth_url/auth_status (Track A)"]
ST10["✅ Step 10: kind dispatch + 405 + 501 stubs (Track A)"]
ST10_5["✅ Step 10.5: remote handler slots on the dispatcher (Track A)"]
ST11["✅ Step 11: cache-hit serve + download signals (Track A)"]
ST12["✅ Step 12: request builder + token-exchange flow (Track B)"]
ST13["✅ Step 13: operations glue + error mapping (Track B)"]
ST14["👀 Step 14: manifest proxy + revalidation (Track B)"]
ST15["👀 Step 15: blob proxy - miss/HEAD/range (Track B)"]
ST16["✅ Step 16: live lists - tags + referrers (Track B)"]
ST17["Step 17: observability (Track B)"]
ST18["Step 18: hermetic e2e proxy harness (Track B)"]
S13S11["✅ S13 Step 11 part 1: UpstreamDoer adapter + seam assertion"]
ST1 --> ST2
ST1 --> ST6
ST1 --> ST7
ST2 --> ST7
ST1 --> ST9
ST7 --> ST8
ST6 --> ST10
ST7 --> ST11
ST10 --> ST11
ST10_5 --> ST11
ST3 --> ST12
ST4 --> ST12
ST5 --> ST12
ST9 --> ST12
S13S11 --> ST12
ST8 --> ST13
ST12 --> ST13
ST11 --> ST14
ST13 --> ST14
ST11 --> ST15
ST13 --> ST15
ST10 --> ST16
ST10_5 --> ST16
ST12 --> ST16
ST14 --> ST17
ST15 --> ST17
ST16 --> ST17
ST17 --> ST18Status
All six schema MRs (1a-1c, 2a-2c) are serialized, not parallel: internal/datastore/migrations/migrations.go sets goose.WithAllowOutofOrder(false) and a migration timestamp is fixed when the file is authored, so each takes the next free timestamp only after the previous one has merged. Both sibling slices serialized theirs for the same reason. Within Step 2 the merge order is 2a → 2c → 2b, relationships last: nothing in this slice reads or writes container_remote_manifest_relationships, so Step 7 waits only on 2a and 2c, and 2b merges off the critical path — it only has to precede Step 8. The plan amendment recording this order rides in the 2a MR.
|
# |
Description | MR | Status | Done | Assignee | Dependencies |
|---|---|---|---|---|---|---|
| 1a |
Schema — |
|
— | |||
| 1b |
Schema — |
|
1a | |||
| 1c |
Schema — |
|
1b | |||
| 2a |
Schema — |
|
1b; timestamp order after 1c | |||
| 2b |
Schema — |
|
1b, 1c; timestamp order after 2c — merges last: nothing in this slice reads or writes the table (population deferred to #264); must merge before Step 8 opens, whose acceptance queries it |
|||
| 2c |
Schema — |
|
1b, 1c; timestamp order after 2a | |||
| 3 |
Remote building blocks: upstream URL with per-segment path escaping, the outbound reference gate applying S12's tag grammar, and the constant-memory streaming rewrite of the |
|
— | |||
| 4 |
|
|
— | |||
| 5 |
Token cache + |
|
—; external: S17 (#314, open) supplies the eviction trigger — 3 criteria ship gated |
|||
| 6 |
Datastore read — remote-repository finder + health: |
|
1a | |||
| 7 |
Datastore read — cache lookup and freshness: |
|
Steps 1, 2a, 2c — not 2b: the lookup reads the tags, manifests, and blobs tables only | |||
| 8 |
Datastore write — cache-fill and the by-digest gate: |
|
Step 7; 2b — its acceptance asserts an index fill writes zero relationship rows, a query that needs the table to exist | |||
| 9 |
Datastore — credentials, |
|
1a; external: S17 (#314, open) owns the transaction, audit event, and API response — 3 criteria ship gated |
|||
| 10 |
Kind dispatch + write unavailability + |
|
Step 6 | |||
| 10.5 |
Remote handler slots on the dispatcher: one |
|
— | |||
| 11 |
Cache-hit serve path + download signals: ADR-005 delivery mode for blobs, force-streamed manifests, |
!1694 (merged), !1707 (merged), !1762 (merged), !1764 (merged) |
|
Steps 7, 10, 10.5; external: buffered-counter centralization (#91, open) gates every |
||
| 12 |
Container request builder + token-exchange flow: |
|
All blockers clear. Steps 3, 4, 5, 9 are merged; the external prerequisite is the |
|||
| 13 |
Operations glue + error mapping: per-fetch |
|
Steps 8, 12; external: #397 (closed) gates the plain-gzip half of the identity-representation rejection |
|||
| 14 |
Manifest proxy — miss fill, digest verification, stale-tag revalidation: |
!1869 (merged), !1872 (merged), !1873 (merged), !1875 (merged) | workflowin review |
|
Steps 11, 13; external: #433 gap 2 (readable |
|
| 15 |
Blob proxy — miss fill, streamed |
|
Steps 11, 13; external: #477 (closed) (single-flight seam shape — 3 coalescing criteria ship gated) |
|||
| 16 |
Live-proxied lists — tag listing + referrers: one upstream |
|
Steps 10, 10.5, 12 | |||
| 17 |
Observability — metrics + wide event: the eight |
!1935 (merged) | workflowin dev |
|
@radbatnag— | Steps 14, 15, 16 |
| 18 |
Hermetic end-to-end proxy harness: one in-package test driving a realistic |
Not started |
|
— | Step 17 |
Cross-workstream dependencies (critical path)
Sibling status verified 2026-08-10. S13 (#280 (closed)) is workflow::in dev. Step 11 (#332 (closed)) is the only S13 step that touches this slice; its Step 12 (#333 (closed)) is virtual resolution, which belongs to S32, and both of its MRs have merged.
-
S13 Step 11 (#332, open, @10io) is three MRs, and the part S16 needs has merged. Step 12 consumes it, every Track B step reaches it through Step 12, and Track A carries none. The split is what unblocks Track B early, because what Step 12 needs is not what is still open: !1290 (merged) (part 1/3) carries the serve taxonomy, the transport marker, the
remote.UpstreamDoeradapter overupstreamhttp.Client(internal/remote/upstreamhttp/doer.go), and the sharedremotetest.AssertUpstreamDoerConformanceseam assertion — that adapter and that assertion are the whole of Step 12's external need, and both are onmain. !1291 (merged) (part 2/3, the standalone request flow) is open with unresolved discussions, and !1292 (merged) (part 3/3, its choreography tests) is draft and conflicted. Neither delivers anything this slice consumes: the standalone flow is a different entry point, which the plan records in Step 13's Note. S16 still writes no adapter and edits no package underinternal/remote. -
#269** is open again, so Step 11 no longer carries a transfer-deadline fix.** A
Closestrailer on !1199 (merged) — S16's own plan MR — closed it on 2026-08-03; @10io reopened it on 2026-08-05 because the close was a side effect, not a decision that the work was done. !1290 (merged) instead records the gap and defers the fix: the interim behavior staysClient.Do's flatrequest_total_timeout, and the progress-based read deadline that would tell a stalled transfer from a slow-but-moving one is still to come. No S16 criterion depends on it, but that flat cap is the real ceiling on how large a layer Step 15 can fill over a slow upstream. Of the two questions @10io left on #269, one is answered:docs/dev/configuration-reference.mdcarries the operator sizing paragraph, which landed with !1290 (merged) and was sharpened by !1328 (merged). The other still wants an answer before Track B starts — whatrequest_total_timeoutGitLab.com actually runs, which needs a Vault lookup. Of #269's four proposed fixes, 2 and 4 landed as S13 spec amendments in !1329 (merged) — fix 2 confirming a reading that is S32-owned, fix 4 recording the blob-cap reachability gap rather than closing it. Fixes 1 (the progress-based read deadline) and 3 (cache fill survives client disconnect) are code ininternal/remote, unassigned, and have no S13 plan step left to carry them, so each needs an owner plus a spec MR and plan MR of its own. The S16 plan repeated the superseded claim that Step 11 resolves #269 — including in Step 12's own prerequisite bullet — and !1397 (merged) corrects it. -
Fetch coalescing is deferred by decision, not by a missing dependency. S13's single-flight layer (#329 (closed)) is merged, but
NewSingleFlightholds theRequestBuilderandCacheStorefor the repository's lifetime, while both seams are request-scoped here on purpose — the container builder holds a bearer token it must not outlive, and the cache store needs the request's digest for Step 8's gate. Reconciling the two is #477 (closed); Steps 13 and 15 keep the request-scoped seams and ship 3 criteria skipped against it. Cross-instance coordination is #375 and S16 asserts nothing that needs it. Two things moved since: the builder-lifetime half now has a spec-side fix queued as #320 (closed) item 9 — an implementation reads credentials at call time insideBuildRequestrather than capturing them at construction — and #528 proposes buffering the first body chunk insideremote.SingleFlightso a body that dies before its first byte can still fall back to a stale cache row. #528 is deferred by operator decision, and as designed its fix lives insideSingleFlight, which S16 does not compose — Step 14's cache fallback would keep the same gap. -
S13 health monitoring is complete. Step 14 (#335 (closed)) and Step 15, the scheduled sweep (#336 (closed), 2026-08-06), are both merged, so the probe, status transitions, failure counter, on-demand entry point, and the periodic sweep in
internal/remote/healthjob.goare all onmain. Step 6 waits on none of it, and both probe criteria stay verifiable againstremotetest.FakeHealthStatusStore. The live successor is #479 — oneHEADper distinct upstream instead of one per row — which also proposes revisiting theRemoteRepositoryEnumeratorshape (pagination or streaming) in the same design pass. Step 6 delivers this format's enumerator entries against today's shape; if #479 lands first, Step 6 follows whatever it settles. -
S13 fetch-seam gaps (#433, open, unassigned) — gap 2 (readable
Docker-Content-Digestplus a pre-commit comparison) gates Step 14's disagreeing-header criterion in full; gap 3 (Content-Lengthexemption on the forwardable-header allowlist) gates the forwarding half of Step 15's cold-fill criterion. Both shared with S14. Both are downstream of thesafeHeadersFromallowlist that gaps 2 and 3 have to extend, which !1290 (merged) landed onmain. One allowlist cannot express the outcome-plus-raw gate those two gaps need, so the shape has to be settled against the mergedsafeHeadersFrom. Gap 1 is in flight as !1325 (closed) (draft). -
S13 per-request no-transparent-decode switch (#397 (closed), open, @10io, quiet since 2026-07-31) — gates the plain-gzip half of Step 13's identity-representation rejection. Shared with S14, whose merged upstream builder carries the same gap.
-
S13 spec amendment (#320 (closed), open) — item 8 is
auth_statusin the behavioral-columns table and the clear-on-url-change rule. Spec text, not code; until it lands, S16's spec is the only place the column and its reset are written down. The issue has grown to 11 items and two of the newer ones also reach this slice: item 9 above, and item 11, which excludes the read-time size caps (ErrResponseSizeTooLarge,ErrDecompressionTooLarge) from the cache-fallback family because they are raised during a bodyRead, never returned fromUpstreamDoer.Do, and so never carry the transport marker. Item 11 was settled in the !1290 (merged) review and its code-side doc fix is onmain; Step 13's error mapping follows it. -
S17 Phase 6 remote-repo CRUD + connection test (#314, open, unassigned, no activity since 2026-07-15) — four auth criteria assert its write surface (Step 9 ships the store half of each), it is the trigger for Step 5's three eviction criteria, and it owns the single transaction around a
urlchange. Also the gate on this slice being user-reachable at all. Nothing has moved on it over the whole Track A run so far. -
Buffered-counter centralization (#91, open, unassigned) — gates three
downloads_countassertions in Step 11 and nothing else in this slice. Step 11 itself is not blocked by it. The spec and the plan both already say how to ship without it: wire each read to the store seam carrying the increment, and ship the store method as a stub that builds theUPDATEand returnsnilwithout executing it, matchingMavenRepositoryStore.BumpRepoDownloadCountininternal/datastore/maven_repositories.go. Thelast_downloaded_atbumps are ungated and must work; npm's injected-bumper shape is available to follow.This issue previously called #91 "the S18 buffered-counter pipeline", which overstates it. #91's scope is moving the stub helpers into one shared package so Maven, npm, and OCI stop each carrying their own, and its exit criteria keep the stub as "currently direct synchronous
UPDATEs". Whether the shared helper executes thedownloads_countincrement is not settled anywhere in #91's text, so #91 merging does not on its own turn those three assertions on — it moves the call site, which is not the same as making the counter count. The working bounded writer that would do the counting is npm's (internal/format/npm/buffered.go), format-private and out of reach per ADR-023, and putting it within reach is what #91 would change. Whoever picks up #91 has to decide the executing-versus-inert question explicitly; until then, treat the gate as still closed however far #91 gets.Still unassigned, still no spec, and no shared package exists (
internal/counters/or otherwise). Moved on 2026-08-14 for the first time since 2026-06-10: @dmeshcharakou opened #632 — no buffered counter column has a reconciliation pass, so a write that is shed drifts permanently — and linked it to #91 as the durability half that centralizing on its own does not address. #632 does not reach this slice: Step 11's only buffered write is the inertdownloads_countstub, and thelast_downloaded_atbumps it does execute are timestamps a later read repairs rather than counters that accumulate drift. -
S12 delivery-mode questions (#434) — whether a
307carriesDocker-Content-Digest, and howRangeinteracts with redirect delivery. Nothing blocked: the affected criteria are scoped to proxied responses. -
ADR-007 reconciliation (#30, items 6-11) — a handbook-repo MR, not a step here, and no open handbook MR carries these items yet. The container range grew by one since this issue was written: item 11 records
CHECK (last_health_status IN (0, 1, 2))on the container block, with the npm and Maven halves split out to #486 (closed). Until the amendment lands, a reviewer diffing Steps 1-2 against ADR-007 will flag correct migrations as divergent,auth_statusmost visibly. -
Namespace partition key on the health and credential seams (#445 (closed)) — merged, so Steps 6 and 9 implement the already-widened signatures. Taking
namespace_idis not the same as pinning it in the predicate, and only the pinning prunes the 64 partitions; each step carries a pinning note. -
Credential encryption at rest (#417) and the realm-host allowlist (#263) stay GA-blocking follow-ups. Step 1a ships interim
tmp_plaintext_*columns.
Related
- Spec: #283 (closed)
- Foundation: #280 (closed)
- AppSec review: #245
- Container virtual (
kind=1): #291