From 25bfaf5b673292bc13d83343e22f463fa7dbcaef Mon Sep 17 00:00:00 2001 From: Mark Jagyo Date: Wed, 17 Dec 2025 22:24:05 +0800 Subject: [PATCH 1/3] Add logging while waiting for services Signed-off-by: Mark Jagyo --- cmd/compose/up.go | 16 +++++++++++++--- cmd/compose/wait.go | 10 +++++++++- pkg/api/api.go | 4 ++++ pkg/compose/start.go | 10 ++++++++++ pkg/compose/wait.go | 16 +++++++++++++++- pkg/e2e/fixtures/start-stop/compose.yaml | 3 +++ pkg/e2e/fixtures/wait/compose.yaml | 4 +++- pkg/e2e/start_stop_test.go | 18 ++++++++++++++++++ pkg/e2e/wait_test.go | 20 ++++++++++++++++++++ 9 files changed, 95 insertions(+), 6 deletions(-) diff --git a/cmd/compose/up.go b/cmd/compose/up.go index 5819900823f..81454c27f52 100644 --- a/cmd/compose/up.go +++ b/cmd/compose/up.go @@ -58,6 +58,7 @@ type upOptions struct { noAttach []string timestamp bool wait bool + log bool waitTimeout int watch bool navigationMenu bool @@ -170,7 +171,8 @@ 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.BoolVar(&up.log, "log", false, "Stream service logs") 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 +196,20 @@ 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.log { + if !up.wait { + return fmt.Errorf("--log should be combined with --wait") + } + } + 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 +307,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 @@ -339,6 +348,7 @@ func runUp( OnExit: upOptions.OnExit(), Wait: upOptions.wait, WaitTimeout: timeout, + Log: upOptions.log, Watch: upOptions.watch, Services: services, NavigationMenu: upOptions.navigationMenu && display.Mode != "plain" && dockerCli.In().IsTerminal(), diff --git a/cmd/compose/wait.go b/cmd/compose/wait.go index 9d86fd314cf..65ebff492c6 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 { @@ -58,22 +60,28 @@ func waitCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *Backe } cmd.Flags().BoolVar(&opts.downProject, "down-project", false, "Drops project when the first container stops") + cmd.Flags().BoolVar(&opts.log, "log", false, "Shows the logs of the service") return cmd } 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, + Log: opts.log, + Project: project, }) } diff --git a/pkg/api/api.go b/pkg/api/api.go index aed77af1523..67bdc746ea5 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -165,6 +165,9 @@ type WaitOptions struct { Services []string // Executes a down when a container exits DownProjectOnContainerExit bool + Log bool + Project *types.Project + Consumer LogConsumer } type VizOptions struct { @@ -293,6 +296,7 @@ type StartOptions struct { ExitCodeFrom string // Wait won't return until containers reached the running|healthy state Wait bool + Log bool WaitTimeout time.Duration // Services passed in the command line to be started Services []string diff --git a/pkg/compose/start.go b/pkg/compose/start.go index eeb232fa66d..8097cacbbb9 100644 --- a/pkg/compose/start.go +++ b/pkg/compose/start.go @@ -95,6 +95,16 @@ func (s *composeService) start(ctx context.Context, projectName string, options } return err } + + if options.Log { + s.Logs(ctx, projectName, options.Attach, api.LogOptions{ + Project: options.Project, + Services: options.Services, + Follow: false, + Tail: "all", + Timestamps: false, + }) + } } return nil diff --git a/pkg/compose/wait.go b/pkg/compose/wait.go index 30413cb533e..ad3d86cbaec 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,15 @@ func (s *composeService) Wait(ctx context.Context, projectName string, options a eg, waitCtx := errgroup.WithContext(ctx) var statusCode int64 + + if options.Log { + 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`}) + }) +} From b6248b6c143f2aa82da2818a0de6a4590de3f5a3 Mon Sep 17 00:00:00 2001 From: Mark Jagyo Date: Tue, 23 Dec 2025 21:55:12 +0800 Subject: [PATCH 2/3] feat(compose): emit and handle container healthy events Signed-off-by: Mark Jagyo --- pkg/api/api.go | 2 ++ pkg/compose/monitor.go | 5 +++++ pkg/compose/start.go | 10 ---------- pkg/compose/up.go | 13 +++++++++++++ 4 files changed, 20 insertions(+), 10 deletions(-) diff --git a/pkg/api/api.go b/pkg/api/api.go index 67bdc746ea5..c24a4158674 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -746,6 +746,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/start.go b/pkg/compose/start.go index 8097cacbbb9..eeb232fa66d 100644 --- a/pkg/compose/start.go +++ b/pkg/compose/start.go @@ -95,16 +95,6 @@ func (s *composeService) start(ctx context.Context, projectName string, options } return err } - - if options.Log { - s.Logs(ctx, projectName, options.Attach, api.LogOptions{ - Project: options.Project, - Services: options.Services, - Follow: false, - Tail: "all", - Timestamps: false, - }) - } } return nil diff --git a/pkg/compose/up.go b/pkg/compose/up.go index d5eb4d87691..d31fc86a4b0 100644 --- a/pkg/compose/up.go +++ b/pkg/compose/up.go @@ -198,6 +198,11 @@ func (s *composeService) Up(ctx context.Context, project *types.Project, options } monitor.withListener(printer.HandleEvent) + monitor.withListener(onHealthy(func(e api.ContainerEvent) { + fmt.Println("Healthy:", e.Service, e.Container.Name) + cancel() + })) + var exitCode int if options.Start.OnExit != api.CascadeIgnore { once := true @@ -301,3 +306,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) + } + } +} From d96fb10b0be3846ec4666fd6b8190582b308f4b6 Mon Sep 17 00:00:00 2001 From: Mark Jagyo Date: Tue, 23 Dec 2025 22:30:56 +0800 Subject: [PATCH 3/3] remove log flag Signed-off-by: Mark Jagyo --- cmd/compose/up.go | 9 --------- cmd/compose/wait.go | 2 -- pkg/api/api.go | 2 -- pkg/compose/up.go | 9 +++++---- pkg/compose/wait.go | 12 +++++------- 5 files changed, 10 insertions(+), 24 deletions(-) diff --git a/cmd/compose/up.go b/cmd/compose/up.go index 81454c27f52..fec28877e0b 100644 --- a/cmd/compose/up.go +++ b/cmd/compose/up.go @@ -58,7 +58,6 @@ type upOptions struct { noAttach []string timestamp bool wait bool - log bool waitTimeout int watch bool navigationMenu bool @@ -172,7 +171,6 @@ func upCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *Backend 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 attached mode by default.") - flags.BoolVar(&up.log, "log", false, "Stream service logs") 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.") @@ -197,12 +195,6 @@ func validateFlags(up *upOptions, create *createOptions) error { return fmt.Errorf("--abort-on-container-failure cannot be combined with --abort-on-container-exit") } - if up.log { - if !up.wait { - return fmt.Errorf("--log should be combined with --wait") - } - } - if up.wait { up.Detach = false if up.attachDependencies || up.cascadeStop || len(up.attach) > 0 { @@ -348,7 +340,6 @@ func runUp( OnExit: upOptions.OnExit(), Wait: upOptions.wait, WaitTimeout: timeout, - Log: upOptions.log, Watch: upOptions.watch, Services: services, NavigationMenu: upOptions.navigationMenu && display.Mode != "plain" && dockerCli.In().IsTerminal(), diff --git a/cmd/compose/wait.go b/cmd/compose/wait.go index 65ebff492c6..f9b4b60db67 100644 --- a/cmd/compose/wait.go +++ b/cmd/compose/wait.go @@ -60,7 +60,6 @@ func waitCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *Backe } cmd.Flags().BoolVar(&opts.downProject, "down-project", false, "Drops project when the first container stops") - cmd.Flags().BoolVar(&opts.log, "log", false, "Shows the logs of the service") return cmd } @@ -81,7 +80,6 @@ func runWait(ctx context.Context, dockerCli command.Cli, backendOptions *Backend Services: opts.services, DownProjectOnContainerExit: opts.downProject, Consumer: consumer, - Log: opts.log, Project: project, }) } diff --git a/pkg/api/api.go b/pkg/api/api.go index c24a4158674..32db3af0cc6 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -165,7 +165,6 @@ type WaitOptions struct { Services []string // Executes a down when a container exits DownProjectOnContainerExit bool - Log bool Project *types.Project Consumer LogConsumer } @@ -296,7 +295,6 @@ type StartOptions struct { ExitCodeFrom string // Wait won't return until containers reached the running|healthy state Wait bool - Log bool WaitTimeout time.Duration // Services passed in the command line to be started Services []string diff --git a/pkg/compose/up.go b/pkg/compose/up.go index d31fc86a4b0..a1ddbdaa60a 100644 --- a/pkg/compose/up.go +++ b/pkg/compose/up.go @@ -198,10 +198,11 @@ func (s *composeService) Up(ctx context.Context, project *types.Project, options } monitor.withListener(printer.HandleEvent) - monitor.withListener(onHealthy(func(e api.ContainerEvent) { - fmt.Println("Healthy:", e.Service, e.Container.Name) - cancel() - })) + if options.Start.Wait { + monitor.withListener(onHealthy(func(e api.ContainerEvent) { + cancel() + })) + } var exitCode int if options.Start.OnExit != api.CascadeIgnore { diff --git a/pkg/compose/wait.go b/pkg/compose/wait.go index ad3d86cbaec..f928d74fd0a 100644 --- a/pkg/compose/wait.go +++ b/pkg/compose/wait.go @@ -42,13 +42,11 @@ func (s *composeService) Wait( eg, waitCtx := errgroup.WithContext(ctx) var statusCode int64 - if options.Log { - s.Logs(ctx, projectName, options.Consumer, api.LogOptions{ - Project: options.Project, - Services: options.Services, - Follow: false, - }) - } + s.Logs(ctx, projectName, options.Consumer, api.LogOptions{ + Project: options.Project, + Services: options.Services, + Follow: false, + }) for _, ctr := range containers { eg.Go(func() error {