diff --git a/cmd/compose/stats.go b/cmd/compose/stats.go index 887da083c2..f0be73739a 100644 --- a/cmd/compose/stats.go +++ b/cmd/compose/stats.go @@ -18,14 +18,10 @@ package compose import ( "context" - "fmt" "github.com/docker/cli/cli/command" "github.com/docker/cli/cli/command/container" - "github.com/moby/moby/client" "github.com/spf13/cobra" - - "github.com/docker/compose/v5/pkg/api" ) type statsOptions struct { @@ -34,6 +30,8 @@ type statsOptions struct { format string noStream bool noTrunc bool + allProjects bool + aggregate bool } func statsCommand(p *ProjectOptions, dockerCli command.Cli) *cobra.Command { @@ -45,6 +43,9 @@ func statsCommand(p *ProjectOptions, dockerCli command.Cli) *cobra.Command { Short: "Display a live stream of container(s) resource usage statistics", Args: cobra.MaximumNArgs(1), RunE: Adapt(func(ctx context.Context, args []string) error { + if opts.allProjects || opts.aggregate { + return runStatsProjects(ctx, dockerCli, opts, args) + } return runStats(ctx, dockerCli, opts, args) }), ValidArgsFunction: completeServiceNames(dockerCli, p), @@ -59,6 +60,8 @@ func statsCommand(p *ProjectOptions, dockerCli command.Cli) *cobra.Command { Refer to https://docs.docker.com/engine/cli/formatting/ for more information about formatting output with templates`) flags.BoolVar(&opts.noStream, "no-stream", false, "Disable streaming stats and only pull the first result") flags.BoolVar(&opts.noTrunc, "no-trunc", false, "Do not truncate output") + flags.BoolVar(&opts.allProjects, "all-projects", false, "Show the containers of all projects instead of the current one, with their project (only 'table' and 'json' formats)") + flags.BoolVar(&opts.aggregate, "aggregate", false, "Show one line per project, summing the resource usage of its containers (only 'table' and 'json' formats)") return cmd } @@ -67,17 +70,11 @@ func runStats(ctx context.Context, dockerCli command.Cli, opts statsOptions, ser if err != nil { return err } - f := client.Filters{} - f.Add("label", fmt.Sprintf("%s=%s", api.ProjectLabel, name)) - - if len(service) > 0 { - f.Add("label", fmt.Sprintf("%s=%s", api.ServiceLabel, service[0])) - } return container.RunStats(ctx, dockerCli, &container.StatsOptions{ All: opts.all, NoStream: opts.noStream, NoTrunc: opts.noTrunc, Format: opts.format, - Filters: f, + Filters: projectFilters(name, service), }) } diff --git a/cmd/compose/stats_projects.go b/cmd/compose/stats_projects.go new file mode 100644 index 0000000000..56a306a140 --- /dev/null +++ b/cmd/compose/stats_projects.go @@ -0,0 +1,340 @@ +/* + 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 ( + "cmp" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "slices" + "strings" + "time" + + "github.com/docker/cli/cli/command" + "github.com/docker/go-units" + "github.com/moby/moby/api/types/container" + "github.com/moby/moby/client" + "golang.org/x/sync/errgroup" + + "github.com/docker/compose/v5/cmd/formatter" + "github.com/docker/compose/v5/internal/naming" + "github.com/docker/compose/v5/pkg/api" +) + +const statsProjectsRefreshInterval = 2 * time.Second + +// statsConcurrency bounds the samples collected at the same time. The daemon needs about a second to sample a +// container, so a refresh of n containers lasts at least n/statsConcurrency seconds. +const statsConcurrency = 32 + +// usage is the resource consumption of one or several containers. +type usage struct { + cpuPercent float64 + memory float64 + netRx float64 + netTx float64 + blockRead float64 + blockWrite float64 + pids uint64 +} + +func (u *usage) add(o usage) { + u.cpuPercent += o.cpuPercent + u.memory += o.memory + u.netRx += o.netRx + u.netTx += o.netTx + u.blockRead += o.blockRead + u.blockWrite += o.blockWrite + u.pids += o.pids +} + +// containerStats is the resource consumption of a container. +type containerStats struct { + project string + name string + memLimit float64 + usage +} + +// projectStats is the resource consumption of all the containers of a project. +type projectStats struct { + name string + containers int + usage +} + +type containerStatsView struct { + Project string + Name string + CPUPerc string + MemUsage string + MemPerc string + NetIO string + BlockIO string + PIDs uint64 +} + +type projectStatsView struct { + Project string + Containers int + CPUPerc string + MemUsage string + NetIO string + BlockIO string + PIDs uint64 +} + +func validateStatsProjects(opts statsOptions, services []string) error { + if len(services) > 0 { + var flags []string + if opts.allProjects { + flags = append(flags, "--all-projects") + } + if opts.aggregate { + flags = append(flags, "--aggregate") + } + return fmt.Errorf("%s report on whole projects and cannot be used with a SERVICE", strings.Join(flags, " and ")) + } + if opts.allProjects && opts.ProjectOptions.ProjectName != "" { + return errors.New("--all-projects reports on all projects and cannot be used with --project-name") + } + return nil +} + +func projectFilters(projectName string, services []string) client.Filters { + f := client.Filters{}.Add("label", fmt.Sprintf("%s=%s", api.ProjectLabel, projectName)) + if len(services) > 0 { + f.Add("label", fmt.Sprintf("%s=%s", api.ServiceLabel, services[0])) + } + return f +} + +// runStatsProjects serves `stats --all-projects` and `stats --aggregate`: the per container or per project +// view of either all the projects or the current one. +func runStatsProjects(ctx context.Context, dockerCli command.Cli, opts statsOptions, services []string) error { + if err := validateStatsProjects(opts, services); err != nil { + return err + } + var filters client.Filters + if opts.allProjects { + filters = client.Filters{}.Add("label", api.ProjectLabel) + } else { + name, err := opts.ProjectOptions.toProjectName(ctx, dockerCli) + if err != nil { + return err + } + filters = projectFilters(name, nil) + } + + render := func(out io.Writer, rows []containerStats) error { + if opts.aggregate { + return printProjectStats(out, opts.format, aggregateByProject(rows)) + } + return printContainerStats(out, opts.format, rows) + } + // reject an unsupported format before sampling anything + if err := render(io.Discard, nil); err != nil { + return err + } + + out := dockerCli.Out() + for { + rows, err := collectContainerStats(ctx, dockerCli.Client(), opts.all, filters) + if err != nil { + return err + } + if !opts.noStream && out.IsTerminal() { + _, _ = io.WriteString(out, "\033[H\033[J") + } + if err := render(out, rows); err != nil || opts.noStream { + return err + } + + select { + case <-ctx.Done(): + return nil + case <-time.After(statsProjectsRefreshInterval): + } + } +} + +// collectContainerStats samples the running containers matching filters. Containers are returned sorted by +// project, then by name; the ones not running are returned without usage. +func collectContainerStats(ctx context.Context, apiClient client.ContainerAPIClient, all bool, filters client.Filters) ([]containerStats, error) { + list, err := apiClient.ContainerList(ctx, client.ContainerListOptions{All: all, Filters: filters}) + if err != nil { + return nil, err + } + + rows := make([]containerStats, len(list.Items)) + var eg errgroup.Group + eg.SetLimit(statsConcurrency) + for i, c := range list.Items { + rows[i] = containerStats{project: c.Labels[api.ProjectLabel], name: naming.CanonicalContainerName(c)} + if c.State != container.StateRunning { + continue + } + eg.Go(func() error { + // a container that cannot be sampled (removed since it was listed, daemon failure) is shown without + // usage rather than stopping the refresh of the others + if v, err := sampleStats(ctx, apiClient, c.ID); err == nil { + rows[i].usage = containerUsage(v) + rows[i].memLimit = float64(v.MemoryStats.Limit) + } + return nil + }) + } + _ = eg.Wait() // the goroutines never fail + // an interrupted sampling is not a list of containers without usage + if err := ctx.Err(); err != nil { + return nil, err + } + + slices.SortFunc(rows, func(a, b containerStats) int { + return cmp.Or(strings.Compare(a.project, b.project), strings.Compare(a.name, b.name)) + }) + return rows, nil +} + +// aggregateByProject sums the usage of the containers of each project. rows must be sorted by project. +func aggregateByProject(rows []containerStats) []projectStats { + var result []projectStats + for _, r := range rows { + if len(result) == 0 || result[len(result)-1].name != r.project { + result = append(result, projectStats{name: r.project}) + } + p := &result[len(result)-1] + p.containers++ + p.add(r.usage) + } + return result +} + +func sampleStats(ctx context.Context, apiClient client.ContainerAPIClient, id string) (container.StatsResponse, error) { + // a single sample is enough as long as the daemon provides the previous one to compute the CPU delta + res, err := apiClient.ContainerStats(ctx, id, client.ContainerStatsOptions{IncludePreviousSample: true}) + if err != nil { + return container.StatsResponse{}, err + } + defer res.Body.Close() //nolint:errcheck + + var v container.StatsResponse + err = json.NewDecoder(res.Body).Decode(&v) + return v, err +} + +// containerUsage computes the same figures as `docker stats` for a Linux container. It follows the helpers of +// docker/cli (cli/command/container/stats_helpers.go: calculateCPUPercentUnix, calculateMemUsageUnixNoCache, +// calculateBlockIO and calculateNetwork), which must be kept in step with it. +func containerUsage(v container.StatsResponse) usage { + u := usage{ + memory: float64(v.MemoryStats.Usage), + pids: v.PidsStats.Current, + } + + // page cache is not memory the container needs: cgroup v1 reports it as total_inactive_file, cgroup v2 as inactive_file + for _, key := range []string{"total_inactive_file", "inactive_file"} { + if cache, ok := v.MemoryStats.Stats[key]; ok && cache < v.MemoryStats.Usage { + u.memory = float64(v.MemoryStats.Usage - cache) + break + } + } + + cpuDelta := float64(v.CPUStats.CPUUsage.TotalUsage) - float64(v.PreCPUStats.CPUUsage.TotalUsage) + systemDelta := float64(v.CPUStats.SystemUsage) - float64(v.PreCPUStats.SystemUsage) + onlineCPUs := float64(v.CPUStats.OnlineCPUs) + if onlineCPUs == 0 { + onlineCPUs = float64(len(v.CPUStats.CPUUsage.PercpuUsage)) + } + if cpuDelta > 0 && systemDelta > 0 { + u.cpuPercent = cpuDelta / systemDelta * onlineCPUs * 100 + } + + for _, n := range v.Networks { + u.netRx += float64(n.RxBytes) + u.netTx += float64(n.TxBytes) + } + for _, e := range v.BlkioStats.IoServiceBytesRecursive { + switch { + case strings.EqualFold(e.Op, "read"): + u.blockRead += float64(e.Value) + case strings.EqualFold(e.Op, "write"): + u.blockWrite += float64(e.Value) + } + } + return u +} + +func printContainerStats(out io.Writer, format string, rows []containerStats) error { + view := make([]containerStatsView, len(rows)) + for i, r := range rows { + memPerc := 0.0 + if r.memLimit > 0 { + memPerc = r.memory / r.memLimit * 100 + } + view[i] = containerStatsView{ + Project: r.project, + Name: r.name, + CPUPerc: fmt.Sprintf("%.2f%%", r.cpuPercent), + MemUsage: units.BytesSize(r.memory) + " / " + units.BytesSize(r.memLimit), + MemPerc: fmt.Sprintf("%.2f%%", memPerc), + NetIO: ioString(r.netRx, r.netTx), + BlockIO: ioString(r.blockRead, r.blockWrite), + PIDs: r.pids, + } + } + return formatter.Print(view, statsFormat(format), out, func(w io.Writer) { + for _, v := range view { + _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%d\n", v.Project, v.Name, v.CPUPerc, v.MemUsage, v.MemPerc, v.NetIO, v.BlockIO, v.PIDs) + } + }, "PROJECT", "NAME", "CPU %", "MEM USAGE / LIMIT", "MEM %", "NET I/O", "BLOCK I/O", "PIDS") +} + +func printProjectStats(out io.Writer, format string, stats []projectStats) error { + view := make([]projectStatsView, len(stats)) + for i, s := range stats { + view[i] = projectStatsView{ + Project: s.name, + Containers: s.containers, + CPUPerc: fmt.Sprintf("%.2f%%", s.cpuPercent), + MemUsage: units.BytesSize(s.memory), + NetIO: ioString(s.netRx, s.netTx), + BlockIO: ioString(s.blockRead, s.blockWrite), + PIDs: s.pids, + } + } + return formatter.Print(view, statsFormat(format), out, func(w io.Writer) { + for _, v := range view { + _, _ = fmt.Fprintf(w, "%s\t%d\t%s\t%s\t%s\t%s\t%d\n", v.Project, v.Containers, v.CPUPerc, v.MemUsage, v.NetIO, v.BlockIO, v.PIDs) + } + }, "PROJECT", "CONTAINERS", "CPU %", "MEM USAGE", "NET I/O", "BLOCK I/O", "PIDS") +} + +// statsFormat makes the json format print one object per line, like `docker stats`, instead of an array. +func statsFormat(format string) string { + if strings.EqualFold(format, formatter.JSON) { + return formatter.TemplateLegacyJSON + } + return format +} + +func ioString(in, out float64) string { + return units.HumanSizeWithPrecision(in, 3) + " / " + units.HumanSizeWithPrecision(out, 3) +} diff --git a/cmd/compose/stats_projects_test.go b/cmd/compose/stats_projects_test.go new file mode 100644 index 0000000000..e9f3c901d9 --- /dev/null +++ b/cmd/compose/stats_projects_test.go @@ -0,0 +1,384 @@ +/* + 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 ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/containerd/errdefs" + "github.com/docker/cli/cli/streams" + "github.com/moby/moby/api/types/container" + "github.com/moby/moby/client" + "go.uber.org/mock/gomock" + "gotest.tools/v3/assert" + + "github.com/docker/compose/v5/pkg/api" + "github.com/docker/compose/v5/pkg/mocks" +) + +func statsBody(t *testing.T, v container.StatsResponse) client.ContainerStatsResult { + t.Helper() + b, err := json.Marshal(v) + assert.NilError(t, err) + return client.ContainerStatsResult{Body: io.NopCloser(bytes.NewReader(b))} +} + +// sampleResponse builds a stats sample using 50% of one CPU (out of 2) over the last interval. +func sampleResponse(memUsage, inactiveFile uint64) container.StatsResponse { + var v container.StatsResponse + v.PreCPUStats.CPUUsage.TotalUsage = 1_000 + v.PreCPUStats.SystemUsage = 10_000 + v.CPUStats.CPUUsage.TotalUsage = 1_500 + v.CPUStats.SystemUsage = 12_000 + v.CPUStats.OnlineCPUs = 2 + v.MemoryStats.Usage = memUsage + v.MemoryStats.Limit = 10_000 + v.MemoryStats.Stats = map[string]uint64{"inactive_file": inactiveFile} + v.Networks = map[string]container.NetworkStats{ + "eth0": {RxBytes: 10, TxBytes: 20}, + "eth1": {RxBytes: 1, TxBytes: 2}, + } + v.BlkioStats.IoServiceBytesRecursive = []container.BlkioStatEntry{ + {Op: "Read", Value: 100}, + {Op: "Write", Value: 200}, + {Op: "Total", Value: 300}, + } + v.PidsStats.Current = 3 + return v +} + +func TestContainerUsage(t *testing.T) { + u := containerUsage(sampleResponse(1000, 400)) + + // (cpuDelta=500 / systemDelta=2000) * 2 cpus * 100 + assert.Equal(t, u.cpuPercent, 50.0) + // page cache (inactive_file) is not counted as usage + assert.Equal(t, u.memory, 600.0) + assert.Equal(t, u.netRx, 11.0) + assert.Equal(t, u.netTx, 22.0) + assert.Equal(t, u.blockRead, 100.0) + assert.Equal(t, u.blockWrite, 200.0) + assert.Equal(t, u.pids, uint64(3)) +} + +func TestContainerUsageWithoutSystemDelta(t *testing.T) { + v := sampleResponse(1000, 0) + v.PreCPUStats.SystemUsage = v.CPUStats.SystemUsage // no previous sample + assert.Equal(t, containerUsage(v).cpuPercent, 0.0) +} + +func projectContainer(name, project, state string) container.Summary { + return container.Summary{ + ID: name + "-id", + // the canonical name comes with a link alias, which must not be picked + Names: []string{"/linked_by/" + name, "/" + name}, + Labels: map[string]string{api.ProjectLabel: project}, + State: container.ContainerState(state), + } +} + +func TestCollectContainerStats(t *testing.T) { + apiClient := mocks.NewMockAPIClient(gomock.NewController(t)) + filters := client.Filters{}.Add("label", api.ProjectLabel) + + apiClient.EXPECT().ContainerList(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ any, opts client.ContainerListOptions) (client.ContainerListResult, error) { + assert.Assert(t, opts.All) + assert.DeepEqual(t, opts.Filters, filters) + return client.ContainerListResult{Items: []container.Summary{ + projectContainer("beta-1", "beta", "running"), + projectContainer("alpha-2", "alpha", "running"), + projectContainer("alpha-1", "alpha", "running"), + projectContainer("alpha-3", "alpha", "exited"), + projectContainer("gone-1", "gone", "running"), + }}, nil + }) + // only running containers are sampled, and the previous sample is needed for the CPU delta + for id, mem := range map[string]uint64{"alpha-1-id": 1000, "alpha-2-id": 3000, "beta-1-id": 500} { + apiClient.EXPECT().ContainerStats(gomock.Any(), id, client.ContainerStatsOptions{IncludePreviousSample: true}). + Return(statsBody(t, sampleResponse(mem, 0)), nil) + } + // removed between the list and the stats call: not an error + apiClient.EXPECT().ContainerStats(gomock.Any(), "gone-1-id", gomock.Any()). + Return(client.ContainerStatsResult{}, errdefs.ErrNotFound) + + rows, err := collectContainerStats(t.Context(), apiClient, true, filters) + assert.NilError(t, err) + + // sorted by project, then by container name + var names []string + for _, r := range rows { + names = append(names, r.project+"/"+r.name) + } + assert.DeepEqual(t, names, []string{"alpha/alpha-1", "alpha/alpha-2", "alpha/alpha-3", "beta/beta-1", "gone/gone-1"}) + + assert.Equal(t, rows[0].memory, 1000.0) + assert.Equal(t, rows[0].memLimit, 10_000.0) + assert.Equal(t, rows[1].memory, 3000.0) + // stopped and removed containers are still listed, without usage + assert.Equal(t, rows[2].memory, 0.0) + assert.Equal(t, rows[4].memory, 0.0) +} + +// A container whose sampling fails is still listed, without usage, and does not stop the others. +func TestCollectContainerStatsKeepsRowsWhenSamplingFails(t *testing.T) { + apiClient := mocks.NewMockAPIClient(gomock.NewController(t)) + apiClient.EXPECT().ContainerList(gomock.Any(), gomock.Any()).Return(client.ContainerListResult{Items: []container.Summary{ + projectContainer("web-1", "app", "running"), + projectContainer("db-1", "app", "running"), + }}, nil) + apiClient.EXPECT().ContainerStats(gomock.Any(), "web-1-id", gomock.Any()).Return(client.ContainerStatsResult{}, errors.New("daemon unavailable")) + apiClient.EXPECT().ContainerStats(gomock.Any(), "db-1-id", gomock.Any()).Return(statsBody(t, sampleResponse(1000, 0)), nil) + + rows, err := collectContainerStats(t.Context(), apiClient, false, client.Filters{}) + assert.NilError(t, err) + assert.Equal(t, len(rows), 2) + assert.Equal(t, rows[0].name, "db-1") + assert.Equal(t, rows[0].memory, 1000.0) + assert.Equal(t, rows[1].name, "web-1") + assert.Equal(t, rows[1].memory, 0.0) +} + +// Stopping the command interrupts the sampling, which must be reported as such rather than rendered as +// containers without usage. +func TestCollectContainerStatsReportsCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + apiClient := mocks.NewMockAPIClient(gomock.NewController(t)) + apiClient.EXPECT().ContainerList(gomock.Any(), gomock.Any()).Return(client.ContainerListResult{Items: []container.Summary{ + projectContainer("web-1", "app", "running"), + }}, nil) + apiClient.EXPECT().ContainerStats(gomock.Any(), "web-1-id", gomock.Any()).DoAndReturn( + func(context.Context, string, client.ContainerStatsOptions) (client.ContainerStatsResult, error) { + cancel() + return client.ContainerStatsResult{}, ctx.Err() + }) + + _, err := collectContainerStats(ctx, apiClient, false, client.Filters{}) + assert.ErrorIs(t, err, context.Canceled) +} + +// The containers are sampled concurrently, but never more than statsConcurrency at once. +func TestCollectContainerStatsBoundsConcurrency(t *testing.T) { + const containers = statsConcurrency + 8 + var items []container.Summary + for i := range containers { + items = append(items, projectContainer(fmt.Sprintf("c-%02d", i), "app", "running")) + } + apiClient := mocks.NewMockAPIClient(gomock.NewController(t)) + apiClient.EXPECT().ContainerList(gomock.Any(), gomock.Any()).Return(client.ContainerListResult{Items: items}, nil) + + var inFlight, peak atomic.Int32 + apiClient.EXPECT().ContainerStats(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( + func(context.Context, string, client.ContainerStatsOptions) (client.ContainerStatsResult, error) { + n := inFlight.Add(1) + defer inFlight.Add(-1) + for p := peak.Load(); n > p && !peak.CompareAndSwap(p, n); p = peak.Load() { + } + time.Sleep(20 * time.Millisecond) + return statsBody(t, sampleResponse(1, 0)), nil + }).Times(containers) + + rows, err := collectContainerStats(t.Context(), apiClient, false, client.Filters{}) + assert.NilError(t, err) + assert.Equal(t, len(rows), containers) + assert.Equal(t, peak.Load(), int32(statsConcurrency)) +} + +// --no-stream samples once and returns: the rows of all the projects, or one line per project with --aggregate. +func TestRunStatsProjectsNoStream(t *testing.T) { + tests := []struct { + name string + aggregate bool + expected []string + }{ + {"containers", false, []string{"alpha/alpha-1", "alpha/alpha-2", "beta/beta-1"}}, + {"aggregate", true, []string{"alpha/2", "beta/1"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + apiClient := mocks.NewMockAPIClient(ctrl) + out := new(bytes.Buffer) + cli := mocks.NewMockCli(ctrl) + cli.EXPECT().Client().Return(apiClient).AnyTimes() + cli.EXPECT().Out().Return(streams.NewOut(out)).AnyTimes() + + apiClient.EXPECT().ContainerList(gomock.Any(), gomock.Any()).Return(client.ContainerListResult{Items: []container.Summary{ + projectContainer("beta-1", "beta", "running"), + projectContainer("alpha-2", "alpha", "running"), + projectContainer("alpha-1", "alpha", "running"), + }}, nil) + apiClient.EXPECT().ContainerStats(gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(context.Context, string, client.ContainerStatsOptions) (client.ContainerStatsResult, error) { + return statsBody(t, sampleResponse(1000, 0)), nil + }).Times(3) + + opts := statsOptions{ProjectOptions: &ProjectOptions{}, allProjects: true, aggregate: tt.aggregate, noStream: true, format: "json"} + assert.NilError(t, runStatsProjects(t.Context(), cli, opts, nil)) + + var got []string + for _, line := range strings.Split(strings.TrimSpace(out.String()), "\n") { + var row map[string]any + assert.NilError(t, json.Unmarshal([]byte(line), &row), line) + if tt.aggregate { + got = append(got, fmt.Sprintf("%s/%v", row["Project"], row["Containers"])) + } else { + got = append(got, fmt.Sprintf("%s/%s", row["Project"], row["Name"])) + } + } + assert.DeepEqual(t, got, tt.expected) + }) + } +} + +func TestAggregateByProject(t *testing.T) { + rows := []containerStats{ + {project: "alpha", name: "alpha-1", usage: usage{cpuPercent: 50, memory: 1000, netRx: 11, pids: 3}}, + {project: "alpha", name: "alpha-2", usage: usage{cpuPercent: 50, memory: 3000, netRx: 11, pids: 3}}, + {project: "alpha", name: "alpha-3"}, + {project: "beta", name: "beta-1", usage: usage{memory: 500}}, + } + + stats := aggregateByProject(rows) + + assert.Equal(t, len(stats), 2) + assert.Equal(t, stats[0].name, "alpha") + assert.Equal(t, stats[0].containers, 3) + assert.Equal(t, stats[0].cpuPercent, 100.0) + assert.Equal(t, stats[0].memory, 4000.0) + assert.Equal(t, stats[0].netRx, 22.0) + assert.Equal(t, stats[0].pids, uint64(6)) + assert.Equal(t, stats[1].name, "beta") + assert.Equal(t, stats[1].containers, 1) + assert.Equal(t, stats[1].memory, 500.0) +} + +// decodeJSONLines decodes the output of the json format, which is one object per line like `docker stats`. +func decodeJSONLines(t *testing.T, out string) []map[string]any { + t.Helper() + var decoded []map[string]any + for _, line := range strings.Split(strings.TrimSpace(out), "\n") { + if line == "" { + continue + } + var obj map[string]any + assert.NilError(t, json.Unmarshal([]byte(line), &obj), line) + decoded = append(decoded, obj) + } + return decoded +} + +func TestPrintContainerStats(t *testing.T) { + rows := []containerStats{{ + project: "alpha", + name: "alpha-1", + memLimit: 10_000, + usage: usage{cpuPercent: 50, memory: 600, netRx: 11, netTx: 22, blockRead: 100, blockWrite: 200, pids: 3}, + }} + + var table bytes.Buffer + assert.NilError(t, printContainerStats(&table, "table", rows)) + lines := strings.Split(strings.TrimSpace(table.String()), "\n") + assert.Equal(t, len(lines), 2) + assert.DeepEqual(t, strings.Fields(lines[0]), []string{"PROJECT", "NAME", "CPU", "%", "MEM", "USAGE", "/", "LIMIT", "MEM", "%", "NET", "I/O", "BLOCK", "I/O", "PIDS"}) + assert.DeepEqual(t, strings.Fields(lines[1]), []string{"alpha", "alpha-1", "50.00%", "600B", "/", "9.766KiB", "6.00%", "11B", "/", "22B", "100B", "/", "200B", "3"}) + + var out bytes.Buffer + assert.NilError(t, printContainerStats(&out, "json", append(rows, containerStats{project: "beta", name: "beta-1"}))) + decoded := decodeJSONLines(t, out.String()) + assert.Equal(t, len(decoded), 2) + assert.Equal(t, decoded[0]["Project"], "alpha") + assert.Equal(t, decoded[0]["Name"], "alpha-1") + assert.Equal(t, decoded[0]["MemPerc"], "6.00%") + assert.Equal(t, decoded[1]["Name"], "beta-1") + + // nothing to report, nothing printed + out.Reset() + assert.NilError(t, printContainerStats(&out, "json", nil)) + assert.Equal(t, out.String(), "") +} + +func TestPrintProjectStats(t *testing.T) { + stats := []projectStats{{name: "alpha", containers: 2, usage: usage{cpuPercent: 100, memory: 4000, pids: 6}}} + + var table bytes.Buffer + assert.NilError(t, printProjectStats(&table, "table", stats)) + lines := strings.Split(strings.TrimSpace(table.String()), "\n") + assert.Equal(t, len(lines), 2) + assert.DeepEqual(t, strings.Fields(lines[0])[:2], []string{"PROJECT", "CONTAINERS"}) + assert.DeepEqual(t, strings.Fields(lines[1])[:3], []string{"alpha", "2", "100.00%"}) + + var out bytes.Buffer + assert.NilError(t, printProjectStats(&out, "json", append(stats, projectStats{name: "beta", containers: 1}))) + decoded := decodeJSONLines(t, out.String()) + assert.Equal(t, len(decoded), 2) + assert.Equal(t, decoded[1]["Project"], "beta") + assert.Equal(t, decoded[0]["Project"], "alpha") + assert.Equal(t, decoded[0]["Containers"], float64(2)) + _, hasName := decoded[0]["Name"] + assert.Assert(t, !hasName) +} + +func TestProjectFilters(t *testing.T) { + assert.DeepEqual(t, projectFilters("foo", nil), client.Filters{}.Add("label", api.ProjectLabel+"=foo")) + assert.DeepEqual(t, projectFilters("foo", []string{"web"}), + client.Filters{}.Add("label", api.ProjectLabel+"=foo", api.ServiceLabel+"=web")) +} + +func TestValidateStatsProjects(t *testing.T) { + err := validateStatsProjects(statsOptions{ProjectOptions: &ProjectOptions{ProjectName: "foo"}, allProjects: true}, nil) + assert.ErrorContains(t, err, "--project-name") + + // whole projects are reported, never a single service: the error names the flags that were given + tests := []struct { + opts statsOptions + message string + }{ + {statsOptions{allProjects: true}, "--all-projects report on whole projects and cannot be used with a SERVICE"}, + {statsOptions{aggregate: true}, "--aggregate report on whole projects and cannot be used with a SERVICE"}, + {statsOptions{allProjects: true, aggregate: true}, "--all-projects and --aggregate report on whole projects and cannot be used with a SERVICE"}, + } + for _, tt := range tests { + tt.opts.ProjectOptions = &ProjectOptions{} + assert.Error(t, validateStatsProjects(tt.opts, []string{"web"}), tt.message) + assert.NilError(t, validateStatsProjects(tt.opts, nil)) + } + + // --aggregate alone reports on the current project, which -p selects + assert.NilError(t, validateStatsProjects(statsOptions{ProjectOptions: &ProjectOptions{ProjectName: "foo"}, aggregate: true}, nil)) +} + +// An unsupported format must fail before anything is listed nor sampled: the mocks expect no call at all. +func TestStatsProjectsRejectsUnsupportedFormatUpFront(t *testing.T) { + ctrl := gomock.NewController(t) + cli := mocks.NewMockCli(ctrl) + + for _, aggregate := range []bool{false, true} { + opts := statsOptions{ProjectOptions: &ProjectOptions{}, allProjects: true, aggregate: aggregate, format: "{{.Name}}"} + err := runStatsProjects(t.Context(), cli, opts, nil) + assert.ErrorIs(t, err, api.ErrParsingFailed) + } +} diff --git a/docs/reference/compose_stats.md b/docs/reference/compose_stats.md index 78d44b8935..843dac86bb 100644 --- a/docs/reference/compose_stats.md +++ b/docs/reference/compose_stats.md @@ -5,13 +5,15 @@ Display a live stream of container(s) resource usage statistics ### Options -| Name | Type | Default | Description | -|:--------------|:---------|:--------|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `-a`, `--all` | `bool` | | Show all containers (default shows just running) | -| `--dry-run` | `bool` | | Execute command in dry run mode | -| `--format` | `string` | | Format output using a custom template:
'table': Print output in table format with column headers (default)
'table TEMPLATE': Print output in table format using the given Go template
'json': Print in JSON format
'TEMPLATE': Print output using the given Go template.
Refer to https://docs.docker.com/engine/cli/formatting/ for more information about formatting output with templates | -| `--no-stream` | `bool` | | Disable streaming stats and only pull the first result | -| `--no-trunc` | `bool` | | Do not truncate output | +| Name | Type | Default | Description | +|:-----------------|:---------|:--------|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `--aggregate` | `bool` | | Show one line per project, summing the resource usage of its containers (only 'table' and 'json' formats) | +| `-a`, `--all` | `bool` | | Show all containers (default shows just running) | +| `--all-projects` | `bool` | | Show the containers of all projects instead of the current one, with their project (only 'table' and 'json' formats) | +| `--dry-run` | `bool` | | Execute command in dry run mode | +| `--format` | `string` | | Format output using a custom template:
'table': Print output in table format with column headers (default)
'table TEMPLATE': Print output in table format using the given Go template
'json': Print in JSON format
'TEMPLATE': Print output using the given Go template.
Refer to https://docs.docker.com/engine/cli/formatting/ for more information about formatting output with templates | +| `--no-stream` | `bool` | | Disable streaming stats and only pull the first result | +| `--no-trunc` | `bool` | | Do not truncate output | diff --git a/docs/reference/docker_compose_stats.yaml b/docs/reference/docker_compose_stats.yaml index e6854b05a2..e79888f5c5 100644 --- a/docs/reference/docker_compose_stats.yaml +++ b/docs/reference/docker_compose_stats.yaml @@ -5,6 +5,17 @@ usage: docker compose stats [OPTIONS] [SERVICE] pname: docker compose plink: docker_compose.yaml options: + - option: aggregate + value_type: bool + default_value: "false" + description: | + Show one line per project, summing the resource usage of its containers (only 'table' and 'json' formats) + deprecated: false + hidden: false + experimental: false + experimentalcli: false + kubernetes: false + swarm: false - option: all shorthand: a value_type: bool @@ -16,6 +27,17 @@ options: experimentalcli: false kubernetes: false swarm: false + - option: all-projects + value_type: bool + default_value: "false" + description: | + Show the containers of all projects instead of the current one, with their project (only 'table' and 'json' formats) + deprecated: false + hidden: false + experimental: false + experimentalcli: false + kubernetes: false + swarm: false - option: format value_type: string description: |-