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, and parent_digests land in Steps 10, 19, 18, and 16. Their absence is test-enforced rather than contract-enforced, because kin-openapi leaves additionalProperties open and ValidateResponse would 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_CopiesEveryProjectedColumn walks 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. Allow on the digest path widens to DELETE, GET, HEAD, and GET .../manifests/bulk_delete moves from 405 to 404, falling through to this route as the image, tag, and package families already do.
  • No second digest parser. parsePathDigest already accepts the canonical spelling alone, so a malformed digest and an absent manifest answer the same 404 code and message, differing only in request_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=integration against 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, a POST, 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.

🤖 Generated with Claude Code

Related to #1150 (closed)

Merge request reports

Loading
Loading