align --format flag and UX with docker cli

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
This commit is contained in:
Nicolas De Loof 2022-12-11 10:59:01 +01:00 committed by Nicolas De loof
parent bc568eeb9b
commit 8c39b5b7fd
19 changed files with 83 additions and 47 deletions

View File

@ -36,6 +36,7 @@ import (
type imageOptions struct { type imageOptions struct {
*projectOptions *projectOptions
Quiet bool Quiet bool
Format string
} }
func imagesCommand(p *projectOptions, backend api.Service) *cobra.Command { func imagesCommand(p *projectOptions, backend api.Service) *cobra.Command {
@ -50,6 +51,7 @@ func imagesCommand(p *projectOptions, backend api.Service) *cobra.Command {
}), }),
ValidArgsFunction: completeServiceNames(p), ValidArgsFunction: completeServiceNames(p),
} }
imgCmd.Flags().StringVar(&opts.Format, "format", "table", "Format the output. Values: [table | json].")
imgCmd.Flags().BoolVarP(&opts.Quiet, "quiet", "q", false, "Only display IDs") imgCmd.Flags().BoolVarP(&opts.Quiet, "quiet", "q", false, "Only display IDs")
return imgCmd return imgCmd
} }
@ -88,7 +90,7 @@ func runImages(ctx context.Context, backend api.Service, opts imageOptions, serv
return images[i].ContainerName < images[j].ContainerName return images[i].ContainerName < images[j].ContainerName
}) })
return formatter.Print(images, formatter.PRETTY, os.Stdout, return formatter.Print(images, opts.Format, os.Stdout,
func(w io.Writer) { func(w io.Writer) {
for _, img := range images { for _, img := range images {
id := stringid.TruncateID(img.ID) id := stringid.TruncateID(img.ID)
@ -104,5 +106,5 @@ func runImages(ctx context.Context, backend api.Service, opts imageOptions, serv
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", img.ContainerName, repo, tag, id, size) _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", img.ContainerName, repo, tag, id, size)
} }
}, },
"Container", "Repository", "Tag", "Image Id", "Size") "CONTAINER", "REPOSITORY", "TAG", "IMAGE ID", "SIZE")
} }

View File

@ -49,7 +49,7 @@ func listCommand(backend api.Service) *cobra.Command {
Args: cobra.NoArgs, Args: cobra.NoArgs,
ValidArgsFunction: noCompletion(), ValidArgsFunction: noCompletion(),
} }
lsCmd.Flags().StringVar(&lsOpts.Format, "format", "pretty", "Format the output. Values: [pretty | json].") lsCmd.Flags().StringVar(&lsOpts.Format, "format", "table", "Format the output. Values: [table | json].")
lsCmd.Flags().BoolVarP(&lsOpts.Quiet, "quiet", "q", false, "Only display IDs.") lsCmd.Flags().BoolVarP(&lsOpts.Quiet, "quiet", "q", false, "Only display IDs.")
lsCmd.Flags().Var(&lsOpts.Filter, "filter", "Filter output based on conditions provided.") lsCmd.Flags().Var(&lsOpts.Filter, "filter", "Filter output based on conditions provided.")
lsCmd.Flags().BoolVarP(&lsOpts.All, "all", "a", false, "Show all stopped Compose projects") lsCmd.Flags().BoolVarP(&lsOpts.All, "all", "a", false, "Show all stopped Compose projects")

View File

@ -83,7 +83,7 @@ func psCommand(p *projectOptions, backend api.Service) *cobra.Command {
ValidArgsFunction: completeServiceNames(p), ValidArgsFunction: completeServiceNames(p),
} }
flags := psCmd.Flags() flags := psCmd.Flags()
flags.StringVar(&opts.Format, "format", "pretty", "Format the output. Values: [pretty | json]") flags.StringVar(&opts.Format, "format", "table", "Format the output. Values: [table | json]")
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")

View File

