diff --git a/cmd/compose/up.go b/cmd/compose/up.go index 5819900823f..fec28877e0b 100644 --- a/cmd/compose/up.go +++ b/cmd/compose/up.go @@ -170,7 +170,7 @@ func upCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *Backend flags.StringArrayVar(&up.attach, "attach", []string{}, "Restrict attaching to the specified services. Incompatible with --attach-dependencies.") flags.StringArrayVar(&up.noAttach, "no-attach", []string{}, "Do not attach (stream logs) to the specified services") flags.BoolVar(&up.attachDependencies, "attach-dependencies", false, "Automatically attach to log output of dependent services") - flags.BoolVar(&up.wait, "wait", false, "Wait for services to be running|healthy. Implies detached mode.") + flags.BoolVar(&up.wait, "wait", false, "Wait for services to be running|healthy. Implies attached mode by default.") flags.IntVar(&up.waitTimeout, "wait-timeout", 0, "Maximum duration in seconds to wait for the project to be running|healthy") flags.BoolVarP(&up.watch, "watch", "w", false, "Watch source code and rebuild/refresh containers when files are updated.") flags.BoolVar(&up.navigationMenu, "menu", false, "Enable interactive shortcuts when running attached. Incompatible with --detach. Can also be enable/disable by setting COMPOSE_MENU environment var.") @@ -194,12 +194,14 @@ func validateFlags(up *upOptions, create *createOptions) error { if up.cascadeStop && up.cascadeFail { return fmt.Errorf("--abort-on-container-failure cannot be combined with --abort-on-container-exit") } + if up.wait { + up.Detach = false 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") } - up.Detach = true } + if create.Build && create.noBuild { return fmt.Errorf("--build and --no-build are incompatible") } @@ -297,7 +299,6 @@ func runUp( var attach []string if !upOptions.Detach { consumer = formatter.NewLogConsumer(ctx, dockerCli.Out(), dockerCli.Err(), !upOptions.noColor, !upOptions.noPrefix, upOptions.timestamp) - var attachSet utils.Set[string] if len(upOptions.attach) != 0 { // services are passed explicitly with --attach, verify they're valid and then use them as-is diff --git a/cmd/compose/wait.go b/cmd/compose/wait.go index 9d86fd314cf..f9b4b60db67 100644 --- a/cmd/compose/wait.go +++ b/cmd/compose/wait.go @@ -24,6 +24,7 @@ import ( "github.com/docker/cli/cli/command" "github.com/spf13/cobra" + "github.com/docker/compose/v5/cmd/formatter" "github.com/docker/compose/v5/pkg/api" "github.com/docker/compose/v5/pkg/compose" ) @@ -34,6 +35,7 @@ type waitOptions struct { services []string downProject bool + log bool } func waitCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *BackendOptions) *cobra.Command { @@ -63,17 +65,21 @@ func waitCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *Backe } func runWait(ctx context.Context, dockerCli command.Cli, backendOptions *BackendOptions, opts *waitOptions) (int64, error) { - _, name, err := opts.projectOrName(ctx, dockerCli) + project, name, err := opts.projectOrName(ctx, dockerCli) if err != nil { return 0, err } + consumer := formatter.NewLogConsumer(ctx, dockerCli.Out(), dockerCli.Err(), false, false, false) backend, err := compose.NewComposeService(dockerCli, backendOptions.Options...) if err != nil { return 0, err } + return backend.Wait(ctx, name, api.WaitOptions{ Services: opts.services, DownProjectOnContainerExit: opts.downProject, + Consumer: consumer, + Project: project, }) } diff --git a/pkg/api/api.go b/pkg/api/api.go index aed77af1523..32db3af0cc6 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -165,6 +165,8 @@ type WaitOptions struct { Services []string // Executes a down when a container exits DownProjectOnContainerExit bool + Project *types.Project + Consumer LogConsumer } type VizOptions struct { @@ -742,6 +744,8 @@ const ( ContainerEventRecreated // ContainerEventExited is a ContainerEvent of type exit. ExitCode is set ContainerEventExited + // ContainerEventHealthy let consumer know container is healthy + ContainerEventHealthy // UserCancel user canceled compose up, we are stopping containers HookEventLog ) diff --git a/pkg/compose/monitor.go b/pkg/compose/monitor.go index e7e70c88308..b11a2b18d73 100644 --- a/pkg/compose/monitor.go +++ b/pkg/compose/monitor.go @@ -164,6 +164,11 @@ func (c *monitor) Start(ctx context.Context) error { } containers.Remove(ctr.ID) } + case events.ActionHealthStatusHealthy: + logrus.Debugf("container %s healthy", ctr.Name) + for _, listener := range c.listeners { + listener(newContainerEvent(event.TimeNano, ctr, api.ContainerEventHealthy)) + } } } } diff --git a/pkg/compose/up.go b/pkg/compose/up.go index d5eb4d87691..a1ddbdaa60a 100644 --- a/pkg/compose/up.go +++ b/pkg/compose/up.go @@ -198,6 +198,12 @@ func (s *composeService) Up(ctx context.Context, project *types.Project, options } monitor.withListener(printer.HandleEvent) + if options.Start.Wait { + monitor.withListener(onHealthy(func(e api.ContainerEvent) { + cancel() + })) + } + var exitCode int if options.Start.OnExit != api.CascadeIgnore { once := true @@ -301,3 +307,11 @@ func (s *composeService) Up(ctx context.Context, project *types.Project, options } return err } + +func onHealthy(fn func(api.ContainerEvent)) api.ContainerEventListener { + return func(e api.ContainerEvent) { + if e.Type == api.ContainerEventHealthy { + fn(e) + } + } +} diff --git a/pkg/compose/wait.go b/pkg/compose/wait.go index 30413cb533e..f928d74fd0a 100644 --- a/pkg/compose/wait.go +++ b/pkg/compose/wait.go @@ -25,8 +25,13 @@ import ( "github.com/docker/compose/v5/pkg/api" ) -func (s *composeService) Wait(ctx context.Context, projectName string, options api.WaitOptions) (int64, error) { +func (s *composeService) Wait( + ctx context.Context, + projectName string, + options api.WaitOptions, +) (int64, error) { containers, err := s.getContainers(ctx, projectName, oneOffInclude, false, options.Services...) + if err != nil { return 0, err } @@ -36,6 +41,13 @@ func (s *composeService) Wait(ctx context.Context, projectName string, options a eg, waitCtx := errgroup.WithContext(ctx) var statusCode int64 + + s.Logs(ctx, projectName, options.Consumer, api.LogOptions{ + Project: options.Project, + Services: options.Services, + Follow: false, + }) + for _, ctr := range containers { eg.Go(func() error { var err error diff --git a/pkg/e2e/fixtures/start-stop/compose.yaml b/pkg/e2e/fixtures/start-stop/compose.yaml index 15f69b2e305..5d96f26edf9 100644 --- a/pkg/e2e/fixtures/start-stop/compose.yaml +++ b/pkg/e2e/fixtures/start-stop/compose.yaml @@ -3,3 +3,6 @@ services: image: nginx:alpine another: image: nginx:alpine + hello: + image: alpine + command: echo please-see-me diff --git a/pkg/e2e/fixtures/wait/compose.yaml b/pkg/e2e/fixtures/wait/compose.yaml index 1a001e6fa87..1fe9bb1fdca 100644 --- a/pkg/e2e/fixtures/wait/compose.yaml +++ b/pkg/e2e/fixtures/wait/compose.yaml @@ -8,4 +8,6 @@ services: infinity: image: alpine command: sleep infinity - + hello: + image: alpine + command: sh -c "echo hello" diff --git a/pkg/e2e/start_stop_test.go b/pkg/e2e/start_stop_test.go index 19f07b71960..a51b758cf41 100644 --- a/pkg/e2e/start_stop_test.go +++ b/pkg/e2e/start_stop_test.go @@ -20,10 +20,12 @@ import ( "fmt" "strings" "testing" + "time" testify "github.com/stretchr/testify/assert" "gotest.tools/v3/assert" "gotest.tools/v3/icmd" + "gotest.tools/v3/poll" ) func TestStartStop(t *testing.T) { @@ -243,6 +245,22 @@ func TestStartStopMultipleServices(t *testing.T) { } } +func TestStartLogService(t *testing.T) { + c := NewParallelCLI(t, WithEnv( + "COMPOSE_PROJECT_NAME=e2e-start-log-svc", + "COMPOSE_FILE=./fixtures/start-stop/compose.yaml")) + + t.Run("run wait log", func(t *testing.T) { + cmd := c.NewDockerComposeCmd(t, "up", "hello", "--wait", "--log") + res := icmd.StartCmd(cmd) + t.Cleanup(func() { + _ = res.Cmd.Process.Kill() + }) + + poll.WaitOn(t, expectOutput(res, "please-see-me"), poll.WithDelay(1000*time.Millisecond), poll.WithTimeout(2*time.Second)) + }) +} + func TestStartSingleServiceAndDependency(t *testing.T) { cli := NewParallelCLI(t, WithEnv( "COMPOSE_PROJECT_NAME=e2e-start-single-deps", diff --git a/pkg/e2e/wait_test.go b/pkg/e2e/wait_test.go index 171027fd9c6..ea453009b07 100644 --- a/pkg/e2e/wait_test.go +++ b/pkg/e2e/wait_test.go @@ -104,3 +104,23 @@ func TestWaitAndDrop(t *testing.T) { res := c.RunDockerCmd(t, "ps", "--all") assert.Assert(t, !strings.Contains(res.Combined(), projectName), res.Combined()) } + +func TestWaitLog(t *testing.T) { + const projectName = "e2e-wait-and-log" + c := NewParallelCLI(t) + + cleanup := func() { + c.RunDockerComposeCmd(t, "--project-name", projectName, "down", "--timeout=0", "--remove-orphans") + } + t.Cleanup(cleanup) + cleanup() + + t.Run("up", func(t *testing.T) { + c.RunDockerComposeCmd(t, "-f", "./fixtures/wait/compose.yaml", "--project-name", projectName, "up", "-d", "hello") + }) + + t.Run("logs", func(t *testing.T) { + res := c.RunDockerComposeCmd(t, "-f", "./fixtures/wait/compose.yaml", "--project-name", projectName, "wait", "hello", "--log") + res.Assert(t, icmd.Expected{Out: `hello`}) + }) +}