feat(managementapi): tag preview, count, and the detail array (S17 Container Manifest Reads plan: 10/20)

Why

Step 10 of the S17 Container Manifest Reads plan, and the last closed-beta-blocking step of the twenty. It serves tags_preview and tags_count on every manifests-list element and tags on manifest detail, hosted and cached alike, so the version list's container rows carry Tags and the detail page has every field the closed-beta pages read. It also lands the per-page facts seam Steps 12, 16, and 18 extend, one field each.

Depends on two prerequisites, both merged: ListRemoteTagNamesByManifestIDs comes from chore(datastore): the windowed remote tag-names... (!2538 - merged) • Hayley Swimelar • 19.4, and the platform triple from feat(managementapi): the platform triple on the... (!2537 - merged) • Hayley Swimelar • 19.4. This branch is rooted on main, so guardrail 20's window is closed.

What

tags is complete on a hosted repository and windowed at 1,000 names on a remote one, with tags_count the true total either way. Nothing caps container_remote_tags rows per manifest at write time, so !2538 (merged)'s constant bounds the cached array. The hosted side has no read-side bound, only the write-time container.manifest_max_tags, so lowering that knob does not trim a manifest tagged under a higher one. Step 18 meets the same asymmetry on children.

Both leaves declare required and properties beside allOf, not in a second branch, because TestContract_ContainerManifestSchemas_ShareOneBase reads them off the leaf. The item bound copies ContainerTag.name's maxLength: 255 rather than the OCI grammar's 128, so a stored name the grammar rejects still renders instead of failing response validation.

ContainerTagNames.MarshalJSON renders nil as [], copying DistTagNames. Measured cost against a plain slice on a hundred-row page at the 128-character ceiling: 831,032 B/op and 2.8x encode time. The plain slice would serialize ContainerManifestDetail{} as "tags":null.

The two lines in version_file_resources_internal_test.go are one //nolint:dupl. The container marshal suite mirrors the npm one row for row (guardrail 6) and dupl reports both clone sites, so suppressing one side moves the finding rather than removing it.

Spec coverage

Spec: docs/specs/S17-rest-management-api.md (Manifest resource fields, Page weight)

Acceptance criteria

# Criterion Tests
AC-128 Manifest detail carries tags and tags_count and no tags_preview TestContainerManifestDetail_TagsArray, TestContainerRemoteManifestDetail_TagsArray, TestContainerManifestTags_KeyAbsenceBothDirections, assertContainerManifestDetailShape repointed at containerManifestDetailResourceKeys. The criterion's other three detail fields belong to Steps 16, 18, and 19.
AC-130 tags_preview at most ten ascending and tags_count the true total on the list, tags complete on the hosted detail, ten/one thousand/1000 at the cap, neither leaf carrying the other's key TestContainerManifestList_TagsPreviewAndCount, TestContainerManifestDetail_TagsArray, TestContainerManifestTags_HostedCapServesBothLeaves, TestContainerManifestTags_KeyAbsenceBothDirections, TestContainerManifestList_ResourceFields
AC-137 The remote arm serves the family from the cached tag rows, all 1,000 names at exactly the window, the first 1,000 with the true total past it, and a tags_count that is never null TestContainerRemoteManifestList_TagsPreviewAndCount, TestContainerRemoteManifestDetail_TagsArray (1, 11, 999, 1000, and 1001 cached names), TestContainerRemoteManifestList_ResourceFields, containerTagsCountOf. The criterion's annotations, platform, referrers, children, parents, and raw clauses belong to Steps 12, 14, 16, 18, and 19.
AC-138 A page costs the keyset scan plus one statement per family at limit=1 and limit=100, and the detail costs the same for one tag and a thousand TestContainerManifestList_TagFactsCostOneReadPerPage, TestContainerManifestDetail_TagCapCostsTheSameReads, TestContainerManifestList_EmptyPageIssuesNoTagRead. The tags share only. The other three families belong to Steps 12, 16, and 18.
Manifest resource fields: [] when untagged, 0 when untagged, never null TestContainerManifestTags_UntaggedServesEmptyArrayNotNull (raw body), TestContainerManifestLeaves_TagArraysNeverNull, TestContainerTagNames_MarshalJSON
Page weight: the preview replaces the array on a hundred-row page TestContainerManifestTags_KeyAbsenceBothDirections, TestContract_ContainerManifestSchema_DeclaresTagsPreview. Structural, so no byte-size assertion.
Contract: the three placements, the 255-character item bound, and no null in either array type TestContract_ContainerManifestSchema_DeclaresTagsPreview, with TestContract_ContainerManifestSchemas_ShareOneBase unchanged
Serializer threading through both base builders, both list leaves, and both detail leaves TestContainerManifestBaseBuilders_CarryTheTagCount, TestContainerManifestLeaves_CarryTheirOwnTagField, TestContainerManifestDetailFromRow_CopiesEveryProjectedColumn
The page-facts seam: one construction site per call shape, and the page's ids in page order TestFetchContainerManifestPageFacts_BuildsThePreviewShape, TestFetchContainerManifestDetailFacts_BuildsTheCompleteShape, TestContainerManifestFactsFrom_AbsentManifestReadsAsZero, TestContainerManifestPageIDs_KeepsPageOrder
Per-format positive hits on docker and oci, both kinds and both routes (guardrail 6) TestContainerManifestTags_PerFormat

