From 29308cb97e90257ccd4a94f27ee3e6b6e1d3431f Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Tue, 1 Jul 2025 17:14:03 +0200 Subject: [PATCH] keep containers attached on stop to capture termination logs Signed-off-by: Nicolas De Loof --- cmd/formatter/ansi.go | 22 ++--- cmd/formatter/logs.go | 7 +- cmd/formatter/shortcut.go | 40 ++++----- cmd/formatter/stopping.go | 119 +++++++++++++++++++++++++++ pkg/compose/convergence.go | 2 +- pkg/compose/create.go | 4 +- pkg/compose/down.go | 18 ++-- pkg/compose/printer.go | 4 +- pkg/compose/stop.go | 6 +- pkg/compose/up.go | 35 ++++---- pkg/e2e/fixtures/stop/compose.yaml | 9 ++ pkg/e2e/up_test.go | 17 ++++ pkg/mocks/mock_docker_api.go | 40 ++++----- pkg/mocks/mock_docker_cli.go | 1 + pkg/mocks/mock_docker_compose_api.go | 1 + pkg/progress/event.go | 2 +- pkg/progress/mixed.go | 76 +++++++++++++++++ pkg/progress/spinner.go | 12 +-- pkg/progress/tty.go | 2 +- pkg/progress/tty_test.go | 8 +- pkg/progress/writer.go | 19 +++++ 21 files changed, 352 insertions(+), 92 deletions(-) create mode 100644 cmd/formatter/stopping.go create mode 100644 pkg/e2e/fixtures/stop/compose.yaml create mode 100644 pkg/progress/mixed.go diff --git a/cmd/formatter/ansi.go b/cmd/formatter/ansi.go index fb7ebaa2d..0d59cf501 100644 --- a/cmd/formatter/ansi.go +++ b/cmd/formatter/ansi.go @@ -28,49 +28,49 @@ func ansi(code string) string { return fmt.Sprintf("\033%s", code) } -func SaveCursor() { +func saveCursor() { if disableAnsi { return } fmt.Print(ansi("7")) } -func RestoreCursor() { +func restoreCursor() { if disableAnsi { return } fmt.Print(ansi("8")) } -func HideCursor() { +func hideCursor() { if disableAnsi { return } fmt.Print(ansi("[?25l")) } -func ShowCursor() { +func showCursor() { if disableAnsi { return } fmt.Print(ansi("[?25h")) } -func MoveCursor(y, x int) { +func moveCursor(y, x int) { if disableAnsi { return } fmt.Print(ansi(fmt.Sprintf("[%d;%dH", y, x))) } -func MoveCursorX(pos int) { +func carriageReturn() { if disableAnsi { return } - fmt.Print(ansi(fmt.Sprintf("[%dG", pos))) + fmt.Print(ansi(fmt.Sprintf("[%dG", 0))) } -func ClearLine() { +func clearLine() { if disableAnsi { return } @@ -78,7 +78,7 @@ func ClearLine() { fmt.Print(ansi("[2K")) } -func MoveCursorUp(lines int) { +func moveCursorUp(lines int) { if disableAnsi { return } @@ -86,7 +86,7 @@ func MoveCursorUp(lines int) { fmt.Print(ansi(fmt.Sprintf("[%dA", lines))) } -func MoveCursorDown(lines int) { +func moveCursorDown(lines int) { if disableAnsi { return } @@ -94,7 +94,7 @@ func MoveCursorDown(lines int) { fmt.Print(ansi(fmt.Sprintf("[%dB", lines))) } -func NewLine() { +func newLine() { // Like \n fmt.Print("\012") } diff --git a/cmd/formatter/logs.go b/cmd/formatter/logs.go index 0c0cb1353..0e2d206c8 100644 --- a/cmd/formatter/logs.go +++ b/cmd/formatter/logs.go @@ -73,9 +73,12 @@ func (l *logConsumer) register(name string) *presenter { } else { cf := monochrome if l.color { - if name == api.WatchLogger { + switch name { + case "": + cf = monochrome + case api.WatchLogger: cf = makeColorFunc("92") - } else { + default: cf = nextColor() } } diff --git a/cmd/formatter/shortcut.go b/cmd/formatter/shortcut.go index 7776e5f1e..d1c736319 100644 --- a/cmd/formatter/shortcut.go +++ b/cmd/formatter/shortcut.go @@ -48,8 +48,8 @@ func (ke *KeyboardError) printError(height int, info string) { if ke.shouldDisplay() { errMessage := ke.err.Error() - MoveCursor(height-1-extraLines(info)-extraLines(errMessage), 0) - ClearLine() + moveCursor(height-1-extraLines(info)-extraLines(errMessage), 0) + clearLine() fmt.Print(errMessage) } @@ -133,7 +133,7 @@ func (lk *LogKeyboard) createBuffer(lines int) { if lines > 0 { allocateSpace(lines) - MoveCursorUp(lines) + moveCursorUp(lines) } } @@ -146,17 +146,17 @@ func (lk *LogKeyboard) printNavigationMenu() { height := goterm.Height() menu := lk.navigationMenu() - MoveCursorX(0) - SaveCursor() + carriageReturn() + saveCursor() lk.kError.printError(height, menu) - MoveCursor(height-extraLines(menu), 0) - ClearLine() + moveCursor(height-extraLines(menu), 0) + clearLine() fmt.Print(menu) - MoveCursorX(0) - RestoreCursor() + carriageReturn() + restoreCursor() } } @@ -188,15 +188,15 @@ func (lk *LogKeyboard) navigationMenu() string { func (lk *LogKeyboard) clearNavigationMenu() { height := goterm.Height() - MoveCursorX(0) - SaveCursor() + carriageReturn() + saveCursor() - // ClearLine() + // clearLine() for i := 0; i < height; i++ { - MoveCursorDown(1) - ClearLine() + moveCursorDown(1) + clearLine() } - RestoreCursor() + restoreCursor() } func (lk *LogKeyboard) openDockerDesktop(ctx context.Context, project *types.Project) { @@ -316,13 +316,13 @@ func (lk *LogKeyboard) HandleKeyEvents(ctx context.Context, event keyboard.KeyEv case keyboard.KeyCtrlC: _ = keyboard.Close() lk.clearNavigationMenu() - ShowCursor() + showCursor() lk.logLevel = NONE // will notify main thread to kill and will handle gracefully lk.signalChannel <- syscall.SIGINT case keyboard.KeyEnter: - NewLine() + newLine() lk.printNavigationMenu() } } @@ -336,9 +336,9 @@ func (lk *LogKeyboard) EnableWatch(enabled bool, watcher Feature) { func allocateSpace(lines int) { for i := 0; i < lines; i++ { - ClearLine() - NewLine() - MoveCursorX(0) + clearLine() + newLine() + carriageReturn() } } diff --git a/cmd/formatter/stopping.go b/cmd/formatter/stopping.go new file mode 100644 index 000000000..afa248673 --- /dev/null +++ b/cmd/formatter/stopping.go @@ -0,0 +1,119 @@ +/* + Copyright 2024 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 formatter + +import ( + "fmt" + "strings" + "time" + + "github.com/buger/goterm" + "github.com/docker/compose/v2/pkg/api" + "github.com/docker/compose/v2/pkg/progress" +) + +type Stopping struct { + api.LogConsumer + enabled bool + spinner *progress.Spinner + ticker *time.Ticker + startedAt time.Time +} + +func NewStopping(l api.LogConsumer) *Stopping { + s := &Stopping{} + s.LogConsumer = logDecorator{ + decorated: l, + Before: s.clear, + After: s.print, + } + return s +} + +func (s *Stopping) ApplicationTermination() { + if progress.Mode != progress.ModeAuto { + // User explicitly opted for output format + return + } + if disableAnsi { + return + } + s.enabled = true + s.spinner = progress.NewSpinner() + hideCursor() + s.startedAt = time.Now() + s.ticker = time.NewTicker(100 * time.Millisecond) + go func() { + for { + <-s.ticker.C + s.print() + } + }() +} + +func (s *Stopping) Close() { + showCursor() + if s.ticker != nil { + s.ticker.Stop() + } + s.clear() +} + +func (s *Stopping) clear() { + if !s.enabled { + return + } + + height := goterm.Height() + carriageReturn() + saveCursor() + + // clearLine() + for i := 0; i < height; i++ { + moveCursorDown(1) + clearLine() + } + restoreCursor() +} + +const stoppingBanner = "Gracefully Stopping... (press Ctrl+C again to force)" + +func (s *Stopping) print() { + if !s.enabled { + return + } + + height := goterm.Height() + width := goterm.Width() + carriageReturn() + saveCursor() + + moveCursor(height, 0) + clearLine() + elapsed := time.Since(s.startedAt).Seconds() + timer := fmt.Sprintf("%.1fs ", elapsed) + pad := width - len(timer) - len(stoppingBanner) - 5 + fmt.Printf("%s %s %s %s", + progress.CountColor(s.spinner.String()), + stoppingBanner, + strings.Repeat(" ", pad), + progress.TimerColor(timer), + ) + + carriageReturn() + restoreCursor() +} diff --git a/pkg/compose/convergence.go b/pkg/compose/convergence.go index 098420f71..a4b0d0e6c 100644 --- a/pkg/compose/convergence.go +++ b/pkg/compose/convergence.go @@ -236,7 +236,7 @@ func (c *convergence) stopDependentContainers(ctx context.Context, project *type err := c.service.stop(ctx, project.Name, api.StopOptions{ Services: dependents, Project: project, - }) + }, nil) if err != nil { return err } diff --git a/pkg/compose/create.go b/pkg/compose/create.go index e2fe3c6a6..b8b0afea4 100644 --- a/pkg/compose/create.go +++ b/pkg/compose/create.go @@ -1428,7 +1428,7 @@ func (s *composeService) removeDivergedNetwork(ctx context.Context, project *typ err := s.stop(ctx, project.Name, api.StopOptions{ Services: services, Project: project, - }) + }, nil) if err != nil { return nil, err } @@ -1599,7 +1599,7 @@ func (s *composeService) removeDivergedVolume(ctx context.Context, name string, err := s.stop(ctx, project.Name, api.StopOptions{ Services: services, Project: project, - }) + }, nil) if err != nil { return err } diff --git a/pkg/compose/down.go b/pkg/compose/down.go index 03f456a6d..b64258dff 100644 --- a/pkg/compose/down.go +++ b/pkg/compose/down.go @@ -298,13 +298,17 @@ func (s *composeService) removeVolume(ctx context.Context, id string, w progress return err } -func (s *composeService) stopContainer(ctx context.Context, w progress.Writer, service *types.ServiceConfig, ctr containerType.Summary, timeout *time.Duration) error { +func (s *composeService) stopContainer( + ctx context.Context, w progress.Writer, + service *types.ServiceConfig, ctr containerType.Summary, + timeout *time.Duration, listener api.ContainerEventListener, +) error { eventName := getContainerProgressName(ctr) w.Event(progress.StoppingEvent(eventName)) if service != nil { for _, hook := range service.PreStop { - err := s.runHook(ctx, ctr, *service, hook, nil) + err := s.runHook(ctx, ctr, *service, hook, listener) if err != nil { // Ignore errors indicating that some containers were already stopped or removed. if cerrdefs.IsNotFound(err) || cerrdefs.IsConflict(err) { @@ -325,11 +329,15 @@ func (s *composeService) stopContainer(ctx context.Context, w progress.Writer, s return nil } -func (s *composeService) stopContainers(ctx context.Context, w progress.Writer, serv *types.ServiceConfig, containers []containerType.Summary, timeout *time.Duration) error { +func (s *composeService) stopContainers( + ctx context.Context, w progress.Writer, + serv *types.ServiceConfig, containers []containerType.Summary, + timeout *time.Duration, listener api.ContainerEventListener, +) error { eg, ctx := errgroup.WithContext(ctx) for _, ctr := range containers { eg.Go(func() error { - return s.stopContainer(ctx, w, serv, ctr, timeout) + return s.stopContainer(ctx, w, serv, ctr, timeout, listener) }) } return eg.Wait() @@ -348,7 +356,7 @@ func (s *composeService) removeContainers(ctx context.Context, containers []cont func (s *composeService) stopAndRemoveContainer(ctx context.Context, ctr containerType.Summary, service *types.ServiceConfig, timeout *time.Duration, volumes bool) error { w := progress.ContextWriter(ctx) eventName := getContainerProgressName(ctr) - err := s.stopContainer(ctx, w, service, ctr, timeout) + err := s.stopContainer(ctx, w, service, ctr, timeout, nil) if cerrdefs.IsNotFound(err) { w.Event(progress.RemovedEvent(eventName)) return nil diff --git a/pkg/compose/printer.go b/pkg/compose/printer.go index 2f300dde0..338312fae 100644 --- a/pkg/compose/printer.go +++ b/pkg/compose/printer.go @@ -149,9 +149,7 @@ func (p *printer) Run(cascade api.Cascade, exitCodeFrom string, stopFn func() er return exitCode, nil } case api.ContainerEventLog, api.HookEventLog: - if !aborting { - p.consumer.Log(container, event.Line) - } + p.consumer.Log(container, event.Line) case api.ContainerEventErr: if !aborting { p.consumer.Err(container, event.Line) diff --git a/pkg/compose/stop.go b/pkg/compose/stop.go index b9514c4b4..8a9cf5aa3 100644 --- a/pkg/compose/stop.go +++ b/pkg/compose/stop.go @@ -27,11 +27,11 @@ import ( func (s *composeService) Stop(ctx context.Context, projectName string, options api.StopOptions) error { return progress.RunWithTitle(ctx, func(ctx context.Context) error { - return s.stop(ctx, strings.ToLower(projectName), options) + return s.stop(ctx, strings.ToLower(projectName), options, nil) }, s.stdinfo(), "Stopping") } -func (s *composeService) stop(ctx context.Context, projectName string, options api.StopOptions) error { +func (s *composeService) stop(ctx context.Context, projectName string, options api.StopOptions, event api.ContainerEventListener) error { containers, err := s.getContainers(ctx, projectName, oneOffExclude, true) if err != nil { return err @@ -55,6 +55,6 @@ func (s *composeService) stop(ctx context.Context, projectName string, options a return nil } serv := project.Services[service] - return s.stopContainers(ctx, w, &serv, containers.filter(isService(service)).filter(isNotOneOff), options.Timeout) + return s.stopContainers(ctx, w, &serv, containers.filter(isService(service)).filter(isNotOneOff), options.Timeout, event) }) } diff --git a/pkg/compose/up.go b/pkg/compose/up.go index 93dc7e78e..dda37f68e 100644 --- a/pkg/compose/up.go +++ b/pkg/compose/up.go @@ -90,6 +90,10 @@ func (s *composeService) Up(ctx context.Context, project *types.Project, options } } + tui := formatter.NewStopping(logConsumer) + defer tui.Close() + logConsumer = tui + watcher, err := NewWatcher(project, options, s.watch, logConsumer) if err != nil && options.Start.Watch { return err @@ -105,16 +109,16 @@ func (s *composeService) Up(ctx context.Context, project *types.Project, options eg.Go(func() error { first := true gracefulTeardown := func() { - printer.Cancel() - _, _ = fmt.Fprintln(s.stdinfo(), "Gracefully stopping... (press Ctrl+C again to force)") + tui.ApplicationTermination() eg.Go(func() error { - err := s.Stop(context.WithoutCancel(ctx), project.Name, api.StopOptions{ - Services: options.Create.Services, - Project: project, - }) - isTerminated.Store(true) - return err + return progress.RunWithLog(context.WithoutCancel(ctx), func(ctx context.Context) error { + return s.stop(ctx, project.Name, api.StopOptions{ + Services: options.Create.Services, + Project: project, + }, printer.HandleEvent) + }, s.stdinfo(), logConsumer) }) + isTerminated.Store(true) first = false } @@ -159,12 +163,15 @@ func (s *composeService) Up(ctx context.Context, project *types.Project, options eg.Go(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{ - Services: options.Create.Services, - Project: project, - }) - }, s.stdinfo()) + eg.Go(func() error { + return progress.RunWithLog(context.WithoutCancel(ctx), func(ctx context.Context) error { + return s.stop(ctx, project.Name, api.StopOptions{ + Services: options.Create.Services, + Project: project, + }, printer.HandleEvent) + }, s.stdinfo(), logConsumer) + }) + return nil }) exitCode = code return err diff --git a/pkg/e2e/fixtures/stop/compose.yaml b/pkg/e2e/fixtures/stop/compose.yaml new file mode 100644 index 000000000..f81462ae3 --- /dev/null +++ b/pkg/e2e/fixtures/stop/compose.yaml @@ -0,0 +1,9 @@ +services: + service1: + image: alpine + command: /bin/true + service2: + image: alpine + command: ping -c 2 localhost + pre_stop: + - command: echo "stop hook running..." diff --git a/pkg/e2e/up_test.go b/pkg/e2e/up_test.go index 65fecca30..0d3e3db0c 100644 --- a/pkg/e2e/up_test.go +++ b/pkg/e2e/up_test.go @@ -206,3 +206,20 @@ func TestUpImageID(t *testing.T) { c = NewCLI(t, WithEnv(fmt.Sprintf("ID=%s", id))) c.RunDockerComposeCmd(t, "-f", "./fixtures/simple-composefile/id.yaml", "--project-name", projectName, "up") } + +func TestUpStopWithLogsMixed(t *testing.T) { + c := NewCLI(t) + const projectName = "compose-e2e-stop-logs" + + t.Cleanup(func() { + c.RunDockerComposeCmd(t, "--project-name", projectName, "down", "-v") + }) + + res := c.RunDockerComposeCmd(t, "-f", "./fixtures/stop/compose.yaml", "--project-name", projectName, "up", "--abort-on-container-exit") + // assert we still get service2 logs after service 1 Stopped event + res.Assert(t, icmd.Expected{ + Err: "Container compose-e2e-stop-logs-service1-1 Stopped", + }) + // assert we get stop hook logs + res.Assert(t, icmd.Expected{Out: "service2-1 -> | stop hook running...\nservice2-1 | 64 bytes"}) +} diff --git a/pkg/mocks/mock_docker_api.go b/pkg/mocks/mock_docker_api.go index 818d84583..4a6ebaacc 100644 --- a/pkg/mocks/mock_docker_api.go +++ b/pkg/mocks/mock_docker_api.go @@ -5,6 +5,7 @@ // // mockgen -destination pkg/mocks/mock_docker_api.go -package mocks github.com/docker/docker/client APIClient // + // Package mocks is a generated GoMock package. package mocks @@ -16,6 +17,7 @@ import ( reflect "reflect" types "github.com/docker/docker/api/types" + build "github.com/docker/docker/api/types/build" checkpoint "github.com/docker/docker/api/types/checkpoint" common "github.com/docker/docker/api/types/common" container "github.com/docker/docker/api/types/container" @@ -56,10 +58,10 @@ func (m *MockAPIClient) EXPECT() *MockAPIClientMockRecorder { } // BuildCachePrune mocks base method. -func (m *MockAPIClient) BuildCachePrune(arg0 context.Context, arg1 types.BuildCachePruneOptions) (*types.BuildCachePruneReport, error) { +func (m *MockAPIClient) BuildCachePrune(arg0 context.Context, arg1 build.CachePruneOptions) (*build.CachePruneReport, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "BuildCachePrune", arg0, arg1) - ret0, _ := ret[0].(*types.BuildCachePruneReport) + ret0, _ := ret[0].(*build.CachePruneReport) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -156,10 +158,10 @@ func (mr *MockAPIClientMockRecorder) Close() *gomock.Call { } // ConfigCreate mocks base method. -func (m *MockAPIClient) ConfigCreate(arg0 context.Context, arg1 swarm.ConfigSpec) (types.ConfigCreateResponse, error) { +func (m *MockAPIClient) ConfigCreate(arg0 context.Context, arg1 swarm.ConfigSpec) (swarm.ConfigCreateResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ConfigCreate", arg0, arg1) - ret0, _ := ret[0].(types.ConfigCreateResponse) + ret0, _ := ret[0].(swarm.ConfigCreateResponse) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -187,7 +189,7 @@ func (mr *MockAPIClientMockRecorder) ConfigInspectWithRaw(arg0, arg1 any) *gomoc } // ConfigList mocks base method. -func (m *MockAPIClient) ConfigList(arg0 context.Context, arg1 types.ConfigListOptions) ([]swarm.Config, error) { +func (m *MockAPIClient) ConfigList(arg0 context.Context, arg1 swarm.ConfigListOptions) ([]swarm.Config, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ConfigList", arg0, arg1) ret0, _ := ret[0].([]swarm.Config) @@ -802,10 +804,10 @@ func (mr *MockAPIClientMockRecorder) HTTPClient() *gomock.Call { } // ImageBuild mocks base method. -func (m *MockAPIClient) ImageBuild(arg0 context.Context, arg1 io.Reader, arg2 types.ImageBuildOptions) (types.ImageBuildResponse, error) { +func (m *MockAPIClient) ImageBuild(arg0 context.Context, arg1 io.Reader, arg2 build.ImageBuildOptions) (build.ImageBuildResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ImageBuild", arg0, arg1, arg2) - ret0, _ := ret[0].(types.ImageBuildResponse) + ret0, _ := ret[0].(build.ImageBuildResponse) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -1220,7 +1222,7 @@ func (mr *MockAPIClientMockRecorder) NodeInspectWithRaw(arg0, arg1 any) *gomock. } // NodeList mocks base method. -func (m *MockAPIClient) NodeList(arg0 context.Context, arg1 types.NodeListOptions) ([]swarm.Node, error) { +func (m *MockAPIClient) NodeList(arg0 context.Context, arg1 swarm.NodeListOptions) ([]swarm.Node, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NodeList", arg0, arg1) ret0, _ := ret[0].([]swarm.Node) @@ -1235,7 +1237,7 @@ func (mr *MockAPIClientMockRecorder) NodeList(arg0, arg1 any) *gomock.Call { } // NodeRemove mocks base method. -func (m *MockAPIClient) NodeRemove(arg0 context.Context, arg1 string, arg2 types.NodeRemoveOptions) error { +func (m *MockAPIClient) NodeRemove(arg0 context.Context, arg1 string, arg2 swarm.NodeRemoveOptions) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "NodeRemove", arg0, arg1, arg2) ret0, _ := ret[0].(error) @@ -1439,10 +1441,10 @@ func (mr *MockAPIClientMockRecorder) RegistryLogin(arg0, arg1 any) *gomock.Call } // SecretCreate mocks base method. -func (m *MockAPIClient) SecretCreate(arg0 context.Context, arg1 swarm.SecretSpec) (types.SecretCreateResponse, error) { +func (m *MockAPIClient) SecretCreate(arg0 context.Context, arg1 swarm.SecretSpec) (swarm.SecretCreateResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SecretCreate", arg0, arg1) - ret0, _ := ret[0].(types.SecretCreateResponse) + ret0, _ := ret[0].(swarm.SecretCreateResponse) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -1470,7 +1472,7 @@ func (mr *MockAPIClientMockRecorder) SecretInspectWithRaw(arg0, arg1 any) *gomoc } // SecretList mocks base method. -func (m *MockAPIClient) SecretList(arg0 context.Context, arg1 types.SecretListOptions) ([]swarm.Secret, error) { +func (m *MockAPIClient) SecretList(arg0 context.Context, arg1 swarm.SecretListOptions) ([]swarm.Secret, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SecretList", arg0, arg1) ret0, _ := ret[0].([]swarm.Secret) @@ -1528,7 +1530,7 @@ func (mr *MockAPIClientMockRecorder) ServerVersion(arg0 any) *gomock.Call { } // ServiceCreate mocks base method. -func (m *MockAPIClient) ServiceCreate(arg0 context.Context, arg1 swarm.ServiceSpec, arg2 types.ServiceCreateOptions) (swarm.ServiceCreateResponse, error) { +func (m *MockAPIClient) ServiceCreate(arg0 context.Context, arg1 swarm.ServiceSpec, arg2 swarm.ServiceCreateOptions) (swarm.ServiceCreateResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ServiceCreate", arg0, arg1, arg2) ret0, _ := ret[0].(swarm.ServiceCreateResponse) @@ -1543,7 +1545,7 @@ func (mr *MockAPIClientMockRecorder) ServiceCreate(arg0, arg1, arg2 any) *gomock } // ServiceInspectWithRaw mocks base method. -func (m *MockAPIClient) ServiceInspectWithRaw(arg0 context.Context, arg1 string, arg2 types.ServiceInspectOptions) (swarm.Service, []byte, error) { +func (m *MockAPIClient) ServiceInspectWithRaw(arg0 context.Context, arg1 string, arg2 swarm.ServiceInspectOptions) (swarm.Service, []byte, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ServiceInspectWithRaw", arg0, arg1, arg2) ret0, _ := ret[0].(swarm.Service) @@ -1559,7 +1561,7 @@ func (mr *MockAPIClientMockRecorder) ServiceInspectWithRaw(arg0, arg1, arg2 any) } // ServiceList mocks base method. -func (m *MockAPIClient) ServiceList(arg0 context.Context, arg1 types.ServiceListOptions) ([]swarm.Service, error) { +func (m *MockAPIClient) ServiceList(arg0 context.Context, arg1 swarm.ServiceListOptions) ([]swarm.Service, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ServiceList", arg0, arg1) ret0, _ := ret[0].([]swarm.Service) @@ -1603,7 +1605,7 @@ func (mr *MockAPIClientMockRecorder) ServiceRemove(arg0, arg1 any) *gomock.Call } // ServiceUpdate mocks base method. -func (m *MockAPIClient) ServiceUpdate(arg0 context.Context, arg1 string, arg2 swarm.Version, arg3 swarm.ServiceSpec, arg4 types.ServiceUpdateOptions) (swarm.ServiceUpdateResponse, error) { +func (m *MockAPIClient) ServiceUpdate(arg0 context.Context, arg1 string, arg2 swarm.Version, arg3 swarm.ServiceSpec, arg4 swarm.ServiceUpdateOptions) (swarm.ServiceUpdateResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ServiceUpdate", arg0, arg1, arg2, arg3, arg4) ret0, _ := ret[0].(swarm.ServiceUpdateResponse) @@ -1618,10 +1620,10 @@ func (mr *MockAPIClientMockRecorder) ServiceUpdate(arg0, arg1, arg2, arg3, arg4 } // SwarmGetUnlockKey mocks base method. -func (m *MockAPIClient) SwarmGetUnlockKey(arg0 context.Context) (types.SwarmUnlockKeyResponse, error) { +func (m *MockAPIClient) SwarmGetUnlockKey(arg0 context.Context) (swarm.UnlockKeyResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SwarmGetUnlockKey", arg0) - ret0, _ := ret[0].(types.SwarmUnlockKeyResponse) + ret0, _ := ret[0].(swarm.UnlockKeyResponse) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -1735,7 +1737,7 @@ func (mr *MockAPIClientMockRecorder) TaskInspectWithRaw(arg0, arg1 any) *gomock. } // TaskList mocks base method. -func (m *MockAPIClient) TaskList(arg0 context.Context, arg1 types.TaskListOptions) ([]swarm.Task, error) { +func (m *MockAPIClient) TaskList(arg0 context.Context, arg1 swarm.TaskListOptions) ([]swarm.Task, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "TaskList", arg0, arg1) ret0, _ := ret[0].([]swarm.Task) diff --git a/pkg/mocks/mock_docker_cli.go b/pkg/mocks/mock_docker_cli.go index 34ccb0446..a540662d7 100644 --- a/pkg/mocks/mock_docker_cli.go +++ b/pkg/mocks/mock_docker_cli.go @@ -5,6 +5,7 @@ // // mockgen -destination pkg/mocks/mock_docker_cli.go -package mocks github.com/docker/cli/cli/command Cli // + // Package mocks is a generated GoMock package. package mocks diff --git a/pkg/mocks/mock_docker_compose_api.go b/pkg/mocks/mock_docker_compose_api.go index 6bb065f34..830d7e1af 100644 --- a/pkg/mocks/mock_docker_compose_api.go +++ b/pkg/mocks/mock_docker_compose_api.go @@ -5,6 +5,7 @@ // // mockgen -destination pkg/mocks/mock_docker_compose_api.go -package mocks -source=./pkg/api/api.go Service // + // Package mocks is a generated GoMock package. package mocks diff --git a/pkg/progress/event.go b/pkg/progress/event.go index 7b66946ee..b2fdcb6eb 100644 --- a/pkg/progress/event.go +++ b/pkg/progress/event.go @@ -60,7 +60,7 @@ type Event struct { Total int64 startTime time.Time endTime time.Time - spinner *spinner + spinner *Spinner } // ErrorMessageEvent creates a new Error Event with message diff --git a/pkg/progress/mixed.go b/pkg/progress/mixed.go new file mode 100644 index 000000000..d3180516a --- /dev/null +++ b/pkg/progress/mixed.go @@ -0,0 +1,76 @@ +/* + 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 progress + +import ( + "context" + "fmt" + + "github.com/docker/cli/cli/streams" + "github.com/docker/compose/v2/pkg/api" +) + +// NewMixedWriter creates a Writer which allows to mix output from progress.Writer with a api.LogConsumer +func NewMixedWriter(out *streams.Out, consumer api.LogConsumer, dryRun bool) Writer { + isTerminal := out.IsTerminal() + if Mode != ModeAuto || !isTerminal { + return &plainWriter{ + out: out, + done: make(chan bool), + dryRun: dryRun, + } + } + return &mixedWriter{ + out: consumer, + done: make(chan bool), + dryRun: dryRun, + } +} + +type mixedWriter struct { + done chan bool + dryRun bool + out api.LogConsumer +} + +func (p *mixedWriter) Start(ctx context.Context) error { + select { + case <-ctx.Done(): + return ctx.Err() + case <-p.done: + return nil + } +} + +func (p *mixedWriter) Event(e Event) { + p.out.Status("", fmt.Sprintf("%s %s %s", e.ID, e.Text, SuccessColor(e.StatusText))) +} + +func (p *mixedWriter) Events(events []Event) { + for _, e := range events { + p.Event(e) + } +} + +func (p *mixedWriter) TailMsgf(msg string, args ...interface{}) { + msg = fmt.Sprintf(msg, args...) + p.out.Status("", WarningColor(msg)) +} + +func (p *mixedWriter) Stop() { + p.done <- true +} diff --git a/pkg/progress/spinner.go b/pkg/progress/spinner.go index 3756e944f..4e434bd4f 100644 --- a/pkg/progress/spinner.go +++ b/pkg/progress/spinner.go @@ -21,7 +21,7 @@ import ( "time" ) -type spinner struct { +type Spinner struct { time time.Time index int chars []string @@ -29,7 +29,7 @@ type spinner struct { done string } -func newSpinner() *spinner { +func NewSpinner() *Spinner { chars := []string{ "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏", } @@ -40,7 +40,7 @@ func newSpinner() *spinner { done = "-" } - return &spinner{ + return &Spinner{ index: 0, time: time.Now(), chars: chars, @@ -48,7 +48,7 @@ func newSpinner() *spinner { } } -func (s *spinner) String() string { +func (s *Spinner) String() string { if s.stop { return s.done } @@ -61,10 +61,10 @@ func (s *spinner) String() string { return s.chars[s.index] } -func (s *spinner) Stop() { +func (s *Spinner) Stop() { s.stop = true } -func (s *spinner) Restart() { +func (s *Spinner) Restart() { s.stop = false } diff --git a/pkg/progress/tty.go b/pkg/progress/tty.go index e6b8738c8..cc4388aa1 100644 --- a/pkg/progress/tty.go +++ b/pkg/progress/tty.go @@ -110,7 +110,7 @@ func (w *ttyWriter) event(e Event) { w.events[e.ID] = last } else { e.startTime = time.Now() - e.spinner = newSpinner() + e.spinner = NewSpinner() if e.Status == Done || e.Status == Error { e.stop() } diff --git a/pkg/progress/tty_test.go b/pkg/progress/tty_test.go index fc484b7c0..3b8c9ba76 100644 --- a/pkg/progress/tty_test.go +++ b/pkg/progress/tty_test.go @@ -34,7 +34,7 @@ func TestLineText(t *testing.T) { StatusText: "Status", endTime: now, startTime: now, - spinner: &spinner{ + spinner: &Spinner{ chars: []string{"."}, }, } @@ -65,7 +65,7 @@ func TestLineTextSingleEvent(t *testing.T) { Status: Done, StatusText: "Status", startTime: now, - spinner: &spinner{ + spinner: &Spinner{ chars: []string{"."}, }, } @@ -87,7 +87,7 @@ func TestErrorEvent(t *testing.T) { Status: Working, StatusText: "Working", startTime: time.Now(), - spinner: &spinner{ + spinner: &Spinner{ chars: []string{"."}, }, } @@ -116,7 +116,7 @@ func TestWarningEvent(t *testing.T) { Status: Working, StatusText: "Working", startTime: time.Now(), - spinner: &spinner{ + spinner: &Spinner{ chars: []string{"."}, }, } diff --git a/pkg/progress/writer.go b/pkg/progress/writer.go index 793adb762..4e9066c5f 100644 --- a/pkg/progress/writer.go +++ b/pkg/progress/writer.go @@ -65,6 +65,25 @@ func Run(ctx context.Context, pf progressFunc, out *streams.Out) error { return err } +func RunWithLog(ctx context.Context, pf progressFunc, out *streams.Out, logConsumer api.LogConsumer) error { + dryRun, ok := ctx.Value(api.DryRunKey{}).(bool) + if !ok { + dryRun = false + } + w := NewMixedWriter(out, logConsumer, dryRun) + eg, _ := errgroup.WithContext(ctx) + eg.Go(func() error { + return w.Start(context.Background()) + }) + eg.Go(func() error { + defer w.Stop() + ctx = WithContextWriter(ctx, w) + err := pf(ctx) + return err + }) + return eg.Wait() +} + func RunWithTitle(ctx context.Context, pf progressFunc, out *streams.Out, progressTitle string) error { _, err := RunWithStatus(ctx, func(ctx context.Context) (string, error) { return "", pf(ctx)