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 servePut after digest verification and does not short-circuit. A cross-family push accumulates a MANIFEST_INVALID (HTTP 400) instead of returning early, so the push event still emits and carries oci.error_cause="cross_family_push" (S12:1011).
  • Family classification derives from the existing accepted media-type constants through a mediaTypeFamily switch, not a new hardcoded list.
  • The conformance seed flips its target from a docker to an oci repository. 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 servePut run 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-family MANIFEST_INVALID from other invalid-manifest rejects. S12:1011 names the exact field, so the classification rides oci.error_cause="cross_family_push" on the push wide event.
  • Local repositoryFormatName helper over exporting the datastore or managementapi mapping. The container resolver only yields docker or oci, 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 realCRFinder test-local adapter, a third copy. Deferred to a later, non-format-only refactor.
  • The Resolution.Format int16 zero-value-is-docker footgun. Not a live bug: the sole production path sets Format, and the finder filters format 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.

Edited by Hayley Swimelar

Merge request reports

Loading
Loading