Signed-off-by: Boaz Shuster <ripcurld.github@gmail.com>
| 1 | 1 |
new file mode 100644 |
| ... | ... |
@@ -0,0 +1,67 @@ |
| 0 |
+package formatter |
|
| 1 |
+ |
|
| 2 |
+import ( |
|
| 3 |
+ "strconv" |
|
| 4 |
+) |
|
| 5 |
+ |
|
| 6 |
+const ( |
|
| 7 |
+ defaultStackTableFormat = "table {{.Name}}\t{{.Services}}"
|
|
| 8 |
+ |
|
| 9 |
+ stackServicesHeader = "SERVICES" |
|
| 10 |
+) |
|
| 11 |
+ |
|
| 12 |
+// Stack contains deployed stack information. |
|
| 13 |
+type Stack struct {
|
|
| 14 |
+ // Name is the name of the stack |
|
| 15 |
+ Name string |
|
| 16 |
+ // Services is the number of the services |
|
| 17 |
+ Services int |
|
| 18 |
+} |
|
| 19 |
+ |
|
| 20 |
+// NewStackFormat returns a format for use with a stack Context |
|
| 21 |
+func NewStackFormat(source string) Format {
|
|
| 22 |
+ switch source {
|
|
| 23 |
+ case TableFormatKey: |
|
| 24 |
+ return defaultStackTableFormat |
|
| 25 |
+ } |
|
| 26 |
+ return Format(source) |
|
| 27 |
+} |
|
| 28 |
+ |
|
| 29 |
+// StackWrite writes formatted stacks using the Context |
|
| 30 |
+func StackWrite(ctx Context, stacks []*Stack) error {
|
|
| 31 |
+ render := func(format func(subContext subContext) error) error {
|
|
| 32 |
+ for _, stack := range stacks {
|
|
| 33 |
+ if err := format(&stackContext{s: stack}); err != nil {
|
|
| 34 |
+ return err |
|
| 35 |
+ } |
|
| 36 |
+ } |
|
| 37 |
+ return nil |
|
| 38 |
+ } |
|
| 39 |
+ return ctx.Write(newStackContext(), render) |
|
| 40 |
+} |
|
| 41 |
+ |
|
| 42 |
+type stackContext struct {
|
|
| 43 |
+ HeaderContext |
|
| 44 |
+ s *Stack |
|
| 45 |
+} |
|
| 46 |
+ |
|
| 47 |
+func newStackContext() *stackContext {
|
|
| 48 |
+ stackCtx := stackContext{}
|
|
| 49 |
+ stackCtx.header = map[string]string{
|
|
| 50 |
+ "Name": nameHeader, |
|
| 51 |
+ "Services": stackServicesHeader, |
|
| 52 |
+ } |
|
| 53 |
+ return &stackCtx |
|
| 54 |
+} |
|
| 55 |
+ |
|
| 56 |
+func (s *stackContext) MarshalJSON() ([]byte, error) {
|
|
| 57 |
+ return marshalJSON(s) |
|
| 58 |
+} |
|
| 59 |
+ |
|
| 60 |
+func (s *stackContext) Name() string {
|
|
| 61 |
+ return s.s.Name |
|
| 62 |
+} |
|
| 63 |
+ |
|
| 64 |
+func (s *stackContext) Services() string {
|
|
| 65 |
+ return strconv.Itoa(s.s.Services) |
|
| 66 |
+} |
| 0 | 67 |
new file mode 100644 |
| ... | ... |
@@ -0,0 +1,64 @@ |
| 0 |
+package formatter |
|
| 1 |
+ |
|
| 2 |
+import ( |
|
| 3 |
+ "bytes" |
|
| 4 |
+ "testing" |
|
| 5 |
+ |
|
| 6 |
+ "github.com/stretchr/testify/assert" |
|
| 7 |
+) |
|
| 8 |
+ |
|
| 9 |
+func TestStackContextWrite(t *testing.T) {
|
|
| 10 |
+ cases := []struct {
|
|
| 11 |
+ context Context |
|
| 12 |
+ expected string |
|
| 13 |
+ }{
|
|
| 14 |
+ // Errors |
|
| 15 |
+ {
|
|
| 16 |
+ Context{Format: "{{InvalidFunction}}"},
|
|
| 17 |
+ `Template parsing error: template: :1: function "InvalidFunction" not defined |
|
| 18 |
+`, |
|
| 19 |
+ }, |
|
| 20 |
+ {
|
|
| 21 |
+ Context{Format: "{{nil}}"},
|
|
| 22 |
+ `Template parsing error: template: :1:2: executing "" at <nil>: nil is not a command |
|
| 23 |
+`, |
|
| 24 |
+ }, |
|
| 25 |
+ // Table format |
|
| 26 |
+ {
|
|
| 27 |
+ Context{Format: NewStackFormat("table")},
|
|
| 28 |
+ `NAME SERVICES |
|
| 29 |
+baz 2 |
|
| 30 |
+bar 1 |
|
| 31 |
+`, |
|
| 32 |
+ }, |
|
| 33 |
+ {
|
|
| 34 |
+ Context{Format: NewStackFormat("table {{.Name}}")},
|
|
| 35 |
+ `NAME |
|
| 36 |
+baz |
|
| 37 |
+bar |
|
| 38 |
+`, |
|
| 39 |
+ }, |
|
| 40 |
+ // Custom Format |
|
| 41 |
+ {
|
|
| 42 |
+ Context{Format: NewStackFormat("{{.Name}}")},
|
|
| 43 |
+ `baz |
|
| 44 |
+bar |
|
| 45 |
+`, |
|
| 46 |
+ }, |
|
| 47 |
+ } |
|
| 48 |
+ |
|
| 49 |
+ stacks := []*Stack{
|
|
| 50 |
+ {Name: "baz", Services: 2},
|
|
| 51 |
+ {Name: "bar", Services: 1},
|
|
| 52 |
+ } |
|
| 53 |
+ for _, testcase := range cases {
|
|
| 54 |
+ out := bytes.NewBufferString("")
|
|
| 55 |
+ testcase.context.Output = out |
|
| 56 |
+ err := StackWrite(testcase.context, stacks) |
|
| 57 |
+ if err != nil {
|
|
| 58 |
+ assert.Error(t, err, testcase.expected) |
|
| 59 |
+ } else {
|
|
| 60 |
+ assert.Equal(t, out.String(), testcase.expected) |
|
| 61 |
+ } |
|
| 62 |
+ } |
|
| 63 |
+} |
| ... | ... |
@@ -1,15 +1,12 @@ |
| 1 | 1 |
package stack |
| 2 | 2 |
|
| 3 | 3 |
import ( |
| 4 |
- "fmt" |
|
| 5 |
- "io" |
|
| 6 | 4 |
"sort" |
| 7 |
- "strconv" |
|
| 8 |
- "text/tabwriter" |
|
| 9 | 5 |
|
| 10 | 6 |
"github.com/docker/docker/api/types" |
| 11 | 7 |
"github.com/docker/docker/cli" |
| 12 | 8 |
"github.com/docker/docker/cli/command" |
| 9 |
+ "github.com/docker/docker/cli/command/formatter" |
|
| 13 | 10 |
"github.com/docker/docker/cli/compose/convert" |
| 14 | 11 |
"github.com/docker/docker/client" |
| 15 | 12 |
"github.com/pkg/errors" |
| ... | ... |
@@ -17,11 +14,8 @@ import ( |
| 17 | 17 |
"golang.org/x/net/context" |
| 18 | 18 |
) |
| 19 | 19 |
|
| 20 |
-const ( |
|
| 21 |
- listItemFmt = "%s\t%s\n" |
|
| 22 |
-) |
|
| 23 |
- |
|
| 24 | 20 |
type listOptions struct {
|
| 21 |
+ format string |
|
| 25 | 22 |
} |
| 26 | 23 |
|
| 27 | 24 |
func newListCommand(dockerCli *command.DockerCli) *cobra.Command {
|
| ... | ... |
@@ -37,6 +31,8 @@ func newListCommand(dockerCli *command.DockerCli) *cobra.Command {
|
| 37 | 37 |
}, |
| 38 | 38 |
} |
| 39 | 39 |
|
| 40 |
+ flags := cmd.Flags() |
|
| 41 |
+ flags.StringVar(&opts.format, "format", "", "Pretty-print stacks using a Go template") |
|
| 40 | 42 |
return cmd |
| 41 | 43 |
} |
| 42 | 44 |
|
| ... | ... |
@@ -48,55 +44,32 @@ func runList(dockerCli *command.DockerCli, opts listOptions) error {
|
| 48 | 48 |
if err != nil {
|
| 49 | 49 |
return err |
| 50 | 50 |
} |
| 51 |
- |
|
| 52 |
- out := dockerCli.Out() |
|
| 53 |
- printTable(out, stacks) |
|
| 54 |
- return nil |
|
| 51 |
+ format := opts.format |
|
| 52 |
+ if len(format) == 0 {
|
|
| 53 |
+ format = formatter.TableFormatKey |
|
| 54 |
+ } |
|
| 55 |
+ stackCtx := formatter.Context{
|
|
| 56 |
+ Output: dockerCli.Out(), |
|
| 57 |
+ Format: formatter.NewStackFormat(format), |
|
| 58 |
+ } |
|
| 59 |
+ sort.Sort(byName(stacks)) |
|
| 60 |
+ return formatter.StackWrite(stackCtx, stacks) |
|
| 55 | 61 |
} |
| 56 | 62 |
|
| 57 |
-type byName []*stack |
|
| 63 |
+type byName []*formatter.Stack |
|
| 58 | 64 |
|
| 59 | 65 |
func (n byName) Len() int { return len(n) }
|
| 60 | 66 |
func (n byName) Swap(i, j int) { n[i], n[j] = n[j], n[i] }
|
| 61 | 67 |
func (n byName) Less(i, j int) bool { return n[i].Name < n[j].Name }
|
| 62 | 68 |
|
| 63 |
-func printTable(out io.Writer, stacks []*stack) {
|
|
| 64 |
- writer := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0) |
|
| 65 |
- |
|
| 66 |
- // Ignore flushing errors |
|
| 67 |
- defer writer.Flush() |
|
| 68 |
- |
|
| 69 |
- sort.Sort(byName(stacks)) |
|
| 70 |
- |
|
| 71 |
- fmt.Fprintf(writer, listItemFmt, "NAME", "SERVICES") |
|
| 72 |
- for _, stack := range stacks {
|
|
| 73 |
- fmt.Fprintf( |
|
| 74 |
- writer, |
|
| 75 |
- listItemFmt, |
|
| 76 |
- stack.Name, |
|
| 77 |
- strconv.Itoa(stack.Services), |
|
| 78 |
- ) |
|
| 79 |
- } |
|
| 80 |
-} |
|
| 81 |
- |
|
| 82 |
-type stack struct {
|
|
| 83 |
- // Name is the name of the stack |
|
| 84 |
- Name string |
|
| 85 |
- // Services is the number of the services |
|
| 86 |
- Services int |
|
| 87 |
-} |
|
| 88 |
- |
|
| 89 |
-func getStacks( |
|
| 90 |
- ctx context.Context, |
|
| 91 |
- apiclient client.APIClient, |
|
| 92 |
-) ([]*stack, error) {
|
|
| 69 |
+func getStacks(ctx context.Context, apiclient client.APIClient) ([]*formatter.Stack, error) {
|
|
| 93 | 70 |
services, err := apiclient.ServiceList( |
| 94 | 71 |
ctx, |
| 95 | 72 |
types.ServiceListOptions{Filters: getAllStacksFilter()})
|
| 96 | 73 |
if err != nil {
|
| 97 | 74 |
return nil, err |
| 98 | 75 |
} |
| 99 |
- m := make(map[string]*stack, 0) |
|
| 76 |
+ m := make(map[string]*formatter.Stack, 0) |
|
| 100 | 77 |
for _, service := range services {
|
| 101 | 78 |
labels := service.Spec.Labels |
| 102 | 79 |
name, ok := labels[convert.LabelNamespace] |
| ... | ... |
@@ -106,7 +79,7 @@ func getStacks( |
| 106 | 106 |
} |
| 107 | 107 |
ztack, ok := m[name] |
| 108 | 108 |
if !ok {
|
| 109 |
- m[name] = &stack{
|
|
| 109 |
+ m[name] = &formatter.Stack{
|
|
| 110 | 110 |
Name: name, |
| 111 | 111 |
Services: 1, |
| 112 | 112 |
} |
| ... | ... |
@@ -114,7 +87,7 @@ func getStacks( |
| 114 | 114 |
ztack.Services++ |
| 115 | 115 |
} |
| 116 | 116 |
} |
| 117 |
- var stacks []*stack |
|
| 117 |
+ var stacks []*formatter.Stack |
|
| 118 | 118 |
for _, stack := range m {
|
| 119 | 119 |
stacks = append(stacks, stack) |
| 120 | 120 |
} |
| ... | ... |
@@ -24,7 +24,8 @@ Aliases: |
| 24 | 24 |
ls, list |
| 25 | 25 |
|
| 26 | 26 |
Options: |
| 27 |
- --help Print usage |
|
| 27 |
+ --help Print usage |
|
| 28 |
+ --format string Pretty-print stacks using a Go template |
|
| 28 | 29 |
``` |
| 29 | 30 |
|
| 30 | 31 |
## Description |
| ... | ... |
@@ -43,6 +44,30 @@ vossibility-stack 6 |
| 43 | 43 |
myapp 2 |
| 44 | 44 |
``` |
| 45 | 45 |
|
| 46 |
+### Formatting |
|
| 47 |
+ |
|
| 48 |
+The formatting option (`--format`) pretty-prints stacks using a Go template. |
|
| 49 |
+ |
|
| 50 |
+Valid placeholders for the Go template are listed below: |
|
| 51 |
+ |
|
| 52 |
+| Placeholder | Description | |
|
| 53 |
+| ----------- | ------------------ | |
|
| 54 |
+| `.Name` | Stack name | |
|
| 55 |
+| `.Services` | Number of services | |
|
| 56 |
+ |
|
| 57 |
+When using the `--format` option, the `stack ls` command either outputs |
|
| 58 |
+the data exactly as the template declares or, when using the |
|
| 59 |
+`table` directive, includes column headers as well. |
|
| 60 |
+ |
|
| 61 |
+The following example uses a template without headers and outputs the |
|
| 62 |
+`Name` and `Services` entries separated by a colon for all stacks: |
|
| 63 |
+ |
|
| 64 |
+```bash |
|
| 65 |
+$ docker stack ls --format "{{.Name}}: {{.Services}}"
|
|
| 66 |
+web-server: 1 |
|
| 67 |
+web-cache: 4 |
|
| 68 |
+``` |
|
| 69 |
+ |
|
| 46 | 70 |
## Related commands |
| 47 | 71 |
|
| 48 | 72 |
* [stack deploy](stack_deploy.md) |
| ... | ... |
@@ -15,6 +15,17 @@ import ( |
| 15 | 15 |
"github.com/go-check/check" |
| 16 | 16 |
) |
| 17 | 17 |
|
| 18 |
+var cleanSpaces = func(s string) string {
|
|
| 19 |
+ lines := strings.Split(s, "\n") |
|
| 20 |
+ for i, line := range lines {
|
|
| 21 |
+ spaceIx := strings.Index(line, " ") |
|
| 22 |
+ if spaceIx > 0 {
|
|
| 23 |
+ lines[i] = line[:spaceIx+1] + strings.TrimLeft(line[spaceIx:], " ") |
|
| 24 |
+ } |
|
| 25 |
+ } |
|
| 26 |
+ return strings.Join(lines, "\n") |
|
| 27 |
+} |
|
| 28 |
+ |
|
| 18 | 29 |
func (s *DockerSwarmSuite) TestStackRemoveUnknown(c *check.C) {
|
| 19 | 30 |
d := s.AddDaemon(c, true, true) |
| 20 | 31 |
|
| ... | ... |
@@ -59,13 +70,13 @@ func (s *DockerSwarmSuite) TestStackDeployComposeFile(c *check.C) {
|
| 59 | 59 |
|
| 60 | 60 |
out, err = d.Cmd("stack", "ls")
|
| 61 | 61 |
c.Assert(err, checker.IsNil) |
| 62 |
- c.Assert(out, check.Equals, "NAME SERVICES\n"+"testdeploy 2\n") |
|
| 62 |
+ c.Assert(cleanSpaces(out), check.Equals, "NAME SERVICES\n"+"testdeploy 2\n") |
|
| 63 | 63 |
|
| 64 | 64 |
out, err = d.Cmd("stack", "rm", testStackName)
|
| 65 | 65 |
c.Assert(err, checker.IsNil) |
| 66 | 66 |
out, err = d.Cmd("stack", "ls")
|
| 67 | 67 |
c.Assert(err, checker.IsNil) |
| 68 |
- c.Assert(out, check.Equals, "NAME SERVICES\n") |
|
| 68 |
+ c.Assert(cleanSpaces(out), check.Equals, "NAME SERVICES\n") |
|
| 69 | 69 |
} |
| 70 | 70 |
|
| 71 | 71 |
func (s *DockerSwarmSuite) TestStackDeployWithSecretsTwice(c *check.C) {
|
| ... | ... |
@@ -180,7 +191,7 @@ func (s *DockerSwarmSuite) TestStackDeployWithDAB(c *check.C) {
|
| 180 | 180 |
stackArgs = []string{"stack", "ls"}
|
| 181 | 181 |
out, err = d.Cmd(stackArgs...) |
| 182 | 182 |
c.Assert(err, checker.IsNil) |
| 183 |
- c.Assert(out, check.Equals, "NAME SERVICES\n"+"test 2\n") |
|
| 183 |
+ c.Assert(cleanSpaces(out), check.Equals, "NAME SERVICES\n"+"test 2\n") |
|
| 184 | 184 |
// rm |
| 185 | 185 |
stackArgs = []string{"stack", "rm", testStackName}
|
| 186 | 186 |
out, err = d.Cmd(stackArgs...) |
| ... | ... |
@@ -191,5 +202,5 @@ func (s *DockerSwarmSuite) TestStackDeployWithDAB(c *check.C) {
|
| 191 | 191 |
stackArgs = []string{"stack", "ls"}
|
| 192 | 192 |
out, err = d.Cmd(stackArgs...) |
| 193 | 193 |
c.Assert(err, checker.IsNil) |
| 194 |
- c.Assert(out, check.Equals, "NAME SERVICES\n") |
|
| 194 |
+ c.Assert(cleanSpaces(out), check.Equals, "NAME SERVICES\n") |
|
| 195 | 195 |
} |