keep containers attached on stop to capture termination logs

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
This commit is contained in:
Nicolas De Loof 2025-07-01 17:14:03 +02:00 committed by Nicolas De loof
parent 0b0242d0ac
commit 29308cb97e
21 changed files with 352 additions and 92 deletions

View File

@ -28,49 +28,49 @@ func ansi(code string) string {
return fmt.Sprintf("\033%s", code) return fmt.Sprintf("\033%s", code)
} }
func SaveCursor() { func saveCursor() {
if disableAnsi { if disableAnsi {
return return
} }
fmt.Print(ansi("7")) fmt.Print(ansi("7"))
} }
func RestoreCursor() { func restoreCursor() {
if disableAnsi { if disableAnsi {
return return
} }
fmt.Print(ansi("8")) fmt.Print(ansi("8"))
} }
func HideCursor() { func hideCursor() {
if disableAnsi { if disableAnsi {
return return
} }
fmt.Print(ansi("[?25l")) fmt.Print(ansi("[?25l"))
} }
func ShowCursor() { func showCursor() {
if disableAnsi { if disableAnsi {
return return
} }
fmt.Print(ansi("[?25h")) fmt.Print(ansi("[?25h"))
} }
func MoveCursor(y, x int) { func moveCursor(y, x int) {
if disableAnsi { if disableAnsi {
return return
} }
fmt.Print(ansi(fmt.Sprintf("[%d;%dH", y, x))) fmt.Print(ansi(fmt.Sprintf("[%d;%dH", y, x)))
} }
func MoveCursorX(pos int) { func carriageReturn() {
if disableAnsi { if disableAnsi {
return return
} }
fmt.Print(ansi(fmt.Sprintf("[%dG", pos))) fmt.Print(ansi(fmt.Sprintf("[%dG", 0)))
} }
func ClearLine() { func clearLine() {
if disableAnsi { if disableAnsi {
return return
} }
@ -78,7 +78,7 @@ func ClearLine() {
fmt.Print(ansi("[2K")) fmt.Print(ansi("[2K"))
} }
func MoveCursorUp(lines int) { func moveCursorUp(lines int) {
if disableAnsi { if disableAnsi {
return return
} }
@ -86,7 +86,7 @@ func MoveCursorUp(lines int) {
fmt.Print(ansi(fmt.Sprintf("[%dA", lines))) fmt.Print(ansi(fmt.Sprintf("[%dA", lines)))
} }
func MoveCursorDown(lines int) { func moveCursorDown(lines int) {
if disableAnsi { if disableAnsi {
return return
} }
@ -94,7 +94,7 @@ func MoveCursorDown(lines int) {
fmt.Print(ansi(fmt.Sprintf("[%dB", lines))) fmt.Print(ansi(fmt.Sprintf("[%dB", lines)))
} }
func NewLine() { func newLine() {
// Like \n // Like \n
fmt.Print("\012") fmt.Print("\012")
} }

View File

@ -73,9 +73,12 @@ func (l *logConsumer) register(name string) *presenter {
} else { } else {
cf := monochrome cf := monochrome
if l.color { if l.color {
if name == api.WatchLogger { switch name {
case "":
cf = monochrome
case api.WatchLogger:
cf = makeColorFunc("92") cf = makeColorFunc("92")
} else { default:
cf = nextColor() cf = nextColor()
} }
} }

View File

@ -48,8 +48,8 @@ func (ke *KeyboardError) printError(height int, info string) {
if ke.shouldDisplay() { if ke.shouldDisplay() {
errMessage := ke.err.Error() errMessage := ke.err.Error()
MoveCursor(height-1-extraLines(info)-extraLines(errMessage), 0) moveCursor(height-1-extraLines(info)-extraLines(errMessage), 0)
ClearLine() clearLine()
fmt.Print(errMessage) fmt.Print(errMessage)
} }
@ -133,7 +133,7 @@ func (lk *LogKeyboard) createBuffer(lines int) {
if lines > 0 { if lines > 0 {
allocateSpace(lines) allocateSpace(lines)
MoveCursorUp(lines) moveCursorUp(lines)
} }
} }
@ -146,17 +146,17 @@ func (lk *LogKeyboard) printNavigationMenu() {
height := goterm.Height() height := goterm.Height()
menu := lk.navigationMenu() menu := lk.navigationMenu()
MoveCursorX(0) carriageReturn()
SaveCursor() saveCursor()
lk.kError.printError(height, menu) lk.kError.printError(height, menu)
MoveCursor(height-extraLines(menu), 0) moveCursor(height-extraLines(menu), 0)
ClearLine() clearLine()
fmt.Print(menu) fmt.Print(menu)
MoveCursorX(0) carriageReturn()
RestoreCursor() restoreCursor()
} }
} }
@ -188,15 +188,15 @@ func (lk *LogKeyboard) navigationMenu() string {
func (lk *LogKeyboard) clearNavigationMenu() { func (lk *LogKeyboard) clearNavigationMenu() {
height := goterm.Height() height := goterm.Height()
MoveCursorX(0) carriageReturn()
SaveCursor() saveCursor()
// ClearLine() // clearLine()
for i := 0; i < height; i++ { for i := 0; i < height; i++ {
MoveCursorDown(1) moveCursorDown(1)
ClearLine() clearLine()
} }
RestoreCursor() restoreCursor()
} }
func (lk *LogKeyboard) openDockerDesktop(ctx context.Context, project *types.Project) { 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: case keyboard.KeyCtrlC:
_ = keyboard.Close() _ = keyboard.Close()
lk.clearNavigationMenu() lk.clearNavigationMenu()
ShowCursor() showCursor()
lk.logLevel = NONE lk.logLevel = NONE
// will notify main thread to kill and will handle gracefully // will notify main thread to kill and will handle gracefully
lk.signalChannel <- syscall.SIGINT lk.signalChannel <- syscall.SIGINT
case keyboard.KeyEnter: case keyboard.KeyEnter:
NewLine() newLine()
lk.printNavigationMenu() lk.printNavigationMenu()
} }
} }
@ -336,9 +336,9 @@ func (lk *LogKeyboard) EnableWatch(enabled bool, watcher Feature) {
func allocateSpace(lines int) { func allocateSpace(lines int) {
for i := 0; i < lines; i++ { for i := 0; i < lines; i++ {
ClearLine() clearLine()
NewLine() newLine()
MoveCursorX(0) carriageReturn()
} }
} }

119
cmd/formatter/stopping.go Normal file
View File

@ -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()
}

