feat(oci): blob cross-repository mount (S12 Step 16)
Why
S12 Step 16: blob cross-repository mount (POST /v2/<name>/blobs/uploads/?mount=<digest>&from=<source>), completing the OCI blob upload surface. Mount lets a client link a blob that already exists in another repository into a new one without re-uploading the bytes (OCI Distribution Spec section 3.3.2), the deduplication path registries rely on for shared base layers.
Built parallel to Phase D (Steps 12-15) with operator sign-off: mount is a pure blob operation with no manifest dependency, so it branches off main ahead of the manifest steps.
What (non-obvious)
- 201/202 only, no info leak (S-2). Mount returns 201 on success or 202 (fallback to a fresh upload session) for every failure: missing source, denied source, or an unparseable, cross-namespace, or absent
from. It never returns 4xx, so a failed mount cannot reveal whether the source repo or blob exists. The six fallback conditions each stamp a distinct internaloci.mount.fallback_reasonwide-event value, but the client-visible 202 is byte-identical across all six (arapidproperty test proves it). fromis the full route path<slug>/container/<repo>/<image>, resolved scoped to the destination namespace. A cross-namespacefromfalls back (ADR-008).- Database-only (ADR-008).
MountBloblinks the source's existing CAS object into the destination viablob_storage_attachments+container_blobs. No payload is copied. Re-mount returns 201 with no secondcontainer_blobsrow (itsON CONFLICT DO NOTHINGplus a re-read). The attachment insert is not guarded, so a re-mount adds an orphan attachment row reclaimed by ADR-011. - Dual auth. Write-to-dest is the existing route auth. Source-read goes through a
sourceAuthorizerseam wired allow-all until S08/ADR-020 (matching the current bootstrap-token stub); the deny path is exercised now via an injected authorizer.
Test plan
go test ./internal/format/oci/... (unit, rapid property, and DB-backed integration), go vet (default and integration tags), and golangci-lint 2.12 all pass.
Spec coverage (S12 Step 16):
| Criterion | Tests |
|---|---|
| AC-4 success links blob into dest | TestMountSucceeds*, TestMountBlobLinksDestNamespace (integration) |
| AC-5 six 202 fallbacks, distinct reasons | TestMountFallbackEmitsReason, TestMountFallbackIndistinguishable (rapid) |
| S-2 no source info leak | TestMountSourceDeniedNeverLeaks |
?mount= wins over ?digest= |
TestMountWinsOverDigest |
| mount-then-pull byte-identical | TestMountThenPullReturnsSourceBytes (integration) |
Conformance: the crossmount spec runs under conformance:oci, which stays allow_failure until Step 18 flips the S12 gate.
Related to #19 (closed)
Database Review Evidence
Queries
Note
Plans are from EXPLAIN (ANALYZE, BUFFERS) against an ephemeral
PostgreSQL 17.10 container (matching GL_PG_CURR_VERSION from
.gitlab-ci-other-versions.yml), with synthesized seed data rolled
back per query and the container torn down at the end of the run.
Numbers reflect moderate cardinality and do not capture
production-scale effects. See
Database review evidence
for seed sizing, methodology, and the anomalies the skill flags.
Expand the row's details for the seed shape, rendered SQL, bound args,
and raw plan.
| Method | Plan node | Index | Rows (plan / actual) | Cost | Time | Buffers (hit / read) | Partitions |
|---|---|---|---|---|---|---|---|
ContainerBlobStore.FindBlobInRepository |
Result (InitPlan → Nested Loop) |
index_container_blobs_on_namespace_id_and_digest |
1 / 1 | 9.34 | 0.032ms | 4 / 0 | 1 of 64 (per table) |
ContainerBlobStore.FindBlobInRepository
Summary: The plan matches the method's intent. The EXISTS subquery probes index_container_blobs_on_namespace_id_and_digest for the (namespace_id, digest) pair, and the namespace_id parameter prunes both container_blobs and container_images to a single hash partition (1 of 64). The image side is a 1-row Seq Scan inside the pruned partition, which the planner correctly prefers over an index probe at that size. Estimates match actuals (1 / 1) and execution stays at 0.032ms against 5000 seeded blobs. The found=false path (digest absent) takes the same plan: the index probe returns zero rows and the join short-circuits (never executed) at 0.042ms. No anomalies. The statement is built with Jet; its rendered SQL is plan-equivalent to a hand-written SELECT EXISTS, and the two-column join keeps the partition prune on both tables.
Seed shape: namespaces=1, repositories=1, container_repositories=1, container_images=1, blob_storage_blobs=1, blob_storage_attachments=1, container_blobs=5000
Rendered SQL (built with Jet, findBlobInRepositoryExists):
SELECT EXISTS (
SELECT $1
FROM public.container_blobs
INNER JOIN public.container_images ON ((container_blobs.container_image_id = container_images.id) AND (container_blobs.namespace_id = container_images.namespace_id))
WHERE ((container_images.namespace_id = $2::uuid) AND (container_images.container_repository_id = $3)) AND (container_blobs.digest = $4::bytea)
)Bound args: [$1=1 (EXISTS inner projection), $2 namespace_id=c547ecff-403f-4c6f-a7f4-1902179f3a4a, $3 container_repository_id=3, $4 digest=\x…09c4 (a seeded digest, decode(lpad(to_hex(2500),64,'0'),'hex'))]
Plan (EXPLAIN (ANALYZE, BUFFERS), found=true — digest present):
Result (cost=9.33..9.34 rows=1 width=1) (actual time=0.014..0.014 rows=1 loops=1)
Buffers: shared hit=4
InitPlan 1
-> Nested Loop (cost=0.28..9.33 rows=1 width=0) (actual time=0.013..0.013 rows=1 loops=1)
Join Filter: (container_blobs.container_image_id = container_images.id)
Buffers: shared hit=4
-> Index Scan using container_blobs_p12_namespace_id_digest_idx on container_blobs_p12 container_blobs (cost=0.28..8.30 rows=1 width=24) (actual time=0.008..0.009 rows=1 loops=1)
Index Cond: ((namespace_id = 'c547ecff-403f-4c6f-a7f4-1902179f3a4a'::uuid) AND (digest = '\x00000000000000000000000000000000000000000000000000000000000009c4'::bytea))
Buffers: shared hit=3
-> Seq Scan on container_images_p12 container_images (cost=0.00..1.01 rows=1 width=24) (actual time=0.003..0.003 rows=1 loops=1)
Filter: ((namespace_id = 'c547ecff-403f-4c6f-a7f4-1902179f3a4a'::uuid) AND (container_repository_id = '3'::bigint))
Buffers: shared hit=1
Planning:
Buffers: shared hit=248
Planning Time: 0.932 ms
Execution Time: 0.032 msPlan (found=false — digest absent, the 202 source_missing fallback path):
Result (cost=9.33..9.34 rows=1 width=1) (actual time=0.012..0.013 rows=1 loops=1)
Buffers: shared hit=2
InitPlan 1
-> Nested Loop (cost=0.28..9.33 rows=1 width=0) (actual time=0.011..0.012 rows=0 loops=1)
Join Filter: (container_blobs.container_image_id = container_images.id)
Buffers: shared hit=2
-> Index Scan using container_blobs_p12_namespace_id_digest_idx on container_blobs_p12 container_blobs (cost=0.28..8.30 rows=1 width=24) (actual time=0.011..0.011 rows=0 loops=1)
Index Cond: ((namespace_id = 'c547ecff-403f-4c6f-a7f4-1902179f3a4a'::uuid) AND (digest = '\x00000000000000000000000000000000000000000000000000000000000f423f'::bytea))
Buffers: shared hit=2
-> Seq Scan on container_images_p12 container_images (cost=0.00..1.01 rows=1 width=24) (never executed)
Filter: ((namespace_id = 'c547ecff-403f-4c6f-a7f4-1902179f3a4a'::uuid) AND (container_repository_id = '3'::bigint))
Planning Time: 0.159 ms
Execution Time: 0.042 msTimings (found=true): planning 0.932ms, execution 0.032ms, total 0.964ms.
Query notes: No anomalies flagged. The selective predicate uses index_container_blobs_on_namespace_id_and_digest, the namespace_id parameter prunes to a single partition on both tables, and planner estimates match seeded actuals.
Context for LLM reviewers
Design rationale.
- The 201/202-only contract is a security control (S-2), not a convenience. Returning 404 on a missing source or 403 on a denied source would leak the source repository's existence and the blob's presence. Every failure mode collapses to a byte-identical 202 plus a fresh upload session, with the distinguishing reason confined to the internal wide event.
- Mount is database-only per ADR-008: it adds a reference (
blob_storage_attachments+container_blobs) to the source's existing CAS object rather than copying bytes. Cross-namespace mount is unsupported per ADR-008 deduplication scope and falls back. allowAllMountSourceAuthorizeris a deliberate S08/ADR-020 stand-in matching the current bootstrap-token "all scopes" posture, not a missing check. ThesourceAuthorizerseam exists so the real per-repository read-scope check is a drop-in later, and the deny branch is tested now.
Two non-feat commits in this MR.
docs(oci): correct S12 Step 16 plan prosefixes three plan errors that diverged from the spec and the as-built code (the canonicalfromform, the idempotency mechanism, and the datastore method name) and adds the wiring and datastore files to the Files entry. The spec governs; the code already matched it.refactor(oci): extract resolveMountSourcepulls the from-parse / resolve / authorize sequence out ofhandleMountto satisfy the 70-linefunlenlimit (94 to 62 lines). Behavior-preserving.
Non-goals (deferred, not omissions).
- Size accounting (
repositories.size_bytes, artifact counts) on mount: S22, consistent with the finalize path, which also writes none. - Garbage collection and soft-delete: S20.
- Real per-repository source read-scope check: S08/ADR-020 (allow-all stand-in now).
- Phase D manifest steps 13-15: tracked separately; mount has no manifest dependency.