diff --git a/pkg/compose/apiSocket.go b/pkg/compose/apiSocket.go index de9d55d097..2829ddfb00 100644 --- a/pkg/compose/apiSocket.go +++ b/pkg/compose/apiSocket.go @@ -65,6 +65,12 @@ func (s *composeService) useAPISocket(project *types.Project) (*types.Project, e return nil, fmt.Errorf("saving creds for API socket: %w", err) } + // 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{} + } project.Configs["#apisocket"] = types.ConfigObjConfig{ Content: configBuf.String(), } @@ -79,6 +85,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 5ae05a1ddc..3779fe4785 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 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 + } + 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 9d3a344e3f..da358cf64b 100644 --- a/pkg/compose/start_test.go +++ b/pkg/compose/start_test.go @@ -19,9 +19,12 @@ package compose import ( + "archive/tar" "context" + "io" "strconv" "sync" + "sync/atomic" "testing" "time" @@ -188,6 +191,104 @@ 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, whether or not it has the maps the option writes into: the +// project `compose start` loads has no service Environment, a programmatic one +// no Configs. +func TestStart_UseAPISocketServiceGetsItsConfigFile(t *testing.T) { + 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) + 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} + + 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, 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 diff --git a/pkg/e2e/api_socket_test.go b/pkg/e2e/api_socket_test.go new file mode 100644 index 0000000000..aaaf788974 --- /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 0000000000..63086747f7 --- /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