View File

@ -236,7 +236,7 @@ func (c *convergence) stopDependentContainers(ctx context.Context, project *type
err := c.service.stop(ctx, project.Name, api.StopOptions{ err := c.service.stop(ctx, project.Name, api.StopOptions{
Services: dependents, Services: dependents,
Project: project, Project: project,
}) }, nil)
if err != nil { if err != nil {
return err return err
} }

View File

@ -1428,7 +1428,7 @@ func (s *composeService) removeDivergedNetwork(ctx context.Context, project *typ
err := s.stop(ctx, project.Name, api.StopOptions{ err := s.stop(ctx, project.Name, api.StopOptions{
Services: services, Services: services,
Project: project, Project: project,
}) }, nil)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -1599,7 +1599,7 @@ func (s *composeService) removeDivergedVolume(ctx context.Context, name string,
err := s.stop(ctx, project.Name, api.StopOptions{ err := s.stop(ctx, project.Name, api.StopOptions{
Services: services, Services: services,
Project: project, Project: project,
}) }, nil)
if err != nil { if err != nil {
return err return err
} }

View File

@ -298,13 +298,17 @@ func (s *composeService) removeVolume(ctx context.Context, id string, w progress
return err 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) eventName := getContainerProgressName(ctr)
w.Event(progress.StoppingEvent(eventName)) w.Event(progress.StoppingEvent(eventName))
if service != nil { if service != nil {
for _, hook := range service.PreStop { for _, hook := range service.PreStop {
err := s.runHook(ctx, ctr, *service, hook, nil) err := s.runHook(ctx, ctr, *service, hook, listener)
if err != nil { if err != nil {
// Ignore errors indicating that some containers were already stopped or removed. // Ignore errors indicating that some containers were already stopped or removed.
if cerrdefs.IsNotFound(err) || cerrdefs.IsConflict(err) { if cerrdefs.IsNotFound(err) || cerrdefs.IsConflict(err) {
@ -325,11 +329,15 @@ func (s *composeService) stopContainer(ctx context.Context, w progress.Writer, s
return nil 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) eg, ctx := errgroup.WithContext(ctx)
for _, ctr := range containers { for _, ctr := range containers {
eg.Go(func() error { eg.Go(func() error {
return s.stopContainer(ctx, w, serv, ctr, timeout) return s.stopContainer(ctx, w, serv, ctr, timeout, listener)
}) })
} }
return eg.Wait() 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 { func (s *composeService) stopAndRemoveContainer(ctx context.Context, ctr containerType.Summary, service *types.ServiceConfig, timeout *time.Duration, volumes bool) error {
w := progress.ContextWriter(ctx) w := progress.ContextWriter(ctx)
eventName := getContainerProgressName(ctr) eventName := getContainerProgressName(ctr)
err := s.stopContainer(ctx, w, service, ctr, timeout) err := s.stopContainer(ctx, w, service, ctr, timeout, nil)
if cerrdefs.IsNotFound(err) { if cerrdefs.IsNotFound(err) {
w.Event(progress.RemovedEvent(eventName)) w.Event(progress.RemovedEvent(eventName))
return nil return nil

View File

@ -149,9 +149,7 @@ func (p *printer) Run(cascade api.Cascade, exitCodeFrom string, stopFn func() er
return exitCode, nil return exitCode, nil
} }
case api.ContainerEventLog, api.HookEventLog: case api.ContainerEventLog, api.HookEventLog:
if !aborting { p.consumer.Log(container, event.Line)
p.consumer.Log(container, event.Line)
}
case api.ContainerEventErr: case api.ContainerEventErr:
if !aborting { if !aborting {
p.consumer.Err(container, event.Line) p.consumer.Err(container, event.Line)

View File

@ -27,11 +27,11 @@ import (
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) error {
return progress.RunWithTitle(ctx, func(ctx context.Context) 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") }, 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) containers, err := s.getContainers(ctx, projectName, oneOffExclude, true)
if err != nil { if err != nil {
return err return err
@ -55,6 +55,6 @@ func (s *composeService) stop(ctx context.Context, projectName string, options a
return nil return nil
} }
serv := project.Services[service] 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)
}) })
} }

View File

@ -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) watcher, err := NewWatcher(project, options, s.watch, logConsumer)
if err != nil && options.Start.Watch { if err != nil && options.Start.Watch {
return err return err
@ -105,16 +109,16 @@ func (s *composeService) Up(ctx context.Context, project *types.Project, options
eg.Go(func() error { eg.Go(func() error {
first := true first := true
gracefulTeardown := func() { gracefulTeardown := func() {
printer.Cancel() tui.ApplicationTermination()
_, _ = fmt.Fprintln(s.stdinfo(), "Gracefully stopping... (press Ctrl+C again to force)")
eg.Go(func() error { eg.Go(func() error {
err := s.Stop(context.WithoutCancel(ctx), project.Name, api.StopOptions{ return progress.RunWithLog(context.WithoutCancel(ctx), func(ctx context.Context) error {
Services: options.Create.Services, return s.stop(ctx, project.Name, api.StopOptions{
Project: project, Services: options.Create.Services,
}) Project: project,
isTerminated.Store(true) }, printer.HandleEvent)
return err }, s.stdinfo(), logConsumer)
}) })
isTerminated.Store(true)
first = false first = false
} }
@ -159,12 +163,15 @@ func (s *composeService) Up(ctx context.Context, project *types.Project, options
eg.Go(func() error { eg.Go(func() error {
code, err := printer.Run(options.Start.OnExit, options.Start.ExitCodeFrom, func() error { code, err := printer.Run(options.Start.OnExit, options.Start.ExitCodeFrom, func() error {
_, _ = fmt.Fprintln(s.stdinfo(), "Aborting on container exit...") _, _ = fmt.Fprintln(s.stdinfo(), "Aborting on container exit...")
return progress.Run(ctx, func(ctx context.Context) error { eg.Go(func() error {
return s.Stop(ctx, project.Name, api.StopOptions{ return progress.RunWithLog(context.WithoutCancel(ctx), func(ctx context.Context) error {
Services: options.Create.Services, return s.stop(ctx, project.Name, api.StopOptions{
Project: project, Services: options.Create.Services,
}) Project: project,
}, s.stdinfo()) }, printer.HandleEvent)
}, s.stdinfo(), logConsumer)
})
return nil
}) })
exitCode = code exitCode = code
return err return err

View File

@ -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..."

View File

@ -206,3 +206,20 @@ func TestUpImageID(t *testing.T) {
c = NewCLI(t, WithEnv(fmt.Sprintf("ID=%s", id))) c = NewCLI(t, WithEnv(fmt.Sprintf("ID=%s", id)))
c.RunDockerComposeCmd(t, "-f", "./fixtures/simple-composefile/id.yaml", "--project-name", projectName, "up") 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"})
}

View File

@ -5,6 +5,7 @@
// //
// mockgen -destination pkg/mocks/mock_docker_api.go -package mocks github.com/docker/docker/client APIClient // 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 is a generated GoMock package.
package mocks package mocks
@ -16,6 +17,7 @@ import (
reflect "reflect" reflect "reflect"
types "github.com/docker/docker/api/types" types "github.com/docker/docker/api/types"
build "github.com/docker/docker/api/types/build"
checkpoint "github.com/docker/docker/api/types/checkpoint" checkpoint "github.com/docker/docker/api/types/checkpoint"
common "github.com/docker/docker/api/types/common" common "github.com/docker/docker/api/types/common"
container "github.com/docker/docker/api/types/container" container "github.com/docker/docker/api/types/container"
@ -56,10 +58,10 @@ func (m *MockAPIClient) EXPECT() *MockAPIClientMockRecorder {
} }
// BuildCachePrune mocks base method. // 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() m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "BuildCachePrune", arg0, arg1) ret := m.ctrl.Call(m, "BuildCachePrune", arg0, arg1)
ret0, _ := ret[0].(*types.BuildCachePruneReport) ret0, _ := ret[0].(*build.CachePruneReport)
ret1, _ := ret[1].(error) ret1, _ := ret[1].(error)
return ret0, ret1 return ret0, ret1
} }
@ -156,10 +158,10 @@ func (mr *MockAPIClientMockRecorder) Close() *gomock.Call {
} }
// ConfigCreate mocks base method. // 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() m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ConfigCreate", arg0, arg1) ret := m.ctrl.Call(m, "ConfigCreate", arg0, arg1)
ret0, _ := ret[0].(types.ConfigCreateResponse) ret0, _ := ret[0].(swarm.ConfigCreateResponse)
ret1, _ := ret[1].(error) ret1, _ := ret[1].(error)
return ret0, ret1 return ret0, ret1
} }
@ -187,7 +189,7 @@ func (mr *MockAPIClientMockRecorder) ConfigInspectWithRaw(arg0, arg1 any) *gomoc
} }
// ConfigList mocks base method. // 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() m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ConfigList", arg0, arg1) ret := m.ctrl.Call(m, "ConfigList", arg0, arg1)
ret0, _ := ret[0].([]swarm.Config) ret0, _ := ret[0].([]swarm.Config)
@ -802,10 +804,10 @@ func (mr *MockAPIClientMockRecorder) HTTPClient() *gomock.Call {
} }
// ImageBuild mocks base method. // 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() m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ImageBuild", arg0, arg1, arg2) ret := m.ctrl.Call(m, "ImageBuild", arg0, arg1, arg2)
ret0, _ := ret[0].(types.ImageBuildResponse) ret0, _ := ret[0].(build.ImageBuildResponse)
ret1, _ := ret[1].(error) ret1, _ := ret[1].(error)
return ret0, ret1 return ret0, ret1
} }
@ -1220,7 +1222,7 @@ func (mr *MockAPIClientMockRecorder) NodeInspectWithRaw(arg0, arg1 any) *gomock.
} }
// NodeList mocks base method. // 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() m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "NodeList", arg0, arg1) ret := m.ctrl.Call(m, "NodeList", arg0, arg1)
ret0, _ := ret[0].([]swarm.Node) ret0, _ := ret[0].([]swarm.Node)
@ -1235,7 +1237,7 @@ func (mr *MockAPIClientMockRecorder) NodeList(arg0, arg1 any) *gomock.Call {
} }
// NodeRemove mocks base method. // 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() m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "NodeRemove", arg0, arg1, arg2) ret := m.ctrl.Call(m, "NodeRemove", arg0, arg1, arg2)
ret0, _ := ret[0].(error) ret0, _ := ret[0].(error)
@ -1439,10 +1441,10 @@ func (mr *MockAPIClientMockRecorder) RegistryLogin(arg0, arg1 any) *gomock.Call
} }
// SecretCreate mocks base method. // 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() m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "SecretCreate", arg0, arg1) ret := m.ctrl.Call(m, "SecretCreate", arg0, arg1)
ret0, _ := ret[0].(types.SecretCreateResponse) ret0, _ := ret[0].(swarm.SecretCreateResponse)
ret1, _ := ret[1].(error) ret1, _ := ret[1].(error)
return ret0, ret1 return ret0, ret1
} }
@ -1470,7 +1472,7 @@ func (mr *MockAPIClientMockRecorder) SecretInspectWithRaw(arg0, arg1 any) *gomoc
} }
// SecretList mocks base method. // 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() m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "SecretList", arg0, arg1) ret := m.ctrl.Call(m, "SecretList", arg0, arg1)
ret0, _ := ret[0].([]swarm.Secret) ret0, _ := ret[0].([]swarm.Secret)
@ -1528,7 +1530,7 @@ func (mr *MockAPIClientMockRecorder) ServerVersion(arg0 any) *gomock.Call {
} }
// ServiceCreate mocks base method. // 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() m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ServiceCreate", arg0, arg1, arg2) ret := m.ctrl.Call(m, "ServiceCreate", arg0, arg1, arg2)
ret0, _ := ret[0].(swarm.ServiceCreateResponse) ret0, _ := ret[0].(swarm.ServiceCreateResponse)
@ -1543,7 +1545,7 @@ func (mr *MockAPIClientMockRecorder) ServiceCreate(arg0, arg1, arg2 any) *gomock
} }
// ServiceInspectWithRaw mocks base method. // 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() m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ServiceInspectWithRaw", arg0, arg1, arg2) ret := m.ctrl.Call(m, "ServiceInspectWithRaw", arg0, arg1, arg2)
ret0, _ := ret[0].(swarm.Service) ret0, _ := ret[0].(swarm.Service)
@ -1559,7 +1561,7 @@ func (mr *MockAPIClientMockRecorder) ServiceInspectWithRaw(arg0, arg1, arg2 any)
} }
// ServiceList mocks base method. // 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() m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ServiceList", arg0, arg1) ret := m.ctrl.Call(m, "ServiceList", arg0, arg1)
ret0, _ := ret[0].([]swarm.Service) ret0, _ := ret[0].([]swarm.Service)
@ -1603,7 +1605,7 @@ func (mr *MockAPIClientMockRecorder) ServiceRemove(arg0, arg1 any) *gomock.Call
} }
// ServiceUpdate mocks base method. // 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() m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ServiceUpdate", arg0, arg1, arg2, arg3, arg4) ret := m.ctrl.Call(m, "ServiceUpdate", arg0, arg1, arg2, arg3, arg4)
ret0, _ := ret[0].(swarm.ServiceUpdateResponse) ret0, _ := ret[0].(swarm.ServiceUpdateResponse)
@ -1618,10 +1620,10 @@ func (mr *MockAPIClientMockRecorder) ServiceUpdate(arg0, arg1, arg2, arg3, arg4
} }
// SwarmGetUnlockKey mocks base method. // 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() m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "SwarmGetUnlockKey", arg0) ret := m.ctrl.Call(m, "SwarmGetUnlockKey", arg0)
ret0, _ := ret[0].(types.SwarmUnlockKeyResponse) ret0, _ := ret[0].(swarm.UnlockKeyResponse)
ret1, _ := ret[1].(error) ret1, _ := ret[1].(error)
return ret0, ret1 return ret0, ret1
} }
@ -1735,7 +1737,7 @@ func (mr *MockAPIClientMockRecorder) TaskInspectWithRaw(arg0, arg1 any) *gomock.
} }
// TaskList mocks base method. // 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() m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "TaskList", arg0, arg1) ret := m.ctrl.Call(m, "TaskList", arg0, arg1)
ret0, _ := ret[0].([]swarm.Task) ret0, _ := ret[0].([]swarm.Task)

