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 asvalue_too_longwith 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
textcolumns tocontainer_manifestswith an inline length CHECK, mirroringartifact_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_reasonenum (7 values) makes failures diagnosable without a fatal path. - Decomposed columns vs raw payload. Chose queryable columns over container-registry's
configuration_payload byteaplus 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
textcolumn 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_variantBound 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 msPartition routing (tableoid of the inserted row, before rollback):
partition | rows
------------------------------------+------
partitions.container_manifests_p33 | 1Timings: planning 0.670ms, execution 4.176ms, total 4.846ms.
Query notes:
CreateContainerManifest: the INSERT widened from 11 to 14 columns, addingarchitecture,os,os_variantas nullable text at$12-$14. The plan is unchanged from the pre-Phase-3 insert:namespace_idtuple-routes the row to a single hash partition (container_manifests_p33, confirmed by both the RI-trigger partition names and atableoidprobe), theON CONFLICTarbiter usesunique_container_manifests_ns_id_ci_id_digest, and there is no new index or scan.- The datastore read methods that project the shared
containerManifestColumnslist gained the same three columns in theirSELECT/RETURNINGprojection 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.