feat(managementapi): serve the container manifest detail route (S17 Container Manifest Reads plan: 6/20)
Serves GET .../{format}/images/{image_id}/manifests/{digest} on hosted and remote container repositories. The digest path answered only DELETE, so the redesigned Docker and OCI version detail page had no single-manifest read to render from. This is Step 6 of the S17 Container Manifest Reads plan, and it deletes Step 4's two getContainerManifest pending entries.
Depends on Step 4, merged 2026-09-10 in docs(openapi): split the container manifest sch... (!2517 - merged) • Hayley Swimelar • 19.4. Plan decision D1 fixes that order: this MR deletes the pending entries Step 4 declared, so it cannot merge first.
What is not obvious
- The response is the base field set alone.
tags,annotations,children, andparent_digestsland in Steps 10, 19, 18, and 16. Their absence is test-enforced rather than contract-enforced, because kin-openapi leavesadditionalPropertiesopen andValidateResponsewould accept a leaked key (D2). - The hosted arm projects the by-digest row onto the list row so one base builder serves both leaves (D2), naming 8 of that row's 11 columns.
TestContainerManifestDetailFromRow_CopiesEveryProjectedColumnwalks the base struct and goes red naming each missing column the moment Step 7 adds the platform triple. Step 7's entry tells its author not to edit here, and that guard is what makes the instruction safe. Confirmed by simulating Step 7 against this branch. - Two live responses change.
Allowon the digest path widens toDELETE, GET, HEAD, andGET .../manifests/bulk_deletemoves from405to404, falling through to this route as the image, tag, and package families already do. - No second digest parser.
parsePathDigestalready accepts the canonical spelling alone, so a malformed digest and an absent manifest answer the same404code and message, differing only inrequest_id.
Spec coverage
| AC | Clause | Where |
|---|---|---|
| #128 (closed) | 200 with every manifests-list element field, both readings of the three nullable columns |
TestContainerManifestDetail_Hosted_PopulatedColumns, ..._Hosted_NullColumns, and the hosted integration case |
| #128 (closed) | No preview, array, or count key on the detail | assertContainerManifestDetailShape, called by every 200 case on both arms |
| #128 (closed) | A referrer row the default list hides is served like any other manifest | TestContainerManifestDetail_ReferrerRowIsServed |
| #128 (closed) | Canonical spelling only, and any other spelling is the absent manifest's 404 |
TestContainerManifestDetail_DigestSpellings_Return404 (9 spellings, both kinds), ..._MalformedAndMissingAreIndistinguishable |
| #137 (closed) | The remote arm serves 200 from the cache, artifact_type and subject_digest null |
TestContainerManifestDetail_Remote_ServesCachedRow, ..._PerFormat, and the remote integration case |
| #128 (closed), #137 (closed) | annotations, tags, children, parent_digests, the platform triple, referrers_count |
Steps 7, 10, 12, 16, 18, and 19. Asserted absent here. |
| #138 (closed) | Detail query count | Step 10, which lands the page-facts fetch. |
Route contract: the golden binding row pins read_artifact at repository scope, TestContractOperations_AreRegisteredAndServed drives both {format} arms with the pending entries gone, and three TestContainerHandlers_ResponsesMatchOpenAPIContract rows validate against ContainerManifestDetail.
Reviewable LOC
1,426 against origin/main, past guardrail 18.
| Group | Lines |
|---|---|
| Production Go | 184 |
| Test Go | 1,220 |
| e2e catalogs | 4 |
| Run recipe | 18 |
The plan accepted about 815 and rejected splitting the hosted and remote arms, which share the fixture, the response assertion, the spelling matrix, and the route-list row. Production came in under forecast, at 184 against about 255. The test half is 2.2 times it, from two review passes adding regression guards and the spelling matrix running against both kinds.
The deletion column is mostly comment caps. scripts/ci/check-comment-caps.sh charges a whole block to its cap once a diff touches one line, so the six main-owned blocks this route falsifies were compressed, with every load-bearing claim relocated to a trailing comment on the line that owns it.
Test plan
go test ./internal/managementapi/..., and the same with-tags=integrationagainst a Postgres tuned like CI's.golangci-lint run ./internal/managementapi/..., plain and--build-tags=integration. Both report 0 issues, against a package baseline of 0.driver.sh smoke: 73 passed, 0 failed. No kind rig ran, so the substitute was a hand-driven pass against a locally booted binary: a pushed manifest and its referrer read by digest, four malformed spellings, an absent digest, an unknown image id, aPOST, and a remote arm filled from an upstream and read from the cache.
Context for LLM agents
Rejected alternatives. A second digest parser beside parsePathDigest was rejected: that helper already implements the canonical-only parse, the shared 404, and the logged 500 on an unbound path value. Pre-assigning the platform triple in the detail projection was rejected in favour of the reflective guard, because assigning three fields no builder reads is a forward reference to Step 7's behavior, and the guard turns the gap into a red test naming the column instead. Splitting the hosted and remote arms into two MRs was rejected by the plan itself.
Non-goals. This MR serves the base field set and nothing else. It widens no reader interface, adds no Deps field, no fake, and no stub, because Step 2 already put both by-digest methods on the two reader interfaces. It touches no OpenAPI YAML and no Bruno request, because Step 3 declared the ContainerManifestDetail leaf and Step 4 the operation.
Known stale claim, deliberately left. parsePathDigest's doc in artifact_write.go calls it "the write-side peer of parsePathID", which this read caller falsifies. Its block is about 40 unexported lines against a comment cap of 1, and the file is outside this step's declared list, so fixing it here would delete reviewed rationale as collateral. It is a stale-claim site the plan's own table does not carry.
Plan drift found while building. The plan's stale-claim table lists 24 sites and is short by at least six, including contract_test.go's sweep doc, which this MR had to correct. Steps 14 and 20 re-derive from that table. Separately, Step 6's Acceptance line and Step 7's "no edit here" instruction disagree about who feeds the detail projection. The guard test resolves that in practice.
Beyond the plan's file list. container_repository_scope_test.go and container_resources_internal_test.go carry review-pass additions the Step 6 entry does not name. A duplicate virtual-kind 404 case was left in place rather than deleted, on the ground that removing coverage during a review pass is the riskier move.
Scope note. The run recipe edit rides the docs(testing) commit under guardrail 21, which requires the recipe to change in the same MR as the dispatch it documents.
Related to #1150 (closed)