feat(npm): mount the remote packument proxy from the composition root

🎯 What this MR does

Builds the shared npm remote proxy in wireServices and threads it through mountSlugAnchoredFormats, so the dispatcher's kind=remote packument slot serves the real handler instead of the interim 501. Step 10d of a 4-MR stack.

This is the MR that changes behavior. GET /{slug}/npm/{repo}/{package_name} on a kind=remote repository stops answering 501 and starts proxying.

📚 The stack:

Step MR What it delivers
10a !1681 (closed) npm.RemoteProxy: one coalescing flight per remote repository, plus the shared test harness
10b !1682 (merged) npm.RemotePackumentHandler: variant selection, the two serve shapes, the response headers
10c !1683 (merged) The error mapping, and the upstream_rejected code
10d · this MR 👉 !1684 (closed) Composition root: mount, run recipe

Merge order: 10a, 10b, 10c, then this. Each MR calls symbols the one before it introduces, so none compiles against main on its own. The plan's Step 10 entry states the order too, because a stack can be merged out of order and the plan is what survives the branches.

🧩 The two pieces

1️⃣ One proxy per process, built from the tiers already wired

buildNpmRemoteProxy composes the remote cache-table stores, the remote-repository row reader (which also answers the durable health status the fetch gate reads), the shared BlobStore, and the shared upstream HTTP client, plus the npm and resolution knobs that bound one metadata fetch and the coalescing layer. One proxy is built per process and shared across every remote npm repository, because it holds the one remote.SingleFlight each of them requires.

It travels to mountSlugAnchoredFormats as a parameter rather than being built there, because it needs the shared upstream HTTP client, which the remote tier owns.

The store takes the production audit sink for the reason buildNpmResolver's construction does: the constructor requires one for the credential-writing methods the same type carries, and a stub here would be the construction a later credential-writing consumer inherits an inert trail from. This construction reads the remote row and its health status; it writes nothing.

2️⃣ Degrade to 501 rather than fail boot

buildNpmRemoteProxy returns an error instead of panicking when a prerequisite is missing, and buildNpmRemoteProxyOrSkip logs it and returns nil, which leaves the remote read slots on their interim 501. That is the same leave-it-unwired posture the async packument-cache rebuild already takes on the same unset value.

The prerequisite that can actually be absent is npm.public_registry_url. Every rewritten dist.tarball in a proxied packument is built from it and then cached, so an unset base URL would persist unusable URLs rather than degrade a single response. wireRemote aborts the boot when the upstream client cannot be built, so the nil-doer guard names a wiring order that changed rather than a runtime condition.

The absent upstream client is left as a nil interface rather than boxed: upstreamhttp.NewDoer wraps even a nil client into a non-nil *Doer, which would clear buildNpmRemoteProxy's own nil check and nil-dereference on the first proxied request instead.

The skip is logged for the reason logOCIAuthzWiring's is: nothing else signals that a configured remote repository is answering 501 rather than proxying, so a dropped Helm value would be silent.

Run recipe

Guardrail 21's companion change. The driver omits npm.public_registry_url in its default ephemeral mode, because it cannot represent an ephemeral port, so kind=remote npm reads answer 501 there. The gotcha sits next to the existing npm install one, which turns on the same key for the same reason, and the troubleshooting row is keyed on the 501 a reader actually sees rather than on a log line they would have to know to look for.

Verified rather than asserted: against a clean driver.sh up, the generated config carries no npm: stanza and ar.log holds npm remote proxy not wired: kind=remote npm reads serve the interim 501. A first check looked like the line was missing; the run had reused an already-live instance whose boot predated the log.

Verification

  • gofmt -l, go build ./..., go vet ./... with and without -tags=integration: clean.
  • go test -count=1 ./internal/format/npm/... ./internal/config/... ./cmd/artifact-registry/: green.
  • go test -tags=integration -run 'TestWireNPM|TestBuildNpm|TestMountSlugAnchoredFormats|TestBuildRootDispatcher' ./cmd/artifact-registry/: green, 113s.
  • go test -tags=integration -run 'TestRemoteProxy|TestRemotePackument' ./internal/format/npm/: green, 43s.
  • golangci-lint run --build-tags=integration --max-same-issues=0 --max-issues-per-linter=0 ./cmd/...: 0 findings on any line this MR adds or changes, checked by mapping each finding against the MR's own hunks. The package total drops from 10 on main to 9, because the branch incidentally fixes a pre-existing wsl finding.
  • driver.sh smoke: 22 passed, 0 failed.

📏 Diff size

454 LOC, under the 500 reviewable-LOC line. Split by file group:

Group Files LOC
Production wire_npm.go +118, wire.go +41, wire_root_dispatcher.go +9 168
Tests wire_npm_test.go +136, wire_npm_boot_integration_test.go +126, and four boot suites at +2 or +3 each 271
Run recipe .claude/skills/run-artifact-registry/SKILL.md +15 15

🧪 E2E scenario impact

docs/testing/e2e/npm.md still scopes remote npm out under "Out of scope until the capability ships", and this MR ships one third of it: the packument read. Dist-tags and tarball reads keep their 501 until Steps 11 and 12. Adding a catalog entry for a capability a client cannot yet install through would describe a scenario that does not run end to end, so the entry belongs with Step 12, and Step 14 owns the conformance flow that exercises it.

Related to #347 (closed)

Edited by Dzmitry (Dima) Meshcharakou

Merge request reports

Loading
Loading