diff --git a/cmd/compose/compose.go b/cmd/compose/compose.go index 256b30d5..29c6f90e 100644 --- a/cmd/compose/compose.go +++ b/cmd/compose/compose.go @@ -275,7 +275,7 @@ func RunningAsStandalone() bool { } // RootCommand returns the compose command with its child commands -func RootCommand(streams command.Cli, backend api.Service) *cobra.Command { //nolint:gocyclo +func RootCommand(dockerCli command.Cli, backend api.Service) *cobra.Command { //nolint:gocyclo // filter out useless commandConn.CloseWrite warning message that can occur // when using a remote context that is unreachable: "commandConn.CloseWrite: commandconn: failed to wait: signal: killed" // https://github.com/docker/cli/blob/e1f24d3c93df6752d3c27c8d61d18260f141310c/cli/connhelper/commandconn/commandconn.go#L203-L215 @@ -307,7 +307,7 @@ func RootCommand(streams command.Cli, backend api.Service) *cobra.Command { //no return cmd.Help() } if version { - return versionCommand(streams).Execute() + return versionCommand(dockerCli).Execute() } _ = cmd.Help() return dockercli.StatusError{ @@ -345,11 +345,11 @@ func RootCommand(streams command.Cli, backend api.Service) *cobra.Command { //no ansi = v } - formatter.SetANSIMode(streams, ansi) + formatter.SetANSIMode(dockerCli, ansi) if noColor, ok := os.LookupEnv("NO_COLOR"); ok && noColor != "" { ui.NoColor() - formatter.SetANSIMode(streams, formatter.Never) + formatter.SetANSIMode(dockerCli, formatter.Never) } switch ansi { @@ -426,26 +426,26 @@ func RootCommand(streams command.Cli, backend api.Service) *cobra.Command { //no } c.AddCommand( - upCommand(&opts, streams, backend), + upCommand(&opts, dockerCli, backend), downCommand(&opts, backend), startCommand(&opts, backend), restartCommand(&opts, backend), stopCommand(&opts, backend), - psCommand(&opts, streams, backend), - listCommand(streams, backend), - logsCommand(&opts, streams, backend), - configCommand(&opts, streams, backend), + psCommand(&opts, dockerCli, backend), + listCommand(dockerCli, backend), + logsCommand(&opts, dockerCli, backend), + configCommand(&opts, dockerCli, backend), killCommand(&opts, backend), - runCommand(&opts, streams, backend), + runCommand(&opts, dockerCli, backend), removeCommand(&opts, backend), - execCommand(&opts, streams, backend), + execCommand(&opts, dockerCli, backend), pauseCommand(&opts, backend), unpauseCommand(&opts, backend), - topCommand(&opts, streams, backend), - eventsCommand(&opts, streams, backend), - portCommand(&opts, streams, backend), - imagesCommand(&opts, streams, backend), - versionCommand(streams), + topCommand(&opts, dockerCli, backend), + eventsCommand(&opts, dockerCli, backend), + portCommand(&opts, dockerCli, backend), + imagesCommand(&opts, dockerCli, backend), + versionCommand(dockerCli), buildCommand(&opts, &progress, backend), pushCommand(&opts, backend), pullCommand(&opts, backend), diff --git a/cmd/compose/ps.go b/cmd/compose/ps.go index 007cc2a9..1386234e 100644 --- a/cmd/compose/ps.go +++ b/cmd/compose/ps.go @@ -19,22 +19,18 @@ package compose import ( "context" "fmt" - "io" "sort" - "strconv" "strings" - "time" "github.com/docker/compose/v2/cmd/formatter" + "github.com/docker/compose/v2/pkg/api" "github.com/docker/compose/v2/pkg/utils" - "github.com/docker/docker/api/types" - formatter2 "github.com/docker/cli/cli/command/formatter" - "github.com/docker/go-units" + "github.com/docker/cli/cli/command" + cliformatter "github.com/docker/cli/cli/command/formatter" + cliflags "github.com/docker/cli/cli/flags" "github.com/pkg/errors" "github.com/spf13/cobra" - - "github.com/docker/compose/v2/pkg/api" ) type psOptions struct { @@ -66,7 +62,7 @@ func (p *psOptions) parseFilter() error { return nil } -func psCommand(p *ProjectOptions, streams api.Streams, backend api.Service) *cobra.Command { +func psCommand(p *ProjectOptions, dockerCli command.Cli, backend api.Service) *cobra.Command { opts := psOptions{ ProjectOptions: p, } @@ -77,12 +73,12 @@ func psCommand(p *ProjectOptions, streams api.Streams, backend api.Service) *cob return opts.parseFilter() }, RunE: Adapt(func(ctx context.Context, args []string) error { - return runPs(ctx, streams, backend, args, opts) + return runPs(ctx, dockerCli, backend, args, opts) }), ValidArgsFunction: completeServiceNames(p), } flags := psCmd.Flags() - flags.StringVar(&opts.Format, "format", "table", "Format the output. Values: [table | json]") + flags.StringVar(&opts.Format, "format", "table", cliflags.FormatHelp) flags.StringVar(&opts.Filter, "filter", "", "Filter services by a property (supported filters: status).") flags.StringArrayVar(&opts.Status, "status", []string{}, "Filter services by status. Values: [paused | restarting | removing | running | dead | created | exited]") flags.BoolVarP(&opts.Quiet, "quiet", "q", false, "Only display IDs") @@ -91,7 +87,7 @@ func psCommand(p *ProjectOptions, streams api.Streams, backend api.Service) *cob return psCmd } -func runPs(ctx context.Context, streams api.Streams, backend api.Service, services []string, opts psOptions) error { +func runPs(ctx context.Context, dockerCli command.Cli, backend api.Service, services []string, opts psOptions) error { project, name, err := opts.projectOrName(services...) if err != nil { return err @@ -125,38 +121,32 @@ func runPs(ctx context.Context, streams api.Streams, backend api.Service, servic if opts.Quiet { for _, c := range containers { - fmt.Fprintln(streams.Out(), c.ID) + fmt.Fprintln(dockerCli.Out(), c.ID) } return nil } if opts.Services { services := []string{} - for _, s := range containers { - if !utils.StringContains(services, s.Service) { - services = append(services, s.Service) + for _, c := range containers { + s := c.Service + if !utils.StringContains(services, s) { + services = append(services, s) } } - fmt.Fprintln(streams.Out(), strings.Join(services, "\n")) + fmt.Fprintln(dockerCli.Out(), strings.Join(services, "\n")) return nil } - return formatter.Print(containers, opts.Format, streams.Out(), - writer(containers), - "NAME", "IMAGE", "COMMAND", "SERVICE", "CREATED", "STATUS", "PORTS") -} - -func writer(containers []api.ContainerSummary) func(w io.Writer) { - return func(w io.Writer) { - for _, container := range containers { - ports := displayablePorts(container) - createdAt := time.Unix(container.Created, 0) - created := units.HumanDuration(time.Now().UTC().Sub(createdAt)) + " ago" - status := container.Status - command := formatter2.Ellipsis(container.Command, 20) - _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", container.Name, container.Image, strconv.Quote(command), container.Service, created, status, ports) - } + if opts.Format == "" { + opts.Format = dockerCli.ConfigFile().PsFormat } + + containerCtx := cliformatter.Context{ + Output: dockerCli.Out(), + Format: formatter.NewContainerFormat(opts.Format, opts.Quiet, false), + } + return formatter.ContainerWrite(containerCtx, containers) } func filterByStatus(containers []api.ContainerSummary, statuses []string) []api.ContainerSummary { @@ -177,21 +167,3 @@ func hasStatus(c api.ContainerSummary, statuses []string) bool { } return false } - -func displayablePorts(c api.ContainerSummary) string { - if c.Publishers == nil { - return "" - } - - ports := make([]types.Port, len(c.Publishers)) - for i, pub := range c.Publishers { - ports[i] = types.Port{ - IP: pub.URL, - PrivatePort: uint16(pub.TargetPort), - PublicPort: uint16(pub.PublishedPort), - Type: pub.Protocol, - } - } - - return formatter2.DisplayablePorts(ports) -} diff --git a/cmd/compose/ps_test.go b/cmd/compose/ps_test.go index 621214ff..c526f831 100644 --- a/cmd/compose/ps_test.go +++ b/cmd/compose/ps_test.go @@ -18,11 +18,11 @@ package compose import ( "context" - "io" "os" "path/filepath" "testing" + "github.com/docker/cli/cli/config/configfile" "github.com/docker/cli/cli/streams" "github.com/docker/compose/v2/pkg/api" "github.com/docker/compose/v2/pkg/mocks" @@ -69,7 +69,11 @@ func TestPsTable(t *testing.T) { }).AnyTimes() opts := psOptions{ProjectOptions: &ProjectOptions{ProjectName: "test"}} - err = runPs(ctx, stream{out: streams.NewOut(f)}, backend, nil, opts) + stdout := streams.NewOut(f) + cli := mocks.NewMockCli(ctrl) + cli.EXPECT().Out().Return(stdout).AnyTimes() + cli.EXPECT().ConfigFile().Return(&configfile.ConfigFile{}).AnyTimes() + err = runPs(ctx, cli, backend, nil, opts) assert.NoError(t, err) _, err = f.Seek(0, 0) @@ -80,21 +84,3 @@ func TestPsTable(t *testing.T) { assert.Contains(t, string(output), "8080/tcp, 8443/tcp") } - -type stream struct { - out *streams.Out - err io.Writer - in *streams.In -} - -func (s stream) Out() *streams.Out { - return s.out -} - -func (s stream) Err() io.Writer { - return s.err -} - -func (s stream) In() *streams.In { - return s.in -} diff --git a/cmd/formatter/container.go b/cmd/formatter/container.go new file mode 100644 index 00000000..872635ad --- /dev/null +++ b/cmd/formatter/container.go @@ -0,0 +1,196 @@ +/* + 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 formatter + +import ( + "time" + + "github.com/docker/cli/cli/command/formatter" + "github.com/docker/compose/v2/pkg/api" + "github.com/docker/docker/api/types" + "github.com/docker/docker/pkg/stringid" + "github.com/docker/go-units" +) + +const ( + defaultContainerTableFormat = "table {{.Name}}\t{{.Image}}\t{{.Command}}\t{{.Service}}\t{{.RunningFor}}\t{{.Status}}\t{{.Ports}}" + + nameHeader = "NAME" + serviceHeader = "SERVICE" + commandHeader = "COMMAND" + runningForHeader = "CREATED" + mountsHeader = "MOUNTS" + localVolumes = "LOCAL VOLUMES" + networksHeader = "NETWORKS" +) + +// NewContainerFormat returns a Format for rendering using a Context +func NewContainerFormat(source string, quiet bool, size bool) formatter.Format { + switch source { + case formatter.TableFormatKey, "": // table formatting is the default if none is set. + if quiet { + return formatter.DefaultQuietFormat + } + format := defaultContainerTableFormat + if size { + format += `\t{{.Size}}` + } + return formatter.Format(format) + case formatter.RawFormatKey: + if quiet { + return `container_id: {{.ID}}` + } + format := `container_id: {{.ID}} +image: {{.Image}} +command: {{.Command}} +created_at: {{.CreatedAt}} +state: {{- pad .State 1 0}} +status: {{- pad .Status 1 0}} +names: {{.Names}} +labels: {{- pad .Labels 1 0}} +ports: {{- pad .Ports 1 0}} +` + if size { + format += `size: {{.Size}}\n` + } + return formatter.Format(format) + default: // custom format + if quiet { + return formatter.DefaultQuietFormat + } + return formatter.Format(source) + } +} + +// ContainerWrite renders the context for a list of containers +func ContainerWrite(ctx formatter.Context, containers []api.ContainerSummary) error { + render := func(format func(subContext formatter.SubContext) error) error { + for _, container := range containers { + err := format(&ContainerContext{trunc: ctx.Trunc, c: container}) + if err != nil { + return err + } + } + return nil + } + return ctx.Write(NewContainerContext(), render) +} + +// ContainerContext is a struct used for rendering a list of containers in a Go template. +type ContainerContext struct { + formatter.HeaderContext + trunc bool + c api.ContainerSummary + + // FieldsUsed is used in the pre-processing step to detect which fields are + // used in the template. It's currently only used to detect use of the .Size + // field which (if used) automatically sets the '--size' option when making + // the API call. + FieldsUsed map[string]interface{} +} + +// NewContainerContext creates a new context for rendering containers +func NewContainerContext() *ContainerContext { + containerCtx := ContainerContext{} + containerCtx.Header = formatter.SubHeaderContext{ + "ID": formatter.ContainerIDHeader, + "Name": nameHeader, + "Service": serviceHeader, + "Image": formatter.ImageHeader, + "Command": commandHeader, + "CreatedAt": formatter.CreatedAtHeader, + "RunningFor": runningForHeader, + "Ports": formatter.PortsHeader, + "State": formatter.StateHeader, + "Status": formatter.StatusHeader, + "Size": formatter.SizeHeader, + "Labels": formatter.LabelsHeader, + } + return &containerCtx +} + +// MarshalJSON makes ContainerContext implement json.Marshaler +func (c *ContainerContext) MarshalJSON() ([]byte, error) { + return formatter.MarshalJSON(c) +} + +// ID returns the container's ID as a string. Depending on the `--no-trunc` +// option being set, the full or truncated ID is returned. +func (c *ContainerContext) ID() string { + if c.trunc { + return stringid.TruncateID(c.c.ID) + } + return c.c.ID +} + +func (c *ContainerContext) Name() string { + return c.c.Name +} + +func (c *ContainerContext) Service() string { + return c.c.Service +} + +func (c *ContainerContext) Image() string { + return c.c.Image +} + +func (c *ContainerContext) Command() string { + return c.c.Command +} + +func (c *ContainerContext) CreatedAt() string { + return time.Unix(c.c.Created, 0).String() +} + +func (c *ContainerContext) RunningFor() string { + createdAt := time.Unix(c.c.Created, 0) + return units.HumanDuration(time.Now().UTC().Sub(createdAt)) + " ago" +} + +func (c *ContainerContext) ExitCode() int { + return c.c.ExitCode +} + +func (c *ContainerContext) State() string { + return c.c.State +} + +func (c *ContainerContext) Status() string { + return c.c.Status +} + +func (c *ContainerContext) Health() string { + return c.c.Health +} + +func (c *ContainerContext) Publishers() api.PortPublishers { + return c.c.Publishers +} + +func (c *ContainerContext) Ports() string { + var ports []types.Port + for _, publisher := range c.c.Publishers { + ports = append(ports, types.Port{ + IP: publisher.URL, + PrivatePort: uint16(publisher.TargetPort), + PublicPort: uint16(publisher.PublishedPort), + Type: publisher.Protocol, + }) + } + return formatter.DisplayablePorts(ports) +} diff --git a/docs/reference/compose_ps.md b/docs/reference/compose_ps.md index 4e97a8a8..15c7b1b6 100644 --- a/docs/reference/compose_ps.md +++ b/docs/reference/compose_ps.md @@ -5,15 +5,15 @@ List containers ### Options -| Name | Type | Default | Description | -|:----------------------|:--------------|:--------|:--------------------------------------------------------------------------------------------------------------| -| `-a`, `--all` | | | Show all stopped containers (including those created by the run command) | -| `--dry-run` | | | Execute command in dry run mode | -| [`--filter`](#filter) | `string` | | Filter services by a property (supported filters: status). | -| [`--format`](#format) | `string` | `table` | Format the output. Values: [table \| json] | -| `-q`, `--quiet` | | | Only display IDs | -| `--services` | | | Display services | -| [`--status`](#status) | `stringArray` | | Filter services by status. Values: [paused \| restarting \| removing \| running \| dead \| created \| exited] | +| Name | Type | Default | Description | +|:----------------------|:--------------|:--------|:-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `-a`, `--all` | | | Show all stopped containers (including those created by the run command) | +| `--dry-run` | | | Execute command in dry run mode | +| [`--filter`](#filter) | `string` | | Filter services by a property (supported filters: status). | +| [`--format`](#format) | `string` | `table` | 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/go/formatting/ for more information about formatting output with templates | +| `-q`, `--quiet` | | | Only display IDs | +| `--services` | | | Display services | +| [`--status`](#status) | `stringArray` | | Filter services by status. Values: [paused \| restarting \| removing \| running \| dead \| created \| exited] | diff --git a/docs/reference/docker_compose_ps.yaml b/docs/reference/docker_compose_ps.yaml index a8c76c3c..d51d0ac9 100644 --- a/docs/reference/docker_compose_ps.yaml +++ b/docs/reference/docker_compose_ps.yaml @@ -46,7 +46,13 @@ options: - option: format value_type: string default_value: table - description: 'Format the output. Values: [table | json]' + description: |- + 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/go/formatting/ for more information about formatting output with templates details_url: '#format' deprecated: false hidden: false diff --git a/pkg/api/api.go b/pkg/api/api.go index 41dda691..16e3b3a5 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -392,7 +392,7 @@ type PortPublisher struct { type ContainerSummary struct { ID string Name string - Image any + Image string Command string Project string Service string diff --git a/pkg/e2e/assert.go b/pkg/e2e/assert.go index 5cfcc1d5..accc9f59 100644 --- a/pkg/e2e/assert.go +++ b/pkg/e2e/assert.go @@ -29,18 +29,16 @@ import ( func RequireServiceState(t testing.TB, cli *CLI, service string, state string) { t.Helper() psRes := cli.RunDockerComposeCmd(t, "ps", "--format=json", service) - var psOut []map[string]interface{} - require.NoError(t, json.Unmarshal([]byte(psRes.Stdout()), &psOut), + var svc map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(psRes.Stdout()), &svc), "Invalid `compose ps` JSON output") - for _, svc := range psOut { - require.Equal(t, service, svc["Service"], - "Found ps output for unexpected service") - require.Equalf(t, - strings.ToLower(state), - strings.ToLower(svc["State"].(string)), - "Service %q (%s) not in expected state", - service, svc["Name"], - ) - } + require.Equal(t, service, svc["Service"], + "Found ps output for unexpected service") + require.Equalf(t, + strings.ToLower(state), + strings.ToLower(svc["State"].(string)), + "Service %q (%s) not in expected state", + service, svc["Name"], + ) } diff --git a/pkg/e2e/framework.go b/pkg/e2e/framework.go index cb3255d0..e9ded780 100644 --- a/pkg/e2e/framework.go +++ b/pkg/e2e/framework.go @@ -361,13 +361,14 @@ func IsHealthy(service string) func(res *icmd.Result) bool { Health string `json:"health"` } - ps := []state{} - err := json.Unmarshal([]byte(res.Stdout()), &ps) - if err != nil { - return false - } - for _, state := range ps { - if state.Name == service && state.Health == "healthy" { + decoder := json.NewDecoder(strings.NewReader(res.Stdout())) + for decoder.More() { + ps := state{} + err := decoder.Decode(&ps) + if err != nil { + return false + } + if ps.Name == service && ps.Health == "healthy" { return true } } diff --git a/pkg/e2e/pause_test.go b/pkg/e2e/pause_test.go index e5060ae3..c3699eb8 100644 --- a/pkg/e2e/pause_test.go +++ b/pkg/e2e/pause_test.go @@ -138,16 +138,14 @@ func urlForService(t testing.TB, cli *CLI, service string, targetPort int) strin func publishedPortForService(t testing.TB, cli *CLI, service string, targetPort int) int { t.Helper() res := cli.RunDockerComposeCmd(t, "ps", "--format=json", service) - var psOut []struct { + var svc struct { Publishers []struct { TargetPort int PublishedPort int } } - require.NoError(t, json.Unmarshal([]byte(res.Stdout()), &psOut), + require.NoError(t, json.Unmarshal([]byte(res.Stdout()), &svc), "Failed to parse `%s` output", res.Cmd.String()) - require.Len(t, psOut, 1, "Expected exactly 1 service") - svc := psOut[0] for _, pp := range svc.Publishers { if pp.TargetPort == targetPort { return pp.PublishedPort diff --git a/pkg/e2e/ps_test.go b/pkg/e2e/ps_test.go index 51e6b24f..ccaa3c2b 100644 --- a/pkg/e2e/ps_test.go +++ b/pkg/e2e/ps_test.go @@ -63,8 +63,12 @@ func TestPs(t *testing.T) { res = c.RunDockerComposeCmd(t, "-f", "./fixtures/ps-test/compose.yaml", "--project-name", projectName, "ps", "--format", "json") var output []api.ContainerSummary - err := json.Unmarshal([]byte(res.Stdout()), &output) - require.NoError(t, err, "Failed to unmarshal ps JSON output") + dec := json.NewDecoder(strings.NewReader(res.Stdout())) + for dec.More() { + var s api.ContainerSummary + require.NoError(t, dec.Decode(&s), "Failed to unmarshal ps JSON output") + output = append(output, s) + } count := 0 assert.Equal(t, 2, len(output))