diff --git a/cmd/compose/logs.go b/cmd/compose/logs.go index afaec851c..4f54acf23 100644 --- a/cmd/compose/logs.go +++ b/cmd/compose/logs.go @@ -67,7 +67,7 @@ func runLogs(ctx context.Context, backend api.Service, opts logsOptions, service if err != nil { return err } - consumer := formatter.NewLogConsumer(ctx, os.Stdout, !opts.noColor, !opts.noPrefix) + consumer := formatter.NewLogConsumer(ctx, os.Stdout, os.Stderr, !opts.noColor, !opts.noPrefix) return backend.Logs(ctx, name, consumer, api.LogOptions{ Project: project, Services: services, diff --git a/cmd/compose/up.go b/cmd/compose/up.go index 5e61026b9..eae6b3d15 100644 --- a/cmd/compose/up.go +++ b/cmd/compose/up.go @@ -176,7 +176,7 @@ func runUp(ctx context.Context, backend api.Service, createOptions createOptions var consumer api.LogConsumer if !upOptions.Detach { - consumer = formatter.NewLogConsumer(ctx, os.Stdout, !upOptions.noColor, !upOptions.noPrefix) + consumer = formatter.NewLogConsumer(ctx, os.Stdout, os.Stderr, !upOptions.noColor, !upOptions.noPrefix) } attachTo := services diff --git a/cmd/formatter/logs.go b/cmd/formatter/logs.go index 74ac6e056..715a9ddaf 100644 --- a/cmd/formatter/logs.go +++ b/cmd/formatter/logs.go @@ -32,18 +32,20 @@ type logConsumer struct { ctx context.Context presenters sync.Map // map[string]*presenter width int - writer io.Writer + stdout io.Writer + stderr io.Writer color bool prefix bool } // NewLogConsumer creates a new LogConsumer -func NewLogConsumer(ctx context.Context, w io.Writer, color bool, prefix bool) api.LogConsumer { +func NewLogConsumer(ctx context.Context, stdout, stderr io.Writer, color bool, prefix bool) api.LogConsumer { return &logConsumer{ ctx: ctx, presenters: sync.Map{}, width: 0, - writer: w, + stdout: stdout, + stderr: stderr, color: color, prefix: prefix, } @@ -83,20 +85,29 @@ func (l *logConsumer) getPresenter(container string) *presenter { } // Log formats a log message as received from name/container -func (l *logConsumer) Log(container, service, message string) { +func (l *logConsumer) Log(container, message string) { + l.write(l.stdout, container, message) +} + +// Log formats a log message as received from name/container +func (l *logConsumer) Err(container, message string) { + l.write(l.stderr, container, message) +} + +func (l *logConsumer) write(w io.Writer, container, message string) { if l.ctx.Err() != nil { return } p := l.getPresenter(container) for _, line := range strings.Split(message, "\n") { - fmt.Fprintf(l.writer, "%s%s\n", p.prefix, line) + fmt.Fprintf(w, "%s%s\n", p.prefix, line) } } func (l *logConsumer) Status(container, msg string) { p := l.getPresenter(container) s := p.colors(fmt.Sprintf("%s %s\n", container, msg)) - l.writer.Write([]byte(s)) //nolint:errcheck + l.stdout.Write([]byte(s)) //nolint:errcheck } func (l *logConsumer) computeWidth() { diff --git a/pkg/api/api.go b/pkg/api/api.go index d02a90e48..0fb6597b2 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -437,7 +437,8 @@ type Stack struct { // LogConsumer is a callback to process log messages from services type LogConsumer interface { - Log(containerName, service, message string) + Log(containerName, message string) + Err(containerName, message string) Status(container, msg string) Register(container string) } @@ -461,8 +462,10 @@ type ContainerEvent struct { } const ( - // ContainerEventLog is a ContainerEvent of type log. Line is set + // ContainerEventLog is a ContainerEvent of type log on stdout. Line is set ContainerEventLog = iota + // ContainerEventErr is a ContainerEvent of type log on stderr. Line is set + ContainerEventErr // ContainerEventAttach is a ContainerEvent of type attach. First event sent about a container ContainerEventAttach // ContainerEventStopped is a ContainerEvent of type stopped. diff --git a/pkg/compose/attach.go b/pkg/compose/attach.go index 729916446..29f961f8f 100644 --- a/pkg/compose/attach.go +++ b/pkg/compose/attach.go @@ -69,7 +69,7 @@ func (s *composeService) attachContainer(ctx context.Context, container moby.Con Service: serviceName, }) - w := utils.GetWriter(func(line string) { + wOut := utils.GetWriter(func(line string) { listener(api.ContainerEvent{ Type: api.ContainerEventLog, Container: containerName, @@ -77,13 +77,21 @@ func (s *composeService) attachContainer(ctx context.Context, container moby.Con Line: line, }) }) + wErr := utils.GetWriter(func(line string) { + listener(api.ContainerEvent{ + Type: api.ContainerEventErr, + Container: containerName, + Service: serviceName, + Line: line, + }) + }) inspect, err := s.dockerCli.Client().ContainerInspect(ctx, container.ID) if err != nil { return err } - _, _, err = s.attachContainerStreams(ctx, container.ID, inspect.Config.Tty, nil, w, w) + _, _, err = s.attachContainerStreams(ctx, container.ID, inspect.Config.Tty, nil, wOut, wErr) return err } diff --git a/pkg/compose/logs.go b/pkg/compose/logs.go index 9968e1283..560d8a154 100644 --- a/pkg/compose/logs.go +++ b/pkg/compose/logs.go @@ -99,7 +99,6 @@ func (s *composeService) logContainers(ctx context.Context, consumer api.LogCons return err } - service := c.Labels[api.ServiceLabel] r, err := s.apiClient().ContainerLogs(ctx, cnt.ID, types.ContainerLogsOptions{ ShowStdout: true, ShowStderr: true, @@ -116,7 +115,7 @@ func (s *composeService) logContainers(ctx context.Context, consumer api.LogCons name := getContainerNameWithoutProject(c) w := utils.GetWriter(func(line string) { - consumer.Log(name, service, line) + consumer.Log(name, line) }) if cnt.Config.Tty { _, err = io.Copy(w, r) diff --git a/pkg/compose/logs_test.go b/pkg/compose/logs_test.go index 2bd05737c..b81f976c7 100644 --- a/pkg/compose/logs_test.go +++ b/pkg/compose/logs_test.go @@ -98,7 +98,7 @@ func TestComposeService_Logs_Demux(t *testing.T) { require.Equal( t, []string{"hello stdout", "hello stderr"}, - consumer.LogsForContainer("service", "c"), + consumer.LogsForContainer("c"), ) } @@ -169,36 +169,37 @@ func TestComposeService_Logs_ServiceFiltering(t *testing.T) { err := tested.Logs(ctx, name, consumer, opts) require.NoError(t, err) - require.Equal(t, []string{"hello c1"}, consumer.LogsForContainer("serviceA", "c1")) - require.Equal(t, []string{"hello c2"}, consumer.LogsForContainer("serviceA", "c2")) - require.Empty(t, consumer.LogsForContainer("serviceB", "c3")) - require.Equal(t, []string{"hello c4"}, consumer.LogsForContainer("serviceC", "c4")) + require.Equal(t, []string{"hello c1"}, consumer.LogsForContainer("c1")) + require.Equal(t, []string{"hello c2"}, consumer.LogsForContainer("c2")) + require.Empty(t, consumer.LogsForContainer("c3")) + require.Equal(t, []string{"hello c4"}, consumer.LogsForContainer("c4")) } type testLogConsumer struct { mu sync.Mutex - // logs is keyed by service, then container; values are log lines - logs map[string]map[string][]string + // logs is keyed container; values are log lines + logs map[string][]string } -func (l *testLogConsumer) Log(containerName, service, message string) { +func (l *testLogConsumer) Log(containerName, message string) { l.mu.Lock() defer l.mu.Unlock() if l.logs == nil { - l.logs = make(map[string]map[string][]string) + l.logs = make(map[string][]string) } - if l.logs[service] == nil { - l.logs[service] = make(map[string][]string) - } - l.logs[service][containerName] = append(l.logs[service][containerName], message) + l.logs[containerName] = append(l.logs[containerName], message) +} + +func (l *testLogConsumer) Err(containerName, message string) { + l.Log(containerName, message) } func (l *testLogConsumer) Status(containerName, msg string) {} func (l *testLogConsumer) Register(containerName string) {} -func (l *testLogConsumer) LogsForContainer(svc string, containerName string) []string { +func (l *testLogConsumer) LogsForContainer(containerName string) []string { l.mu.Lock() defer l.mu.Unlock() - return l.logs[svc][containerName] + return l.logs[containerName] } diff --git a/pkg/compose/printer.go b/pkg/compose/printer.go index e83ec2a36..a46012767 100644 --- a/pkg/compose/printer.go +++ b/pkg/compose/printer.go @@ -118,7 +118,11 @@ func (p *printer) Run(ctx context.Context, cascadeStop bool, exitCodeFrom string } case api.ContainerEventLog: if !aborting { - p.consumer.Log(container, event.Service, event.Line) + p.consumer.Log(container, event.Line) + } + case api.ContainerEventErr: + if !aborting { + p.consumer.Err(container, event.Line) } } } diff --git a/pkg/e2e/compose_up_test.go b/pkg/e2e/compose_up_test.go index f8a2093c6..7aef1a5df 100644 --- a/pkg/e2e/compose_up_test.go +++ b/pkg/e2e/compose_up_test.go @@ -66,3 +66,13 @@ func TestPortRange(t *testing.T) { c.RunDockerComposeCmd(t, "--project-name", projectName, "down", "--remove-orphans") } + +func TestStdoutStderr(t *testing.T) { + c := NewParallelCLI(t) + const projectName = "e2e-stdout-stderr" + + res := c.RunDockerComposeCmdNoCheck(t, "-f", "fixtures/stdout-stderr/compose.yaml", "--project-name", projectName, "up") + res.Assert(t, icmd.Expected{Out: "log to stdout", Err: "log to stderr"}) + + c.RunDockerComposeCmd(t, "--project-name", projectName, "down", "--remove-orphans") +} diff --git a/pkg/e2e/fixtures/stdout-stderr/compose.yaml b/pkg/e2e/fixtures/stdout-stderr/compose.yaml new file mode 100644 index 000000000..a5ab0a8a2 --- /dev/null +++ b/pkg/e2e/fixtures/stdout-stderr/compose.yaml @@ -0,0 +1,6 @@ +services: + stderr: + image: alpine + command: /bin/ash /log_to_stderr.sh + volumes: + - ./log_to_stderr.sh:/log_to_stderr.sh diff --git a/pkg/e2e/fixtures/stdout-stderr/log_to_stderr.sh b/pkg/e2e/fixtures/stdout-stderr/log_to_stderr.sh new file mode 100755 index 000000000..f015ca89b --- /dev/null +++ b/pkg/e2e/fixtures/stdout-stderr/log_to_stderr.sh @@ -0,0 +1,16 @@ +# 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. + +>&2 echo "log to stderr" +echo "log to stdout" diff --git a/pkg/mocks/mock_docker_compose_api.go b/pkg/mocks/mock_docker_compose_api.go index 3046ae427..73874f8dc 100644 --- a/pkg/mocks/mock_docker_compose_api.go +++ b/pkg/mocks/mock_docker_compose_api.go @@ -416,16 +416,28 @@ func (m *MockLogConsumer) EXPECT() *MockLogConsumerMockRecorder { return m.recorder } -// Log mocks base method. -func (m *MockLogConsumer) Log(containerName, service, message string) { +// Err mocks base method. +func (m *MockLogConsumer) Err(containerName, message string) { m.ctrl.T.Helper() - m.ctrl.Call(m, "Log", containerName, service, message) + m.ctrl.Call(m, "Err", containerName, message) +} + +// Err indicates an expected call of Err. +func (mr *MockLogConsumerMockRecorder) Err(containerName, message interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Err", reflect.TypeOf((*MockLogConsumer)(nil).Err), containerName, message) +} + +// Log mocks base method. +func (m *MockLogConsumer) Log(containerName, message string) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "Log", containerName, message) } // Log indicates an expected call of Log. -func (mr *MockLogConsumerMockRecorder) Log(containerName, service, message interface{}) *gomock.Call { +func (mr *MockLogConsumerMockRecorder) Log(containerName, message interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Log", reflect.TypeOf((*MockLogConsumer)(nil).Log), containerName, service, message) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Log", reflect.TypeOf((*MockLogConsumer)(nil).Log), containerName, message) } // Register mocks base method.