Error cases

# Condition Tests
E-1 The batched name read fails: the whole page or detail is a logged 500, never a partial count TestContainerManifestTags_StoreFailureIsALogged500 (hosted and cached, page and detail), TestFetchContainerManifestFacts_StoreErrorFailsTheWholeRead
E-2 An empty page makes no store call, because both stores reject an empty id set TestContainerManifestList_EmptyPageIssuesNoTagRead, TestFetchContainerManifestPageFacts_EmptyPageMakesNoCall
E-3 A forgotten call-shape field (the zero value, or both fields set) reaches the handler as a 500 Both fakes guard on errContainerTagPreviewShape and errContainerRemoteTagPreviewShape, which is what makes every other case fail loudly. The store-side pins belong to Steps 8 and 9.
E-4 A manifest absent from a family's result set reads as [] and 0 rather than as an error TestContainerManifestFactsFrom_AbsentManifestReadsAsZero, TestContainerManifestTags_UntaggedServesEmptyArrayNotNull
E-5 A remote repository with no tag rows to join No counterpart. Both kinds serve the family, unlike the dist-tag suite this mirrors, whose Maven and remote-npm arms have no rows to join.

Security considerations

# Concern Tests
S-1 The batched read is scoped to the resolved namespace TestContainerManifestList_TagsPreviewAndCount, TestFetchContainerManifestPageFacts_BuildsThePreviewShape, TestFetchContainerManifestDetailFacts_BuildsTheCompleteShape
S-2 A forgotten preview limit would be the unbounded read the window exists to prevent TestFetchContainerManifestPageFacts_BuildsThePreviewShape records and pins the limit at ten, and both fakes reject every other pairing. The store-side guards belong to Steps 8 and 9.
S-3 An image of another repository stays unreachable Pre-existing and unchanged: TestContainerManifestList_ParentResolution, and the detail suite's own chain cases.
S-4 A page holds one pooled connection at a time Not asserted: one family cannot distinguish a sequential fetch from a fan-out. The rule binds Steps 12, 16, and 18.

Reviewable LOC

1,809 across 21 files, past guardrail 18's 500: 295 production Go, 55 contract YAML, 1,455 test Go, 4 e2e catalog.

The plan rejected splitting the seam from the family, because the seam has no reader until a family fills it and the family nothing to read through without the seam. Production landed near its ~235 forecast. The test half is 4.8x its ~300, which is where the size is: AC #138 (closed) wants a query count at two page sizes on both arms, AC #130 (closed) and #137 (closed) want the window pinned at 1, 11, 999, 1,000, and 1,001 names, and both key absences are test-enforced because kin-openapi leaves additionalProperties open on both leaves.

Test plan

  • Unit: go test ./internal/managementapi/... ./cmd/... ./internal/datastore/..., green.
  • Integration against a real Postgres: both packages green, 104s and 247s, and managementapi re-run green after the rebase at 210s.
  • golangci-lint run, plus --build-tags=integration --new-from-rev against this branch's merge-base: 0 issues, 0 new. The same run against origin/main reports nine contextcheck findings in internal/datastore/counter_drain_integration_test.go, a file main rewrote after this base and this branch never touches.
  • driver.sh smoke on an isolated stack off this branch's binary: 73 passed, 0 failed.
  • redocly lint at the pinned version: exit 0.

