feat(oci): extract image-config platform metadata on push

Why

The monolith list view will let users filter and sort container images by platform. That needs architecture, os, and os_variant on each stored manifest, and those fields do not exist today. This is Phase 3 of the Docker/OCI format split, a deferrable fast-follow that adds the columns and populates them without changing push behavior.

Phase 2 has merged, so this MR now targets main and its diff is Phase 3 only. It builds on feat(oci): enforce repository media-type family... (!923 - merged) • Hayley Swimelar.

What (the non-obvious parts)

  • Extraction is best-effort and never fails a push. On an image-manifest push, the handler reads the referenced config blob and parses the platform fields. Any read or parse failure (missing or deleted config, unparseable JSON, oversized blob) leaves all three columns NULL and still returns 201, with a WARN log plus the configExtractionsTotal{extract_reason} metric making the miss observable. An over-255 value is the narrower case: the byte clamp drops only that column to NULL (the others still populate) and is metered as value_too_long with no WARN, since a pathological over-long value is benign enrichment loss, not a failure.
  • Only image manifests self-populate (Option A). An image index leaves the columns NULL, and its children populate from their own configs when pushed. The handler gates on manifest type, so a crafted index carrying a readable config descriptor cannot populate the index row. We do not read platform from index descriptors.
  • Migration adds three nullable text columns to container_manifests with an inline length CHECK, mirroring artifact_type. It propagates to all 64 hash partitions.

Test plan

Unit and integration green locally (6 MinIO-backed push tests, datastore round-trip, migration up and down, and the H2 wiring-boot assertion). conformance:oci is the protocol gate, and this change is push-transparent. Spec coverage:

Spec coverage

Acceptance criteria

# Criterion Tests
AC-1 Image manifest, valid image config: triple populated TestParseImageConfigPlatform/case_1, TestExtractImagePlatform/success, TestManifestPushPopulatesPlatformTriple (int)
AC-2 Config valid, variant absent: os_variant NULL TestParseImageConfigPlatform/case_2
AC-3 Config has os.version but no variant: os_variant NULL TestParseImageConfigPlatform/case_3
AC-4 TOCTOU (config deleted after Step 4): all NULL, push 201 TestExtractImagePlatform/case_4 (reason=toctou_missing)
AC-5 Config not valid JSON: all NULL, push 201 TestParseImageConfigPlatform/case_5, TestExtractImagePlatform/case_5 (reason=parse_error)
AC-6 Config valid JSON, no platform fields: all NULL TestParseImageConfigPlatform/case_6
AC-7 Non-image config media type: skip read, all NULL TestIsImageConfig, TestExtractImagePlatform/case_7 (reason=skip)
AC-8 Oversized config: errConfigTooLarge, all NULL, push 201 TestExtractImagePlatform/case_8 (reason=oversized), TestConfigBlobReader_OversizedReturnsErrConfigTooLarge
AC-9 Image manifest with no config descriptor: all NULL TestExtractImagePlatform/case_9 (reason=skip)
AC-10 Docker schema2 config media type: same extraction, OCI parity TestParseImageConfigPlatform/case_10, TestIsImageConfig, TestManifestPushDockerConfigParity (int)
AC-11 Manifest index / Docker list: index row all NULL (Option A) TestManifestPushIndexRowStaysNull (int), TestManifestPushIndexWithConfigStaysNull (int, regression)
AC-12 Idempotent re-push keeps first-push values, no error TestManifestPushIdempotentRepushKeepsPlatform (int)
AC-13 Concurrent same-digest push: deterministic winning values TestManifestPushConcurrentSameDigestPlatform (int)
AC-14 variant holding an OS-version string: os_variant from variant TestParseImageConfigPlatform/case_14
AC-15 H1: 300-byte architecture clamps to NULL, push 201, no CHECK 500 TestPlatformValue, TestParseImageConfigPlatform/case_15, TestExtractImagePlatform/case_15 (reason=value_too_long), TestManifestPushOverlongValueClampsToNull (int)
AC-16 Config media type with parameter or different case skips TestIsImageConfig, TestExtractImagePlatform/case_16 (reason=skip)
AC-17 *string round-trip: value survives, nil stays NULL TestCreateContainerManifest_PlatformTripleRoundTrip (int)
AC-18 H2: config reader wired in production, unwired handler no-ops TestWithConfigReader_WiringContract, TestBuildOCIStoreHandlers_WiresConfigReader (int), TestExtractImagePlatform/unwired_reader
AC-19 H2: extraction metric bounded-reason enum registered and exposed TestRegisterMetrics_ExposesAllVectors, TestMetricVectorLabelSets
AC-20 Migration adds three nullable columns with a length CHECK, reversible migration a2fce235 + TestContainerManifestsPlatform_ColumnsAbsentAfterDown

