test(e2e): run and up scenarios

- compose run splits into seven scenarios (one-off basics, ports, deps,
  optional deps, quiet pull, --pull always, chained build contexts).
  The orphan-warning cases fold into the basic scenario: an exited
  one-off IS the orphan the next run warns about, no second compose
  file needed. Piped-stdin and stop-signal tests stay legacy.
- up/compose_up: unhealthy service, dependency exit (both now also
  locking that dependents stay in created state), build dependencies
  (project-scoped image name instead of the fixture's global one),
  optional dependency, --all-resources, profile targeting (dropping the
  fixture's global container_names), image-by-ID, exit-code-from (42
  and 143), port ranges, stdout/stderr separation (StderrContains joins
  the vocabulary) and logging-driver reconfiguration (now locking the
  Recreated expectation). The Ctrl-C dependency test stays legacy.
- Fixtures start-fail/, resources/, stop/, port-range/, stdout-stderr/,
  logging-driver/, profiles/ and most of dependencies/ and run-test/
  are removed.

Signed-off-by: Nicolas De Loof <[email protected]>
This commit is contained in:
Nicolas De Loof
2026-08-18 11:36:49 +02:00
parent 93d5433423
commit bc1fb24744
26 changed files with 513 additions and 622 deletions
+14
View File
@@ -88,6 +88,20 @@ func StdoutContains(sub string) Check {
}
}
// 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 {
return Check{
name: fmt.Sprintf("stderr contains %q", sub),
fn: func(ctx *CheckContext) error {
if !strings.Contains(ctx.result.Stderr(), sub) {
return fmt.Errorf("not found in stderr")
}
return nil
},
}
}
// OutputNotContains expects the command output not to contain a string.
func OutputNotContains(sub string) Check {
return Check{
+215 -216
View File
@@ -17,227 +17,235 @@
package e2e
import (
"os"
"strings"
"testing"
"time"
"gotest.tools/v3/assert"
"gotest.tools/v3/icmd"
"gotest.tools/v3/poll"
)
func TestLocalComposeRun(t *testing.T) {
func TestComposeRun(t *testing.T) {
s := NewScenario(t, "run must execute a one-off with the service command or an override, starting its dependencies")
s.Files(`
-- compose.yaml --
services:
back:
image: alpine
command: echo "Hello there!!"
depends_on:
- db
db:
image: alpine
init: true
command: sleep infinity
front:
image: alpine
init: true
command: sleep infinity
-- run.env --
FOO=BAR
`).
Step("run executes the service's own command and starts its dependency",
ComposeCmd("run", "back"),
StdoutContains("Hello there!!"),
OutputNotContains("orphan"),
ServiceState("db", "running"),
OneOffState("back", "exited"),
ServiceNotCreated("front")).
Step("run with an override command warns about the previous one-off, now an orphan",
ComposeCmd("run", "back", "echo", "Hello one more time"),
StdoutContains("Hello one more time"),
OutputContains("orphan")).
Step("COMPOSE_IGNORE_ORPHANS silences the warning",
ComposeCmd("run", "back", "echo", "Hello again").WithEnv("COMPOSE_IGNORE_ORPHANS=True"),
StdoutContains("Hello again"),
OutputNotContains("orphan")).
Step("run --rm leaves the earlier one-offs alone and removes its own container",
ComposeCmd("run", "--rm", "back", "echo", "Hello and gone").WithEnv("COMPOSE_IGNORE_ORPHANS=True"),
StdoutContains("Hello and gone"),
OneOffsUntouched("back")).
Step("run --volumes bind-mounts the requested host path",
ComposeCmd("run", "--volumes", s.Dir()+":/foo", "back", "/bin/sh", "-c", "ls /foo").WithEnv("COMPOSE_IGNORE_ORPHANS=True"),
StdoutContains("compose.yaml")).
Step("run --env-from-file injects the file's variables",
ComposeCmd("run", "--env-from-file", s.Dir()+"/run.env", "front", "env").WithEnv("COMPOSE_IGNORE_ORPHANS=True"),
StdoutContains("FOO=BAR")).
Step("run --env injects the variable",
ComposeCmd("run", "--env", "FOO=BAR", "front", "env").WithEnv("COMPOSE_IGNORE_ORPHANS=True"),
StdoutContains("FOO=BAR"))
}
func TestComposeRunPorts(t *testing.T) {
s := NewScenario(t, "run must only publish ports when asked: --publish for ad-hoc, --service-ports for the model's")
s.Compose(`
services:
back:
image: alpine
ports:
- 8082:80
`).
Step("run --publish maps the requested port, not the model's",
ComposeCmd("run", "--publish", "8081:80", "-d", "back", "/bin/sh", "-c", "sleep 30")).
Step("the ad-hoc mapping is live",
DockerCmd("ps", "--filter", "label=com.docker.compose.project="+s.Project()),
OutputContains("8081->80/tcp"),
OutputNotContains("8082->80/tcp")).
Step("run --service-ports maps the model's ports",
ComposeCmd("run", "--service-ports", "-d", "back", "/bin/sh", "-c", "sleep 30")).
Step("the model's mapping is live",
DockerCmd("ps", "--filter", "label=com.docker.compose.project="+s.Project()),
OutputContains("8082->80/tcp"))
}
func TestComposeRunDeps(t *testing.T) {
// Regression test for https://github.com/docker/compose/issues/9459
// run used to start other services of the project beyond the target's
// dependency chain.
NewScenario(t, "run must start the target's dependencies and nothing else, unless --no-deps").
Compose(`
services:
service_a:
image: bash
command: echo "a"
depends_on:
- shared_dep
service_b:
image: bash
command: echo "b"
depends_on:
- shared_dep
shared_dep:
image: bash
`).
Step("run starts the shared dependency but not the sibling service",
ComposeCmd("run", "service_a"),
OutputContains("shared_dep"),
OutputNotContains("service_b"),
ServiceNotCreated("service_b")).
Step("run --no-deps starts nothing but the one-off",
ComposeCmd("run", "--no-deps", "service_a").WithEnv("COMPOSE_IGNORE_ORPHANS=True"),
OutputNotContains("service_b"),
OutputNotContains("shared_dep"),
NotRecreated("shared_dep"),
ServiceNotCreated("service_b"))
}
func TestComposeRunNotRequiredDeps(t *testing.T) {
NewScenario(t, "run must skip a dependency marked required: false when its profile is inactive").
Compose(`
services:
foo:
image: bash
command: echo "foo"
depends_on:
bar:
required: false
condition: service_healthy
bar:
image: alpine
init: true
command: sleep infinity
profiles: [not-required]
`).
Step("run executes the service without materializing the optional dependency",
ComposeCmd("run", "foo"),
OutputContains("foo"),
ServiceNotCreated("bar"))
}
func TestComposeRunQuietPull(t *testing.T) {
NewScenario(t, "run --quiet-pull and COMPOSE_PROGRESS=quiet must silence pull progress at two levels").
Compose(`
services:
backend:
image: hello-world
`).
Step("start without the image locally",
ComposeCmd("down", "--rmi", "all")).
Step("--quiet-pull keeps the decision but drops the layer progress",
ComposeCmd("run", "--quiet-pull", "backend"),
OutputNotContains("Pull complete"),
OutputContains("Pulled")).
Step("remove the image again",
ComposeCmd("down", "--rmi", "all")).
Step("COMPOSE_PROGRESS=quiet silences the pull entirely",
ComposeCmd("run", "backend").WithEnv("COMPOSE_PROGRESS=quiet"),
OutputNotContains("Pull complete"),
OutputNotContains("Pulled"))
}
func TestComposeRunPullAlways(t *testing.T) {
NewScenario(t, "run --pull always must pull the image even when present locally").
Compose(`
services:
backend:
image: nginx
command: nginx -t
`).
Step("run reports the pull it was asked to always perform",
ComposeCmd("run", "--pull", "always", "backend"),
OutputContains("Image nginx Pulling"),
OutputContains("Image nginx Pulled"))
}
func TestComposeRunBuild(t *testing.T) {
s := NewScenario(t, "run must build a service whose image comes from another service's build context")
s.Compose(`
services:
build:
build:
dockerfile_inline: "FROM base"
additional_contexts:
base: "service:build_base"
build_base:
build:
dockerfile_inline: "FROM alpine"
`).
Defer(
DockerCmd("image", "rm", "-f", s.Project()+"-build").MayFail(),
DockerCmd("image", "rm", "-f", s.Project()+"-build_base").MayFail()).
Step("run builds the chained images and executes the command",
ComposeCmd("run", "build", "echo", "hello world"),
StdoutContains("hello world"))
}
func TestComposeRunRmStopSignal(t *testing.T) {
c := NewParallelCLI(t)
defer c.cleanupWithDown(t, "run-test")
t.Run("compose run", func(t *testing.T) {
res := c.RunDockerComposeCmd(t, "-f", "./fixtures/run-test/compose.yaml", "run", "back")
lines := Lines(res.Stdout())
assert.Equal(t, lines[len(lines)-1], "Hello there!!", res.Stdout())
assert.Assert(t, !strings.Contains(res.Combined(), "orphan"))
res = c.RunDockerComposeCmd(t, "-f", "./fixtures/run-test/compose.yaml", "run", "back", "echo",
"Hello one more time")
lines = Lines(res.Stdout())
assert.Equal(t, lines[len(lines)-1], "Hello one more time", res.Stdout())
assert.Assert(t, strings.Contains(res.Combined(), "orphan"))
projectName := "run-test"
t.Cleanup(func() {
c.cleanupWithDown(t, projectName)
})
t.Run("check run container exited", func(t *testing.T) {
res := c.RunDockerCmd(t, "ps", "--all")
lines := Lines(res.Stdout())
var runContainerID string
var truncatedSlug string
for _, line := range lines {
fields := strings.Fields(line)
containerID := fields[len(fields)-1]
assert.Assert(t, !strings.HasPrefix(containerID, "run-test-front"))
if strings.HasPrefix(containerID, "run-test-back") {
// only the one-off container for back service
assert.Assert(t, strings.HasPrefix(containerID, "run-test-back-run-"), containerID)
truncatedSlug = strings.Replace(containerID, "run-test-back-run-", "", 1)
runContainerID = containerID
}
if strings.HasPrefix(containerID, "run-test-db-1") {
assert.Assert(t, strings.Contains(line, "Up"), line)
}
res := c.RunDockerComposeCmd(t, "--project-name", projectName, "-f", "./fixtures/ps-test/compose.yaml", "run", "--rm", "-d", "nginx")
res.Assert(t, icmd.Success)
res = c.RunDockerCmd(t, "ps", "--quiet", "--filter", "name=run-test-nginx")
containerID := strings.TrimSpace(res.Stdout())
res = c.RunDockerCmd(t, "stop", containerID)
res.Assert(t, icmd.Success)
// --rm auto-removal is async, wait for the container to be removed
poll.WaitOn(t, func(l poll.LogT) poll.Result {
res = c.RunDockerCmd(t, "ps", "--all", "--filter", "name=run-test-nginx", "--format", "'{{.Names}}'")
if strings.Contains(res.Stdout(), "run-test-nginx") {
return poll.Continue("container still present: %s", res.Stdout())
}
assert.Assert(t, runContainerID != "")
res = c.RunDockerCmd(t, "inspect", runContainerID)
res.Assert(t, icmd.Expected{Out: ` "Status": "exited"`})
res.Assert(t, icmd.Expected{Out: `"com.docker.compose.project": "run-test"`})
res.Assert(t, icmd.Expected{Out: `"com.docker.compose.oneoff": "True",`})
res.Assert(t, icmd.Expected{Out: `"com.docker.compose.slug": "` + truncatedSlug})
})
return poll.Success()
}, poll.WithTimeout(10*time.Second), poll.WithDelay(500*time.Millisecond))
}
t.Run("compose run --rm", func(t *testing.T) {
res := c.RunDockerComposeCmd(t, "-f", "./fixtures/run-test/compose.yaml", "run", "--rm", "back", "echo",
"Hello again")
lines := Lines(res.Stdout())
assert.Equal(t, lines[len(lines)-1], "Hello again", res.Stdout())
res = c.RunDockerCmd(t, "ps", "--all")
assert.Assert(t, strings.Contains(res.Stdout(), "run-test-back"), res.Stdout())
})
t.Run("down", func(t *testing.T) {
c.RunDockerComposeCmd(t, "-f", "./fixtures/run-test/compose.yaml", "down", "--remove-orphans")
res := c.RunDockerCmd(t, "ps", "--all")
assert.Assert(t, !strings.Contains(res.Stdout(), "run-test"), res.Stdout())
})
t.Run("compose run --volumes", func(t *testing.T) {
wd, err := os.Getwd()
assert.NilError(t, err)
res := c.RunDockerComposeCmd(t, "-f", "./fixtures/run-test/compose.yaml", "run", "--volumes", wd+":/foo",
"back", "/bin/sh", "-c", "ls /foo")
res.Assert(t, icmd.Expected{Out: "compose_run_test.go"})
res = c.RunDockerCmd(t, "ps", "--all")
assert.Assert(t, strings.Contains(res.Stdout(), "run-test-back"), res.Stdout())
})
t.Run("compose run --publish", func(t *testing.T) {
c.RunDockerComposeCmd(t, "-f", "./fixtures/run-test/ports.yaml", "run", "--publish", "8081:80", "-d", "back",
"/bin/sh", "-c", "sleep 1")
res := c.RunDockerCmd(t, "ps")
assert.Assert(t, strings.Contains(res.Stdout(), "8081->80/tcp"), res.Stdout())
assert.Assert(t, !strings.Contains(res.Stdout(), "8082->80/tcp"), res.Stdout())
})
t.Run("compose run --service-ports", func(t *testing.T) {
c.RunDockerComposeCmd(t, "-f", "./fixtures/run-test/ports.yaml", "run", "--service-ports", "-d", "back",
"/bin/sh", "-c", "sleep 1")
res := c.RunDockerCmd(t, "ps")
assert.Assert(t, strings.Contains(res.Stdout(), "8082->80/tcp"), res.Stdout())
})
t.Run("compose run orphan", func(t *testing.T) {
// Use different compose files to get an orphan container
c.RunDockerComposeCmd(t, "-f", "./fixtures/run-test/orphan.yaml", "run", "simple")
res := c.RunDockerComposeCmd(t, "-f", "./fixtures/run-test/compose.yaml", "run", "back", "echo", "Hello")
assert.Assert(t, strings.Contains(res.Combined(), "orphan"))
cmd := c.NewDockerComposeCmd(t, "-f", "./fixtures/run-test/compose.yaml", "run", "back", "echo", "Hello")
res = icmd.RunCmd(cmd, func(cmd *icmd.Cmd) {
cmd.Env = append(cmd.Env, "COMPOSE_IGNORE_ORPHANS=True")
})
assert.Assert(t, !strings.Contains(res.Combined(), "orphan"))
})
t.Run("down", func(t *testing.T) {
cmd := c.NewDockerComposeCmd(t, "-f", "./fixtures/run-test/compose.yaml", "down")
icmd.RunCmd(cmd, func(c *icmd.Cmd) {
c.Env = append(c.Env, "COMPOSE_REMOVE_ORPHANS=True")
})
res := c.RunDockerCmd(t, "ps", "--all")
assert.Assert(t, !strings.Contains(res.Stdout(), "run-test"), res.Stdout())
})
t.Run("run starts only container and dependencies", func(t *testing.T) {
// ensure that even if another service is up run does not start it: https://github.com/docker/compose/issues/9459
res := c.RunDockerComposeCmd(t, "-f", "./fixtures/run-test/deps.yaml", "up", "service_b", "--menu=false")
res.Assert(t, icmd.Success)
res = c.RunDockerComposeCmd(t, "-f", "./fixtures/run-test/deps.yaml", "run", "service_a")
assert.Assert(t, strings.Contains(res.Combined(), "shared_dep"), res.Combined())
assert.Assert(t, !strings.Contains(res.Combined(), "service_b"), res.Combined())
c.RunDockerComposeCmd(t, "-f", "./fixtures/run-test/deps.yaml", "down", "--remove-orphans")
})
t.Run("run without dependencies", func(t *testing.T) {
res := c.RunDockerComposeCmd(t, "-f", "./fixtures/run-test/deps.yaml", "run", "--no-deps", "service_a")
assert.Assert(t, !strings.Contains(res.Combined(), "shared_dep"), res.Combined())
assert.Assert(t, !strings.Contains(res.Combined(), "service_b"), res.Combined())
c.RunDockerComposeCmd(t, "-f", "./fixtures/run-test/deps.yaml", "down", "--remove-orphans")
})
t.Run("run with not required dependency", func(t *testing.T) {
res := c.RunDockerComposeCmd(t, "-f", "./fixtures/dependencies/deps-not-required.yaml", "run", "foo")
assert.Assert(t, strings.Contains(res.Combined(), "foo"), res.Combined())
assert.Assert(t, !strings.Contains(res.Combined(), "bar"), res.Combined())
c.RunDockerComposeCmd(t, "-f", "./fixtures/dependencies/deps-not-required.yaml", "down", "--remove-orphans")
})
t.Run("--quiet-pull", func(t *testing.T) {
res := c.RunDockerComposeCmd(t, "-f", "./fixtures/run-test/quiet-pull.yaml", "down", "--remove-orphans", "--rmi", "all")
res.Assert(t, icmd.Success)
res = c.RunDockerComposeCmd(t, "-f", "./fixtures/run-test/quiet-pull.yaml", "run", "--quiet-pull", "backend")
assert.Assert(t, !strings.Contains(res.Combined(), "Pull complete"), res.Combined())
assert.Assert(t, strings.Contains(res.Combined(), "Pulled"), res.Combined())
})
t.Run("COMPOSE_PROGRESS quiet", func(t *testing.T) {
res := c.RunDockerComposeCmd(t, "-f", "./fixtures/run-test/quiet-pull.yaml", "down", "--remove-orphans", "--rmi", "all")
res.Assert(t, icmd.Success)
cmd := c.NewDockerComposeCmd(t, "-f", "./fixtures/run-test/quiet-pull.yaml", "run", "backend")
res = icmd.RunCmd(cmd, func(c *icmd.Cmd) {
c.Env = append(c.Env, "COMPOSE_PROGRESS=quiet")
})
assert.Assert(t, !strings.Contains(res.Combined(), "Pull complete"), res.Combined())
assert.Assert(t, !strings.Contains(res.Combined(), "Pulled"), res.Combined())
})
t.Run("--pull", func(t *testing.T) {
res := c.RunDockerComposeCmd(t, "-f", "./fixtures/run-test/pull.yaml", "down", "--remove-orphans", "--rmi", "all")
res.Assert(t, icmd.Success)
res = c.RunDockerComposeCmd(t, "-f", "./fixtures/run-test/pull.yaml", "run", "--pull", "always", "backend")
assert.Assert(t, strings.Contains(res.Combined(), "Image nginx Pulling"), res.Combined())
assert.Assert(t, strings.Contains(res.Combined(), "Image nginx Pulled"), res.Combined())
})
t.Run("compose run --env-from-file", func(t *testing.T) {
res := c.RunDockerComposeCmd(t, "-f", "./fixtures/run-test/compose.yaml", "run", "--env-from-file", "./fixtures/run-test/run.env",
"front", "env")
res.Assert(t, icmd.Expected{Out: "FOO=BAR"})
})
t.Run("compose run -rm with stop signal", func(t *testing.T) {
projectName := "run-test"
res := c.RunDockerComposeCmd(t, "--project-name", projectName, "-f", "./fixtures/ps-test/compose.yaml", "run", "--rm", "-d", "nginx")
res.Assert(t, icmd.Success)
res = c.RunDockerCmd(t, "ps", "--quiet", "--filter", "name=run-test-nginx")
containerID := strings.TrimSpace(res.Stdout())
res = c.RunDockerCmd(t, "stop", containerID)
res.Assert(t, icmd.Success)
// --rm auto-removal is async, wait for the container to be removed
poll.WaitOn(t, func(l poll.LogT) poll.Result {
res = c.RunDockerCmd(t, "ps", "--all", "--filter", "name=run-test-nginx", "--format", "'{{.Names}}'")
if strings.Contains(res.Stdout(), "run-test-nginx") {
return poll.Continue("container still present: %s", res.Stdout())
}
return poll.Success()
}, poll.WithTimeout(10*time.Second), poll.WithDelay(500*time.Millisecond))
})
t.Run("compose run --env", func(t *testing.T) {
res := c.RunDockerComposeCmd(t, "-f", "./fixtures/run-test/compose.yaml", "run", "--env", "FOO=BAR",
"front", "env")
res.Assert(t, icmd.Expected{Out: "FOO=BAR"})
})
t.Run("compose run --build", func(t *testing.T) {
c.cleanupWithDown(t, "run-test", "--rmi=local")
res := c.RunDockerComposeCmd(t, "-f", "./fixtures/run-test/compose.yaml", "run", "build", "echo", "hello world")
res.Assert(t, icmd.Expected{Out: "hello world"})
})
func TestComposeRunPipedInput(t *testing.T) {
if composeStandaloneMode {
t.Skip("Skipping test compose with piped input detection in standalone mode")
}
c := NewParallelCLI(t)
defer c.cleanupWithDown(t, "run-piped-test")
t.Run("compose run with piped input detection", func(t *testing.T) {
if composeStandaloneMode {
t.Skip("Skipping test compose with piped input detection in standalone mode")
}
// Test that piped input is properly detected and TTY is automatically disabled
// This tests the logic added in run.go that checks dockerCli.In().IsTerminal()
cmd := c.NewCmd("sh", "-c", "echo 'piped-content' | docker compose -f ./fixtures/run-test/piped-test.yaml run --rm piped-test")
cmd := c.NewCmd("sh", "-c", "echo 'piped-content' | docker compose -p run-piped-test -f ./fixtures/run-test/piped-test.yaml run --rm piped-test")
res := icmd.RunCmd(cmd)
res.Assert(t, icmd.Expected{Out: "piped-content"})
@@ -245,12 +253,9 @@ func TestLocalComposeRun(t *testing.T) {
})
t.Run("compose run piped input should not allocate TTY", func(t *testing.T) {
if composeStandaloneMode {
t.Skip("Skipping test compose with piped input detection in standalone mode")
}
// Test that when stdin is piped, the container correctly detects no TTY
// This verifies that the automatic noTty=true setting works correctly
cmd := c.NewCmd("sh", "-c", "echo '' | docker compose -f ./fixtures/run-test/piped-test.yaml run --rm tty-test")
cmd := c.NewCmd("sh", "-c", "echo '' | docker compose -p run-piped-test -f ./fixtures/run-test/piped-test.yaml run --rm tty-test")
res := icmd.RunCmd(cmd)
res.Assert(t, icmd.Expected{Out: "No TTY detected"})
@@ -258,12 +263,9 @@ func TestLocalComposeRun(t *testing.T) {
})
t.Run("compose run piped input with explicit --tty should fail", func(t *testing.T) {
if composeStandaloneMode {
t.Skip("Skipping test compose with piped input detection in standalone mode")
}
// Test that explicitly requesting TTY with piped input fails with proper error message
// This should trigger the "input device is not a TTY" error
cmd := c.NewCmd("sh", "-c", "echo 'test' | docker compose -f ./fixtures/run-test/piped-test.yaml run --rm --tty piped-test")
cmd := c.NewCmd("sh", "-c", "echo 'test' | docker compose -p run-piped-test -f ./fixtures/run-test/piped-test.yaml run --rm --tty piped-test")
res := icmd.RunCmd(cmd)
res.Assert(t, icmd.Expected{
@@ -273,12 +275,9 @@ func TestLocalComposeRun(t *testing.T) {
})
t.Run("compose run piped input with --no-tty=false should fail", func(t *testing.T) {
if composeStandaloneMode {
t.Skip("Skipping test compose with piped input detection in standalone mode")
}
// Test that explicitly disabling --no-tty (i.e., requesting TTY) with piped input fails
// This should also trigger the "input device is not a TTY" error
cmd := c.NewCmd("sh", "-c", "echo 'test' | docker compose -f ./fixtures/run-test/piped-test.yaml run --rm --no-tty=false piped-test")
cmd := c.NewCmd("sh", "-c", "echo 'test' | docker compose -p run-piped-test -f ./fixtures/run-test/piped-test.yaml run --rm --no-tty=false piped-test")
res := icmd.RunCmd(cmd)
res.Assert(t, icmd.Expected{
+127 -69
View File
@@ -20,95 +20,153 @@ import (
"strings"
"testing"
"time"
"gotest.tools/v3/assert"
"gotest.tools/v3/icmd"
)
func TestUpWait(t *testing.T) {
c := NewParallelCLI(t)
const projectName = "e2e-deps-wait"
timeout := time.After(30 * time.Second)
done := make(chan bool)
go func() {
//nolint:nolintlint,testifylint // helper asserts inside goroutine; acceptable in this e2e test
res := c.RunDockerComposeCmd(t, "-f", "fixtures/dependencies/deps-completed-successfully.yaml", "--project-name", projectName, "up", "--wait", "-d")
assert.Assert(t, strings.Contains(res.Combined(), "e2e-deps-wait-oneshot-1"), res.Combined())
done <- true
}()
select {
case <-timeout:
t.Fatal("test did not finish in time")
case <-done:
break
}
c.RunDockerComposeCmd(t, "--project-name", projectName, "down")
s := NewScenario(t, "up --wait must return once dependencies completed and services run")
s.Compose(`
services:
oneshot:
image: alpine
command: echo 'hello world'
longrunning:
image: alpine
init: true
depends_on:
oneshot:
condition: service_completed_successfully
command: sleep infinity
`).
Step("up --wait returns with the long-running service up and the oneshot completed",
ComposeCmd("up", "--wait", "-d").Within(30*time.Second),
OutputContains(s.Project()+"-oneshot-1"),
ServiceState("longrunning", "running"),
ServiceState("oneshot", "exited"))
}
const exitCodeFromCompose = `
services:
safe:
image: 'alpine'
init: true
command: ['/bin/sh', '-c', 'sleep infinity'] # never exiting
failure:
image: 'alpine'
init: true
command: ['/bin/sh', '-c', 'sleep 1 ; echo "exiting with error" ; exit 42']
test:
image: 'alpine'
init: true
command: ['/bin/sh', '-c', 'sleep 99999 ; echo "tests are OK"'] # very long job
depends_on: [safe]
`
func TestUpExitCodeFrom(t *testing.T) {
c := NewParallelCLI(t)
const projectName = "e2e-exit-code-from"
res := c.RunDockerComposeCmdNoCheck(t, "-f", "fixtures/start-fail/start-depends_on-long-lived.yaml", "--project-name", projectName, "up", "--menu=false", "--exit-code-from=failure", "failure")
res.Assert(t, icmd.Expected{ExitCode: 42})
c.RunDockerComposeCmd(t, "--project-name", projectName, "down", "--remove-orphans")
NewScenario(t, "up --exit-code-from must return the selected service's exit code").
Compose(exitCodeFromCompose).
Step("up returns the failing service's code once it exits",
ComposeCmd("up", "--menu=false", "--exit-code-from=failure", "failure").MayFail().Within(60*time.Second),
ExitCode(42))
}
func TestUpExitCodeFromContainerKilled(t *testing.T) {
c := NewParallelCLI(t)
const projectName = "e2e-exit-code-from-kill"
res := c.RunDockerComposeCmdNoCheck(t, "-f", "fixtures/start-fail/start-depends_on-long-lived.yaml", "--project-name", projectName, "up", "--menu=false", "--exit-code-from=test")
res.Assert(t, icmd.Expected{ExitCode: 143})
c.RunDockerComposeCmd(t, "--project-name", projectName, "down", "--remove-orphans")
NewScenario(t, "up --exit-code-from must report 143 for a service stopped by the abort").
Compose(exitCodeFromCompose).
Step("the watched long-lived service is stopped when another exits",
ComposeCmd("up", "--menu=false", "--exit-code-from=test").MayFail().Within(60*time.Second),
ExitCode(143))
}
func TestPortRange(t *testing.T) {
c := NewParallelCLI(t)
const projectName = "e2e-port-range"
NewScenario(t, "a published port range must accommodate scaled replicas and single ports alike").
Compose(`
services:
a:
image: alpine
init: true
command: sleep infinity
scale: 5
ports:
- "6005-6015:80"
reset := func() {
c.RunDockerComposeCmd(t, "--project-name", projectName, "down", "--remove-orphans", "--timeout=0")
}
reset()
t.Cleanup(reset)
b:
image: alpine
init: true
command: sleep infinity
ports:
- 80
res := c.RunDockerComposeCmdNoCheck(t, "-f", "fixtures/port-range/compose.yaml", "--project-name", projectName, "up", "-d")
res.Assert(t, icmd.Success)
c:
image: alpine
init: true
command: sleep infinity
ports:
- 80
`).
Step("up binds every replica within the range",
ComposeCmd("up", "-d"),
ServiceScale("a", 5),
ServiceState("b", "running"),
ServiceState("c", "running"))
}
func TestStdoutStderr(t *testing.T) {
c := NewParallelCLI(t)
const projectName = "e2e-stdout-stderr"
res := c.RunDockerComposeCmdNoCheck(t, "-f", "fixtures/stdout-stderr/compose.yaml", "--project-name", projectName, "up", "--menu=false")
res.Assert(t, icmd.Expected{Out: "log to stdout", Err: "log to stderr"})
c.RunDockerComposeCmd(t, "--project-name", projectName, "down", "--remove-orphans")
NewScenario(t, "up must relay each container stream to its own: stdout to stdout, stderr to stderr").
Files(`
-- compose.yaml --
services:
stderr:
image: alpine
init: true
command: /bin/ash /log_to_stderr.sh
volumes:
- ./log_to_stderr.sh:/log_to_stderr.sh
-- log_to_stderr.sh --
>&2 echo "log to stderr"
echo "log to stdout"
`).
Step("the two streams arrive separated",
ComposeCmd("up", "--menu=false"),
StdoutContains("log to stdout"),
StderrContains("log to stderr"))
}
func TestLoggingDriver(t *testing.T) {
c := NewCLI(t)
const projectName = "e2e-logging-driver"
defer c.cleanupWithDown(t, projectName)
host := "HOST=127.0.0.1"
res := c.RunDockerCmd(t, "info", "-f", "{{.OperatingSystem}}")
os := res.Stdout()
if strings.TrimSpace(os) == "Docker Desktop" {
host = "HOST=host.docker.internal"
s := NewScenario(t, "a logging-driver address change must reconfigure the service on the next up")
host := "127.0.0.1"
if strings.Contains(s.CLI().RunDockerCmd(t, "info", "-f", "{{.OperatingSystem}}").Stdout(), "Docker Desktop") {
host = "host.docker.internal"
}
s.Compose(`
services:
fluentbit:
image: fluent/fluent-bit:3.1.7-debug
ports:
- "24224:24224"
- "24224:24224/udp"
environment:
FOO: ${BAR}
cmd := c.NewDockerComposeCmd(t, "-f", "fixtures/logging-driver/compose.yaml", "--project-name", projectName, "up", "-d")
cmd.Env = append(cmd.Env, host, "BAR=foo")
icmd.RunCmd(cmd).Assert(t, icmd.Success)
cmd = c.NewDockerComposeCmd(t, "-f", "fixtures/logging-driver/compose.yaml", "--project-name", projectName, "up", "-d")
cmd.Env = append(cmd.Env, host, "BAR=zot")
icmd.RunCmd(cmd).Assert(t, icmd.Success)
app:
image: alpine
init: true
command: sleep infinity
depends_on:
fluentbit:
condition: service_started
restart: true
logging:
driver: fluentd
options:
fluentd-address: ${HOST:-127.0.0.1}:24224
`).
Env("HOST="+host).
Step("up starts the log collector and the app",
ComposeCmd("up", "-d").WithEnv("BAR=foo"),
ServiceState("fluentbit", "running"),
ServiceState("app", "running")).
Step("a collector config change is applied by recreating it",
ComposeCmd("up", "-d").WithEnv("BAR=zot"),
Recreated("fluentbit"),
ServiceState("app", "running"))
}
-16
View File
@@ -1,16 +0,0 @@
# 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 busybox:1.35.0
RUN echo "hello"
@@ -1,10 +0,0 @@
services:
web:
image: nginx:alpine
depends_on:
db:
condition: service_healthy
db:
image: alpine
command: sh -c "exit 1"
@@ -1,11 +0,0 @@
services:
oneshot:
image: alpine
command: echo 'hello world'
longrunning:
image: alpine
init: true
depends_on:
oneshot:
condition: service_completed_successfully
command: sleep infinity
@@ -1,11 +0,0 @@
services:
foo:
image: bash
command: echo "foo"
depends_on:
bar:
required: false
condition: service_healthy
bar:
image: nginx:alpine
profiles: [not-required]
@@ -1,9 +0,0 @@
services:
foo:
image: built-image-dependency
build:
context: .
bar:
image: built-image-dependency
depends_on:
- foo
@@ -1,19 +0,0 @@
services:
fluentbit:
image: fluent/fluent-bit:3.1.7-debug
ports:
- "24224:24224"
- "24224:24224/udp"
environment:
FOO: ${BAR}
app:
image: nginx
depends_on:
fluentbit:
condition: service_started
restart: true
logging:
driver: fluentd
options:
fluentd-address: ${HOST:-127.0.0.1}:24224
-16
View File
@@ -1,16 +0,0 @@
services:
a:
image: nginx:alpine
scale: 5
ports:
- "6005-6015:80"
b:
image: nginx:alpine
ports:
- 80
c:
image: nginx:alpine
ports:
- 80
@@ -1,15 +0,0 @@
services:
foo:
container_name: foo_c
profiles: [ test ]
image: alpine
depends_on: [ db ]
bar:
container_name: bar_c
profiles: [ test ]
image: alpine
db:
container_name: db_c
image: alpine
-5
View File
@@ -1,5 +0,0 @@
volumes:
my_vol: {}
networks:
my_net: {}
-31
View File
@@ -1,31 +0,0 @@
services:
back:
image: alpine
command: echo "Hello there!!"
depends_on:
- db
networks:
- backnet
db:
image: nginx:alpine
networks:
- backnet
volumes:
- data:/test
front:
image: nginx:alpine
networks:
- frontnet
build:
build:
dockerfile_inline: "FROM base"
additional_contexts:
base: "service:build_base"
build_base:
build:
dockerfile_inline: "FROM alpine"
networks:
frontnet:
backnet:
volumes:
data:
-13
View File
@@ -1,13 +0,0 @@
services:
service_a:
image: bash
command: echo "a"
depends_on:
- shared_dep
service_b:
image: bash
command: echo "b"
depends_on:
- shared_dep
shared_dep:
image: bash
-4
View File
@@ -1,4 +0,0 @@
services:
simple:
image: alpine
command: echo "Hi there!!"
-5
View File
@@ -1,5 +0,0 @@
services:
back:
image: alpine
ports:
- 8082:80
-4
View File
@@ -1,4 +0,0 @@
services:
backend:
image: nginx
command: nginx -t
@@ -1,3 +0,0 @@
services:
backend:
image: hello-world
-1
View File
@@ -1 +0,0 @@
FOO=BAR
@@ -1,3 +0,0 @@
services:
test:
image: ${ID:?ID variable must be set}
-16
View File
@@ -1,16 +0,0 @@
services:
fail:
image: alpine
init: true
command: sleep infinity
healthcheck:
test: "false"
interval: 1s
retries: 3
depends:
image: alpine
init: true
command: sleep infinity
depends_on:
fail:
condition: service_healthy
@@ -1,14 +0,0 @@
services:
safe:
image: 'alpine'
init: true
command: ['/bin/sh', '-c', 'sleep infinity'] # never exiting
failure:
image: 'alpine'
init: true
command: ['/bin/sh', '-c', 'sleep 1 ; echo "exiting with error" ; exit 42']
test:
image: 'alpine'
init: true
command: ['/bin/sh', '-c', 'sleep 99999 ; echo "tests are OK"'] # very long job
depends_on: [safe]
@@ -1,7 +0,0 @@
services:
stderr:
image: alpine
init: true
command: /bin/ash /log_to_stderr.sh
volumes:
- ./log_to_stderr.sh:/log_to_stderr.sh
@@ -1,16 +0,0 @@
# 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.
>&2 echo "log to stderr"
echo "log to stdout"
-9
View File
@@ -1,9 +0,0 @@
services:
service1:
image: alpine
command: /bin/true
service2:
image: alpine
command: ping -c 2 localhost
pre_stop:
- command: echo "stop hook running..."
+157 -99
View File
@@ -21,7 +21,6 @@ package e2e
import (
"context"
"errors"
"fmt"
"os/exec"
"strings"
"syscall"
@@ -29,19 +28,35 @@ import (
"time"
"gotest.tools/v3/assert"
"gotest.tools/v3/icmd"
"github.com/docker/compose/v5/pkg/utils"
)
func TestUpServiceUnhealthy(t *testing.T) {
c := NewParallelCLI(t)
const projectName = "e2e-start-fail"
res := c.RunDockerComposeCmdNoCheck(t, "-f", "fixtures/start-fail/compose.yaml", "--project-name", projectName, "up", "-d")
res.Assert(t, icmd.Expected{ExitCode: 1, Err: `container e2e-start-fail-fail-1 is unhealthy`})
c.RunDockerComposeCmd(t, "--project-name", projectName, "down")
s := NewScenario(t, "up must fail when a service never turns healthy")
s.Compose(`
services:
fail:
image: alpine
init: true
command: sleep infinity
healthcheck:
test: "false"
interval: 1s
retries: 3
depends:
image: alpine
init: true
command: sleep infinity
depends_on:
fail:
condition: service_healthy
`).
Step("up reports the unhealthy container and fails",
ComposeCmd("up", "-d").MayFail().Within(60*time.Second),
ExitCode(1),
OutputContains("container "+s.Project()+"-fail-1 is unhealthy"),
ServiceState("depends", "created"))
}
func TestUpDependenciesNotStopped(t *testing.T) {
@@ -107,118 +122,161 @@ func TestUpDependenciesNotStopped(t *testing.T) {
}
func TestUpWithBuildDependencies(t *testing.T) {
c := NewParallelCLI(t)
t.Run("up with service using image build by an another service", func(t *testing.T) {
// ensure local test run does not reuse previously build image
c.RunDockerOrExitError(t, "rmi", "built-image-dependency")
res := c.RunDockerComposeCmd(t, "--project-directory", "fixtures/dependencies",
"-f", "fixtures/dependencies/service-image-depends-on.yaml", "up", "-d")
t.Cleanup(func() {
c.RunDockerComposeCmd(t, "--project-directory", "fixtures/dependencies",
"-f", "fixtures/dependencies/service-image-depends-on.yaml", "down", "--rmi", "all")
})
res.Assert(t, icmd.Success)
})
s := NewScenario(t, "up must build a service's image before starting another service that reuses it")
image := s.Project() + "-built"
s.Files(`
-- compose.yaml --
services:
foo:
image: ${BUILT_IMAGE}
build:
context: .
bar:
image: ${BUILT_IMAGE}
depends_on:
- foo
-- Dockerfile --
FROM busybox:1.35.0
RUN echo "hello"
`).
Env("BUILT_IMAGE="+image).
Defer(DockerCmd("image", "rm", "-f", image).MayFail()).
Step("up builds once and starts both services from the built image",
ComposeCmd("up", "-d"),
ImageExists(image))
}
func TestUpWithDependencyExit(t *testing.T) {
c := NewParallelCLI(t)
t.Run("up with dependency to exit before being healthy", func(t *testing.T) {
res := c.RunDockerComposeCmdNoCheck(t, "--project-directory", "fixtures/dependencies",
"-f", "fixtures/dependencies/dependency-exit.yaml", "up", "-d")
t.Cleanup(func() {
c.RunDockerComposeCmd(t, "--project-name", "dependencies", "down")
})
res.Assert(t, icmd.Expected{ExitCode: 1, Err: "dependency failed to start: container dependencies-db-1 exited (1)"})
})
s := NewScenario(t, "up must fail when a dependency exits before turning healthy")
s.Compose(`
services:
web:
image: alpine
init: true
command: sleep infinity
depends_on:
db:
condition: service_healthy
db:
image: alpine
command: sh -c "exit 1"
`).
Step("up reports the exited dependency and fails",
ComposeCmd("up", "-d").MayFail(),
ExitCode(1),
OutputContains("dependency failed to start: container "+s.Project()+"-db-1 exited (1)"),
ServiceState("web", "created"))
}
func TestScaleDoesntRecreate(t *testing.T) {
c := NewCLI(t)
const projectName = "compose-e2e-scale"
t.Cleanup(func() {
c.RunDockerComposeCmd(t, "--project-name", projectName, "down")
})
c.RunDockerComposeCmd(t, "-f", "fixtures/simple-composefile/compose.yaml", "--project-name", projectName, "up", "-d")
res := c.RunDockerComposeCmd(t, "-f", "fixtures/simple-composefile/compose.yaml", "--project-name", projectName, "up", "--scale", "simple=2", "-d")
assert.Check(t, !strings.Contains(res.Combined(), "Recreated"))
NewScenario(t, "scaling up must add a replica without recreating the existing one").
Compose(`
services:
simple:
image: alpine
init: true
command: sleep infinity
`).
Step("up starts the first replica",
ComposeCmd("up", "-d"),
ReplicaNumbers("simple", 1)).
Step("up --scale adds the second replica, keeping the first",
ComposeCmd("up", "--scale", "simple=2", "-d"),
ReplicaNumbers("simple", 1, 2),
OutputNotContains("Recreated"))
}
func TestUpWithDependencyNotRequired(t *testing.T) {
c := NewCLI(t)
const projectName = "compose-e2e-dependency-not-required"
t.Cleanup(func() {
c.RunDockerComposeCmd(t, "--project-name", projectName, "down")
})
res := c.RunDockerComposeCmd(t, "-f", "./fixtures/dependencies/deps-not-required.yaml", "--project-name", projectName,
"--profile", "not-required", "up", "-d")
assert.Assert(t, strings.Contains(res.Combined(), "foo"), res.Combined())
assert.Assert(t, strings.Contains(res.Combined(), " optional dependency \"bar\" failed to start"), res.Combined())
NewScenario(t, "up must start the service even when an optional dependency cannot").
Compose(`
services:
foo:
image: bash
command: echo "foo"
depends_on:
bar:
required: false
condition: service_healthy
bar:
image: alpine
init: true
command: sleep infinity
profiles: [not-required]
`).
Step("up succeeds, reporting the optional dependency failure",
ComposeCmd("--profile", "not-required", "up", "-d"),
OutputContains("foo"),
OutputContains(`optional dependency "bar" failed to start`))
}
func TestUpWithAllResources(t *testing.T) {
c := NewCLI(t)
const projectName = "compose-e2e-all-resources"
t.Cleanup(func() {
c.RunDockerComposeCmd(t, "--project-name", projectName, "down", "-v")
})
s := NewScenario(t, "up --all-resources must create volumes and networks no service uses")
s.Compose(`
volumes:
my_vol: {}
res := c.RunDockerComposeCmd(t, "-f", "./fixtures/resources/compose.yaml", "--all-resources", "--project-name", projectName, "up")
assert.Assert(t, strings.Contains(res.Combined(), fmt.Sprintf(`Volume %s_my_vol Created`, projectName)), res.Combined())
assert.Assert(t, strings.Contains(res.Combined(), fmt.Sprintf(`Network %s_my_net Created`, projectName)), res.Combined())
networks:
my_net: {}
`).
Step("up creates the unused volume and network",
ComposeCmd("--all-resources", "up"),
OutputContains("Volume "+s.Project()+"_my_vol Created"),
OutputContains("Network "+s.Project()+"_my_net Created"))
}
func TestUpProfile(t *testing.T) {
c := NewCLI(t)
const projectName = "compose-e2e-up-profile"
t.Cleanup(func() {
c.RunDockerComposeCmd(t, "--project-name", projectName, "--profile", "test", "down", "-v")
})
NewScenario(t, "up on a profiled service must start it and its dependencies, not its profile siblings").
Compose(`
services:
foo:
profiles: [ test ]
image: alpine
depends_on: [ db ]
res := c.RunDockerComposeCmd(t, "-f", "./fixtures/profiles/docker-compose.yaml", "--project-name", projectName, "up", "foo")
assert.Assert(t, strings.Contains(res.Combined(), `Container db_c Created`), res.Combined())
assert.Assert(t, strings.Contains(res.Combined(), `Container foo_c Created`), res.Combined())
assert.Assert(t, !strings.Contains(res.Combined(), `Container bar_c Created`), res.Combined())
bar:
profiles: [ test ]
image: alpine
db:
image: alpine
`).
Step("up starts the target and its dependency only",
ComposeCmd("up", "foo"),
ServiceState("foo", "exited"),
ServiceState("db", "exited"),
ServiceNotCreated("bar"))
}
func TestUpImageID(t *testing.T) {
c := NewCLI(t)
const projectName = "compose-e2e-up-image-id"
digest := strings.TrimSpace(c.RunDockerCmd(t, "image", "inspect", "alpine", "-f", "{{ .ID }}").Stdout())
s := NewScenario(t, "a service image referenced by its bare ID must be usable")
digest := strings.TrimSpace(s.CLI().RunDockerCmd(t, "image", "inspect", "alpine", "-f", "{{ .ID }}").Stdout())
_, id, _ := strings.Cut(digest, ":")
t.Cleanup(func() {
c.RunDockerComposeCmd(t, "--project-name", projectName, "down", "-v")
})
c = NewCLI(t, WithEnv(fmt.Sprintf("ID=%s", id)))
c.RunDockerComposeCmd(t, "-f", "./fixtures/simple-composefile/id.yaml", "--project-name", projectName, "up")
s.Compose(`
services:
test:
image: ${ID:?ID variable must be set}
`).
Env("ID="+id).
Step("up runs the container from the image ID",
ComposeCmd("up"))
}
func TestUpStopWithLogsMixed(t *testing.T) {
c := NewCLI(t)
const projectName = "compose-e2e-stop-logs"
t.Cleanup(func() {
c.RunDockerComposeCmd(t, "--project-name", projectName, "down", "-v")
})
res := c.RunDockerComposeCmd(t, "-f", "./fixtures/stop/compose.yaml", "--project-name", projectName, "up", "--abort-on-container-exit")
// assert we still get service2 logs after service 1 Stopped event
res.Assert(t, icmd.Expected{
Err: "Container compose-e2e-stop-logs-service1-1 Stopped",
})
// assert we get stop hook logs
res.Assert(t, icmd.Expected{Out: "service2-1 -> | stop hook running...\nservice2-1 | 64 bytes"})
s := NewScenario(t, "on abort, logs of surviving services must keep flowing while others stop, hooks included")
s.Compose(`
services:
service1:
image: alpine
command: /bin/true
service2:
image: alpine
command: ping -c 2 localhost
pre_stop:
- command: echo "stop hook running..."
`).
Step("up aborts on the first exit but still relays service2's logs and stop hook",
ComposeCmd("up", "--abort-on-container-exit").Within(60*time.Second),
StderrContains("Container "+s.Project()+"-service1-1 Stopped"),
StdoutContains("stop hook running..."),
StdoutContains("64 bytes"))
}