View File

@ -5,6 +5,7 @@
// //
// mockgen -destination pkg/mocks/mock_docker_cli.go -package mocks github.com/docker/cli/cli/command Cli // 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 is a generated GoMock package.
package mocks package mocks

View File

@ -5,6 +5,7 @@
// //
// mockgen -destination pkg/mocks/mock_docker_compose_api.go -package mocks -source=./pkg/api/api.go Service // 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 is a generated GoMock package.
package mocks package mocks

View File

@ -60,7 +60,7 @@ type Event struct {
Total int64 Total int64
startTime time.Time startTime time.Time
endTime time.Time endTime time.Time
spinner *spinner spinner *Spinner
} }
// ErrorMessageEvent creates a new Error Event with message // ErrorMessageEvent creates a new Error Event with message

76
pkg/progress/mixed.go Normal file
View File

@ -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
}

View File

@ -21,7 +21,7 @@ import (
"time" "time"
) )
type spinner struct { type Spinner struct {
time time.Time time time.Time
index int index int
chars []string chars []string
@ -29,7 +29,7 @@ type spinner struct {
done string done string
} }
func newSpinner() *spinner { func NewSpinner() *Spinner {
chars := []string{ chars := []string{
"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏", "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏",
} }
@ -40,7 +40,7 @@ func newSpinner() *spinner {
done = "-" done = "-"
} }
return &spinner{ return &Spinner{
index: 0, index: 0,
time: time.Now(), time: time.Now(),
chars: chars, chars: chars,
@ -48,7 +48,7 @@ func newSpinner() *spinner {
} }
} }
func (s *spinner) String() string { func (s *Spinner) String() string {
if s.stop { if s.stop {
return s.done return s.done
} }
@ -61,10 +61,10 @@ func (s *spinner) String() string {
return s.chars[s.index] return s.chars[s.index]
} }
func (s *spinner) Stop() { func (s *Spinner) Stop() {
s.stop = true s.stop = true
} }
func (s *spinner) Restart() { func (s *Spinner) Restart() {
s.stop = false s.stop = false
} }

View File

@ -110,7 +110,7 @@ func (w *ttyWriter) event(e Event) {
w.events[e.ID] = last w.events[e.ID] = last
} else { } else {
e.startTime = time.Now() e.startTime = time.Now()
e.spinner = newSpinner() e.spinner = NewSpinner()
if e.Status == Done || e.Status == Error { if e.Status == Done || e.Status == Error {
e.stop() e.stop()
} }

View File

@ -34,7 +34,7 @@ func TestLineText(t *testing.T) {
StatusText: "Status", StatusText: "Status",
endTime: now, endTime: now,
startTime: now, startTime: now,
spinner: &spinner{ spinner: &Spinner{
chars: []string{"."}, chars: []string{"."},
}, },
} }
@ -65,7 +65,7 @@ func TestLineTextSingleEvent(t *testing.T) {
Status: Done, Status: Done,
StatusText: "Status", StatusText: "Status",
startTime: now, startTime: now,
spinner: &spinner{ spinner: &Spinner{
chars: []string{"."}, chars: []string{"."},
}, },
} }
@ -87,7 +87,7 @@ func TestErrorEvent(t *testing.T) {
Status: Working, Status: Working,
StatusText: "Working", StatusText: "Working",
startTime: time.Now(), startTime: time.Now(),
spinner: &spinner{ spinner: &Spinner{
chars: []string{"."}, chars: []string{"."},
}, },
} }
@ -116,7 +116,7 @@ func TestWarningEvent(t *testing.T) {
Status: Working, Status: Working,
StatusText: "Working", StatusText: "Working",
startTime: time.Now(), startTime: time.Now(),
spinner: &spinner{ spinner: &Spinner{
chars: []string{"."}, chars: []string{"."},
}, },
} }

View File

@ -65,6 +65,25 @@ func Run(ctx context.Context, pf progressFunc, out *streams.Out) error {
return err 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 { func RunWithTitle(ctx context.Context, pf progressFunc, out *streams.Out, progressTitle string) error {
_, err := RunWithStatus(ctx, func(ctx context.Context) (string, error) { _, err := RunWithStatus(ctx, func(ctx context.Context) (string, error) {
return "", pf(ctx) return "", pf(ctx)