From 8527c6e1881e8e9c6bead95bc8c6ae04249fb486 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Mon, 26 Oct 2026 16:46:22 +0100 Subject: [PATCH 1/3] fix(start): give a started use_api_socket service its config file A container created beforehand and only started by `compose start` (`create` then `start`, `up --no-start` then `start`, or a dependency named to `start`) came up with DOCKER_CONFIG pointing at /run/secrets/docker and no file there: use_api_socket is applied by rewriting the project, and the config file it adds is injected when a container starts, from the project the start works on. Create and run applied the rewriting, start did not. start() now applies it too, on the copy it already makes of the project, so the caller's is left alone, and a project already rewritten (a dependency started by run) is not rewritten twice. The rewriting tolerates a project without environment or configs maps, as the one `compose start` loads. up -d, a foreground up, scale and run already provided the file. Signed-off-by: Nicolas De Loof --- pkg/compose/apiSocket.go | 8 ++++ pkg/compose/start.go | 11 +++++ pkg/compose/start_test.go | 47 +++++++++++++++++++ pkg/e2e/api_socket_test.go | 41 ++++++++++++++++ .../compose.yaml | 6 +++ 5 files changed, 113 insertions(+) create mode 100644 pkg/e2e/api_socket_test.go create mode 100644 pkg/e2e/testdata/TestStartCreatedUseAPISocketService/compose.yaml diff --git a/pkg/compose/apiSocket.go b/pkg/compose/apiSocket.go index de9d55d0977..93e361661f0 100644 --- a/pkg/compose/apiSocket.go +++ b/pkg/compose/apiSocket.go @@ -65,6 +65,11 @@ func (s *composeService) useAPISocket(project *types.Project) (*types.Project, e return nil, fmt.Errorf("saving creds for API socket: %w", err) } + // a project that did not go through environment resolution (the one + // `compose start` loads) has no maps to write into + if project.Configs == nil { + project.Configs = types.Configs{} + } project.Configs["#apisocket"] = types.ConfigObjConfig{ Content: configBuf.String(), } @@ -79,6 +84,9 @@ func (s *composeService) useAPISocket(project *types.Project) (*types.Project, e Target: "/var/run/docker.sock", }) + if service.Environment == nil { + service.Environment = types.MappingWithEquals{} + } _, envvarPresent := service.Environment["DOCKER_CONFIG"] // If the DOCKER_CONFIG env var is already present, we assume the client knows diff --git a/pkg/compose/start.go b/pkg/compose/start.go index 5ae05a1ddcc..6443bd67220 100644 --- a/pkg/compose/start.go +++ b/pkg/compose/start.go @@ -60,6 +60,17 @@ func (s *composeService) start(ctx context.Context, projectName string, options // and the dependency waits read them project = project.WithoutUnresolvedOptionalDependencies() + // use_api_socket is applied by rewriting the project (see useAPISocket), + // and the config file it adds is injected when a container starts, from the + // project given to the start: a container created beforehand and only + // started here would otherwise get DOCKER_CONFIG without the file it points + // to. The project is the copy made above, so the caller's is left alone, and + // one already transformed (a dependency started by `run`) is not done twice. + project, err := s.useAPISocket(project) + if err != nil { + return err + } + observed, err := s.collectObservedState(ctx, project, ScopeStart) if err != nil { return err diff --git a/pkg/compose/start_test.go b/pkg/compose/start_test.go index 9d3a344e3f6..4f21598a675 100644 --- a/pkg/compose/start_test.go +++ b/pkg/compose/start_test.go @@ -19,7 +19,9 @@ package compose import ( + "archive/tar" "context" + "io" "strconv" "sync" "testing" @@ -188,6 +190,51 @@ func TestStart_WaitTimeoutBoundsDependencyWaits(t *testing.T) { assert.Error(t, err, "timeout waiting for dependencies") } +// TestStart_UseAPISocketServiceGetsItsConfigFile pins that start() applies +// use_api_socket to the project it works on: the config file the option adds is +// injected when a container starts, from that project, so a container created +// beforehand and only started here (`create` then `start`) would otherwise get +// DOCKER_CONFIG pointing at a file that was never created. The caller's project +// is left as it was. +func TestStart_UseAPISocketServiceGetsItsConfigFile(t *testing.T) { + svc, apiClient, _ := newStartTestService(t) + svc.contextInfo = linuxContextInfo{} + + // no environment and no configs map, as in the project `compose start` + // loads, which does not resolve the environment + web := types.ServiceConfig{Name: "web", ContainerSpec: types.ContainerSpec{Image: "alpine", UseAPISocket: true}} + project := &types.Project{Name: "prj", Services: types.Services{"web": web}} + + ctr := serviceContainer("web") + ctr.State = container.StateCreated + ctr.Labels[api.ProjectLabel] = "prj" + apiClient.EXPECT().ContainerList(gomock.Any(), gomock.Any()). + Return(client.ContainerListResult{Items: []container.Summary{ctr}}, nil) + + var injected []string + copied := apiClient.EXPECT().CopyToContainer(gomock.Any(), ctr.ID, gomock.Any()). + DoAndReturn(func(_ context.Context, _ string, opts client.CopyToContainerOptions) (client.CopyToContainerResult, error) { + tr := tar.NewReader(opts.Content) + for { + header, err := tr.Next() + if err == io.EOF { + break + } + assert.NilError(t, err) + injected = append(injected, header.Name) + } + return client.CopyToContainerResult{}, nil + }) + apiClient.EXPECT().ContainerStart(gomock.Any(), ctr.ID, gomock.Any()). + Return(client.ContainerStartResult{}, nil).After(copied) + + err := svc.start(t.Context(), "prj", api.StartOptions{Project: project}) + + assert.NilError(t, err) + assert.DeepEqual(t, injected, []string{"/run/secrets/docker/config.json"}) + assert.Assert(t, project.Configs == nil && project.Services["web"].Environment == nil, "the caller's project must not be transformed") +} + // TestGetDependencyCondition locks the --wait condition selection: a service // that others depend on with service_completed_successfully is waited on with // that condition (or --wait would hang on one-shot services), anything else diff --git a/pkg/e2e/api_socket_test.go b/pkg/e2e/api_socket_test.go new file mode 100644 index 00000000000..aaaf7889742 --- /dev/null +++ b/pkg/e2e/api_socket_test.go @@ -0,0 +1,41 @@ +//go:build e2e + +/* + Copyright 2026 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 e2e + +import ( + "testing" + "time" +) + +// Regression test for https://github.com/docker/compose/issues/14386: a +// container created beforehand and only started by `compose start` got +// DOCKER_CONFIG pointing at /run/secrets/docker, without the config file the +// use_api_socket option is meant to provide there. The file is injected when +// the container starts, from the project given to the start, which did not +// carry the option. +func TestStartCreatedUseAPISocketService(t *testing.T) { + NewScenario(t, "start must provide the use_api_socket config file to a container created beforehand"). + Step("create makes the container without starting it", + ComposeCmd("create"), + ServiceState("app", "created")). + Step("start runs it with the config file in place", + ComposeCmd("start"), + Eventually(ServiceState("app", "running"), 10*time.Second), + ExecOutputContains("app", "[ -f /run/secrets/docker/config.json ] && echo apisocket-config-present", "apisocket-config-present")) +} diff --git a/pkg/e2e/testdata/TestStartCreatedUseAPISocketService/compose.yaml b/pkg/e2e/testdata/TestStartCreatedUseAPISocketService/compose.yaml new file mode 100644 index 00000000000..63086747f7f --- /dev/null +++ b/pkg/e2e/testdata/TestStartCreatedUseAPISocketService/compose.yaml @@ -0,0 +1,6 @@ +services: + app: + image: alpine + init: true + use_api_socket: true + command: sleep infinity From a08dfbefe6f8bc61adc5a91c8a4fa35a789259ee Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Mon, 26 Oct 2026 16:48:17 +0100 Subject: [PATCH 2/3] test(start): cover a caller project with and without the maps use_api_socket writes into The project `compose start` loads has no environment nor configs map, which the rewriting used to write into; a project built with empty maps is the other shape. Both are started with the config file in place, and neither caller's project is written to. Signed-off-by: Nicolas De Loof --- pkg/compose/start_test.go | 84 ++++++++++++++++++++++----------------- 1 file changed, 48 insertions(+), 36 deletions(-) diff --git a/pkg/compose/start_test.go b/pkg/compose/start_test.go index 4f21598a675..defc7cef4bd 100644 --- a/pkg/compose/start_test.go +++ b/pkg/compose/start_test.go @@ -195,44 +195,56 @@ func TestStart_WaitTimeoutBoundsDependencyWaits(t *testing.T) { // injected when a container starts, from that project, so a container created // beforehand and only started here (`create` then `start`) would otherwise get // DOCKER_CONFIG pointing at a file that was never created. The caller's project -// is left as it was. +// is left as it was, whether or not it has the maps the option writes into: the +// project `compose start` loads does not resolve the environment, so it has none. func TestStart_UseAPISocketServiceGetsItsConfigFile(t *testing.T) { - svc, apiClient, _ := newStartTestService(t) - svc.contextInfo = linuxContextInfo{} - - // no environment and no configs map, as in the project `compose start` - // loads, which does not resolve the environment - web := types.ServiceConfig{Name: "web", ContainerSpec: types.ContainerSpec{Image: "alpine", UseAPISocket: true}} - project := &types.Project{Name: "prj", Services: types.Services{"web": web}} - - ctr := serviceContainer("web") - ctr.State = container.StateCreated - ctr.Labels[api.ProjectLabel] = "prj" - apiClient.EXPECT().ContainerList(gomock.Any(), gomock.Any()). - Return(client.ContainerListResult{Items: []container.Summary{ctr}}, nil) - - var injected []string - copied := apiClient.EXPECT().CopyToContainer(gomock.Any(), ctr.ID, gomock.Any()). - DoAndReturn(func(_ context.Context, _ string, opts client.CopyToContainerOptions) (client.CopyToContainerResult, error) { - tr := tar.NewReader(opts.Content) - for { - header, err := tr.Next() - if err == io.EOF { - break - } - assert.NilError(t, err) - injected = append(injected, header.Name) - } - return client.CopyToContainerResult{}, nil + tests := []struct { + name string + env types.MappingWithEquals + configs types.Configs + }{ + {name: "project without environment nor configs maps"}, + {name: "project with empty maps", env: types.MappingWithEquals{}, configs: types.Configs{}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc, apiClient, _ := newStartTestService(t) + svc.contextInfo = linuxContextInfo{} + + web := types.ServiceConfig{Name: "web", ContainerSpec: types.ContainerSpec{Image: "alpine", UseAPISocket: true, Environment: tt.env}} + project := &types.Project{Name: "prj", Services: types.Services{"web": web}, Configs: tt.configs} + + ctr := serviceContainer("web") + ctr.State = container.StateCreated + ctr.Labels[api.ProjectLabel] = "prj" + apiClient.EXPECT().ContainerList(gomock.Any(), gomock.Any()). + Return(client.ContainerListResult{Items: []container.Summary{ctr}}, nil) + + var injected []string + copied := apiClient.EXPECT().CopyToContainer(gomock.Any(), ctr.ID, gomock.Any()). + DoAndReturn(func(_ context.Context, _ string, opts client.CopyToContainerOptions) (client.CopyToContainerResult, error) { + tr := tar.NewReader(opts.Content) + for { + header, err := tr.Next() + if err == io.EOF { + break + } + assert.NilError(t, err) + injected = append(injected, header.Name) + } + return client.CopyToContainerResult{}, nil + }) + apiClient.EXPECT().ContainerStart(gomock.Any(), ctr.ID, gomock.Any()). + Return(client.ContainerStartResult{}, nil).After(copied) + + err := svc.start(t.Context(), "prj", api.StartOptions{Project: project}) + + assert.NilError(t, err) + assert.DeepEqual(t, injected, []string{"/run/secrets/docker/config.json"}) + assert.Equal(t, len(project.Configs), 0, "the caller's configs must not be written to") + assert.Equal(t, len(project.Services["web"].Environment), 0, "the caller's service environment must not be written to") }) - apiClient.EXPECT().ContainerStart(gomock.Any(), ctr.ID, gomock.Any()). - Return(client.ContainerStartResult{}, nil).After(copied) - - err := svc.start(t.Context(), "prj", api.StartOptions{Project: project}) - - assert.NilError(t, err) - assert.DeepEqual(t, injected, []string{"/run/secrets/docker/config.json"}) - assert.Assert(t, project.Configs == nil && project.Services["web"].Environment == nil, "the caller's project must not be transformed") + } } // TestGetDependencyCondition locks the --wait condition selection: a service From c206790057c62a9c9c4bc6f44dfe47b82bbcdf2e Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Mon, 26 Oct 2026 16:59:27 +0100 Subject: [PATCH 3/3] test(start): pin that a rewritten project is injected once, settle the comments - a project that already went through use_api_socket, as the one `run` hands to the dependencies it starts, is injected once by start(): rewriting it again would duplicate the config entry, and the file would be copied twice. The first test also checks that the option was applied; - the comments say which maps the rewriting may find nil: the service Environment in the project `compose start` loads, the Configs of a programmatic one, and that a project rebuilt from the labels knows nothing of the option. Signed-off-by: Nicolas De Loof --- pkg/compose/apiSocket.go | 5 +++-- pkg/compose/start.go | 12 +++++----- pkg/compose/start_test.go | 46 +++++++++++++++++++++++++++++++++++++-- 3 files changed, 53 insertions(+), 10 deletions(-) diff --git a/pkg/compose/apiSocket.go b/pkg/compose/apiSocket.go index 93e361661f0..2829ddfb000 100644 --- a/pkg/compose/apiSocket.go +++ b/pkg/compose/apiSocket.go @@ -65,8 +65,9 @@ func (s *composeService) useAPISocket(project *types.Project) (*types.Project, e return nil, fmt.Errorf("saving creds for API socket: %w", err) } - // a project that did not go through environment resolution (the one - // `compose start` loads) has no maps to write into + // the maps to write into may be nil: a programmatic project has no Configs, + // and the one `compose start` loads, which resolves no environment, has no + // service Environment if project.Configs == nil { project.Configs = types.Configs{} } diff --git a/pkg/compose/start.go b/pkg/compose/start.go index 6443bd67220..3779fe47855 100644 --- a/pkg/compose/start.go +++ b/pkg/compose/start.go @@ -60,12 +60,12 @@ func (s *composeService) start(ctx context.Context, projectName string, options // and the dependency waits read them project = project.WithoutUnresolvedOptionalDependencies() - // use_api_socket is applied by rewriting the project (see useAPISocket), - // and the config file it adds is injected when a container starts, from the - // project given to the start: a container created beforehand and only - // started here would otherwise get DOCKER_CONFIG without the file it points - // to. The project is the copy made above, so the caller's is left alone, and - // one already transformed (a dependency started by `run`) is not done twice. + // use_api_socket rewrites the project (see useAPISocket), and the config + // file it adds is injected when a container starts, from the service the + // plan carries: a container created beforehand would otherwise come up + // without it. Done on the copy made above, before the plan is built. Like + // create and run, it looks up the credentials. A project rebuilt from the + // labels knows nothing of the option. project, err := s.useAPISocket(project) if err != nil { return err diff --git a/pkg/compose/start_test.go b/pkg/compose/start_test.go index defc7cef4bd..da358cf64b5 100644 --- a/pkg/compose/start_test.go +++ b/pkg/compose/start_test.go @@ -24,6 +24,7 @@ import ( "io" "strconv" "sync" + "sync/atomic" "testing" "time" @@ -196,7 +197,8 @@ func TestStart_WaitTimeoutBoundsDependencyWaits(t *testing.T) { // beforehand and only started here (`create` then `start`) would otherwise get // DOCKER_CONFIG pointing at a file that was never created. The caller's project // is left as it was, whether or not it has the maps the option writes into: the -// project `compose start` loads does not resolve the environment, so it has none. +// project `compose start` loads has no service Environment, a programmatic one +// no Configs. func TestStart_UseAPISocketServiceGetsItsConfigFile(t *testing.T) { tests := []struct { name string @@ -209,7 +211,8 @@ func TestStart_UseAPISocketServiceGetsItsConfigFile(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { svc, apiClient, _ := newStartTestService(t) - svc.contextInfo = linuxContextInfo{} + info := &countingContextInfo{} + svc.contextInfo = info web := types.ServiceConfig{Name: "web", ContainerSpec: types.ContainerSpec{Image: "alpine", UseAPISocket: true, Environment: tt.env}} project := &types.Project{Name: "prj", Services: types.Services{"web": web}, Configs: tt.configs} @@ -241,12 +244,51 @@ func TestStart_UseAPISocketServiceGetsItsConfigFile(t *testing.T) { assert.NilError(t, err) assert.DeepEqual(t, injected, []string{"/run/secrets/docker/config.json"}) + assert.Assert(t, info.osTypeCalls.Load() > 0, "use_api_socket must have been applied") assert.Equal(t, len(project.Configs), 0, "the caller's configs must not be written to") assert.Equal(t, len(project.Services["web"].Environment), 0, "the caller's service environment must not be written to") }) } } +// countingContextInfo is a Linux engine's context that counts how often +// useAPISocket got past its early return, which tells that it was applied. +type countingContextInfo struct{ osTypeCalls atomic.Int32 } + +func (*countingContextInfo) CurrentContext() string { return "default" } +func (c *countingContextInfo) ServerOSType() string { + c.osTypeCalls.Add(1) + return "linux" +} +func (*countingContextInfo) BuildKitEnabled() (bool, error) { return false, nil } + +// TestStart_AProjectAlreadyRewrittenIsInjectedOnce pins that start() does not +// rewrite twice a project that already went through use_api_socket, as the one +// `run` hands to the dependencies it starts: the config file would be injected +// once per duplicated entry. +func TestStart_AProjectAlreadyRewrittenIsInjectedOnce(t *testing.T) { + svc, apiClient, _ := newStartTestService(t) + svc.contextInfo = &countingContextInfo{} + + web := types.ServiceConfig{Name: "web", ContainerSpec: types.ContainerSpec{Image: "alpine", UseAPISocket: true, Environment: types.MappingWithEquals{}}} + project := &types.Project{Name: "prj", Services: types.Services{"web": web}, Configs: types.Configs{}} + project, err := svc.useAPISocket(project) + assert.NilError(t, err) + + ctr := serviceContainer("web") + ctr.State = container.StateCreated + ctr.Labels[api.ProjectLabel] = "prj" + apiClient.EXPECT().ContainerList(gomock.Any(), gomock.Any()). + Return(client.ContainerListResult{Items: []container.Summary{ctr}}, nil) + copied := apiClient.EXPECT().CopyToContainer(gomock.Any(), ctr.ID, gomock.Any()). + Return(client.CopyToContainerResult{}, nil).Times(1) + apiClient.EXPECT().ContainerStart(gomock.Any(), ctr.ID, gomock.Any()). + Return(client.ContainerStartResult{}, nil).After(copied) + + assert.NilError(t, svc.start(t.Context(), "prj", api.StartOptions{Project: project})) + assert.Equal(t, len(project.Services["web"].Configs), 1, "the project handed to start was rewritten again") +} + // TestGetDependencyCondition locks the --wait condition selection: a service // that others depend on with service_completed_successfully is waited on with // that condition (or --wait would hang on one-shot services), anything else