logs: filter to services from current Compose file (#9811)

* logs: filter to services from current Compose file

When using the file model, only attach to services
referenced in the active Compose file.

For example, let's say you have `compose-base.yaml`
and `compose.yaml`, where the former only has a
subset of the services but are both run as part of
the same named project.

Project based command:
```
docker compose -p myproj logs
```
This should return logs for active services based
on the project name, regardless of Compose file
state on disk.

File based command:
```
docker compose --file compose-base.yaml logs
```
This should return logs for ONLY services that are
defined in `compose-base.yaml`. Any other services
are considered 'orphaned' within the context of the
command and should be ignored.

See also #9705.

Fixes #9801.

Signed-off-by: Milas Bowman <milas.bowman@docker.com>
This commit is contained in:
Milas Bowman 2022-09-08 16:26:00 -04:00 committed by GitHub
parent 7a8d157871
commit 61845dd781
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 290 additions and 26 deletions

View File

@ -63,12 +63,13 @@ func logsCommand(p *projectOptions, backend api.Service) *cobra.Command {
} }
func runLogs(ctx context.Context, backend api.Service, opts logsOptions, services []string) error { func runLogs(ctx context.Context, backend api.Service, opts logsOptions, services []string) error {
projectName, err := opts.toProjectName() project, name, err := opts.projectOrName()
if err != nil { if err != nil {
return err return err
} }
consumer := formatter.NewLogConsumer(ctx, os.Stdout, !opts.noColor, !opts.noPrefix) consumer := formatter.NewLogConsumer(ctx, os.Stdout, !opts.noColor, !opts.noPrefix)
return backend.Logs(ctx, projectName, consumer, api.LogOptions{ return backend.Logs(ctx, name, consumer, api.LogOptions{
Project: project,
Services: services, Services: services,
Follow: opts.follow, Follow: opts.follow,
Tail: opts.tail, Tail: opts.tail,

View File

@ -380,6 +380,7 @@ type ServiceStatus struct {
// LogOptions defines optional parameters for the `Log` API // LogOptions defines optional parameters for the `Log` API
type LogOptions struct { type LogOptions struct {
Project *types.Project
Services []string Services []string
Tail string Tail string
Since string Since string
@ -431,7 +432,7 @@ type Stack struct {
// LogConsumer is a callback to process log messages from services // LogConsumer is a callback to process log messages from services
type LogConsumer interface { type LogConsumer interface {
Log(service, container, message string) Log(containerName, service, message string)
Status(container, msg string) Status(container, msg string)
Register(container string) Register(container string)
} }
@ -442,6 +443,10 @@ type ContainerEventListener func(event ContainerEvent)
// ContainerEvent notify an event has been collected on source container implementing Service // ContainerEvent notify an event has been collected on source container implementing Service
type ContainerEvent struct { type ContainerEvent struct {
Type int Type int
// Container is the name of the container _without the project prefix_.
//
// This is only suitable for display purposes within Compose, as it's
// not guaranteed to be unique across services.
Container string Container string
Service string Service string
Line string Line string

View File

@ -23,12 +23,13 @@ import (
"testing" "testing"
"github.com/compose-spec/compose-go/types" "github.com/compose-spec/compose-go/types"
"github.com/docker/compose/v2/pkg/api"
"github.com/docker/compose/v2/pkg/mocks"
moby "github.com/docker/docker/api/types" moby "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/filters"
"github.com/golang/mock/gomock" "github.com/golang/mock/gomock"
"gotest.tools/assert" "gotest.tools/assert"
"github.com/docker/compose/v2/pkg/api"
"github.com/docker/compose/v2/pkg/mocks"
) )
func TestContainerName(t *testing.T) { func TestContainerName(t *testing.T) {
@ -77,7 +78,9 @@ func TestServiceLinks(t *testing.T) {
apiClient := mocks.NewMockAPIClient(mockCtrl) apiClient := mocks.NewMockAPIClient(mockCtrl)
cli := mocks.NewMockCli(mockCtrl) cli := mocks.NewMockCli(mockCtrl)
tested.dockerCli = cli tested := composeService{
dockerCli: cli,
}
cli.EXPECT().Client().Return(apiClient).AnyTimes() cli.EXPECT().Client().Return(apiClient).AnyTimes()
s.Links = []string{"db"} s.Links = []string{"db"}
@ -99,7 +102,9 @@ func TestServiceLinks(t *testing.T) {
defer mockCtrl.Finish() defer mockCtrl.Finish()
apiClient := mocks.NewMockAPIClient(mockCtrl) apiClient := mocks.NewMockAPIClient(mockCtrl)
cli := mocks.NewMockCli(mockCtrl) cli := mocks.NewMockCli(mockCtrl)
tested.dockerCli = cli tested := composeService{
dockerCli: cli,
}
cli.EXPECT().Client().Return(apiClient).AnyTimes() cli.EXPECT().Client().Return(apiClient).AnyTimes()
s.Links = []string{"db:db"} s.Links = []string{"db:db"}
@ -121,7 +126,9 @@ func TestServiceLinks(t *testing.T) {
defer mockCtrl.Finish() defer mockCtrl.Finish()
apiClient := mocks.NewMockAPIClient(mockCtrl) apiClient := mocks.NewMockAPIClient(mockCtrl)
cli := mocks.NewMockCli(mockCtrl) cli := mocks.NewMockCli(mockCtrl)
tested.dockerCli = cli tested := composeService{
dockerCli: cli,
}
cli.EXPECT().Client().Return(apiClient).AnyTimes() cli.EXPECT().Client().Return(apiClient).AnyTimes()
s.Links = []string{"db:dbname"} s.Links = []string{"db:dbname"}
@ -143,7 +150,9 @@ func TestServiceLinks(t *testing.T) {
defer mockCtrl.Finish() defer mockCtrl.Finish()
apiClient := mocks.NewMockAPIClient(mockCtrl) apiClient := mocks.NewMockAPIClient(mockCtrl)
cli := mocks.NewMockCli(mockCtrl) cli := mocks.NewMockCli(mockCtrl)
tested.dockerCli = cli tested := composeService{
dockerCli: cli,
}
cli.EXPECT().Client().Return(apiClient).AnyTimes() cli.EXPECT().Client().Return(apiClient).AnyTimes()
s.Links = []string{"db:dbname"} s.Links = []string{"db:dbname"}
@ -169,7 +178,9 @@ func TestServiceLinks(t *testing.T) {
defer mockCtrl.Finish() defer mockCtrl.Finish()
apiClient := mocks.NewMockAPIClient(mockCtrl) apiClient := mocks.NewMockAPIClient(mockCtrl)
cli := mocks.NewMockCli(mockCtrl) cli := mocks.NewMockCli(mockCtrl)
tested.dockerCli = cli tested := composeService{
dockerCli: cli,
}
cli.EXPECT().Client().Return(apiClient).AnyTimes() cli.EXPECT().Client().Return(apiClient).AnyTimes()
s.Links = []string{} s.Links = []string{}
@ -203,7 +214,9 @@ func TestWaitDependencies(t *testing.T) {
apiClient := mocks.NewMockAPIClient(mockCtrl) apiClient := mocks.NewMockAPIClient(mockCtrl)
cli := mocks.NewMockCli(mockCtrl) cli := mocks.NewMockCli(mockCtrl)
tested.dockerCli = cli tested := composeService{
dockerCli: cli,
}
cli.EXPECT().Client().Return(apiClient).AnyTimes() cli.EXPECT().Client().Return(apiClient).AnyTimes()
t.Run("should skip dependencies with scale 0", func(t *testing.T) { t.Run("should skip dependencies with scale 0", func(t *testing.T) {

View File

@ -37,7 +37,9 @@ func TestDown(t *testing.T) {
api := mocks.NewMockAPIClient(mockCtrl) api := mocks.NewMockAPIClient(mockCtrl)
cli := mocks.NewMockCli(mockCtrl) cli := mocks.NewMockCli(mockCtrl)
tested.dockerCli = cli tested := composeService{
dockerCli: cli,
}
cli.EXPECT().Client().Return(api).AnyTimes() cli.EXPECT().Client().Return(api).AnyTimes()
api.EXPECT().ContainerList(gomock.Any(), projectFilterListOpt(false)).Return( api.EXPECT().ContainerList(gomock.Any(), projectFilterListOpt(false)).Return(
@ -85,7 +87,9 @@ func TestDownRemoveOrphans(t *testing.T) {
api := mocks.NewMockAPIClient(mockCtrl) api := mocks.NewMockAPIClient(mockCtrl)
cli := mocks.NewMockCli(mockCtrl) cli := mocks.NewMockCli(mockCtrl)
tested.dockerCli = cli tested := composeService{
dockerCli: cli,
}
cli.EXPECT().Client().Return(api).AnyTimes() cli.EXPECT().Client().Return(api).AnyTimes()
api.EXPECT().ContainerList(gomock.Any(), projectFilterListOpt(true)).Return( api.EXPECT().ContainerList(gomock.Any(), projectFilterListOpt(true)).Return(
@ -122,7 +126,9 @@ func TestDownRemoveVolumes(t *testing.T) {
api := mocks.NewMockAPIClient(mockCtrl) api := mocks.NewMockAPIClient(mockCtrl)
cli := mocks.NewMockCli(mockCtrl) cli := mocks.NewMockCli(mockCtrl)
tested.dockerCli = cli tested := composeService{
dockerCli: cli,
}
cli.EXPECT().Client().Return(api).AnyTimes() cli.EXPECT().Client().Return(api).AnyTimes()
api.EXPECT().ContainerList(gomock.Any(), projectFilterListOpt(false)).Return( api.EXPECT().ContainerList(gomock.Any(), projectFilterListOpt(false)).Return(
@ -149,7 +155,9 @@ func TestDownRemoveImageLocal(t *testing.T) {
api := mocks.NewMockAPIClient(mockCtrl) api := mocks.NewMockAPIClient(mockCtrl)
cli := mocks.NewMockCli(mockCtrl) cli := mocks.NewMockCli(mockCtrl)
tested.dockerCli = cli tested := composeService{
dockerCli: cli,
}
cli.EXPECT().Client().Return(api).AnyTimes() cli.EXPECT().Client().Return(api).AnyTimes()
container := testContainer("service1", "123", false) container := testContainer("service1", "123", false)
@ -180,7 +188,9 @@ func TestDownRemoveImageLocalNoLabel(t *testing.T) {
api := mocks.NewMockAPIClient(mockCtrl) api := mocks.NewMockAPIClient(mockCtrl)
cli := mocks.NewMockCli(mockCtrl) cli := mocks.NewMockCli(mockCtrl)
tested.dockerCli = cli tested := composeService{
dockerCli: cli,
}
cli.EXPECT().Client().Return(api).AnyTimes() cli.EXPECT().Client().Return(api).AnyTimes()
container := testContainer("service1", "123", false) container := testContainer("service1", "123", false)
@ -208,7 +218,9 @@ func TestDownRemoveImageAll(t *testing.T) {
api := mocks.NewMockAPIClient(mockCtrl) api := mocks.NewMockAPIClient(mockCtrl)
cli := mocks.NewMockCli(mockCtrl) cli := mocks.NewMockCli(mockCtrl)
tested.dockerCli = cli tested := composeService{
dockerCli: cli,
}
cli.EXPECT().Client().Return(api).AnyTimes() cli.EXPECT().Client().Return(api).AnyTimes()
api.EXPECT().ContainerList(gomock.Any(), projectFilterListOpt(false)).Return( api.EXPECT().ContainerList(gomock.Any(), projectFilterListOpt(false)).Return(

View File

@ -35,15 +35,15 @@ import (
const testProject = "testProject" const testProject = "testProject"
var tested = composeService{}
func TestKillAll(t *testing.T) { func TestKillAll(t *testing.T) {
mockCtrl := gomock.NewController(t) mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish() defer mockCtrl.Finish()
api := mocks.NewMockAPIClient(mockCtrl) api := mocks.NewMockAPIClient(mockCtrl)
cli := mocks.NewMockCli(mockCtrl) cli := mocks.NewMockCli(mockCtrl)
tested.dockerCli = cli tested := composeService{
dockerCli: cli,
}
cli.EXPECT().Client().Return(api).AnyTimes() cli.EXPECT().Client().Return(api).AnyTimes()
name := strings.ToLower(testProject) name := strings.ToLower(testProject)
@ -74,7 +74,9 @@ func TestKillSignal(t *testing.T) {
api := mocks.NewMockAPIClient(mockCtrl) api := mocks.NewMockAPIClient(mockCtrl)
cli := mocks.NewMockCli(mockCtrl) cli := mocks.NewMockCli(mockCtrl)
tested.dockerCli = cli tested := composeService{
dockerCli: cli,
}
cli.EXPECT().Client().Return(api).AnyTimes() cli.EXPECT().Client().Return(api).AnyTimes()
name := strings.ToLower(testProject) name := strings.ToLower(testProject)
@ -97,9 +99,13 @@ func TestKillSignal(t *testing.T) {
} }
func testContainer(service string, id string, oneOff bool) moby.Container { func testContainer(service string, id string, oneOff bool) moby.Container {
// canonical docker names in the API start with a leading slash, some
// parts of Compose code will attempt to strip this off, so make sure
// it's consistently present
name := "/" + strings.TrimPrefix(id, "/")
return moby.Container{ return moby.Container{
ID: id, ID: id,
Names: []string{id}, Names: []string{name},
Labels: containerLabels(service, oneOff), Labels: containerLabels(service, oneOff),
} }
} }

View File

@ -29,13 +29,32 @@ import (
"github.com/docker/compose/v2/pkg/utils" "github.com/docker/compose/v2/pkg/utils"
) )
func (s *composeService) Logs(ctx context.Context, projectName string, consumer api.LogConsumer, options api.LogOptions) error { func (s *composeService) Logs(
ctx context.Context,
projectName string,
consumer api.LogConsumer,
options api.LogOptions,
) error {
projectName = strings.ToLower(projectName) projectName = strings.ToLower(projectName)
containers, err := s.getContainers(ctx, projectName, oneOffExclude, true, options.Services...) containers, err := s.getContainers(ctx, projectName, oneOffExclude, true, options.Services...)
if err != nil { if err != nil {
return err return err
} }
project := options.Project
if project == nil {
project, err = s.getProjectWithResources(ctx, containers, projectName)
if err != nil {
return err
}
}
if len(options.Services) == 0 {
options.Services = project.ServiceNames()
}
containers = containers.filter(isService(options.Services...))
eg, ctx := errgroup.WithContext(ctx) eg, ctx := errgroup.WithContext(ctx)
for _, c := range containers { for _, c := range containers {
c := c c := c

204
pkg/compose/logs_test.go Normal file
View File

@ -0,0 +1,204 @@
/*
Copyright 2022 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 (
"context"
"io"
"strings"
"sync"
"testing"
"github.com/compose-spec/compose-go/types"
moby "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/pkg/stdcopy"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
compose "github.com/docker/compose/v2/pkg/api"
"github.com/docker/compose/v2/pkg/mocks"
)
func TestComposeService_Logs_Demux(t *testing.T) {
mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish()
api := mocks.NewMockAPIClient(mockCtrl)
cli := mocks.NewMockCli(mockCtrl)
tested := composeService{
dockerCli: cli,
}
cli.EXPECT().Client().Return(api).AnyTimes()
name := strings.ToLower(testProject)
ctx := context.Background()
api.EXPECT().ContainerList(ctx, moby.ContainerListOptions{
All: true,
Filters: filters.NewArgs(oneOffFilter(false), projectFilter(name)),
}).Return(
[]moby.Container{
testContainer("service", "c", false),
},
nil,
)
api.EXPECT().
ContainerInspect(anyCancellableContext(), "c").
Return(moby.ContainerJSON{
ContainerJSONBase: &moby.ContainerJSONBase{ID: "c"},
Config: &container.Config{Tty: false},
}, nil)
c1Reader, c1Writer := io.Pipe()
t.Cleanup(func() {
_ = c1Reader.Close()
_ = c1Writer.Close()
})
c1Stdout := stdcopy.NewStdWriter(c1Writer, stdcopy.Stdout)
c1Stderr := stdcopy.NewStdWriter(c1Writer, stdcopy.Stderr)
go func() {
_, err := c1Stdout.Write([]byte("hello stdout\n"))
assert.NoError(t, err, "Writing to fake stdout")
_, err = c1Stderr.Write([]byte("hello stderr\n"))
assert.NoError(t, err, "Writing to fake stderr")
_ = c1Writer.Close()
}()
api.EXPECT().ContainerLogs(anyCancellableContext(), "c", gomock.Any()).
Return(c1Reader, nil)
opts := compose.LogOptions{
Project: &types.Project{
Services: types.Services{
{Name: "service"},
},
},
}
consumer := &testLogConsumer{}
err := tested.Logs(ctx, name, consumer, opts)
require.NoError(t, err)
require.Equal(
t,
[]string{"hello stdout", "hello stderr"},
consumer.LogsForContainer("service", "c"),
)
}
// TestComposeService_Logs_ServiceFiltering ensures that we do not include
// logs from out-of-scope services based on the Compose file vs actual state.
//
// NOTE(milas): This test exists because each method is currently duplicating
// a lot of the project/service filtering logic. We should consider moving it
// to an earlier point in the loading process, at which point this test could
// safely be removed.
func TestComposeService_Logs_ServiceFiltering(t *testing.T) {
mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish()
api := mocks.NewMockAPIClient(mockCtrl)
cli := mocks.NewMockCli(mockCtrl)
tested := composeService{
dockerCli: cli,
}
cli.EXPECT().Client().Return(api).AnyTimes()
name := strings.ToLower(testProject)
ctx := context.Background()
api.EXPECT().ContainerList(ctx, moby.ContainerListOptions{
All: true,
Filters: filters.NewArgs(oneOffFilter(false), projectFilter(name)),
}).Return(
[]moby.Container{
testContainer("serviceA", "c1", false),
testContainer("serviceA", "c2", false),
// serviceB will be filtered out by the project definition to
// ensure we ignore "orphan" containers
testContainer("serviceB", "c3", false),
testContainer("serviceC", "c4", false),
},
nil,
)
for _, id := range []string{"c1", "c2", "c4"} {
id := id
api.EXPECT().
ContainerInspect(anyCancellableContext(), id).
Return(
moby.ContainerJSON{
ContainerJSONBase: &moby.ContainerJSONBase{ID: id},
Config: &container.Config{Tty: true},
},
nil,
)
api.EXPECT().ContainerLogs(anyCancellableContext(), id, gomock.Any()).
Return(io.NopCloser(strings.NewReader("hello "+id+"\n")), nil).
Times(1)
}
// this simulates passing `--filename` with a Compose file that does NOT
// reference `serviceB` even though it has running services for this proj
proj := &types.Project{
Services: types.Services{
{Name: "serviceA"},
{Name: "serviceC"},
},
}
consumer := &testLogConsumer{}
opts := compose.LogOptions{
Project: proj,
}
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"))
}
type testLogConsumer struct {
mu sync.Mutex
// logs is keyed by service, then container; values are log lines
logs map[string]map[string][]string
}
func (l *testLogConsumer) Log(containerName, service, message string) {
l.mu.Lock()
defer l.mu.Unlock()
if l.logs == nil {
l.logs = make(map[string]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)
}
func (l *testLogConsumer) Status(containerName, msg string) {}
func (l *testLogConsumer) Register(containerName string) {}
func (l *testLogConsumer) LogsForContainer(svc string, containerName string) []string {
l.mu.Lock()
defer l.mu.Unlock()
return l.logs[svc][containerName]
}

View File

@ -38,7 +38,9 @@ func TestPs(t *testing.T) {
api := mocks.NewMockAPIClient(mockCtrl) api := mocks.NewMockAPIClient(mockCtrl)
cli := mocks.NewMockCli(mockCtrl) cli := mocks.NewMockCli(mockCtrl)
tested.dockerCli = cli tested := composeService{
dockerCli: cli,
}
cli.EXPECT().Client().Return(api).AnyTimes() cli.EXPECT().Client().Return(api).AnyTimes()
ctx := context.Background() ctx := context.Background()

View File

@ -38,7 +38,9 @@ func TestStopTimeout(t *testing.T) {
api := mocks.NewMockAPIClient(mockCtrl) api := mocks.NewMockAPIClient(mockCtrl)
cli := mocks.NewMockCli(mockCtrl) cli := mocks.NewMockCli(mockCtrl)
tested.dockerCli = cli tested := composeService{
dockerCli: cli,
}
cli.EXPECT().Client().Return(api).AnyTimes() cli.EXPECT().Client().Return(api).AnyTimes()
ctx := context.Background() ctx := context.Background()