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/-/livenessand/-/readinessbodies and pinsversion/commit/build-dateagainst sentinel values. Verified by hand-injecting aVersion <-> Commitswap 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) andinternal/server/server.go(BuildInfo struct + Deps field +httpserver.Configwiring, 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 undercmd/artifact-registry/testdata/(57). - ~311 LOC pure deletion: removal of the placeholder root-level
main.go/main_test.gothat thecmd/-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.go → cmd/artifact-registry/main.go → cmd/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
buildAppsignature. Plan:buildApp(ctx, configPath) (*app.App, *config.Config, error). Shipped:buildApp(ctx, path) (*app.App, error)with a privateassembleApp(ctx, path) (*app.App, *config.Config, *server.Server, error)for the test seam. The plan didn't anticipate that lifecycle tests need*server.Serverto readAddr()/ProbeAddr()/Mux(), so the original 4-tuple was a test-only widening of the production signature. The split was driven by/review-branchfinding [O3] and applied in949044e.- ldflag target. Plan: stamp
main.version/main.commit/main.datepackage-level vars incmd/artifact-registry/main.go. Shipped: stampgitlab.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 toserver.BuildInfofor the probe-body identity. Themain.*vars described in the plan are not created. internal/server/server.gotouched. Plan named that file under Step 5. Step 6 added aBuildInfofield toserver.Depsand threadedapp.{Version,Commit,Date}through tohttpserver.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_SurfacesBuildInfoininternal/server/server_test.go— W2 review-driven addition to catch aVersion <-> Commitswap typo in the new BuildInfo wiring.waitForBoundAddrpoll helper — simple poll-until-non-empty wait forServer.Startto bind the listener. Race-safe under LabKit v2.10.2's synchronizedAddr()/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.Runinstalls itssignal.NotifyContextonly aftera.Startreturns; a SIGTERM delivered in that window has no Go handler and the OS kills the binary.TestMainnow holds a process-widesignal.Notifysink so SIGTERM/SIGINT cannot trip the default terminate-process action, andkeepSignallingretries 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 insidea.Start— tracked at issue #101. - W2 — BuildInfo end-to-end assertion. Compile-time field-name guard alone would let
Version: deps.BuildInfo.Commitship. 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
-racebecause of an unsynchronised listener write inside LabKit v2.8.0'sServer.Start(issue #100). Upstream fix gitlab-org/labkit!504 (merged) merged on 2026-05-26 and shipped in v2.10.2 —go.modis now bumped, thestartBarrierapp.ComponentandawaitStartBarrierhelper are removed, and lifecycle tests pollsrv.ProbeAddr()/srv.Addr()directly under the now-synchronised accessors. Verified locally withgo test -race -count=20of the lifecycle suite.