diff --git a/cmd/compose/top.go b/cmd/compose/top.go index 379b2b0c54..2ec318bc07 100644 --- a/cmd/compose/top.go +++ b/cmd/compose/top.go @@ -49,6 +49,9 @@ func topCommand(p *ProjectOptions, dockerCli command.Cli, backend api.Service) * return topCmd } +type topHeader map[string]int // maps a proc title to its output index +type topEntries map[string]string + func runTop(ctx context.Context, dockerCli command.Cli, backend api.Service, opts topOptions, services []string) error { projectName, err := opts.toProjectName(ctx, dockerCli) if err != nil { @@ -63,30 +66,60 @@ func runTop(ctx context.Context, dockerCli command.Cli, backend api.Service, opt return containers[i].Name < containers[j].Name }) + header, entries := collectTop(containers) + return topPrint(dockerCli.Out(), header, entries) +} + +func collectTop(containers []api.ContainerProcSummary) (topHeader, []topEntries) { + // map column name to its header (should keep working if backend.Top returns + // varying columns for different containers) + header := topHeader{"SERVICE": 0} + + // assume one process per container and grow if needed + entries := make([]topEntries, 0, len(containers)) + for _, container := range containers { - _, _ = fmt.Fprintf(dockerCli.Out(), "%s\n", container.Name) - err := psPrinter(dockerCli.Out(), func(w io.Writer) { - for _, proc := range container.Processes { - info := []interface{}{} - for _, p := range proc { - info = append(info, p) - } - _, _ = fmt.Fprintf(w, strings.Repeat("%s\t", len(info))+"\n", info...) + for _, proc := range container.Processes { + entry := topEntries{"SERVICE": container.Name} + for i, title := range container.Titles { + if _, exists := header[title]; !exists { + header[title] = len(header) + } + entry[title] = proc[i] } - _, _ = fmt.Fprintln(w) - }, - container.Titles...) - if err != nil { - return err + + entries = append(entries, entry) } } - return nil + return header, entries } -func psPrinter(out io.Writer, printer func(writer io.Writer), headers ...string) error { +func topPrint(out io.Writer, headers topHeader, rows []topEntries) error { + if len(rows) == 0 { + return nil + } + w := tabwriter.NewWriter(out, 5, 1, 3, ' ', 0) - _, _ = fmt.Fprintln(w, strings.Join(headers, "\t")) - printer(w) + + // write headers in the order we've encountered them + h := make([]string, len(headers)) + for title, index := range headers { + h[index] = title + } + _, _ = fmt.Fprintln(w, strings.Join(h, "\t")) + + for _, row := range rows { + // write proc data in header order + r := make([]string, len(headers)) + for title, index := range headers { + if v, ok := row[title]; ok { + r[index] = v + } else { + r[index] = "-" + } + } + _, _ = fmt.Fprintln(w, strings.Join(r, "\t")) + } return w.Flush() } diff --git a/cmd/compose/top_test.go b/cmd/compose/top_test.go new file mode 100644 index 0000000000..9a79ea148c --- /dev/null +++ b/cmd/compose/top_test.go @@ -0,0 +1,313 @@ +/* + Copyright 2024 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" + "strings" + "testing" + + "github.com/docker/compose/v2/pkg/api" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var topTestCases = []struct { + name string + titles []string + procs [][]string + + header topHeader + entries []topEntries + output string +}{ + { + name: "noprocs", + titles: []string{"UID", "PID", "PPID", "C", "STIME", "TTY", "TIME", "CMD"}, + procs: [][]string{}, + header: topHeader{"SERVICE": 0}, + entries: []topEntries{}, + output: "", + }, + { + name: "simple", + titles: []string{"UID", "PID", "PPID", "C", "STIME", "TTY", "TIME", "CMD"}, + procs: [][]string{{"root", "1", "1", "0", "12:00", "?", "00:00:01", "/entrypoint"}}, + header: topHeader{ + "SERVICE": 0, + "UID": 1, + "PID": 2, + "PPID": 3, + "C": 4, + "STIME": 5, + "TTY": 6, + "TIME": 7, + "CMD": 8, + }, + entries: []topEntries{ + { + "SERVICE": "simple", + "UID": "root", + "PID": "1", + "PPID": "1", + "C": "0", + "STIME": "12:00", + "TTY": "?", + "TIME": "00:00:01", + "CMD": "/entrypoint", + }, + }, + output: trim(` + SERVICE UID PID PPID C STIME TTY TIME CMD + simple root 1 1 0 12:00 ? 00:00:01 /entrypoint + `), + }, + { + name: "noppid", + titles: []string{"UID", "PID", "C", "STIME", "TTY", "TIME", "CMD"}, + procs: [][]string{{"root", "1", "0", "12:00", "?", "00:00:02", "/entrypoint"}}, + header: topHeader{ + "SERVICE": 0, + "UID": 1, + "PID": 2, + "C": 3, + "STIME": 4, + "TTY": 5, + "TIME": 6, + "CMD": 7, + }, + entries: []topEntries{ + { + "SERVICE": "noppid", + "UID": "root", + "PID": "1", + "C": "0", + "STIME": "12:00", + "TTY": "?", + "TIME": "00:00:02", + "CMD": "/entrypoint", + }, + }, + output: trim(` + SERVICE UID PID C STIME TTY TIME CMD + noppid root 1 0 12:00 ? 00:00:02 /entrypoint + `), + }, + { + name: "extra-hdr", + titles: []string{"UID", "GID", "PID", "PPID", "C", "STIME", "TTY", "TIME", "CMD"}, + procs: [][]string{{"root", "1", "1", "1", "0", "12:00", "?", "00:00:03", "/entrypoint"}}, + header: topHeader{ + "SERVICE": 0, + "UID": 1, + "GID": 2, + "PID": 3, + "PPID": 4, + "C": 5, + "STIME": 6, + "TTY": 7, + "TIME": 8, + "CMD": 9, + }, + entries: []topEntries{ + { + "SERVICE": "extra-hdr", + "UID": "root", + "GID": "1", + "PID": "1", + "PPID": "1", + "C": "0", + "STIME": "12:00", + "TTY": "?", + "TIME": "00:00:03", + "CMD": "/entrypoint", + }, + }, + output: trim(` + SERVICE UID GID PID PPID C STIME TTY TIME CMD + extra-hdr root 1 1 1 0 12:00 ? 00:00:03 /entrypoint + `), + }, + { + name: "multiple", + titles: []string{"UID", "PID", "PPID", "C", "STIME", "TTY", "TIME", "CMD"}, + procs: [][]string{ + {"root", "1", "1", "0", "12:00", "?", "00:00:04", "/entrypoint"}, + {"root", "123", "1", "0", "12:00", "?", "00:00:42", "sleep infinity"}, + }, + header: topHeader{ + "SERVICE": 0, + "UID": 1, + "PID": 2, + "PPID": 3, + "C": 4, + "STIME": 5, + "TTY": 6, + "TIME": 7, + "CMD": 8, + }, + entries: []topEntries{ + { + "SERVICE": "multiple", + "UID": "root", + "PID": "1", + "PPID": "1", + "C": "0", + "STIME": "12:00", + "TTY": "?", + "TIME": "00:00:04", + "CMD": "/entrypoint", + }, + { + "SERVICE": "multiple", + "UID": "root", + "PID": "123", + "PPID": "1", + "C": "0", + "STIME": "12:00", + "TTY": "?", + "TIME": "00:00:42", + "CMD": "sleep infinity", + }, + }, + output: trim(` + SERVICE UID PID PPID C STIME TTY TIME CMD + multiple root 1 1 0 12:00 ? 00:00:04 /entrypoint + multiple root 123 1 0 12:00 ? 00:00:42 sleep infinity + `), + }, +} + +// TestRunTopCore only tests the core functionality of runTop: formatting +// and printing of the output of (api.Service).Top(). +func TestRunTopCore(t *testing.T) { + t.Parallel() + + all := []api.ContainerProcSummary{} + + for _, tc := range topTestCases { + summary := api.ContainerProcSummary{ + Name: tc.name, + Titles: tc.titles, + Processes: tc.procs, + } + all = append(all, summary) + + t.Run(tc.name, func(t *testing.T) { + header, entries := collectTop([]api.ContainerProcSummary{summary}) + assert.EqualValues(t, tc.header, header) + assert.EqualValues(t, tc.entries, entries) + + var buf bytes.Buffer + err := topPrint(&buf, header, entries) + + require.NoError(t, err) + assert.Equal(t, tc.output, buf.String()) + }) + } + + t.Run("all", func(t *testing.T) { + header, entries := collectTop(all) + assert.EqualValues(t, topHeader{ + "SERVICE": 0, + "UID": 1, + "PID": 2, + "PPID": 3, + "C": 4, + "STIME": 5, + "TTY": 6, + "TIME": 7, + "CMD": 8, + "GID": 9, + }, header) + assert.EqualValues(t, []topEntries{ + { + "SERVICE": "simple", + "UID": "root", + "PID": "1", + "PPID": "1", + "C": "0", + "STIME": "12:00", + "TTY": "?", + "TIME": "00:00:01", + "CMD": "/entrypoint", + }, { + "SERVICE": "noppid", + "UID": "root", + "PID": "1", + "C": "0", + "STIME": "12:00", + "TTY": "?", + "TIME": "00:00:02", + "CMD": "/entrypoint", + }, { + "SERVICE": "extra-hdr", + "UID": "root", + "GID": "1", + "PID": "1", + "PPID": "1", + "C": "0", + "STIME": "12:00", + "TTY": "?", + "TIME": "00:00:03", + "CMD": "/entrypoint", + }, { + "SERVICE": "multiple", + "UID": "root", + "PID": "1", + "PPID": "1", + "C": "0", + "STIME": "12:00", + "TTY": "?", + "TIME": "00:00:04", + "CMD": "/entrypoint", + }, { + "SERVICE": "multiple", + "UID": "root", + "PID": "123", + "PPID": "1", + "C": "0", + "STIME": "12:00", + "TTY": "?", + "TIME": "00:00:42", + "CMD": "sleep infinity", + }, + }, entries) + + var buf bytes.Buffer + err := topPrint(&buf, header, entries) + require.NoError(t, err) + assert.Equal(t, trim(` + SERVICE UID PID PPID C STIME TTY TIME CMD GID + simple root 1 1 0 12:00 ? 00:00:01 /entrypoint - + noppid root 1 - 0 12:00 ? 00:00:02 /entrypoint - + extra-hdr root 1 1 0 12:00 ? 00:00:03 /entrypoint 1 + multiple root 1 1 0 12:00 ? 00:00:04 /entrypoint - + multiple root 123 1 0 12:00 ? 00:00:42 sleep infinity - + `), buf.String()) + + }) +} + +func trim(s string) string { + var out bytes.Buffer + for _, line := range strings.Split(strings.TrimSpace(s), "\n") { + out.WriteString(strings.TrimSpace(line)) + out.WriteRune('\n') + } + return out.String() +}