feat(cmd): S01 composition root cmd/artifact-registry/ (Step 6)

Summary

Composition root for the artifact-registry binary (S01 Step 6). Wires the urfave/cli/v3 entry point, loads the YAML config, constructs LabKit's app.App with both *server.Server (HTTP listener + middleware chain from Step 5) and the pre-shutdown delay component (Step 4) registered in inverse-drain order. Stamps Version / Commit / BuildDate into LabKit's package-level app.{Version,Commit,Date} via ldflags so structured-log enrichment and /-/liveness / /-/readiness body identity share a single source.

Plan: docs/plans/2026-05-20-s01-http-server-and-routing.md Spec: docs/specs/S01-http-server-and-routing.md

Test plan

  • Unit tests on buildApp: happy path, missing config, two invalid-YAML paths (protovalidate vs Go-side duration-parse), shutdown-timeout derivation table.
  • Lifecycle integration tests against a real listener: SIGTERM, SIGINT, drain-timeout, readiness flip during drain, liveness invariance during drain, liveness/readiness GET shape, non-GET fallthrough to 404 envelope, unknown-route 404.
  • BuildInfo wiring assertion (TestServer_ProbeBody_SurfacesBuildInfo): decodes /-/liveness and /-/readiness bodies and pins version / commit / build-date against sentinel values. Verified by hand-injecting a Version <-> Commit swap and observing the test fail on the expected sentinel.
  • Full suite green under go test -race -count=1 ./... (cmd/artifact-registry: ~2.6s, internal/server: ~1.1s).
  • goimports, golangci-lint@v2.12.0 (CI parity) clean.

Size justification

Raw diff is ~1350 line-events (~1020 added / ~330 deleted) across 17 files, above the 500-LOC ceiling per docs/dev/development-model.md. Breakdown:

  • ~139 LOC reviewable production code: cmd/artifact-registry/main.go (composition root, 125) and internal/server/server.go (BuildInfo struct + Deps field + httpserver.Config wiring, 14).
  • ~20 LOC build infra: .goreleaser.yml, scripts/build.sh, config.example.yaml (ldflag-target updates + server stanza).
  • ~860 LOC test code: cmd/artifact-registry/main_test.go (lifecycle tests + helpers, 664), internal/server/server_test.go (probe-body + non-GET assertions, 115), internal/server/server_helpers_test.go (package-local cleanup helper, 16), four YAML fixtures under cmd/artifact-registry/testdata/ (57).
  • ~311 LOC pure deletion: removal of the placeholder root-level main.go / main_test.go that the cmd/-style layout replaces.

Composition root is intentionally indivisible — splitting would require interim non-runnable revisions of the binary. Recommend reviewing in the order: internal/server/server.gocmd/artifact-registry/main.gocmd/artifact-registry/main_test.go → fixtures & build infra.

Spec coverage

Spec: docs/specs/S01-http-server-and-routing.md

Acceptance criteria

