From 7a7114fb5f351417be79dbbacf5beebb591506c5 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Mon, 8 Feb 2021 11:04:46 +0100 Subject: [PATCH 1/9] Capture container exit code and dump on console Signed-off-by: Nicolas De Loof --- aci/compose.go | 2 +- api/client/compose.go | 2 +- api/compose/api.go | 11 ++++++++++- cli/cmd/compose/run.go | 2 +- cli/cmd/compose/start.go | 7 +++++-- cli/cmd/compose/up.go | 6 ++++-- cli/formatter/logs.go | 22 +++++++++++++++------ ecs/local/compose.go | 4 ++-- ecs/logs.go | 6 ++++++ ecs/up.go | 2 +- kube/compose.go | 2 +- local/compose/attach.go | 38 +++++++++++++----------------------- local/compose/compose.go | 10 ++++++++++ local/compose/containers.go | 22 ++++++++++++++++++++- local/compose/start.go | 39 +++++++++++++++++++++++++++++-------- 15 files changed, 124 insertions(+), 51 deletions(-) diff --git a/aci/compose.go b/aci/compose.go index 079acb983..b33c29049 100644 --- a/aci/compose.go +++ b/aci/compose.go @@ -60,7 +60,7 @@ func (cs *aciComposeService) Create(ctx context.Context, project *types.Project, return errdefs.ErrNotImplemented } -func (cs *aciComposeService) Start(ctx context.Context, project *types.Project, consumer compose.LogConsumer) error { +func (cs *aciComposeService) Start(ctx context.Context, project *types.Project, options compose.StartOptions) error { return errdefs.ErrNotImplemented } diff --git a/api/client/compose.go b/api/client/compose.go index e201068f1..eacbc2ad8 100644 --- a/api/client/compose.go +++ b/api/client/compose.go @@ -44,7 +44,7 @@ func (c *composeService) Create(ctx context.Context, project *types.Project, opt return errdefs.ErrNotImplemented } -func (c *composeService) Start(ctx context.Context, project *types.Project, consumer compose.LogConsumer) error { +func (c *composeService) Start(ctx context.Context, project *types.Project, options compose.StartOptions) error { return errdefs.ErrNotImplemented } diff --git a/api/compose/api.go b/api/compose/api.go index f3e32bf4b..aed5d79c7 100644 --- a/api/compose/api.go +++ b/api/compose/api.go @@ -34,7 +34,7 @@ type Service interface { // Create executes the equivalent to a `compose create` Create(ctx context.Context, project *types.Project, opts CreateOptions) error // Start executes the equivalent to a `compose start` - Start(ctx context.Context, project *types.Project, consumer LogConsumer) error + Start(ctx context.Context, project *types.Project, options StartOptions) error // Stop executes the equivalent to a `compose stop` Stop(ctx context.Context, project *types.Project) error // Up executes the equivalent to a `compose up` @@ -63,6 +63,14 @@ type CreateOptions struct { Recreate string } +// StartOptions group options of the Start API +type StartOptions struct { + // Attach will attach to container and pipe stdout/stderr to LogConsumer + Attach LogConsumer + // CascadeStop will run `Stop` on any container exit + CascadeStop bool +} + // UpOptions group options of the Up API type UpOptions struct { // Detach will create services and return immediately @@ -177,4 +185,5 @@ type Stack struct { // LogConsumer is a callback to process log messages from services type LogConsumer interface { Log(service, container, message string) + Exit(service, container string, exitCode int) } diff --git a/cli/cmd/compose/run.go b/cli/cmd/compose/run.go index fc385b776..27cab00f0 100644 --- a/cli/cmd/compose/run.go +++ b/cli/cmd/compose/run.go @@ -102,7 +102,7 @@ func startDependencies(ctx context.Context, c *client.Client, project *types.Pro if err := c.ComposeService().Create(ctx, project, compose.CreateOptions{}); err != nil { return err } - if err := c.ComposeService().Start(ctx, project, nil); err != nil { + if err := c.ComposeService().Start(ctx, project, compose.StartOptions{}); err != nil { return err } return nil diff --git a/cli/cmd/compose/start.go b/cli/cmd/compose/start.go index f2290c3e9..fd734fb3f 100644 --- a/cli/cmd/compose/start.go +++ b/cli/cmd/compose/start.go @@ -18,6 +18,7 @@ package compose import ( "context" + "github.com/docker/compose-cli/api/compose" "os" "github.com/spf13/cobra" @@ -61,10 +62,12 @@ func runStart(ctx context.Context, opts startOptions, services []string) error { if opts.Detach { _, err = progress.Run(ctx, func(ctx context.Context) (string, error) { - return "", c.ComposeService().Start(ctx, project, nil) + return "", c.ComposeService().Start(ctx, project, compose.StartOptions{}) }) return err } - return c.ComposeService().Start(ctx, project, formatter.NewLogConsumer(ctx, os.Stdout)) + return c.ComposeService().Start(ctx, project, compose.StartOptions{ + Attach: formatter.NewLogConsumer(ctx, os.Stdout), + }) } diff --git a/cli/cmd/compose/up.go b/cli/cmd/compose/up.go index 90da72c0c..325f81325 100644 --- a/cli/cmd/compose/up.go +++ b/cli/cmd/compose/up.go @@ -129,7 +129,7 @@ func runCreateStart(ctx context.Context, opts upOptions, services []string) erro return "", err } if opts.Detach { - err = c.ComposeService().Start(ctx, project, nil) + err = c.ComposeService().Start(ctx, project, compose.StartOptions{}) } return "", err }) @@ -145,7 +145,9 @@ func runCreateStart(ctx context.Context, opts upOptions, services []string) erro return nil } - err = c.ComposeService().Start(ctx, project, formatter.NewLogConsumer(ctx, os.Stdout)) + err = c.ComposeService().Start(ctx, project, compose.StartOptions{ + Attach: formatter.NewLogConsumer(ctx, os.Stdout), + }) if errors.Is(ctx.Err(), context.Canceled) { fmt.Println("Gracefully stopping...") ctx = context.Background() diff --git a/cli/formatter/logs.go b/cli/formatter/logs.go index c3542bcd1..22f6e981d 100644 --- a/cli/formatter/logs.go +++ b/cli/formatter/logs.go @@ -42,12 +42,7 @@ func (l *logConsumer) Log(service, container, message string) { if l.ctx.Err() != nil { return } - cf, ok := l.colors[service] - if !ok { - cf = <-loop - l.colors[service] = cf - l.computeWidth() - } + cf := l.getColorFunc(service) prefix := fmt.Sprintf("%-"+strconv.Itoa(l.width)+"s |", container) for _, line := range strings.Split(message, "\n") { @@ -56,6 +51,21 @@ func (l *logConsumer) Log(service, container, message string) { } } +func (l *logConsumer) Exit(service, container string, exitCode int) { + msg := fmt.Sprintf("%s exited with code %d\n", container, exitCode) + l.writer.Write([]byte(l.getColorFunc(service)(msg))) +} + +func (l *logConsumer) getColorFunc(service string) colorFunc { + cf, ok := l.colors[service] + if !ok { + cf = <-loop + l.colors[service] = cf + l.computeWidth() + } + return cf +} + func (l *logConsumer) computeWidth() { width := 0 for n := range l.colors { diff --git a/ecs/local/compose.go b/ecs/local/compose.go index f110960c6..ce5710a17 100644 --- a/ecs/local/compose.go +++ b/ecs/local/compose.go @@ -53,8 +53,8 @@ func (e ecsLocalSimulation) Create(ctx context.Context, project *types.Project, return e.compose.Create(ctx, enhanced, opts) } -func (e ecsLocalSimulation) Start(ctx context.Context, project *types.Project, consumer compose.LogConsumer) error { - return e.compose.Start(ctx, project, consumer) +func (e ecsLocalSimulation) Start(ctx context.Context, project *types.Project, options compose.StartOptions) error { + return e.compose.Start(ctx, project, options) } func (e ecsLocalSimulation) Stop(ctx context.Context, project *types.Project) error { diff --git a/ecs/logs.go b/ecs/logs.go index 255e66e85..f54a9d111 100644 --- a/ecs/logs.go +++ b/ecs/logs.go @@ -54,3 +54,9 @@ func (a *allowListLogConsumer) Log(service, container, message string) { a.delegate.Log(service, container, message) } } + +func (a *allowListLogConsumer) Exit(service, container string, exitCode int) { + if a.allowList[service] { + a.delegate.Exit(service, container, exitCode) + } +} diff --git a/ecs/up.go b/ecs/up.go index 34acbd69f..f66bca71a 100644 --- a/ecs/up.go +++ b/ecs/up.go @@ -47,7 +47,7 @@ func (b *ecsAPIService) Create(ctx context.Context, project *types.Project, opts return errdefs.ErrNotImplemented } -func (b *ecsAPIService) Start(ctx context.Context, project *types.Project, consumer compose.LogConsumer) error { +func (b *ecsAPIService) Start(ctx context.Context, project *types.Project, options compose.StartOptions) error { return errdefs.ErrNotImplemented } diff --git a/kube/compose.go b/kube/compose.go index 79995987a..f6bf9e90f 100644 --- a/kube/compose.go +++ b/kube/compose.go @@ -144,7 +144,7 @@ func (s *composeService) Create(ctx context.Context, project *types.Project, opt } // Start executes the equivalent to a `compose start` -func (s *composeService) Start(ctx context.Context, project *types.Project, consumer compose.LogConsumer) error { +func (s *composeService) Start(ctx context.Context, project *types.Project, options compose.StartOptions) error { return errdefs.ErrNotImplemented } diff --git a/local/compose/attach.go b/local/compose/attach.go index 8149561fb..3292e5601 100644 --- a/local/compose/attach.go +++ b/local/compose/attach.go @@ -28,22 +28,14 @@ import ( "github.com/compose-spec/compose-go/types" moby "github.com/docker/docker/api/types" - "github.com/docker/docker/api/types/filters" "github.com/docker/docker/pkg/stdcopy" - "golang.org/x/sync/errgroup" ) -func (s *composeService) attach(ctx context.Context, project *types.Project, consumer compose.LogConsumer) (*errgroup.Group, error) { - containers, err := s.apiClient.ContainerList(ctx, moby.ContainerListOptions{ - Filters: filters.NewArgs( - projectFilter(project.Name), - ), - All: true, - }) +func (s *composeService) attach(ctx context.Context, project *types.Project, consumer compose.LogConsumer) (Containers, error) { + containers, err := s.getContainers(ctx, project) if err != nil { return nil, err } - containers = Containers(containers).filter(isService(project.ServiceNames()...)) var names []string for _, c := range containers { @@ -51,19 +43,15 @@ func (s *composeService) attach(ctx context.Context, project *types.Project, con } fmt.Printf("Attaching to %s\n", strings.Join(names, ", ")) - eg, ctx := errgroup.WithContext(ctx) - for _, c := range containers { - container := c - eg.Go(func() error { - return s.attachContainer(ctx, container, consumer, project) - }) + for _, container := range containers { + s.attachContainer(ctx, container, consumer, project) } - return eg, nil + return containers, nil } func (s *composeService) attachContainer(ctx context.Context, container moby.Container, consumer compose.LogConsumer, project *types.Project) error { serviceName := container.Labels[serviceLabel] - w := utils.GetWriter(serviceName, getCanonicalContainerName(container), consumer) + w := getWriter(serviceName, getContainerNameWithoutProject(container), consumer) service, err := project.GetService(serviceName) if err != nil { @@ -94,13 +82,15 @@ func (s *composeService) attachContainerStreams(ctx context.Context, container m } if w != nil { - if tty { - _, err = io.Copy(w, stdout) - } else { - _, err = stdcopy.StdCopy(w, w, stdout) - } + go func() { + if tty { + io.Copy(w, stdout) // nolint:errcheck + } else { + stdcopy.StdCopy(w, w, stdout) // nolint:errcheck + } + }() } - return err + return nil } func (s *composeService) getContainerStreams(ctx context.Context, container moby.Container) (io.WriteCloser, io.ReadCloser, error) { diff --git a/local/compose/compose.go b/local/compose/compose.go index 894db8fe9..d3af6e536 100644 --- a/local/compose/compose.go +++ b/local/compose/compose.go @@ -55,6 +55,16 @@ func getCanonicalContainerName(c moby.Container) string { return c.Names[0][1:] } +func getContainerNameWithoutProject(c moby.Container) string { + name := getCanonicalContainerName(c) + project := c.Labels[projectLabel] + prefix := fmt.Sprintf("%s_%s_", project, c.Labels[serviceLabel]) + if strings.HasPrefix(name, prefix) { + return name[len(project)+1:] + } + return name +} + func (s *composeService) Convert(ctx context.Context, project *types.Project, options compose.ConvertOptions) ([]byte, error) { switch options.Format { case "json": diff --git a/local/compose/containers.go b/local/compose/containers.go index ee13f9d23..c90e4e0c7 100644 --- a/local/compose/containers.go +++ b/local/compose/containers.go @@ -16,11 +16,31 @@ package compose -import moby "github.com/docker/docker/api/types" +import ( + "context" + "github.com/compose-spec/compose-go/types" + moby "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/filters" +) // Containers is a set of moby Container type Containers []moby.Container +func (s *composeService) getContainers(ctx context.Context, project *types.Project) (Containers, error) { + var containers Containers + containers, err := s.apiClient.ContainerList(ctx, moby.ContainerListOptions{ + Filters: filters.NewArgs( + projectFilter(project.Name), + ), + All: true, + }) + if err != nil { + return nil, err + } + containers = containers.filter(isService(project.ServiceNames()...)) + return containers, nil +} + // containerPredicate define a predicate we want container to satisfy for filtering operations type containerPredicate func(c moby.Container) bool diff --git a/local/compose/start.go b/local/compose/start.go index ab2e7ed52..a81164bf8 100644 --- a/local/compose/start.go +++ b/local/compose/start.go @@ -18,6 +18,7 @@ package compose import ( "context" + "github.com/docker/docker/api/types/container" "github.com/docker/compose-cli/api/compose" @@ -25,14 +26,20 @@ import ( "golang.org/x/sync/errgroup" ) -func (s *composeService) Start(ctx context.Context, project *types.Project, consumer compose.LogConsumer) error { - var group *errgroup.Group - if consumer != nil { - eg, err := s.attach(ctx, project, consumer) +func (s *composeService) Start(ctx context.Context, project *types.Project, options compose.StartOptions) error { + var containers Containers + if options.Attach != nil { + c, err := s.attach(ctx, project, options.Attach) if err != nil { return err } - group = eg + containers = c + } else { + c, err := s.getContainers(ctx, project) + if err != nil { + return err + } + containers = c } err := InDependencyOrder(ctx, project, func(c context.Context, service types.ServiceConfig) error { @@ -41,8 +48,24 @@ func (s *composeService) Start(ctx context.Context, project *types.Project, cons if err != nil { return err } - if group != nil { - return group.Wait() + + if options.Attach == nil { + return nil } - return nil + + eg, ctx := errgroup.WithContext(ctx) + for _, c := range containers { + c := c + eg.Go(func() error { + statusC, errC := s.apiClient.ContainerWait(ctx, c.ID, container.WaitConditionNotRunning) + select { + case status := <-statusC: + options.Attach.Exit(c.Labels[serviceLabel], getContainerNameWithoutProject(c), int(status.StatusCode)) + return nil + case err := <-errC: + return err + } + }) + } + return eg.Wait() } From f3d093cb54ba1ed30751b467d5e629958228e18e Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Mon, 8 Feb 2021 11:42:42 +0100 Subject: [PATCH 2/9] introduce cascade stop "--abort-on-container-exit" option Signed-off-by: Nicolas De Loof --- api/compose/api.go | 15 ++++++++++++--- cli/cmd/compose/start.go | 2 +- cli/cmd/compose/up.go | 23 ++++++++++++++++++++++- cli/formatter/logs.go | 7 ++++--- ecs/logs.go | 4 ++-- local/compose/attach.go | 5 ++++- local/compose/containers.go | 1 + local/compose/start.go | 12 ++++++++++-- 8 files changed, 56 insertions(+), 13 deletions(-) diff --git a/api/compose/api.go b/api/compose/api.go index aed5d79c7..7240eca25 100644 --- a/api/compose/api.go +++ b/api/compose/api.go @@ -67,8 +67,8 @@ type CreateOptions struct { type StartOptions struct { // Attach will attach to container and pipe stdout/stderr to LogConsumer Attach LogConsumer - // CascadeStop will run `Stop` on any container exit - CascadeStop bool + // Listener will get notified on container events + Listener Listener } // UpOptions group options of the Up API @@ -185,5 +185,14 @@ type Stack struct { // LogConsumer is a callback to process log messages from services type LogConsumer interface { Log(service, container, message string) - Exit(service, container string, exitCode int) + Status(service, container, message string) +} + +// Listener get notified on container Events +type Listener chan Event + +// Event let us know a Container exited +type Event struct { + Service string + Status int } diff --git a/cli/cmd/compose/start.go b/cli/cmd/compose/start.go index fd734fb3f..07be32ee6 100644 --- a/cli/cmd/compose/start.go +++ b/cli/cmd/compose/start.go @@ -18,12 +18,12 @@ package compose import ( "context" - "github.com/docker/compose-cli/api/compose" "os" "github.com/spf13/cobra" "github.com/docker/compose-cli/api/client" + "github.com/docker/compose-cli/api/compose" "github.com/docker/compose-cli/api/progress" "github.com/docker/compose-cli/cli/formatter" ) diff --git a/cli/cmd/compose/up.go b/cli/cmd/compose/up.go index 325f81325..bdb50f0cc 100644 --- a/cli/cmd/compose/up.go +++ b/cli/cmd/compose/up.go @@ -49,6 +49,7 @@ type upOptions struct { forceRecreate bool noRecreate bool noStart bool + cascadeStop bool } func (o upOptions) recreateStrategy() string { @@ -73,6 +74,9 @@ func upCommand(p *projectOptions, contextType string) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { switch contextType { case store.LocalContextType, store.DefaultContextType, store.EcsLocalSimulationContextType: + if opts.cascadeStop && opts.Detach { + return fmt.Errorf("--abort-on-container-exit and --detach are incompatible") + } if opts.forceRecreate && opts.noRecreate { return fmt.Errorf("--force-recreate and --no-recreate are incompatible") } @@ -95,6 +99,7 @@ func upCommand(p *projectOptions, contextType string) *cobra.Command { flags.BoolVar(&opts.forceRecreate, "force-recreate", false, "Recreate containers even if their configuration and image haven't changed.") flags.BoolVar(&opts.noRecreate, "no-recreate", false, "If containers already exist, don't recreate them. Incompatible with --force-recreate.") flags.BoolVar(&opts.noStart, "no-start", false, "Don't start the services after creating them.") + flags.BoolVar(&opts.cascadeStop, "abort-on-container-exit", false, "Stops all containers if any container was stopped. Incompatible with -d") } return upCmd @@ -145,9 +150,25 @@ func runCreateStart(ctx context.Context, opts upOptions, services []string) erro return nil } + ctx, cancel := context.WithCancel(ctx) + listener := make(chan compose.Event) + go func() { + var aborting bool + for { + <-listener + if opts.cascadeStop && !aborting { + aborting = true + fmt.Println("Aborting on container exit...") + cancel() + } + } + }() + err = c.ComposeService().Start(ctx, project, compose.StartOptions{ - Attach: formatter.NewLogConsumer(ctx, os.Stdout), + Attach: formatter.NewLogConsumer(ctx, os.Stdout), + Listener: listener, }) + if errors.Is(ctx.Err(), context.Canceled) { fmt.Println("Gracefully stopping...") ctx = context.Background() diff --git a/cli/formatter/logs.go b/cli/formatter/logs.go index 22f6e981d..3bfbbbff6 100644 --- a/cli/formatter/logs.go +++ b/cli/formatter/logs.go @@ -51,9 +51,10 @@ func (l *logConsumer) Log(service, container, message string) { } } -func (l *logConsumer) Exit(service, container string, exitCode int) { - msg := fmt.Sprintf("%s exited with code %d\n", container, exitCode) - l.writer.Write([]byte(l.getColorFunc(service)(msg))) +func (l *logConsumer) Status(service, container, msg string) { + cf := l.getColorFunc(service) + buf := bytes.NewBufferString(fmt.Sprintf("%s %s \n", cf(container), cf(msg))) + l.writer.Write(buf.Bytes()) // nolint:errcheck } func (l *logConsumer) getColorFunc(service string) colorFunc { diff --git a/ecs/logs.go b/ecs/logs.go index f54a9d111..e36cd3df6 100644 --- a/ecs/logs.go +++ b/ecs/logs.go @@ -55,8 +55,8 @@ func (a *allowListLogConsumer) Log(service, container, message string) { } } -func (a *allowListLogConsumer) Exit(service, container string, exitCode int) { +func (a *allowListLogConsumer) Status(service, container, message string) { if a.allowList[service] { - a.delegate.Exit(service, container, exitCode) + a.delegate.Status(service, container, message) } } diff --git a/local/compose/attach.go b/local/compose/attach.go index 3292e5601..d0ecc963a 100644 --- a/local/compose/attach.go +++ b/local/compose/attach.go @@ -44,7 +44,10 @@ func (s *composeService) attach(ctx context.Context, project *types.Project, con fmt.Printf("Attaching to %s\n", strings.Join(names, ", ")) for _, container := range containers { - s.attachContainer(ctx, container, consumer, project) + err := s.attachContainer(ctx, container, consumer, project) + if err != nil { + return nil, err + } } return containers, nil } diff --git a/local/compose/containers.go b/local/compose/containers.go index c90e4e0c7..470c96ff2 100644 --- a/local/compose/containers.go +++ b/local/compose/containers.go @@ -18,6 +18,7 @@ package compose import ( "context" + "github.com/compose-spec/compose-go/types" moby "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/filters" diff --git a/local/compose/start.go b/local/compose/start.go index a81164bf8..47cdb470c 100644 --- a/local/compose/start.go +++ b/local/compose/start.go @@ -18,11 +18,12 @@ package compose import ( "context" - "github.com/docker/docker/api/types/container" + "fmt" "github.com/docker/compose-cli/api/compose" "github.com/compose-spec/compose-go/types" + "github.com/docker/docker/api/types/container" "golang.org/x/sync/errgroup" ) @@ -60,7 +61,14 @@ func (s *composeService) Start(ctx context.Context, project *types.Project, opti statusC, errC := s.apiClient.ContainerWait(ctx, c.ID, container.WaitConditionNotRunning) select { case status := <-statusC: - options.Attach.Exit(c.Labels[serviceLabel], getContainerNameWithoutProject(c), int(status.StatusCode)) + service := c.Labels[serviceLabel] + options.Attach.Status(service, getContainerNameWithoutProject(c), fmt.Sprintf("exited with code %d", status.StatusCode)) + if options.Listener != nil { + options.Listener <- compose.Event{ + Service: service, + Status: int(status.StatusCode), + } + } return nil case err := <-errC: return err From 27d640dd412da52902b84e8391d84f5c37fdf253 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Tue, 9 Feb 2021 10:49:48 +0100 Subject: [PATCH 3/9] e2e test and alignment with docker-compose output Signed-off-by: Nicolas De Loof --- cli/formatter/logs.go | 2 +- local/compose/start.go | 2 +- local/e2e/compose/cascade_stop_test.go | 36 +++++++++++++++++++ .../fixtures/cascade-stop-test/compose.yaml | 4 +++ 4 files changed, 42 insertions(+), 2 deletions(-) create mode 100644 local/e2e/compose/cascade_stop_test.go create mode 100644 local/e2e/compose/fixtures/cascade-stop-test/compose.yaml diff --git a/cli/formatter/logs.go b/cli/formatter/logs.go index 3bfbbbff6..83eb4d4df 100644 --- a/cli/formatter/logs.go +++ b/cli/formatter/logs.go @@ -53,7 +53,7 @@ func (l *logConsumer) Log(service, container, message string) { func (l *logConsumer) Status(service, container, msg string) { cf := l.getColorFunc(service) - buf := bytes.NewBufferString(fmt.Sprintf("%s %s \n", cf(container), cf(msg))) + buf := bytes.NewBufferString(cf(fmt.Sprintf("%s %s\n", container, msg))) l.writer.Write(buf.Bytes()) // nolint:errcheck } diff --git a/local/compose/start.go b/local/compose/start.go index 47cdb470c..3315587e3 100644 --- a/local/compose/start.go +++ b/local/compose/start.go @@ -62,7 +62,7 @@ func (s *composeService) Start(ctx context.Context, project *types.Project, opti select { case status := <-statusC: service := c.Labels[serviceLabel] - options.Attach.Status(service, getContainerNameWithoutProject(c), fmt.Sprintf("exited with code %d", status.StatusCode)) + options.Attach.Status(service, getCanonicalContainerName(c), fmt.Sprintf("exited with code %d", status.StatusCode)) if options.Listener != nil { options.Listener <- compose.Event{ Service: service, diff --git a/local/e2e/compose/cascade_stop_test.go b/local/e2e/compose/cascade_stop_test.go new file mode 100644 index 000000000..449e825e3 --- /dev/null +++ b/local/e2e/compose/cascade_stop_test.go @@ -0,0 +1,36 @@ +/* + 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. +*/ + +package e2e + +import ( + "testing" + + "gotest.tools/v3/icmd" + + . "github.com/docker/compose-cli/utils/e2e" +) + +func TestCascadeStop(t *testing.T) { + c := NewParallelE2eCLI(t, binDir) + + const projectName = "compose-e2e-logs" + + res := c.RunDockerCmd("compose", "-f", "./fixtures/cascade-stop-test/compose.yaml", "--project-name", projectName, "up", "--abort-on-container-exit") + res.Assert(t, icmd.Expected{Out: `PING localhost (127.0.0.1)`}) + res.Assert(t, icmd.Expected{Out: `ping_1 exited with code 0`}) + res.Assert(t, icmd.Expected{Out: `Aborting on container exit...`}) +} diff --git a/local/e2e/compose/fixtures/cascade-stop-test/compose.yaml b/local/e2e/compose/fixtures/cascade-stop-test/compose.yaml new file mode 100644 index 000000000..24ef57c7d --- /dev/null +++ b/local/e2e/compose/fixtures/cascade-stop-test/compose.yaml @@ -0,0 +1,4 @@ +services: + ping: + image: busybox:1.27.2 + command: ping localhost -c 1 From 06b033db6c3e46e9540ce586b146a2ca33828565 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Tue, 9 Feb 2021 11:53:35 +0100 Subject: [PATCH 4/9] `Event` is way too generic noon Signed-off-by: Nicolas De Loof --- api/compose/api.go | 9 +++------ cli/cmd/compose/up.go | 2 +- local/compose/start.go | 2 +- 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/api/compose/api.go b/api/compose/api.go index 7240eca25..3dcf17341 100644 --- a/api/compose/api.go +++ b/api/compose/api.go @@ -68,7 +68,7 @@ type StartOptions struct { // Attach will attach to container and pipe stdout/stderr to LogConsumer Attach LogConsumer // Listener will get notified on container events - Listener Listener + Listener chan ContainerExited } // UpOptions group options of the Up API @@ -188,11 +188,8 @@ type LogConsumer interface { Status(service, container, message string) } -// Listener get notified on container Events -type Listener chan Event - -// Event let us know a Container exited -type Event struct { +// ContainerExited let us know a Container exited +type ContainerExited struct { Service string Status int } diff --git a/cli/cmd/compose/up.go b/cli/cmd/compose/up.go index bdb50f0cc..32e99b6fe 100644 --- a/cli/cmd/compose/up.go +++ b/cli/cmd/compose/up.go @@ -151,7 +151,7 @@ func runCreateStart(ctx context.Context, opts upOptions, services []string) erro } ctx, cancel := context.WithCancel(ctx) - listener := make(chan compose.Event) + listener := make(chan compose.ContainerExited) go func() { var aborting bool for { diff --git a/local/compose/start.go b/local/compose/start.go index 3315587e3..30fcc5f21 100644 --- a/local/compose/start.go +++ b/local/compose/start.go @@ -64,7 +64,7 @@ func (s *composeService) Start(ctx context.Context, project *types.Project, opti service := c.Labels[serviceLabel] options.Attach.Status(service, getCanonicalContainerName(c), fmt.Sprintf("exited with code %d", status.StatusCode)) if options.Listener != nil { - options.Listener <- compose.Event{ + options.Listener <- compose.ContainerExited{ Service: service, Status: int(status.StatusCode), } From 17c26e81ff5a567b7319d4318ac20302d0c3c48d Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Tue, 9 Feb 2021 12:40:31 +0100 Subject: [PATCH 5/9] capture exit code and log as ERROR Signed-off-by: Nicolas De Loof --- cli/cmd/compose/up.go | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/cli/cmd/compose/up.go b/cli/cmd/compose/up.go index 32e99b6fe..ad41cac21 100644 --- a/cli/cmd/compose/up.go +++ b/cli/cmd/compose/up.go @@ -20,6 +20,7 @@ import ( "context" "errors" "fmt" + "github.com/sirupsen/logrus" "os" "path/filepath" @@ -152,14 +153,15 @@ func runCreateStart(ctx context.Context, opts upOptions, services []string) erro ctx, cancel := context.WithCancel(ctx) listener := make(chan compose.ContainerExited) + exitCode := make(chan int) go func() { var aborting bool for { - <-listener + exit := <-listener if opts.cascadeStop && !aborting { aborting = true - fmt.Println("Aborting on container exit...") cancel() + exitCode <- exit.Status } } }() @@ -170,15 +172,29 @@ func runCreateStart(ctx context.Context, opts upOptions, services []string) erro }) if errors.Is(ctx.Err(), context.Canceled) { - fmt.Println("Gracefully stopping...") - ctx = context.Background() - _, err = progress.Run(ctx, func(ctx context.Context) (string, error) { - return "", c.ComposeService().Stop(ctx, project) - }) + select { + case exit := <-exitCode: + fmt.Println("Aborting on container exit...") + err = stop(c, project) + logrus.Error(exit) + // os.Exit(exit) + default: + // cancelled by user + fmt.Println("Gracefully stopping...") + err = stop(c, project) + } } return err } +func stop(c *client.Client, project *types.Project) error { + ctx := context.Background() + _, err := progress.Run(ctx, func(ctx context.Context) (string, error) { + return "", c.ComposeService().Stop(ctx, project) + }) + return err +} + func setup(ctx context.Context, opts composeOptions, services []string) (*client.Client, *types.Project, error) { c, err := client.NewWithDefaultLocalBackend(ctx) if err != nil { From a4b003ecfa334ab7687d0d7b41ee83f03d6af73b Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Tue, 9 Feb 2021 16:51:48 +0100 Subject: [PATCH 6/9] pull logs and events better than aggregate events from multiple channels Signed-off-by: Nicolas De Loof --- api/compose/api.go | 26 ++++-- cli/cmd/compose/start.go | 26 ++++-- cli/cmd/compose/up.go | 84 +++++++++++-------- ecs/logs.go | 34 +------- local/compose/attach.go | 5 +- local/compose/logs.go | 34 +++++++- local/compose/start.go | 32 +++---- local/e2e/compose/cascade_stop_test.go | 4 +- .../fixtures/cascade-stop-test/compose.yaml | 5 +- utils/logconsumer.go | 6 ++ 10 files changed, 145 insertions(+), 111 deletions(-) diff --git a/api/compose/api.go b/api/compose/api.go index 3dcf17341..e19da94fa 100644 --- a/api/compose/api.go +++ b/api/compose/api.go @@ -65,10 +65,8 @@ type CreateOptions struct { // StartOptions group options of the Start API type StartOptions struct { - // Attach will attach to container and pipe stdout/stderr to LogConsumer - Attach LogConsumer - // Listener will get notified on container events - Listener chan ContainerExited + // Attach will attach to service containers and pipe stdout/stderr to channel + Attach chan ContainerEvent } // UpOptions group options of the Up API @@ -185,11 +183,21 @@ type Stack struct { // LogConsumer is a callback to process log messages from services type LogConsumer interface { Log(service, container, message string) - Status(service, container, message string) + Status(service, container, msg string) } -// ContainerExited let us know a Container exited -type ContainerExited struct { - Service string - Status int +// ContainerEvent notify an event has been collected on Source container implementing Service +type ContainerEvent struct { + Type int + Source string + Service string + Line string + ExitCode int } + +const ( + // ContainerEventLog is a ContainerEvent of type log. Line is set + ContainerEventLog = iota + // ContainerEventExit is a ContainerEvent of type exit. ExitCode is set + ContainerEventExit +) diff --git a/cli/cmd/compose/start.go b/cli/cmd/compose/start.go index 07be32ee6..94d994c04 100644 --- a/cli/cmd/compose/start.go +++ b/cli/cmd/compose/start.go @@ -18,14 +18,12 @@ package compose import ( "context" - "os" - - "github.com/spf13/cobra" "github.com/docker/compose-cli/api/client" "github.com/docker/compose-cli/api/compose" "github.com/docker/compose-cli/api/progress" - "github.com/docker/compose-cli/cli/formatter" + + "github.com/spf13/cobra" ) type startOptions struct { @@ -67,7 +65,23 @@ func runStart(ctx context.Context, opts startOptions, services []string) error { return err } - return c.ComposeService().Start(ctx, project, compose.StartOptions{ - Attach: formatter.NewLogConsumer(ctx, os.Stdout), + queue := make(chan compose.ContainerEvent) + printer := printer{ + queue: queue, + } + err = c.ComposeService().Start(ctx, project, compose.StartOptions{ + Attach: queue, }) + if err != nil { + return err + } + + _, err = printer.run(ctx, false, func() error { + ctx := context.Background() + _, err := progress.Run(ctx, func(ctx context.Context) (string, error) { + return "", c.ComposeService().Stop(ctx, project) + }) + return err + }) + return err } diff --git a/cli/cmd/compose/up.go b/cli/cmd/compose/up.go index ad41cac21..60e74cc47 100644 --- a/cli/cmd/compose/up.go +++ b/cli/cmd/compose/up.go @@ -18,11 +18,11 @@ package compose import ( "context" - "errors" "fmt" - "github.com/sirupsen/logrus" "os" + "os/signal" "path/filepath" + "syscall" "github.com/docker/compose-cli/api/client" "github.com/docker/compose-cli/api/compose" @@ -31,6 +31,7 @@ import ( "github.com/docker/compose-cli/cli/formatter" "github.com/compose-spec/compose-go/types" + "github.com/sirupsen/logrus" "github.com/spf13/cobra" ) @@ -151,47 +152,35 @@ func runCreateStart(ctx context.Context, opts upOptions, services []string) erro return nil } - ctx, cancel := context.WithCancel(ctx) - listener := make(chan compose.ContainerExited) - exitCode := make(chan int) + queue := make(chan compose.ContainerEvent) + printer := printer{ + queue: queue, + } + + stopFunc := func() error { + ctx := context.Background() + _, err := progress.Run(ctx, func(ctx context.Context) (string, error) { + return "", c.ComposeService().Stop(ctx, project) + }) + return err + } + signalChan := make(chan os.Signal, 1) + signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM) go func() { - var aborting bool - for { - exit := <-listener - if opts.cascadeStop && !aborting { - aborting = true - cancel() - exitCode <- exit.Status - } - } + <-signalChan + fmt.Println("Gracefully stopping...") + stopFunc() // nolint:errcheck }() err = c.ComposeService().Start(ctx, project, compose.StartOptions{ - Attach: formatter.NewLogConsumer(ctx, os.Stdout), - Listener: listener, + Attach: queue, }) - - if errors.Is(ctx.Err(), context.Canceled) { - select { - case exit := <-exitCode: - fmt.Println("Aborting on container exit...") - err = stop(c, project) - logrus.Error(exit) - // os.Exit(exit) - default: - // cancelled by user - fmt.Println("Gracefully stopping...") - err = stop(c, project) - } + if err != nil { + return err } - return err -} -func stop(c *client.Client, project *types.Project) error { - ctx := context.Background() - _, err := progress.Run(ctx, func(ctx context.Context) (string, error) { - return "", c.ComposeService().Stop(ctx, project) - }) + _, err = printer.run(ctx, opts.cascadeStop, stopFunc) + // FIXME os.Exit return err } @@ -235,3 +224,26 @@ func setup(ctx context.Context, opts composeOptions, services []string) (*client return c, project, nil } + +type printer struct { + queue chan compose.ContainerEvent +} + +func (p printer) run(ctx context.Context, cascadeStop bool, stopFn func() error) (int, error) { //nolint:unparam + consumer := formatter.NewLogConsumer(ctx, os.Stdout) + for { + event := <-p.queue + switch event.Type { + case compose.ContainerEventExit: + consumer.Status(event.Service, event.Source, fmt.Sprintf("exited with code %d", event.ExitCode)) + if cascadeStop { + fmt.Println("Aborting on container exit...") + err := stopFn() + logrus.Error(event.ExitCode) + return event.ExitCode, err + } + case compose.ContainerEventLog: + consumer.Log(event.Service, event.Source, event.Line) + } + } +} diff --git a/ecs/logs.go b/ecs/logs.go index e36cd3df6..59c6df44d 100644 --- a/ecs/logs.go +++ b/ecs/logs.go @@ -20,43 +20,13 @@ import ( "context" "github.com/docker/compose-cli/api/compose" + "github.com/docker/compose-cli/utils" ) func (b *ecsAPIService) Logs(ctx context.Context, projectName string, consumer compose.LogConsumer, options compose.LogOptions) error { if len(options.Services) > 0 { - consumer = filteredLogConsumer(consumer, options.Services) + consumer = utils.FilteredLogConsumer(consumer, options.Services) } err := b.aws.GetLogs(ctx, projectName, consumer.Log, options.Follow) return err } - -func filteredLogConsumer(consumer compose.LogConsumer, services []string) compose.LogConsumer { - if len(services) == 0 { - return consumer - } - allowed := map[string]bool{} - for _, s := range services { - allowed[s] = true - } - return &allowListLogConsumer{ - allowList: allowed, - delegate: consumer, - } -} - -type allowListLogConsumer struct { - allowList map[string]bool - delegate compose.LogConsumer -} - -func (a *allowListLogConsumer) Log(service, container, message string) { - if a.allowList[service] { - a.delegate.Log(service, container, message) - } -} - -func (a *allowListLogConsumer) Status(service, container, message string) { - if a.allowList[service] { - a.delegate.Status(service, container, message) - } -} diff --git a/local/compose/attach.go b/local/compose/attach.go index d0ecc963a..164e5b1be 100644 --- a/local/compose/attach.go +++ b/local/compose/attach.go @@ -24,14 +24,13 @@ import ( "github.com/docker/compose-cli/api/compose" convert "github.com/docker/compose-cli/local/moby" - "github.com/docker/compose-cli/utils" "github.com/compose-spec/compose-go/types" moby "github.com/docker/docker/api/types" "github.com/docker/docker/pkg/stdcopy" ) -func (s *composeService) attach(ctx context.Context, project *types.Project, consumer compose.LogConsumer) (Containers, error) { +func (s *composeService) attach(ctx context.Context, project *types.Project, consumer chan compose.ContainerEvent) (Containers, error) { containers, err := s.getContainers(ctx, project) if err != nil { return nil, err @@ -52,7 +51,7 @@ func (s *composeService) attach(ctx context.Context, project *types.Project, con return containers, nil } -func (s *composeService) attachContainer(ctx context.Context, container moby.Container, consumer compose.LogConsumer, project *types.Project) error { +func (s *composeService) attachContainer(ctx context.Context, container moby.Container, consumer chan compose.ContainerEvent, project *types.Project) error { serviceName := container.Labels[serviceLabel] w := getWriter(serviceName, getContainerNameWithoutProject(container), consumer) diff --git a/local/compose/logs.go b/local/compose/logs.go index ce6b2f89c..2d47c9987 100644 --- a/local/compose/logs.go +++ b/local/compose/logs.go @@ -17,6 +17,7 @@ package compose import ( + "bytes" "context" "io" @@ -52,6 +53,7 @@ func (s *composeService) Logs(ctx context.Context, projectName string, consumer } eg, ctx := errgroup.WithContext(ctx) for _, c := range list { + c := c service := c.Labels[serviceLabel] if ignore(service) { continue @@ -73,7 +75,7 @@ func (s *composeService) Logs(ctx context.Context, projectName string, consumer if err != nil { return err } - w := utils.GetWriter(service, container.Name[1:], consumer) + w := utils.GetWriter(service, getContainerNameWithoutProject(c), consumer) if container.Config.Tty { _, err = io.Copy(w, r) } else { @@ -84,3 +86,33 @@ func (s *composeService) Logs(ctx context.Context, projectName string, consumer } return eg.Wait() } + +type splitBuffer struct { + service string + container string + consumer chan compose.ContainerEvent +} + +// getWriter creates a io.Writer that will actually split by line and format by LogConsumer +func getWriter(service, container string, events chan compose.ContainerEvent) io.Writer { + return splitBuffer{ + service: service, + container: container, + consumer: events, + } +} + +func (s splitBuffer) Write(b []byte) (n int, err error) { + split := bytes.Split(b, []byte{'\n'}) + for _, line := range split { + if len(line) != 0 { + s.consumer <- compose.ContainerEvent{ + Type: compose.ContainerEventLog, + Service: s.service, + Source: s.container, + Line: string(line), + } + } + } + return len(b), nil +} diff --git a/local/compose/start.go b/local/compose/start.go index 30fcc5f21..7c4b2f076 100644 --- a/local/compose/start.go +++ b/local/compose/start.go @@ -18,13 +18,12 @@ package compose import ( "context" - "fmt" "github.com/docker/compose-cli/api/compose" "github.com/compose-spec/compose-go/types" "github.com/docker/docker/api/types/container" - "golang.org/x/sync/errgroup" + "github.com/sirupsen/logrus" ) func (s *composeService) Start(ctx context.Context, project *types.Project, options compose.StartOptions) error { @@ -35,12 +34,6 @@ func (s *composeService) Start(ctx context.Context, project *types.Project, opti return err } containers = c - } else { - c, err := s.getContainers(ctx, project) - if err != nil { - return err - } - containers = c } err := InDependencyOrder(ctx, project, func(c context.Context, service types.ServiceConfig) error { @@ -54,26 +47,21 @@ func (s *composeService) Start(ctx context.Context, project *types.Project, opti return nil } - eg, ctx := errgroup.WithContext(ctx) for _, c := range containers { c := c - eg.Go(func() error { - statusC, errC := s.apiClient.ContainerWait(ctx, c.ID, container.WaitConditionNotRunning) + go func() { + statusC, errC := s.apiClient.ContainerWait(context.Background(), c.ID, container.WaitConditionNotRunning) select { case status := <-statusC: - service := c.Labels[serviceLabel] - options.Attach.Status(service, getCanonicalContainerName(c), fmt.Sprintf("exited with code %d", status.StatusCode)) - if options.Listener != nil { - options.Listener <- compose.ContainerExited{ - Service: service, - Status: int(status.StatusCode), - } + options.Attach <- compose.ContainerEvent{ + Type: compose.ContainerEventExit, + Source: getCanonicalContainerName(c), + ExitCode: int(status.StatusCode), } - return nil case err := <-errC: - return err + logrus.Warnf("Unexpected API error for %s : %s\n", getCanonicalContainerName(c), err.Error()) } - }) + }() } - return eg.Wait() + return nil } diff --git a/local/e2e/compose/cascade_stop_test.go b/local/e2e/compose/cascade_stop_test.go index 449e825e3..1cb060784 100644 --- a/local/e2e/compose/cascade_stop_test.go +++ b/local/e2e/compose/cascade_stop_test.go @@ -31,6 +31,8 @@ func TestCascadeStop(t *testing.T) { res := c.RunDockerCmd("compose", "-f", "./fixtures/cascade-stop-test/compose.yaml", "--project-name", projectName, "up", "--abort-on-container-exit") res.Assert(t, icmd.Expected{Out: `PING localhost (127.0.0.1)`}) - res.Assert(t, icmd.Expected{Out: `ping_1 exited with code 0`}) + res.Assert(t, icmd.Expected{Out: `/does_not_exist: No such file or directory`}) + res.Assert(t, icmd.Expected{Out: `should_fail_1 exited with code 1`}) res.Assert(t, icmd.Expected{Out: `Aborting on container exit...`}) + // FIXME res.Assert(t, icmd.Expected{ExitCode: 1}) } diff --git a/local/e2e/compose/fixtures/cascade-stop-test/compose.yaml b/local/e2e/compose/fixtures/cascade-stop-test/compose.yaml index 24ef57c7d..e30fd3a5d 100644 --- a/local/e2e/compose/fixtures/cascade-stop-test/compose.yaml +++ b/local/e2e/compose/fixtures/cascade-stop-test/compose.yaml @@ -1,4 +1,7 @@ services: + should_fail: + image: busybox:1.27.2 + command: ls /does_not_exist ping: image: busybox:1.27.2 - command: ping localhost -c 1 + command: ping localhost diff --git a/utils/logconsumer.go b/utils/logconsumer.go index 2dd7e4963..8caf6ba22 100644 --- a/utils/logconsumer.go +++ b/utils/logconsumer.go @@ -58,6 +58,12 @@ func (a *allowListLogConsumer) Log(service, container, message string) { } } +func (a *allowListLogConsumer) Status(service, container, message string) { + if a.allowList[service] { + a.delegate.Status(service, container, message) + } +} + type splitBuffer struct { service string container string From 752edcce65cd2d84e5be846f5daba2cd588a6de2 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Tue, 9 Feb 2021 19:17:12 +0100 Subject: [PATCH 7/9] introduce `--exit-code-from` Signed-off-by: Nicolas De Loof --- cli/cmd/compose/start.go | 2 +- cli/cmd/compose/up.go | 28 ++++++++++++++++++++++------ local/compose/start.go | 1 + 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/cli/cmd/compose/start.go b/cli/cmd/compose/start.go index 94d994c04..97792146e 100644 --- a/cli/cmd/compose/start.go +++ b/cli/cmd/compose/start.go @@ -76,7 +76,7 @@ func runStart(ctx context.Context, opts startOptions, services []string) error { return err } - _, err = printer.run(ctx, false, func() error { + _, err = printer.run(ctx, false, "", func() error { ctx := context.Background() _, err := progress.Run(ctx, func(ctx context.Context) (string, error) { return "", c.ComposeService().Stop(ctx, project) diff --git a/cli/cmd/compose/up.go b/cli/cmd/compose/up.go index 60e74cc47..6585c8e91 100644 --- a/cli/cmd/compose/up.go +++ b/cli/cmd/compose/up.go @@ -52,6 +52,7 @@ type upOptions struct { noRecreate bool noStart bool cascadeStop bool + exitCodeFrom string } func (o upOptions) recreateStrategy() string { @@ -76,6 +77,9 @@ func upCommand(p *projectOptions, contextType string) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { switch contextType { case store.LocalContextType, store.DefaultContextType, store.EcsLocalSimulationContextType: + if opts.exitCodeFrom != "" { + opts.cascadeStop = true + } if opts.cascadeStop && opts.Detach { return fmt.Errorf("--abort-on-container-exit and --detach are incompatible") } @@ -102,6 +106,7 @@ func upCommand(p *projectOptions, contextType string) *cobra.Command { flags.BoolVar(&opts.noRecreate, "no-recreate", false, "If containers already exist, don't recreate them. Incompatible with --force-recreate.") flags.BoolVar(&opts.noStart, "no-start", false, "Don't start the services after creating them.") flags.BoolVar(&opts.cascadeStop, "abort-on-container-exit", false, "Stops all containers if any container was stopped. Incompatible with -d") + flags.StringVar(&opts.exitCodeFrom, "exit-code-from", "", "Return the exit code of the selected service container. Implies --abort-on-container-exit") } return upCmd @@ -179,7 +184,7 @@ func runCreateStart(ctx context.Context, opts upOptions, services []string) erro return err } - _, err = printer.run(ctx, opts.cascadeStop, stopFunc) + _, err = printer.run(ctx, opts.cascadeStop, opts.exitCodeFrom, stopFunc) // FIXME os.Exit return err } @@ -229,21 +234,32 @@ type printer struct { queue chan compose.ContainerEvent } -func (p printer) run(ctx context.Context, cascadeStop bool, stopFn func() error) (int, error) { //nolint:unparam +func (p printer) run(ctx context.Context, cascadeStop bool, exitCodeFrom string, stopFn func() error) (int, error) { //nolint:unparam consumer := formatter.NewLogConsumer(ctx, os.Stdout) + var aborting bool for { event := <-p.queue switch event.Type { case compose.ContainerEventExit: - consumer.Status(event.Service, event.Source, fmt.Sprintf("exited with code %d", event.ExitCode)) - if cascadeStop { + if !aborting { + consumer.Status(event.Service, event.Source, fmt.Sprintf("exited with code %d", event.ExitCode)) + } + if cascadeStop && !aborting { + aborting = true fmt.Println("Aborting on container exit...") err := stopFn() + if err != nil { + return 0, err + } + } + if exitCodeFrom == "" || exitCodeFrom == event.Service { logrus.Error(event.ExitCode) - return event.ExitCode, err + return event.ExitCode, nil } case compose.ContainerEventLog: - consumer.Log(event.Service, event.Source, event.Line) + if !aborting { + consumer.Log(event.Service, event.Source, event.Line) + } } } } diff --git a/local/compose/start.go b/local/compose/start.go index 7c4b2f076..83ecfeabd 100644 --- a/local/compose/start.go +++ b/local/compose/start.go @@ -56,6 +56,7 @@ func (s *composeService) Start(ctx context.Context, project *types.Project, opti options.Attach <- compose.ContainerEvent{ Type: compose.ContainerEventExit, Source: getCanonicalContainerName(c), + Service: c.Labels[serviceLabel], ExitCode: int(status.StatusCode), } case err := <-errC: From d9fe745cc0e5acff1234de2caec550112056846e Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Wed, 10 Feb 2021 14:20:32 +0100 Subject: [PATCH 8/9] avoid use of channels in API for gRPC compatibility Signed-off-by: Nicolas De Loof --- api/compose/api.go | 5 +++- cli/cmd/compose/start.go | 4 ++- cli/cmd/compose/up.go | 11 ++++++-- cli/cmd/exit.go | 28 +++++++++++++++++++ cli/main.go | 9 ++++++ go.sum | 1 + local/compose/attach.go | 4 +-- local/compose/logs.go | 8 +++--- local/compose/start.go | 4 +-- local/e2e/compose/cascade_stop_test.go | 23 +++++++++++---- .../fixtures/cascade-stop-test/compose.yaml | 4 +-- 11 files changed, 80 insertions(+), 21 deletions(-) create mode 100644 cli/cmd/exit.go diff --git a/api/compose/api.go b/api/compose/api.go index e19da94fa..3d3f063d1 100644 --- a/api/compose/api.go +++ b/api/compose/api.go @@ -66,7 +66,7 @@ type CreateOptions struct { // StartOptions group options of the Start API type StartOptions struct { // Attach will attach to service containers and pipe stdout/stderr to channel - Attach chan ContainerEvent + Attach ContainerEventListener } // UpOptions group options of the Up API @@ -186,6 +186,9 @@ type LogConsumer interface { Status(service, container, msg string) } +// ContainerEventListener is a callback to process ContainerEvent from services +type ContainerEventListener func(event ContainerEvent) + // ContainerEvent notify an event has been collected on Source container implementing Service type ContainerEvent struct { Type int diff --git a/cli/cmd/compose/start.go b/cli/cmd/compose/start.go index 97792146e..1b0abb604 100644 --- a/cli/cmd/compose/start.go +++ b/cli/cmd/compose/start.go @@ -70,7 +70,9 @@ func runStart(ctx context.Context, opts startOptions, services []string) error { queue: queue, } err = c.ComposeService().Start(ctx, project, compose.StartOptions{ - Attach: queue, + Attach: func(event compose.ContainerEvent) { + queue <- event + }, }) if err != nil { return err diff --git a/cli/cmd/compose/up.go b/cli/cmd/compose/up.go index 6585c8e91..537900e17 100644 --- a/cli/cmd/compose/up.go +++ b/cli/cmd/compose/up.go @@ -28,6 +28,7 @@ import ( "github.com/docker/compose-cli/api/compose" "github.com/docker/compose-cli/api/context/store" "github.com/docker/compose-cli/api/progress" + "github.com/docker/compose-cli/cli/cmd" "github.com/docker/compose-cli/cli/formatter" "github.com/compose-spec/compose-go/types" @@ -178,14 +179,18 @@ func runCreateStart(ctx context.Context, opts upOptions, services []string) erro }() err = c.ComposeService().Start(ctx, project, compose.StartOptions{ - Attach: queue, + Attach: func(event compose.ContainerEvent) { + queue <- event + }, }) if err != nil { return err } - _, err = printer.run(ctx, opts.cascadeStop, opts.exitCodeFrom, stopFunc) - // FIXME os.Exit + exitCode, err := printer.run(ctx, opts.cascadeStop, opts.exitCodeFrom, stopFunc) + if exitCode != 0 { + return cmd.ExitCodeError{ExitCode: exitCode} + } return err } diff --git a/cli/cmd/exit.go b/cli/cmd/exit.go new file mode 100644 index 000000000..fa45ad889 --- /dev/null +++ b/cli/cmd/exit.go @@ -0,0 +1,28 @@ +/* + 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. +*/ + +package cmd + +import "strconv" + +// ExitCodeError reports an exit code set by command. +type ExitCodeError struct { + ExitCode int +} + +func (e ExitCodeError) Error() string { + return strconv.Itoa(e.ExitCode) +} diff --git a/cli/main.go b/cli/main.go index d05720ca3..601657de0 100644 --- a/cli/main.go +++ b/cli/main.go @@ -170,6 +170,10 @@ func main() { fmt.Fprintf(os.Stderr, "Unable to parse logging level: %s\n", opts.LogLevel) os.Exit(1) } + logrus.SetFormatter(&logrus.TextFormatter{ + DisableTimestamp: true, + DisableLevelTruncation: true, + }) logrus.SetLevel(level) if opts.Debug { logrus.SetLevel(logrus.DebugLevel) @@ -241,6 +245,11 @@ $ docker context create %s `, cc.Type(), store.EcsContextType), ctype) } func exit(ctx string, err error, ctype string) { + if exit, ok := err.(cmd.ExitCodeError); ok { + metrics.Track(ctype, os.Args[1:], metrics.SuccessStatus) + os.Exit(exit.ExitCode) + } + metrics.Track(ctype, os.Args[1:], metrics.FailureStatus) if errors.Is(err, errdefs.ErrLoginRequired) { diff --git a/go.sum b/go.sum index 2480a1a71..4a2b0121f 100644 --- a/go.sum +++ b/go.sum @@ -485,6 +485,7 @@ github.com/franela/goreq v0.0.0-20171204163338-bcd34c9993f8/go.mod h1:ZhphrRTfi2 github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/fvbommel/sortorder v1.0.1 h1:dSnXLt4mJYH25uDDGa3biZNQsozaUWDSWeKJ0qqFfzE= github.com/fvbommel/sortorder v1.0.1/go.mod h1:uk88iVf1ovNn1iLfgUVU2F9o5eO30ui720w+kxuqRs0= github.com/garyburd/redigo v0.0.0-20150301180006-535138d7bcd7 h1:LofdAjjjqCSXMwLGgOgnE+rdPuvX9DxCqaHwKy7i/ko= github.com/garyburd/redigo v0.0.0-20150301180006-535138d7bcd7/go.mod h1:NR3MbYisc3/PwhQ00EMzDiPmrwpPxAn5GI05/YaO1SY= diff --git a/local/compose/attach.go b/local/compose/attach.go index 164e5b1be..d72e52e95 100644 --- a/local/compose/attach.go +++ b/local/compose/attach.go @@ -30,7 +30,7 @@ import ( "github.com/docker/docker/pkg/stdcopy" ) -func (s *composeService) attach(ctx context.Context, project *types.Project, consumer chan compose.ContainerEvent) (Containers, error) { +func (s *composeService) attach(ctx context.Context, project *types.Project, consumer compose.ContainerEventListener) (Containers, error) { containers, err := s.getContainers(ctx, project) if err != nil { return nil, err @@ -51,7 +51,7 @@ func (s *composeService) attach(ctx context.Context, project *types.Project, con return containers, nil } -func (s *composeService) attachContainer(ctx context.Context, container moby.Container, consumer chan compose.ContainerEvent, project *types.Project) error { +func (s *composeService) attachContainer(ctx context.Context, container moby.Container, consumer compose.ContainerEventListener, project *types.Project) error { serviceName := container.Labels[serviceLabel] w := getWriter(serviceName, getContainerNameWithoutProject(container), consumer) diff --git a/local/compose/logs.go b/local/compose/logs.go index 2d47c9987..6835d9681 100644 --- a/local/compose/logs.go +++ b/local/compose/logs.go @@ -90,11 +90,11 @@ func (s *composeService) Logs(ctx context.Context, projectName string, consumer type splitBuffer struct { service string container string - consumer chan compose.ContainerEvent + consumer compose.ContainerEventListener } // getWriter creates a io.Writer that will actually split by line and format by LogConsumer -func getWriter(service, container string, events chan compose.ContainerEvent) io.Writer { +func getWriter(service, container string, events compose.ContainerEventListener) io.Writer { return splitBuffer{ service: service, container: container, @@ -106,12 +106,12 @@ func (s splitBuffer) Write(b []byte) (n int, err error) { split := bytes.Split(b, []byte{'\n'}) for _, line := range split { if len(line) != 0 { - s.consumer <- compose.ContainerEvent{ + s.consumer(compose.ContainerEvent{ Type: compose.ContainerEventLog, Service: s.service, Source: s.container, Line: string(line), - } + }) } } return len(b), nil diff --git a/local/compose/start.go b/local/compose/start.go index 83ecfeabd..c1aa7cc9c 100644 --- a/local/compose/start.go +++ b/local/compose/start.go @@ -53,12 +53,12 @@ func (s *composeService) Start(ctx context.Context, project *types.Project, opti statusC, errC := s.apiClient.ContainerWait(context.Background(), c.ID, container.WaitConditionNotRunning) select { case status := <-statusC: - options.Attach <- compose.ContainerEvent{ + options.Attach(compose.ContainerEvent{ Type: compose.ContainerEventExit, Source: getCanonicalContainerName(c), Service: c.Labels[serviceLabel], ExitCode: int(status.StatusCode), - } + }) case err := <-errC: logrus.Warnf("Unexpected API error for %s : %s\n", getCanonicalContainerName(c), err.Error()) } diff --git a/local/e2e/compose/cascade_stop_test.go b/local/e2e/compose/cascade_stop_test.go index 1cb060784..34c2e09a7 100644 --- a/local/e2e/compose/cascade_stop_test.go +++ b/local/e2e/compose/cascade_stop_test.go @@ -29,10 +29,21 @@ func TestCascadeStop(t *testing.T) { const projectName = "compose-e2e-logs" - res := c.RunDockerCmd("compose", "-f", "./fixtures/cascade-stop-test/compose.yaml", "--project-name", projectName, "up", "--abort-on-container-exit") - res.Assert(t, icmd.Expected{Out: `PING localhost (127.0.0.1)`}) - res.Assert(t, icmd.Expected{Out: `/does_not_exist: No such file or directory`}) - res.Assert(t, icmd.Expected{Out: `should_fail_1 exited with code 1`}) - res.Assert(t, icmd.Expected{Out: `Aborting on container exit...`}) - // FIXME res.Assert(t, icmd.Expected{ExitCode: 1}) + t.Run("abort-on-container-exit", func(t *testing.T) { + res := c.RunDockerCmd("compose", "-f", "./fixtures/cascade-stop-test/compose.yaml", "--project-name", projectName, "up", "--abort-on-container-exit") + res.Assert(t, icmd.Expected{Out: `/does_not_exist: No such file or directory`}) + res.Assert(t, icmd.Expected{Out: `should_fail_1 exited with code 1`}) + res.Assert(t, icmd.Expected{Out: `Aborting on container exit...`}) + res.Assert(t, icmd.Expected{Out: `ERROR 1`}) + res.Assert(t, icmd.Expected{ExitCode: 1}) + }) + + t.Run("exit-code-from", func(t *testing.T) { + res := c.RunDockerCmd("compose", "-f", "./fixtures/cascade-stop-test/compose.yaml", "--project-name", projectName, "up", "--exit-code-from=sleep") + res.Assert(t, icmd.Expected{Out: `/does_not_exist: No such file or directory`}) + res.Assert(t, icmd.Expected{Out: `should_fail_1 exited with code 1`}) + res.Assert(t, icmd.Expected{Out: `Aborting on container exit...`}) + res.Assert(t, icmd.Expected{Out: `ERROR 143`}) + res.Assert(t, icmd.Expected{ExitCode: 143}) + }) } diff --git a/local/e2e/compose/fixtures/cascade-stop-test/compose.yaml b/local/e2e/compose/fixtures/cascade-stop-test/compose.yaml index e30fd3a5d..b6844afaa 100644 --- a/local/e2e/compose/fixtures/cascade-stop-test/compose.yaml +++ b/local/e2e/compose/fixtures/cascade-stop-test/compose.yaml @@ -2,6 +2,6 @@ services: should_fail: image: busybox:1.27.2 command: ls /does_not_exist - ping: + sleep: # will be killed image: busybox:1.27.2 - command: ping localhost + command: sleep 10 From 8b90814de63d12782c749ea324279c27be19092a Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Thu, 11 Feb 2021 10:45:11 +0100 Subject: [PATCH 9/9] e2e test Signed-off-by: Nicolas De Loof --- api/compose/api.go | 2 +- cli/cmd/compose/up.go | 7 ++++++ local/e2e/compose/cascade_stop_test.go | 23 +++++++++---------- .../fixtures/cascade-stop-test/compose.yaml | 2 +- 4 files changed, 20 insertions(+), 14 deletions(-) diff --git a/api/compose/api.go b/api/compose/api.go index 3d3f063d1..417edd699 100644 --- a/api/compose/api.go +++ b/api/compose/api.go @@ -65,7 +65,7 @@ type CreateOptions struct { // StartOptions group options of the Start API type StartOptions struct { - // Attach will attach to service containers and pipe stdout/stderr to channel + // Attach will attach to service containers and send container logs and events Attach ContainerEventListener } diff --git a/cli/cmd/compose/up.go b/cli/cmd/compose/up.go index 537900e17..99719f43b 100644 --- a/cli/cmd/compose/up.go +++ b/cli/cmd/compose/up.go @@ -133,6 +133,13 @@ func runCreateStart(ctx context.Context, opts upOptions, services []string) erro return err } + if opts.exitCodeFrom != "" { + _, err := project.GetService(opts.exitCodeFrom) + if err != nil { + return err + } + } + _, err = progress.Run(ctx, func(ctx context.Context) (string, error) { err := c.ComposeService().Create(ctx, project, compose.CreateOptions{ RemoveOrphans: opts.removeOrphans, diff --git a/local/e2e/compose/cascade_stop_test.go b/local/e2e/compose/cascade_stop_test.go index 34c2e09a7..b25f54385 100644 --- a/local/e2e/compose/cascade_stop_test.go +++ b/local/e2e/compose/cascade_stop_test.go @@ -30,20 +30,19 @@ func TestCascadeStop(t *testing.T) { const projectName = "compose-e2e-logs" t.Run("abort-on-container-exit", func(t *testing.T) { - res := c.RunDockerCmd("compose", "-f", "./fixtures/cascade-stop-test/compose.yaml", "--project-name", projectName, "up", "--abort-on-container-exit") - res.Assert(t, icmd.Expected{Out: `/does_not_exist: No such file or directory`}) - res.Assert(t, icmd.Expected{Out: `should_fail_1 exited with code 1`}) - res.Assert(t, icmd.Expected{Out: `Aborting on container exit...`}) - res.Assert(t, icmd.Expected{Out: `ERROR 1`}) - res.Assert(t, icmd.Expected{ExitCode: 1}) + res := c.RunDockerOrExitError("compose", "-f", "./fixtures/cascade-stop-test/compose.yaml", "--project-name", projectName, "up", "--abort-on-container-exit") + res.Assert(t, icmd.Expected{ExitCode: 1, Out: `should_fail_1 exited with code 1`}) + res.Assert(t, icmd.Expected{ExitCode: 1, Out: `Aborting on container exit...`}) }) t.Run("exit-code-from", func(t *testing.T) { - res := c.RunDockerCmd("compose", "-f", "./fixtures/cascade-stop-test/compose.yaml", "--project-name", projectName, "up", "--exit-code-from=sleep") - res.Assert(t, icmd.Expected{Out: `/does_not_exist: No such file or directory`}) - res.Assert(t, icmd.Expected{Out: `should_fail_1 exited with code 1`}) - res.Assert(t, icmd.Expected{Out: `Aborting on container exit...`}) - res.Assert(t, icmd.Expected{Out: `ERROR 143`}) - res.Assert(t, icmd.Expected{ExitCode: 143}) + res := c.RunDockerOrExitError("compose", "-f", "./fixtures/cascade-stop-test/compose.yaml", "--project-name", projectName, "up", "--exit-code-from=sleep") + res.Assert(t, icmd.Expected{ExitCode: 137, Out: `should_fail_1 exited with code 1`}) + res.Assert(t, icmd.Expected{ExitCode: 137, Out: `Aborting on container exit...`}) + }) + + t.Run("exit-code-from unknown", func(t *testing.T) { + res := c.RunDockerOrExitError("compose", "-f", "./fixtures/cascade-stop-test/compose.yaml", "--project-name", projectName, "up", "--exit-code-from=unknown") + res.Assert(t, icmd.Expected{ExitCode: 1, Err: `no such service: unknown`}) }) } diff --git a/local/e2e/compose/fixtures/cascade-stop-test/compose.yaml b/local/e2e/compose/fixtures/cascade-stop-test/compose.yaml index b6844afaa..5473670f7 100644 --- a/local/e2e/compose/fixtures/cascade-stop-test/compose.yaml +++ b/local/e2e/compose/fixtures/cascade-stop-test/compose.yaml @@ -4,4 +4,4 @@ services: command: ls /does_not_exist sleep: # will be killed image: busybox:1.27.2 - command: sleep 10 + command: ping localhost