diff --git a/cli/command/formatter/stack.go b/cli/command/formatter/stack.go new file mode 100644 index 0000000000..676bcc05fe --- /dev/null +++ b/cli/command/formatter/stack.go @@ -0,0 +1,67 @@ +package formatter + +import ( + "strconv" +) + +const ( + defaultStackTableFormat = "table {{.Name}}\t{{.Services}}" + + stackServicesHeader = "SERVICES" +) + +// Stack contains deployed stack information. +type Stack struct { + // Name is the name of the stack + Name string + // Services is the number of the services + Services int +} + +// NewStackFormat returns a format for use with a stack Context +func NewStackFormat(source string) Format { + switch source { + case TableFormatKey: + return defaultStackTableFormat + } + return Format(source) +} + +// StackWrite writes formatted stacks using the Context +func StackWrite(ctx Context, stacks []*Stack) error { + render := func(format func(subContext subContext) error) error { + for _, stack := range stacks { + if err := format(&stackContext{s: stack}); err != nil { + return err + } + } + return nil + } + return ctx.Write(newStackContext(), render) +} + +type stackContext struct { + HeaderContext + s *Stack +} + +func newStackContext() *stackContext { + stackCtx := stackContext{} + stackCtx.header = map[string]string{ + "Name": nameHeader, + "Services": stackServicesHeader, + } + return &stackCtx +} + +func (s *stackContext) MarshalJSON() ([]byte, error) { + return marshalJSON(s) +} + +func (s *stackContext) Name() string { + return s.s.Name +} + +func (s *stackContext) Services() string { + return strconv.Itoa(s.s.Services) +} diff --git a/cli/command/formatter/stack_test.go b/cli/command/formatter/stack_test.go new file mode 100644 index 0000000000..b18ae7f083 --- /dev/null +++ b/cli/command/formatter/stack_test.go @@ -0,0 +1,64 @@ +package formatter + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestStackContextWrite(t *testing.T) { + cases := []struct { + context Context + expected string + }{ + // Errors + { + Context{Format: "{{InvalidFunction}}"}, + `Template parsing error: template: :1: function "InvalidFunction" not defined +`, + }, + { + Context{Format: "{{nil}}"}, + `Template parsing error: template: :1:2: executing "" at : nil is not a command +`, + }, + // Table format + { + Context{Format: NewStackFormat("table")}, + `NAME SERVICES +baz 2 +bar 1 +`, + }, + { + Context{Format: NewStackFormat("table {{.Name}}")}, + `NAME +baz +bar +`, + }, + // Custom Format + { + Context{Format: NewStackFormat("{{.Name}}")}, + `baz +bar +`, + }, + } + + stacks := []*Stack{ + {Name: "baz", Services: 2}, + {Name: "bar", Services: 1}, + } + for _, testcase := range cases { + out := bytes.NewBufferString("") + testcase.context.Output = out + err := StackWrite(testcase.context, stacks) + if err != nil { + assert.Error(t, err, testcase.expected) + } else { + assert.Equal(t, out.String(), testcase.expected) + } + } +} diff --git a/cli/command/stack/list.go b/cli/command/stack/list.go index f27d5009ed..61f1e6b439 100644 --- a/cli/command/stack/list.go +++ b/cli/command/stack/list.go @@ -1,15 +1,12 @@ package stack import ( - "fmt" - "io" "sort" - "strconv" - "text/tabwriter" "github.com/docker/docker/api/types" "github.com/docker/docker/cli" "github.com/docker/docker/cli/command" + "github.com/docker/docker/cli/command/formatter" "github.com/docker/docker/cli/compose/convert" "github.com/docker/docker/client" "github.com/pkg/errors" @@ -17,11 +14,8 @@ import ( "golang.org/x/net/context" ) -const ( - listItemFmt = "%s\t%s\n" -) - type listOptions struct { + format string } func newListCommand(dockerCli *command.DockerCli) *cobra.Command { @@ -37,6 +31,8 @@ func newListCommand(dockerCli *command.DockerCli) *cobra.Command { }, } + flags := cmd.Flags() + flags.StringVar(&opts.format, "format", "", "Pretty-print stacks using a Go template") return cmd } @@ -48,55 +44,32 @@ func runList(dockerCli *command.DockerCli, opts listOptions) error { if err != nil { return err } - - out := dockerCli.Out() - printTable(out, stacks) - return nil + format := opts.format + if len(format) == 0 { + format = formatter.TableFormatKey + } + stackCtx := formatter.Context{ + Output: dockerCli.Out(), + Format: formatter.NewStackFormat(format), + } + sort.Sort(byName(stacks)) + return formatter.StackWrite(stackCtx, stacks) } -type byName []*stack +type byName []*formatter.Stack func (n byName) Len() int { return len(n) } func (n byName) Swap(i, j int) { n[i], n[j] = n[j], n[i] } func (n byName) Less(i, j int) bool { return n[i].Name < n[j].Name } -func printTable(out io.Writer, stacks []*stack) { - writer := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0) - - // Ignore flushing errors - defer writer.Flush() - - sort.Sort(byName(stacks)) - - fmt.Fprintf(writer, listItemFmt, "NAME", "SERVICES") - for _, stack := range stacks { - fmt.Fprintf( - writer, - listItemFmt, - stack.Name, - strconv.Itoa(stack.Services), - ) - } -} - -type stack struct { - // Name is the name of the stack - Name string - // Services is the number of the services - Services int -} - -func getStacks( - ctx context.Context, - apiclient client.APIClient, -) ([]*stack, error) { +func getStacks(ctx context.Context, apiclient client.APIClient) ([]*formatter.Stack, error) { services, err := apiclient.ServiceList( ctx, types.ServiceListOptions{Filters: getAllStacksFilter()}) if err != nil { return nil, err } - m := make(map[string]*stack, 0) + m := make(map[string]*formatter.Stack, 0) for _, service := range services { labels := service.Spec.Labels name, ok := labels[convert.LabelNamespace] @@ -106,7 +79,7 @@ func getStacks( } ztack, ok := m[name] if !ok { - m[name] = &stack{ + m[name] = &formatter.Stack{ Name: name, Services: 1, } @@ -114,7 +87,7 @@ func getStacks( ztack.Services++ } } - var stacks []*stack + var stacks []*formatter.Stack for _, stack := range m { stacks = append(stacks, stack) } diff --git a/docs/reference/commandline/stack_ls.md b/docs/reference/commandline/stack_ls.md index 567d947bab..91349b6947 100644 --- a/docs/reference/commandline/stack_ls.md +++ b/docs/reference/commandline/stack_ls.md @@ -24,7 +24,8 @@ Aliases: ls, list Options: - --help Print usage + --help Print usage + --format string Pretty-print stacks using a Go template ``` ## Description @@ -43,6 +44,30 @@ vossibility-stack 6 myapp 2 ``` +### Formatting + +The formatting option (`--format`) pretty-prints stacks using a Go template. + +Valid placeholders for the Go template are listed below: + +| Placeholder | Description | +| ----------- | ------------------ | +| `.Name` | Stack name | +| `.Services` | Number of services | + +When using the `--format` option, the `stack ls` command either outputs +the data exactly as the template declares or, when using the +`table` directive, includes column headers as well. + +The following example uses a template without headers and outputs the +`Name` and `Services` entries separated by a colon for all stacks: + +```bash +$ docker stack ls --format "{{.Name}}: {{.Services}}" +web-server: 1 +web-cache: 4 +``` + ## Related commands * [stack deploy](stack_deploy.md) diff --git a/integration-cli/docker_cli_stack_test.go b/integration-cli/docker_cli_stack_test.go index d754a2c77a..91fe4d75c0 100644 --- a/integration-cli/docker_cli_stack_test.go +++ b/integration-cli/docker_cli_stack_test.go @@ -15,6 +15,17 @@ import ( "github.com/go-check/check" ) +var cleanSpaces = func(s string) string { + lines := strings.Split(s, "\n") + for i, line := range lines { + spaceIx := strings.Index(line, " ") + if spaceIx > 0 { + lines[i] = line[:spaceIx+1] + strings.TrimLeft(line[spaceIx:], " ") + } + } + return strings.Join(lines, "\n") +} + func (s *DockerSwarmSuite) TestStackRemoveUnknown(c *check.C) { d := s.AddDaemon(c, true, true) @@ -59,13 +70,13 @@ func (s *DockerSwarmSuite) TestStackDeployComposeFile(c *check.C) { out, err = d.Cmd("stack", "ls") c.Assert(err, checker.IsNil) - c.Assert(out, check.Equals, "NAME SERVICES\n"+"testdeploy 2\n") + c.Assert(cleanSpaces(out), check.Equals, "NAME SERVICES\n"+"testdeploy 2\n") out, err = d.Cmd("stack", "rm", testStackName) c.Assert(err, checker.IsNil) out, err = d.Cmd("stack", "ls") c.Assert(err, checker.IsNil) - c.Assert(out, check.Equals, "NAME SERVICES\n") + c.Assert(cleanSpaces(out), check.Equals, "NAME SERVICES\n") } func (s *DockerSwarmSuite) TestStackDeployWithSecretsTwice(c *check.C) { @@ -180,7 +191,7 @@ func (s *DockerSwarmSuite) TestStackDeployWithDAB(c *check.C) { stackArgs = []string{"stack", "ls"} out, err = d.Cmd(stackArgs...) c.Assert(err, checker.IsNil) - c.Assert(out, check.Equals, "NAME SERVICES\n"+"test 2\n") + c.Assert(cleanSpaces(out), check.Equals, "NAME SERVICES\n"+"test 2\n") // rm stackArgs = []string{"stack", "rm", testStackName} out, err = d.Cmd(stackArgs...) @@ -191,5 +202,5 @@ func (s *DockerSwarmSuite) TestStackDeployWithDAB(c *check.C) { stackArgs = []string{"stack", "ls"} out, err = d.Cmd(stackArgs...) c.Assert(err, checker.IsNil) - c.Assert(out, check.Equals, "NAME SERVICES\n") + c.Assert(cleanSpaces(out), check.Equals, "NAME SERVICES\n") }