@ -28,14 +28,15 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
func TestPsPretty(t *testing.T) { func TestPsTable(t *testing.T) {
ctx := context.Background() ctx := context.Background()
origStdout := os.Stdout origStdout := os.Stdout
t.Cleanup(func() { t.Cleanup(func() {
os.Stdout = origStdout os.Stdout = origStdout
}) })
dir := t.TempDir() dir := t.TempDir()
f, err := os.Create(filepath.Join(dir, "output.txt")) out := filepath.Join(dir, "output.txt")
f, err := os.Create(out)
if err != nil { if err != nil {
t.Fatal("could not create output file") t.Fatal("could not create output file")
} }
@ -53,6 +54,7 @@ func TestPsPretty(t *testing.T) {
{ {
ID: "abc123", ID: "abc123",
Name: "ABC", Name: "ABC",
Image: "foo/bar",
Publishers: api.PortPublishers{ Publishers: api.PortPublishers{
{ {
TargetPort: 8080, TargetPort: 8080,
@ -76,8 +78,7 @@ func TestPsPretty(t *testing.T) {
_, err = f.Seek(0, 0) _, err = f.Seek(0, 0)
assert.NoError(t, err) assert.NoError(t, err)
output := make([]byte, 256) output, err := os.ReadFile(out)
_, err = f.Read(output)
assert.NoError(t, err) assert.NoError(t, err)
assert.Contains(t, string(output), "8080/tcp, 8443/tcp") assert.Contains(t, string(output), "8080/tcp, 8443/tcp")

View File

@ -17,10 +17,13 @@
package formatter package formatter
const ( const (
// JSON is the constant for Json formats on list commands // JSON Print in JSON format
JSON = "json" JSON = "json"
// TemplateLegacyJSON the legacy json formatting value using go template // TemplateLegacyJSON the legacy json formatting value using go template
TemplateLegacyJSON = "{{json.}}" TemplateLegacyJSON = "{{json.}}"
// PRETTY is the constant for default formats on list commands // PRETTY is the constant for default formats on list commands
// Deprecated: use TABLE
PRETTY = "pretty" PRETTY = "pretty"
// TABLE Print output in table format with column headers (default)
TABLE = "table"
) )

View File

@ -30,7 +30,7 @@ import (
// Print prints formatted lists in different formats // Print prints formatted lists in different formats
func Print(toJSON interface{}, format string, outWriter io.Writer, writerFn func(w io.Writer), headers ...string) error { func Print(toJSON interface{}, format string, outWriter io.Writer, writerFn func(w io.Writer), headers ...string) error {
switch strings.ToLower(format) { switch strings.ToLower(format) {
case PRETTY, "": case TABLE, PRETTY, "":
return PrintPrettySection(outWriter, writerFn, headers...) return PrintPrettySection(outWriter, writerFn, headers...)
case TemplateLegacyJSON: case TemplateLegacyJSON:
switch reflect.TypeOf(toJSON).Kind() { switch reflect.TypeOf(toJSON).Kind() {

View File

@ -7,6 +7,7 @@ List images used by the created containers
| Name | Type | Default | Description | | Name | Type | Default | Description |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `--format` | `string` | `table` | Format the output. Values: [table \| json]. |
| `-q`, `--quiet` | | | Only display IDs | | `-q`, `--quiet` | | | Only display IDs |

View File

@ -9,7 +9,7 @@ List running compose projects
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `-a`, `--all` | | | Show all stopped Compose projects | | `-a`, `--all` | | | Show all stopped Compose projects |
| `--filter` | `filter` | | Filter output based on conditions provided. | | `--filter` | `filter` | | Filter output based on conditions provided. |
| `--format` | `string` | `pretty` | Format the output. Values: [pretty \| json]. | | `--format` | `string` | `table` | Format the output. Values: [table \| json]. |
| `-q`, `--quiet` | | | Only display IDs. | | `-q`, `--quiet` | | | Only display IDs. |

View File

@ -9,7 +9,7 @@ List containers
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `-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) |
| [`--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` | `pretty` | Format the output. Values: [pretty \| json] | | [`--format`](#format) | `string` | `table` | Format the output. Values: [table \| json] |
| `-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

@ -5,6 +5,16 @@ usage: docker compose images [OPTIONS] [SERVICE...]
pname: docker compose pname: docker compose
plink: docker_compose.yaml plink: docker_compose.yaml
options: options:
- option: format
value_type: string
default_value: table
description: 'Format the output. Values: [table | json].'
deprecated: false
hidden: false
experimental: false
experimentalcli: false
kubernetes: false
swarm: false
- option: quiet - option: quiet
shorthand: q shorthand: q
value_type: bool value_type: bool

View File

@ -27,8 +27,8 @@ options:
swarm: false swarm: false
- option: format - option: format
value_type: string value_type: string
default_value: pretty default_value: table
description: 'Format the output. Values: [pretty | json].' description: 'Format the output. Values: [table | json].'
deprecated: false deprecated: false
hidden: false hidden: false
experimental: false experimental: false

View File

@ -38,8 +38,8 @@ options:
swarm: false swarm: false
- option: format - option: format
value_type: string value_type: string
default_value: pretty default_value: table
description: 'Format the output. Values: [pretty | json]' description: 'Format the output. Values: [table | json]'
details_url: '#format' details_url: '#format'
deprecated: false deprecated: false
hidden: false hidden: false

View File

@ -61,10 +61,13 @@ func TestPs(t *testing.T) {
containers, err := tested.Ps(ctx, strings.ToLower(testProject), compose.PsOptions{}) containers, err := tested.Ps(ctx, strings.ToLower(testProject), compose.PsOptions{})
expected := []compose.ContainerSummary{ expected := []compose.ContainerSummary{
{ID: "123", Name: "123", Project: strings.ToLower(testProject), Service: "service1", State: "running", Health: "healthy", Publishers: nil}, {ID: "123", Name: "123", Image: "foo", Project: strings.ToLower(testProject), Service: "service1",
{ID: "456", Name: "456", Project: strings.ToLower(testProject), Service: "service1", State: "running", Health: "", Publishers: []compose.PortPublisher{{URL: "localhost", TargetPort: 90, State: "running", Health: "healthy", Publishers: nil},
PublishedPort: 80}}}, {ID: "456", Name: "456", Image: "foo", Project: strings.ToLower(testProject), Service: "service1",
{ID: "789", Name: "789", Project: strings.ToLower(testProject), Service: "service2", State: "exited", Health: "", ExitCode: 130, Publishers: nil}, State: "running", Health: "",
Publishers: []compose.PortPublisher{{URL: "localhost", TargetPort: 90, PublishedPort: 80}}},
{ID: "789", Name: "789", Image: "foo", Project: strings.ToLower(testProject), Service: "service2",
State: "exited", Health: "", ExitCode: 130, Publishers: nil},
} }
assert.NilError(t, err) assert.NilError(t, err)
assert.DeepEqual(t, containers, expected) assert.DeepEqual(t, containers, expected)
@ -74,6 +77,7 @@ func containerDetails(service string, id string, status string, health string, e
container := moby.Container{ container := moby.Container{
ID: id, ID: id,
Names: []string{"/" + id}, Names: []string{"/" + id},
Image: "foo",
Labels: containerLabels(service, false), Labels: containerLabels(service, false),
State: status, State: status,
} }

View File

@ -88,13 +88,11 @@ func TestLocalComposeUp(t *testing.T) {
t.Run("check healthcheck output", func(t *testing.T) { t.Run("check healthcheck output", func(t *testing.T) {
c.WaitForCmdResult(t, c.NewDockerComposeCmd(t, "-p", projectName, "ps", "--format", "json"), c.WaitForCmdResult(t, c.NewDockerComposeCmd(t, "-p", projectName, "ps", "--format", "json"),
StdoutContains(`"Name":"compose-e2e-demo-web-1","Command":"/dispatcher","Project":"compose-e2e-demo","Service":"web","State":"running","Health":"healthy"`), IsHealthy(projectName+"-web-1"),
5*time.Second, 1*time.Second) 5*time.Second, 1*time.Second)
res := c.RunDockerComposeCmd(t, "-p", projectName, "ps") res := c.RunDockerComposeCmd(t, "-p", projectName, "ps")
res.Assert(t, icmd.Expected{Out: `NAME COMMAND SERVICE STATUS PORTS`}) assertServiceStatus(t, projectName, "web", "(healthy)", res.Stdout())
res.Assert(t, icmd.Expected{Out: `compose-e2e-demo-web-1 "/dispatcher" web running (healthy) 0.0.0.0:90->80/tcp`})
res.Assert(t, icmd.Expected{Out: `compose-e2e-demo-db-1 "docker-entrypoint.s…" db running 5432/tcp`})
}) })
t.Run("images", func(t *testing.T) { t.Run("images", func(t *testing.T) {

View File

@ -45,7 +45,7 @@ func TestCopy(t *testing.T) {
t.Run("make sure service is running", func(t *testing.T) { t.Run("make sure service is running", func(t *testing.T) {
res := c.RunDockerComposeCmd(t, "-p", projectName, "ps") res := c.RunDockerComposeCmd(t, "-p", projectName, "ps")
res.Assert(t, icmd.Expected{Out: `nginx running`}) assertServiceStatus(t, projectName, "nginx", "Up", res.Stdout())
}) })
t.Run("copy to container copies the file to the all containers by default", func(t *testing.T) { t.Run("copy to container copies the file to the all containers by default", func(t *testing.T) {

View File

@ -17,6 +17,7 @@
package e2e package e2e
import ( import (
"encoding/json"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@ -314,6 +315,27 @@ func StdoutContains(expected string) func(*icmd.Result) bool {
} }
} }
func IsHealthy(service string) func(res *icmd.Result) bool {
return func(res *icmd.Result) bool {
type state struct {
Name string `json:"name"`
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" {
return true
}
}
return false
}
}
// WaitForCmdResult try to execute a cmd until resulting output matches given predicate // WaitForCmdResult try to execute a cmd until resulting output matches given predicate
func (c *CLI) WaitForCmdResult( func (c *CLI) WaitForCmdResult(
t testing.TB, t testing.TB,

View File

@ -40,7 +40,7 @@ func TestPs(t *testing.T) {
assert.Contains(t, res.Combined(), "Container e2e-ps-busybox-1 Started", res.Combined()) assert.Contains(t, res.Combined(), "Container e2e-ps-busybox-1 Started", res.Combined())
t.Run("pretty", func(t *testing.T) { t.Run("table", func(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")
lines := strings.Split(res.Stdout(), "\n") lines := strings.Split(res.Stdout(), "\n")
assert.Equal(t, 4, len(lines)) assert.Equal(t, 4, len(lines))

View File

@ -26,16 +26,17 @@ import (
"gotest.tools/v3/assert" "gotest.tools/v3/assert"
) )
func assertServiceStatus(t *testing.T, projectName, service, status string, ps string) {
// match output with random spaces like:
// e2e-start-stop-db-1 alpine:latest "echo hello" db 1 minutes ago Exited (0) 1 minutes ago
regx := fmt.Sprintf("%s-%s-1.+%s\\s+.+%s.+", projectName, service, service, status)
testify.Regexp(t, regx, ps)
}
func TestRestart(t *testing.T) { func TestRestart(t *testing.T) {
c := NewParallelCLI(t) c := NewParallelCLI(t)
const projectName = "e2e-restart" const projectName = "e2e-restart"
getServiceRegx := func(service string, status string) string {
// match output with random spaces like:
// e2e-start-stop-db-1 "echo hello" db running
return fmt.Sprintf("%s-%s-1.+%s\\s+%s", projectName, service, service, status)
}
t.Run("Up a project", func(t *testing.T) { t.Run("Up a project", func(t *testing.T) {
// This is just to ensure the containers do NOT exist // This is just to ensure the containers do NOT exist
c.RunDockerComposeCmd(t, "--project-name", projectName, "down") c.RunDockerComposeCmd(t, "--project-name", projectName, "down")
@ -48,7 +49,7 @@ func TestRestart(t *testing.T) {
StdoutContains(`"State":"exited"`), 10*time.Second, 1*time.Second) StdoutContains(`"State":"exited"`), 10*time.Second, 1*time.Second)
res = c.RunDockerComposeCmd(t, "--project-name", projectName, "ps", "-a") res = c.RunDockerComposeCmd(t, "--project-name", projectName, "ps", "-a")
testify.Regexp(t, getServiceRegx("restart", "exited"), res.Stdout()) assertServiceStatus(t, projectName, "restart", "Exited", res.Stdout())
c.RunDockerComposeCmd(t, "-f", "./fixtures/restart-test/compose.yaml", "--project-name", projectName, "restart") c.RunDockerComposeCmd(t, "-f", "./fixtures/restart-test/compose.yaml", "--project-name", projectName, "restart")
@ -56,7 +57,7 @@ func TestRestart(t *testing.T) {
time.Sleep(time.Second) time.Sleep(time.Second)
res = c.RunDockerComposeCmd(t, "--project-name", projectName, "ps") res = c.RunDockerComposeCmd(t, "--project-name", projectName, "ps")
testify.Regexp(t, getServiceRegx("restart", "running"), res.Stdout()) assertServiceStatus(t, projectName, "restart", "Up", res.Stdout())
// Clean up // Clean up
c.RunDockerComposeCmd(t, "--project-name", projectName, "down") c.RunDockerComposeCmd(t, "--project-name", projectName, "down")

View File

@ -36,12 +36,6 @@ func TestStartStop(t *testing.T) {
return fmt.Sprintf("%s\\s+%s\\(%d\\)", projectName, status, 2) return fmt.Sprintf("%s\\s+%s\\(%d\\)", projectName, status, 2)
} }
getServiceRegx := func(service string, status string) string {
// match output with random spaces like:
// e2e-start-stop-db-1 "echo hello" db running
return fmt.Sprintf("%s-%s-1.+%s\\s+%s", projectName, service, service, status)
}
t.Run("Up a project", func(t *testing.T) { t.Run("Up a project", func(t *testing.T) {
res := c.RunDockerComposeCmd(t, "-f", "./fixtures/start-stop/compose.yaml", "--project-name", projectName, "up", res := c.RunDockerComposeCmd(t, "-f", "./fixtures/start-stop/compose.yaml", "--project-name", projectName, "up",
"-d") "-d")
@ -51,8 +45,8 @@ func TestStartStop(t *testing.T) {
testify.Regexp(t, getProjectRegx("running"), res.Stdout()) testify.Regexp(t, getProjectRegx("running"), res.Stdout())
res = c.RunDockerComposeCmd(t, "--project-name", projectName, "ps") res = c.RunDockerComposeCmd(t, "--project-name", projectName, "ps")
testify.Regexp(t, getServiceRegx("simple", "running"), res.Stdout()) assertServiceStatus(t, projectName, "simple", "Up", res.Stdout())
testify.Regexp(t, getServiceRegx("another", "running"), res.Stdout()) assertServiceStatus(t, projectName, "another", "Up", res.Stdout())
}) })
t.Run("stop project", func(t *testing.T) { t.Run("stop project", func(t *testing.T) {
@ -68,8 +62,8 @@ func TestStartStop(t *testing.T) {
assert.Assert(t, !strings.Contains(res.Combined(), "e2e-start-stop-no-dependencies-words-1"), res.Combined()) assert.Assert(t, !strings.Contains(res.Combined(), "e2e-start-stop-no-dependencies-words-1"), res.Combined())
res = c.RunDockerComposeCmd(t, "--project-name", projectName, "ps", "--all") res = c.RunDockerComposeCmd(t, "--project-name", projectName, "ps", "--all")
testify.Regexp(t, getServiceRegx("simple", "exited"), res.Stdout()) assertServiceStatus(t, projectName, "simple", "Exited", res.Stdout())
testify.Regexp(t, getServiceRegx("another", "exited"), res.Stdout()) assertServiceStatus(t, projectName, "another", "Exited", res.Stdout())
}) })
t.Run("start project", func(t *testing.T) { t.Run("start project", func(t *testing.T) {