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 <[email protected]>
This commit is contained in:
Nicolas De Loof
2026-08-18 16:49:56 +02:00
committed by Nicolas De loof
parent ee7f323fed
commit f4f6ac5324
15 changed files with 123 additions and 232 deletions
+16
View File
@@ -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 {
+34 -74
View File
@@ -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"))
}
@@ -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
@@ -1,10 +0,0 @@
services:
app:
image: alpine
volumes:
- my_vol:/my_vol
volumes:
my_vol:
labels:
foo: zot
@@ -1,10 +0,0 @@
services:
app:
image: alpine
volumes:
- my_vol:/my_vol
volumes:
my_vol:
external: true
name: test_external_volume_2
@@ -7,4 +7,4 @@ services:
source: nginx:alpine
target: /mnt/image
image:
subpath: usr/share/nginx/html/
subpath: usr/share/nginx/html/
@@ -0,0 +1,5 @@
FROM alpine
WORKDIR /app
ARG CONTENT=initial
RUN echo "$CONTENT" > /app/content.txt
@@ -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
@@ -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"
@@ -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
@@ -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"
@@ -7,4 +7,4 @@ services:
volumes:
my_vol:
labels:
foo: bar
foo: ${VOL_LABEL:-bar}
@@ -7,4 +7,4 @@ services:
volumes:
my_vol:
external: true
name: test_external_volume
name: ${EXTERNAL_VOLUME}
+63 -102
View File
@@ -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"))
}