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 <yong.tang.github@outlook.com>
This commit is contained in:
Yong Tang 2016-11-06 21:54:40 -08:00
parent 9e940b9020
commit 53bdc98713
8 changed files with 311 additions and 95 deletions

View File

@ -38,6 +38,7 @@ type Cli interface {
Out() *OutStream Out() *OutStream
Err() io.Writer Err() io.Writer
In() *InStream In() *InStream
ConfigFile() *configfile.ConfigFile
} }
// DockerCli is an instance the docker command line client. // DockerCli is an instance the docker command line client.

145
command/formatter/task.go Normal file
View File

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

View File

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

View File

@ -8,6 +8,7 @@ import (
"github.com/docker/docker/api/types/swarm" "github.com/docker/docker/api/types/swarm"
"github.com/docker/docker/cli" "github.com/docker/docker/cli"
"github.com/docker/docker/cli/command" "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/idresolver"
"github.com/docker/docker/cli/command/task" "github.com/docker/docker/cli/command/task"
"github.com/docker/docker/opts" "github.com/docker/docker/opts"
@ -19,6 +20,8 @@ type psOptions struct {
nodeIDs []string nodeIDs []string
noResolve bool noResolve bool
noTrunc bool noTrunc bool
quiet bool
format string
filter opts.FilterOpt 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.noTrunc, "no-trunc", false, "Do not truncate output")
flags.BoolVar(&opts.noResolve, "no-resolve", false, "Do not map IDs to Names") 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.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 return cmd
} }
@ -81,7 +86,16 @@ func runPs(dockerCli command.Cli, opts psOptions) error {
tasks = append(tasks, nodeTasks...) 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()) errs = append(errs, err.Error())
} }

View File

@ -10,6 +10,7 @@ import (
"github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/filters"
"github.com/docker/docker/cli" "github.com/docker/docker/cli"
"github.com/docker/docker/cli/command" "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/idresolver"
"github.com/docker/docker/cli/command/node" "github.com/docker/docker/cli/command/node"
"github.com/docker/docker/cli/command/task" "github.com/docker/docker/cli/command/task"
@ -22,6 +23,7 @@ type psOptions struct {
quiet bool quiet bool
noResolve bool noResolve bool
noTrunc bool noTrunc bool
format string
filter opts.FilterOpt 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.BoolVarP(&opts.quiet, "quiet", "q", false, "Only display task IDs")
flags.BoolVar(&opts.noTrunc, "no-trunc", false, "Do not truncate output") 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.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") flags.VarP(&opts.filter, "filter", "f", "Filter output based on conditions provided")
return cmd return cmd
@ -107,8 +110,14 @@ func runPS(dockerCli *command.DockerCli, opts psOptions) error {
return err return err
} }
if opts.quiet { format := opts.format
return task.PrintQuiet(dockerCli, tasks) 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)
} }

View File

@ -8,6 +8,7 @@ import (
"github.com/docker/docker/api/types" "github.com/docker/docker/api/types"
"github.com/docker/docker/cli" "github.com/docker/docker/cli"
"github.com/docker/docker/cli/command" "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/idresolver"
"github.com/docker/docker/cli/command/task" "github.com/docker/docker/cli/command/task"
"github.com/docker/docker/opts" "github.com/docker/docker/opts"
@ -19,6 +20,8 @@ type psOptions struct {
noTrunc bool noTrunc bool
namespace string namespace string
noResolve bool noResolve bool
quiet bool
format string
} }
func newPsCommand(dockerCli *command.DockerCli) *cobra.Command { 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.noTrunc, "no-trunc", false, "Do not truncate output")
flags.BoolVar(&opts.noResolve, "no-resolve", false, "Do not map IDs to Names") 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.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 return cmd
} }
@ -58,5 +63,14 @@ func runPS(dockerCli *command.DockerCli, opts psOptions) error {
return nil 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)
} }

View File

@ -2,42 +2,16 @@ package task
import ( import (
"fmt" "fmt"
"io"
"sort" "sort"
"strings"
"text/tabwriter"
"time"
"golang.org/x/net/context" "golang.org/x/net/context"
"github.com/docker/distribution/reference"
"github.com/docker/docker/api/types/swarm" "github.com/docker/docker/api/types/swarm"
"github.com/docker/docker/cli/command" "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/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 type tasksBySlot []swarm.Task
func (t tasksBySlot) Len() int { 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) 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 <node>` // Besides this, command `docker node ps <node>`
// and `docker stack ps` will call this, too. // 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)) sort.Stable(tasksBySlot(tasks))
writer := tabwriter.NewWriter(dockerCli.Out(), 0, 4, 2, ' ', 0) names := map[string]string{}
nodes := map[string]string{}
// Ignore flushing errors tasksCtx := formatter.Context{
defer writer.Flush() Output: dockerCli.Out(),
fmt.Fprintln(writer, strings.Join([]string{"ID", "NAME", "IMAGE", "NODE", "DESIRED STATE", "CURRENT STATE", "ERROR", "PORTS"}, "\t")) Format: formatter.NewTaskFormat(format, quiet),
Trunc: trunc,
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)
} }
return nil
}
func print(out io.Writer, ctx context.Context, tasks []swarm.Task, resolver *idresolver.IDResolver, noTrunc bool) error {
prevName := "" prevName := ""
for _, task := range tasks { for _, task := range tasks {
id := task.ID
if !noTrunc {
id = stringid.TruncateID(id)
}
serviceName, err := resolver.Resolve(ctx, swarm.Service{}, task.ServiceID) serviceName, err := resolver.Resolve(ctx, swarm.Service{}, task.ServiceID)
if err != nil { if err != nil {
return err return err
@ -118,42 +73,12 @@ func print(out io.Writer, ctx context.Context, tasks []swarm.Task, resolver *idr
} }
prevName = name prevName = name
// Trim and quote the error message. names[task.ID] = name
taskErr := task.Status.Err if tasksCtx.Format.IsTable() {
if !noTrunc && len(taskErr) > maxErrLength { names[task.ID] = indentedName
taskErr = fmt.Sprintf("%s…", taskErr[:maxErrLength-1])
} }
if len(taskErr) > 0 { nodes[task.ID] = nodeValue
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),
)
} }
return nil
return formatter.TaskWrite(tasksCtx, tasks, names, nodes)
} }

View File

@ -36,6 +36,7 @@ type ConfigFile struct {
Filename string `json:"-"` // Note: for internal use only Filename string `json:"-"` // Note: for internal use only
ServiceInspectFormat string `json:"serviceInspectFormat,omitempty"` ServiceInspectFormat string `json:"serviceInspectFormat,omitempty"`
ServicesFormat string `json:"servicesFormat,omitempty"` ServicesFormat string `json:"servicesFormat,omitempty"`
TasksFormat string `json:"tasksFormat,omitempty"`
} }
// LegacyLoadFromReader reads the non-nested configuration data given and sets up the // LegacyLoadFromReader reads the non-nested configuration data given and sets up the