Error cases

# Condition Tests
E-1 Generic storage read error (not TOCTOU): all NULL, push 201 TestExtractImagePlatform/generic_storage_read_error (reason=read_error)
E-2 Adapter read exceeds maxConfigBytes: errConfigTooLarge (N+1) TestConfigBlobReader_OversizedReturnsErrConfigTooLarge
E-3 NewConfigBlobReader on a nil store: panic at construction TestNewConfigBlobReader_PanicsOnNilStore

Security considerations

# Concern Tests
SC-1 Client-controlled over-255 config value cannot escalate to a 500 TestPlatformValue, TestManifestPushOverlongValueClampsToNull (int)
SC-2 Oversized config read is memory-bounded (maxConfigBytes+1) TestConfigBlobReader_OversizedReturnsErrConfigTooLarge
SC-3 Digest/namespace never on a metric label (cardinality/PII) TestMetricVectorLabelSets, audit TestWiring_CardinalityAuditPasses
SC-4 Extraction is non-fatal: no read/parse failure ever fails a push push-201 assertions in the integration push tests

Accepted deviations (working-plan pipeline): no docs/plans/ plan or plan MR, roughly 508 reviewable production LOC (the total is test-dominated), and the S12 config-parse-on-ingest amendment is a deferred follow-up.

Related to #259 (closed)

Context for LLM agents

Design rationale and rejected alternatives:

  • Option A vs B (index handling). Chose A: each image manifest populates its own row from its own config, and index rows stay NULL while children self-populate. Rejected B (index push backfills children from descriptor .platform). The config is authoritative over the optional and often-inaccurate index-descriptor platform, B duplicates the source of truth and needs a new descriptor Platform field, and the container-registry reference impl never reads index-descriptor platform on push. The issue's literal "read the platform fields from the index descriptors" phrasing was resolved in favor of A.
  • Best-effort vs fatal extraction. Chose best-effort (NULL plus WARN plus metric). Metadata extraction must never turn a valid push into an error, a push-path transparency requirement from Docker/OCI split Phase 3: image-config metadata... (#259 - closed) • Hayley Swimelar. The bounded extract_reason enum (7 values) makes failures diagnosable without a fatal path.
  • Decomposed columns vs raw payload. Chose queryable columns over container-registry's configuration_payload bytea plus lazy parse, because the list view filters and sorts on these fields in SQL.

Non-goals:

  • No index-descriptor platform read (Option A).
  • No S12 spec amendment here (deferred follow-up).
  • No GC or reachability changes.
  • No change to protocol-visible push behavior.

Hardening (from the-fool pass): H1 clamps an over-255 value to NULL so the CHECK cannot turn a 201 into a 500. H2 panics at boot if the config reader is unwired so extraction cannot silently no-op for every push.

Database Review Evidence

Migrations

Note

Timings are from CI (db:migrate matrix, goose verbose) against an empty database, in apply / rollback order per PG version. Production-scale validation via Database Lab is not yet available. See Database review evidence for the matrix rationale and how to read the numbers.

Migration PG 16 PG 17 PG 18
20260715053440_add_container_manifests_platform_columns.sql OK (74ms / 94.11ms) OK (76.12ms / 93.66ms) OK (47.08ms / 49.25ms)

Migration notes:

  • Metadata-only. Adding a nullable text column with an inline CHECK is a catalog-only change in PostgreSQL 11+ (no table rewrite, no scan), and the all-NULL column skips constraint validation. The change recurses to all 64 hash partitions inside goose's single Up transaction. All three PG versions apply and roll back under 100ms on an empty database. No version-specific regression (slowest apply is 76ms on PG 17 against 74ms on PG 16, well under 2x), no failures, and no boot-budget risk against the 5-minute per-migration cap. Rollback (~94ms) tracks apply, both catalog-only.

Queries

Note

Plans are from EXPLAIN (ANALYZE, BUFFERS) against an ephemeral PostgreSQL 17 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 each 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
datastore.ContainerManifestStore.CreateContainerManifest Insert unique_container_manifests_ns_id_ci_id_digest (arbiter) 1 / 1 0.01 4.176ms 86 / 4 1 (container_manifests_p33)
datastore.ContainerManifestStore.CreateContainerManifest

Summary: The plan matches the method's intent, an idempotent single-row insert. The namespace_id partition key routes the row to exactly one of 64 hash partitions (container_manifests_p33), the ON CONFLICT arbiter uses the correct composite unique index unique_container_manifests_ns_id_ci_id_digest, and plan and actual rows agree (1 / 1). The Phase 3 widening (three nullable text values passed positionally at $12-$14) adds no index and no scan, so the plan is identical to the pre-Phase-3 insert. Execution time is dominated by the three foreign-key RI triggers, which fire on the child partition. No anomalies.

Seed shape: namespaces=1, repositories=1, container_repositories=1, container_images=1, blob_storage_blobs=1, blob_storage_attachments=1 (write target container_manifests seeded with 0 rows).

Rendered SQL:

INSERT INTO container_manifests
	(id, namespace_id, container_image_id, blob_storage_attachment_id,
	size, media_type, artifact_type, annotations,
	digest, blob_sha256, subject_digest, architecture, os, os_variant)
	VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10, $11, $12, $13, $14)
	ON CONFLICT (namespace_id, container_image_id, digest) DO NOTHING
	RETURNING id, namespace_id, container_image_id,
	blob_storage_attachment_id, size, created_at, last_downloaded_at,
	media_type, artifact_type, annotations,
	digest, blob_sha256, subject_digest, architecture, os, os_variant

