feat(oci): step 6 dispatcher, base endpoint, and route wiring
Why
Step 6 of the OCI local plan lands the HTTP entry point for the OCI Distribution API: the /v2/ dispatcher, the base endpoint, and the name-resolution seam that maps URL path segments to database identifiers. Per-action handlers (blob, manifest, tag, and referrers) ship in later steps and need this dispatcher to mount against. This implements the Base Endpoint and Name Resolution sections of S12.
What (non-obvious parts)
- Right-to-left action peel. OCI image names may contain
/, so the dispatcher peels known action suffixes (/blobs/uploads,/manifests/<reference>, and so on) from the right edge in a fixed order rather than tokenizing left to right. The pattern and its rationale live in docs/dev/format-url-routing.md. - Interim 501 for resolved routes. A successfully-resolved route whose per-action handler has not shipped yet returns 501 with an INTERNAL envelope, distinct from the 404 a resolver miss produces. This is scaffolding that later steps replace.
- Resolution seam.
internal/namespaceowns slug and repository-name resolution with tier-specific sentinels (ErrNamespaceNotFound,ErrRepositoryNotFound). Thedatastore.ErrNotFoundtranslation to those sentinels happens at the composition root (cmd/artifact-registry/wire_oci.go), which keeps the datastore generic.
Scope
In scope: the dispatcher, base endpoint, route parsing, slug and repository resolution, and the container_repositories and namespaces lookups. Deferred to later work: per-action handlers, per-repository JWT authorization (S08/ADR-020, with a bootstrap token until then), and GC coordination (S20).
Test plan
- Unit: parser success, failure, and right-to-left disambiguation matrices; dispatcher base, 404, NAME_INVALID, NAME_UNKNOWN, and INTERNAL paths; base handler; resolver tiers and error pass-through.
- Integration (
integrationbuild tag):FindBySlug,FindByNameInNamespace, and the full resolver chain against a seeded database. - Gates:
go build,go vet,golangci-lint,gofumpt, andgo test -race -shortall pass. The single lint hit is a pre-existing false positive inmain_test.go, unrelated to this branch.
Review fixes
The self-review open items and the AppSec MR Reviewer's findings are resolved in this branch:
- Silent 500s (AppSec high). Both INTERNAL paths now log the underlying cause through
log.FromContextbefore responding.NewInternalEnvelopediscards its error by contract, so the log is the only record. - Wrong error code (AppSec medium). The base-endpoint 405 emits
UNSUPPORTEDand the interim 501 emitsINTERNAL, replacing the transientUNAVAILABLE(a 503-only code).UNSUPPORTEDis added tooci/errors.goand to S12's Error Cases table. Both responses keep the OCI envelope, so the S01 interceptor does not rewrite them into the project envelope. - Untested translation seam (AppSec high). The wiring adapters now depend on small consumer interfaces and have unit tests covering
datastore.ErrNotFound(bare and wrapped) to the tier sentinel, error pass-through, and the hit case. - Silent no-database boot.
assembleApplogs a warning when a config without adatabase:stanza boots a server with no OCI routes mounted. - Stale dispatcher docs. The
DispatchHandlertype doc, theServeHTTPdoc, and thebase.gocomment now match the 405 and 501 behavior, and the type doc states the 501 resolve-disclosure honestly. - Unvalidated slug and repository name reflected (AppSec low). Declined.
resolve.godocuments that slug and repository-name grammar validation is deferred to the S08/ADR-020 middleware, and the values are JSON-escaped in responses. This is worth a hardening pass when S08 lands.
Also clears the two blocking pipeline failures: the editorconfig indentation in namespace_integration_test.go and the broken ADR-022 link in format-url-routing.md.