# Criterion Tests
AC-1 Server starts on configured Address TestBuildApp_HappyPath (loaded value pinned), TestLifecycle_GracefulShutdownOnSIGTERM (bind exercised end-to-end via runApp)
AC-2 SIGTERM → 503 readiness, delay, drain, exit 0 TestLifecycle_GracefulShutdownOnSIGTERM, TestLifecycle_ReadinessFlipsTo503DuringDrain
AC-3 SIGINT behaves identically to SIGTERM TestLifecycle_GracefulShutdownOnSIGINT
AC-4 Drain timeout exceeded → warn + exit 1 TestLifecycle_DrainTimeoutExits1
AC-5 GET /-/liveness 200 + JSON body TestLifecycle_LivenessHandlesGET, TestLifecycle_LivenessUnaffectedDuringDrain
AC-6 GET /-/readiness 200 + JSON per-check status TestLifecycle_ReadinessHandlesGET
AC-7 POST /-/liveness returns 404 envelope TestLifecycle_HealthEndpointsRejectNonGET — POST on the application listener falls through to the empty app mux and the ResponseInterceptor produces the JSON envelope. TestServer_ProbeListener_NonGETBehavior separately pins LabKit v2.10.2's actual probe-listener behavior (POST → 405). Spec amendment pending at issue #98.
AC-10 Correlation-ID propagation LabKit-owned (CorrelationIDMiddleware); Step 3 round-trips it in internal/server/middleware_test.go. Not re-tested here.
AC-11 Access-log fields LabKit-owned (access-log middleware). Not directly asserted in Step 6.
AC-12 Panic recovery returns plain-text 500 LabKit-owned (PanicRecoveryMiddleware). Not directly asserted in Step 6.
AC-18 HEAD /-/liveness not supported TestLifecycle_HealthEndpointsRejectNonGET — HEAD on the application listener returns 404 (per AC #18 (closed)). TestServer_ProbeListener_NonGETBehavior separately pins that LabKit's probe listener auto-routes HEAD to GET → 200. Spec amendment pending at issue #98.
AC-19 Invalid X-Request-ID is not propagated LabKit-owned (CorrelationIDMiddleware). Not directly asserted in Step 6.
AC-20 app.Config.ShutdownTimeout = Shutdown.Delay + Shutdown.Timeout + P TestBuildApp_ShutdownTimeoutDerivation

Error cases

Condition Tests
Server fails to start: missing config file TestBuildApp_MissingConfigFile
Server fails to start: invalid YAML (protovalidate) TestBuildApp_InvalidYAML/protovalidate_rejects_empty_address
Server fails to start: invalid YAML (non-positive duration) TestBuildApp_InvalidYAML/non-positive_duration_rejected_by_Go-side_check
Shutdown timeout exceeded TestLifecycle_DrainTimeoutExits1 (also covers AC-4)
Request to unknown route TestLifecycle_UnknownRouteReturnsJSONEnvelope (404 + JSON envelope through composed chain)

Security considerations

Concern Tests
Health endpoints bypass auth Exercised implicitly: probes served by LabKit before the application handler chain. TestLifecycle_LivenessHandlesGET / TestLifecycle_ReadinessHandlesGET reach them with no auth wiring.
Panic recovery / correlation-ID sanitization / header hardening (nosniff) LabKit-owned + Step 3 middleware. Step 6 does not re-test.

Wiring guards (not spec ACs, but catch regressions specific to this MR)

Concern Tests
BuildInfo.{Version,Commit,BuildDate} reach probe-body JSON TestServer_ProbeBody_SurfacesBuildInfo (verified by hand-injecting a Version<->Commit swap and observing the test fail)
Inverse-drain order (shutdownDelay before HTTP server) TestLifecycle_ReadinessFlipsTo503DuringDrain (readiness flips while HTTP still serves)
srv.AddReadinessCheck("shutdown", …) registered TestLifecycle_ReadinessFlipsTo503DuringDrain, TestLifecycle_ReadinessHandlesGET

Plan deviations

This MR deviates from the Step 6 contract in the plan in three substantive ways. Each is called out so reviewers can decide whether the deviation belongs in the plan instead of in-MR, and so future readers don't treat the frozen plan as authoritative on these points.

Substantive deviations

  • buildApp signature. Plan: buildApp(ctx, configPath) (*app.App, *config.Config, error). Shipped: buildApp(ctx, path) (*app.App, error) with a private assembleApp(ctx, path) (*app.App, *config.Config, *server.Server, error) for the test seam. The plan didn't anticipate that lifecycle tests need *server.Server to read Addr() / ProbeAddr() / Mux(), so the original 4-tuple was a test-only widening of the production signature. The split was driven by /review-branch finding [O3] and applied in 949044e.
  • ldflag target. Plan: stamp main.version / main.commit / main.date package-level vars in cmd/artifact-registry/main.go. Shipped: stamp gitlab.com/gitlab-org/labkit/v2/app.{Version,Commit,Date} directly (the package-level vars LabKit owns). This eliminates a layer of duplication — LabKit consumes those same vars internally for structured-log enrichment, and the composition root now also passes them to server.BuildInfo for the probe-body identity. The main.* vars described in the plan are not created.
  • internal/server/server.go touched. Plan named that file under Step 5. Step 6 added a BuildInfo field to server.Deps and threaded app.{Version,Commit,Date} through to httpserver.Config.{Version,Commit,BuildDate}. Necessary for the ldflag-stamp change above; the alternative (rebuild the value pathway in the composition root) would have been worse.

Additions beyond plan

  • Extra lifecycle tests beyond the 9 named in the plan: TestLifecycle_LivenessHandlesGET, TestLifecycle_ReadinessHandlesGET, TestLifecycle_HealthEndpointsRejectNonGET, TestLifecycle_UnknownRouteReturnsJSONEnvelope — coverage for ACs that the plan listed as "exercised by the integration test" without naming individual tests.
  • TestServer_ProbeBody_SurfacesBuildInfo in internal/server/server_test.go — W2 review-driven addition to catch a Version <-> Commit swap typo in the new BuildInfo wiring.
  • waitForBoundAddr poll helper — simple poll-until-non-empty wait for Server.Start to bind the listener. Race-safe under LabKit v2.10.2's synchronized Addr() / ProbeAddr() accessors (gitlab-org/labkit!504 (merged), tracked in issue #100).

Notable follow-ups landed in-MR

  • W1 — signal-handler race in lifecycle tests. app.Run installs its signal.NotifyContext only after a.Start returns; a SIGTERM delivered in that window has no Go handler and the OS kills the binary. TestMain now holds a process-wide signal.Notify sink so SIGTERM/SIGINT cannot trip the default terminate-process action, and keepSignalling retries every 100ms so a signal lost to the saturated sink eventually lands on LabKit's handler once it's installed. Removable once LabKit moves the handler install inside a.Start — tracked at issue #101.
  • W2 — BuildInfo end-to-end assertion. Compile-time field-name guard alone would let Version: deps.BuildInfo.Commit ship. New focused test asserts the probe body JSON.
  • W3 — bump LabKit v2.10.2, drop the barrier workaround. Earlier passes gated nine lifecycle tests under -race because of an unsynchronised listener write inside LabKit v2.8.0's Server.Start (issue #100). Upstream fix gitlab-org/labkit!504 (merged) merged on 2026-05-26 and shipped in v2.10.2 — go.mod is now bumped, the startBarrier app.Component and awaitStartBarrier helper are removed, and lifecycle tests poll srv.ProbeAddr() / srv.Addr() directly under the now-synchronised accessors. Verified locally with go test -race -count=20 of the lifecycle suite.
Edited by Suleimi Ahmed

Merge request reports

Loading
Loading