From f4f6ac5324f52e348a2a7e03ac8b68e74bc91290 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Mon, 17 Aug 2026 08:52:32 +0200 Subject: [PATCH] test(e2e): volume and build-once scenarios - volumes: external-volume switching, volume recreation on definition change and image volumes become scenarios; the two-compose-file fixtures collapse into one model with an interpolated volume name or label, so the changed definition is visible in the test. The rebuild scenario locks the Recreated expectation and scopes the source image to the project. TestLocalComposeVolume (fixed host port, HTTP) and TestProjectVolumeBind (host file written mid-test) stay legacy. - build-once: the three run --build regression cases each get a scenario; OutputMatchesCount joins the vocabulary to count the build reports, replacing the hand-rolled regex counter, and image names are project-scoped instead of random suffixes. Signed-off-by: Nicolas De Loof --- pkg/e2e/checks.go | 16 ++ pkg/e2e/compose_run_build_once_test.go | 108 ++++-------- .../fixtures/image-volume-recreate/Dockerfile | 21 --- .../fixtures/recreate-volumes/compose2.yaml | 10 -- pkg/e2e/fixtures/switch-volumes/compose2.yaml | 10 -- .../TestImageVolume}/compose.yaml | 2 +- .../Dockerfile | 5 + .../compose.yaml | 4 +- .../TestRunBuildOnceDependency/compose.yaml} | 4 - .../compose.yaml} | 4 - .../TestRunBuildOnceNoDeps/compose.yaml} | 2 - .../TestUpRecreateVolumes}/compose.yaml | 2 +- .../compose.yaml} | 0 .../TestUpSwitchVolumes}/compose.yaml | 2 +- pkg/e2e/volumes_test.go | 165 +++++++----------- 15 files changed, 123 insertions(+), 232 deletions(-) delete mode 100644 pkg/e2e/fixtures/image-volume-recreate/Dockerfile delete mode 100644 pkg/e2e/fixtures/recreate-volumes/compose2.yaml delete mode 100644 pkg/e2e/fixtures/switch-volumes/compose2.yaml rename pkg/e2e/{fixtures/volumes => testdata/TestImageVolume}/compose.yaml (81%) create mode 100644 pkg/e2e/testdata/TestImageVolumeRecreateOnRebuild/Dockerfile rename pkg/e2e/{fixtures/image-volume-recreate => testdata/TestImageVolumeRecreateOnRebuild}/compose.yaml (80%) rename pkg/e2e/{fixtures/run-test/build-once.yaml => testdata/TestRunBuildOnceDependency/compose.yaml} (60%) rename pkg/e2e/{fixtures/run-test/build-once-nested.yaml => testdata/TestRunBuildOnceNestedDependencies/compose.yaml} (82%) rename pkg/e2e/{fixtures/run-test/build-once-no-deps.yaml => testdata/TestRunBuildOnceNoDeps/compose.yaml} (82%) rename pkg/e2e/{fixtures/recreate-volumes => testdata/TestUpRecreateVolumes}/compose.yaml (78%) rename pkg/e2e/{fixtures/recreate-volumes/bind.yaml => testdata/TestUpRecreateVolumesIgnoreBinds/compose.yaml} (100%) rename pkg/e2e/{fixtures/switch-volumes => testdata/TestUpSwitchVolumes}/compose.yaml (78%) diff --git a/pkg/e2e/checks.go b/pkg/e2e/checks.go index a4ceb10b3..efe0a2718 100644 --- a/pkg/e2e/checks.go +++ b/pkg/e2e/checks.go @@ -25,6 +25,7 @@ package e2e import ( "fmt" "os" + "regexp" "runtime" "slices" "strings" @@ -88,6 +89,21 @@ func StdoutContains(sub string) Check { } } +// OutputMatchesCount expects the regular expression to match the command's +// stdout exactly n times, e.g. counting how many times a service was built. +func OutputMatchesCount(pattern string, n int) Check { + return Check{ + name: fmt.Sprintf("output matches %q %d time(s)", pattern, n), + fn: func(ctx *CheckContext) error { + matches := regexp.MustCompile(pattern).FindAllString(ctx.result.Stdout(), -1) + if len(matches) != n { + return fmt.Errorf("matched %d time(s)", len(matches)) + } + return nil + }, + } +} + // StderrContains expects the command's stderr to contain a string, e.g. a // container's stderr stream relayed by up. func StderrContains(sub string) Check { diff --git a/pkg/e2e/compose_run_build_once_test.go b/pkg/e2e/compose_run_build_once_test.go index c1d9f2417..31b124665 100644 --- a/pkg/e2e/compose_run_build_once_test.go +++ b/pkg/e2e/compose_run_build_once_test.go @@ -17,87 +17,47 @@ package e2e import ( - "crypto/rand" - "encoding/hex" - "fmt" "regexp" - "strings" "testing" - - "gotest.tools/v3/assert" ) -// 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) +// The build-once scenarios lock a regression where run built pull_policy: +// build dependencies twice — once in startDependencies and once in +// ensureImagesExists. Counting the "naming to ... done" build reports in the +// verbose output is the observable. - t.Run("dependency with pull_policy build is built only once", func(t *testing.T) { - projectName := randomProjectName("build-once") - composeFile := "./fixtures/run-test/build-once.yaml" - t.Cleanup(func() { - c.RunDockerComposeCmd(t, "-p", projectName, "-f", composeFile, "down", "--rmi", "local", "--remove-orphans", "-v") - }) - res := c.RunDockerComposeCmd(t, "-p", projectName, "-f", composeFile, "--verbose", "run", "--build", "--rm", "curl") - - output := res.Stdout() - - nginxBuilds := countServiceBuilds(output, projectName, "nginx") - - assert.Equal(t, nginxBuilds, 1, "nginx should build once, built %d times\nOutput:\n%s", nginxBuilds, output) - assert.Assert(t, strings.Contains(res.Stdout(), "curl service")) - }) - - t.Run("nested dependencies build only once each", func(t *testing.T) { - projectName := randomProjectName("build-nested") - composeFile := "./fixtures/run-test/build-once-nested.yaml" - t.Cleanup(func() { - c.RunDockerComposeCmd(t, "-p", projectName, "-f", composeFile, "down", "--rmi", "local", "--remove-orphans", "-v") - }) - res := c.RunDockerComposeCmd(t, "-p", projectName, "-f", composeFile, "--verbose", "run", "--build", "--rm", "app") - - output := res.Stdout() - - dbBuilds := countServiceBuilds(output, projectName, "db") - apiBuilds := countServiceBuilds(output, projectName, "api") - appBuilds := countServiceBuilds(output, projectName, "app") - - assert.Equal(t, dbBuilds, 1, "db should build once, built %d times\nOutput:\n%s", dbBuilds, output) - assert.Equal(t, apiBuilds, 1, "api should build once, built %d times\nOutput:\n%s", apiBuilds, output) - assert.Equal(t, appBuilds, 1, "app should build once, built %d times\nOutput:\n%s", appBuilds, output) - assert.Assert(t, strings.Contains(output, "App running")) - }) - - t.Run("service with no dependencies builds once", func(t *testing.T) { - projectName := randomProjectName("build-simple") - composeFile := "./fixtures/run-test/build-once-no-deps.yaml" - t.Cleanup(func() { - c.RunDockerComposeCmd(t, "-p", projectName, "-f", composeFile, "down", "--rmi", "local", "--remove-orphans", "-v") - }) - res := c.RunDockerComposeCmd(t, "-p", projectName, "-f", composeFile, "run", "--build", "--rm", "simple") - - output := res.Stdout() - - simpleBuilds := countServiceBuilds(output, projectName, "simple") - - assert.Equal(t, simpleBuilds, 1, "simple should build once, built %d times\nOutput:\n%s", simpleBuilds, output) - assert.Assert(t, strings.Contains(res.Stdout(), "Simple service")) - }) +func builtOnce(project, service string) Check { + return OutputMatchesCount(`naming to .*`+regexp.QuoteMeta(project)+`-`+regexp.QuoteMeta(service)+`.* done`, 1) } -// countServiceBuilds counts how many times a service was built by matching -// the "naming to *{projectName}-{serviceName}* done" pattern in the output -func countServiceBuilds(output, projectName, serviceName string) int { - pattern := regexp.MustCompile(`naming to .*` + regexp.QuoteMeta(projectName) + `-` + regexp.QuoteMeta(serviceName) + `.* done`) - return len(pattern.FindAllString(output, -1)) +func TestRunBuildOnceDependency(t *testing.T) { + s := NewScenario(t, "run --build must build a pull_policy: build dependency exactly once") + s.Defer(DockerCmd("image", "rm", "-f", s.Project()+"-nginx").MayFail()). + Step("run builds the dependency once and executes the service", + ComposeCmd("--verbose", "run", "--build", "--rm", "curl"), + builtOnce(s.Project(), "nginx"), + StdoutContains("curl service")) } -// randomProjectName generates a unique project name for parallel test execution -// Format: prefix-<8 random hex chars> (e.g., "build-once-3f4a9b2c") -func randomProjectName(prefix string) string { - b := make([]byte, 4) // 4 bytes = 8 hex chars - rand.Read(b) - return fmt.Sprintf("%s-%s", prefix, hex.EncodeToString(b)) +func TestRunBuildOnceNestedDependencies(t *testing.T) { + s := NewScenario(t, "run --build must build each service of a dependency chain exactly once") + s.Defer( + DockerCmd("image", "rm", "-f", s.Project()+"-db").MayFail(), + DockerCmd("image", "rm", "-f", s.Project()+"-api").MayFail(), + DockerCmd("image", "rm", "-f", s.Project()+"-app").MayFail()). + Step("run builds the whole chain, each image once", + ComposeCmd("--verbose", "run", "--build", "--rm", "app"), + builtOnce(s.Project(), "db"), + builtOnce(s.Project(), "api"), + builtOnce(s.Project(), "app"), + StdoutContains("App running")) +} + +func TestRunBuildOnceNoDeps(t *testing.T) { + s := NewScenario(t, "run --build on a dependency-less service must build it exactly once") + s.Defer(DockerCmd("image", "rm", "-f", s.Project()+"-simple").MayFail()). + Step("run builds the service once and executes it", + ComposeCmd("run", "--build", "--rm", "simple"), + builtOnce(s.Project(), "simple"), + StdoutContains("Simple service")) } diff --git a/pkg/e2e/fixtures/image-volume-recreate/Dockerfile b/pkg/e2e/fixtures/image-volume-recreate/Dockerfile deleted file mode 100644 index ee0234de1..000000000 --- a/pkg/e2e/fixtures/image-volume-recreate/Dockerfile +++ /dev/null @@ -1,21 +0,0 @@ -# syntax=docker/dockerfile:1 -# -# 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. -# - -FROM alpine -WORKDIR /app -ARG CONTENT=initial -RUN echo "$CONTENT" > /app/content.txt diff --git a/pkg/e2e/fixtures/recreate-volumes/compose2.yaml b/pkg/e2e/fixtures/recreate-volumes/compose2.yaml deleted file mode 100644 index 96a073f05..000000000 --- a/pkg/e2e/fixtures/recreate-volumes/compose2.yaml +++ /dev/null @@ -1,10 +0,0 @@ -services: - app: - image: alpine - volumes: - - my_vol:/my_vol - -volumes: - my_vol: - labels: - foo: zot diff --git a/pkg/e2e/fixtures/switch-volumes/compose2.yaml b/pkg/e2e/fixtures/switch-volumes/compose2.yaml deleted file mode 100644 index 6d52097f9..000000000 --- a/pkg/e2e/fixtures/switch-volumes/compose2.yaml +++ /dev/null @@ -1,10 +0,0 @@ -services: - app: - image: alpine - volumes: - - my_vol:/my_vol - -volumes: - my_vol: - external: true - name: test_external_volume_2 diff --git a/pkg/e2e/fixtures/volumes/compose.yaml b/pkg/e2e/testdata/TestImageVolume/compose.yaml similarity index 81% rename from pkg/e2e/fixtures/volumes/compose.yaml rename to pkg/e2e/testdata/TestImageVolume/compose.yaml index 4aad0482b..834f76009 100644 --- a/pkg/e2e/fixtures/volumes/compose.yaml +++ b/pkg/e2e/testdata/TestImageVolume/compose.yaml @@ -7,4 +7,4 @@ services: source: nginx:alpine target: /mnt/image image: - subpath: usr/share/nginx/html/ \ No newline at end of file + subpath: usr/share/nginx/html/ diff --git a/pkg/e2e/testdata/TestImageVolumeRecreateOnRebuild/Dockerfile b/pkg/e2e/testdata/TestImageVolumeRecreateOnRebuild/Dockerfile new file mode 100644 index 000000000..2ca2fa463 --- /dev/null +++ b/pkg/e2e/testdata/TestImageVolumeRecreateOnRebuild/Dockerfile @@ -0,0 +1,5 @@ +FROM alpine +WORKDIR /app +ARG CONTENT=initial +RUN echo "$CONTENT" > /app/content.txt + diff --git a/pkg/e2e/fixtures/image-volume-recreate/compose.yaml b/pkg/e2e/testdata/TestImageVolumeRecreateOnRebuild/compose.yaml similarity index 80% rename from pkg/e2e/fixtures/image-volume-recreate/compose.yaml rename to pkg/e2e/testdata/TestImageVolumeRecreateOnRebuild/compose.yaml index 7b8165219..89717df04 100644 --- a/pkg/e2e/fixtures/image-volume-recreate/compose.yaml +++ b/pkg/e2e/testdata/TestImageVolumeRecreateOnRebuild/compose.yaml @@ -3,7 +3,7 @@ services: build: context: . dockerfile: Dockerfile - image: image-volume-source + image: ${SOURCE_IMAGE} consumer: image: alpine @@ -12,7 +12,7 @@ services: command: ["cat", "/data/content.txt"] volumes: - type: image - source: image-volume-source + source: ${SOURCE_IMAGE} target: /data image: subpath: app diff --git a/pkg/e2e/fixtures/run-test/build-once.yaml b/pkg/e2e/testdata/TestRunBuildOnceDependency/compose.yaml similarity index 60% rename from pkg/e2e/fixtures/run-test/build-once.yaml rename to pkg/e2e/testdata/TestRunBuildOnceDependency/compose.yaml index 7a6f84dbc..f7a9ffb4a 100644 --- a/pkg/e2e/fixtures/run-test/build-once.yaml +++ b/pkg/e2e/testdata/TestRunBuildOnceDependency/compose.yaml @@ -1,6 +1,4 @@ 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: @@ -9,10 +7,8 @@ services: RUN echo "Nginx built at $(date)" > /build-time.txt CMD sleep 3600 - # Service that depends on nginx curl: image: alpine depends_on: - nginx command: echo "curl service" - diff --git a/pkg/e2e/fixtures/run-test/build-once-nested.yaml b/pkg/e2e/testdata/TestRunBuildOnceNestedDependencies/compose.yaml similarity index 82% rename from pkg/e2e/fixtures/run-test/build-once-nested.yaml rename to pkg/e2e/testdata/TestRunBuildOnceNestedDependencies/compose.yaml index 4972db5a7..d982e5fae 100644 --- a/pkg/e2e/fixtures/run-test/build-once-nested.yaml +++ b/pkg/e2e/testdata/TestRunBuildOnceNestedDependencies/compose.yaml @@ -1,5 +1,4 @@ services: - # Database service with build db: pull_policy: build build: @@ -8,7 +7,6 @@ services: RUN echo "DB built at $(date)" > /db-build.txt CMD sleep 3600 - # API service that depends on db api: pull_policy: build build: @@ -19,7 +17,6 @@ services: depends_on: - db - # App service that depends on api (which depends on db) app: pull_policy: build build: @@ -29,4 +26,3 @@ services: CMD echo "App running" depends_on: - api - diff --git a/pkg/e2e/fixtures/run-test/build-once-no-deps.yaml b/pkg/e2e/testdata/TestRunBuildOnceNoDeps/compose.yaml similarity index 82% rename from pkg/e2e/fixtures/run-test/build-once-no-deps.yaml rename to pkg/e2e/testdata/TestRunBuildOnceNoDeps/compose.yaml index 36f4258b3..d5c7af6b7 100644 --- a/pkg/e2e/fixtures/run-test/build-once-no-deps.yaml +++ b/pkg/e2e/testdata/TestRunBuildOnceNoDeps/compose.yaml @@ -1,5 +1,4 @@ services: - # Simple service with no dependencies simple: pull_policy: build build: @@ -7,4 +6,3 @@ services: FROM alpine RUN echo "Simple built at $(date)" > /build.txt CMD echo "Simple service" - diff --git a/pkg/e2e/fixtures/recreate-volumes/compose.yaml b/pkg/e2e/testdata/TestUpRecreateVolumes/compose.yaml similarity index 78% rename from pkg/e2e/fixtures/recreate-volumes/compose.yaml rename to pkg/e2e/testdata/TestUpRecreateVolumes/compose.yaml index e0e40c721..818801b47 100644 --- a/pkg/e2e/fixtures/recreate-volumes/compose.yaml +++ b/pkg/e2e/testdata/TestUpRecreateVolumes/compose.yaml @@ -7,4 +7,4 @@ services: volumes: my_vol: labels: - foo: bar + foo: ${VOL_LABEL:-bar} diff --git a/pkg/e2e/fixtures/recreate-volumes/bind.yaml b/pkg/e2e/testdata/TestUpRecreateVolumesIgnoreBinds/compose.yaml similarity index 100% rename from pkg/e2e/fixtures/recreate-volumes/bind.yaml rename to pkg/e2e/testdata/TestUpRecreateVolumesIgnoreBinds/compose.yaml diff --git a/pkg/e2e/fixtures/switch-volumes/compose.yaml b/pkg/e2e/testdata/TestUpSwitchVolumes/compose.yaml similarity index 78% rename from pkg/e2e/fixtures/switch-volumes/compose.yaml rename to pkg/e2e/testdata/TestUpSwitchVolumes/compose.yaml index 9da0dcba1..52c115a2b 100644 --- a/pkg/e2e/fixtures/switch-volumes/compose.yaml +++ b/pkg/e2e/testdata/TestUpSwitchVolumes/compose.yaml @@ -7,4 +7,4 @@ services: volumes: my_vol: external: true - name: test_external_volume + name: ${EXTERNAL_VOLUME} diff --git a/pkg/e2e/volumes_test.go b/pkg/e2e/volumes_test.go index 3936b5e0d..429786f4d 100644 --- a/pkg/e2e/volumes_test.go +++ b/pkg/e2e/volumes_test.go @@ -17,7 +17,6 @@ package e2e import ( - "fmt" "net/http" "path/filepath" "runtime" @@ -121,74 +120,57 @@ func TestProjectVolumeBind(t *testing.T) { } func TestUpSwitchVolumes(t *testing.T) { - c := NewCLI(t) - const projectName = "compose-e2e-switch-volumes" - t.Cleanup(func() { - c.cleanupWithDown(t, projectName) - c.RunDockerCmd(t, "volume", "rm", "-f", "test_external_volume") - c.RunDockerCmd(t, "volume", "rm", "-f", "test_external_volume_2") - }) - - c.RunDockerCmd(t, "volume", "create", "test_external_volume") - c.RunDockerCmd(t, "volume", "create", "test_external_volume_2") - - c.RunDockerComposeCmd(t, "-f", "./fixtures/switch-volumes/compose.yaml", "--project-name", projectName, "up", "-d") - - res := c.RunDockerCmd(t, "inspect", fmt.Sprintf("%s-app-1", projectName), "-f", "{{ (index .Mounts 0).Name }}") - res.Assert(t, icmd.Expected{Out: "test_external_volume"}) - - c.RunDockerComposeCmd(t, "-f", "./fixtures/switch-volumes/compose2.yaml", "--project-name", projectName, "up", "-d") - res = c.RunDockerCmd(t, "inspect", fmt.Sprintf("%s-app-1", projectName), "-f", "{{ (index .Mounts 0).Name }}") - res.Assert(t, icmd.Expected{Out: "test_external_volume_2"}) + s := NewScenario(t, "switching a service's external volume must reconnect the container to the new one") + vol1, vol2 := s.Project()+"-ext-1", s.Project()+"-ext-2" + s.Defer( + DockerCmd("volume", "rm", "-f", vol1).MayFail(), + DockerCmd("volume", "rm", "-f", vol2).MayFail()). + Step("create the two external volumes", + DockerCmd("volume", "create", vol1)). + Step("and the second one", + DockerCmd("volume", "create", vol2)). + Step("up mounts the first volume", + ComposeCmd("up", "-d").WithEnv("EXTERNAL_VOLUME="+vol1)). + Step("the container's mount points at the first volume", + DockerCmd("inspect", s.Project()+"-app-1", "-f", "{{ (index .Mounts 0).Name }}"), + OutputContains(vol1)). + Step("up with the other volume reconnects the service", + ComposeCmd("up", "-d").WithEnv("EXTERNAL_VOLUME="+vol2)). + Step("the container's mount points at the second volume", + DockerCmd("inspect", s.Project()+"-app-1", "-f", "{{ (index .Mounts 0).Name }}"), + OutputContains(vol2)) } func TestUpRecreateVolumes(t *testing.T) { - c := NewCLI(t) - const projectName = "compose-e2e-recreate-volumes" - t.Cleanup(func() { - c.cleanupWithDown(t, projectName) - }) - - c.RunDockerComposeCmd(t, "-f", "./fixtures/recreate-volumes/compose.yaml", "--project-name", projectName, "up", "-d") - - res := c.RunDockerCmd(t, "volume", "inspect", fmt.Sprintf("%s_my_vol", projectName), "-f", "{{ index .Labels \"foo\" }}") - res.Assert(t, icmd.Expected{Out: "bar"}) - - c.RunDockerComposeCmd(t, "-f", "./fixtures/recreate-volumes/compose2.yaml", "--project-name", projectName, "up", "-d", "-y") - res = c.RunDockerCmd(t, "volume", "inspect", fmt.Sprintf("%s_my_vol", projectName), "-f", "{{ index .Labels \"foo\" }}") - res.Assert(t, icmd.Expected{Out: "zot"}) + s := NewScenario(t, "a volume definition change must recreate the volume on an approved up") + s.Step("up creates the volume with its declared label", + ComposeCmd("up", "-d")). + Step("the volume carries the initial label", + DockerCmd("volume", "inspect", s.Project()+"_my_vol", "-f", `{{ index .Labels "foo" }}`), + OutputContains("bar")). + Step("up -y with a changed label recreates the volume", + ComposeCmd("up", "-d", "-y").WithEnv("VOL_LABEL=zot")). + Step("the volume carries the new label", + DockerCmd("volume", "inspect", s.Project()+"_my_vol", "-f", `{{ index .Labels "foo" }}`), + OutputContains("zot")) } -func TestUpRecreateVolumes_IgnoreBinds(t *testing.T) { - c := NewCLI(t) - const projectName = "compose-e2e-recreate-volumes" - t.Cleanup(func() { - c.cleanupWithDown(t, projectName) - }) - - c.RunDockerComposeCmd(t, "-f", "./fixtures/recreate-volumes/bind.yaml", "--project-name", projectName, "up", "-d") - - res := c.RunDockerComposeCmd(t, "-f", "./fixtures/recreate-volumes/bind.yaml", "--project-name", projectName, "up", "-d") - assert.Check(t, !strings.Contains(res.Combined(), "Recreated")) +func TestUpRecreateVolumesIgnoreBinds(t *testing.T) { + NewScenario(t, "a bind mount must not be mistaken for a volume change on an unchanged up"). + Step("up starts the service with its bind mount", + ComposeCmd("up", "-d")). + Step("an unchanged up leaves the container alone", + ComposeCmd("up", "-d"), + OutputNotContains("Recreated"), + NotRecreated("app")) } func TestImageVolume(t *testing.T) { - c := NewCLI(t) - const projectName = "compose-e2e-image-volume" - t.Cleanup(func() { - c.cleanupWithDown(t, projectName) - }) - - version := c.RunDockerCmd(t, "version", "-f", "{{.Server.Version}}") - major, _, found := strings.Cut(version.Combined(), ".") - assert.Assert(t, found) - if major == "26" || major == "27" { - t.Skip("Skipping test due to docker version < 28") - } - - res := c.RunDockerComposeCmd(t, "-f", "./fixtures/volumes/compose.yaml", "--project-name", projectName, "up", "with_image") - out := res.Combined() - assert.Check(t, strings.Contains(out, "index.html")) + NewScenario(t, "an image volume must mount the source image's content at the requested subpath"). + Requires(EngineVersionAtLeast(28)). + Step("up mounts the image content", + ComposeCmd("up", "with_image"), + OutputContains("index.html")) } func TestImageVolumeImageAlreadyLocal(t *testing.T) { @@ -212,45 +194,24 @@ func TestImageVolumeImageAlreadyLocal(t *testing.T) { } func TestImageVolumeRecreateOnRebuild(t *testing.T) { - c := NewCLI(t) - const projectName = "compose-e2e-image-volume-recreate" - t.Cleanup(func() { - c.cleanupWithDown(t, projectName) - c.RunDockerOrExitError(t, "rmi", "-f", "image-volume-source") - }) - - version := c.RunDockerCmd(t, "version", "-f", "{{.Server.Version}}") - major, _, found := strings.Cut(version.Combined(), ".") - assert.Assert(t, found) - if major == "26" || major == "27" { - t.Skip("Skipping test due to docker version < 28") - } - - // First build and run with initial content - c.RunDockerComposeCmd(t, "-f", "./fixtures/image-volume-recreate/compose.yaml", - "--project-name", projectName, "build", "--build-arg", "CONTENT=foo") - res := c.RunDockerComposeCmd(t, "-f", "./fixtures/image-volume-recreate/compose.yaml", - "--project-name", projectName, "up", "-d") - assert.Check(t, !strings.Contains(res.Combined(), "error")) - - // Check initial content - res = c.RunDockerComposeCmd(t, "-f", "./fixtures/image-volume-recreate/compose.yaml", - "--project-name", projectName, "logs", "consumer") - assert.Check(t, strings.Contains(res.Combined(), "foo"), "Expected 'foo' in output, got: %s", res.Combined()) - - // Rebuild source image with different content - c.RunDockerComposeCmd(t, "-f", "./fixtures/image-volume-recreate/compose.yaml", - "--project-name", projectName, "build", "--build-arg", "CONTENT=bar") - - // Run up again - consumer should be recreated because source image changed - res = c.RunDockerComposeCmd(t, "-f", "./fixtures/image-volume-recreate/compose.yaml", - "--project-name", projectName, "up", "-d") - // The consumer container should be recreated - assert.Check(t, strings.Contains(res.Combined(), "Recreate") || strings.Contains(res.Combined(), "Created"), - "Expected container to be recreated, got: %s", res.Combined()) - - // Check updated content - res = c.RunDockerComposeCmd(t, "-f", "./fixtures/image-volume-recreate/compose.yaml", - "--project-name", projectName, "logs", "consumer") - assert.Check(t, strings.Contains(res.Combined(), "bar"), "Expected 'bar' in output after rebuild, got: %s", res.Combined()) + s := NewScenario(t, "rebuilding an image-volume source must recreate its consumer to expose the new content") + image := s.Project() + "-source-image" + s.Requires(EngineVersionAtLeast(28)). + Env("SOURCE_IMAGE="+image). + Defer(DockerCmd("image", "rm", "-f", image).MayFail()). + Step("build the source image with its initial content", + ComposeCmd("build", "--build-arg", "CONTENT=foo")). + Step("up mounts the image volume into the consumer", + ComposeCmd("up", "-d")). + Step("the consumer read the initial content", + ComposeCmd("logs", "consumer"), + OutputContains("foo")). + Step("rebuild the source with new content", + ComposeCmd("build", "--build-arg", "CONTENT=bar")). + Step("up recreates the consumer against the rebuilt image", + ComposeCmd("up", "-d"), + Recreated("consumer")). + Step("the consumer read the new content", + ComposeCmd("logs", "consumer"), + OutputContains("bar")) }