From 53009462ca45666cc1541898539a258fed216777 Mon Sep 17 00:00:00 2001 From: Nick Sieger Date: Tue, 21 Jul 2026 15:24:56 -0500 Subject: [PATCH 1/6] feat(up): push project config to coordinator over docker socket Detect the `com.docker.compose.project.config` context metadata key (bool true or string "true"); when set, POST the full project config to the coordinator at `/v{version}/compose/project` over the engine socket dialer at the start of `compose up`, before any other Docker API call. - version-gated behind new apiVersionComposeProjectConfig constant - skipped in dry-run - non-fatal: warn and continue on request or engine error Signed-off-by: Nick Sieger --- pkg/compose/api_versions.go | 9 ++ pkg/compose/project_config.go | 132 ++++++++++++++++++ pkg/compose/project_config_test.go | 207 +++++++++++++++++++++++++++++ pkg/compose/up.go | 9 ++ 4 files changed, 357 insertions(+) create mode 100644 pkg/compose/project_config.go create mode 100644 pkg/compose/project_config_test.go diff --git a/pkg/compose/api_versions.go b/pkg/compose/api_versions.go index d39aef8aacd..30b22b25541 100644 --- a/pkg/compose/api_versions.go +++ b/pkg/compose/api_versions.go @@ -48,6 +48,15 @@ const ( // - interface_name was not configurable // - ImageList didn't support platform filtering apiVersion149 = "1.49" + + // apiVersionComposeProjectConfig is the Engine/coordinator API version that + // introduced POST /v{version}/compose/project for the Compose + // project-config push (see projectConfigPushEnabled / pushProjectConfig). + // + // Detection of the push is driven by the projectConfigMetadataKey context + // metadata key; this constant documents the minimum coordinator version and + // provides a single place to gate on should that become necessary. + apiVersionComposeProjectConfig = "1.51" ) // Docker Engine version strings for user-facing error messages. diff --git a/pkg/compose/project_config.go b/pkg/compose/project_config.go new file mode 100644 index 00000000000..6f5d39a8a68 --- /dev/null +++ b/pkg/compose/project_config.go @@ -0,0 +1,132 @@ +/* + Copyright 2020 Docker Compose CLI authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package compose + +import ( + "bytes" + "context" + "fmt" + "io" + "net" + "net/http" + "strings" + + "github.com/compose-spec/compose-go/v2/types" + "github.com/docker/cli/cli/command" + "github.com/moby/moby/client/pkg/versions" +) + +// projectConfigMetadataKey is the Docker context metadata key that opts a +// socket into the Compose project-config push. When the current context's +// metadata carries this key set to a truthy value, the socket is assumed to +// accept POST /v{version}/compose/project (see apiVersionComposeProjectConfig), +// and Compose sends the project configuration to the coordinator at the start +// of "compose up" before any other Docker API call. +const projectConfigMetadataKey = "com.docker.compose.project.config" + +// projectConfigResponseBodyLimit bounds how much of an error response body is +// read back into the warning message. +const projectConfigResponseBodyLimit = 2048 + +// projectConfigPushEnabled reports whether the current Docker context opts into +// the project-config push via the projectConfigMetadataKey metadata key. A +// metadata read failure is treated as "not enabled" so that "compose up" is +// never blocked on context inspection. The value is accepted as either a JSON +// boolean true or the string "true" (case-insensitive), mirroring how custom +// context metadata may be decoded (see ConfigFromDockerContext in +// internal/tracing/docker_context.go). +func (s *composeService) projectConfigPushEnabled() bool { + meta, err := s.dockerCli.ContextStore().GetMetadata(s.dockerCli.CurrentContext()) + if err != nil { + return false + } + + var value any + switch m := meta.Metadata.(type) { + case command.DockerContext: + value = m.AdditionalFields[projectConfigMetadataKey] + case map[string]any: + value = m[projectConfigMetadataKey] + } + + switch v := value.(type) { + case bool: + return v + case string: + return strings.EqualFold(v, "true") + default: + return false + } +} + +// pushProjectConfig sends the full project configuration to the coordinator +// over the Docker API socket. The request targets the version-negotiated +// POST /v{version}/compose/project endpoint using the engine dialer for +// transport (mirroring internal/desktop/client.go). A non-2xx response is +// returned as an error; callers warn and continue. +func (s *composeService) pushProjectConfig(ctx context.Context, project *types.Project) error { + payload, err := project.MarshalJSON() + if err != nil { + return fmt.Errorf("marshaling project config: %w", err) + } + + version, err := s.RuntimeAPIVersion(ctx) + if err != nil { + return fmt.Errorf("negotiating API version: %w", err) + } + if versions.LessThan(version, apiVersionComposeProjectConfig) { + return fmt.Errorf("coordinator API version %s does not support the project-config push (requires %s or later)", + version, apiVersionComposeProjectConfig) + } + + dialer := s.apiClient().Dialer() + httpClient := &http.Client{ + Transport: &http.Transport{ + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + return dialer(ctx) + }, + }, + } + + // The host is cosmetic: the custom dialer handles routing to the engine + // socket. It exists only to form a valid URL (see desktop.backendURL). + url := fmt.Sprintf("http://docker/v%s/compose/project", version) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := httpClient.Do(req) + if err != nil { + return err + } + defer func() { + _ = resp.Body.Close() + }() + + if resp.StatusCode >= http.StatusBadRequest { + body, _ := io.ReadAll(io.LimitReader(resp.Body, projectConfigResponseBodyLimit)) + msg := strings.TrimSpace(string(body)) + if msg == "" { + return fmt.Errorf("coordinator returned status %d", resp.StatusCode) + } + return fmt.Errorf("coordinator returned status %d: %s", resp.StatusCode, msg) + } + + return nil +} diff --git a/pkg/compose/project_config_test.go b/pkg/compose/project_config_test.go new file mode 100644 index 00000000000..15527396676 --- /dev/null +++ b/pkg/compose/project_config_test.go @@ -0,0 +1,207 @@ +/* + Copyright 2020 Docker Compose CLI authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package compose + +import ( + "context" + "io" + "net" + "net/http" + "net/http/httptest" + "testing" + + "github.com/compose-spec/compose-go/v2/types" + "github.com/docker/cli/cli/command" + "github.com/docker/cli/cli/context/store" + "github.com/moby/moby/client" + "go.uber.org/mock/gomock" + "gotest.tools/v3/assert" + + "github.com/docker/compose/v5/pkg/mocks" +) + +var testProjectConfigStoreCfg = store.NewConfig( + func() any { + return &map[string]any{} + }, +) + +func newProjectConfigStore(t *testing.T, meta any) store.Store { + t.Helper() + st := store.New(t.TempDir(), testProjectConfigStoreCfg) + err := st.CreateOrUpdate(store.Metadata{ + Name: "test", + Metadata: meta, + Endpoints: make(map[string]any), + }) + assert.NilError(t, err) + return st +} + +func TestProjectConfigPushEnabled(t *testing.T) { + if testing.Short() { + t.Skip("Requires filesystem access") + } + + dockerContext := func(fields map[string]any) command.DockerContext { + return command.DockerContext{Description: "test", AdditionalFields: fields} + } + + tests := []struct { + name string + meta any + want bool + }{ + { + name: "boolean true", + meta: dockerContext(map[string]any{projectConfigMetadataKey: true}), + want: true, + }, + { + name: "string true", + meta: dockerContext(map[string]any{projectConfigMetadataKey: "true"}), + want: true, + }, + { + name: "string TRUE case-insensitive", + meta: dockerContext(map[string]any{projectConfigMetadataKey: "TRUE"}), + want: true, + }, + { + name: "boolean false", + meta: dockerContext(map[string]any{projectConfigMetadataKey: false}), + want: false, + }, + { + name: "string other", + meta: dockerContext(map[string]any{projectConfigMetadataKey: "yes"}), + want: false, + }, + { + name: "key absent", + meta: dockerContext(map[string]any{"other": true}), + want: false, + }, + { + name: "raw map form", + meta: map[string]any{projectConfigMetadataKey: true}, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockCtrl := gomock.NewController(t) + cli := mocks.NewMockCli(mockCtrl) + cli.EXPECT().ContextStore().Return(newProjectConfigStore(t, tt.meta)).AnyTimes() + cli.EXPECT().CurrentContext().Return("test").AnyTimes() + + s := &composeService{dockerCli: cli} + assert.Equal(t, s.projectConfigPushEnabled(), tt.want) + }) + } +} + +func TestProjectConfigPushEnabledMetadataError(t *testing.T) { + mockCtrl := gomock.NewController(t) + cli := mocks.NewMockCli(mockCtrl) + // An empty store returns an error for an unknown context; that must be + // treated as "not enabled" rather than blocking up. + cli.EXPECT().ContextStore().Return(store.New(t.TempDir(), testProjectConfigStoreCfg)).AnyTimes() + cli.EXPECT().CurrentContext().Return("missing").AnyTimes() + + s := &composeService{dockerCli: cli} + assert.Equal(t, s.projectConfigPushEnabled(), false) +} + +func newPushTestService(t *testing.T, version string) (*composeService, *mocks.MockAPIClient) { + t.Helper() + mockCtrl := gomock.NewController(t) + t.Cleanup(mockCtrl.Finish) + apiClient := mocks.NewMockAPIClient(mockCtrl) + cli := mocks.NewMockCli(mockCtrl) + cli.EXPECT().Client().Return(apiClient).AnyTimes() + apiClient.EXPECT().Ping(gomock.Any(), client.PingOptions{NegotiateAPIVersion: true}). + Return(client.PingResult{APIVersion: version}, nil).AnyTimes() + apiClient.EXPECT().ClientVersion().Return(version).AnyTimes() + tested, err := NewComposeService(cli) + assert.NilError(t, err) + return tested.(*composeService), apiClient +} + +func newTestProject() *types.Project { + return &types.Project{ + Name: "test", + Services: types.Services{ + "web": {Name: "web", Image: "nginx"}, + }, + } +} + +func dialerFor(addr string) func(context.Context) (net.Conn, error) { + return func(ctx context.Context) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, "tcp", addr) + } +} + +func TestPushProjectConfig(t *testing.T) { + var ( + gotMethod string + gotPath string + gotBody []byte + ) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + gotPath = r.URL.Path + gotBody, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + s, apiClient := newPushTestService(t, apiVersionComposeProjectConfig) + apiClient.EXPECT().Dialer().Return(dialerFor(srv.Listener.Addr().String())).AnyTimes() + + err := s.pushProjectConfig(t.Context(), newTestProject()) + assert.NilError(t, err) + assert.Equal(t, gotMethod, http.MethodPost) + assert.Equal(t, gotPath, "/v"+apiVersionComposeProjectConfig+"/compose/project") + assert.Assert(t, len(gotBody) > 0) +} + +func TestPushProjectConfigServerError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "placement failed", http.StatusInternalServerError) + })) + defer srv.Close() + + s, apiClient := newPushTestService(t, apiVersionComposeProjectConfig) + apiClient.EXPECT().Dialer().Return(dialerFor(srv.Listener.Addr().String())).AnyTimes() + + err := s.pushProjectConfig(t.Context(), newTestProject()) + assert.ErrorContains(t, err, "500") + assert.ErrorContains(t, err, "placement failed") +} + +func TestPushProjectConfigVersionTooLow(t *testing.T) { + s, apiClient := newPushTestService(t, "1.44") + // Dialer must never be reached: the version gate rejects first. + apiClient.EXPECT().Dialer().Times(0) + + err := s.pushProjectConfig(t.Context(), newTestProject()) + assert.ErrorContains(t, err, "does not support the project-config push") +} diff --git a/pkg/compose/up.go b/pkg/compose/up.go index b2bcb3f7dbb..3e1b1110d12 100644 --- a/pkg/compose/up.go +++ b/pkg/compose/up.go @@ -43,6 +43,15 @@ import ( func (s *composeService) Up(ctx context.Context, project *types.Project, options api.UpOptions) error { //nolint:gocyclo err := Run(ctx, tracing.SpanWrapFunc("project/up", tracing.ProjectOptions(ctx, project), func(ctx context.Context) error { + // When the current Docker context opts into the project-config push, send + // the project configuration to the coordinator before any other Docker API + // call. Failures are non-fatal: warn and continue bringing the project up. + if !s.dryRun && s.projectConfigPushEnabled() { + if err := s.pushProjectConfig(ctx, project); err != nil { + logrus.Warnf("project config push to coordinator failed, continuing: %v", err) + } + } + err := s.create(ctx, project, options.Create) if err != nil { return err From cc0858bfb6121878bebee56da53854e85e56af9d Mon Sep 17 00:00:00 2001 From: Nick Sieger Date: Wed, 22 Jul 2026 09:57:18 -0500 Subject: [PATCH 2/6] refactor(up): move project-config push into internal/coordinator Relocate the coordinator-specific project-config push out of pkg/compose, which is Compose's reusable surface, into a dedicated internal/coordinator package mirroring internal/desktop. Detection, the engine-dialer HTTP client, the versioned URL and outcome handling now live there; up.go keeps a thin call-site. Bound the push with an http.Client timeout so a coordinator that accepts the connection but never responds cannot hang "compose up", preserving the non-fatal warn-and-continue guarantee. Signed-off-by: Nick Sieger --- internal/coordinator/coordinator.go | 161 ++++++++++++++++++ .../coordinator/coordinator_test.go | 118 +++++++------ pkg/compose/api_versions.go | 9 - pkg/compose/project_config.go | 132 -------------- pkg/compose/up.go | 14 +- pkg/compose/up_test.go | 83 +++++++++ 6 files changed, 327 insertions(+), 190 deletions(-) create mode 100644 internal/coordinator/coordinator.go rename pkg/compose/project_config_test.go => internal/coordinator/coordinator_test.go (54%) delete mode 100644 pkg/compose/project_config.go diff --git a/internal/coordinator/coordinator.go b/internal/coordinator/coordinator.go new file mode 100644 index 00000000000..b7c9d20b64f --- /dev/null +++ b/internal/coordinator/coordinator.go @@ -0,0 +1,161 @@ +/* + Copyright 2020 Docker Compose CLI authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +// Package coordinator holds the coordinator-specific integration used by +// Compose: detecting a coordinator-enabled Docker context and pushing the +// project configuration to it over the engine socket. It mirrors the +// self-contained layout of internal/desktop (detection, HTTP client, versioned +// URL and outcome handling) so that pkg/compose keeps only a thin call-site. +package coordinator + +import ( + "bytes" + "context" + "fmt" + "io" + "net" + "net/http" + "strings" + "time" + + "github.com/compose-spec/compose-go/v2/types" + "github.com/docker/cli/cli/command" + "github.com/moby/moby/client/pkg/versions" +) + +// MetadataKey is the Docker context metadata key that opts a socket into the +// Compose project-config push. When the current context's metadata carries +// this key set to a truthy value, the socket is assumed to accept +// POST /v{version}/compose/project (see MinAPIVersion), and Compose sends the +// project configuration to the coordinator at the start of "compose up" before +// any other Docker API call. +const MetadataKey = "com.docker.compose.project.config" + +// MinAPIVersion is the Engine/coordinator API version that introduced +// POST /v{version}/compose/project for the Compose project-config push. It +// documents the minimum coordinator version and is the single place the push +// gates on. +const MinAPIVersion = "1.51" + +// defaultTimeout bounds a single project-config push. The push runs before any +// other Docker API call in "compose up", so without a deadline a coordinator +// that accepts the connection but never responds would hang "up" indefinitely, +// defeating the non-fatal "warn and continue" guarantee. +const defaultTimeout = 30 * time.Second + +// responseBodyLimit bounds how much of an error response body is read back +// into the returned error message. +const responseBodyLimit = 2048 + +// PushEnabled reports whether the current Docker context opts into the +// project-config push via the MetadataKey metadata key. A metadata read +// failure is treated as "not enabled" so that "compose up" is never blocked on +// context inspection. The value is accepted as either a JSON boolean true or +// the string "true" (case-insensitive), mirroring how custom context metadata +// may be decoded (see ConfigFromDockerContext in +// internal/tracing/docker_context.go). +func PushEnabled(dockerCli command.Cli) bool { + meta, err := dockerCli.ContextStore().GetMetadata(dockerCli.CurrentContext()) + if err != nil { + return false + } + + var value any + switch m := meta.Metadata.(type) { + case command.DockerContext: + value = m.AdditionalFields[MetadataKey] + case map[string]any: + value = m[MetadataKey] + } + + switch v := value.(type) { + case bool: + return v + case string: + return strings.EqualFold(v, "true") + default: + return false + } +} + +// Client pushes the Compose project configuration to a coordinator over the +// Docker engine socket, using the engine dialer for transport (mirroring +// internal/desktop/client.go). +type Client struct { + dialer func(ctx context.Context) (net.Conn, error) + timeout time.Duration +} + +// NewClient builds a coordinator client that reaches the engine over the given +// dialer (typically apiClient.Dialer()). +func NewClient(dialer func(ctx context.Context) (net.Conn, error)) *Client { + return &Client{dialer: dialer, timeout: defaultTimeout} +} + +// PushProjectConfig sends the full project configuration to the coordinator's +// version-negotiated POST /v{version}/compose/project endpoint. apiVersion is +// the negotiated engine API version; the push is rejected before any network +// call when it predates MinAPIVersion. A non-2xx response is returned as an +// error; callers warn and continue. The request is bounded by a timeout so a +// coordinator that never responds cannot hang "compose up". +func (c *Client) PushProjectConfig(ctx context.Context, apiVersion string, project *types.Project) error { + if versions.LessThan(apiVersion, MinAPIVersion) { + return fmt.Errorf("coordinator API version %s does not support the project-config push (requires %s or later)", + apiVersion, MinAPIVersion) + } + + payload, err := project.MarshalJSON() + if err != nil { + return fmt.Errorf("marshaling project config: %w", err) + } + + httpClient := &http.Client{ + Timeout: c.timeout, + Transport: &http.Transport{ + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + return c.dialer(ctx) + }, + }, + } + + // The host is cosmetic: the custom dialer handles routing to the engine + // socket. It exists only to form a valid URL (see desktop.backendURL). + url := fmt.Sprintf("http://docker/v%s/compose/project", apiVersion) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := httpClient.Do(req) + if err != nil { + return err + } + defer func() { + _ = resp.Body.Close() + }() + + if resp.StatusCode >= http.StatusBadRequest { + body, _ := io.ReadAll(io.LimitReader(resp.Body, responseBodyLimit)) + msg := strings.TrimSpace(string(body)) + if msg == "" { + return fmt.Errorf("coordinator returned status %d", resp.StatusCode) + } + return fmt.Errorf("coordinator returned status %d: %s", resp.StatusCode, msg) + } + + return nil +} diff --git a/pkg/compose/project_config_test.go b/internal/coordinator/coordinator_test.go similarity index 54% rename from pkg/compose/project_config_test.go rename to internal/coordinator/coordinator_test.go index 15527396676..96a3ab09545 100644 --- a/pkg/compose/project_config_test.go +++ b/internal/coordinator/coordinator_test.go @@ -14,35 +14,36 @@ limitations under the License. */ -package compose +package coordinator import ( "context" + "errors" "io" "net" "net/http" "net/http/httptest" "testing" + "time" "github.com/compose-spec/compose-go/v2/types" "github.com/docker/cli/cli/command" "github.com/docker/cli/cli/context/store" - "github.com/moby/moby/client" "go.uber.org/mock/gomock" "gotest.tools/v3/assert" "github.com/docker/compose/v5/pkg/mocks" ) -var testProjectConfigStoreCfg = store.NewConfig( +var testStoreCfg = store.NewConfig( func() any { return &map[string]any{} }, ) -func newProjectConfigStore(t *testing.T, meta any) store.Store { +func newContextStore(t *testing.T, meta any) store.Store { t.Helper() - st := store.New(t.TempDir(), testProjectConfigStoreCfg) + st := store.New(t.TempDir(), testStoreCfg) err := st.CreateOrUpdate(store.Metadata{ Name: "test", Metadata: meta, @@ -52,7 +53,7 @@ func newProjectConfigStore(t *testing.T, meta any) store.Store { return st } -func TestProjectConfigPushEnabled(t *testing.T) { +func TestPushEnabled(t *testing.T) { if testing.Short() { t.Skip("Requires filesystem access") } @@ -68,27 +69,27 @@ func TestProjectConfigPushEnabled(t *testing.T) { }{ { name: "boolean true", - meta: dockerContext(map[string]any{projectConfigMetadataKey: true}), + meta: dockerContext(map[string]any{MetadataKey: true}), want: true, }, { name: "string true", - meta: dockerContext(map[string]any{projectConfigMetadataKey: "true"}), + meta: dockerContext(map[string]any{MetadataKey: "true"}), want: true, }, { name: "string TRUE case-insensitive", - meta: dockerContext(map[string]any{projectConfigMetadataKey: "TRUE"}), + meta: dockerContext(map[string]any{MetadataKey: "TRUE"}), want: true, }, { name: "boolean false", - meta: dockerContext(map[string]any{projectConfigMetadataKey: false}), + meta: dockerContext(map[string]any{MetadataKey: false}), want: false, }, { name: "string other", - meta: dockerContext(map[string]any{projectConfigMetadataKey: "yes"}), + meta: dockerContext(map[string]any{MetadataKey: "yes"}), want: false, }, { @@ -98,7 +99,7 @@ func TestProjectConfigPushEnabled(t *testing.T) { }, { name: "raw map form", - meta: map[string]any{projectConfigMetadataKey: true}, + meta: map[string]any{MetadataKey: true}, want: true, }, } @@ -107,40 +108,23 @@ func TestProjectConfigPushEnabled(t *testing.T) { t.Run(tt.name, func(t *testing.T) { mockCtrl := gomock.NewController(t) cli := mocks.NewMockCli(mockCtrl) - cli.EXPECT().ContextStore().Return(newProjectConfigStore(t, tt.meta)).AnyTimes() + cli.EXPECT().ContextStore().Return(newContextStore(t, tt.meta)).AnyTimes() cli.EXPECT().CurrentContext().Return("test").AnyTimes() - s := &composeService{dockerCli: cli} - assert.Equal(t, s.projectConfigPushEnabled(), tt.want) + assert.Equal(t, PushEnabled(cli), tt.want) }) } } -func TestProjectConfigPushEnabledMetadataError(t *testing.T) { +func TestPushEnabledMetadataError(t *testing.T) { mockCtrl := gomock.NewController(t) cli := mocks.NewMockCli(mockCtrl) // An empty store returns an error for an unknown context; that must be // treated as "not enabled" rather than blocking up. - cli.EXPECT().ContextStore().Return(store.New(t.TempDir(), testProjectConfigStoreCfg)).AnyTimes() + cli.EXPECT().ContextStore().Return(store.New(t.TempDir(), testStoreCfg)).AnyTimes() cli.EXPECT().CurrentContext().Return("missing").AnyTimes() - s := &composeService{dockerCli: cli} - assert.Equal(t, s.projectConfigPushEnabled(), false) -} - -func newPushTestService(t *testing.T, version string) (*composeService, *mocks.MockAPIClient) { - t.Helper() - mockCtrl := gomock.NewController(t) - t.Cleanup(mockCtrl.Finish) - apiClient := mocks.NewMockAPIClient(mockCtrl) - cli := mocks.NewMockCli(mockCtrl) - cli.EXPECT().Client().Return(apiClient).AnyTimes() - apiClient.EXPECT().Ping(gomock.Any(), client.PingOptions{NegotiateAPIVersion: true}). - Return(client.PingResult{APIVersion: version}, nil).AnyTimes() - apiClient.EXPECT().ClientVersion().Return(version).AnyTimes() - tested, err := NewComposeService(cli) - assert.NilError(t, err) - return tested.(*composeService), apiClient + assert.Equal(t, PushEnabled(cli), false) } func newTestProject() *types.Project { @@ -173,13 +157,11 @@ func TestPushProjectConfig(t *testing.T) { })) defer srv.Close() - s, apiClient := newPushTestService(t, apiVersionComposeProjectConfig) - apiClient.EXPECT().Dialer().Return(dialerFor(srv.Listener.Addr().String())).AnyTimes() - - err := s.pushProjectConfig(t.Context(), newTestProject()) + c := NewClient(dialerFor(srv.Listener.Addr().String())) + err := c.PushProjectConfig(t.Context(), MinAPIVersion, newTestProject()) assert.NilError(t, err) assert.Equal(t, gotMethod, http.MethodPost) - assert.Equal(t, gotPath, "/v"+apiVersionComposeProjectConfig+"/compose/project") + assert.Equal(t, gotPath, "/v"+MinAPIVersion+"/compose/project") assert.Assert(t, len(gotBody) > 0) } @@ -189,19 +171,59 @@ func TestPushProjectConfigServerError(t *testing.T) { })) defer srv.Close() - s, apiClient := newPushTestService(t, apiVersionComposeProjectConfig) - apiClient.EXPECT().Dialer().Return(dialerFor(srv.Listener.Addr().String())).AnyTimes() - - err := s.pushProjectConfig(t.Context(), newTestProject()) + c := NewClient(dialerFor(srv.Listener.Addr().String())) + err := c.PushProjectConfig(t.Context(), MinAPIVersion, newTestProject()) assert.ErrorContains(t, err, "500") assert.ErrorContains(t, err, "placement failed") } -func TestPushProjectConfigVersionTooLow(t *testing.T) { - s, apiClient := newPushTestService(t, "1.44") - // Dialer must never be reached: the version gate rejects first. - apiClient.EXPECT().Dialer().Times(0) +func TestPushProjectConfigDialerError(t *testing.T) { + // A dialer that never connects surfaces as a request error, which callers + // treat as non-fatal. + dialer := func(context.Context) (net.Conn, error) { + return nil, errors.New("boom: no engine socket") + } + c := NewClient(dialer) + err := c.PushProjectConfig(t.Context(), MinAPIVersion, newTestProject()) + assert.ErrorContains(t, err, "boom: no engine socket") +} + +func TestPushProjectConfigErrorEmptyBody(t *testing.T) { + // A non-2xx status with no body still yields a useful error. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + })) + defer srv.Close() + + c := NewClient(dialerFor(srv.Listener.Addr().String())) + err := c.PushProjectConfig(t.Context(), MinAPIVersion, newTestProject()) + assert.ErrorContains(t, err, "coordinator returned status 503") +} - err := s.pushProjectConfig(t.Context(), newTestProject()) +func TestPushProjectConfigVersionTooLow(t *testing.T) { + // The dialer must never be reached: the version gate rejects first. + dialer := func(context.Context) (net.Conn, error) { + t.Fatal("dialer should not be called when the API version is too low") + return nil, nil + } + c := NewClient(dialer) + err := c.PushProjectConfig(t.Context(), "1.44", newTestProject()) assert.ErrorContains(t, err, "does not support the project-config push") } + +func TestPushProjectConfigTimeout(t *testing.T) { + // A coordinator that accepts the connection but never responds must not + // hang the push: the client timeout bounds the request. + block := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + <-block + })) + defer srv.Close() + defer close(block) + + c := NewClient(dialerFor(srv.Listener.Addr().String())) + c.timeout = 50 * time.Millisecond + + err := c.PushProjectConfig(t.Context(), MinAPIVersion, newTestProject()) + assert.ErrorContains(t, err, "context deadline exceeded") +} diff --git a/pkg/compose/api_versions.go b/pkg/compose/api_versions.go index 30b22b25541..d39aef8aacd 100644 --- a/pkg/compose/api_versions.go +++ b/pkg/compose/api_versions.go @@ -48,15 +48,6 @@ const ( // - interface_name was not configurable // - ImageList didn't support platform filtering apiVersion149 = "1.49" - - // apiVersionComposeProjectConfig is the Engine/coordinator API version that - // introduced POST /v{version}/compose/project for the Compose - // project-config push (see projectConfigPushEnabled / pushProjectConfig). - // - // Detection of the push is driven by the projectConfigMetadataKey context - // metadata key; this constant documents the minimum coordinator version and - // provides a single place to gate on should that become necessary. - apiVersionComposeProjectConfig = "1.51" ) // Docker Engine version strings for user-facing error messages. diff --git a/pkg/compose/project_config.go b/pkg/compose/project_config.go deleted file mode 100644 index 6f5d39a8a68..00000000000 --- a/pkg/compose/project_config.go +++ /dev/null @@ -1,132 +0,0 @@ -/* - Copyright 2020 Docker Compose CLI authors - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -*/ - -package compose - -import ( - "bytes" - "context" - "fmt" - "io" - "net" - "net/http" - "strings" - - "github.com/compose-spec/compose-go/v2/types" - "github.com/docker/cli/cli/command" - "github.com/moby/moby/client/pkg/versions" -) - -// projectConfigMetadataKey is the Docker context metadata key that opts a -// socket into the Compose project-config push. When the current context's -// metadata carries this key set to a truthy value, the socket is assumed to -// accept POST /v{version}/compose/project (see apiVersionComposeProjectConfig), -// and Compose sends the project configuration to the coordinator at the start -// of "compose up" before any other Docker API call. -const projectConfigMetadataKey = "com.docker.compose.project.config" - -// projectConfigResponseBodyLimit bounds how much of an error response body is -// read back into the warning message. -const projectConfigResponseBodyLimit = 2048 - -// projectConfigPushEnabled reports whether the current Docker context opts into -// the project-config push via the projectConfigMetadataKey metadata key. A -// metadata read failure is treated as "not enabled" so that "compose up" is -// never blocked on context inspection. The value is accepted as either a JSON -// boolean true or the string "true" (case-insensitive), mirroring how custom -// context metadata may be decoded (see ConfigFromDockerContext in -// internal/tracing/docker_context.go). -func (s *composeService) projectConfigPushEnabled() bool { - meta, err := s.dockerCli.ContextStore().GetMetadata(s.dockerCli.CurrentContext()) - if err != nil { - return false - } - - var value any - switch m := meta.Metadata.(type) { - case command.DockerContext: - value = m.AdditionalFields[projectConfigMetadataKey] - case map[string]any: - value = m[projectConfigMetadataKey] - } - - switch v := value.(type) { - case bool: - return v - case string: - return strings.EqualFold(v, "true") - default: - return false - } -} - -// pushProjectConfig sends the full project configuration to the coordinator -// over the Docker API socket. The request targets the version-negotiated -// POST /v{version}/compose/project endpoint using the engine dialer for -// transport (mirroring internal/desktop/client.go). A non-2xx response is -// returned as an error; callers warn and continue. -func (s *composeService) pushProjectConfig(ctx context.Context, project *types.Project) error { - payload, err := project.MarshalJSON() - if err != nil { - return fmt.Errorf("marshaling project config: %w", err) - } - - version, err := s.RuntimeAPIVersion(ctx) - if err != nil { - return fmt.Errorf("negotiating API version: %w", err) - } - if versions.LessThan(version, apiVersionComposeProjectConfig) { - return fmt.Errorf("coordinator API version %s does not support the project-config push (requires %s or later)", - version, apiVersionComposeProjectConfig) - } - - dialer := s.apiClient().Dialer() - httpClient := &http.Client{ - Transport: &http.Transport{ - DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { - return dialer(ctx) - }, - }, - } - - // The host is cosmetic: the custom dialer handles routing to the engine - // socket. It exists only to form a valid URL (see desktop.backendURL). - url := fmt.Sprintf("http://docker/v%s/compose/project", version) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload)) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/json") - - resp, err := httpClient.Do(req) - if err != nil { - return err - } - defer func() { - _ = resp.Body.Close() - }() - - if resp.StatusCode >= http.StatusBadRequest { - body, _ := io.ReadAll(io.LimitReader(resp.Body, projectConfigResponseBodyLimit)) - msg := strings.TrimSpace(string(body)) - if msg == "" { - return fmt.Errorf("coordinator returned status %d", resp.StatusCode) - } - return fmt.Errorf("coordinator returned status %d: %s", resp.StatusCode, msg) - } - - return nil -} diff --git a/pkg/compose/up.go b/pkg/compose/up.go index 3e1b1110d12..6611e392d00 100644 --- a/pkg/compose/up.go +++ b/pkg/compose/up.go @@ -36,6 +36,7 @@ import ( "golang.org/x/sync/errgroup" "github.com/docker/compose/v5/cmd/formatter" + "github.com/docker/compose/v5/internal/coordinator" "github.com/docker/compose/v5/internal/desktop" "github.com/docker/compose/v5/internal/tracing" "github.com/docker/compose/v5/pkg/api" @@ -46,7 +47,7 @@ func (s *composeService) Up(ctx context.Context, project *types.Project, options // When the current Docker context opts into the project-config push, send // the project configuration to the coordinator before any other Docker API // call. Failures are non-fatal: warn and continue bringing the project up. - if !s.dryRun && s.projectConfigPushEnabled() { + if !s.dryRun && coordinator.PushEnabled(s.dockerCli) { if err := s.pushProjectConfig(ctx, project); err != nil { logrus.Warnf("project config push to coordinator failed, continuing: %v", err) } @@ -311,6 +312,17 @@ func (s *composeService) Up(ctx context.Context, project *types.Project, options return err } +// pushProjectConfig negotiates the engine API version and hands the project +// off to the coordinator client, which owns the coordinator-specific transport +// and outcome handling (see internal/coordinator). +func (s *composeService) pushProjectConfig(ctx context.Context, project *types.Project) error { + version, err := s.RuntimeAPIVersion(ctx) + if err != nil { + return fmt.Errorf("negotiating API version: %w", err) + } + return coordinator.NewClient(s.apiClient().Dialer()).PushProjectConfig(ctx, version, project) +} + func shouldFollowStartEvent(event api.ContainerEvent, attached []string, attachTo []string) bool { if event.Type != api.ContainerEventStarted { return false diff --git a/pkg/compose/up_test.go b/pkg/compose/up_test.go index f38a9e1aeeb..c5c2ce8765b 100644 --- a/pkg/compose/up_test.go +++ b/pkg/compose/up_test.go @@ -17,13 +17,96 @@ package compose import ( + "context" + "errors" + "net" + "net/http" + "net/http/httptest" "testing" + "github.com/compose-spec/compose-go/v2/types" + "github.com/moby/moby/client" + "go.uber.org/mock/gomock" "gotest.tools/v3/assert" + "github.com/docker/compose/v5/internal/coordinator" "github.com/docker/compose/v5/pkg/api" + "github.com/docker/compose/v5/pkg/mocks" ) +func newPushTestService(t *testing.T, apiClient *mocks.MockAPIClient, version string) *composeService { + t.Helper() + mockCtrl := gomock.NewController(t) + t.Cleanup(mockCtrl.Finish) + cli := mocks.NewMockCli(mockCtrl) + cli.EXPECT().Client().Return(apiClient).AnyTimes() + apiClient.EXPECT().Ping(gomock.Any(), client.PingOptions{NegotiateAPIVersion: true}). + Return(client.PingResult{APIVersion: version}, nil).AnyTimes() + apiClient.EXPECT().ClientVersion().Return(version).AnyTimes() + tested, err := NewComposeService(cli) + assert.NilError(t, err) + return tested.(*composeService) +} + +func dialerFor(addr string) func(context.Context) (net.Conn, error) { + return func(ctx context.Context) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, "tcp", addr) + } +} + +func newUpTestProject() *types.Project { + return &types.Project{ + Name: "test", + Services: types.Services{ + "web": {Name: "web", Image: "nginx"}, + }, + } +} + +// TestPushProjectConfigGlue covers the pkg/compose glue: it negotiates the +// engine API version and delegates to the coordinator client, which pushes to +// the version-negotiated endpoint reached over the engine dialer. +func TestPushProjectConfigGlue(t *testing.T) { + var gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + mockCtrl := gomock.NewController(t) + apiClient := mocks.NewMockAPIClient(mockCtrl) + apiClient.EXPECT().Dialer().Return(dialerFor(srv.Listener.Addr().String())).AnyTimes() + s := newPushTestService(t, apiClient, coordinator.MinAPIVersion) + + err := s.pushProjectConfig(t.Context(), newUpTestProject()) + assert.NilError(t, err) + assert.Equal(t, gotPath, "/v"+coordinator.MinAPIVersion+"/compose/project") +} + +// TestPushProjectConfigGlueVersionError covers the glue's error branch when +// API-version negotiation fails; the coordinator dialer is never reached. +func TestPushProjectConfigGlueVersionError(t *testing.T) { + mockCtrl := gomock.NewController(t) + t.Cleanup(mockCtrl.Finish) + apiClient := mocks.NewMockAPIClient(mockCtrl) + cli := mocks.NewMockCli(mockCtrl) + cli.EXPECT().Client().Return(apiClient).AnyTimes() + apiClient.EXPECT().Ping(gomock.Any(), client.PingOptions{NegotiateAPIVersion: true}). + Return(client.PingResult{}, errors.New("engine unreachable")).AnyTimes() + // The dialer must never be reached when negotiation fails. + apiClient.EXPECT().Dialer().Times(0) + + tested, err := NewComposeService(cli) + assert.NilError(t, err) + s := tested.(*composeService) + + err = s.pushProjectConfig(t.Context(), newUpTestProject()) + assert.ErrorContains(t, err, "negotiating API version") + assert.ErrorContains(t, err, "engine unreachable") +} + func TestShouldFollowStartEvent(t *testing.T) { tests := []struct { name string From 160b0e303d2e5b2f851d25c671033d155d6a01f3 Mon Sep 17 00:00:00 2001 From: Nick Sieger Date: Wed, 22 Jul 2026 10:55:10 -0500 Subject: [PATCH 3/6] fix(coordinator): close idle connections after project-config push http.Transport is allocated per call and discarded; without CloseIdleConnections its persistConn goroutines linger until IdleConnTimeout. Tear them down eagerly in the deferred cleanup. Signed-off-by: Nick Sieger --- internal/coordinator/coordinator.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/coordinator/coordinator.go b/internal/coordinator/coordinator.go index b7c9d20b64f..a9be7929e43 100644 --- a/internal/coordinator/coordinator.go +++ b/internal/coordinator/coordinator.go @@ -146,6 +146,10 @@ func (c *Client) PushProjectConfig(ctx context.Context, apiVersion string, proje } defer func() { _ = resp.Body.Close() + // The transport is discarded after this call; tear down its idle + // persistConn goroutines eagerly instead of waiting out + // IdleConnTimeout, which matters when "up" is invoked in a loop. + httpClient.CloseIdleConnections() }() if resp.StatusCode >= http.StatusBadRequest { From 44141c29b4c5b100b9137d74511968ba1b822515 Mon Sep 17 00:00:00 2001 From: Nick Sieger Date: Thu, 23 Jul 2026 08:57:35 -0500 Subject: [PATCH 4/6] fix: apply suggestions from code review Co-authored-by: Guillaume Lours <705411+glours@users.noreply.github.com> Signed-off-by: Nick Sieger --- internal/coordinator/coordinator.go | 2 +- pkg/compose/up.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/coordinator/coordinator.go b/internal/coordinator/coordinator.go index a9be7929e43..854cf2f9fe1 100644 --- a/internal/coordinator/coordinator.go +++ b/internal/coordinator/coordinator.go @@ -54,7 +54,7 @@ const MinAPIVersion = "1.51" // other Docker API call in "compose up", so without a deadline a coordinator // that accepts the connection but never responds would hang "up" indefinitely, // defeating the non-fatal "warn and continue" guarantee. -const defaultTimeout = 30 * time.Second +const defaultTimeout = 10 * time.Second // responseBodyLimit bounds how much of an error response body is read back // into the returned error message. diff --git a/pkg/compose/up.go b/pkg/compose/up.go index 6611e392d00..ef698011cb5 100644 --- a/pkg/compose/up.go +++ b/pkg/compose/up.go @@ -49,7 +49,7 @@ func (s *composeService) Up(ctx context.Context, project *types.Project, options // call. Failures are non-fatal: warn and continue bringing the project up. if !s.dryRun && coordinator.PushEnabled(s.dockerCli) { if err := s.pushProjectConfig(ctx, project); err != nil { - logrus.Warnf("project config push to coordinator failed, continuing: %v", err) + s.events.On(newEvent(api.ResourceCompose, api.Warning, "project config push to coordinator failed, continuing", err.Error())) } } From d2a77c94adaf813c17be7275e701f3702b718b6e Mon Sep 17 00:00:00 2001 From: Nick Sieger Date: Thu, 23 Jul 2026 10:00:27 -0500 Subject: [PATCH 5/6] feat(coordinator): signal whether pushed project config is complete "compose up" with no service arguments and no profiles applied resolves the whole project; naming services narrows it to a subset (+ dependency closure), and an applied profile disables the services outside it. Advertise which one the coordinator received via a new X-Compose-Project-Complete header so it can merge partial pushes with stored config instead of treating every push as authoritative. - mark complete only when the service selection is empty and no profile is active; blank profile entries (compose-go's no-profile sentinel) are ignored via hasActiveProfiles - set the header explicitly (true/false); absent means older client and must be read as "false" so no prune is ever triggered on its behalf Signed-off-by: Nick Sieger --- internal/coordinator/coordinator.go | 20 ++++++++++++- internal/coordinator/coordinator_test.go | 37 ++++++++++++++++++------ pkg/compose/up.go | 31 ++++++++++++++++---- pkg/compose/up_test.go | 23 +++++++++++++-- 4 files changed, 94 insertions(+), 17 deletions(-) diff --git a/internal/coordinator/coordinator.go b/internal/coordinator/coordinator.go index 854cf2f9fe1..f4e9feb4544 100644 --- a/internal/coordinator/coordinator.go +++ b/internal/coordinator/coordinator.go @@ -28,6 +28,7 @@ import ( "io" "net" "net/http" + "strconv" "strings" "time" @@ -60,6 +61,16 @@ const defaultTimeout = 10 * time.Second // into the returned error message. const responseBodyLimit = 2048 +// CompleteHeader signals whether the pushed project is the whole project or a +// subset. "compose up" with no service arguments resolves the entire project +// and sends "true"; "compose up " narrows the project to the named +// services plus their dependency closure and sends "false", telling the +// coordinator to merge the payload with previously pushed config rather than +// treat it as authoritative. An absent header (older Compose clients) must be +// read as "false": those clients may also have pushed a subset, so the +// coordinator must never prune on their behalf. +const CompleteHeader = "X-Compose-Project-Complete" + // PushEnabled reports whether the current Docker context opts into the // project-config push via the MetadataKey metadata key. A metadata read // failure is treated as "not enabled" so that "compose up" is never blocked on @@ -111,7 +122,11 @@ func NewClient(dialer func(ctx context.Context) (net.Conn, error)) *Client { // call when it predates MinAPIVersion. A non-2xx response is returned as an // error; callers warn and continue. The request is bounded by a timeout so a // coordinator that never responds cannot hang "compose up". -func (c *Client) PushProjectConfig(ctx context.Context, apiVersion string, project *types.Project) error { +// +// complete reports whether project is the whole project (true) or a subset +// that the coordinator should merge with what it already holds (false); it is +// conveyed via CompleteHeader. +func (c *Client) PushProjectConfig(ctx context.Context, apiVersion string, project *types.Project, complete bool) error { if versions.LessThan(apiVersion, MinAPIVersion) { return fmt.Errorf("coordinator API version %s does not support the project-config push (requires %s or later)", apiVersion, MinAPIVersion) @@ -139,6 +154,9 @@ func (c *Client) PushProjectConfig(ctx context.Context, apiVersion string, proje return err } req.Header.Set("Content-Type", "application/json") + // Always set the header explicitly (not omit-on-false) so the value is + // unambiguous on the wire; only absence means "older client". + req.Header.Set(CompleteHeader, strconv.FormatBool(complete)) resp, err := httpClient.Do(req) if err != nil { diff --git a/internal/coordinator/coordinator_test.go b/internal/coordinator/coordinator_test.go index 96a3ab09545..aab4e97c035 100644 --- a/internal/coordinator/coordinator_test.go +++ b/internal/coordinator/coordinator_test.go @@ -145,24 +145,43 @@ func dialerFor(addr string) func(context.Context) (net.Conn, error) { func TestPushProjectConfig(t *testing.T) { var ( - gotMethod string - gotPath string - gotBody []byte + gotMethod string + gotPath string + gotBody []byte + gotComplete string ) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotMethod = r.Method gotPath = r.URL.Path gotBody, _ = io.ReadAll(r.Body) + gotComplete = r.Header.Get(CompleteHeader) w.WriteHeader(http.StatusOK) })) defer srv.Close() c := NewClient(dialerFor(srv.Listener.Addr().String())) - err := c.PushProjectConfig(t.Context(), MinAPIVersion, newTestProject()) + err := c.PushProjectConfig(t.Context(), MinAPIVersion, newTestProject(), true) assert.NilError(t, err) assert.Equal(t, gotMethod, http.MethodPost) assert.Equal(t, gotPath, "/v"+MinAPIVersion+"/compose/project") assert.Assert(t, len(gotBody) > 0) + assert.Equal(t, gotComplete, "true") +} + +func TestPushProjectConfigSubsetHeader(t *testing.T) { + // A subset push (empty-selection == false) must advertise itself so the + // coordinator merges rather than prunes. + var gotComplete string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotComplete = r.Header.Get(CompleteHeader) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + c := NewClient(dialerFor(srv.Listener.Addr().String())) + err := c.PushProjectConfig(t.Context(), MinAPIVersion, newTestProject(), false) + assert.NilError(t, err) + assert.Equal(t, gotComplete, "false") } func TestPushProjectConfigServerError(t *testing.T) { @@ -172,7 +191,7 @@ func TestPushProjectConfigServerError(t *testing.T) { defer srv.Close() c := NewClient(dialerFor(srv.Listener.Addr().String())) - err := c.PushProjectConfig(t.Context(), MinAPIVersion, newTestProject()) + err := c.PushProjectConfig(t.Context(), MinAPIVersion, newTestProject(), true) assert.ErrorContains(t, err, "500") assert.ErrorContains(t, err, "placement failed") } @@ -184,7 +203,7 @@ func TestPushProjectConfigDialerError(t *testing.T) { return nil, errors.New("boom: no engine socket") } c := NewClient(dialer) - err := c.PushProjectConfig(t.Context(), MinAPIVersion, newTestProject()) + err := c.PushProjectConfig(t.Context(), MinAPIVersion, newTestProject(), true) assert.ErrorContains(t, err, "boom: no engine socket") } @@ -196,7 +215,7 @@ func TestPushProjectConfigErrorEmptyBody(t *testing.T) { defer srv.Close() c := NewClient(dialerFor(srv.Listener.Addr().String())) - err := c.PushProjectConfig(t.Context(), MinAPIVersion, newTestProject()) + err := c.PushProjectConfig(t.Context(), MinAPIVersion, newTestProject(), true) assert.ErrorContains(t, err, "coordinator returned status 503") } @@ -207,7 +226,7 @@ func TestPushProjectConfigVersionTooLow(t *testing.T) { return nil, nil } c := NewClient(dialer) - err := c.PushProjectConfig(t.Context(), "1.44", newTestProject()) + err := c.PushProjectConfig(t.Context(), "1.44", newTestProject(), true) assert.ErrorContains(t, err, "does not support the project-config push") } @@ -224,6 +243,6 @@ func TestPushProjectConfigTimeout(t *testing.T) { c := NewClient(dialerFor(srv.Listener.Addr().String())) c.timeout = 50 * time.Millisecond - err := c.PushProjectConfig(t.Context(), MinAPIVersion, newTestProject()) + err := c.PushProjectConfig(t.Context(), MinAPIVersion, newTestProject(), true) assert.ErrorContains(t, err, "context deadline exceeded") } diff --git a/pkg/compose/up.go b/pkg/compose/up.go index ef698011cb5..e547586c9b5 100644 --- a/pkg/compose/up.go +++ b/pkg/compose/up.go @@ -48,8 +48,15 @@ func (s *composeService) Up(ctx context.Context, project *types.Project, options // the project configuration to the coordinator before any other Docker API // call. Failures are non-fatal: warn and continue bringing the project up. if !s.dryRun && coordinator.PushEnabled(s.dockerCli) { - if err := s.pushProjectConfig(ctx, project); err != nil { - s.events.On(newEvent(api.ResourceCompose, api.Warning, "project config push to coordinator failed, continuing", err.Error())) + // The push is complete only when "up" resolved the whole project: + // an empty service selection AND no profiles applied. A non-empty + // selection narrows the project to a subset (+ dependency closure), + // and an applied profile disables the services outside it; either + // way the payload is partial and the coordinator must merge it + // rather than treat it as the authoritative project. + complete := len(options.Start.Services) == 0 && !hasActiveProfiles(project.Profiles) + if err := s.pushProjectConfig(ctx, project, complete); err != nil { + s.events.On(newEvent(api.ResourceCompose, api.Warning, "project config push to coordinator failed, continuing", err.Error())) } } @@ -314,13 +321,27 @@ func (s *composeService) Up(ctx context.Context, project *types.Project, options // pushProjectConfig negotiates the engine API version and hands the project // off to the coordinator client, which owns the coordinator-specific transport -// and outcome handling (see internal/coordinator). -func (s *composeService) pushProjectConfig(ctx context.Context, project *types.Project) error { +// and outcome handling (see internal/coordinator). complete reports whether +// project is the whole project or a subset the coordinator should merge. +// hasActiveProfiles reports whether any profile is applied to the project. +// project.Profiles carries a single empty-string entry when no profile is +// selected (see compose-go WithDefaultProfiles splitting an unset +// COMPOSE_PROFILES), so blank entries are ignored. +func hasActiveProfiles(profiles []string) bool { + for _, p := range profiles { + if p != "" { + return true + } + } + return false +} + +func (s *composeService) pushProjectConfig(ctx context.Context, project *types.Project, complete bool) error { version, err := s.RuntimeAPIVersion(ctx) if err != nil { return fmt.Errorf("negotiating API version: %w", err) } - return coordinator.NewClient(s.apiClient().Dialer()).PushProjectConfig(ctx, version, project) + return coordinator.NewClient(s.apiClient().Dialer()).PushProjectConfig(ctx, version, project, complete) } func shouldFollowStartEvent(event api.ContainerEvent, attached []string, attachTo []string) bool { diff --git a/pkg/compose/up_test.go b/pkg/compose/up_test.go index c5c2ce8765b..2f6401a8457 100644 --- a/pkg/compose/up_test.go +++ b/pkg/compose/up_test.go @@ -80,7 +80,7 @@ func TestPushProjectConfigGlue(t *testing.T) { apiClient.EXPECT().Dialer().Return(dialerFor(srv.Listener.Addr().String())).AnyTimes() s := newPushTestService(t, apiClient, coordinator.MinAPIVersion) - err := s.pushProjectConfig(t.Context(), newUpTestProject()) + err := s.pushProjectConfig(t.Context(), newUpTestProject(), true) assert.NilError(t, err) assert.Equal(t, gotPath, "/v"+coordinator.MinAPIVersion+"/compose/project") } @@ -102,11 +102,30 @@ func TestPushProjectConfigGlueVersionError(t *testing.T) { assert.NilError(t, err) s := tested.(*composeService) - err = s.pushProjectConfig(t.Context(), newUpTestProject()) + err = s.pushProjectConfig(t.Context(), newUpTestProject(), true) assert.ErrorContains(t, err, "negotiating API version") assert.ErrorContains(t, err, "engine unreachable") } +func TestHasActiveProfiles(t *testing.T) { + tests := []struct { + name string + profiles []string + want bool + }{ + {name: "nil", profiles: nil, want: false}, + {name: "empty slice", profiles: []string{}, want: false}, + {name: "single blank (default, no profile)", profiles: []string{""}, want: false}, + {name: "named profile", profiles: []string{"debug"}, want: true}, + {name: "blank plus named", profiles: []string{"", "debug"}, want: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, hasActiveProfiles(tt.profiles), tt.want) + }) + } +} + func TestShouldFollowStartEvent(t *testing.T) { tests := []struct { name string From 95d538385352c61bc4d2f3f04b0f275cb400d391 Mon Sep 17 00:00:00 2001 From: Nick Sieger Date: Thu, 23 Jul 2026 10:05:42 -0500 Subject: [PATCH 6/6] refactor: Rename PushEnabled to just Enabled Use the metadata to indicate coordinator presense, not just capable of accepting the push project config api. Signed-off-by: Nick Sieger --- internal/coordinator/coordinator.go | 25 ++++++++++++------------ internal/coordinator/coordinator_test.go | 4 ++-- pkg/compose/up.go | 2 +- 3 files changed, 15 insertions(+), 16 deletions(-) diff --git a/internal/coordinator/coordinator.go b/internal/coordinator/coordinator.go index f4e9feb4544..fb1fef379ec 100644 --- a/internal/coordinator/coordinator.go +++ b/internal/coordinator/coordinator.go @@ -37,13 +37,13 @@ import ( "github.com/moby/moby/client/pkg/versions" ) -// MetadataKey is the Docker context metadata key that opts a socket into the -// Compose project-config push. When the current context's metadata carries -// this key set to a truthy value, the socket is assumed to accept -// POST /v{version}/compose/project (see MinAPIVersion), and Compose sends the +// MetadataKey is the Docker context metadata key that identifies a coordinator +// behind a Docker API socket. When the current context's metadata carries this +// key set to a truthy value, the socket is assumed to accept POST +// /v{version}/compose/project (see MinAPIVersion), and Compose sends the // project configuration to the coordinator at the start of "compose up" before // any other Docker API call. -const MetadataKey = "com.docker.compose.project.config" +const MetadataKey = "com.docker.compose.coordinator" // MinAPIVersion is the Engine/coordinator API version that introduced // POST /v{version}/compose/project for the Compose project-config push. It @@ -71,14 +71,13 @@ const responseBodyLimit = 2048 // coordinator must never prune on their behalf. const CompleteHeader = "X-Compose-Project-Complete" -// PushEnabled reports whether the current Docker context opts into the -// project-config push via the MetadataKey metadata key. A metadata read -// failure is treated as "not enabled" so that "compose up" is never blocked on -// context inspection. The value is accepted as either a JSON boolean true or -// the string "true" (case-insensitive), mirroring how custom context metadata -// may be decoded (see ConfigFromDockerContext in -// internal/tracing/docker_context.go). -func PushEnabled(dockerCli command.Cli) bool { +// Enabled reports whether the current Docker context represents a compose +// coordinator via the MetadataKey metadata key. A metadata read failure is +// treated as "not enabled" so that "compose up" is never blocked on context +// inspection. The value is accepted as either a JSON boolean true or the string +// "true" (case-insensitive), mirroring how custom context metadata may be +// decoded (see ConfigFromDockerContext in internal/tracing/docker_context.go). +func Enabled(dockerCli command.Cli) bool { meta, err := dockerCli.ContextStore().GetMetadata(dockerCli.CurrentContext()) if err != nil { return false diff --git a/internal/coordinator/coordinator_test.go b/internal/coordinator/coordinator_test.go index aab4e97c035..9f0dd57b766 100644 --- a/internal/coordinator/coordinator_test.go +++ b/internal/coordinator/coordinator_test.go @@ -111,7 +111,7 @@ func TestPushEnabled(t *testing.T) { cli.EXPECT().ContextStore().Return(newContextStore(t, tt.meta)).AnyTimes() cli.EXPECT().CurrentContext().Return("test").AnyTimes() - assert.Equal(t, PushEnabled(cli), tt.want) + assert.Equal(t, Enabled(cli), tt.want) }) } } @@ -124,7 +124,7 @@ func TestPushEnabledMetadataError(t *testing.T) { cli.EXPECT().ContextStore().Return(store.New(t.TempDir(), testStoreCfg)).AnyTimes() cli.EXPECT().CurrentContext().Return("missing").AnyTimes() - assert.Equal(t, PushEnabled(cli), false) + assert.Equal(t, Enabled(cli), false) } func newTestProject() *types.Project { diff --git a/pkg/compose/up.go b/pkg/compose/up.go index e547586c9b5..530477862ee 100644 --- a/pkg/compose/up.go +++ b/pkg/compose/up.go @@ -47,7 +47,7 @@ func (s *composeService) Up(ctx context.Context, project *types.Project, options // When the current Docker context opts into the project-config push, send // the project configuration to the coordinator before any other Docker API // call. Failures are non-fatal: warn and continue bringing the project up. - if !s.dryRun && coordinator.PushEnabled(s.dockerCli) { + if !s.dryRun && coordinator.Enabled(s.dockerCli) { // The push is complete only when "up" resolved the whole project: // an empty service selection AND no profiles applied. A non-empty // selection narrows the project to a subset (+ dependency closure),