align docker compose ps with docker CLI to support --format

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
This commit is contained in:
Nicolas De Loof 2023-08-22 09:26:09 +02:00 committed by Nicolas De loof
parent 19f66918cc
commit 1054792b47
11 changed files with 283 additions and 122 deletions

View File

@ -275,7 +275,7 @@ func RunningAsStandalone() bool {
} }
// RootCommand returns the compose command with its child commands // 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 // 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" // 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 // 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() return cmd.Help()
} }
if version { if version {
return versionCommand(streams).Execute() return versionCommand(dockerCli).Execute()
} }
_ = cmd.Help() _ = cmd.Help()
return dockercli.StatusError{ return dockercli.StatusError{
@ -345,11 +345,11 @@ func RootCommand(streams command.Cli, backend api.Service) *cobra.Command { //no
ansi = v ansi = v
} }
formatter.SetANSIMode(streams, ansi) formatter.SetANSIMode(dockerCli, ansi)
if noColor, ok := os.LookupEnv("NO_COLOR"); ok && noColor != "" { if noColor, ok := os.LookupEnv("NO_COLOR"); ok && noColor != "" {
ui.NoColor() ui.NoColor()
formatter.SetANSIMode(streams, formatter.Never) formatter.SetANSIMode(dockerCli, formatter.Never)
} }
switch ansi { switch ansi {
@ -426,26 +426,26 @@ func RootCommand(streams command.Cli, backend api.Service) *cobra.Command { //no
} }
c.AddCommand( c.AddCommand(
upCommand(&opts, streams, backend), upCommand(&opts, dockerCli, backend),
downCommand(&opts, backend), downCommand(&opts, backend),
startCommand(&opts, backend), startCommand(&opts, backend),
restartCommand(&opts, backend), restartCommand(&opts, backend),
stopCommand(&opts, backend), stopCommand(&opts, backend),
psCommand(&opts, streams, backend), psCommand(&opts, dockerCli, backend),
listCommand(streams, backend), listCommand(dockerCli, backend),
logsCommand(&opts, streams, backend), logsCommand(&opts, dockerCli, backend),
configCommand(&opts, streams, backend), configCommand(&opts, dockerCli, backend),
killCommand(&opts, backend), killCommand(&opts, backend),
runCommand(&opts, streams, backend), runCommand(&opts, dockerCli, backend),
removeCommand(&opts, backend), removeCommand(&opts, backend),
execCommand(&opts, streams, backend), execCommand(&opts, dockerCli, backend),
pauseCommand(&opts, backend), pauseCommand(&opts, backend),
unpauseCommand(&opts, backend), unpauseCommand(&opts, backend),
topCommand(&opts, streams, backend), topCommand(&opts, dockerCli, backend),
eventsCommand(&opts, streams, backend), eventsCommand(&opts, dockerCli, backend),
portCommand(&opts, streams, backend), portCommand(&opts, dockerCli, backend),
imagesCommand(&opts, streams, backend), imagesCommand(&opts, dockerCli, backend),
versionCommand(streams), versionCommand(dockerCli),
buildCommand(&opts, &progress, backend), buildCommand(&opts, &progress, backend),
pushCommand(&opts, backend), pushCommand(&opts, backend),
pullCommand(&opts, backend), pullCommand(&opts, backend),

View File

@ -19,22 +19,18 @@ package compose
import ( import (
"context" "context"
"fmt" "fmt"
"io"
"sort" "sort"
"strconv"
"strings" "strings"
"time"
"github.com/docker/compose/v2/cmd/formatter" "github.com/docker/compose/v2/cmd/formatter"
"github.com/docker/compose/v2/pkg/api"
"github.com/docker/compose/v2/pkg/utils" "github.com/docker/compose/v2/pkg/utils"
"github.com/docker/docker/api/types"
formatter2 "github.com/docker/cli/cli/command/formatter" "github.com/docker/cli/cli/command"
"github.com/docker/go-units" cliformatter "github.com/docker/cli/cli/command/formatter"
cliflags "github.com/docker/cli/cli/flags"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/docker/compose/v2/pkg/api"
) )
type psOptions struct { type psOptions struct {
@ -66,7 +62,7 @@ func (p *psOptions) parseFilter() error {
return nil 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{ opts := psOptions{
ProjectOptions: p, ProjectOptions: p,
} }
@ -77,12 +73,12 @@ func psCommand(p *ProjectOptions, streams api.Streams, backend api.Service) *cob
return opts.parseFilter() return opts.parseFilter()
}, },
RunE: Adapt(func(ctx context.Context, args []string) error { 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), ValidArgsFunction: completeServiceNames(p),
} }
flags := psCmd.Flags() 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.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.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") 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 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...) project, name, err := opts.projectOrName(services...)
if err != nil { if err != nil {
return err return err
@ -125,38 +121,32 @@ func runPs(ctx context.Context, streams api.Streams, backend api.Service, servic
if opts.Quiet { if opts.Quiet {
for _, c := range containers { for _, c := range containers {
fmt.Fprintln(streams.Out(), c.ID) fmt.Fprintln(dockerCli.Out(), c.ID)
} }
return nil return nil
} }
if opts.Services { if opts.Services {
services := []string{} services := []string{}
for _, s := range containers { for _, c := range containers {
if !utils.StringContains(services, s.Service) { s := c.Service
services = append(services, s.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 nil
} }
return formatter.Print(containers, opts.Format, streams.Out(), if opts.Format == "" {
writer(containers), opts.Format = dockerCli.ConfigFile().PsFormat
"NAME", "IMAGE", "COMMAND", "SERVICE", "CREATED", "STATUS", "PORTS")
} }
func writer(containers []api.ContainerSummary) func(w io.Writer) { containerCtx := cliformatter.Context{
return func(w io.Writer) { Output: dockerCli.Out(),
for _, container := range containers { Format: formatter.NewContainerFormat(opts.Format, opts.Quiet, false),
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)
}
} }
return formatter.ContainerWrite(containerCtx, containers)
} }
func filterByStatus(containers []api.ContainerSummary, statuses []string) []api.ContainerSummary { func filterByStatus(containers []api.ContainerSummary, statuses []string) []api.ContainerSummary {
@ -177,21 +167,3 @@ func hasStatus(c api.ContainerSummary, statuses []string) bool {
} }
return false 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)
}

View File

@ -18,11 +18,11 @@ package compose
import ( import (
"context" "context"
"io"
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
"github.com/docker/cli/cli/config/configfile"
"github.com/docker/cli/cli/streams" "github.com/docker/cli/cli/streams"
"github.com/docker/compose/v2/pkg/api" "github.com/docker/compose/v2/pkg/api"
"github.com/docker/compose/v2/pkg/mocks" "github.com/docker/compose/v2/pkg/mocks"
@ -69,7 +69,11 @@ func TestPsTable(t *testing.T) {
}).AnyTimes() }).AnyTimes()
opts := psOptions{ProjectOptions: &ProjectOptions{ProjectName: "test"}} 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) assert.NoError(t, err)
_, err = f.Seek(0, 0) _, err = f.Seek(0, 0)
@ -80,21 +84,3 @@ func TestPsTable(t *testing.T) {
assert.Contains(t, string(output), "8080/tcp, 8443/tcp") 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
}

196
cmd/formatter/container.go Normal file
View File

@ -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)
}

View File

@ -6,11 +6,11 @@ List containers
### Options ### Options
| Name | Type | Default | Description | | Name | Type | Default | Description |
|:----------------------|:--------------|:--------|:--------------------------------------------------------------------------------------------------------------| |:----------------------|:--------------|:--------|:-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `-a`, `--all` | | | Show all stopped containers (including those created by the run command) | | `-a`, `--all` | | | Show all stopped containers (including those created by the run command) |
| `--dry-run` | | | Execute command in dry run mode | | `--dry-run` | | | Execute command in dry run mode |
| [`--filter`](#filter) | `string` | | Filter services by a property (supported filters: status). | | [`--filter`](#filter) | `string` | | Filter services by a property (supported filters: status). |
| [`--format`](#format) | `string` | `table` | Format the output. Values: [table \| json] | | [`--format`](#format) | `string` | `table` | Format output using a custom template:<br>'table': Print output in table format with column headers (default)<br>'table TEMPLATE': Print output in table format using the given Go template<br>'json': Print in JSON format<br>'TEMPLATE': Print output using the given Go template.<br>Refer to https://docs.docker.com/go/formatting/ for more information about formatting output with templates |
| `-q`, `--quiet` | | | Only display IDs | | `-q`, `--quiet` | | | Only display IDs |
| `--services` | | | Display services | | `--services` | | | Display services |
| [`--status`](#status) | `stringArray` | | Filter services by status. Values: [paused \| restarting \| removing \| running \| dead \| created \| exited] | | [`--status`](#status) | `stringArray` | | Filter services by status. Values: [paused \| restarting \| removing \| running \| dead \| created \| exited] |

View File

@ -46,7 +46,13 @@ options:
- option: format - option: format
value_type: string value_type: string
default_value: table 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' details_url: '#format'
deprecated: false deprecated: false
hidden: false hidden: false

View File

@ -392,7 +392,7 @@ type PortPublisher struct {
type ContainerSummary struct { type ContainerSummary struct {
ID string ID string
Name string Name string
Image any Image string
Command string Command string
Project string Project string
Service string Service string

View File

@ -29,11 +29,10 @@ import (
func RequireServiceState(t testing.TB, cli *CLI, service string, state string) { func RequireServiceState(t testing.TB, cli *CLI, service string, state string) {
t.Helper() t.Helper()
psRes := cli.RunDockerComposeCmd(t, "ps", "--format=json", service) psRes := cli.RunDockerComposeCmd(t, "ps", "--format=json", service)
var psOut []map[string]interface{} var svc map[string]interface{}
require.NoError(t, json.Unmarshal([]byte(psRes.Stdout()), &psOut), require.NoError(t, json.Unmarshal([]byte(psRes.Stdout()), &svc),
"Invalid `compose ps` JSON output") "Invalid `compose ps` JSON output")
for _, svc := range psOut {
require.Equal(t, service, svc["Service"], require.Equal(t, service, svc["Service"],
"Found ps output for unexpected service") "Found ps output for unexpected service")
require.Equalf(t, require.Equalf(t,
@ -43,4 +42,3 @@ func RequireServiceState(t testing.TB, cli *CLI, service string, state string) {
service, svc["Name"], service, svc["Name"],
) )
} }
}

View File

@ -361,13 +361,14 @@ func IsHealthy(service string) func(res *icmd.Result) bool {
Health string `json:"health"` Health string `json:"health"`
} }
ps := []state{} decoder := json.NewDecoder(strings.NewReader(res.Stdout()))
err := json.Unmarshal([]byte(res.Stdout()), &ps) for decoder.More() {
ps := state{}
err := decoder.Decode(&ps)
if err != nil { if err != nil {
return false return false
} }
for _, state := range ps { if ps.Name == service && ps.Health == "healthy" {
if state.Name == service && state.Health == "healthy" {
return true return true
} }
} }

View File

@ -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 { func publishedPortForService(t testing.TB, cli *CLI, service string, targetPort int) int {
t.Helper() t.Helper()
res := cli.RunDockerComposeCmd(t, "ps", "--format=json", service) res := cli.RunDockerComposeCmd(t, "ps", "--format=json", service)
var psOut []struct { var svc struct {
Publishers []struct { Publishers []struct {
TargetPort int TargetPort int
PublishedPort 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()) "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 { for _, pp := range svc.Publishers {
if pp.TargetPort == targetPort { if pp.TargetPort == targetPort {
return pp.PublishedPort return pp.PublishedPort

View File

@ -63,8 +63,12 @@ func TestPs(t *testing.T) {
res = c.RunDockerComposeCmd(t, "-f", "./fixtures/ps-test/compose.yaml", "--project-name", projectName, "ps", res = c.RunDockerComposeCmd(t, "-f", "./fixtures/ps-test/compose.yaml", "--project-name", projectName, "ps",
"--format", "json") "--format", "json")
var output []api.ContainerSummary var output []api.ContainerSummary
err := json.Unmarshal([]byte(res.Stdout()), &output) dec := json.NewDecoder(strings.NewReader(res.Stdout()))
require.NoError(t, err, "Failed to unmarshal ps JSON output") 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 count := 0
assert.Equal(t, 2, len(output)) assert.Equal(t, 2, len(output))