Bound args: [$1=<manifest uuid>, $2=<namespace uuid>, $3=<image uuid>, $4=<attachment id, bigint>, $5=1234, $6='application/vnd.oci.image.manifest.v1+json', $7=NULL, $8=NULL, $9=\x2222...(32 bytes), $10=\x1111...(32 bytes, matches attachment sha256), $11=NULL, $12='amd64', $13='linux', $14='v8']

Plan (EXPLAIN (ANALYZE, BUFFERS) output):

 Insert on container_manifests  (cost=0.00..0.01 rows=1 width=368) (actual time=0.679..0.680 rows=1 loops=1)
   Conflict Resolution: NOTHING
   Conflict Arbiter Indexes: unique_container_manifests_ns_id_ci_id_digest
   Tuples Inserted: 1
   Conflicting Tuples: 0
   Buffers: shared hit=86 read=4 dirtied=9 written=5
   ->  Result  (cost=0.00..0.01 rows=1 width=368) (actual time=0.003..0.003 rows=1 loops=1)
 Planning:
   Buffers: shared hit=94
 Planning Time: 0.670 ms
 Trigger for constraint fk_container_manifests_blob_storage_attachment_id_bsa on container_manifests_p33: time=1.101 calls=1
 Trigger for constraint fk_container_manifests_container_image_id_container_images on container_manifests_p33: time=1.008 calls=1
 Trigger for constraint fk_container_manifests_namespace_id_namespaces on container_manifests_p33: time=0.259 calls=1
 Execution Time: 4.176 ms

Partition routing (tableoid of the inserted row, before rollback):

             partition              | rows
------------------------------------+------
 partitions.container_manifests_p33 |    1

Timings: planning 0.670ms, execution 4.176ms, total 4.846ms.

Query notes:

  • CreateContainerManifest: the INSERT widened from 11 to 14 columns, adding architecture, os, os_variant as nullable text at $12-$14. The plan is unchanged from the pre-Phase-3 insert: namespace_id tuple-routes the row to a single hash partition (container_manifests_p33, confirmed by both the RI-trigger partition names and a tableoid probe), the ON CONFLICT arbiter uses unique_container_manifests_ns_id_ci_id_digest, and there is no new index or scan.
  • The datastore read methods that project the shared containerManifestColumns list gained the same three columns in their SELECT/RETURNING projection only. That is the identical row fetch with a wider target list, so the plan shape (index choice, partition pruning, row estimates) does not change. They were not separately planned.
Edited by Hayley Swimelar

Merge request reports

Loading
Loading