Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 44 additions & 34 deletions cmd/compose/compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -495,40 +495,9 @@ func RootCommand(dockerCli command.Cli, backendOptions *BackendOptions) *cobra.C
}

detached, _ := cmd.Flags().GetBool("detach")
var ep api.EventProcessor
switch opts.Progress {
case "", display.ModeAuto:
switch {
case ansi == "never":
display.Mode = display.ModePlain
ep = display.Plain(dockerCli.Err())
case dockerCli.Out().IsTerminal():
ep = display.Full(dockerCli.Err(), stdinfo(dockerCli), detached)
default:
ep = display.Plain(dockerCli.Err())
}
case display.ModeTTY:
if ansi == "never" {
return fmt.Errorf("can't use --progress tty while ANSI support is disabled")
}
display.Mode = display.ModeTTY
ep = display.Full(dockerCli.Err(), stdinfo(dockerCli), detached)

case display.ModePlain:
if ansi == "always" {
return fmt.Errorf("can't use --progress plain while ANSI support is forced")
}
display.Mode = display.ModePlain
ep = display.Plain(dockerCli.Err())
case display.ModeQuiet, "none":
display.Mode = display.ModeQuiet
ep = display.Quiet()
case display.ModeJSON:
display.Mode = display.ModeJSON
logrus.SetFormatter(&logrus.JSONFormatter{})
ep = display.JSON(dockerCli.Err())
default:
return fmt.Errorf("unsupported --progress value %q", opts.Progress)
ep, err := selectEventProcessor(dockerCli, opts.Progress, ansi, detached)
if err != nil {
return err
}
backendOptions.Add(compose.WithEventProcessor(ep))

Expand Down Expand Up @@ -666,6 +635,47 @@ func stdinfo(dockerCli command.Cli) io.Writer {
return dockerCli.Err()
}

// selectEventProcessor picks the EventProcessor for Compose progress rendering.
//
// In auto mode we probe Err() (not Out()) because the renderer writes to stderr;
// probing stdout would force plain mode whenever stdout is redirected (e.g.
// `docker compose up | tee log`) while stderr is still a terminal.
func selectEventProcessor(dockerCli command.Cli, progress, ansi string, detached bool) (api.EventProcessor, error) {
switch progress {
case "", display.ModeAuto:
switch {
case ansi == "never":
display.Mode = display.ModePlain
return display.Plain(dockerCli.Err()), nil
case dockerCli.Err().IsTerminal():
return display.Full(dockerCli.Err(), stdinfo(dockerCli), detached), nil
default:
return display.Plain(dockerCli.Err()), nil
}
case display.ModeTTY:
if ansi == "never" {
return nil, fmt.Errorf("can't use --progress tty while ANSI support is disabled")
}
display.Mode = display.ModeTTY
return display.Full(dockerCli.Err(), stdinfo(dockerCli), detached), nil
case display.ModePlain:
if ansi == "always" {
return nil, fmt.Errorf("can't use --progress plain while ANSI support is forced")
}
display.Mode = display.ModePlain
return display.Plain(dockerCli.Err()), nil
case display.ModeQuiet, "none":
display.Mode = display.ModeQuiet
return display.Quiet(), nil
case display.ModeJSON:
display.Mode = display.ModeJSON
logrus.SetFormatter(&logrus.JSONFormatter{})
return display.JSON(dockerCli.Err()), nil
default:
return nil, fmt.Errorf("unsupported --progress value %q", progress)
}
}

func setEnvWithDotEnv(opts ProjectOptions, dockerCli command.Cli) error {
// Check if we're using a remote config (OCI or Git)
// If so, skip env loading as remote loaders haven't been initialized yet
Expand Down
209 changes: 209 additions & 0 deletions cmd/compose/compose_progress_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
//go:build !windows

/*
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 compose

import (
"fmt"
"os"
"testing"

"github.com/creack/pty"
"github.com/docker/cli/cli/streams"
"github.com/sirupsen/logrus"
"go.uber.org/mock/gomock"
"gotest.tools/v3/assert"

"github.com/docker/compose/v5/cmd/display"
"github.com/docker/compose/v5/pkg/mocks"
)

// saveGlobalState snapshots package-level state that selectEventProcessor
// mutates (display.Mode and, in JSON mode, the logrus standard formatter)
// and restores it on test cleanup.
func saveGlobalState(t *testing.T) {
t.Helper()
originalMode := display.Mode
originalFormatter := logrus.StandardLogger().Formatter
t.Cleanup(func() {
display.Mode = originalMode
logrus.SetFormatter(originalFormatter)
})
}

// newStream returns a *streams.Out whose IsTerminal() matches tty. When tty is
// true it is backed by a pseudo-terminal slave; otherwise by an os.Pipe writer.
func newStream(t *testing.T, tty bool) *streams.Out {
t.Helper()
if tty {
ptmx, slave, err := pty.Open()
assert.NilError(t, err)
t.Cleanup(func() {
_ = ptmx.Close()
_ = slave.Close()
})
return streams.NewOut(slave)
}
r, w, err := os.Pipe()
assert.NilError(t, err)
t.Cleanup(func() {
_ = r.Close()
_ = w.Close()
})
return streams.NewOut(w)
}

func newMockCli(t *testing.T, out, errStream *streams.Out) *mocks.MockCli {
t.Helper()
cli := mocks.NewMockCli(gomock.NewController(t))
cli.EXPECT().Out().Return(out).AnyTimes()
cli.EXPECT().Err().Return(errStream).AnyTimes()
return cli
}

// TestSelectEventProcessor_AutoMode covers the regression from docker/compose#13570:
// auto mode must probe Err() (not Out()) so `docker compose up | tee log` still
// renders the colorized UI on stderr.
func TestSelectEventProcessor_AutoMode(t *testing.T) {
tests := []struct {
name string
outIsTTY bool
errIsTTY bool
ansi string
wantType string
}{
{
name: "stderr TTY, stdout piped -> Full",
errIsTTY: true,
ansi: "auto",
wantType: "*display.ttyWriter",
},
{
name: "stderr piped, stdout TTY -> Plain (do not fall back to stdout)",
outIsTTY: true,
ansi: "auto",
wantType: "*display.plainWriter",
},
{
name: "both TTY -> Full",
outIsTTY: true,
errIsTTY: true,
ansi: "auto",
wantType: "*display.ttyWriter",
},
{
name: "both piped -> Plain",
ansi: "auto",
wantType: "*display.plainWriter",
},
{
name: "ansi never forces Plain even when stderr is TTY",
outIsTTY: true,
errIsTTY: true,
ansi: "never",
wantType: "*display.plainWriter",
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
saveGlobalState(t)
cli := newMockCli(t, newStream(t, tc.outIsTTY), newStream(t, tc.errIsTTY))

ep, err := selectEventProcessor(cli, "", tc.ansi, false)
assert.NilError(t, err)
assert.Equal(t, fmt.Sprintf("%T", ep), tc.wantType)
})
}
}

func TestSelectEventProcessor_ExplicitMode(t *testing.T) {
tests := []struct {
name string
progress string
ansi string
wantType string
wantErrText string
}{
{
name: "progress=tty forces Full regardless of streams",
progress: display.ModeTTY,
ansi: "auto",
wantType: "*display.ttyWriter",
},
{
name: "progress=tty with ansi=never is rejected",
progress: display.ModeTTY,
ansi: "never",
wantErrText: "can't use --progress tty while ANSI support is disabled",
},
{
name: "progress=plain forces Plain",
progress: display.ModePlain,
ansi: "auto",
wantType: "*display.plainWriter",
},
{
name: "progress=plain with ansi=always is rejected",
progress: display.ModePlain,
ansi: "always",
wantErrText: "can't use --progress plain while ANSI support is forced",
},
{
name: "progress=quiet returns Quiet",
progress: display.ModeQuiet,
ansi: "auto",
wantType: "*display.quiet",
},
{
name: `progress="none" aliases to Quiet`,
progress: "none",
ansi: "auto",
wantType: "*display.quiet",
},
{
name: "progress=json returns JSON",
progress: display.ModeJSON,
ansi: "auto",
wantType: "*display.jsonWriter",
},
{
name: "unknown progress value is rejected",
progress: "bogus",
ansi: "auto",
wantErrText: `unsupported --progress value "bogus"`,
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
saveGlobalState(t)
// Explicit modes don't probe IsTerminal; pipes are fine for both.
cli := newMockCli(t, newStream(t, false), newStream(t, false))

ep, err := selectEventProcessor(cli, tc.progress, tc.ansi, false)
if tc.wantErrText != "" {
assert.ErrorContains(t, err, tc.wantErrText)
assert.Assert(t, ep == nil)
return
}
assert.NilError(t, err)
assert.Equal(t, fmt.Sprintf("%T", ep), tc.wantType)
})
}
}
Loading