mirror of
https://github.com/docker/compose.git
synced 2026-08-04 14:47:40 +00:00
fix(progress): probe stderr (not stdout) for TTY auto-detection
The progress UI writes to dockerCli.Err() but the auto-mode selector was probing dockerCli.Out().IsTerminal(), introduced when the EventProcessor was moved to the CLI layer. Any context that pipes stdout while keeping stderr attached — `docker compose up | tee log`, some CI wrappers, PowerShell native-command capture — silently dropped to plain mode. Align the detection with the stream the renderer actually targets, restoring v4 behavior. Extract the switch into selectEventProcessor so the auto-mode logic can be unit-tested with a real pty pair. Fixes docker/compose#13570 Signed-off-by: Guillaume Lours <glours@users.noreply.github.com>
This commit is contained in:
parent
cd4f21647d
commit
b8fba27032
2 changed files with 253 additions and 34 deletions
|
|
@ -499,40 +499,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))
|
||||
|
||||
|
|
@ -670,6 +639,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
|
||||
|
|
|
|||
209
cmd/compose/compose_progress_test.go
Normal file
209
cmd/compose/compose_progress_test.go
Normal file
|
|
@ -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…
Add table
Add a link
Reference in a new issue