No route changes, so no e2e scenario is added (guardrail 12) and no recipe edit is owed (guardrail 21). The two manifest-detail-via-api catalog rows gained tags, tags_count, and the platform triple in their field lists.

Coordination

Step 7 merged first and this branch rebased onto it: feat(managementapi): the platform triple on the... (!2537 - merged) • Hayley Swimelar • 19.4. The one hunk worse than textual, its wholesale replacement of TestContainerManifestList_ResourceFields where this branch extended the old body, is resolved with both sides live: the docker/oci sweep and the per-value platform assertions from Step 7, tags_preview and tags_count from this step. Both catalog rows now name the platform triple as well as the tags pair.

Context for LLM agents

Design rationale, and what was rejected

The facts struct, rather than a map parameter per family. The serializer takes one containerManifestFacts value. The rejected shape is a serializer growing one map[uuid.UUID][]string parameter per family as Steps 12, 16, and 18 land, which is the primitive-crossing smell the plan's Accepted code smells section names and this struct exists to avoid.

Two fetches, one per call shape, each with a hosted arm and a cached arm. The page shape {PreviewLimit: 10} is built only in fetchContainerManifestPageFacts and the detail shape {Complete: true} only in fetchContainerManifestDetailFacts. Four per-kind fetch functions were rejected: that is two construction sites per call shape, and each later family would then add four arms rather than two. The two stores take distinct parameter types, so one literal per kind inside each fetch is the floor.

Statements run sequentially, never over an errgroup. D3: a page then holds one pooled connection at a time, and its facts cost the sum of the landed families' figures rather than their maximum. Fanning four families out buys back at most 300 ms on a hosted page at every ceiling at once, at the price of one pooled connection per family for every in-flight page. pageRemoteSettings (internal/managementapi/list.go) is the in-tree precedent.

Any facts error fails the whole response. A partial count is a wrong count, so the handler writes a logged 500 rather than serving a page with some rows' counts missing.

ContainerTagNames puts the empty-array rule in the type. []string(nil) marshals as null and the contract declares [] for an untagged manifest. Putting MarshalJSON on the named type is what makes the remote arm, the zero value, and a manifest absent from the facts map correct without a per-arm reminder. DistTagNames (internal/managementapi/version_file_resources.go) solved this once and this copies it.

The remote tags window is a constant, not container.manifest_max_tags. Nothing caps container_remote_tags rows per manifest at write time, and the knob reaches no remote write path, so reading it would shrink every cached array the day an operator lowered it while hosted manifests kept the thousand names they were pushed with. Step 9 owns the constant, and this step serves what it returns.

The item bound is 255, not 128. It copies ContainerTag.name's maxLength, not the OCI tag grammar's 128 characters. The grammar belongs to the OCI distribution specification rather than this contract, so a stored name the grammar would reject still has to render rather than fail response validation. The tagName parameter's note in api/openapi/v1.yaml states the same reason for the same choice.

Splitting the seam from the family was rejected. The plan's Step 10 entry records it: the seam has no reader until a family fills it, and the family has nothing to read through without the seam.

Non-goals

  • children, children_preview, children_count, parent_digests, parents_preview, parents_count, referrers_count, and annotations. Steps 12, 16, 18, and 19 each add one family to the seam this step lands.
  • The two tag statements themselves, their argument guards, their unit and integration tests, and their EXPLAIN pins and ceiling timings. Steps 8 and 9 own those and merged them, and this step only calls them.
  • A remote manifest's children and parent_digests. container_remote_manifest_relationships has no writer, so those arms stay constants until S16's relationship population lands.
  • A configuration knob for the preview length. Ten is the contract's figure (S17 Page weight), not an operator setting, and guardrail 14 makes a knob here evidence the plan is wrong rather than a feature.
  • The referrers, raw-payload, and manifest-detail routes. No route is added, changed, or re-registered here.

🤖 Generated with Claude Code

Related to #1150 (closed)

Edited by Hayley Swimelar

Merge request reports

Loading
Loading