From 53bdc98713739cb1f6d08d52d535e361de6442f2 Mon Sep 17 00:00:00 2001 From: Yong Tang Date: Sun, 6 Nov 2016 21:54:40 -0800 Subject: [PATCH] Add `--format` to `docker service ps` This fix tries to address the issue raised in 27189 where it is not possible to support configured formatting stored in config.json. Since `--format` was not supported in `docker service ps`, the flag `--format` has also been added in this fix. This fix 1. Add `--format` to `docker service ps` 2. Add `tasksFormat` to config.json 3. Add `--format` to `docker stack ps` 4. Add `--format` to `docker node ps` The related docs has been updated. An integration test has been added. This fix fixes 27189. Signed-off-by: Yong Tang --- command/cli.go | 1 + command/formatter/task.go | 145 +++++++++++++++++++++++++++++++++ command/formatter/task_test.go | 107 ++++++++++++++++++++++++ command/node/ps.go | 16 +++- command/service/ps.go | 15 +++- command/stack/ps.go | 16 +++- command/task/print.go | 105 ++++-------------------- config/configfile/file.go | 1 + 8 files changed, 311 insertions(+), 95 deletions(-) create mode 100644 command/formatter/task.go create mode 100644 command/formatter/task_test.go diff --git a/command/cli.go b/command/cli.go index bf9d554608..782c3a5074 100644 --- a/command/cli.go +++ b/command/cli.go @@ -38,6 +38,7 @@ type Cli interface { Out() *OutStream Err() io.Writer In() *InStream + ConfigFile() *configfile.ConfigFile } // DockerCli is an instance the docker command line client. diff --git a/command/formatter/task.go b/command/formatter/task.go new file mode 100644 index 0000000000..caf7651515 --- /dev/null +++ b/command/formatter/task.go @@ -0,0 +1,145 @@ +package formatter + +import ( + "fmt" + "strings" + "time" + + "github.com/docker/distribution/reference" + "github.com/docker/docker/api/types/swarm" + "github.com/docker/docker/cli/command" + "github.com/docker/docker/pkg/stringid" + "github.com/docker/go-units" +) + +const ( + defaultTaskTableFormat = "table {{.ID}}\t{{.Name}}\t{{.Image}}\t{{.Node}}\t{{.DesiredState}}\t{{.CurrentState}}\t{{.Error}}\t{{.Ports}}" + + nodeHeader = "NODE" + taskIDHeader = "ID" + desiredStateHeader = "DESIRED STATE" + currentStateHeader = "CURRENT STATE" + errorHeader = "ERROR" + + maxErrLength = 30 +) + +// NewTaskFormat returns a Format for rendering using a task Context +func NewTaskFormat(source string, quiet bool) Format { + switch source { + case TableFormatKey: + if quiet { + return defaultQuietFormat + } + return defaultTaskTableFormat + case RawFormatKey: + if quiet { + return `id: {{.ID}}` + } + return `id: {{.ID}}\nname: {{.Name}}\nimage: {{.Image}}\nnode: {{.Node}}\ndesired_state: {{.DesiredState}}\ncurrent_state: {{.CurrentState}}\nerror: {{.Error}}\nports: {{.Ports}}\n` + } + return Format(source) +} + +// TaskWrite writes the context +func TaskWrite(ctx Context, tasks []swarm.Task, names map[string]string, nodes map[string]string) error { + render := func(format func(subContext subContext) error) error { + for _, task := range tasks { + taskCtx := &taskContext{trunc: ctx.Trunc, task: task, name: names[task.ID], node: nodes[task.ID]} + if err := format(taskCtx); err != nil { + return err + } + } + return nil + } + return ctx.Write(&taskContext{}, render) +} + +type taskContext struct { + HeaderContext + trunc bool + task swarm.Task + name string + node string +} + +func (c *taskContext) MarshalJSON() ([]byte, error) { + return marshalJSON(c) +} + +func (c *taskContext) ID() string { + c.AddHeader(taskIDHeader) + if c.trunc { + return stringid.TruncateID(c.task.ID) + } + return c.task.ID +} + +func (c *taskContext) Name() string { + c.AddHeader(nameHeader) + return c.name +} + +func (c *taskContext) Image() string { + c.AddHeader(imageHeader) + image := c.task.Spec.ContainerSpec.Image + if c.trunc { + ref, err := reference.ParseNormalizedNamed(image) + if err == nil { + // update image string for display, (strips any digest) + if nt, ok := ref.(reference.NamedTagged); ok { + if namedTagged, err := reference.WithTag(reference.TrimNamed(nt), nt.Tag()); err == nil { + image = reference.FamiliarString(namedTagged) + } + } + } + } + return image +} + +func (c *taskContext) Node() string { + c.AddHeader(nodeHeader) + return c.node +} + +func (c *taskContext) DesiredState() string { + c.AddHeader(desiredStateHeader) + return command.PrettyPrint(c.task.DesiredState) +} + +func (c *taskContext) CurrentState() string { + c.AddHeader(currentStateHeader) + return fmt.Sprintf("%s %s ago", + command.PrettyPrint(c.task.Status.State), + strings.ToLower(units.HumanDuration(time.Since(c.task.Status.Timestamp))), + ) +} + +func (c *taskContext) Error() string { + c.AddHeader(errorHeader) + // Trim and quote the error message. + taskErr := c.task.Status.Err + if c.trunc && len(taskErr) > maxErrLength { + taskErr = fmt.Sprintf("%s…", taskErr[:maxErrLength-1]) + } + if len(taskErr) > 0 { + taskErr = fmt.Sprintf("\"%s\"", taskErr) + } + return taskErr +} + +func (c *taskContext) Ports() string { + c.AddHeader(portsHeader) + if len(c.task.Status.PortStatus.Ports) == 0 { + return "" + } + ports := []string{} + for _, pConfig := range c.task.Status.PortStatus.Ports { + ports = append(ports, fmt.Sprintf("*:%d->%d/%s", + pConfig.PublishedPort, + pConfig.TargetPort, + pConfig.Protocol, + )) + } + return strings.Join(ports, ",") +} diff --git a/command/formatter/task_test.go b/command/formatter/task_test.go new file mode 100644 index 0000000000..c990f68619 --- /dev/null +++ b/command/formatter/task_test.go @@ -0,0 +1,107 @@ +package formatter + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/docker/docker/api/types/swarm" + "github.com/docker/docker/pkg/testutil/assert" +) + +func TestTaskContextWrite(t *testing.T) { + cases := []struct { + context Context + expected string + }{ + { + 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 +`, + }, + { + Context{Format: NewTaskFormat("table", true)}, + `taskID1 +taskID2 +`, + }, + { + Context{Format: NewTaskFormat("table {{.Name}} {{.Node}} {{.Ports}}", false)}, + `NAME NODE PORTS +foobar_baz foo1 +foobar_bar foo2 +`, + }, + { + Context{Format: NewTaskFormat("table {{.Name}}", true)}, + `NAME +foobar_baz +foobar_bar +`, + }, + { + Context{Format: NewTaskFormat("raw", true)}, + `id: taskID1 +id: taskID2 +`, + }, + { + Context{Format: NewTaskFormat("{{.Name}} {{.Node}}", false)}, + `foobar_baz foo1 +foobar_bar foo2 +`, + }, + } + + for _, testcase := range cases { + tasks := []swarm.Task{ + {ID: "taskID1"}, + {ID: "taskID2"}, + } + names := map[string]string{ + "taskID1": "foobar_baz", + "taskID2": "foobar_bar", + } + nodes := map[string]string{ + "taskID1": "foo1", + "taskID2": "foo2", + } + out := bytes.NewBufferString("") + testcase.context.Output = out + err := TaskWrite(testcase.context, tasks, names, nodes) + if err != nil { + assert.Error(t, err, testcase.expected) + } else { + assert.Equal(t, out.String(), testcase.expected) + } + } +} + +func TestTaskContextWriteJSONField(t *testing.T) { + tasks := []swarm.Task{ + {ID: "taskID1"}, + {ID: "taskID2"}, + } + names := map[string]string{ + "taskID1": "foobar_baz", + "taskID2": "foobar_bar", + } + out := bytes.NewBufferString("") + err := TaskWrite(Context{Format: "{{json .ID}}", Output: out}, tasks, names, map[string]string{}) + if err != nil { + t.Fatal(err) + } + for i, line := range strings.Split(strings.TrimSpace(out.String()), "\n") { + var s string + if err := json.Unmarshal([]byte(line), &s); err != nil { + t.Fatal(err) + } + assert.Equal(t, s, tasks[i].ID) + } +} diff --git a/command/node/ps.go b/command/node/ps.go index 52ac36646e..cb0f3efdfc 100644 --- a/command/node/ps.go +++ b/command/node/ps.go @@ -8,6 +8,7 @@ import ( "github.com/docker/docker/api/types/swarm" "github.com/docker/docker/cli" "github.com/docker/docker/cli/command" + "github.com/docker/docker/cli/command/formatter" "github.com/docker/docker/cli/command/idresolver" "github.com/docker/docker/cli/command/task" "github.com/docker/docker/opts" @@ -19,6 +20,8 @@ type psOptions struct { nodeIDs []string noResolve bool noTrunc bool + quiet bool + format string filter opts.FilterOpt } @@ -43,6 +46,8 @@ func newPsCommand(dockerCli command.Cli) *cobra.Command { flags.BoolVar(&opts.noTrunc, "no-trunc", false, "Do not truncate output") flags.BoolVar(&opts.noResolve, "no-resolve", false, "Do not map IDs to Names") flags.VarP(&opts.filter, "filter", "f", "Filter output based on conditions provided") + flags.StringVar(&opts.format, "format", "", "Pretty-print tasks using a Go template") + flags.BoolVarP(&opts.quiet, "quiet", "q", false, "Only display task IDs") return cmd } @@ -81,7 +86,16 @@ func runPs(dockerCli command.Cli, opts psOptions) error { tasks = append(tasks, nodeTasks...) } - if err := task.Print(dockerCli, ctx, tasks, idresolver.New(client, opts.noResolve), opts.noTrunc); err != nil { + format := opts.format + if len(format) == 0 { + if dockerCli.ConfigFile() != nil && len(dockerCli.ConfigFile().TasksFormat) > 0 && !opts.quiet { + format = dockerCli.ConfigFile().TasksFormat + } else { + format = formatter.TableFormatKey + } + } + + if err := task.Print(dockerCli, ctx, tasks, idresolver.New(client, opts.noResolve), !opts.noTrunc, opts.quiet, format); err != nil { errs = append(errs, err.Error()) } diff --git a/command/service/ps.go b/command/service/ps.go index 12b25bf4f6..c4ff1b9e3f 100644 --- a/command/service/ps.go +++ b/command/service/ps.go @@ -10,6 +10,7 @@ import ( "github.com/docker/docker/api/types/filters" "github.com/docker/docker/cli" "github.com/docker/docker/cli/command" + "github.com/docker/docker/cli/command/formatter" "github.com/docker/docker/cli/command/idresolver" "github.com/docker/docker/cli/command/node" "github.com/docker/docker/cli/command/task" @@ -22,6 +23,7 @@ type psOptions struct { quiet bool noResolve bool noTrunc bool + format string filter opts.FilterOpt } @@ -41,6 +43,7 @@ func newPsCommand(dockerCli *command.DockerCli) *cobra.Command { flags.BoolVarP(&opts.quiet, "quiet", "q", false, "Only display task IDs") flags.BoolVar(&opts.noTrunc, "no-trunc", false, "Do not truncate output") flags.BoolVar(&opts.noResolve, "no-resolve", false, "Do not map IDs to Names") + flags.StringVar(&opts.format, "format", "", "Pretty-print tasks using a Go template") flags.VarP(&opts.filter, "filter", "f", "Filter output based on conditions provided") return cmd @@ -107,8 +110,14 @@ func runPS(dockerCli *command.DockerCli, opts psOptions) error { return err } - if opts.quiet { - return task.PrintQuiet(dockerCli, tasks) + format := opts.format + if len(format) == 0 { + if len(dockerCli.ConfigFile().TasksFormat) > 0 && !opts.quiet { + format = dockerCli.ConfigFile().TasksFormat + } else { + format = formatter.TableFormatKey + } } - return task.Print(dockerCli, ctx, tasks, idresolver.New(client, opts.noResolve), opts.noTrunc) + + return task.Print(dockerCli, ctx, tasks, idresolver.New(client, opts.noResolve), !opts.noTrunc, opts.quiet, format) } diff --git a/command/stack/ps.go b/command/stack/ps.go index 7bbcf54205..bac5307bd1 100644 --- a/command/stack/ps.go +++ b/command/stack/ps.go @@ -8,6 +8,7 @@ import ( "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/command/idresolver" "github.com/docker/docker/cli/command/task" "github.com/docker/docker/opts" @@ -19,6 +20,8 @@ type psOptions struct { noTrunc bool namespace string noResolve bool + quiet bool + format string } func newPsCommand(dockerCli *command.DockerCli) *cobra.Command { @@ -37,6 +40,8 @@ func newPsCommand(dockerCli *command.DockerCli) *cobra.Command { flags.BoolVar(&opts.noTrunc, "no-trunc", false, "Do not truncate output") flags.BoolVar(&opts.noResolve, "no-resolve", false, "Do not map IDs to Names") flags.VarP(&opts.filter, "filter", "f", "Filter output based on conditions provided") + flags.BoolVarP(&opts.quiet, "quiet", "q", false, "Only display task IDs") + flags.StringVar(&opts.format, "format", "", "Pretty-print tasks using a Go template") return cmd } @@ -58,5 +63,14 @@ func runPS(dockerCli *command.DockerCli, opts psOptions) error { return nil } - return task.Print(dockerCli, ctx, tasks, idresolver.New(client, opts.noResolve), opts.noTrunc) + format := opts.format + if len(format) == 0 { + if len(dockerCli.ConfigFile().TasksFormat) > 0 && !opts.quiet { + format = dockerCli.ConfigFile().TasksFormat + } else { + format = formatter.TableFormatKey + } + } + + return task.Print(dockerCli, ctx, tasks, idresolver.New(client, opts.noResolve), !opts.noTrunc, opts.quiet, format) } diff --git a/command/task/print.go b/command/task/print.go index d7e20bb59a..3df3b2985a 100644 --- a/command/task/print.go +++ b/command/task/print.go @@ -2,42 +2,16 @@ package task import ( "fmt" - "io" "sort" - "strings" - "text/tabwriter" - "time" "golang.org/x/net/context" - "github.com/docker/distribution/reference" "github.com/docker/docker/api/types/swarm" "github.com/docker/docker/cli/command" + "github.com/docker/docker/cli/command/formatter" "github.com/docker/docker/cli/command/idresolver" - "github.com/docker/docker/pkg/stringid" - "github.com/docker/go-units" ) -const ( - psTaskItemFmt = "%s\t%s\t%s\t%s\t%s\t%s %s ago\t%s\t%s\n" - maxErrLength = 30 -) - -type portStatus swarm.PortStatus - -func (ps portStatus) String() string { - if len(ps.Ports) == 0 { - return "" - } - - str := fmt.Sprintf("*:%d->%d/%s", ps.Ports[0].PublishedPort, ps.Ports[0].TargetPort, ps.Ports[0].Protocol) - for _, pConfig := range ps.Ports[1:] { - str += fmt.Sprintf(",*:%d->%d/%s", pConfig.PublishedPort, pConfig.TargetPort, pConfig.Protocol) - } - - return str -} - type tasksBySlot []swarm.Task func (t tasksBySlot) Len() int { @@ -58,42 +32,23 @@ func (t tasksBySlot) Less(i, j int) bool { return t[j].Meta.CreatedAt.Before(t[i].CreatedAt) } -// Print task information in a table format. +// Print task information in a format. // Besides this, command `docker node ps ` // and `docker stack ps` will call this, too. -func Print(dockerCli command.Cli, ctx context.Context, tasks []swarm.Task, resolver *idresolver.IDResolver, noTrunc bool) error { +func Print(dockerCli command.Cli, ctx context.Context, tasks []swarm.Task, resolver *idresolver.IDResolver, trunc, quiet bool, format string) error { sort.Stable(tasksBySlot(tasks)) - writer := tabwriter.NewWriter(dockerCli.Out(), 0, 4, 2, ' ', 0) + names := map[string]string{} + nodes := map[string]string{} - // Ignore flushing errors - defer writer.Flush() - fmt.Fprintln(writer, strings.Join([]string{"ID", "NAME", "IMAGE", "NODE", "DESIRED STATE", "CURRENT STATE", "ERROR", "PORTS"}, "\t")) - - return print(writer, ctx, tasks, resolver, noTrunc) -} - -// PrintQuiet shows task list in a quiet way. -func PrintQuiet(dockerCli command.Cli, tasks []swarm.Task) error { - sort.Stable(tasksBySlot(tasks)) - - out := dockerCli.Out() - - for _, task := range tasks { - fmt.Fprintln(out, task.ID) + tasksCtx := formatter.Context{ + Output: dockerCli.Out(), + Format: formatter.NewTaskFormat(format, quiet), + Trunc: trunc, } - return nil -} - -func print(out io.Writer, ctx context.Context, tasks []swarm.Task, resolver *idresolver.IDResolver, noTrunc bool) error { prevName := "" for _, task := range tasks { - id := task.ID - if !noTrunc { - id = stringid.TruncateID(id) - } - serviceName, err := resolver.Resolve(ctx, swarm.Service{}, task.ServiceID) if err != nil { return err @@ -118,42 +73,12 @@ func print(out io.Writer, ctx context.Context, tasks []swarm.Task, resolver *idr } prevName = name - // Trim and quote the error message. - taskErr := task.Status.Err - if !noTrunc && len(taskErr) > maxErrLength { - taskErr = fmt.Sprintf("%s…", taskErr[:maxErrLength-1]) + names[task.ID] = name + if tasksCtx.Format.IsTable() { + names[task.ID] = indentedName } - if len(taskErr) > 0 { - taskErr = fmt.Sprintf("\"%s\"", taskErr) - } - - image := task.Spec.ContainerSpec.Image - if !noTrunc { - ref, err := reference.ParseNormalizedNamed(image) - if err == nil { - // update image string for display, (strips any digest) - if nt, ok := ref.(reference.NamedTagged); ok { - if namedTagged, err := reference.WithTag(reference.TrimNamed(nt), nt.Tag()); err == nil { - image = reference.FamiliarString(namedTagged) - } - } - - } - } - - fmt.Fprintf( - out, - psTaskItemFmt, - id, - indentedName, - image, - nodeValue, - command.PrettyPrint(task.DesiredState), - command.PrettyPrint(task.Status.State), - strings.ToLower(units.HumanDuration(time.Since(task.Status.Timestamp))), - taskErr, - portStatus(task.Status.PortStatus), - ) + nodes[task.ID] = nodeValue } - return nil + + return formatter.TaskWrite(tasksCtx, tasks, names, nodes) } diff --git a/config/configfile/file.go b/config/configfile/file.go index c321b97f2e..d83434676e 100644 --- a/config/configfile/file.go +++ b/config/configfile/file.go @@ -36,6 +36,7 @@ type ConfigFile struct { Filename string `json:"-"` // Note: for internal use only ServiceInspectFormat string `json:"serviceInspectFormat,omitempty"` ServicesFormat string `json:"servicesFormat,omitempty"` + TasksFormat string `json:"tasksFormat,omitempty"` } // LegacyLoadFromReader reads the non-nested configuration data given and sets up the