diff --git a/cmd/compose/up.go b/cmd/compose/up.go index 7c9cc6e01..559387cbe 100644 --- a/cmd/compose/up.go +++ b/cmd/compose/up.go @@ -46,6 +46,7 @@ type upOptions struct { noStart bool noDeps bool cascadeStop bool + cascadeFail bool exitCodeFrom string noColor bool noPrefix bool @@ -89,6 +90,17 @@ func (opts *upOptions) validateNavigationMenu(dockerCli command.Cli, experimenta } } +func (opts upOptions) OnExit() api.Cascade { + switch { + case opts.cascadeStop: + return api.CascadeStop + case opts.cascadeFail: + return api.CascadeFail + default: + return api.CascadeIgnore + } +} + func upCommand(p *ProjectOptions, dockerCli command.Cli, backend api.Service, experiments *experimental.State) *cobra.Command { up := upOptions{} create := createOptions{} @@ -131,6 +143,7 @@ func upCommand(p *ProjectOptions, dockerCli command.Cli, backend api.Service, ex flags.BoolVar(&create.noRecreate, "no-recreate", false, "If containers already exist, don't recreate them. Incompatible with --force-recreate.") flags.BoolVar(&up.noStart, "no-start", false, "Don't start the services after creating them") flags.BoolVar(&up.cascadeStop, "abort-on-container-exit", false, "Stops all containers if any container was stopped. Incompatible with -d") + flags.BoolVar(&up.cascadeFail, "abort-on-container-failure", false, "Stops all containers if any container exited with failure. Incompatible with -d") flags.StringVar(&up.exitCodeFrom, "exit-code-from", "", "Return the exit code of the selected service container. Implies --abort-on-container-exit") flags.IntVarP(&create.timeout, "timeout", "t", 0, "Use this timeout in seconds for container shutdown when attached or when containers are already running") flags.BoolVar(&up.timestamp, "timestamps", false, "Show timestamps") @@ -152,9 +165,12 @@ func upCommand(p *ProjectOptions, dockerCli command.Cli, backend api.Service, ex //nolint:gocyclo func validateFlags(up *upOptions, create *createOptions) error { - if up.exitCodeFrom != "" { + if up.exitCodeFrom != "" && !up.cascadeFail { up.cascadeStop = true } + if up.cascadeStop && up.cascadeFail { + return fmt.Errorf("--abort-on-container-failure cannot be combined with --abort-on-container-exit") + } if up.wait { if up.attachDependencies || up.cascadeStop || len(up.attach) > 0 { return fmt.Errorf("--wait cannot be combined with --abort-on-container-exit, --attach or --attach-dependencies") @@ -164,8 +180,8 @@ func validateFlags(up *upOptions, create *createOptions) error { if create.Build && create.noBuild { return fmt.Errorf("--build and --no-build are incompatible") } - if up.Detach && (up.attachDependencies || up.cascadeStop || len(up.attach) > 0) { - return fmt.Errorf("--detach cannot be combined with --abort-on-container-exit, --attach or --attach-dependencies") + if up.Detach && (up.attachDependencies || up.cascadeStop || up.cascadeFail || len(up.attach) > 0) { + return fmt.Errorf("--detach cannot be combined with --abort-on-container-exit, --abort-on-container-failure, --attach or --attach-dependencies") } if create.forceRecreate && create.noRecreate { return fmt.Errorf("--force-recreate and --no-recreate are incompatible") @@ -278,7 +294,7 @@ func runUp( Attach: consumer, AttachTo: attach, ExitCodeFrom: upOptions.exitCodeFrom, - CascadeStop: upOptions.cascadeStop, + OnExit: upOptions.OnExit(), Wait: upOptions.wait, WaitTimeout: timeout, Watch: upOptions.watch, diff --git a/cmd/compose/watch.go b/cmd/compose/watch.go index 880711a20..897da728d 100644 --- a/cmd/compose/watch.go +++ b/cmd/compose/watch.go @@ -104,10 +104,9 @@ func runWatch(ctx context.Context, dockerCli command.Cli, backend api.Service, w QuietPull: buildOpts.quiet, }, Start: api.StartOptions{ - Project: project, - Attach: nil, - CascadeStop: false, - Services: services, + Project: project, + Attach: nil, + Services: services, }, } if err := backend.Up(ctx, project, upOpts); err != nil { diff --git a/docs/reference/compose_up.md b/docs/reference/compose_up.md index a34766c48..382996395 100644 --- a/docs/reference/compose_up.md +++ b/docs/reference/compose_up.md @@ -5,34 +5,35 @@ Create and start containers ### Options -| Name | Type | Default | Description | -|:-----------------------------|:--------------|:---------|:--------------------------------------------------------------------------------------------------------| -| `--abort-on-container-exit` | | | Stops all containers if any container was stopped. Incompatible with -d | -| `--always-recreate-deps` | | | Recreate dependent containers. Incompatible with --no-recreate. | -| `--attach` | `stringArray` | | Restrict attaching to the specified services. Incompatible with --attach-dependencies. | -| `--attach-dependencies` | | | Automatically attach to log output of dependent services | -| `--build` | | | Build images before starting containers | -| `-d`, `--detach` | | | Detached mode: Run containers in the background | -| `--dry-run` | | | Execute command in dry run mode | -| `--exit-code-from` | `string` | | Return the exit code of the selected service container. Implies --abort-on-container-exit | -| `--force-recreate` | | | Recreate containers even if their configuration and image haven't changed | -| `--no-attach` | `stringArray` | | Do not attach (stream logs) to the specified services | -| `--no-build` | | | Don't build an image, even if it's policy | -| `--no-color` | | | Produce monochrome output | -| `--no-deps` | | | Don't start linked services | -| `--no-log-prefix` | | | Don't print prefix in logs | -| `--no-recreate` | | | If containers already exist, don't recreate them. Incompatible with --force-recreate. | -| `--no-start` | | | Don't start the services after creating them | -| `--pull` | `string` | `policy` | Pull image before running ("always"\|"missing"\|"never") | -| `--quiet-pull` | | | Pull without printing progress information | -| `--remove-orphans` | | | Remove containers for services not defined in the Compose file | -| `-V`, `--renew-anon-volumes` | | | Recreate anonymous volumes instead of retrieving data from the previous containers | -| `--scale` | `stringArray` | | Scale SERVICE to NUM instances. Overrides the `scale` setting in the Compose file if present. | -| `-t`, `--timeout` | `int` | `0` | Use this timeout in seconds for container shutdown when attached or when containers are already running | -| `--timestamps` | | | Show timestamps | -| `--wait` | | | Wait for services to be running\|healthy. Implies detached mode. | -| `--wait-timeout` | `int` | `0` | Maximum duration to wait for the project to be running\|healthy | -| `-w`, `--watch` | | | Watch source code and rebuild/refresh containers when files are updated. | +| Name | Type | Default | Description | +|:-------------------------------|:--------------|:---------|:--------------------------------------------------------------------------------------------------------| +| `--abort-on-container-exit` | | | Stops all containers if any container was stopped. Incompatible with -d | +| `--abort-on-container-failure` | | | Stops all containers if any container exited with failure. Incompatible with -d | +| `--always-recreate-deps` | | | Recreate dependent containers. Incompatible with --no-recreate. | +| `--attach` | `stringArray` | | Restrict attaching to the specified services. Incompatible with --attach-dependencies. | +| `--attach-dependencies` | | | Automatically attach to log output of dependent services | +| `--build` | | | Build images before starting containers | +| `-d`, `--detach` | | | Detached mode: Run containers in the background | +| `--dry-run` | | | Execute command in dry run mode | +| `--exit-code-from` | `string` | | Return the exit code of the selected service container. Implies --abort-on-container-exit | +| `--force-recreate` | | | Recreate containers even if their configuration and image haven't changed | +| `--no-attach` | `stringArray` | | Do not attach (stream logs) to the specified services | +| `--no-build` | | | Don't build an image, even if it's policy | +| `--no-color` | | | Produce monochrome output | +| `--no-deps` | | | Don't start linked services | +| `--no-log-prefix` | | | Don't print prefix in logs | +| `--no-recreate` | | | If containers already exist, don't recreate them. Incompatible with --force-recreate. | +| `--no-start` | | | Don't start the services after creating them | +| `--pull` | `string` | `policy` | Pull image before running ("always"\|"missing"\|"never") | +| `--quiet-pull` | | | Pull without printing progress information | +| `--remove-orphans` | | | Remove containers for services not defined in the Compose file | +| `-V`, `--renew-anon-volumes` | | | Recreate anonymous volumes instead of retrieving data from the previous containers | +| `--scale` | `stringArray` | | Scale SERVICE to NUM instances. Overrides the `scale` setting in the Compose file if present. | +| `-t`, `--timeout` | `int` | `0` | Use this timeout in seconds for container shutdown when attached or when containers are already running | +| `--timestamps` | | | Show timestamps | +| `--wait` | | | Wait for services to be running\|healthy. Implies detached mode. | +| `--wait-timeout` | `int` | `0` | Maximum duration to wait for the project to be running\|healthy | +| `-w`, `--watch` | | | Watch source code and rebuild/refresh containers when files are updated. | diff --git a/docs/reference/docker_compose_up.yaml b/docs/reference/docker_compose_up.yaml index 967a16379..760a32da3 100644 --- a/docs/reference/docker_compose_up.yaml +++ b/docs/reference/docker_compose_up.yaml @@ -35,6 +35,17 @@ options: experimentalcli: false kubernetes: false swarm: false + - option: abort-on-container-failure + value_type: bool + default_value: "false" + description: | + Stops all containers if any container exited with failure. Incompatible with -d + deprecated: false + hidden: false + experimental: false + experimentalcli: false + kubernetes: false + swarm: false - option: always-recreate-deps value_type: bool default_value: "false" diff --git a/pkg/api/api.go b/pkg/api/api.go index ac6a2e0db..ff503d259 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -209,8 +209,8 @@ type StartOptions struct { Attach LogConsumer // AttachTo set the services to attach to AttachTo []string - // CascadeStop stops the application when a container stops - CascadeStop bool + // OnExit defines behavior when a container stops + OnExit Cascade // ExitCodeFrom return exit code from specified service ExitCodeFrom string // Wait won't return until containers reached the running|healthy state @@ -222,6 +222,14 @@ type StartOptions struct { NavigationMenu bool } +type Cascade int + +const ( + CascadeIgnore Cascade = iota + CascadeStop Cascade = iota + CascadeFail Cascade = iota +) + // RestartOptions group options of the Restart API type RestartOptions struct { // Project is the compose project used to define this app. Might be nil if user ran command just with project name diff --git a/pkg/compose/logs.go b/pkg/compose/logs.go index c44c538a4..c5ac1d749 100644 --- a/pkg/compose/logs.go +++ b/pkg/compose/logs.go @@ -80,7 +80,7 @@ func (s *composeService) Logs( containers = containers.filter(isRunning()) printer := newLogPrinter(consumer) eg.Go(func() error { - _, err := printer.Run(false, "", nil) + _, err := printer.Run(api.CascadeIgnore, "", nil) return err }) diff --git a/pkg/compose/printer.go b/pkg/compose/printer.go index 45b0f7914..e6835aeff 100644 --- a/pkg/compose/printer.go +++ b/pkg/compose/printer.go @@ -26,7 +26,7 @@ import ( // logPrinter watch application containers an collect their logs type logPrinter interface { HandleEvent(event api.ContainerEvent) - Run(cascadeStop bool, exitCodeFrom string, stopFn func() error) (int, error) + Run(cascade api.Cascade, exitCodeFrom string, stopFn func() error) (int, error) Cancel() Stop() } @@ -79,7 +79,7 @@ func (p *printer) HandleEvent(event api.ContainerEvent) { } //nolint:gocyclo -func (p *printer) Run(cascadeStop bool, exitCodeFrom string, stopFn func() error) (int, error) { +func (p *printer) Run(cascade api.Cascade, exitCodeFrom string, stopFn func() error) (int, error) { var ( aborting bool exitCode int @@ -115,7 +115,7 @@ func (p *printer) Run(cascadeStop bool, exitCodeFrom string, stopFn func() error delete(containers, id) } - if cascadeStop { + if cascade == api.CascadeStop { if !aborting { aborting = true err := stopFn() @@ -123,14 +123,24 @@ func (p *printer) Run(cascadeStop bool, exitCodeFrom string, stopFn func() error return 0, err } } - if event.Type == api.ContainerEventExit { - if exitCodeFrom == "" { - exitCodeFrom = event.Service - } - if exitCodeFrom == event.Service { - exitCode = event.ExitCode + } + if event.Type == api.ContainerEventExit { + if cascade == api.CascadeFail && event.ExitCode != 0 { + exitCodeFrom = event.Service + if !aborting { + aborting = true + err := stopFn() + if err != nil { + return 0, err + } } } + if cascade == api.CascadeStop && exitCodeFrom == "" { + exitCodeFrom = event.Service + } + if exitCodeFrom == event.Service { + exitCode = event.ExitCode + } } if len(containers) == 0 { // Last container terminated, done diff --git a/pkg/compose/up.go b/pkg/compose/up.go index 956dbd766..dcc97b47e 100644 --- a/pkg/compose/up.go +++ b/pkg/compose/up.go @@ -134,7 +134,7 @@ func (s *composeService) Up(ctx context.Context, project *types.Project, options var exitCode int eg.Go(func() error { - code, err := printer.Run(options.Start.CascadeStop, options.Start.ExitCodeFrom, func() error { + code, err := printer.Run(options.Start.OnExit, options.Start.ExitCodeFrom, func() error { fmt.Fprintln(s.stdinfo(), "Aborting on container exit...") return progress.Run(ctx, func(ctx context.Context) error { return s.Stop(ctx, project.Name, api.StopOptions{ diff --git a/pkg/e2e/cascade_test.go b/pkg/e2e/cascade_test.go new file mode 100644 index 000000000..e1f86940e --- /dev/null +++ b/pkg/e2e/cascade_test.go @@ -0,0 +1,53 @@ +//go:build !windows +// +build !windows + +/* + Copyright 2022 Docker Compose CLI authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package e2e + +import ( + "strings" + "testing" + + "gotest.tools/v3/assert" +) + +func TestCascadeStop(t *testing.T) { + c := NewCLI(t) + const projectName = "compose-e2e-cascade-stop" + t.Cleanup(func() { + c.RunDockerComposeCmd(t, "--project-name", projectName, "down") + }) + + res := c.RunDockerComposeCmd(t, "-f", "./fixtures/cascade/compose.yaml", "--project-name", projectName, + "up", "--abort-on-container-exit") + assert.Assert(t, strings.Contains(res.Combined(), "exit-1 exited with code 0"), res.Combined()) +} + +func TestCascadeFail(t *testing.T) { + c := NewCLI(t) + const projectName = "compose-e2e-cascade-fail" + t.Cleanup(func() { + c.RunDockerComposeCmd(t, "--project-name", projectName, "down") + }) + + res := c.RunDockerComposeCmdNoCheck(t, "-f", "./fixtures/cascade/compose.yaml", "--project-name", projectName, + "up", "--abort-on-container-failure") + assert.Assert(t, strings.Contains(res.Combined(), "exit-1 exited with code 0"), res.Combined()) + assert.Assert(t, strings.Contains(res.Combined(), "fail-1 exited with code 1"), res.Combined()) + assert.Equal(t, res.ExitCode, 1) +} diff --git a/pkg/e2e/fixtures/cascade/compose.yaml b/pkg/e2e/fixtures/cascade/compose.yaml new file mode 100644 index 000000000..2e90d6981 --- /dev/null +++ b/pkg/e2e/fixtures/cascade/compose.yaml @@ -0,0 +1,8 @@ +services: + exit: + image: alpine + command: /bin/true + + fail: + image: alpine + command: sh -c "sleep 0.1 && /bin/false"