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/namespace owns slug and repository-name resolution with tier-specific sentinels (ErrNamespaceNotFound, ErrRepositoryNotFound). The datastore.ErrNotFound translation 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 (integration build tag): FindBySlug, FindByNameInNamespace, and the full resolver chain against a seeded database.
  • Gates: go build, go vet, golangci-lint, gofumpt, and go test -race -short all pass. The single lint hit is a pre-existing false positive in main_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.FromContext before responding. NewInternalEnvelope discards its error by contract, so the log is the only record.
  • Wrong error code (AppSec medium). The base-endpoint 405 emits UNSUPPORTED and the interim 501 emits INTERNAL, replacing the transient UNAVAILABLE (a 503-only code). UNSUPPORTED is added to oci/errors.go and 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. assembleApp logs a warning when a config without a database: stanza boots a server with no OCI routes mounted.
  • Stale dispatcher docs. The DispatchHandler type doc, the ServeHTTP doc, and the base.go comment 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.go documents 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.

Edited by Hayley Swimelar

Merge request reports

Loading
Loading