Add `--format` for `docker node ls`

This fix tries to address the comment https://github.com/docker/docker/pull/30376#discussion_r97465334
where it was not possible to specify `--format` for `docker node ls`. The `--format` flag
is a quite useful flag that could be used in many places such as completion.

This fix implements `--format` for `docker node ls` and add `nodesFormat` in config.json
so that it is possible to specify the output when `docker node ls` is invoked.

Related documentations have been updated.

A set of unit tests have been added.

This fix is related to #30376.

Signed-off-by: Yong Tang <yong.tang.github@outlook.com>
This commit is contained in:
Yong Tang 2017-01-24 13:17:40 -08:00
parent 9a5513b791
commit 4fc1d6782c
5 changed files with 397 additions and 90 deletions

99
command/formatter/node.go Normal file
View File

@ -0,0 +1,99 @@
package formatter
import (
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/swarm"
"github.com/docker/docker/cli/command"
)
const (
defaultNodeTableFormat = "table {{.ID}}\t{{.Hostname}}\t{{.Status}}\t{{.Availability}}\t{{.ManagerStatus}}"
nodeIDHeader = "ID"
hostnameHeader = "HOSTNAME"
availabilityHeader = "AVAILABILITY"
managerStatusHeader = "MANAGER STATUS"
)
// NewNodeFormat returns a Format for rendering using a node Context
func NewNodeFormat(source string, quiet bool) Format {
switch source {
case TableFormatKey:
if quiet {
return defaultQuietFormat
}
return defaultNodeTableFormat
case RawFormatKey:
if quiet {
return `node_id: {{.ID}}`
}
return `node_id: {{.ID}}\nhostname: {{.Hostname}}\nstatus: {{.Status}}\navailability: {{.Availability}}\nmanager_status: {{.ManagerStatus}}\n`
}
return Format(source)
}
// NodeWrite writes the context
func NodeWrite(ctx Context, nodes []swarm.Node, info types.Info) error {
render := func(format func(subContext subContext) error) error {
for _, node := range nodes {
nodeCtx := &nodeContext{n: node, info: info}
if err := format(nodeCtx); err != nil {
return err
}
}
return nil
}
nodeCtx := nodeContext{}
nodeCtx.header = nodeHeaderContext{
"ID": nodeIDHeader,
"Hostname": hostnameHeader,
"Status": statusHeader,
"Availability": availabilityHeader,
"ManagerStatus": managerStatusHeader,
}
return ctx.Write(&nodeCtx, render)
}
type nodeHeaderContext map[string]string
type nodeContext struct {
HeaderContext
n swarm.Node
info types.Info
}
func (c *nodeContext) MarshalJSON() ([]byte, error) {
return marshalJSON(c)
}
func (c *nodeContext) ID() string {
nodeID := c.n.ID
if nodeID == c.info.Swarm.NodeID {
nodeID = nodeID + " *"
}
return nodeID
}
func (c *nodeContext) Hostname() string {
return c.n.Description.Hostname
}
func (c *nodeContext) Status() string {
return command.PrettyPrint(string(c.n.Status.State))
}
func (c *nodeContext) Availability() string {
return command.PrettyPrint(string(c.n.Spec.Availability))
}
func (c *nodeContext) ManagerStatus() string {
reachability := ""
if c.n.ManagerStatus != nil {
if c.n.ManagerStatus.Leader {
reachability = "Leader"
} else {
reachability = string(c.n.ManagerStatus.Reachability)
}
}
return command.PrettyPrint(reachability)
}

View File

