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 <nicolas.deloof@gmail.com>
This commit is contained in:
Nicolas De Loof 2026-08-17 08:49:36 +02:00 committed by Nicolas De loof
parent 9409c461f7
commit ee7f323fed
35 changed files with 347 additions and 490 deletions

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{

View file

@ -17,227 +17,157 @@
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.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.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").
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").
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").
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").
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.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 +175,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 +185,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 +197,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{

View file

@ -20,95 +20,61 @@ 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.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"))
}
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").
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").
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"
reset := func() {
c.RunDockerComposeCmd(t, "--project-name", projectName, "down", "--remove-orphans", "--timeout=0")
}
reset()
t.Cleanup(reset)
res := c.RunDockerComposeCmdNoCheck(t, "-f", "fixtures/port-range/compose.yaml", "--project-name", projectName, "up", "-d")
res.Assert(t, icmd.Success)
NewScenario(t, "a published port range must accommodate scaled replicas and single ports alike").
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").
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"
}
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)
s.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"))
}

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"

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

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:

View file

@ -1,4 +0,0 @@
services:
simple:
image: alpine
command: echo "Hi there!!"

View file

@ -1,3 +0,0 @@
services:
backend:
image: hello-world

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.
>&2 echo "log to stderr"
echo "log to stdout"

View file

@ -0,0 +1,14 @@
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

View file

@ -0,0 +1,9 @@
services:
build:
build:
dockerfile_inline: "FROM base"
additional_contexts:
base: "service:build_base"
build_base:
build:
dockerfile_inline: "FROM alpine"

View file

@ -10,4 +10,4 @@ services:
depends_on:
- shared_dep
shared_dep:
image: bash
image: bash

View file

@ -7,5 +7,7 @@ services:
required: false
condition: service_healthy
bar:
image: nginx:alpine
profiles: [not-required]
image: alpine
init: true
command: sleep infinity
profiles: [not-required]

View file

@ -1,4 +1,4 @@
services:
backend:
image: nginx
command: nginx -t
command: nginx -t

View file

@ -0,0 +1,3 @@
services:
backend:
image: hello-world

View file

@ -8,7 +8,9 @@ services:
FOO: ${BAR}
app:
image: nginx
image: alpine
init: true
command: sleep infinity
depends_on:
fluentbit:
condition: service_started

View file

@ -0,0 +1,22 @@
services:
a:
image: alpine
init: true
command: sleep infinity
scale: 5
ports:
- "6005-6015:80"
b:
image: alpine
init: true
command: sleep infinity
ports:
- 80
c:
image: alpine
init: true
command: sleep infinity
ports:
- 80

View file

@ -0,0 +1,5 @@
services:
simple:
image: alpine
init: true
command: sleep infinity

View file

@ -0,0 +1,3 @@
>&2 echo "log to stderr"
echo "log to stdout"

View file

@ -0,0 +1,14 @@
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]

View file

@ -1,15 +1,12 @@
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
image: alpine

View file

@ -2,4 +2,4 @@ volumes:
my_vol: {}
networks:
my_net: {}
my_net: {}

View file

@ -0,0 +1,3 @@
FROM busybox:1.35.0
RUN echo "hello"

View file

@ -1,9 +1,9 @@
services:
foo:
image: built-image-dependency
image: ${BUILT_IMAGE}
build:
context: .
bar:
image: built-image-dependency
image: ${BUILT_IMAGE}
depends_on:
- foo

View file

@ -1,10 +1,11 @@
services:
web:
image: nginx:alpine
image: alpine
init: true
command: sleep infinity
depends_on:
db:
condition: service_healthy
db:
image: alpine
command: sh -c "exit 1"

View file

@ -0,0 +1,13 @@
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]

View file

@ -21,7 +21,6 @@ package e2e
import (
"context"
"errors"
"fmt"
"os/exec"
"strings"
"syscall"
@ -29,19 +28,17 @@ 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.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 +104,74 @@ 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.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.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").
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").
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")
})
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())
s := NewScenario(t, "up --all-resources must create volumes and networks no service uses")
s.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")
})
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())
NewScenario(t, "up on a profiled service must start it and its dependencies, not its profile siblings").
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.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.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"))
}