Skip to content

Per-service API client with identifying headers#13963

Open
nicksieger wants to merge 2 commits into
docker:mainfrom
nicksieger:per-service-api-client
Open

Per-service API client with identifying headers#13963
nicksieger wants to merge 2 commits into
docker:mainfrom
nicksieger:per-service-api-client

Conversation

@nicksieger

@nicksieger nicksieger commented Jul 22, 2026

Copy link
Copy Markdown
Member

What I did

Tag the Docker API requests that Compose makes on behalf of a specific service with HTTP headers identifying the project and service the request
originates from:

  • X-Docker-Compose-Project
  • X-Docker-Compose-Service

This lets the daemon (and anything in front of it) attribute an incoming API call to the Compose project/service it came from.

Why

Today every Compose request reaches the daemon indistinguishable from any other. Attributing container-lifecycle calls to their originating service makes daemon-side logging, auditing, and policy far easier, and gives operators a reliable signal for which service triggered a given create/start/pull.

How

The moby client applies custom HTTP headers per client instance, not per request — there is no per-request header hook or request-mutation callback (WithHTTPHeaders is the only lever; the sole hook, WithResponseHook, is response-side). So a single shared client can't carry service-specific headers.

This PR introduces a small per-service client layer:

  • serviceClient(project, service) accessor + cache (serviceClientCache): memoizes one API client per project/service pair (guarded by a mutex, since per-service pulls run concurrently under an errgroup). Clients are owned by the composeService and closed via Close().
  • newServiceClient rebuilds an equivalent client from the CLI's Docker endpoint via docker.Endpoint.ClientOpts() — mirroring how docker/cli constructs its own client (endpoint, TLS/socket/conn-helper, negotiated API version, compose/ user-agent) — then layers the identifying headers on top of any user-configured HTTPHeaders. In --dry-run mode the scoped client is re-wrapped in DryRunClient so dry-run interception is preserved.

Call sites routed through the per-service client:

  • Image pull (/images/create).
  • Up-path container lifecycle — ContainerCreate, the legacy-API (< 1.44) per-network NetworkConnect and its ContainerRemove cleanup, the post-create ContainerInspect, and ContainerStart.

Project-wide calls (networks, volumes, events, project-scoped container listings, Ping) intentionally stay on the shared client. Dependency health-poll inspects (isServiceHealthy/isServiceCompleted) also stay on the shared client, since they read a dependency's containers and don't have unambiguous service attribution.

Testing

  • TestServiceClientHeaders: spins up an httptest daemon and asserts an /images/create request carries the project/service headers, that CLI-configured headers are preserved, that repeated calls return the cached client, and that a different service yields a distinct client.
  • TestResolvePullPlatforms: table test for the pull-platform resolution helper.
  • Existing TestCreateMobyContainer* tests seed the client cache with the mock so the service-scoped calls resolve to it.
  • go test ./pkg/compose/ green; golangci-lint run --build-tags "e2e" reports 0 issues.

Related issue

(not mandatory) A picture of a cute animal, if possible in relation to what you did

Introduce composeService.serviceClient(project, service), a cached
APIClient that tags requests with X-Docker-Compose-Project/-Service
headers so the daemon can attribute them. Generalizes the pull-only
client into shared infrastructure, closed via composeService.Close(),
and routes the image pull path through it. Preserves --dry-run by
re-wrapping scoped clients in DryRunClient.

Signed-off-by: Nick Sieger <nick@nicksieger.com>
Use serviceClient for ContainerCreate, ContainerStart, and the create-time
NetworkConnect/ContainerRemove/ContainerInspect calls so each request in the
up path carries the project/service identifying headers. Dependency health
inspects stay on the shared client (cross-service reads).

Signed-off-by: Nick Sieger <nick@nicksieger.com>
@nicksieger
nicksieger requested review from a team as code owners July 22, 2026 14:38
@nicksieger
nicksieger requested review from glours and ndeloof July 22, 2026 14:38

@docker-agent docker-agent left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assessment: 🟢 APPROVE

The per-service API client implementation is well-structured and correct. Key aspects reviewed:

  • Cache correctness: The mutex-guarded serviceClientCache correctly handles concurrent access from concurrent pull goroutines and container lifecycle calls. Lazy map initialization is safe (inside the lock).
  • DryRun wrapping: newServiceClient correctly re-wraps the freshly-built client in DryRunClient when s.dryRun == true, preserving dry-run interception semantics.
  • Resource lifecycle: Close() properly drains and closes all cached clients. The DryRunClient.Close() delegates to the inner client's Close(), so no resource leak on the dry-run path.
  • Header precedence: User-configured headers are copied first, then compose-specific headers are set — ensuring compose attribution headers always win, which is the intended behavior.
  • API version pinning: newServiceClient pins ClientVersion() from the shared client, ensuring consistent API version negotiation across all service clients.
  • Test coverage: TestServiceClientHeaders exercises header forwarding, CLI header preservation, cache hit/miss, and close semantics. TestResolvePullPlatforms covers the extracted helper.

No bugs introduced by this PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants