From 13d0225a88da3ba4b939d7744559bc655ee16ead Mon Sep 17 00:00:00 2001 From: Suleiman Dibirov Date: Tue, 28 Oct 2025 16:08:44 +0200 Subject: [PATCH 1/6] fix(run): Ensure images exist only for the target service in run command Signed-off-by: Suleiman Dibirov --- pkg/compose/run.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/pkg/compose/run.go b/pkg/compose/run.go index 0e454e6a2f3..af605182a83 100644 --- a/pkg/compose/run.go +++ b/pkg/compose/run.go @@ -97,7 +97,15 @@ func (s *composeService) prepareRun(ctx context.Context, project *types.Project, Add(api.SlugLabel, slug). Add(api.OneoffLabel, "True") - if err := s.ensureImagesExists(ctx, project, opts.Build, opts.QuietPull); err != nil { // all dependencies already checked, but might miss service img + // Only ensure image exists for the target service, dependencies were already handled by startDependencies + var buildOpts *api.BuildOptions + if opts.Build != nil { + // Create a copy of build options and restrict to only the target service + buildOptsCopy := *opts.Build + buildOptsCopy.Services = []string{opts.Service} + buildOpts = &buildOptsCopy + } + if err := s.ensureImagesExists(ctx, project, buildOpts, opts.QuietPull); err != nil { // all dependencies already checked, but might miss service img return "", err } From e424a97424456212d8c703ebf4075defc5622ea1 Mon Sep 17 00:00:00 2001 From: Suleiman Dibirov Date: Tue, 28 Oct 2025 16:15:32 +0200 Subject: [PATCH 2/6] add e2e tests Signed-off-by: Suleiman Dibirov --- pkg/e2e/compose_run_build_once_test.go | 87 +++++++++++++++++++ .../fixtures/run-test/build-once-nested.yaml | 32 +++++++ .../fixtures/run-test/build-once-no-deps.yaml | 10 +++ pkg/e2e/fixtures/run-test/build-once.yaml | 18 ++++ 4 files changed, 147 insertions(+) create mode 100644 pkg/e2e/compose_run_build_once_test.go create mode 100644 pkg/e2e/fixtures/run-test/build-once-nested.yaml create mode 100644 pkg/e2e/fixtures/run-test/build-once-no-deps.yaml create mode 100644 pkg/e2e/fixtures/run-test/build-once.yaml diff --git a/pkg/e2e/compose_run_build_once_test.go b/pkg/e2e/compose_run_build_once_test.go new file mode 100644 index 00000000000..699457fc8cb --- /dev/null +++ b/pkg/e2e/compose_run_build_once_test.go @@ -0,0 +1,87 @@ +/* + 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 e2e + +import ( + "strings" + "testing" + + "gotest.tools/v3/assert" + "gotest.tools/v3/icmd" +) + +// TestRunBuildOnce tests that services with pull_policy: build are only built once +// when using 'docker compose run', even when they are dependencies. +// This addresses a bug where dependencies were built twice: once in startDependencies +// and once in ensureImagesExists. +func TestRunBuildOnce(t *testing.T) { + c := NewParallelCLI(t) + + t.Run("dependency with pull_policy build is built only once", func(t *testing.T) { + res := c.RunDockerComposeCmd(t, "-f", "./fixtures/run-test/build-once.yaml", "down", "--rmi", "local", "--remove-orphans") + res.Assert(t, icmd.Success) + + res = c.RunDockerComposeCmd(t, "-f", "./fixtures/run-test/build-once.yaml", "run", "--rm", "curl") + res.Assert(t, icmd.Success) + + // Count how many times nginx was built by looking for its unique RUN command output + nginxBuilds := strings.Count(res.Combined(), "Building nginx at") + + // nginx should build exactly once, not twice + assert.Equal(t, nginxBuilds, 1, "nginx dependency should build once, but built %d times", nginxBuilds) + assert.Assert(t, strings.Contains(res.Combined(), "curl service")) + + c.RunDockerComposeCmd(t, "-f", "./fixtures/run-test/build-once.yaml", "down", "--remove-orphans") + }) + + t.Run("nested dependencies build only once each", func(t *testing.T) { + res := c.RunDockerComposeCmd(t, "-f", "./fixtures/run-test/build-once-nested.yaml", "down", "--rmi", "local", "--remove-orphans") + res.Assert(t, icmd.Success) + + res = c.RunDockerComposeCmd(t, "-f", "./fixtures/run-test/build-once-nested.yaml", "run", "--rm", "app") + res.Assert(t, icmd.Success) + + output := res.Combined() + + // Each service should build exactly once + dbBuilds := strings.Count(output, "DB built at") + apiBuilds := strings.Count(output, "API built at") + appBuilds := strings.Count(output, "App built at") + + assert.Equal(t, dbBuilds, 1, "db should build once, built %d times", dbBuilds) + assert.Equal(t, apiBuilds, 1, "api should build once, built %d times", apiBuilds) + assert.Equal(t, appBuilds, 1, "app should build once, built %d times", appBuilds) + assert.Assert(t, strings.Contains(output, "App running")) + + c.RunDockerComposeCmd(t, "-f", "./fixtures/run-test/build-once-nested.yaml", "down", "--remove-orphans") + }) + + t.Run("service with no dependencies builds once", func(t *testing.T) { + res := c.RunDockerComposeCmd(t, "-f", "./fixtures/run-test/build-once-no-deps.yaml", "down", "--rmi", "local", "--remove-orphans") + res.Assert(t, icmd.Success) + + res = c.RunDockerComposeCmd(t, "-f", "./fixtures/run-test/build-once-no-deps.yaml", "run", "--rm", "simple") + res.Assert(t, icmd.Success) + + // Should build exactly once + simpleBuilds := strings.Count(res.Combined(), "Simple service built at") + assert.Equal(t, simpleBuilds, 1, "simple should build once, built %d times", simpleBuilds) + assert.Assert(t, strings.Contains(res.Combined(), "Simple service")) + + c.RunDockerComposeCmd(t, "-f", "./fixtures/run-test/build-once-no-deps.yaml", "down", "--remove-orphans") + }) +} diff --git a/pkg/e2e/fixtures/run-test/build-once-nested.yaml b/pkg/e2e/fixtures/run-test/build-once-nested.yaml new file mode 100644 index 00000000000..4972db5a7bb --- /dev/null +++ b/pkg/e2e/fixtures/run-test/build-once-nested.yaml @@ -0,0 +1,32 @@ +services: + # Database service with build + db: + pull_policy: build + build: + dockerfile_inline: | + FROM alpine + RUN echo "DB built at $(date)" > /db-build.txt + CMD sleep 3600 + + # API service that depends on db + api: + pull_policy: build + build: + dockerfile_inline: | + FROM alpine + RUN echo "API built at $(date)" > /api-build.txt + CMD sleep 3600 + depends_on: + - db + + # App service that depends on api (which depends on db) + app: + pull_policy: build + build: + dockerfile_inline: | + FROM alpine + RUN echo "App built at $(date)" > /app-build.txt + CMD echo "App running" + depends_on: + - api + diff --git a/pkg/e2e/fixtures/run-test/build-once-no-deps.yaml b/pkg/e2e/fixtures/run-test/build-once-no-deps.yaml new file mode 100644 index 00000000000..bf53d951671 --- /dev/null +++ b/pkg/e2e/fixtures/run-test/build-once-no-deps.yaml @@ -0,0 +1,10 @@ +services: + # Simple service with no dependencies + simple: + pull_policy: build + build: + dockerfile_inline: | + FROM alpine + RUN echo "Simple service built at $(date)" > /build.txt + CMD echo "Simple service" + diff --git a/pkg/e2e/fixtures/run-test/build-once.yaml b/pkg/e2e/fixtures/run-test/build-once.yaml new file mode 100644 index 00000000000..1d86f875fc2 --- /dev/null +++ b/pkg/e2e/fixtures/run-test/build-once.yaml @@ -0,0 +1,18 @@ +services: + # Service with pull_policy: build to ensure it always rebuilds + # This is the key to testing the bug - without the fix, this would build twice + nginx: + pull_policy: build + build: + dockerfile_inline: | + FROM alpine + RUN echo "Building nginx at $(date)" > /build-time.txt + CMD sleep 3600 + + # Service that depends on nginx + curl: + image: alpine + depends_on: + - nginx + command: echo "curl service" + From 529dd4e16b41b15d1505bb642ebde530ac1eccd4 Mon Sep 17 00:00:00 2001 From: Suleiman Dibirov Date: Tue, 28 Oct 2025 17:22:49 +0200 Subject: [PATCH 3/6] lint fix Signed-off-by: Suleiman Dibirov --- pkg/compose/run.go | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/pkg/compose/run.go b/pkg/compose/run.go index af605182a83..9fafec94b5d 100644 --- a/pkg/compose/run.go +++ b/pkg/compose/run.go @@ -98,13 +98,7 @@ func (s *composeService) prepareRun(ctx context.Context, project *types.Project, Add(api.OneoffLabel, "True") // Only ensure image exists for the target service, dependencies were already handled by startDependencies - var buildOpts *api.BuildOptions - if opts.Build != nil { - // Create a copy of build options and restrict to only the target service - buildOptsCopy := *opts.Build - buildOptsCopy.Services = []string{opts.Service} - buildOpts = &buildOptsCopy - } + buildOpts := prepareBuildOptions(opts) if err := s.ensureImagesExists(ctx, project, buildOpts, opts.QuietPull); err != nil { // all dependencies already checked, but might miss service img return "", err } @@ -155,6 +149,16 @@ func (s *composeService) prepareRun(ctx context.Context, project *types.Project, return created.ID, err } +func prepareBuildOptions(opts api.RunOptions) *api.BuildOptions { + if opts.Build == nil { + return nil + } + // Create a copy of build options and restrict to only the target service + buildOptsCopy := *opts.Build + buildOptsCopy.Services = []string{opts.Service} + return &buildOptsCopy +} + func applyRunOptions(project *types.Project, service *types.ServiceConfig, opts api.RunOptions) { service.Tty = opts.Tty service.StdinOpen = opts.Interactive From e4f4a5aa86002b9f1f404c4d93dba5212c29c58f Mon Sep 17 00:00:00 2001 From: Suleiman Dibirov Date: Tue, 28 Oct 2025 18:02:35 +0200 Subject: [PATCH 4/6] no parallel in compose_run_build_once_test.go Signed-off-by: Suleiman Dibirov --- pkg/e2e/compose_run_build_once_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/e2e/compose_run_build_once_test.go b/pkg/e2e/compose_run_build_once_test.go index 699457fc8cb..fc7f9920af9 100644 --- a/pkg/e2e/compose_run_build_once_test.go +++ b/pkg/e2e/compose_run_build_once_test.go @@ -29,7 +29,7 @@ import ( // This addresses a bug where dependencies were built twice: once in startDependencies // and once in ensureImagesExists. func TestRunBuildOnce(t *testing.T) { - c := NewParallelCLI(t) + c := NewCLI(t) t.Run("dependency with pull_policy build is built only once", func(t *testing.T) { res := c.RunDockerComposeCmd(t, "-f", "./fixtures/run-test/build-once.yaml", "down", "--rmi", "local", "--remove-orphans") From e27e8d5f1eae75138faeb3d303608a8c666a693a Mon Sep 17 00:00:00 2001 From: Suleiman Dibirov Date: Tue, 28 Oct 2025 18:35:54 +0200 Subject: [PATCH 5/6] Revert "no parallel in compose_run_build_once_test.go" This reverts commit e4f4a5aa86002b9f1f404c4d93dba5212c29c58f. Signed-off-by: Suleiman Dibirov --- pkg/e2e/compose_run_build_once_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/e2e/compose_run_build_once_test.go b/pkg/e2e/compose_run_build_once_test.go index fc7f9920af9..699457fc8cb 100644 --- a/pkg/e2e/compose_run_build_once_test.go +++ b/pkg/e2e/compose_run_build_once_test.go @@ -29,7 +29,7 @@ import ( // This addresses a bug where dependencies were built twice: once in startDependencies // and once in ensureImagesExists. func TestRunBuildOnce(t *testing.T) { - c := NewCLI(t) + c := NewParallelCLI(t) t.Run("dependency with pull_policy build is built only once", func(t *testing.T) { res := c.RunDockerComposeCmd(t, "-f", "./fixtures/run-test/build-once.yaml", "down", "--rmi", "local", "--remove-orphans") From ef8d28093d8131025d7dbd52997a2533622b2436 Mon Sep 17 00:00:00 2001 From: Suleiman Dibirov Date: Tue, 28 Oct 2025 18:43:40 +0200 Subject: [PATCH 6/6] Update e2e tests in compose_run_build_once_test.go to use project names for Docker Compose commands. Signed-off-by: Suleiman Dibirov --- pkg/e2e/compose_run_build_once_test.go | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/pkg/e2e/compose_run_build_once_test.go b/pkg/e2e/compose_run_build_once_test.go index 699457fc8cb..c891ca0f9b9 100644 --- a/pkg/e2e/compose_run_build_once_test.go +++ b/pkg/e2e/compose_run_build_once_test.go @@ -32,10 +32,11 @@ func TestRunBuildOnce(t *testing.T) { c := NewParallelCLI(t) t.Run("dependency with pull_policy build is built only once", func(t *testing.T) { - res := c.RunDockerComposeCmd(t, "-f", "./fixtures/run-test/build-once.yaml", "down", "--rmi", "local", "--remove-orphans") + projectName := "e2e-run-build-once-single" + res := c.RunDockerComposeCmd(t, "-p", projectName, "-f", "./fixtures/run-test/build-once.yaml", "down", "--rmi", "local", "--remove-orphans") res.Assert(t, icmd.Success) - res = c.RunDockerComposeCmd(t, "-f", "./fixtures/run-test/build-once.yaml", "run", "--rm", "curl") + res = c.RunDockerComposeCmd(t, "-p", projectName, "-f", "./fixtures/run-test/build-once.yaml", "run", "--rm", "curl") res.Assert(t, icmd.Success) // Count how many times nginx was built by looking for its unique RUN command output @@ -45,14 +46,15 @@ func TestRunBuildOnce(t *testing.T) { assert.Equal(t, nginxBuilds, 1, "nginx dependency should build once, but built %d times", nginxBuilds) assert.Assert(t, strings.Contains(res.Combined(), "curl service")) - c.RunDockerComposeCmd(t, "-f", "./fixtures/run-test/build-once.yaml", "down", "--remove-orphans") + c.RunDockerComposeCmd(t, "-p", projectName, "-f", "./fixtures/run-test/build-once.yaml", "down", "--remove-orphans") }) t.Run("nested dependencies build only once each", func(t *testing.T) { - res := c.RunDockerComposeCmd(t, "-f", "./fixtures/run-test/build-once-nested.yaml", "down", "--rmi", "local", "--remove-orphans") + projectName := "e2e-run-build-once-nested" + res := c.RunDockerComposeCmd(t, "-p", projectName, "-f", "./fixtures/run-test/build-once-nested.yaml", "down", "--rmi", "local", "--remove-orphans") res.Assert(t, icmd.Success) - res = c.RunDockerComposeCmd(t, "-f", "./fixtures/run-test/build-once-nested.yaml", "run", "--rm", "app") + res = c.RunDockerComposeCmd(t, "-p", projectName, "-f", "./fixtures/run-test/build-once-nested.yaml", "run", "--rm", "app") res.Assert(t, icmd.Success) output := res.Combined() @@ -67,14 +69,15 @@ func TestRunBuildOnce(t *testing.T) { assert.Equal(t, appBuilds, 1, "app should build once, built %d times", appBuilds) assert.Assert(t, strings.Contains(output, "App running")) - c.RunDockerComposeCmd(t, "-f", "./fixtures/run-test/build-once-nested.yaml", "down", "--remove-orphans") + c.RunDockerComposeCmd(t, "-p", projectName, "-f", "./fixtures/run-test/build-once-nested.yaml", "down", "--remove-orphans") }) t.Run("service with no dependencies builds once", func(t *testing.T) { - res := c.RunDockerComposeCmd(t, "-f", "./fixtures/run-test/build-once-no-deps.yaml", "down", "--rmi", "local", "--remove-orphans") + projectName := "e2e-run-build-once-no-deps" + res := c.RunDockerComposeCmd(t, "-p", projectName, "-f", "./fixtures/run-test/build-once-no-deps.yaml", "down", "--rmi", "local", "--remove-orphans") res.Assert(t, icmd.Success) - res = c.RunDockerComposeCmd(t, "-f", "./fixtures/run-test/build-once-no-deps.yaml", "run", "--rm", "simple") + res = c.RunDockerComposeCmd(t, "-p", projectName, "-f", "./fixtures/run-test/build-once-no-deps.yaml", "run", "--rm", "simple") res.Assert(t, icmd.Success) // Should build exactly once @@ -82,6 +85,6 @@ func TestRunBuildOnce(t *testing.T) { assert.Equal(t, simpleBuilds, 1, "simple should build once, built %d times", simpleBuilds) assert.Assert(t, strings.Contains(res.Combined(), "Simple service")) - c.RunDockerComposeCmd(t, "-f", "./fixtures/run-test/build-once-no-deps.yaml", "down", "--remove-orphans") + c.RunDockerComposeCmd(t, "-p", projectName, "-f", "./fixtures/run-test/build-once-no-deps.yaml", "down", "--remove-orphans") }) }