feat(oci): enforce repository media-type family at manifest push
Why
Under the Docker/OCI format split, each repository has a declared format. A docker repository holds only Docker manifests, and an oci repository holds only OCI manifests. Phase 1 landed the enum and typed repositories but enforced nothing, so a docker repository still accepted OCI manifests and the split guarantee stayed nominal. This change enforces the family at manifest push.
Phase 1 is tracked in Docker/OCI split Phase 1: repository format enu... (#257 - closed) • Hayley Swimelar.
What
Non-obvious parts only:
- The gate sits in
servePutafter digest verification and does not short-circuit. A cross-family push accumulates aMANIFEST_INVALID(HTTP 400) instead of returning early, so the push event still emits and carriesoci.error_cause="cross_family_push"(S12:1011). - Family classification derives from the existing accepted media-type constants through a
mediaTypeFamilyswitch, not a new hardcoded list. - The conformance seed flips its target from a
dockerto anocirepository. The pinned distribution-spec v1.1.0 suite pushes OCI media types only, so the issue's "pushes both" premise was wrong and no dual-repository fixture is needed.
Behavior change
A cross-family manifest push that returned 201 Created under Phase 1 now returns 400 MANIFEST_INVALID. The break is intended and client-visible: it is the strict-typing guarantee. This MR ships strict mode only. Advisory and none modes are deferred.
Test plan
| # | Criterion | Tests |
|---|---|---|
| AC1 | Docker manifest -> oci repo: 400 MANIFEST_INVALID, detail names oci + the docker media type | TestManifestPush_MediaTypeFamilyGate/AC1_docker_manifest_into_oci_repo_is_cross-family |
| AC2 | OCI image manifest -> docker repo: 400, detail names docker + the OCI type | TestManifestPush_MediaTypeFamilyGate/AC2_oci_image_manifest_into_docker_repo_is_cross-family |
| AC3 | OCI manifest -> oci repo: not gate-rejected | TestManifestPush_MediaTypeFamilyGate/AC3_oci_manifest_into_oci_repo_is_same-family |
| AC4 | Docker manifest -> docker repo: not gate-rejected | TestManifestPush_MediaTypeFamilyGate/AC4_docker_manifest_into_docker_repo_is_same-family |
| AC5 | No Content-Type + no mediaType, structural-OCI body -> docker repo: 400 with cross-family detail (not ambiguous) | TestManifestPush_MediaTypeFamilyGate/AC5_structural-OCI_no-signal_body_into_docker_repo_is_cross-family |
| AC6 | Cross-family reject emits oci.error_cause="cross_family_push"; absent on clean push | TestManifestPush_CrossFamilyTelemetry |
| AC7 | E2E cross-family push, both directions, real resolver + real DB: 400 | TestManifestPushCrossFamilyGateIntegration (integration) |
| AC8 | E2E same-family push, both directions: proceeds | TestManifestPushSameFamilyGateIntegration (integration) |
| AC9 | mediaTypeFamily over all four types incl. docker-list vs oci-index | TestMediaTypeFamily (internal) |
| AC10 | FindByNameInNamespace returns format for docker (0) and oci (3) | TestContainerRepositoryStore_FindByNameInNamespace (integration) |
| AC11 | conformance:oci green after seed flip | provision.sh seed flip; verified by the branch pipeline conformance:oci job |
| AC12 | Non-short-circuit: cross-family error accumulates, does not mask a downstream fault (S12:1009) | TestManifestPush accumulate-contract subtest |
| AC13 | Unconfirmed/undetectable type skips the family gate (no spurious cross-family error) | TestManifestPush_UnconfirmedTypeSkipsFamilyGate |
Unit:
go test ./internal/format/oci/... ./internal/namespace/... ./internal/datastore/...Integration (the E2E gate and the format projection):
go test -tags=integration ./internal/format/oci/... ./internal/datastore/...conformance:oci must be green on the branch pipeline.
Related to #258 (closed)
Context for LLM agents
Design rationale and rejected alternatives
- Non-short-circuit gate. The gate accumulates the cross-family error and lets
servePutrun to completion, so the push event still emits on a rejected push (S12:1009). Rejected an early-return or skip-flag: it would suppress the push event and contradict the spec. - Wide-event field over a new counter. The existing push counter is keyed by
(push_step, error_code)and cannot distinguish the cross-familyMANIFEST_INVALIDfrom other invalid-manifest rejects. S12:1011 names the exact field, so the classification ridesoci.error_cause="cross_family_push"on the push wide event. - Local
repositoryFormatNamehelper over exporting the datastore or managementapi mapping. The container resolver only yieldsdockeroroci, so two values are the complete set. Consolidating the four-entry format map is out of scope.
Non-goals
- Advisory and none enforcement modes (deferred).
- Blob-level enforcement. Blobs stay content-addressed and format-agnostic.
- Consolidating the
realCRFindertest-local adapter, a third copy. Deferred to a later, non-format-only refactor. - The
Resolution.Format int16zero-value-is-dockerfootgun. Not a live bug: the sole production path setsFormat, and the finder filtersformat IN (docker, oci). A defense-in-depth note is deferred.
Single-MR rationale
About 70 production LOC. The rest is test coverage plus mechanical fan-out (12 seed call sites, 4 finder fakes). The plumbing is dead code without the gate, and the conformance seed flip must co-ship with the gate, so splitting would produce MRs that cannot be reviewed independently.