From 32e21ecbfe3af9a8b5762ad1a549518221c4939e Mon Sep 17 00:00:00 2001 From: Yong Tang Date: Tue, 24 Jan 2017 13:17:40 -0800 Subject: [PATCH] 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 --- cli/command/formatter/node.go | 99 ++++++++++++++ cli/command/formatter/node_test.go | 188 ++++++++++++++++++++++++++ cli/command/node/list.go | 70 ++-------- cli/command/node/list_test.go | 129 +++++++++++++----- cli/config/configfile/file.go | 1 + docs/reference/commandline/cli.md | 6 + docs/reference/commandline/node_ls.md | 39 +++++- 7 files changed, 439 insertions(+), 93 deletions(-) create mode 100644 cli/command/formatter/node.go create mode 100644 cli/command/formatter/node_test.go diff --git a/cli/command/formatter/node.go b/cli/command/formatter/node.go new file mode 100644 index 0000000000..bd478e57f2 --- /dev/null +++ b/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) +} diff --git a/cli/command/formatter/node_test.go b/cli/command/formatter/node_test.go new file mode 100644 index 0000000000..e3e341fc8b --- /dev/null +++ b/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 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) + } +} diff --git a/cli/command/node/list.go b/cli/command/node/list.go index d166401ab7..9c6224dd19 100644 --- a/cli/command/node/list.go +++ b/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) - } + 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) } diff --git a/cli/command/node/list_test.go b/cli/command/node/list_test.go index 7b657cd73c..13a21d1b52 100644 --- a/cli/command/node/list_test.go +++ b/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`) +} diff --git a/cli/config/configfile/file.go b/cli/config/configfile/file.go index cc1c3d0d54..f0f6924049 100644 --- a/cli/config/configfile/file.go +++ b/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 diff --git a/docs/reference/commandline/cli.md b/docs/reference/commandline/cli.md index c290e61d5c..0d48d19725 100644 --- a/docs/reference/commandline/cli.md +++ b/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": { diff --git a/docs/reference/commandline/node_ls.md b/docs/reference/commandline/node_ls.md index 836fceaea2..ad2ffcd050 100644 --- a/docs/reference/commandline/node_ls.md +++ b/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)