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)
}
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")
}

View File

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

View File

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

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{
Services: dependents,
Project: project,
})
}, nil)
if err != nil {
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{
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
}

View File

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

View File

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

View File

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

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)
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

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.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
//
// 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)

View File

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

View File

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

View File

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

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"
)
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
}

View File

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

View File

@ -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{"."},
},
}

View File

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