@ -0,0 +1,188 @@
package formatter
import (
"bytes"
"encoding/json"
"strings"
"testing"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/swarm"
"github.com/docker/docker/pkg/stringid"
"github.com/docker/docker/pkg/testutil/assert"
)
func TestNodeContext(t *testing.T) {
nodeID := stringid.GenerateRandomID()
var ctx nodeContext
cases := []struct {
nodeCtx nodeContext
expValue string
call func() string
}{
{nodeContext{
n: swarm.Node{ID: nodeID},
}, nodeID, ctx.ID},
{nodeContext{
n: swarm.Node{Description: swarm.NodeDescription{Hostname: "node_hostname"}},
}, "node_hostname", ctx.Hostname},
{nodeContext{
n: swarm.Node{Status: swarm.NodeStatus{State: swarm.NodeState("foo")}},
}, "Foo", ctx.Status},
{nodeContext{
n: swarm.Node{Spec: swarm.NodeSpec{Availability: swarm.NodeAvailability("drain")}},
}, "Drain", ctx.Availability},
{nodeContext{
n: swarm.Node{ManagerStatus: &swarm.ManagerStatus{Leader: true}},
}, "Leader", ctx.ManagerStatus},
}
for _, c := range cases {
ctx = c.nodeCtx
v := c.call()
if strings.Contains(v, ",") {
compareMultipleValues(t, v, c.expValue)
} else if v != c.expValue {
t.Fatalf("Expected %s, was %s\n", c.expValue, v)
}
}
}
func TestNodeContextWrite(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>: nil is not a command
`,
},
// Table format
{
Context{Format: NewNodeFormat("table", false)},
`ID HOSTNAME STATUS AVAILABILITY MANAGER STATUS
nodeID1 foobar_baz Foo Drain Leader
nodeID2 foobar_bar Bar Active Reachable
`,
},
{
Context{Format: NewNodeFormat("table", true)},
`nodeID1
nodeID2
`,
},
{
Context{Format: NewNodeFormat("table {{.Hostname}}", false)},
`HOSTNAME
foobar_baz
foobar_bar
`,
},
{
Context{Format: NewNodeFormat("table {{.Hostname}}", true)},
`HOSTNAME
foobar_baz
foobar_bar
`,
},
// Raw Format
{
Context{Format: NewNodeFormat("raw", false)},
`node_id: nodeID1
hostname: foobar_baz
status: Foo
availability: Drain
manager_status: Leader
node_id: nodeID2
hostname: foobar_bar
status: Bar
availability: Active
manager_status: Reachable
`,
},
{
Context{Format: NewNodeFormat("raw", true)},
`node_id: nodeID1
node_id: nodeID2
`,
},
// Custom Format
{
Context{Format: NewNodeFormat("{{.Hostname}}", false)},
`foobar_baz
foobar_bar
`,
},
}
for _, testcase := range cases {
nodes := []swarm.Node{
{ID: "nodeID1", Description: swarm.NodeDescription{Hostname: "foobar_baz"}, Status: swarm.NodeStatus{State: swarm.NodeState("foo")}, Spec: swarm.NodeSpec{Availability: swarm.NodeAvailability("drain")}, ManagerStatus: &swarm.ManagerStatus{Leader: true}},
{ID: "nodeID2", Description: swarm.NodeDescription{Hostname: "foobar_bar"}, Status: swarm.NodeStatus{State: swarm.NodeState("bar")}, Spec: swarm.NodeSpec{Availability: swarm.NodeAvailability("active")}, ManagerStatus: &swarm.ManagerStatus{Leader: false, Reachability: swarm.Reachability("Reachable")}},
}
out := bytes.NewBufferString("")
testcase.context.Output = out
err := NodeWrite(testcase.context, nodes, types.Info{})
if err != nil {
assert.Error(t, err, testcase.expected)
} else {
assert.Equal(t, out.String(), testcase.expected)
}
}
}
func TestNodeContextWriteJSON(t *testing.T) {
nodes := []swarm.Node{
{ID: "nodeID1", Description: swarm.NodeDescription{Hostname: "foobar_baz"}},
{ID: "nodeID2", Description: swarm.NodeDescription{Hostname: "foobar_bar"}},
}
expectedJSONs := []map[string]interface{}{
{"Availability": "", "Hostname": "foobar_baz", "ID": "nodeID1", "ManagerStatus": "", "Status": ""},
{"Availability": "", "Hostname": "foobar_bar", "ID": "nodeID2", "ManagerStatus": "", "Status": ""},
}
out := bytes.NewBufferString("")
err := NodeWrite(Context{Format: "{{json .}}", Output: out}, nodes, types.Info{})
if err != nil {
t.Fatal(err)
}
for i, line := range strings.Split(strings.TrimSpace(out.String()), "\n") {
t.Logf("Output: line %d: %s", i, line)
var m map[string]interface{}
if err := json.Unmarshal([]byte(line), &m); err != nil {
t.Fatal(err)
}
assert.DeepEqual(t, m, expectedJSONs[i])
}
}
func TestNodeContextWriteJSONField(t *testing.T) {
nodes := []swarm.Node{
{ID: "nodeID1", Description: swarm.NodeDescription{Hostname: "foobar_baz"}},
{ID: "nodeID2", Description: swarm.NodeDescription{Hostname: "foobar_bar"}},
}
out := bytes.NewBufferString("")
err := NodeWrite(Context{Format: "{{json .ID}}", Output: out}, nodes, types.Info{})
if err != nil {
t.Fatal(err)
}
for i, line := range strings.Split(strings.TrimSpace(out.String()), "\n") {
t.Logf("Output: line %d: %s", i, line)
var s string
if err := json.Unmarshal([]byte(line), &s); err != nil {
t.Fatal(err)
}
assert.Equal(t, s, nodes[i].ID)
}
}

View File

@ -1,26 +1,19 @@
package node
import (
"fmt"
"io"
"text/tabwriter"
"golang.org/x/net/context"
"github.com/docker/docker/api/types"
"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/opts"
"github.com/spf13/cobra"
)
const (
listItemFmt = "%s\t%s\t%s\t%s\t%s\n"
)
type listOptions struct {
quiet bool
format string
filter opts.FilterOpt
}
@ -38,6 +31,7 @@ func newListCommand(dockerCli command.Cli) *cobra.Command {
}
flags := cmd.Flags()
flags.BoolVarP(&opts.quiet, "quiet", "q", false, "Only display IDs")
flags.StringVar(&opts.format, "format", "", "Pretty-print nodes using a Go template")
flags.VarP(&opts.filter, "filter", "f", "Filter output based on conditions provided")
return cmd
@ -45,7 +39,6 @@ func newListCommand(dockerCli command.Cli) *cobra.Command {
func runList(dockerCli command.Cli, opts listOptions) error {
client := dockerCli.Client()
out := dockerCli.Out()
ctx := context.Background()
nodes, err := client.NodeList(
@ -55,61 +48,26 @@ func runList(dockerCli command.Cli, opts listOptions) error {
return err
}
info := types.Info{}
if len(nodes) > 0 && !opts.quiet {
// only non-empty nodes and not quiet, should we call /info api
info, err := client.Info(ctx)
info, err = client.Info(ctx)
if err != nil {
return err
}
printTable(out, nodes, info)
} else if !opts.quiet {
// no nodes and not quiet, print only one line with columns ID, HOSTNAME, ...
printTable(out, nodes, types.Info{})
} else {
printQuiet(out, nodes)
}
return nil
}
func printTable(out io.Writer, nodes []swarm.Node, info types.Info) {
writer := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)
// Ignore flushing errors
defer writer.Flush()
fmt.Fprintf(writer, listItemFmt, "ID", "HOSTNAME", "STATUS", "AVAILABILITY", "MANAGER STATUS")
for _, node := range nodes {
name := node.Description.Hostname
availability := string(node.Spec.Availability)
reachability := ""
if node.ManagerStatus != nil {
if node.ManagerStatus.Leader {
reachability = "Leader"
} else {
reachability = string(node.ManagerStatus.Reachability)
format := opts.format
if len(format) == 0 {
format = formatter.TableFormatKey
if len(dockerCli.ConfigFile().NodesFormat) > 0 && !opts.quiet {
format = dockerCli.ConfigFile().NodesFormat
}
}
ID := node.ID
if node.ID == info.Swarm.NodeID {
ID = ID + " *"
}
fmt.Fprintf(
writer,
listItemFmt,
ID,
name,
command.PrettyPrint(string(node.Status.State)),
command.PrettyPrint(availability),
command.PrettyPrint(reachability))
}
}
func printQuiet(out io.Writer, nodes []swarm.Node) {
for _, node := range nodes {
fmt.Fprintln(out, node.ID)
nodesCtx := formatter.Context{
Output: dockerCli.Out(),
Format: formatter.NewNodeFormat(format, opts.quiet),
}
return formatter.NodeWrite(nodesCtx, nodes, info)
}

View File

@ -7,6 +7,7 @@ import (
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/swarm"
"github.com/docker/docker/cli/config/configfile"
"github.com/docker/docker/cli/internal/test"
"github.com/pkg/errors"
// Import builders to get the builder function as package function
@ -42,11 +43,12 @@ func TestNodeListErrorOnAPIFailure(t *testing.T) {
}
for _, tc := range testCases {
buf := new(bytes.Buffer)
cmd := newListCommand(
test.NewFakeCli(&fakeClient{
cli := test.NewFakeCli(&fakeClient{
nodeListFunc: tc.nodeListFunc,
infoFunc: tc.infoFunc,
}, buf))
}, buf)
cli.SetConfigfile(&configfile.ConfigFile{})
cmd := newListCommand(cli)
cmd.SetOutput(ioutil.Discard)
assert.Error(t, cmd.Execute(), tc.expectedError)
}
@ -54,8 +56,7 @@ func TestNodeListErrorOnAPIFailure(t *testing.T) {
func TestNodeList(t *testing.T) {
buf := new(bytes.Buffer)
cmd := newListCommand(
test.NewFakeCli(&fakeClient{
cli := test.NewFakeCli(&fakeClient{
nodeListFunc: func() ([]swarm.Node, error) {
return []swarm.Node{
*Node(NodeID("nodeID1"), Hostname("nodeHostname1"), Manager(Leader())),
@ -70,7 +71,9 @@ func TestNodeList(t *testing.T) {
},
}, nil
},
}, buf))
}, buf)
cli.SetConfigfile(&configfile.ConfigFile{})
cmd := newListCommand(cli)
assert.NilError(t, cmd.Execute())
assert.Contains(t, buf.String(), `nodeID1 * nodeHostname1 Ready Active Leader`)
assert.Contains(t, buf.String(), `nodeID2 nodeHostname2 Ready Active Reachable`)
@ -79,14 +82,15 @@ func TestNodeList(t *testing.T) {
func TestNodeListQuietShouldOnlyPrintIDs(t *testing.T) {
buf := new(bytes.Buffer)
cmd := newListCommand(
test.NewFakeCli(&fakeClient{
cli := test.NewFakeCli(&fakeClient{
nodeListFunc: func() ([]swarm.Node, error) {
return []swarm.Node{
*Node(),
}, nil
},
}, buf))
}, buf)
cli.SetConfigfile(&configfile.ConfigFile{})
cmd := newListCommand(cli)
cmd.Flags().Set("quiet", "true")
assert.NilError(t, cmd.Execute())
assert.Contains(t, buf.String(), "nodeID")
@ -95,7 +99,64 @@ func TestNodeListQuietShouldOnlyPrintIDs(t *testing.T) {
// Test case for #24090
func TestNodeListContainsHostname(t *testing.T) {
buf := new(bytes.Buffer)
cmd := newListCommand(test.NewFakeCli(&fakeClient{}, buf))
cli := test.NewFakeCli(&fakeClient{}, buf)
cli.SetConfigfile(&configfile.ConfigFile{})
cmd := newListCommand(cli)
assert.NilError(t, cmd.Execute())
assert.Contains(t, buf.String(), "HOSTNAME")
}
func TestNodeListDefaultFormat(t *testing.T) {
buf := new(bytes.Buffer)
cli := test.NewFakeCli(&fakeClient{
nodeListFunc: func() ([]swarm.Node, error) {
return []swarm.Node{
*Node(NodeID("nodeID1"), Hostname("nodeHostname1"), Manager(Leader())),
*Node(NodeID("nodeID2"), Hostname("nodeHostname2"), Manager()),
*Node(NodeID("nodeID3"), Hostname("nodeHostname3")),
}, nil
},
infoFunc: func() (types.Info, error) {
return types.Info{
Swarm: swarm.Info{
NodeID: "nodeID1",
},
}, nil
},
}, buf)
cli.SetConfigfile(&configfile.ConfigFile{
NodesFormat: "{{.ID}}: {{.Hostname}} {{.Status}}/{{.ManagerStatus}}",
})
cmd := newListCommand(cli)
assert.NilError(t, cmd.Execute())
assert.Contains(t, buf.String(), `nodeID1 *: nodeHostname1 Ready/Leader`)
assert.Contains(t, buf.String(), `nodeID2: nodeHostname2 Ready/Reachable`)
assert.Contains(t, buf.String(), `nodeID3: nodeHostname3 Ready`)
}
func TestNodeListFormat(t *testing.T) {
buf := new(bytes.Buffer)
cli := test.NewFakeCli(&fakeClient{
nodeListFunc: func() ([]swarm.Node, error) {
return []swarm.Node{
*Node(NodeID("nodeID1"), Hostname("nodeHostname1"), Manager(Leader())),
*Node(NodeID("nodeID2"), Hostname("nodeHostname2"), Manager()),
}, nil
},
infoFunc: func() (types.Info, error) {
return types.Info{
Swarm: swarm.Info{
NodeID: "nodeID1",
},
}, nil
},
}, buf)
cli.SetConfigfile(&configfile.ConfigFile{
NodesFormat: "{{.ID}}: {{.Hostname}} {{.Status}}/{{.ManagerStatus}}",
})
cmd := newListCommand(cli)
cmd.Flags().Set("format", "{{.Hostname}}: {{.ManagerStatus}}")
assert.NilError(t, cmd.Execute())
assert.Contains(t, buf.String(), `nodeHostname1: Leader`)
assert.Contains(t, buf.String(), `nodeHostname2: Reachable`)
}

View File

@ -38,6 +38,7 @@ type ConfigFile struct {
ServicesFormat string `json:"servicesFormat,omitempty"`
TasksFormat string `json:"tasksFormat,omitempty"`
SecretFormat string `json:"secretFormat,omitempty"`
NodesFormat string `json:"nodesFormat,omitempty"`
}
// LegacyLoadFromReader reads the non-nested configuration data given and sets up the