feat(oci): tag listing (S12 Step 15)
Why
S12 Step 15 implements GET /v2/<slug>/container/<repo>/<image>/tags/list: the paginated, case-insensitively ordered tag list a client uses to discover what it can pull. It is the read side of the tag surface and the last Phase D handler before the Step 18 conformance close.
Built parallel to the other Phase D manifest steps per the plan's pathfinder sequencing. Tag listing reads the container_tags rows the manifest-push path writes, so it has no new schema dependency.
What (non-obvious)
- No new database query, so no Database Review Evidence block. The handler delegates the keyset page to the existing Step-4
datastore.ContainerTagStore.ListByImage: case-insensitiveORDER BY LOWER(name), name, row-value cursor(LOWER(name), name) > (LOWER(last), last), and alimit+1look-ahead that drives theLinkheader. This MR touches nointernal/datastore/file, so the query's plan and partition-prune evidence stay with Step 4.TestTagListSinglePartitionPrune(integration, EXPLAIN) re-pins that the handler's call still prunes to onecontainer_tags_partNand reads theLOWER(name)index without a Sort. - Pagination knobs run on package-const defaults (100 / 1000).
pagination_default_sizeandpagination_max_size(S12 Configuration) stay on thedefaultPaginationSize/defaultMaxPaginationSizeconstants because thecontainerconfig message they belong to is not yet in the proto or config loader, the same gap that leavesimage_max_manifestsandmanifest_max_tagson defaults.WithPaginationDefaultSize/WithPaginationMaxSizeare wired and ready for that config. This is a known deferral, not a missing limit. n=0short-circuits to 200 with an emptytagsarray and noLinkheader, before any datastore call.noutside[0, max]or non-integer is 400PAGINATION_NUMBER_INVALID. Alastthat fails the OCI tag grammar returns the same 400.- Empty is not absent. A resolved image with no tags returns 200 with
{"tags": []}(nevernull), not 404. Only an unknown repository or image is 404NAME_UNKNOWN. - Consumer-side
tagListerseam, nil-guard panic at construction. The handler depends on a narrowtagListerinterface rather than the datastore package, so it tests against an in-memory fake (mirroring the blob and upload handlers).NewTagsHandlerpanics on a nil dependency to surface a composition-root wiring bug at boot, which the boot smoke test exercises.
Test plan
go test ./internal/format/oci/... ./cmd/artifact-registry/... (unit, rapid property, and DB-backed integration via testcontainers), go vet (default and integration tags), and golangci-lint 2.12 all pass locally. The OCI fuzz and conformance:oci jobs run in CI. Conformance stays allow_failure until Step 18 flips the S12 gate.
Spec: docs/specs/S12-container-oci-hosted.md (Tag Listing). Spec-coverage slice for Step 15: AC-18 (tag listing paginated), E-15 (PAGINATION_NUMBER_INVALID), S-12 (pagination limits / partition-pruned query). The spec numbers its acceptance criteria as a list rather than with stable AC-N labels, so the rows below cite the Tag Listing scenario and error rows directly.
Acceptance criteria
| Criterion | Tests |
|---|---|
AC-18 Default page size (100) when n absent |
TestTagList_DefaultPageSizeWhenNAbsent |
AC-18 Link header to next page when more tags exist, cursor round-trips, no Link on final page |
TestTagList_LinkHeaderWhenMorePages, TestTagListPaginationWalkProperty |
AC-18 n=10&last=foo returns the tags after foo in case-insensitive order |
TestTagList_LastReturnsTagsAfter |
AC-18 Case-collision ordering deterministic (Foo, foo) |
TestTagList_CaseCollisionDeterministic, TestTagListCursorAcrossCaseCollision (integration), TestTagListPaginationWalkProperty |
| AC-18 Empty repository (resolved image, no tags) returns 200 with empty list, not 404 | TestTagList_EmptyImageReturns200 |
AC-18 n=0 returns 200 with empty tags array and no Link header |
TestTagList_NZeroReturnsEmptyNoLink |
AC-18 Well-formed last matching no existing tag returns 200 with tags lexically after it |
TestTagList_WellFormedLastNoMatch |
| AC-18 Cursor pagination across a case collision yields every row once, no repeat or skip | TestTagListCursorAcrossCaseCollision (integration), TestTagListPaginationWalkProperty |
AC-18 Response name is fully-qualified <slug>/container/<repo>/<image>, tags renders [] not null |
TestTagList_NameFieldIsFullyQualified, TestTagList_EmptyImageReturns200 |
Error cases
| Condition | Tests |
|---|---|
E-15 n out of range [0, max] -> 400 PAGINATION_NUMBER_INVALID |
TestTagList_PaginationInvalidTable (n=-1, n>max), TestTagList_PaginationValidBoundaries (n=0, n=max accepted) |
E-15 Malformed n (non-integer, empty, float) -> 400 PAGINATION_NUMBER_INVALID |
TestTagList_PaginationInvalidTable |
E-15 last fails the tag regex -> 400 PAGINATION_NUMBER_INVALID with "invalid last parameter: <value>" detail |
TestTagList_PaginationInvalidTable (leading dot, illegal char, >128 chars) |
Repository does not exist -> 404 NAME_UNKNOWN |
TestTagList_UnknownNamespaceReturns404 |
Image does not exist -> 404 NAME_UNKNOWN (image tier) |
TestTagList_ImageMissingReturns404 |
Security considerations
| Concern | Tests |
|---|---|
| S-12 Pagination limit (max 1000 items/page) bounds unbounded queries (DoS mitigation) | TestTagList_PaginationInvalidTable (n>max rejected), TestTagList_PaginationValidBoundaries (n=max) |
S-12 Tag-list query is partition-pruned to one container_tags_partN via namespace_id, served by the LOWER(name) index without a Sort |
TestTagListSinglePartitionPrune (integration, EXPLAIN). The builder-pinned EXPLAIN against the exact statement is owned by the Step-4 datastore test. |
| S-9 Error envelopes carry no internal detail (no SQL, stack traces, DB keys) | TestTagList_ListerErrorReturnsInternal (500 INTERNAL, generic envelope, cause logged not leaked) |
Datastore keyset/ordering correctness (case-insensitive ORDER BY, row-value cursor) |
Owned by the Step-4 datastore integration tests. The handler tests assert the handler preserves and paginates that order. |
Related to #19 (closed)
Context for LLM reviewers
Design rationale.
- This MR is handler-only by design. Tag listing's database work (the case-insensitive keyset query, its index, its partition prune) landed in Step 4 on
datastore.ContainerTagStore.ListByImage. Step 15 adds the HTTP surface that calls it and paginates the result. That is why the diff has nointernal/datastore/change and no Database Review Evidence block: there is no new statement to EXPLAIN.TestTagListSinglePartitionPruneis a regression pin, not new-query evidence. - The pagination defaults sit on package constants (100 / 1000) rather than config because the
containerconfig message does not exist in the proto or config loader yet. This is the same deferral already accepted forimage_max_manifestsandmanifest_max_tags. TheWithPaginationDefaultSize/WithPaginationMaxSizeoptions exist so the wiring is a one-line change when the config message lands, and tests use them to drive the Link-header and out-of-range paths without seeding 1000 tags. Reading this as a missing DoS limit would be wrong: the 1000-item cap is enforced now, only its source is a constant rather than config. n=0is a valid request (200, empty page, noLink), distinct from absentn(default 100). The spec lists both. The handler short-circuitsn=0before the datastore call.
Three non-feat commits in this MR.
refactor: simplify oci tag listing per code-simplifier passreusesPathComponents.FullPathfor the qualified image name instead of a fourth copy of the segment concatenation, and drops a dead empty-slice guard inparsePaginationN(url.Valuesnever stores a present key with no value). Behavior-preserving.test(oci): cover tag-list validation and dispatcher tags wiringcloses test-adequacy gaps a pre-MR review found: theValidatePath -> 400 NAME_INVALIDbranch, theNewDispatchHandlerWithTagsnil-guards andGET tags/listdelegation arm, and the EXPLAIN test's missing Filter-absence assertion.docs(oci): fix tag-list pagination citation and boot-test scopecorrects a citation (ADR-004's 1000 is the per-manifest tag cap, not a pagination default, so the page-size constants cite S12 alone) and broadens the boot smoke-test docstring.
Non-goals (deferred, not omissions).
- Wiring
pagination_default_size/pagination_max_sizefrom config: lands with thecontainerconfig message (same follow-up asimage_max_manifests). - Garbage collection and soft-delete of tags: S20.
- Per-repository read-scope auth: S08 / ADR-020 (the bootstrap-token "all scopes" stand-in applies, as on the other OCI handlers).