瀏覽代碼

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>
Yong Tang 8 年之前
父節點
當前提交
32e21ecbfe

+ 99 - 0
cli/command/formatter/node.go

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

+ 188 - 0
cli/command/formatter/node_test.go

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

+ 14 - 56
cli/command/node/list.go

@@ -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)
-			}
-		}
-
-		ID := node.ID
-		if node.ID == info.Swarm.NodeID {
-			ID = ID + " *"
+	format := opts.format
+	if len(format) == 0 {
+		format = formatter.TableFormatKey
+		if len(dockerCli.ConfigFile().NodesFormat) > 0 && !opts.quiet {
+			format = dockerCli.ConfigFile().NodesFormat
 		}
-
-		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)
 }

+ 95 - 34
cli/command/node/list_test.go

@@ -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{
-				nodeListFunc: tc.nodeListFunc,
-				infoFunc:     tc.infoFunc,
-			}, buf))
+		cli := test.NewFakeCli(&fakeClient{
+			nodeListFunc: tc.nodeListFunc,
+			infoFunc:     tc.infoFunc,
+		}, buf)
+		cli.SetConfigfile(&configfile.ConfigFile{})
+		cmd := newListCommand(cli)
 		cmd.SetOutput(ioutil.Discard)
 		assert.Error(t, cmd.Execute(), tc.expectedError)
 	}
@@ -54,39 +56,41 @@ func TestNodeListErrorOnAPIFailure(t *testing.T) {
 
 func TestNodeList(t *testing.T) {
 	buf := new(bytes.Buffer)
-	cmd := newListCommand(
-		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 := 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{})
+	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`)
-	assert.Contains(t, buf.String(), `nodeID3    nodeHostname3  Ready   Active`)
+	assert.Contains(t, buf.String(), `nodeID1 *           nodeHostname1       Ready               Active              Leader`)
+	assert.Contains(t, buf.String(), `nodeID2             nodeHostname2       Ready               Active              Reachable`)
+	assert.Contains(t, buf.String(), `nodeID3             nodeHostname3       Ready               Active`)
 }
 
 func TestNodeListQuietShouldOnlyPrintIDs(t *testing.T) {
 	buf := new(bytes.Buffer)
-	cmd := newListCommand(
-		test.NewFakeCli(&fakeClient{
-			nodeListFunc: func() ([]swarm.Node, error) {
-				return []swarm.Node{
-					*Node(),
-				}, nil
-			},
-		}, buf))
+	cli := test.NewFakeCli(&fakeClient{
+		nodeListFunc: func() ([]swarm.Node, error) {
+			return []swarm.Node{
+				*Node(),
+			}, nil
+		},
+	}, 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`)
+}

+ 1 - 0
cli/config/configfile/file.go

@@ -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

+ 6 - 0
docs/reference/commandline/cli.md

@@ -167,6 +167,11 @@ property is not set, the client falls back to the default table
 format. For a list of supported formatting directives, see
 [**Formatting** section in the `docker secret ls` documentation](secret_ls.md)
 
+The property `nodesFormat` specifies the default format for `docker node ls` output.
+When the `--format` flag is not provided with the `docker node ls` command,
+Docker's client uses this property. If this property is not set, the client
+falls back to the default table format. For a list of supported formatting
+directives, see the [**Formatting** section in the `docker node ls` documentation](node_ls.md)
 
 The property `credsStore` specifies an external binary to serve as the default
 credential store. When this property is set, `docker login` will attempt to
@@ -214,6 +219,7 @@ Following is a sample `config.json` file:
   "servicesFormat": "table {{.ID}}\t{{.Name}}\t{{.Mode}}",
   "secretFormat": "table {{.ID}}\t{{.Name}}\t{{.CreatedAt}}\t{{.UpdatedAt}}",
   "serviceInspectFormat": "pretty",
+  "nodesFormat": "table {{.ID}}\t{{.Hostname}}\t{{.Availability}}",
   "detachKeys": "ctrl-e,e",
   "credsStore": "secretservice",
   "credHelpers": {

+ 36 - 3
docs/reference/commandline/node_ls.md

@@ -24,9 +24,10 @@ Aliases:
   ls, list
 
 Options:
-  -f, --filter value   Filter output based on conditions provided
-      --help           Print usage
-  -q, --quiet          Only display IDs
+  -f, --filter filter   Filter output based on conditions provided
+      --format string   Pretty-print nodes using a Go template
+      --help            Print usage
+  -q, --quiet           Only display IDs
 ```
 
 ## Description
@@ -45,6 +46,10 @@ ID                           HOSTNAME        STATUS  AVAILABILITY  MANAGER STATU
 38ciaotwjuritcdtn9npbnkuz    swarm-worker1   Ready   Active
 e216jshn25ckzbvmwlnh5jr3g *  swarm-manager1  Ready   Active        Leader
 ```
+> **Note:**
+> If the `ID` field of the node is followed by a `*` (e.g., `e216jshn25ckzbvmwlnh5jr3g *`)
+> in the above example output, then this node is also the node of the current docker daemon.
+
 
 ### Filtering
 
@@ -124,6 +129,34 @@ ID                           HOSTNAME        STATUS  AVAILABILITY  MANAGER STATU
 e216jshn25ckzbvmwlnh5jr3g *  swarm-manager1  Ready   Active        Leader
 ```
 
+### Formatting
+
+The formatting options (`--format`) pretty-prints nodes output
+using a Go template.
+
+Valid placeholders for the Go template are listed below:
+
+Placeholder      | Description
+-----------------|------------------------------------------------------------------------------------------
+`.ID`            | Node ID
+`.Hostname`      | Node hostname
+`.Status`        | Node status
+`.Availability`  | Node availability ("active", "pause", or "drain")
+`.ManagerStatus` | Manager status of the node
+
+When using the `--format` option, the `node ls` command will either
+output 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
+`ID` and `Hostname` entries separated by a colon for all nodes:
+
+```bash
+$ docker node ls --format "{{.ID}}: {{.Hostname}}"
+e216jshn25ckzbvmwlnh5jr3g *: swarm-manager1
+``
+
+
 ## Related commands
 
 * [node demote](node_demote.md)