Browse Source

Merge pull request #29143 from vdemeester/node-cli-unit-tests

Add some unit tests to the node and swarm cli code
Sebastiaan van Stijn 8 years ago
parent
commit
38f766ae0e
60 changed files with 2512 additions and 147 deletions
  1. 9 1
      cli/command/cli.go
  2. 68 0
      cli/command/node/client_test.go
  3. 2 2
      cli/command/node/demote.go
  4. 88 0
      cli/command/node/demote_test.go
  5. 2 2
      cli/command/node/inspect.go
  6. 122 0
      cli/command/node/inspect_test.go
  7. 2 2
      cli/command/node/list.go
  8. 101 0
      cli/command/node/list_test.go
  9. 0 36
      cli/command/node/opts.go
  10. 2 2
      cli/command/node/promote.go
  11. 88 0
      cli/command/node/promote_test.go
  12. 2 2
      cli/command/node/ps.go
  13. 132 0
      cli/command/node/ps_test.go
  14. 2 2
      cli/command/node/remove.go
  15. 47 0
      cli/command/node/remove_test.go
  16. 25 0
      cli/command/node/testdata/node-inspect-pretty.manager-leader.golden
  17. 25 0
      cli/command/node/testdata/node-inspect-pretty.manager.golden
  18. 23 0
      cli/command/node/testdata/node-inspect-pretty.simple.golden
  19. 2 0
      cli/command/node/testdata/node-ps.simple.golden
  20. 4 0
      cli/command/node/testdata/node-ps.with-errors.golden
  21. 3 3
      cli/command/node/update.go
  22. 172 0
      cli/command/node/update_test.go
  23. 84 0
      cli/command/swarm/client_test.go
  24. 3 3
      cli/command/swarm/init.go
  25. 129 0
      cli/command/swarm/init_test.go
  26. 2 2
      cli/command/swarm/join.go
  27. 102 0
      cli/command/swarm/join_test.go
  28. 3 3
      cli/command/swarm/join_token.go
  29. 215 0
      cli/command/swarm/join_token_test.go
  30. 2 2
      cli/command/swarm/leave.go
  31. 52 0
      cli/command/swarm/leave_test.go
  32. 73 0
      cli/command/swarm/opts_test.go
  33. 11 0
      cli/command/swarm/testdata/init-init-autolock.golden
  34. 4 0
      cli/command/swarm/testdata/init-init.golden
  35. 1 0
      cli/command/swarm/testdata/jointoken-manager-quiet.golden
  36. 8 0
      cli/command/swarm/testdata/jointoken-manager-rotate.golden
  37. 6 0
      cli/command/swarm/testdata/jointoken-manager.golden
  38. 1 0
      cli/command/swarm/testdata/jointoken-worker-quiet.golden
  39. 6 0
      cli/command/swarm/testdata/jointoken-worker.golden
  40. 1 0
      cli/command/swarm/testdata/unlockkeys-unlock-key-quiet.golden
  41. 1 0
      cli/command/swarm/testdata/unlockkeys-unlock-key-rotate-quiet.golden
  42. 9 0
      cli/command/swarm/testdata/unlockkeys-unlock-key-rotate.golden
  43. 7 0
      cli/command/swarm/testdata/unlockkeys-unlock-key.golden
  44. 1 0
      cli/command/swarm/testdata/update-all-flags-quiet.golden
  45. 8 0
      cli/command/swarm/testdata/update-autolock-unlock-key.golden
  46. 13 0
      cli/command/swarm/testdata/update-noargs.golden
  47. 2 2
      cli/command/swarm/unlock.go
  48. 4 5
      cli/command/swarm/unlock_key.go
  49. 175 0
      cli/command/swarm/unlock_key_test.go
  50. 101 0
      cli/command/swarm/unlock_test.go
  51. 7 7
      cli/command/swarm/update.go
  52. 182 0
      cli/command/swarm/update_test.go
  53. 2 2
      cli/command/task/print.go
  54. 117 0
      cli/internal/test/builders/node.go
  55. 39 0
      cli/internal/test/builders/swarm.go
  56. 111 0
      cli/internal/test/builders/task.go
  57. 48 0
      cli/internal/test/cli.go
  58. 13 69
      integration-cli/docker_cli_swarm_test.go
  59. 20 0
      pkg/testutil/assert/assert.go
  60. 28 0
      pkg/testutil/golden/golden.go

+ 9 - 1
cli/command/cli.go

@@ -32,7 +32,15 @@ type Streams interface {
 	Err() io.Writer
 	Err() io.Writer
 }
 }
 
 
-// DockerCli represents the docker command line client.
+// Cli represents the docker command line client.
+type Cli interface {
+	Client() client.APIClient
+	Out() *OutStream
+	Err() io.Writer
+	In() *InStream
+}
+
+// DockerCli is an instance the docker command line client.
 // Instances of the client can be returned from NewDockerCli.
 // Instances of the client can be returned from NewDockerCli.
 type DockerCli struct {
 type DockerCli struct {
 	configFile      *configfile.ConfigFile
 	configFile      *configfile.ConfigFile

+ 68 - 0
cli/command/node/client_test.go

@@ -0,0 +1,68 @@
+package node
+
+import (
+	"github.com/docker/docker/api/types"
+	"github.com/docker/docker/api/types/swarm"
+	"github.com/docker/docker/client"
+	"golang.org/x/net/context"
+)
+
+type fakeClient struct {
+	client.Client
+	infoFunc        func() (types.Info, error)
+	nodeInspectFunc func() (swarm.Node, []byte, error)
+	nodeListFunc    func() ([]swarm.Node, error)
+	nodeRemoveFunc  func() error
+	nodeUpdateFunc  func(nodeID string, version swarm.Version, node swarm.NodeSpec) error
+	taskInspectFunc func(taskID string) (swarm.Task, []byte, error)
+	taskListFunc    func(options types.TaskListOptions) ([]swarm.Task, error)
+}
+
+func (cli *fakeClient) NodeInspectWithRaw(ctx context.Context, ref string) (swarm.Node, []byte, error) {
+	if cli.nodeInspectFunc != nil {
+		return cli.nodeInspectFunc()
+	}
+	return swarm.Node{}, []byte{}, nil
+}
+
+func (cli *fakeClient) NodeList(ctx context.Context, options types.NodeListOptions) ([]swarm.Node, error) {
+	if cli.nodeListFunc != nil {
+		return cli.nodeListFunc()
+	}
+	return []swarm.Node{}, nil
+}
+
+func (cli *fakeClient) NodeRemove(ctx context.Context, nodeID string, options types.NodeRemoveOptions) error {
+	if cli.nodeRemoveFunc != nil {
+		return cli.nodeRemoveFunc()
+	}
+	return nil
+}
+
+func (cli *fakeClient) NodeUpdate(ctx context.Context, nodeID string, version swarm.Version, node swarm.NodeSpec) error {
+	if cli.nodeUpdateFunc != nil {
+		return cli.nodeUpdateFunc(nodeID, version, node)
+	}
+	return nil
+}
+
+func (cli *fakeClient) Info(ctx context.Context) (types.Info, error) {
+	if cli.infoFunc != nil {
+		return cli.infoFunc()
+	}
+	return types.Info{}, nil
+}
+
+func (cli *fakeClient) TaskInspectWithRaw(ctx context.Context, taskID string) (swarm.Task, []byte, error) {
+	if cli.taskInspectFunc != nil {
+		return cli.taskInspectFunc(taskID)
+	}
+	return swarm.Task{}, []byte{}, nil
+}
+
+func (cli *fakeClient) TaskList(ctx context.Context, options types.TaskListOptions) ([]swarm.Task, error) {
+	if cli.taskListFunc != nil {
+		return cli.taskListFunc(options)
+	}
+	return []swarm.Task{}, nil
+}

+ 2 - 2
cli/command/node/demote.go

@@ -9,7 +9,7 @@ import (
 	"github.com/spf13/cobra"
 	"github.com/spf13/cobra"
 )
 )
 
 
-func newDemoteCommand(dockerCli *command.DockerCli) *cobra.Command {
+func newDemoteCommand(dockerCli command.Cli) *cobra.Command {
 	return &cobra.Command{
 	return &cobra.Command{
 		Use:   "demote NODE [NODE...]",
 		Use:   "demote NODE [NODE...]",
 		Short: "Demote one or more nodes from manager in the swarm",
 		Short: "Demote one or more nodes from manager in the swarm",
@@ -20,7 +20,7 @@ func newDemoteCommand(dockerCli *command.DockerCli) *cobra.Command {
 	}
 	}
 }
 }
 
 
-func runDemote(dockerCli *command.DockerCli, nodes []string) error {
+func runDemote(dockerCli command.Cli, nodes []string) error {
 	demote := func(node *swarm.Node) error {
 	demote := func(node *swarm.Node) error {
 		if node.Spec.Role == swarm.NodeRoleWorker {
 		if node.Spec.Role == swarm.NodeRoleWorker {
 			fmt.Fprintf(dockerCli.Out(), "Node %s is already a worker.\n", node.ID)
 			fmt.Fprintf(dockerCli.Out(), "Node %s is already a worker.\n", node.ID)

+ 88 - 0
cli/command/node/demote_test.go

@@ -0,0 +1,88 @@
+package node
+
+import (
+	"bytes"
+	"fmt"
+	"io/ioutil"
+	"testing"
+
+	"github.com/docker/docker/api/types/swarm"
+	"github.com/docker/docker/cli/internal/test"
+	// Import builders to get the builder function as package function
+	. "github.com/docker/docker/cli/internal/test/builders"
+	"github.com/docker/docker/pkg/testutil/assert"
+)
+
+func TestNodeDemoteErrors(t *testing.T) {
+	testCases := []struct {
+		args            []string
+		nodeInspectFunc func() (swarm.Node, []byte, error)
+		nodeUpdateFunc  func(nodeID string, version swarm.Version, node swarm.NodeSpec) error
+		expectedError   string
+	}{
+		{
+			expectedError: "requires at least 1 argument",
+		},
+		{
+			args: []string{"nodeID"},
+			nodeInspectFunc: func() (swarm.Node, []byte, error) {
+				return swarm.Node{}, []byte{}, fmt.Errorf("error inspecting the node")
+			},
+			expectedError: "error inspecting the node",
+		},
+		{
+			args: []string{"nodeID"},
+			nodeUpdateFunc: func(nodeID string, version swarm.Version, node swarm.NodeSpec) error {
+				return fmt.Errorf("error updating the node")
+			},
+			expectedError: "error updating the node",
+		},
+	}
+	for _, tc := range testCases {
+		buf := new(bytes.Buffer)
+		cmd := newDemoteCommand(
+			test.NewFakeCli(&fakeClient{
+				nodeInspectFunc: tc.nodeInspectFunc,
+				nodeUpdateFunc:  tc.nodeUpdateFunc,
+			}, buf))
+		cmd.SetArgs(tc.args)
+		cmd.SetOutput(ioutil.Discard)
+		assert.Error(t, cmd.Execute(), tc.expectedError)
+	}
+}
+
+func TestNodeDemoteNoChange(t *testing.T) {
+	buf := new(bytes.Buffer)
+	cmd := newDemoteCommand(
+		test.NewFakeCli(&fakeClient{
+			nodeInspectFunc: func() (swarm.Node, []byte, error) {
+				return *Node(), []byte{}, nil
+			},
+			nodeUpdateFunc: func(nodeID string, version swarm.Version, node swarm.NodeSpec) error {
+				if node.Role != swarm.NodeRoleWorker {
+					return fmt.Errorf("expected role worker, got %s", node.Role)
+				}
+				return nil
+			},
+		}, buf))
+	cmd.SetArgs([]string{"nodeID"})
+	assert.NilError(t, cmd.Execute())
+}
+
+func TestNodeDemoteMultipleNode(t *testing.T) {
+	buf := new(bytes.Buffer)
+	cmd := newDemoteCommand(
+		test.NewFakeCli(&fakeClient{
+			nodeInspectFunc: func() (swarm.Node, []byte, error) {
+				return *Node(Manager()), []byte{}, nil
+			},
+			nodeUpdateFunc: func(nodeID string, version swarm.Version, node swarm.NodeSpec) error {
+				if node.Role != swarm.NodeRoleWorker {
+					return fmt.Errorf("expected role worker, got %s", node.Role)
+				}
+				return nil
+			},
+		}, buf))
+	cmd.SetArgs([]string{"nodeID1", "nodeID2"})
+	assert.NilError(t, cmd.Execute())
+}

+ 2 - 2
cli/command/node/inspect.go

@@ -22,7 +22,7 @@ type inspectOptions struct {
 	pretty  bool
 	pretty  bool
 }
 }
 
 
-func newInspectCommand(dockerCli *command.DockerCli) *cobra.Command {
+func newInspectCommand(dockerCli command.Cli) *cobra.Command {
 	var opts inspectOptions
 	var opts inspectOptions
 
 
 	cmd := &cobra.Command{
 	cmd := &cobra.Command{
@@ -41,7 +41,7 @@ func newInspectCommand(dockerCli *command.DockerCli) *cobra.Command {
 	return cmd
 	return cmd
 }
 }
 
 
-func runInspect(dockerCli *command.DockerCli, opts inspectOptions) error {
+func runInspect(dockerCli command.Cli, opts inspectOptions) error {
 	client := dockerCli.Client()
 	client := dockerCli.Client()
 	ctx := context.Background()
 	ctx := context.Background()
 	getRef := func(ref string) (interface{}, []byte, error) {
 	getRef := func(ref string) (interface{}, []byte, error) {

+ 122 - 0
cli/command/node/inspect_test.go

@@ -0,0 +1,122 @@
+package node
+
+import (
+	"bytes"
+	"fmt"
+	"io/ioutil"
+	"testing"
+
+	"github.com/docker/docker/api/types"
+	"github.com/docker/docker/api/types/swarm"
+	"github.com/docker/docker/cli/internal/test"
+	// Import builders to get the builder function as package function
+	. "github.com/docker/docker/cli/internal/test/builders"
+	"github.com/docker/docker/pkg/testutil/assert"
+	"github.com/docker/docker/pkg/testutil/golden"
+)
+
+func TestNodeInspectErrors(t *testing.T) {
+	testCases := []struct {
+		args            []string
+		flags           map[string]string
+		nodeInspectFunc func() (swarm.Node, []byte, error)
+		infoFunc        func() (types.Info, error)
+		expectedError   string
+	}{
+		{
+			expectedError: "requires at least 1 argument",
+		},
+		{
+			args: []string{"self"},
+			infoFunc: func() (types.Info, error) {
+				return types.Info{}, fmt.Errorf("error asking for node info")
+			},
+			expectedError: "error asking for node info",
+		},
+		{
+			args: []string{"nodeID"},
+			nodeInspectFunc: func() (swarm.Node, []byte, error) {
+				return swarm.Node{}, []byte{}, fmt.Errorf("error inspecting the node")
+			},
+			infoFunc: func() (types.Info, error) {
+				return types.Info{}, fmt.Errorf("error asking for node info")
+			},
+			expectedError: "error inspecting the node",
+		},
+		{
+			args: []string{"self"},
+			nodeInspectFunc: func() (swarm.Node, []byte, error) {
+				return swarm.Node{}, []byte{}, fmt.Errorf("error inspecting the node")
+			},
+			infoFunc: func() (types.Info, error) {
+				return types.Info{}, nil
+			},
+			expectedError: "error inspecting the node",
+		},
+		{
+			args: []string{"self"},
+			flags: map[string]string{
+				"pretty": "true",
+			},
+			infoFunc: func() (types.Info, error) {
+				return types.Info{}, fmt.Errorf("error asking for node info")
+			},
+			expectedError: "error asking for node info",
+		},
+	}
+	for _, tc := range testCases {
+		buf := new(bytes.Buffer)
+		cmd := newInspectCommand(
+			test.NewFakeCli(&fakeClient{
+				nodeInspectFunc: tc.nodeInspectFunc,
+				infoFunc:        tc.infoFunc,
+			}, buf))
+		cmd.SetArgs(tc.args)
+		for key, value := range tc.flags {
+			cmd.Flags().Set(key, value)
+		}
+		cmd.SetOutput(ioutil.Discard)
+		assert.Error(t, cmd.Execute(), tc.expectedError)
+	}
+}
+
+func TestNodeInspectPretty(t *testing.T) {
+	testCases := []struct {
+		name            string
+		nodeInspectFunc func() (swarm.Node, []byte, error)
+	}{
+		{
+			name: "simple",
+			nodeInspectFunc: func() (swarm.Node, []byte, error) {
+				return *Node(NodeLabels(map[string]string{
+					"lbl1": "value1",
+				})), []byte{}, nil
+			},
+		},
+		{
+			name: "manager",
+			nodeInspectFunc: func() (swarm.Node, []byte, error) {
+				return *Node(Manager()), []byte{}, nil
+			},
+		},
+		{
+			name: "manager-leader",
+			nodeInspectFunc: func() (swarm.Node, []byte, error) {
+				return *Node(Manager(Leader())), []byte{}, nil
+			},
+		},
+	}
+	for _, tc := range testCases {
+		buf := new(bytes.Buffer)
+		cmd := newInspectCommand(
+			test.NewFakeCli(&fakeClient{
+				nodeInspectFunc: tc.nodeInspectFunc,
+			}, buf))
+		cmd.SetArgs([]string{"nodeID"})
+		cmd.Flags().Set("pretty", "true")
+		assert.NilError(t, cmd.Execute())
+		actual := buf.String()
+		expected := golden.Get(t, []byte(actual), fmt.Sprintf("node-inspect-pretty.%s.golden", tc.name))
+		assert.EqualNormalizedString(t, assert.RemoveSpace, actual, string(expected))
+	}
+}

+ 2 - 2
cli/command/node/list.go

@@ -24,7 +24,7 @@ type listOptions struct {
 	filter opts.FilterOpt
 	filter opts.FilterOpt
 }
 }
 
 
-func newListCommand(dockerCli *command.DockerCli) *cobra.Command {
+func newListCommand(dockerCli command.Cli) *cobra.Command {
 	opts := listOptions{filter: opts.NewFilterOpt()}
 	opts := listOptions{filter: opts.NewFilterOpt()}
 
 
 	cmd := &cobra.Command{
 	cmd := &cobra.Command{
@@ -43,7 +43,7 @@ func newListCommand(dockerCli *command.DockerCli) *cobra.Command {
 	return cmd
 	return cmd
 }
 }
 
 
-func runList(dockerCli *command.DockerCli, opts listOptions) error {
+func runList(dockerCli command.Cli, opts listOptions) error {
 	client := dockerCli.Client()
 	client := dockerCli.Client()
 	out := dockerCli.Out()
 	out := dockerCli.Out()
 	ctx := context.Background()
 	ctx := context.Background()

+ 101 - 0
cli/command/node/list_test.go

@@ -0,0 +1,101 @@
+package node
+
+import (
+	"bytes"
+	"fmt"
+	"io/ioutil"
+	"testing"
+
+	"github.com/docker/docker/api/types"
+	"github.com/docker/docker/api/types/swarm"
+	"github.com/docker/docker/cli/internal/test"
+	// Import builders to get the builder function as package function
+	. "github.com/docker/docker/cli/internal/test/builders"
+	"github.com/docker/docker/pkg/testutil/assert"
+)
+
+func TestNodeListErrorOnAPIFailure(t *testing.T) {
+	testCases := []struct {
+		nodeListFunc  func() ([]swarm.Node, error)
+		infoFunc      func() (types.Info, error)
+		expectedError string
+	}{
+		{
+			nodeListFunc: func() ([]swarm.Node, error) {
+				return []swarm.Node{}, fmt.Errorf("error listing nodes")
+			},
+			expectedError: "error listing nodes",
+		},
+		{
+			nodeListFunc: func() ([]swarm.Node, error) {
+				return []swarm.Node{
+					{
+						ID: "nodeID",
+					},
+				}, nil
+			},
+			infoFunc: func() (types.Info, error) {
+				return types.Info{}, fmt.Errorf("error asking for node info")
+			},
+			expectedError: "error asking for node info",
+		},
+	}
+	for _, tc := range testCases {
+		buf := new(bytes.Buffer)
+		cmd := newListCommand(
+			test.NewFakeCli(&fakeClient{
+				nodeListFunc: tc.nodeListFunc,
+				infoFunc:     tc.infoFunc,
+			}, buf))
+		cmd.SetOutput(ioutil.Discard)
+		assert.Error(t, cmd.Execute(), tc.expectedError)
+	}
+}
+
+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))
+	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`)
+}
+
+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))
+	cmd.Flags().Set("quiet", "true")
+	assert.NilError(t, cmd.Execute())
+	assert.Contains(t, buf.String(), "nodeID")
+}
+
+// Test case for #24090
+func TestNodeListContainsHostname(t *testing.T) {
+	buf := new(bytes.Buffer)
+	cmd := newListCommand(test.NewFakeCli(&fakeClient{}, buf))
+	assert.NilError(t, cmd.Execute())
+	assert.Contains(t, buf.String(), "HOSTNAME")
+}

+ 0 - 36
cli/command/node/opts.go

@@ -1,12 +1,7 @@
 package node
 package node
 
 
 import (
 import (
-	"fmt"
-	"strings"
-
-	"github.com/docker/docker/api/types/swarm"
 	"github.com/docker/docker/opts"
 	"github.com/docker/docker/opts"
-	runconfigopts "github.com/docker/docker/runconfig/opts"
 )
 )
 
 
 type nodeOptions struct {
 type nodeOptions struct {
@@ -27,34 +22,3 @@ func newNodeOptions() *nodeOptions {
 		},
 		},
 	}
 	}
 }
 }
-
-func (opts *nodeOptions) ToNodeSpec() (swarm.NodeSpec, error) {
-	var spec swarm.NodeSpec
-
-	spec.Annotations.Name = opts.annotations.name
-	spec.Annotations.Labels = runconfigopts.ConvertKVStringsToMap(opts.annotations.labels.GetAll())
-
-	switch swarm.NodeRole(strings.ToLower(opts.role)) {
-	case swarm.NodeRoleWorker:
-		spec.Role = swarm.NodeRoleWorker
-	case swarm.NodeRoleManager:
-		spec.Role = swarm.NodeRoleManager
-	case "":
-	default:
-		return swarm.NodeSpec{}, fmt.Errorf("invalid role %q, only worker and manager are supported", opts.role)
-	}
-
-	switch swarm.NodeAvailability(strings.ToLower(opts.availability)) {
-	case swarm.NodeAvailabilityActive:
-		spec.Availability = swarm.NodeAvailabilityActive
-	case swarm.NodeAvailabilityPause:
-		spec.Availability = swarm.NodeAvailabilityPause
-	case swarm.NodeAvailabilityDrain:
-		spec.Availability = swarm.NodeAvailabilityDrain
-	case "":
-	default:
-		return swarm.NodeSpec{}, fmt.Errorf("invalid availability %q, only active, pause and drain are supported", opts.availability)
-	}
-
-	return spec, nil
-}

+ 2 - 2
cli/command/node/promote.go

@@ -9,7 +9,7 @@ import (
 	"github.com/spf13/cobra"
 	"github.com/spf13/cobra"
 )
 )
 
 
-func newPromoteCommand(dockerCli *command.DockerCli) *cobra.Command {
+func newPromoteCommand(dockerCli command.Cli) *cobra.Command {
 	return &cobra.Command{
 	return &cobra.Command{
 		Use:   "promote NODE [NODE...]",
 		Use:   "promote NODE [NODE...]",
 		Short: "Promote one or more nodes to manager in the swarm",
 		Short: "Promote one or more nodes to manager in the swarm",
@@ -20,7 +20,7 @@ func newPromoteCommand(dockerCli *command.DockerCli) *cobra.Command {
 	}
 	}
 }
 }
 
 
-func runPromote(dockerCli *command.DockerCli, nodes []string) error {
+func runPromote(dockerCli command.Cli, nodes []string) error {
 	promote := func(node *swarm.Node) error {
 	promote := func(node *swarm.Node) error {
 		if node.Spec.Role == swarm.NodeRoleManager {
 		if node.Spec.Role == swarm.NodeRoleManager {
 			fmt.Fprintf(dockerCli.Out(), "Node %s is already a manager.\n", node.ID)
 			fmt.Fprintf(dockerCli.Out(), "Node %s is already a manager.\n", node.ID)

+ 88 - 0
cli/command/node/promote_test.go

@@ -0,0 +1,88 @@
+package node
+
+import (
+	"bytes"
+	"fmt"
+	"io/ioutil"
+	"testing"
+
+	"github.com/docker/docker/api/types/swarm"
+	"github.com/docker/docker/cli/internal/test"
+	// Import builders to get the builder function as package function
+	. "github.com/docker/docker/cli/internal/test/builders"
+	"github.com/docker/docker/pkg/testutil/assert"
+)
+
+func TestNodePromoteErrors(t *testing.T) {
+	testCases := []struct {
+		args            []string
+		nodeInspectFunc func() (swarm.Node, []byte, error)
+		nodeUpdateFunc  func(nodeID string, version swarm.Version, node swarm.NodeSpec) error
+		expectedError   string
+	}{
+		{
+			expectedError: "requires at least 1 argument",
+		},
+		{
+			args: []string{"nodeID"},
+			nodeInspectFunc: func() (swarm.Node, []byte, error) {
+				return swarm.Node{}, []byte{}, fmt.Errorf("error inspecting the node")
+			},
+			expectedError: "error inspecting the node",
+		},
+		{
+			args: []string{"nodeID"},
+			nodeUpdateFunc: func(nodeID string, version swarm.Version, node swarm.NodeSpec) error {
+				return fmt.Errorf("error updating the node")
+			},
+			expectedError: "error updating the node",
+		},
+	}
+	for _, tc := range testCases {
+		buf := new(bytes.Buffer)
+		cmd := newPromoteCommand(
+			test.NewFakeCli(&fakeClient{
+				nodeInspectFunc: tc.nodeInspectFunc,
+				nodeUpdateFunc:  tc.nodeUpdateFunc,
+			}, buf))
+		cmd.SetArgs(tc.args)
+		cmd.SetOutput(ioutil.Discard)
+		assert.Error(t, cmd.Execute(), tc.expectedError)
+	}
+}
+
+func TestNodePromoteNoChange(t *testing.T) {
+	buf := new(bytes.Buffer)
+	cmd := newPromoteCommand(
+		test.NewFakeCli(&fakeClient{
+			nodeInspectFunc: func() (swarm.Node, []byte, error) {
+				return *Node(Manager()), []byte{}, nil
+			},
+			nodeUpdateFunc: func(nodeID string, version swarm.Version, node swarm.NodeSpec) error {
+				if node.Role != swarm.NodeRoleManager {
+					return fmt.Errorf("expected role manager, got %s", node.Role)
+				}
+				return nil
+			},
+		}, buf))
+	cmd.SetArgs([]string{"nodeID"})
+	assert.NilError(t, cmd.Execute())
+}
+
+func TestNodePromoteMultipleNode(t *testing.T) {
+	buf := new(bytes.Buffer)
+	cmd := newPromoteCommand(
+		test.NewFakeCli(&fakeClient{
+			nodeInspectFunc: func() (swarm.Node, []byte, error) {
+				return *Node(), []byte{}, nil
+			},
+			nodeUpdateFunc: func(nodeID string, version swarm.Version, node swarm.NodeSpec) error {
+				if node.Role != swarm.NodeRoleManager {
+					return fmt.Errorf("expected role manager, got %s", node.Role)
+				}
+				return nil
+			},
+		}, buf))
+	cmd.SetArgs([]string{"nodeID1", "nodeID2"})
+	assert.NilError(t, cmd.Execute())
+}

+ 2 - 2
cli/command/node/ps.go

@@ -22,7 +22,7 @@ type psOptions struct {
 	filter    opts.FilterOpt
 	filter    opts.FilterOpt
 }
 }
 
 
-func newPsCommand(dockerCli *command.DockerCli) *cobra.Command {
+func newPsCommand(dockerCli command.Cli) *cobra.Command {
 	opts := psOptions{filter: opts.NewFilterOpt()}
 	opts := psOptions{filter: opts.NewFilterOpt()}
 
 
 	cmd := &cobra.Command{
 	cmd := &cobra.Command{
@@ -47,7 +47,7 @@ func newPsCommand(dockerCli *command.DockerCli) *cobra.Command {
 	return cmd
 	return cmd
 }
 }
 
 
-func runPs(dockerCli *command.DockerCli, opts psOptions) error {
+func runPs(dockerCli command.Cli, opts psOptions) error {
 	client := dockerCli.Client()
 	client := dockerCli.Client()
 	ctx := context.Background()
 	ctx := context.Background()
 
 

+ 132 - 0
cli/command/node/ps_test.go

@@ -0,0 +1,132 @@
+package node
+
+import (
+	"bytes"
+	"fmt"
+	"io/ioutil"
+	"testing"
+	"time"
+
+	"github.com/docker/docker/api/types"
+	"github.com/docker/docker/api/types/swarm"
+	"github.com/docker/docker/cli/internal/test"
+	// Import builders to get the builder function as package function
+	. "github.com/docker/docker/cli/internal/test/builders"
+	"github.com/docker/docker/pkg/testutil/assert"
+	"github.com/docker/docker/pkg/testutil/golden"
+)
+
+func TestNodePsErrors(t *testing.T) {
+	testCases := []struct {
+		args            []string
+		flags           map[string]string
+		infoFunc        func() (types.Info, error)
+		nodeInspectFunc func() (swarm.Node, []byte, error)
+		taskListFunc    func(options types.TaskListOptions) ([]swarm.Task, error)
+		taskInspectFunc func(taskID string) (swarm.Task, []byte, error)
+		expectedError   string
+	}{
+		{
+			infoFunc: func() (types.Info, error) {
+				return types.Info{}, fmt.Errorf("error asking for node info")
+			},
+			expectedError: "error asking for node info",
+		},
+		{
+			args: []string{"nodeID"},
+			nodeInspectFunc: func() (swarm.Node, []byte, error) {
+				return swarm.Node{}, []byte{}, fmt.Errorf("error inspecting the node")
+			},
+			expectedError: "error inspecting the node",
+		},
+		{
+			args: []string{"nodeID"},
+			taskListFunc: func(options types.TaskListOptions) ([]swarm.Task, error) {
+				return []swarm.Task{}, fmt.Errorf("error returning the task list")
+			},
+			expectedError: "error returning the task list",
+		},
+	}
+	for _, tc := range testCases {
+		buf := new(bytes.Buffer)
+		cmd := newPsCommand(
+			test.NewFakeCli(&fakeClient{
+				infoFunc:        tc.infoFunc,
+				nodeInspectFunc: tc.nodeInspectFunc,
+				taskInspectFunc: tc.taskInspectFunc,
+				taskListFunc:    tc.taskListFunc,
+			}, buf))
+		cmd.SetArgs(tc.args)
+		for key, value := range tc.flags {
+			cmd.Flags().Set(key, value)
+		}
+		cmd.SetOutput(ioutil.Discard)
+		assert.Error(t, cmd.Execute(), tc.expectedError)
+	}
+}
+
+func TestNodePs(t *testing.T) {
+	testCases := []struct {
+		name            string
+		args            []string
+		flags           map[string]string
+		infoFunc        func() (types.Info, error)
+		nodeInspectFunc func() (swarm.Node, []byte, error)
+		taskListFunc    func(options types.TaskListOptions) ([]swarm.Task, error)
+		taskInspectFunc func(taskID string) (swarm.Task, []byte, error)
+	}{
+		{
+			name: "simple",
+			args: []string{"nodeID"},
+			nodeInspectFunc: func() (swarm.Node, []byte, error) {
+				return *Node(), []byte{}, nil
+			},
+			taskListFunc: func(options types.TaskListOptions) ([]swarm.Task, error) {
+				return []swarm.Task{
+					*Task(WithStatus(Timestamp(time.Now().Add(-2*time.Hour)), PortStatus([]swarm.PortConfig{
+						{
+							TargetPort:    80,
+							PublishedPort: 80,
+							Protocol:      "tcp",
+						},
+					}))),
+				}, nil
+			},
+		},
+		{
+			name: "with-errors",
+			args: []string{"nodeID"},
+			nodeInspectFunc: func() (swarm.Node, []byte, error) {
+				return *Node(), []byte{}, nil
+			},
+			taskListFunc: func(options types.TaskListOptions) ([]swarm.Task, error) {
+				return []swarm.Task{
+					*Task(TaskID("taskID1"), ServiceID("failure"),
+						WithStatus(Timestamp(time.Now().Add(-2*time.Hour)), StatusErr("a task error"))),
+					*Task(TaskID("taskID2"), ServiceID("failure"),
+						WithStatus(Timestamp(time.Now().Add(-3*time.Hour)), StatusErr("a task error"))),
+					*Task(TaskID("taskID3"), ServiceID("failure"),
+						WithStatus(Timestamp(time.Now().Add(-4*time.Hour)), StatusErr("a task error"))),
+				}, nil
+			},
+		},
+	}
+	for _, tc := range testCases {
+		buf := new(bytes.Buffer)
+		cmd := newPsCommand(
+			test.NewFakeCli(&fakeClient{
+				infoFunc:        tc.infoFunc,
+				nodeInspectFunc: tc.nodeInspectFunc,
+				taskInspectFunc: tc.taskInspectFunc,
+				taskListFunc:    tc.taskListFunc,
+			}, buf))
+		cmd.SetArgs(tc.args)
+		for key, value := range tc.flags {
+			cmd.Flags().Set(key, value)
+		}
+		assert.NilError(t, cmd.Execute())
+		actual := buf.String()
+		expected := golden.Get(t, []byte(actual), fmt.Sprintf("node-ps.%s.golden", tc.name))
+		assert.EqualNormalizedString(t, assert.RemoveSpace, actual, string(expected))
+	}
+}

+ 2 - 2
cli/command/node/remove.go

@@ -16,7 +16,7 @@ type removeOptions struct {
 	force bool
 	force bool
 }
 }
 
 
-func newRemoveCommand(dockerCli *command.DockerCli) *cobra.Command {
+func newRemoveCommand(dockerCli command.Cli) *cobra.Command {
 	opts := removeOptions{}
 	opts := removeOptions{}
 
 
 	cmd := &cobra.Command{
 	cmd := &cobra.Command{
@@ -33,7 +33,7 @@ func newRemoveCommand(dockerCli *command.DockerCli) *cobra.Command {
 	return cmd
 	return cmd
 }
 }
 
 
-func runRemove(dockerCli *command.DockerCli, args []string, opts removeOptions) error {
+func runRemove(dockerCli command.Cli, args []string, opts removeOptions) error {
 	client := dockerCli.Client()
 	client := dockerCli.Client()
 	ctx := context.Background()
 	ctx := context.Background()
 
 

+ 47 - 0
cli/command/node/remove_test.go

@@ -0,0 +1,47 @@
+package node
+
+import (
+	"bytes"
+	"fmt"
+	"io/ioutil"
+	"testing"
+
+	"github.com/docker/docker/cli/internal/test"
+	"github.com/docker/docker/pkg/testutil/assert"
+)
+
+func TestNodeRemoveErrors(t *testing.T) {
+	testCases := []struct {
+		args           []string
+		nodeRemoveFunc func() error
+		expectedError  string
+	}{
+		{
+			expectedError: "requires at least 1 argument",
+		},
+		{
+			args: []string{"nodeID"},
+			nodeRemoveFunc: func() error {
+				return fmt.Errorf("error removing the node")
+			},
+			expectedError: "error removing the node",
+		},
+	}
+	for _, tc := range testCases {
+		buf := new(bytes.Buffer)
+		cmd := newRemoveCommand(
+			test.NewFakeCli(&fakeClient{
+				nodeRemoveFunc: tc.nodeRemoveFunc,
+			}, buf))
+		cmd.SetArgs(tc.args)
+		cmd.SetOutput(ioutil.Discard)
+		assert.Error(t, cmd.Execute(), tc.expectedError)
+	}
+}
+
+func TestNodeRemoveMultiple(t *testing.T) {
+	buf := new(bytes.Buffer)
+	cmd := newRemoveCommand(test.NewFakeCli(&fakeClient{}, buf))
+	cmd.SetArgs([]string{"nodeID1", "nodeID2"})
+	assert.NilError(t, cmd.Execute())
+}

+ 25 - 0
cli/command/node/testdata/node-inspect-pretty.manager-leader.golden

@@ -0,0 +1,25 @@
+ID:			nodeID
+Name:			defaultNodeName
+Hostname:		defaultNodeHostname
+Joined at:		2009-11-10 23:00:00 +0000 utc
+Status:
+ State:			Ready
+ Availability:		Active
+ Address:		127.0.0.1
+Manager Status:
+ Address:		127.0.0.1
+ Raft Status:		Reachable
+ Leader:		Yes
+Platform:
+ Operating System:	linux
+ Architecture:		x86_64
+Resources:
+ CPUs:			0
+ Memory:		20 MiB
+Plugins:
+  Network:		bridge, overlay
+  Volume:		local
+Engine Version:		1.13.0
+Engine Labels:
+ - engine = label
+

+ 25 - 0
cli/command/node/testdata/node-inspect-pretty.manager.golden

@@ -0,0 +1,25 @@
+ID:			nodeID
+Name:			defaultNodeName
+Hostname:		defaultNodeHostname
+Joined at:		2009-11-10 23:00:00 +0000 utc
+Status:
+ State:			Ready
+ Availability:		Active
+ Address:		127.0.0.1
+Manager Status:
+ Address:		127.0.0.1
+ Raft Status:		Reachable
+ Leader:		No
+Platform:
+ Operating System:	linux
+ Architecture:		x86_64
+Resources:
+ CPUs:			0
+ Memory:		20 MiB
+Plugins:
+  Network:		bridge, overlay
+  Volume:		local
+Engine Version:		1.13.0
+Engine Labels:
+ - engine = label
+

+ 23 - 0
cli/command/node/testdata/node-inspect-pretty.simple.golden

@@ -0,0 +1,23 @@
+ID:			nodeID
+Name:			defaultNodeName
+Labels:
+ - lbl1 = value1
+Hostname:		defaultNodeHostname
+Joined at:		2009-11-10 23:00:00 +0000 utc
+Status:
+ State:			Ready
+ Availability:		Active
+ Address:		127.0.0.1
+Platform:
+ Operating System:	linux
+ Architecture:		x86_64
+Resources:
+ CPUs:			0
+ Memory:		20 MiB
+Plugins:
+  Network:		bridge, overlay
+  Volume:		local
+Engine Version:		1.13.0
+Engine Labels:
+ - engine = label
+

+ 2 - 0
cli/command/node/testdata/node-ps.simple.golden

@@ -0,0 +1,2 @@
+ID      NAME                         IMAGE          NODE             DESIRED STATE  CURRENT STATE      ERROR  PORTS
+taskID  rl02d5gwz6chzu7il5fhtb8be.1  myimage:mytag  defaultNodeName  Ready          Ready 2 hours ago         *:80->80/tcp

+ 4 - 0
cli/command/node/testdata/node-ps.with-errors.golden

@@ -0,0 +1,4 @@
+ID       NAME           IMAGE          NODE             DESIRED STATE  CURRENT STATE      ERROR           PORTS
+taskID1  failure.1      myimage:mytag  defaultNodeName  Ready          Ready 2 hours ago  "a task error"  
+taskID2   \_ failure.1  myimage:mytag  defaultNodeName  Ready          Ready 3 hours ago  "a task error"  
+taskID3   \_ failure.1  myimage:mytag  defaultNodeName  Ready          Ready 4 hours ago  "a task error"  

+ 3 - 3
cli/command/node/update.go

@@ -18,7 +18,7 @@ var (
 	errNoRoleChange = errors.New("role was already set to the requested value")
 	errNoRoleChange = errors.New("role was already set to the requested value")
 )
 )
 
 
-func newUpdateCommand(dockerCli *command.DockerCli) *cobra.Command {
+func newUpdateCommand(dockerCli command.Cli) *cobra.Command {
 	nodeOpts := newNodeOptions()
 	nodeOpts := newNodeOptions()
 
 
 	cmd := &cobra.Command{
 	cmd := &cobra.Command{
@@ -39,14 +39,14 @@ func newUpdateCommand(dockerCli *command.DockerCli) *cobra.Command {
 	return cmd
 	return cmd
 }
 }
 
 
-func runUpdate(dockerCli *command.DockerCli, flags *pflag.FlagSet, nodeID string) error {
+func runUpdate(dockerCli command.Cli, flags *pflag.FlagSet, nodeID string) error {
 	success := func(_ string) {
 	success := func(_ string) {
 		fmt.Fprintln(dockerCli.Out(), nodeID)
 		fmt.Fprintln(dockerCli.Out(), nodeID)
 	}
 	}
 	return updateNodes(dockerCli, []string{nodeID}, mergeNodeUpdate(flags), success)
 	return updateNodes(dockerCli, []string{nodeID}, mergeNodeUpdate(flags), success)
 }
 }
 
 
-func updateNodes(dockerCli *command.DockerCli, nodes []string, mergeNode func(node *swarm.Node) error, success func(nodeID string)) error {
+func updateNodes(dockerCli command.Cli, nodes []string, mergeNode func(node *swarm.Node) error, success func(nodeID string)) error {
 	client := dockerCli.Client()
 	client := dockerCli.Client()
 	ctx := context.Background()
 	ctx := context.Background()
 
 

+ 172 - 0
cli/command/node/update_test.go

@@ -0,0 +1,172 @@
+package node
+
+import (
+	"bytes"
+	"fmt"
+	"io/ioutil"
+	"testing"
+
+	"github.com/docker/docker/api/types/swarm"
+	"github.com/docker/docker/cli/internal/test"
+	// Import builders to get the builder function as package function
+	. "github.com/docker/docker/cli/internal/test/builders"
+	"github.com/docker/docker/pkg/testutil/assert"
+)
+
+func TestNodeUpdateErrors(t *testing.T) {
+	testCases := []struct {
+		args            []string
+		flags           map[string]string
+		nodeInspectFunc func() (swarm.Node, []byte, error)
+		nodeUpdateFunc  func(nodeID string, version swarm.Version, node swarm.NodeSpec) error
+		expectedError   string
+	}{
+		{
+			expectedError: "requires exactly 1 argument",
+		},
+		{
+			args:          []string{"node1", "node2"},
+			expectedError: "requires exactly 1 argument",
+		},
+		{
+			args: []string{"nodeID"},
+			nodeInspectFunc: func() (swarm.Node, []byte, error) {
+				return swarm.Node{}, []byte{}, fmt.Errorf("error inspecting the node")
+			},
+			expectedError: "error inspecting the node",
+		},
+		{
+			args: []string{"nodeID"},
+			nodeUpdateFunc: func(nodeID string, version swarm.Version, node swarm.NodeSpec) error {
+				return fmt.Errorf("error updating the node")
+			},
+			expectedError: "error updating the node",
+		},
+		{
+			args: []string{"nodeID"},
+			nodeInspectFunc: func() (swarm.Node, []byte, error) {
+				return *Node(NodeLabels(map[string]string{
+					"key": "value",
+				})), []byte{}, nil
+			},
+			flags: map[string]string{
+				"label-rm": "notpresent",
+			},
+			expectedError: "key notpresent doesn't exist in node's labels",
+		},
+	}
+	for _, tc := range testCases {
+		buf := new(bytes.Buffer)
+		cmd := newUpdateCommand(
+			test.NewFakeCli(&fakeClient{
+				nodeInspectFunc: tc.nodeInspectFunc,
+				nodeUpdateFunc:  tc.nodeUpdateFunc,
+			}, buf))
+		cmd.SetArgs(tc.args)
+		for key, value := range tc.flags {
+			cmd.Flags().Set(key, value)
+		}
+		cmd.SetOutput(ioutil.Discard)
+		assert.Error(t, cmd.Execute(), tc.expectedError)
+	}
+}
+
+func TestNodeUpdate(t *testing.T) {
+	testCases := []struct {
+		args            []string
+		flags           map[string]string
+		nodeInspectFunc func() (swarm.Node, []byte, error)
+		nodeUpdateFunc  func(nodeID string, version swarm.Version, node swarm.NodeSpec) error
+	}{
+		{
+			args: []string{"nodeID"},
+			flags: map[string]string{
+				"role": "manager",
+			},
+			nodeInspectFunc: func() (swarm.Node, []byte, error) {
+				return *Node(), []byte{}, nil
+			},
+			nodeUpdateFunc: func(nodeID string, version swarm.Version, node swarm.NodeSpec) error {
+				if node.Role != swarm.NodeRoleManager {
+					return fmt.Errorf("expected role manager, got %s", node.Role)
+				}
+				return nil
+			},
+		},
+		{
+			args: []string{"nodeID"},
+			flags: map[string]string{
+				"availability": "drain",
+			},
+			nodeInspectFunc: func() (swarm.Node, []byte, error) {
+				return *Node(), []byte{}, nil
+			},
+			nodeUpdateFunc: func(nodeID string, version swarm.Version, node swarm.NodeSpec) error {
+				if node.Availability != swarm.NodeAvailabilityDrain {
+					return fmt.Errorf("expected drain availability, got %s", node.Availability)
+				}
+				return nil
+			},
+		},
+		{
+			args: []string{"nodeID"},
+			flags: map[string]string{
+				"label-add": "lbl",
+			},
+			nodeInspectFunc: func() (swarm.Node, []byte, error) {
+				return *Node(), []byte{}, nil
+			},
+			nodeUpdateFunc: func(nodeID string, version swarm.Version, node swarm.NodeSpec) error {
+				if _, present := node.Annotations.Labels["lbl"]; !present {
+					return fmt.Errorf("expected 'lbl' label, got %v", node.Annotations.Labels)
+				}
+				return nil
+			},
+		},
+		{
+			args: []string{"nodeID"},
+			flags: map[string]string{
+				"label-add": "key=value",
+			},
+			nodeInspectFunc: func() (swarm.Node, []byte, error) {
+				return *Node(), []byte{}, nil
+			},
+			nodeUpdateFunc: func(nodeID string, version swarm.Version, node swarm.NodeSpec) error {
+				if value, present := node.Annotations.Labels["key"]; !present || value != "value" {
+					return fmt.Errorf("expected 'key' label to be 'value', got %v", node.Annotations.Labels)
+				}
+				return nil
+			},
+		},
+		{
+			args: []string{"nodeID"},
+			flags: map[string]string{
+				"label-rm": "key",
+			},
+			nodeInspectFunc: func() (swarm.Node, []byte, error) {
+				return *Node(NodeLabels(map[string]string{
+					"key": "value",
+				})), []byte{}, nil
+			},
+			nodeUpdateFunc: func(nodeID string, version swarm.Version, node swarm.NodeSpec) error {
+				if len(node.Annotations.Labels) > 0 {
+					return fmt.Errorf("expected no labels, got %v", node.Annotations.Labels)
+				}
+				return nil
+			},
+		},
+	}
+	for _, tc := range testCases {
+		buf := new(bytes.Buffer)
+		cmd := newUpdateCommand(
+			test.NewFakeCli(&fakeClient{
+				nodeInspectFunc: tc.nodeInspectFunc,
+				nodeUpdateFunc:  tc.nodeUpdateFunc,
+			}, buf))
+		cmd.SetArgs(tc.args)
+		for key, value := range tc.flags {
+			cmd.Flags().Set(key, value)
+		}
+		assert.NilError(t, cmd.Execute())
+	}
+}

+ 84 - 0
cli/command/swarm/client_test.go

@@ -0,0 +1,84 @@
+package swarm
+
+import (
+	"github.com/docker/docker/api/types"
+	"github.com/docker/docker/api/types/swarm"
+	"github.com/docker/docker/client"
+	"golang.org/x/net/context"
+)
+
+type fakeClient struct {
+	client.Client
+	infoFunc              func() (types.Info, error)
+	swarmInitFunc         func() (string, error)
+	swarmInspectFunc      func() (swarm.Swarm, error)
+	nodeInspectFunc       func() (swarm.Node, []byte, error)
+	swarmGetUnlockKeyFunc func() (types.SwarmUnlockKeyResponse, error)
+	swarmJoinFunc         func() error
+	swarmLeaveFunc        func() error
+	swarmUpdateFunc       func(swarm swarm.Spec, flags swarm.UpdateFlags) error
+	swarmUnlockFunc       func(req swarm.UnlockRequest) error
+}
+
+func (cli *fakeClient) Info(ctx context.Context) (types.Info, error) {
+	if cli.infoFunc != nil {
+		return cli.infoFunc()
+	}
+	return types.Info{}, nil
+}
+
+func (cli *fakeClient) NodeInspectWithRaw(ctx context.Context, ref string) (swarm.Node, []byte, error) {
+	if cli.nodeInspectFunc != nil {
+		return cli.nodeInspectFunc()
+	}
+	return swarm.Node{}, []byte{}, nil
+}
+
+func (cli *fakeClient) SwarmInit(ctx context.Context, req swarm.InitRequest) (string, error) {
+	if cli.swarmInitFunc != nil {
+		return cli.swarmInitFunc()
+	}
+	return "", nil
+}
+
+func (cli *fakeClient) SwarmInspect(ctx context.Context) (swarm.Swarm, error) {
+	if cli.swarmInspectFunc != nil {
+		return cli.swarmInspectFunc()
+	}
+	return swarm.Swarm{}, nil
+}
+
+func (cli *fakeClient) SwarmGetUnlockKey(ctx context.Context) (types.SwarmUnlockKeyResponse, error) {
+	if cli.swarmGetUnlockKeyFunc != nil {
+		return cli.swarmGetUnlockKeyFunc()
+	}
+	return types.SwarmUnlockKeyResponse{}, nil
+}
+
+func (cli *fakeClient) SwarmJoin(ctx context.Context, req swarm.JoinRequest) error {
+	if cli.swarmJoinFunc != nil {
+		return cli.swarmJoinFunc()
+	}
+	return nil
+}
+
+func (cli *fakeClient) SwarmLeave(ctx context.Context, force bool) error {
+	if cli.swarmLeaveFunc != nil {
+		return cli.swarmLeaveFunc()
+	}
+	return nil
+}
+
+func (cli *fakeClient) SwarmUpdate(ctx context.Context, version swarm.Version, swarm swarm.Spec, flags swarm.UpdateFlags) error {
+	if cli.swarmUpdateFunc != nil {
+		return cli.swarmUpdateFunc(swarm, flags)
+	}
+	return nil
+}
+
+func (cli *fakeClient) SwarmUnlock(ctx context.Context, req swarm.UnlockRequest) error {
+	if cli.swarmUnlockFunc != nil {
+		return cli.swarmUnlockFunc(req)
+	}
+	return nil
+}

+ 3 - 3
cli/command/swarm/init.go

@@ -22,7 +22,7 @@ type initOptions struct {
 	forceNewCluster bool
 	forceNewCluster bool
 }
 }
 
 
-func newInitCommand(dockerCli *command.DockerCli) *cobra.Command {
+func newInitCommand(dockerCli command.Cli) *cobra.Command {
 	opts := initOptions{
 	opts := initOptions{
 		listenAddr: NewListenAddrOption(),
 		listenAddr: NewListenAddrOption(),
 	}
 	}
@@ -45,7 +45,7 @@ func newInitCommand(dockerCli *command.DockerCli) *cobra.Command {
 	return cmd
 	return cmd
 }
 }
 
 
-func runInit(dockerCli *command.DockerCli, flags *pflag.FlagSet, opts initOptions) error {
+func runInit(dockerCli command.Cli, flags *pflag.FlagSet, opts initOptions) error {
 	client := dockerCli.Client()
 	client := dockerCli.Client()
 	ctx := context.Background()
 	ctx := context.Background()
 
 
@@ -67,7 +67,7 @@ func runInit(dockerCli *command.DockerCli, flags *pflag.FlagSet, opts initOption
 
 
 	fmt.Fprintf(dockerCli.Out(), "Swarm initialized: current node (%s) is now a manager.\n\n", nodeID)
 	fmt.Fprintf(dockerCli.Out(), "Swarm initialized: current node (%s) is now a manager.\n\n", nodeID)
 
 
-	if err := printJoinCommand(ctx, dockerCli, nodeID, true, false); err != nil {
+	if err := printJoinCommand(ctx, dockerCli, nodeID, false, true); err != nil {
 		return err
 		return err
 	}
 	}
 
 

+ 129 - 0
cli/command/swarm/init_test.go

@@ -0,0 +1,129 @@
+package swarm
+
+import (
+	"bytes"
+	"fmt"
+	"io/ioutil"
+	"testing"
+
+	"github.com/docker/docker/api/types"
+	"github.com/docker/docker/api/types/swarm"
+	"github.com/docker/docker/cli/internal/test"
+	"github.com/docker/docker/pkg/testutil/assert"
+	"github.com/docker/docker/pkg/testutil/golden"
+)
+
+func TestSwarmInitErrorOnAPIFailure(t *testing.T) {
+	testCases := []struct {
+		name                  string
+		flags                 map[string]string
+		swarmInitFunc         func() (string, error)
+		swarmInspectFunc      func() (swarm.Swarm, error)
+		swarmGetUnlockKeyFunc func() (types.SwarmUnlockKeyResponse, error)
+		nodeInspectFunc       func() (swarm.Node, []byte, error)
+		expectedError         string
+	}{
+		{
+			name: "init-failed",
+			swarmInitFunc: func() (string, error) {
+				return "", fmt.Errorf("error initializing the swarm")
+			},
+			expectedError: "error initializing the swarm",
+		},
+		{
+			name: "init-faild-with-ip-choice",
+			swarmInitFunc: func() (string, error) {
+				return "", fmt.Errorf("could not choose an IP address to advertise")
+			},
+			expectedError: "could not choose an IP address to advertise - specify one with --advertise-addr",
+		},
+		{
+			name: "swarm-inspect-after-init-failed",
+			swarmInspectFunc: func() (swarm.Swarm, error) {
+				return swarm.Swarm{}, fmt.Errorf("error inspecting the swarm")
+			},
+			expectedError: "error inspecting the swarm",
+		},
+		{
+			name: "node-inspect-after-init-failed",
+			nodeInspectFunc: func() (swarm.Node, []byte, error) {
+				return swarm.Node{}, []byte{}, fmt.Errorf("error inspecting the node")
+			},
+			expectedError: "error inspecting the node",
+		},
+		{
+			name: "swarm-get-unlock-key-after-init-failed",
+			flags: map[string]string{
+				flagAutolock: "true",
+			},
+			swarmGetUnlockKeyFunc: func() (types.SwarmUnlockKeyResponse, error) {
+				return types.SwarmUnlockKeyResponse{}, fmt.Errorf("error getting swarm unlock key")
+			},
+			expectedError: "could not fetch unlock key: error getting swarm unlock key",
+		},
+	}
+	for _, tc := range testCases {
+		buf := new(bytes.Buffer)
+		cmd := newInitCommand(
+			test.NewFakeCli(&fakeClient{
+				swarmInitFunc:         tc.swarmInitFunc,
+				swarmInspectFunc:      tc.swarmInspectFunc,
+				swarmGetUnlockKeyFunc: tc.swarmGetUnlockKeyFunc,
+				nodeInspectFunc:       tc.nodeInspectFunc,
+			}, buf))
+		for key, value := range tc.flags {
+			cmd.Flags().Set(key, value)
+		}
+		cmd.SetOutput(ioutil.Discard)
+		assert.Error(t, cmd.Execute(), tc.expectedError)
+	}
+}
+
+func TestSwarmInit(t *testing.T) {
+	testCases := []struct {
+		name                  string
+		flags                 map[string]string
+		swarmInitFunc         func() (string, error)
+		swarmInspectFunc      func() (swarm.Swarm, error)
+		swarmGetUnlockKeyFunc func() (types.SwarmUnlockKeyResponse, error)
+		nodeInspectFunc       func() (swarm.Node, []byte, error)
+	}{
+		{
+			name: "init",
+			swarmInitFunc: func() (string, error) {
+				return "nodeID", nil
+			},
+		},
+		{
+			name: "init-autolock",
+			flags: map[string]string{
+				flagAutolock: "true",
+			},
+			swarmInitFunc: func() (string, error) {
+				return "nodeID", nil
+			},
+			swarmGetUnlockKeyFunc: func() (types.SwarmUnlockKeyResponse, error) {
+				return types.SwarmUnlockKeyResponse{
+					UnlockKey: "unlock-key",
+				}, nil
+			},
+		},
+	}
+	for _, tc := range testCases {
+		buf := new(bytes.Buffer)
+		cmd := newInitCommand(
+			test.NewFakeCli(&fakeClient{
+				swarmInitFunc:         tc.swarmInitFunc,
+				swarmInspectFunc:      tc.swarmInspectFunc,
+				swarmGetUnlockKeyFunc: tc.swarmGetUnlockKeyFunc,
+				nodeInspectFunc:       tc.nodeInspectFunc,
+			}, buf))
+		for key, value := range tc.flags {
+			cmd.Flags().Set(key, value)
+		}
+		assert.NilError(t, cmd.Execute())
+		actual := buf.String()
+		expected := golden.Get(t, []byte(actual), fmt.Sprintf("init-%s.golden", tc.name))
+		assert.EqualNormalizedString(t, assert.RemoveSpace, actual, string(expected))
+	}
+}

+ 2 - 2
cli/command/swarm/join.go

@@ -18,7 +18,7 @@ type joinOptions struct {
 	token         string
 	token         string
 }
 }
 
 
-func newJoinCommand(dockerCli *command.DockerCli) *cobra.Command {
+func newJoinCommand(dockerCli command.Cli) *cobra.Command {
 	opts := joinOptions{
 	opts := joinOptions{
 		listenAddr: NewListenAddrOption(),
 		listenAddr: NewListenAddrOption(),
 	}
 	}
@@ -40,7 +40,7 @@ func newJoinCommand(dockerCli *command.DockerCli) *cobra.Command {
 	return cmd
 	return cmd
 }
 }
 
 
-func runJoin(dockerCli *command.DockerCli, opts joinOptions) error {
+func runJoin(dockerCli command.Cli, opts joinOptions) error {
 	client := dockerCli.Client()
 	client := dockerCli.Client()
 	ctx := context.Background()
 	ctx := context.Background()
 
 

+ 102 - 0
cli/command/swarm/join_test.go

@@ -0,0 +1,102 @@
+package swarm
+
+import (
+	"bytes"
+	"fmt"
+	"io/ioutil"
+	"strings"
+	"testing"
+
+	"github.com/docker/docker/api/types"
+	"github.com/docker/docker/api/types/swarm"
+	"github.com/docker/docker/cli/internal/test"
+	"github.com/docker/docker/pkg/testutil/assert"
+)
+
+func TestSwarmJoinErrors(t *testing.T) {
+	testCases := []struct {
+		name          string
+		args          []string
+		swarmJoinFunc func() error
+		infoFunc      func() (types.Info, error)
+		expectedError string
+	}{
+		{
+			name:          "not-enough-args",
+			expectedError: "requires exactly 1 argument",
+		},
+		{
+			name:          "too-many-args",
+			args:          []string{"remote1", "remote2"},
+			expectedError: "requires exactly 1 argument",
+		},
+		{
+			name: "join-failed",
+			args: []string{"remote"},
+			swarmJoinFunc: func() error {
+				return fmt.Errorf("error joining the swarm")
+			},
+			expectedError: "error joining the swarm",
+		},
+		{
+			name: "join-failed-on-init",
+			args: []string{"remote"},
+			infoFunc: func() (types.Info, error) {
+				return types.Info{}, fmt.Errorf("error asking for node info")
+			},
+			expectedError: "error asking for node info",
+		},
+	}
+	for _, tc := range testCases {
+		buf := new(bytes.Buffer)
+		cmd := newJoinCommand(
+			test.NewFakeCli(&fakeClient{
+				swarmJoinFunc: tc.swarmJoinFunc,
+				infoFunc:      tc.infoFunc,
+			}, buf))
+		cmd.SetArgs(tc.args)
+		cmd.SetOutput(ioutil.Discard)
+		assert.Error(t, cmd.Execute(), tc.expectedError)
+	}
+}
+
+func TestSwarmJoin(t *testing.T) {
+	testCases := []struct {
+		name     string
+		infoFunc func() (types.Info, error)
+		expected string
+	}{
+		{
+			name: "join-as-manager",
+			infoFunc: func() (types.Info, error) {
+				return types.Info{
+					Swarm: swarm.Info{
+						ControlAvailable: true,
+					},
+				}, nil
+			},
+			expected: "This node joined a swarm as a manager.",
+		},
+		{
+			name: "join-as-worker",
+			infoFunc: func() (types.Info, error) {
+				return types.Info{
+					Swarm: swarm.Info{
+						ControlAvailable: false,
+					},
+				}, nil
+			},
+			expected: "This node joined a swarm as a worker.",
+		},
+	}
+	for _, tc := range testCases {
+		buf := new(bytes.Buffer)
+		cmd := newJoinCommand(
+			test.NewFakeCli(&fakeClient{
+				infoFunc: tc.infoFunc,
+			}, buf))
+		cmd.SetArgs([]string{"remote"})
+		assert.NilError(t, cmd.Execute())
+		assert.Equal(t, strings.TrimSpace(buf.String()), tc.expected)
+	}
+}

+ 3 - 3
cli/command/swarm/join_token.go

@@ -18,7 +18,7 @@ type joinTokenOptions struct {
 	quiet  bool
 	quiet  bool
 }
 }
 
 
-func newJoinTokenCommand(dockerCli *command.DockerCli) *cobra.Command {
+func newJoinTokenCommand(dockerCli command.Cli) *cobra.Command {
 	opts := joinTokenOptions{}
 	opts := joinTokenOptions{}
 
 
 	cmd := &cobra.Command{
 	cmd := &cobra.Command{
@@ -38,7 +38,7 @@ func newJoinTokenCommand(dockerCli *command.DockerCli) *cobra.Command {
 	return cmd
 	return cmd
 }
 }
 
 
-func runJoinToken(dockerCli *command.DockerCli, opts joinTokenOptions) error {
+func runJoinToken(dockerCli command.Cli, opts joinTokenOptions) error {
 	worker := opts.role == "worker"
 	worker := opts.role == "worker"
 	manager := opts.role == "manager"
 	manager := opts.role == "manager"
 
 
@@ -94,7 +94,7 @@ func runJoinToken(dockerCli *command.DockerCli, opts joinTokenOptions) error {
 	return printJoinCommand(ctx, dockerCli, info.Swarm.NodeID, worker, manager)
 	return printJoinCommand(ctx, dockerCli, info.Swarm.NodeID, worker, manager)
 }
 }
 
 
-func printJoinCommand(ctx context.Context, dockerCli *command.DockerCli, nodeID string, worker bool, manager bool) error {
+func printJoinCommand(ctx context.Context, dockerCli command.Cli, nodeID string, worker bool, manager bool) error {
 	client := dockerCli.Client()
 	client := dockerCli.Client()
 
 
 	node, _, err := client.NodeInspectWithRaw(ctx, nodeID)
 	node, _, err := client.NodeInspectWithRaw(ctx, nodeID)

+ 215 - 0
cli/command/swarm/join_token_test.go

@@ -0,0 +1,215 @@
+package swarm
+
+import (
+	"bytes"
+	"fmt"
+	"io/ioutil"
+	"testing"
+
+	"github.com/docker/docker/api/types"
+	"github.com/docker/docker/api/types/swarm"
+	"github.com/docker/docker/cli/internal/test"
+	// Import builders to get the builder function as package function
+	. "github.com/docker/docker/cli/internal/test/builders"
+	"github.com/docker/docker/pkg/testutil/assert"
+	"github.com/docker/docker/pkg/testutil/golden"
+)
+
+func TestSwarmJoinTokenErrors(t *testing.T) {
+	testCases := []struct {
+		name             string
+		args             []string
+		flags            map[string]string
+		infoFunc         func() (types.Info, error)
+		swarmInspectFunc func() (swarm.Swarm, error)
+		swarmUpdateFunc  func(swarm swarm.Spec, flags swarm.UpdateFlags) error
+		nodeInspectFunc  func() (swarm.Node, []byte, error)
+		expectedError    string
+	}{
+		{
+			name:          "not-enough-args",
+			expectedError: "requires exactly 1 argument",
+		},
+		{
+			name:          "too-many-args",
+			args:          []string{"worker", "manager"},
+			expectedError: "requires exactly 1 argument",
+		},
+		{
+			name:          "invalid-args",
+			args:          []string{"foo"},
+			expectedError: "unknown role foo",
+		},
+		{
+			name: "swarm-inspect-failed",
+			args: []string{"worker"},
+			swarmInspectFunc: func() (swarm.Swarm, error) {
+				return swarm.Swarm{}, fmt.Errorf("error inspecting the swarm")
+			},
+			expectedError: "error inspecting the swarm",
+		},
+		{
+			name: "swarm-inspect-rotate-failed",
+			args: []string{"worker"},
+			flags: map[string]string{
+				flagRotate: "true",
+			},
+			swarmInspectFunc: func() (swarm.Swarm, error) {
+				return swarm.Swarm{}, fmt.Errorf("error inspecting the swarm")
+			},
+			expectedError: "error inspecting the swarm",
+		},
+		{
+			name: "swarm-update-failed",
+			args: []string{"worker"},
+			flags: map[string]string{
+				flagRotate: "true",
+			},
+			swarmUpdateFunc: func(swarm swarm.Spec, flags swarm.UpdateFlags) error {
+				return fmt.Errorf("error updating the swarm")
+			},
+			expectedError: "error updating the swarm",
+		},
+		{
+			name: "node-inspect-failed",
+			args: []string{"worker"},
+			nodeInspectFunc: func() (swarm.Node, []byte, error) {
+				return swarm.Node{}, []byte{}, fmt.Errorf("error inspecting node")
+			},
+			expectedError: "error inspecting node",
+		},
+		{
+			name: "info-failed",
+			args: []string{"worker"},
+			infoFunc: func() (types.Info, error) {
+				return types.Info{}, fmt.Errorf("error asking for node info")
+			},
+			expectedError: "error asking for node info",
+		},
+	}
+	for _, tc := range testCases {
+		buf := new(bytes.Buffer)
+		cmd := newJoinTokenCommand(
+			test.NewFakeCli(&fakeClient{
+				swarmInspectFunc: tc.swarmInspectFunc,
+				swarmUpdateFunc:  tc.swarmUpdateFunc,
+				infoFunc:         tc.infoFunc,
+				nodeInspectFunc:  tc.nodeInspectFunc,
+			}, buf))
+		cmd.SetArgs(tc.args)
+		for key, value := range tc.flags {
+			cmd.Flags().Set(key, value)
+		}
+		cmd.SetOutput(ioutil.Discard)
+		assert.Error(t, cmd.Execute(), tc.expectedError)
+	}
+}
+
+func TestSwarmJoinToken(t *testing.T) {
+	testCases := []struct {
+		name             string
+		args             []string
+		flags            map[string]string
+		infoFunc         func() (types.Info, error)
+		swarmInspectFunc func() (swarm.Swarm, error)
+		nodeInspectFunc  func() (swarm.Node, []byte, error)
+	}{
+		{
+			name: "worker",
+			args: []string{"worker"},
+			infoFunc: func() (types.Info, error) {
+				return types.Info{
+					Swarm: swarm.Info{
+						NodeID: "nodeID",
+					},
+				}, nil
+			},
+			nodeInspectFunc: func() (swarm.Node, []byte, error) {
+				return *Node(Manager()), []byte{}, nil
+			},
+			swarmInspectFunc: func() (swarm.Swarm, error) {
+				return *Swarm(), nil
+			},
+		},
+		{
+			name: "manager",
+			args: []string{"manager"},
+			infoFunc: func() (types.Info, error) {
+				return types.Info{
+					Swarm: swarm.Info{
+						NodeID: "nodeID",
+					},
+				}, nil
+			},
+			nodeInspectFunc: func() (swarm.Node, []byte, error) {
+				return *Node(Manager()), []byte{}, nil
+			},
+			swarmInspectFunc: func() (swarm.Swarm, error) {
+				return *Swarm(), nil
+			},
+		},
+		{
+			name: "manager-rotate",
+			args: []string{"manager"},
+			flags: map[string]string{
+				flagRotate: "true",
+			},
+			infoFunc: func() (types.Info, error) {
+				return types.Info{
+					Swarm: swarm.Info{
+						NodeID: "nodeID",
+					},
+				}, nil
+			},
+			nodeInspectFunc: func() (swarm.Node, []byte, error) {
+				return *Node(Manager()), []byte{}, nil
+			},
+			swarmInspectFunc: func() (swarm.Swarm, error) {
+				return *Swarm(), nil
+			},
+		},
+		{
+			name: "worker-quiet",
+			args: []string{"worker"},
+			flags: map[string]string{
+				flagQuiet: "true",
+			},
+			nodeInspectFunc: func() (swarm.Node, []byte, error) {
+				return *Node(Manager()), []byte{}, nil
+			},
+			swarmInspectFunc: func() (swarm.Swarm, error) {
+				return *Swarm(), nil
+			},
+		},
+		{
+			name: "manager-quiet",
+			args: []string{"manager"},
+			flags: map[string]string{
+				flagQuiet: "true",
+			},
+			nodeInspectFunc: func() (swarm.Node, []byte, error) {
+				return *Node(Manager()), []byte{}, nil
+			},
+			swarmInspectFunc: func() (swarm.Swarm, error) {
+				return *Swarm(), nil
+			},
+		},
+	}
+	for _, tc := range testCases {
+		buf := new(bytes.Buffer)
+		cmd := newJoinTokenCommand(
+			test.NewFakeCli(&fakeClient{
+				swarmInspectFunc: tc.swarmInspectFunc,
+				infoFunc:         tc.infoFunc,
+				nodeInspectFunc:  tc.nodeInspectFunc,
+			}, buf))
+		cmd.SetArgs(tc.args)
+		for key, value := range tc.flags {
+			cmd.Flags().Set(key, value)
+		}
+		assert.NilError(t, cmd.Execute())
+		actual := buf.String()
+		expected := golden.Get(t, []byte(actual), fmt.Sprintf("jointoken-%s.golden", tc.name))
+		assert.EqualNormalizedString(t, assert.RemoveSpace, actual, string(expected))
+	}
+}

+ 2 - 2
cli/command/swarm/leave.go

@@ -14,7 +14,7 @@ type leaveOptions struct {
 	force bool
 	force bool
 }
 }
 
 
-func newLeaveCommand(dockerCli *command.DockerCli) *cobra.Command {
+func newLeaveCommand(dockerCli command.Cli) *cobra.Command {
 	opts := leaveOptions{}
 	opts := leaveOptions{}
 
 
 	cmd := &cobra.Command{
 	cmd := &cobra.Command{
@@ -31,7 +31,7 @@ func newLeaveCommand(dockerCli *command.DockerCli) *cobra.Command {
 	return cmd
 	return cmd
 }
 }
 
 
-func runLeave(dockerCli *command.DockerCli, opts leaveOptions) error {
+func runLeave(dockerCli command.Cli, opts leaveOptions) error {
 	client := dockerCli.Client()
 	client := dockerCli.Client()
 	ctx := context.Background()
 	ctx := context.Background()
 
 

+ 52 - 0
cli/command/swarm/leave_test.go

@@ -0,0 +1,52 @@
+package swarm
+
+import (
+	"bytes"
+	"fmt"
+	"io/ioutil"
+	"strings"
+	"testing"
+
+	"github.com/docker/docker/cli/internal/test"
+	"github.com/docker/docker/pkg/testutil/assert"
+)
+
+func TestSwarmLeaveErrors(t *testing.T) {
+	testCases := []struct {
+		name           string
+		args           []string
+		swarmLeaveFunc func() error
+		expectedError  string
+	}{
+		{
+			name:          "too-many-args",
+			args:          []string{"foo"},
+			expectedError: "accepts no argument(s)",
+		},
+		{
+			name: "leave-failed",
+			swarmLeaveFunc: func() error {
+				return fmt.Errorf("error leaving the swarm")
+			},
+			expectedError: "error leaving the swarm",
+		},
+	}
+	for _, tc := range testCases {
+		buf := new(bytes.Buffer)
+		cmd := newLeaveCommand(
+			test.NewFakeCli(&fakeClient{
+				swarmLeaveFunc: tc.swarmLeaveFunc,
+			}, buf))
+		cmd.SetArgs(tc.args)
+		cmd.SetOutput(ioutil.Discard)
+		assert.Error(t, cmd.Execute(), tc.expectedError)
+	}
+}
+
+func TestSwarmLeave(t *testing.T) {
+	buf := new(bytes.Buffer)
+	cmd := newLeaveCommand(
+		test.NewFakeCli(&fakeClient{}, buf))
+	assert.NilError(t, cmd.Execute())
+	assert.Equal(t, strings.TrimSpace(buf.String()), "Node left the swarm.")
+}

+ 73 - 0
cli/command/swarm/opts_test.go

@@ -35,3 +35,76 @@ func TestNodeAddrOptionSetInvalidFormat(t *testing.T) {
 	opt := NewListenAddrOption()
 	opt := NewListenAddrOption()
 	assert.Error(t, opt.Set("http://localhost:4545"), "Invalid")
 	assert.Error(t, opt.Set("http://localhost:4545"), "Invalid")
 }
 }
+
+func TestExternalCAOptionErrors(t *testing.T) {
+	testCases := []struct {
+		externalCA    string
+		expectedError string
+	}{
+		{
+			externalCA:    "",
+			expectedError: "EOF",
+		},
+		{
+			externalCA:    "anything",
+			expectedError: "invalid field 'anything' must be a key=value pair",
+		},
+		{
+			externalCA:    "foo=bar",
+			expectedError: "the external-ca option needs a protocol= parameter",
+		},
+		{
+			externalCA:    "protocol=baz",
+			expectedError: "unrecognized external CA protocol baz",
+		},
+		{
+			externalCA:    "protocol=cfssl",
+			expectedError: "the external-ca option needs a url= parameter",
+		},
+	}
+	for _, tc := range testCases {
+		opt := &ExternalCAOption{}
+		assert.Error(t, opt.Set(tc.externalCA), tc.expectedError)
+	}
+}
+
+func TestExternalCAOption(t *testing.T) {
+	testCases := []struct {
+		externalCA string
+		expected   string
+	}{
+		{
+			externalCA: "protocol=cfssl,url=anything",
+			expected:   "cfssl: anything",
+		},
+		{
+			externalCA: "protocol=CFSSL,url=anything",
+			expected:   "cfssl: anything",
+		},
+		{
+			externalCA: "protocol=Cfssl,url=https://example.com",
+			expected:   "cfssl: https://example.com",
+		},
+		{
+			externalCA: "protocol=Cfssl,url=https://example.com,foo=bar",
+			expected:   "cfssl: https://example.com",
+		},
+		{
+			externalCA: "protocol=Cfssl,url=https://example.com,foo=bar,foo=baz",
+			expected:   "cfssl: https://example.com",
+		},
+	}
+	for _, tc := range testCases {
+		opt := &ExternalCAOption{}
+		assert.NilError(t, opt.Set(tc.externalCA))
+		assert.Equal(t, opt.String(), tc.expected)
+	}
+}
+
+func TestExternalCAOptionMultiple(t *testing.T) {
+	opt := &ExternalCAOption{}
+	assert.NilError(t, opt.Set("protocol=cfssl,url=https://example.com"))
+	assert.NilError(t, opt.Set("protocol=CFSSL,url=anything"))
+	assert.Equal(t, len(opt.Value()), 2)
+	assert.Equal(t, opt.String(), "cfssl: https://example.com, cfssl: anything")
+}

+ 11 - 0
cli/command/swarm/testdata/init-init-autolock.golden

@@ -0,0 +1,11 @@
+Swarm initialized: current node (nodeID) is now a manager.
+
+To add a manager to this swarm, run 'docker swarm join-token manager' and follow the instructions.
+
+To unlock a swarm manager after it restarts, run the `docker swarm unlock`
+command and provide the following key:
+
+    unlock-key
+
+Please remember to store this key in a password manager, since without it you
+will not be able to restart the manager.

+ 4 - 0
cli/command/swarm/testdata/init-init.golden

@@ -0,0 +1,4 @@
+Swarm initialized: current node (nodeID) is now a manager.
+
+To add a manager to this swarm, run 'docker swarm join-token manager' and follow the instructions.
+

+ 1 - 0
cli/command/swarm/testdata/jointoken-manager-quiet.golden

@@ -0,0 +1 @@
+manager-join-token

+ 8 - 0
cli/command/swarm/testdata/jointoken-manager-rotate.golden

@@ -0,0 +1,8 @@
+Successfully rotated manager join token.
+
+To add a manager to this swarm, run the following command:
+
+    docker swarm join \
+    --token manager-join-token \
+    127.0.0.1
+

+ 6 - 0
cli/command/swarm/testdata/jointoken-manager.golden

@@ -0,0 +1,6 @@
+To add a manager to this swarm, run the following command:
+
+    docker swarm join \
+    --token manager-join-token \
+    127.0.0.1
+

+ 1 - 0
cli/command/swarm/testdata/jointoken-worker-quiet.golden

@@ -0,0 +1 @@
+worker-join-token

+ 6 - 0
cli/command/swarm/testdata/jointoken-worker.golden

@@ -0,0 +1,6 @@
+To add a worker to this swarm, run the following command:
+
+    docker swarm join \
+    --token worker-join-token \
+    127.0.0.1
+

+ 1 - 0
cli/command/swarm/testdata/unlockkeys-unlock-key-quiet.golden

@@ -0,0 +1 @@
+unlock-key

+ 1 - 0
cli/command/swarm/testdata/unlockkeys-unlock-key-rotate-quiet.golden

@@ -0,0 +1 @@
+unlock-key

+ 9 - 0
cli/command/swarm/testdata/unlockkeys-unlock-key-rotate.golden

@@ -0,0 +1,9 @@
+Successfully rotated manager unlock key.
+
+To unlock a swarm manager after it restarts, run the `docker swarm unlock`
+command and provide the following key:
+
+    unlock-key
+
+Please remember to store this key in a password manager, since without it you
+will not be able to restart the manager.

+ 7 - 0
cli/command/swarm/testdata/unlockkeys-unlock-key.golden

@@ -0,0 +1,7 @@
+To unlock a swarm manager after it restarts, run the `docker swarm unlock`
+command and provide the following key:
+
+    unlock-key
+
+Please remember to store this key in a password manager, since without it you
+will not be able to restart the manager.

+ 1 - 0
cli/command/swarm/testdata/update-all-flags-quiet.golden

@@ -0,0 +1 @@
+Swarm updated.

+ 8 - 0
cli/command/swarm/testdata/update-autolock-unlock-key.golden

@@ -0,0 +1,8 @@
+Swarm updated.
+To unlock a swarm manager after it restarts, run the `docker swarm unlock`
+command and provide the following key:
+
+    unlock-key
+
+Please remember to store this key in a password manager, since without it you
+will not be able to restart the manager.

+ 13 - 0
cli/command/swarm/testdata/update-noargs.golden

@@ -0,0 +1,13 @@
+Update the swarm
+
+Usage:
+  update [OPTIONS] [flags]
+
+Flags:
+      --autolock                        Change manager autolocking setting (true|false)
+      --cert-expiry duration            Validity period for node certificates (ns|us|ms|s|m|h) (default 2160h0m0s)
+      --dispatcher-heartbeat duration   Dispatcher heartbeat period (ns|us|ms|s|m|h) (default 5s)
+      --external-ca external-ca         Specifications of one or more certificate signing endpoints
+      --max-snapshots uint              Number of additional Raft snapshots to retain
+      --snapshot-interval uint          Number of log entries between Raft snapshots (default 10000)
+      --task-history-limit int          Task history retention limit (default 5)

+ 2 - 2
cli/command/swarm/unlock.go

@@ -18,7 +18,7 @@ import (
 
 
 type unlockOptions struct{}
 type unlockOptions struct{}
 
 
-func newUnlockCommand(dockerCli *command.DockerCli) *cobra.Command {
+func newUnlockCommand(dockerCli command.Cli) *cobra.Command {
 	opts := unlockOptions{}
 	opts := unlockOptions{}
 
 
 	cmd := &cobra.Command{
 	cmd := &cobra.Command{
@@ -33,7 +33,7 @@ func newUnlockCommand(dockerCli *command.DockerCli) *cobra.Command {
 	return cmd
 	return cmd
 }
 }
 
 
-func runUnlock(dockerCli *command.DockerCli, opts unlockOptions) error {
+func runUnlock(dockerCli command.Cli, opts unlockOptions) error {
 	client := dockerCli.Client()
 	client := dockerCli.Client()
 	ctx := context.Background()
 	ctx := context.Background()
 
 

+ 4 - 5
cli/command/swarm/unlock_key.go

@@ -3,12 +3,11 @@ package swarm
 import (
 import (
 	"fmt"
 	"fmt"
 
 
-	"github.com/spf13/cobra"
-
 	"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/pkg/errors"
 	"github.com/pkg/errors"
+	"github.com/spf13/cobra"
 	"golang.org/x/net/context"
 	"golang.org/x/net/context"
 )
 )
 
 
@@ -17,7 +16,7 @@ type unlockKeyOptions struct {
 	quiet  bool
 	quiet  bool
 }
 }
 
 
-func newUnlockKeyCommand(dockerCli *command.DockerCli) *cobra.Command {
+func newUnlockKeyCommand(dockerCli command.Cli) *cobra.Command {
 	opts := unlockKeyOptions{}
 	opts := unlockKeyOptions{}
 
 
 	cmd := &cobra.Command{
 	cmd := &cobra.Command{
@@ -36,7 +35,7 @@ func newUnlockKeyCommand(dockerCli *command.DockerCli) *cobra.Command {
 	return cmd
 	return cmd
 }
 }
 
 
-func runUnlockKey(dockerCli *command.DockerCli, opts unlockKeyOptions) error {
+func runUnlockKey(dockerCli command.Cli, opts unlockKeyOptions) error {
 	client := dockerCli.Client()
 	client := dockerCli.Client()
 	ctx := context.Background()
 	ctx := context.Background()
 
 
@@ -79,7 +78,7 @@ func runUnlockKey(dockerCli *command.DockerCli, opts unlockKeyOptions) error {
 	return nil
 	return nil
 }
 }
 
 
-func printUnlockCommand(ctx context.Context, dockerCli *command.DockerCli, unlockKey string) {
+func printUnlockCommand(ctx context.Context, dockerCli command.Cli, unlockKey string) {
 	if len(unlockKey) > 0 {
 	if len(unlockKey) > 0 {
 		fmt.Fprintf(dockerCli.Out(), "To unlock a swarm manager after it restarts, run the `docker swarm unlock`\ncommand and provide the following key:\n\n    %s\n\nPlease remember to store this key in a password manager, since without it you\nwill not be able to restart the manager.\n", unlockKey)
 		fmt.Fprintf(dockerCli.Out(), "To unlock a swarm manager after it restarts, run the `docker swarm unlock`\ncommand and provide the following key:\n\n    %s\n\nPlease remember to store this key in a password manager, since without it you\nwill not be able to restart the manager.\n", unlockKey)
 	}
 	}

+ 175 - 0
cli/command/swarm/unlock_key_test.go

@@ -0,0 +1,175 @@
+package swarm
+
+import (
+	"bytes"
+	"fmt"
+	"io/ioutil"
+	"testing"
+
+	"github.com/docker/docker/api/types"
+	"github.com/docker/docker/api/types/swarm"
+	"github.com/docker/docker/cli/internal/test"
+	// Import builders to get the builder function as package function
+	. "github.com/docker/docker/cli/internal/test/builders"
+	"github.com/docker/docker/pkg/testutil/assert"
+	"github.com/docker/docker/pkg/testutil/golden"
+)
+
+func TestSwarmUnlockKeyErrors(t *testing.T) {
+	testCases := []struct {
+		name                  string
+		args                  []string
+		flags                 map[string]string
+		swarmInspectFunc      func() (swarm.Swarm, error)
+		swarmUpdateFunc       func(swarm swarm.Spec, flags swarm.UpdateFlags) error
+		swarmGetUnlockKeyFunc func() (types.SwarmUnlockKeyResponse, error)
+		expectedError         string
+	}{
+		{
+			name:          "too-many-args",
+			args:          []string{"foo"},
+			expectedError: "accepts no argument(s)",
+		},
+		{
+			name: "swarm-inspect-rotate-failed",
+			flags: map[string]string{
+				flagRotate: "true",
+			},
+			swarmInspectFunc: func() (swarm.Swarm, error) {
+				return swarm.Swarm{}, fmt.Errorf("error inspecting the swarm")
+			},
+			expectedError: "error inspecting the swarm",
+		},
+		{
+			name: "swarm-rotate-no-autolock-failed",
+			flags: map[string]string{
+				flagRotate: "true",
+			},
+			swarmInspectFunc: func() (swarm.Swarm, error) {
+				return *Swarm(), nil
+			},
+			expectedError: "cannot rotate because autolock is not turned on",
+		},
+		{
+			name: "swarm-update-failed",
+			flags: map[string]string{
+				flagRotate: "true",
+			},
+			swarmInspectFunc: func() (swarm.Swarm, error) {
+				return *Swarm(Autolock()), nil
+			},
+			swarmUpdateFunc: func(swarm swarm.Spec, flags swarm.UpdateFlags) error {
+				return fmt.Errorf("error updating the swarm")
+			},
+			expectedError: "error updating the swarm",
+		},
+		{
+			name: "swarm-get-unlock-key-failed",
+			swarmGetUnlockKeyFunc: func() (types.SwarmUnlockKeyResponse, error) {
+				return types.SwarmUnlockKeyResponse{}, fmt.Errorf("error getting unlock key")
+			},
+			expectedError: "error getting unlock key",
+		},
+		{
+			name: "swarm-no-unlock-key-failed",
+			swarmGetUnlockKeyFunc: func() (types.SwarmUnlockKeyResponse, error) {
+				return types.SwarmUnlockKeyResponse{
+					UnlockKey: "",
+				}, nil
+			},
+			expectedError: "no unlock key is set",
+		},
+	}
+	for _, tc := range testCases {
+		buf := new(bytes.Buffer)
+		cmd := newUnlockKeyCommand(
+			test.NewFakeCli(&fakeClient{
+				swarmInspectFunc:      tc.swarmInspectFunc,
+				swarmUpdateFunc:       tc.swarmUpdateFunc,
+				swarmGetUnlockKeyFunc: tc.swarmGetUnlockKeyFunc,
+			}, buf))
+		cmd.SetArgs(tc.args)
+		for key, value := range tc.flags {
+			cmd.Flags().Set(key, value)
+		}
+		cmd.SetOutput(ioutil.Discard)
+		assert.Error(t, cmd.Execute(), tc.expectedError)
+	}
+}
+
+func TestSwarmUnlockKey(t *testing.T) {
+	testCases := []struct {
+		name                  string
+		args                  []string
+		flags                 map[string]string
+		swarmInspectFunc      func() (swarm.Swarm, error)
+		swarmUpdateFunc       func(swarm swarm.Spec, flags swarm.UpdateFlags) error
+		swarmGetUnlockKeyFunc func() (types.SwarmUnlockKeyResponse, error)
+	}{
+		{
+			name: "unlock-key",
+			swarmGetUnlockKeyFunc: func() (types.SwarmUnlockKeyResponse, error) {
+				return types.SwarmUnlockKeyResponse{
+					UnlockKey: "unlock-key",
+				}, nil
+			},
+		},
+		{
+			name: "unlock-key-quiet",
+			flags: map[string]string{
+				flagQuiet: "true",
+			},
+			swarmGetUnlockKeyFunc: func() (types.SwarmUnlockKeyResponse, error) {
+				return types.SwarmUnlockKeyResponse{
+					UnlockKey: "unlock-key",
+				}, nil
+			},
+		},
+		{
+			name: "unlock-key-rotate",
+			flags: map[string]string{
+				flagRotate: "true",
+			},
+			swarmInspectFunc: func() (swarm.Swarm, error) {
+				return *Swarm(Autolock()), nil
+			},
+			swarmGetUnlockKeyFunc: func() (types.SwarmUnlockKeyResponse, error) {
+				return types.SwarmUnlockKeyResponse{
+					UnlockKey: "unlock-key",
+				}, nil
+			},
+		},
+		{
+			name: "unlock-key-rotate-quiet",
+			flags: map[string]string{
+				flagQuiet:  "true",
+				flagRotate: "true",
+			},
+			swarmInspectFunc: func() (swarm.Swarm, error) {
+				return *Swarm(Autolock()), nil
+			},
+			swarmGetUnlockKeyFunc: func() (types.SwarmUnlockKeyResponse, error) {
+				return types.SwarmUnlockKeyResponse{
+					UnlockKey: "unlock-key",
+				}, nil
+			},
+		},
+	}
+	for _, tc := range testCases {
+		buf := new(bytes.Buffer)
+		cmd := newUnlockKeyCommand(
+			test.NewFakeCli(&fakeClient{
+				swarmInspectFunc:      tc.swarmInspectFunc,
+				swarmUpdateFunc:       tc.swarmUpdateFunc,
+				swarmGetUnlockKeyFunc: tc.swarmGetUnlockKeyFunc,
+			}, buf))
+		cmd.SetArgs(tc.args)
+		for key, value := range tc.flags {
+			cmd.Flags().Set(key, value)
+		}
+		assert.NilError(t, cmd.Execute())
+		actual := buf.String()
+		expected := golden.Get(t, []byte(actual), fmt.Sprintf("unlockkeys-%s.golden", tc.name))
+		assert.EqualNormalizedString(t, assert.RemoveSpace, actual, string(expected))
+	}
+}

+ 101 - 0
cli/command/swarm/unlock_test.go

@@ -0,0 +1,101 @@
+package swarm
+
+import (
+	"bytes"
+	"fmt"
+	"io/ioutil"
+	"strings"
+	"testing"
+
+	"github.com/docker/docker/api/types"
+	"github.com/docker/docker/api/types/swarm"
+	"github.com/docker/docker/cli/internal/test"
+	"github.com/docker/docker/pkg/testutil/assert"
+)
+
+func TestSwarmUnlockErrors(t *testing.T) {
+	testCases := []struct {
+		name            string
+		args            []string
+		input           string
+		swarmUnlockFunc func(req swarm.UnlockRequest) error
+		infoFunc        func() (types.Info, error)
+		expectedError   string
+	}{
+		{
+			name:          "too-many-args",
+			args:          []string{"foo"},
+			expectedError: "accepts no argument(s)",
+		},
+		{
+			name: "is-not-part-of-a-swarm",
+			infoFunc: func() (types.Info, error) {
+				return types.Info{
+					Swarm: swarm.Info{
+						LocalNodeState: swarm.LocalNodeStateInactive,
+					},
+				}, nil
+			},
+			expectedError: "This node is not part of a swarm",
+		},
+		{
+			name: "is-not-locked",
+			infoFunc: func() (types.Info, error) {
+				return types.Info{
+					Swarm: swarm.Info{
+						LocalNodeState: swarm.LocalNodeStateActive,
+					},
+				}, nil
+			},
+			expectedError: "Error: swarm is not locked",
+		},
+		{
+			name: "unlockrequest-failed",
+			infoFunc: func() (types.Info, error) {
+				return types.Info{
+					Swarm: swarm.Info{
+						LocalNodeState: swarm.LocalNodeStateLocked,
+					},
+				}, nil
+			},
+			swarmUnlockFunc: func(req swarm.UnlockRequest) error {
+				return fmt.Errorf("error unlocking the swarm")
+			},
+			expectedError: "error unlocking the swarm",
+		},
+	}
+	for _, tc := range testCases {
+		buf := new(bytes.Buffer)
+		cmd := newUnlockCommand(
+			test.NewFakeCli(&fakeClient{
+				infoFunc:        tc.infoFunc,
+				swarmUnlockFunc: tc.swarmUnlockFunc,
+			}, buf))
+		cmd.SetArgs(tc.args)
+		cmd.SetOutput(ioutil.Discard)
+		assert.Error(t, cmd.Execute(), tc.expectedError)
+	}
+}
+
+func TestSwarmUnlock(t *testing.T) {
+	input := "unlockKey"
+	buf := new(bytes.Buffer)
+	dockerCli := test.NewFakeCli(&fakeClient{
+		infoFunc: func() (types.Info, error) {
+			return types.Info{
+				Swarm: swarm.Info{
+					LocalNodeState: swarm.LocalNodeStateLocked,
+				},
+			}, nil
+		},
+		swarmUnlockFunc: func(req swarm.UnlockRequest) error {
+			if req.UnlockKey != input {
+				return fmt.Errorf("Invalid unlock key")
+			}
+			return nil
+		},
+	}, buf)
+	dockerCli.SetIn(ioutil.NopCloser(strings.NewReader(input)))
+	cmd := newUnlockCommand(dockerCli)
+	assert.NilError(t, cmd.Execute())
+}

+ 7 - 7
cli/command/swarm/update.go

@@ -13,7 +13,7 @@ import (
 	"github.com/spf13/pflag"
 	"github.com/spf13/pflag"
 )
 )
 
 
-func newUpdateCommand(dockerCli *command.DockerCli) *cobra.Command {
+func newUpdateCommand(dockerCli command.Cli) *cobra.Command {
 	opts := swarmOptions{}
 	opts := swarmOptions{}
 
 
 	cmd := &cobra.Command{
 	cmd := &cobra.Command{
@@ -36,24 +36,24 @@ func newUpdateCommand(dockerCli *command.DockerCli) *cobra.Command {
 	return cmd
 	return cmd
 }
 }
 
 
-func runUpdate(dockerCli *command.DockerCli, flags *pflag.FlagSet, opts swarmOptions) error {
+func runUpdate(dockerCli command.Cli, flags *pflag.FlagSet, opts swarmOptions) error {
 	client := dockerCli.Client()
 	client := dockerCli.Client()
 	ctx := context.Background()
 	ctx := context.Background()
 
 
 	var updateFlags swarm.UpdateFlags
 	var updateFlags swarm.UpdateFlags
 
 
-	swarm, err := client.SwarmInspect(ctx)
+	swarmInspect, err := client.SwarmInspect(ctx)
 	if err != nil {
 	if err != nil {
 		return err
 		return err
 	}
 	}
 
 
-	prevAutoLock := swarm.Spec.EncryptionConfig.AutoLockManagers
+	prevAutoLock := swarmInspect.Spec.EncryptionConfig.AutoLockManagers
 
 
-	opts.mergeSwarmSpec(&swarm.Spec, flags)
+	opts.mergeSwarmSpec(&swarmInspect.Spec, flags)
 
 
-	curAutoLock := swarm.Spec.EncryptionConfig.AutoLockManagers
+	curAutoLock := swarmInspect.Spec.EncryptionConfig.AutoLockManagers
 
 
-	err = client.SwarmUpdate(ctx, swarm.Version, swarm.Spec, updateFlags)
+	err = client.SwarmUpdate(ctx, swarmInspect.Version, swarmInspect.Spec, updateFlags)
 	if err != nil {
 	if err != nil {
 		return err
 		return err
 	}
 	}

+ 182 - 0
cli/command/swarm/update_test.go

@@ -0,0 +1,182 @@
+package swarm
+
+import (
+	"bytes"
+	"fmt"
+	"io/ioutil"
+	"testing"
+	"time"
+
+	"github.com/docker/docker/api/types"
+	"github.com/docker/docker/api/types/swarm"
+	"github.com/docker/docker/cli/internal/test"
+	// Import builders to get the builder function as package function
+	. "github.com/docker/docker/cli/internal/test/builders"
+	"github.com/docker/docker/pkg/testutil/assert"
+	"github.com/docker/docker/pkg/testutil/golden"
+)
+
+func TestSwarmUpdateErrors(t *testing.T) {
+	testCases := []struct {
+		name                  string
+		args                  []string
+		flags                 map[string]string
+		swarmInspectFunc      func() (swarm.Swarm, error)
+		swarmUpdateFunc       func(swarm swarm.Spec, flags swarm.UpdateFlags) error
+		swarmGetUnlockKeyFunc func() (types.SwarmUnlockKeyResponse, error)
+		expectedError         string
+	}{
+		{
+			name:          "too-many-args",
+			args:          []string{"foo"},
+			expectedError: "accepts no argument(s)",
+		},
+		{
+			name: "swarm-inspect-error",
+			flags: map[string]string{
+				flagTaskHistoryLimit: "10",
+			},
+			swarmInspectFunc: func() (swarm.Swarm, error) {
+				return swarm.Swarm{}, fmt.Errorf("error inspecting the swarm")
+			},
+			expectedError: "error inspecting the swarm",
+		},
+		{
+			name: "swarm-update-error",
+			flags: map[string]string{
+				flagTaskHistoryLimit: "10",
+			},
+			swarmUpdateFunc: func(swarm swarm.Spec, flags swarm.UpdateFlags) error {
+				return fmt.Errorf("error updating the swarm")
+			},
+			expectedError: "error updating the swarm",
+		},
+		{
+			name: "swarm-unlockkey-error",
+			flags: map[string]string{
+				flagAutolock: "true",
+			},
+			swarmInspectFunc: func() (swarm.Swarm, error) {
+				return *Swarm(), nil
+			},
+			swarmGetUnlockKeyFunc: func() (types.SwarmUnlockKeyResponse, error) {
+				return types.SwarmUnlockKeyResponse{}, fmt.Errorf("error getting unlock key")
+			},
+			expectedError: "error getting unlock key",
+		},
+	}
+	for _, tc := range testCases {
+		buf := new(bytes.Buffer)
+		cmd := newUpdateCommand(
+			test.NewFakeCli(&fakeClient{
+				swarmInspectFunc:      tc.swarmInspectFunc,
+				swarmUpdateFunc:       tc.swarmUpdateFunc,
+				swarmGetUnlockKeyFunc: tc.swarmGetUnlockKeyFunc,
+			}, buf))
+		cmd.SetArgs(tc.args)
+		for key, value := range tc.flags {
+			cmd.Flags().Set(key, value)
+		}
+		cmd.SetOutput(ioutil.Discard)
+		assert.Error(t, cmd.Execute(), tc.expectedError)
+	}
+}
+
+func TestSwarmUpdate(t *testing.T) {
+	testCases := []struct {
+		name                  string
+		args                  []string
+		flags                 map[string]string
+		swarmInspectFunc      func() (swarm.Swarm, error)
+		swarmUpdateFunc       func(swarm swarm.Spec, flags swarm.UpdateFlags) error
+		swarmGetUnlockKeyFunc func() (types.SwarmUnlockKeyResponse, error)
+	}{
+		{
+			name: "noargs",
+		},
+		{
+			name: "all-flags-quiet",
+			flags: map[string]string{
+				flagTaskHistoryLimit:    "10",
+				flagDispatcherHeartbeat: "10s",
+				flagCertExpiry:          "20s",
+				flagExternalCA:          "protocol=cfssl,url=https://example.com.",
+				flagMaxSnapshots:        "10",
+				flagSnapshotInterval:    "100",
+				flagAutolock:            "true",
+				flagQuiet:               "true",
+			},
+			swarmUpdateFunc: func(swarm swarm.Spec, flags swarm.UpdateFlags) error {
+				if *swarm.Orchestration.TaskHistoryRetentionLimit != 10 {
+					return fmt.Errorf("historyLimit not correctly set")
+				}
+				heartbeatDuration, err := time.ParseDuration("10s")
+				if err != nil {
+					return err
+				}
+				if swarm.Dispatcher.HeartbeatPeriod != heartbeatDuration {
+					return fmt.Errorf("heartbeatPeriodLimit not correctly set")
+				}
+				certExpiryDuration, err := time.ParseDuration("20s")
+				if err != nil {
+					return err
+				}
+				if swarm.CAConfig.NodeCertExpiry != certExpiryDuration {
+					return fmt.Errorf("certExpiry not correctly set")
+				}
+				if len(swarm.CAConfig.ExternalCAs) != 1 {
+					return fmt.Errorf("externalCA not correctly set")
+				}
+				if *swarm.Raft.KeepOldSnapshots != 10 {
+					return fmt.Errorf("keepOldSnapshots not correctly set")
+				}
+				if swarm.Raft.SnapshotInterval != 100 {
+					return fmt.Errorf("snapshotInterval not correctly set")
+				}
+				if !swarm.EncryptionConfig.AutoLockManagers {
+					return fmt.Errorf("autolock not correctly set")
+				}
+				return nil
+			},
+		},
+		{
+			name: "autolock-unlock-key",
+			flags: map[string]string{
+				flagTaskHistoryLimit: "10",
+				flagAutolock:         "true",
+			},
+			swarmUpdateFunc: func(swarm swarm.Spec, flags swarm.UpdateFlags) error {
+				if *swarm.Orchestration.TaskHistoryRetentionLimit != 10 {
+					return fmt.Errorf("historyLimit not correctly set")
+				}
+				return nil
+			},
+			swarmInspectFunc: func() (swarm.Swarm, error) {
+				return *Swarm(), nil
+			},
+			swarmGetUnlockKeyFunc: func() (types.SwarmUnlockKeyResponse, error) {
+				return types.SwarmUnlockKeyResponse{
+					UnlockKey: "unlock-key",
+				}, nil
+			},
+		},
+	}
+	for _, tc := range testCases {
+		buf := new(bytes.Buffer)
+		cmd := newUpdateCommand(
+			test.NewFakeCli(&fakeClient{
+				swarmInspectFunc:      tc.swarmInspectFunc,
+				swarmUpdateFunc:       tc.swarmUpdateFunc,
+				swarmGetUnlockKeyFunc: tc.swarmGetUnlockKeyFunc,
+			}, buf))
+		cmd.SetArgs(tc.args)
+		for key, value := range tc.flags {
+			cmd.Flags().Set(key, value)
+		}
+		cmd.SetOutput(buf)
+		assert.NilError(t, cmd.Execute())
+		actual := buf.String()
+		expected := golden.Get(t, []byte(actual), fmt.Sprintf("update-%s.golden", tc.name))
+		assert.EqualNormalizedString(t, assert.RemoveSpace, actual, string(expected))
+	}
+}

+ 2 - 2
cli/command/task/print.go

@@ -61,7 +61,7 @@ func (t tasksBySlot) Less(i, j int) bool {
 // Print task information in a table format.
 // Print task information in a table 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.DockerCli, 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, noTrunc bool) error {
 	sort.Stable(tasksBySlot(tasks))
 	sort.Stable(tasksBySlot(tasks))
 
 
 	writer := tabwriter.NewWriter(dockerCli.Out(), 0, 4, 2, ' ', 0)
 	writer := tabwriter.NewWriter(dockerCli.Out(), 0, 4, 2, ' ', 0)
@@ -74,7 +74,7 @@ func Print(dockerCli *command.DockerCli, ctx context.Context, tasks []swarm.Task
 }
 }
 
 
 // PrintQuiet shows task list in a quiet way.
 // PrintQuiet shows task list in a quiet way.
-func PrintQuiet(dockerCli *command.DockerCli, tasks []swarm.Task) error {
+func PrintQuiet(dockerCli command.Cli, tasks []swarm.Task) error {
 	sort.Stable(tasksBySlot(tasks))
 	sort.Stable(tasksBySlot(tasks))
 
 
 	out := dockerCli.Out()
 	out := dockerCli.Out()

+ 117 - 0
cli/internal/test/builders/node.go

@@ -0,0 +1,117 @@
+package builders
+
+import (
+	"time"
+
+	"github.com/docker/docker/api/types/swarm"
+)
+
+// Node creates a node with default values.
+// Any number of node function builder can be pass to augment it.
+func Node(builders ...func(*swarm.Node)) *swarm.Node {
+	t1 := time.Date(2009, time.November, 10, 23, 0, 0, 0, time.UTC)
+	node := &swarm.Node{
+		ID: "nodeID",
+		Meta: swarm.Meta{
+			CreatedAt: t1,
+		},
+		Description: swarm.NodeDescription{
+			Hostname: "defaultNodeHostname",
+			Platform: swarm.Platform{
+				Architecture: "x86_64",
+				OS:           "linux",
+			},
+			Resources: swarm.Resources{
+				NanoCPUs:    4,
+				MemoryBytes: 20 * 1024 * 1024,
+			},
+			Engine: swarm.EngineDescription{
+				EngineVersion: "1.13.0",
+				Labels: map[string]string{
+					"engine": "label",
+				},
+				Plugins: []swarm.PluginDescription{
+					{
+						Type: "Volume",
+						Name: "local",
+					},
+					{
+						Type: "Network",
+						Name: "bridge",
+					},
+					{
+						Type: "Network",
+						Name: "overlay",
+					},
+				},
+			},
+		},
+		Status: swarm.NodeStatus{
+			State: swarm.NodeStateReady,
+			Addr:  "127.0.0.1",
+		},
+		Spec: swarm.NodeSpec{
+			Annotations: swarm.Annotations{
+				Name: "defaultNodeName",
+			},
+			Role:         swarm.NodeRoleWorker,
+			Availability: swarm.NodeAvailabilityActive,
+		},
+	}
+
+	for _, builder := range builders {
+		builder(node)
+	}
+
+	return node
+}
+
+// NodeID sets the node id
+func NodeID(id string) func(*swarm.Node) {
+	return func(node *swarm.Node) {
+		node.ID = id
+	}
+}
+
+// NodeLabels sets the node labels
+func NodeLabels(labels map[string]string) func(*swarm.Node) {
+	return func(node *swarm.Node) {
+		node.Spec.Labels = labels
+	}
+}
+
+// Hostname sets the node hostname
+func Hostname(hostname string) func(*swarm.Node) {
+	return func(node *swarm.Node) {
+		node.Description.Hostname = hostname
+	}
+}
+
+// Leader sets the current node as a leader
+func Leader() func(*swarm.ManagerStatus) {
+	return func(managerStatus *swarm.ManagerStatus) {
+		managerStatus.Leader = true
+	}
+}
+
+// Manager set the current node as a manager
+func Manager(managerStatusBuilders ...func(*swarm.ManagerStatus)) func(*swarm.Node) {
+	return func(node *swarm.Node) {
+		node.Spec.Role = swarm.NodeRoleManager
+		node.ManagerStatus = ManagerStatus(managerStatusBuilders...)
+	}
+}
+
+// ManagerStatus create a ManageStatus with default values.
+func ManagerStatus(managerStatusBuilders ...func(*swarm.ManagerStatus)) *swarm.ManagerStatus {
+	managerStatus := &swarm.ManagerStatus{
+		Reachability: swarm.ReachabilityReachable,
+		Addr:         "127.0.0.1",
+	}
+
+	for _, builder := range managerStatusBuilders {
+		builder(managerStatus)
+	}
+
+	return managerStatus
+}

+ 39 - 0
cli/internal/test/builders/swarm.go

@@ -0,0 +1,39 @@
+package builders
+
+import (
+	"time"
+
+	"github.com/docker/docker/api/types/swarm"
+)
+
+// Swarm creates a swarm with default values.
+// Any number of swarm function builder can be pass to augment it.
+func Swarm(swarmBuilders ...func(*swarm.Swarm)) *swarm.Swarm {
+	t1 := time.Date(2009, time.November, 10, 23, 0, 0, 0, time.UTC)
+	swarm := &swarm.Swarm{
+		ClusterInfo: swarm.ClusterInfo{
+			ID: "swarm",
+			Meta: swarm.Meta{
+				CreatedAt: t1,
+			},
+			Spec: swarm.Spec{},
+		},
+		JoinTokens: swarm.JoinTokens{
+			Worker:  "worker-join-token",
+			Manager: "manager-join-token",
+		},
+	}
+
+	for _, builder := range swarmBuilders {
+		builder(swarm)
+	}
+
+	return swarm
+}
+
+// Autolock set the swarm into autolock mode
+func Autolock() func(*swarm.Swarm) {
+	return func(swarm *swarm.Swarm) {
+		swarm.Spec.EncryptionConfig.AutoLockManagers = true
+	}
+}

+ 111 - 0
cli/internal/test/builders/task.go

@@ -0,0 +1,111 @@
+package builders
+
+import (
+	"time"
+
+	"github.com/docker/docker/api/types/swarm"
+)
+
+var (
+	defaultTime = time.Date(2009, time.November, 10, 23, 0, 0, 0, time.UTC)
+)
+
+// Task creates a task with default values .
+// Any number of task function builder can be pass to augment it.
+func Task(taskBuilders ...func(*swarm.Task)) *swarm.Task {
+	task := &swarm.Task{
+		ID: "taskID",
+		Meta: swarm.Meta{
+			CreatedAt: defaultTime,
+		},
+		Annotations: swarm.Annotations{
+			Name: "defaultTaskName",
+		},
+		Spec:         *TaskSpec(),
+		ServiceID:    "rl02d5gwz6chzu7il5fhtb8be",
+		Slot:         1,
+		Status:       *TaskStatus(),
+		DesiredState: swarm.TaskStateReady,
+	}
+
+	for _, builder := range taskBuilders {
+		builder(task)
+	}
+
+	return task
+}
+
+// TaskID sets the task ID
+func TaskID(id string) func(*swarm.Task) {
+	return func(task *swarm.Task) {
+		task.ID = id
+	}
+}
+
+// ServiceID sets the task service's ID
+func ServiceID(id string) func(*swarm.Task) {
+	return func(task *swarm.Task) {
+		task.ServiceID = id
+	}
+}
+
+// WithStatus sets the task status
+func WithStatus(statusBuilders ...func(*swarm.TaskStatus)) func(*swarm.Task) {
+	return func(task *swarm.Task) {
+		task.Status = *TaskStatus(statusBuilders...)
+	}
+}
+
+// TaskStatus creates a task status with default values .
+// Any number of taskStatus function builder can be pass to augment it.
+func TaskStatus(statusBuilders ...func(*swarm.TaskStatus)) *swarm.TaskStatus {
+	timestamp := defaultTime.Add(1 * time.Hour)
+	taskStatus := &swarm.TaskStatus{
+		State:     swarm.TaskStateReady,
+		Timestamp: timestamp,
+	}
+
+	for _, builder := range statusBuilders {
+		builder(taskStatus)
+	}
+
+	return taskStatus
+}
+
+// Timestamp sets the task status timestamp
+func Timestamp(t time.Time) func(*swarm.TaskStatus) {
+	return func(taskStatus *swarm.TaskStatus) {
+		taskStatus.Timestamp = t
+	}
+}
+
+// StatusErr sets the tasks status error
+func StatusErr(err string) func(*swarm.TaskStatus) {
+	return func(taskStatus *swarm.TaskStatus) {
+		taskStatus.Err = err
+	}
+}
+
+// PortStatus sets the tasks port config status
+// FIXME(vdemeester) should be a sub builder 👼
+func PortStatus(portConfigs []swarm.PortConfig) func(*swarm.TaskStatus) {
+	return func(taskStatus *swarm.TaskStatus) {
+		taskStatus.PortStatus.Ports = portConfigs
+	}
+}
+
+// TaskSpec creates a task spec with default values .
+// Any number of taskSpec function builder can be pass to augment it.
+func TaskSpec(specBuilders ...func(*swarm.TaskSpec)) *swarm.TaskSpec {
+	taskSpec := &swarm.TaskSpec{
+		ContainerSpec: swarm.ContainerSpec{
+			Image: "myimage:mytag",
+		},
+	}
+
+	for _, builder := range specBuilders {
+		builder(taskSpec)
+	}
+
+	return taskSpec
+}

+ 48 - 0
cli/internal/test/cli.go

@@ -0,0 +1,48 @@
+// Package test is a test-only package that can be used by other cli package to write unit test
+package test
+
+import (
+	"io"
+	"io/ioutil"
+
+	"github.com/docker/docker/cli/command"
+	"github.com/docker/docker/client"
+	"strings"
+)
+
+// FakeCli emulates the default DockerCli
+type FakeCli struct {
+	command.DockerCli
+	client client.APIClient
+	out    io.Writer
+	in     io.ReadCloser
+}
+
+// NewFakeCli returns a Cli backed by the fakeCli
+func NewFakeCli(client client.APIClient, out io.Writer) *FakeCli {
+	return &FakeCli{
+		client: client,
+		out:    out,
+		in:     ioutil.NopCloser(strings.NewReader("")),
+	}
+}
+
+// SetIn sets the input of the cli to the specified ReadCloser
+func (c *FakeCli) SetIn(in io.ReadCloser) {
+	c.in = in
+}
+
+// Client returns a docker API client
+func (c *FakeCli) Client() client.APIClient {
+	return c.client
+}
+
+// Out returns the output stream the cli should write on
+func (c *FakeCli) Out() *command.OutStream {
+	return command.NewOutStream(c.out)
+}
+
+// In returns thi input stream the cli will use
+func (c *FakeCli) In() *command.InStream {
+	return command.NewInStream(c.in)
+}

+ 13 - 69
integration-cli/docker_cli_swarm_test.go

@@ -119,16 +119,6 @@ func (s *DockerSwarmSuite) TestSwarmIncompatibleDaemon(c *check.C) {
 	d.Start(c)
 	d.Start(c)
 }
 }
 
 
-// Test case for #24090
-func (s *DockerSwarmSuite) TestSwarmNodeListHostname(c *check.C) {
-	d := s.AddDaemon(c, true, true)
-
-	// The first line should contain "HOSTNAME"
-	out, err := d.Cmd("node", "ls")
-	c.Assert(err, checker.IsNil)
-	c.Assert(strings.Split(out, "\n")[0], checker.Contains, "HOSTNAME")
-}
-
 func (s *DockerSwarmSuite) TestSwarmServiceTemplatingHostname(c *check.C) {
 func (s *DockerSwarmSuite) TestSwarmServiceTemplatingHostname(c *check.C) {
 	d := s.AddDaemon(c, true, true)
 	d := s.AddDaemon(c, true, true)
 
 
@@ -235,51 +225,23 @@ func (s *DockerSwarmSuite) TestSwarmNodeTaskListFilter(c *check.C) {
 func (s *DockerSwarmSuite) TestSwarmPublishAdd(c *check.C) {
 func (s *DockerSwarmSuite) TestSwarmPublishAdd(c *check.C) {
 	d := s.AddDaemon(c, true, true)
 	d := s.AddDaemon(c, true, true)
 
 
-	testCases := []struct {
-		name       string
-		publishAdd []string
-		ports      string
-	}{
-		{
-			name: "simple-syntax",
-			publishAdd: []string{
-				"80:80",
-				"80:80",
-				"80:80",
-				"80:20",
-			},
-			ports: "[{ tcp 80 80 ingress}]",
-		},
-		{
-			name: "complex-syntax",
-			publishAdd: []string{
-				"target=90,published=90,protocol=tcp,mode=ingress",
-				"target=90,published=90,protocol=tcp,mode=ingress",
-				"target=90,published=90,protocol=tcp,mode=ingress",
-				"target=30,published=90,protocol=tcp,mode=ingress",
-			},
-			ports: "[{ tcp 90 90 ingress}]",
-		},
-	}
-
-	for _, tc := range testCases {
-		out, err := d.Cmd("service", "create", "--name", tc.name, "--label", "x=y", "busybox", "top")
-		c.Assert(err, checker.IsNil, check.Commentf(out))
-		c.Assert(strings.TrimSpace(out), checker.Not(checker.Equals), "")
+	name := "top"
+	out, err := d.Cmd("service", "create", "--name", name, "--label", "x=y", "busybox", "top")
+	c.Assert(err, checker.IsNil)
+	c.Assert(strings.TrimSpace(out), checker.Not(checker.Equals), "")
 
 
-		out, err = d.CmdRetryOutOfSequence("service", "update", "--publish-add", tc.publishAdd[0], tc.name)
-		c.Assert(err, checker.IsNil, check.Commentf(out))
+	out, err = d.Cmd("service", "update", "--publish-add", "80:80", name)
+	c.Assert(err, checker.IsNil)
 
 
-		out, err = d.CmdRetryOutOfSequence("service", "update", "--publish-add", tc.publishAdd[1], tc.name)
-		c.Assert(err, checker.IsNil, check.Commentf(out))
+	out, err = d.CmdRetryOutOfSequence("service", "update", "--publish-add", "80:80", name)
+	c.Assert(err, checker.IsNil)
 
 
-		out, err = d.CmdRetryOutOfSequence("service", "update", "--publish-add", tc.publishAdd[2], "--publish-add", tc.publishAdd[3], tc.name)
-		c.Assert(err, checker.NotNil, check.Commentf(out))
+	out, err = d.CmdRetryOutOfSequence("service", "update", "--publish-add", "80:80", "--publish-add", "80:20", name)
+	c.Assert(err, checker.NotNil)
 
 
-		out, err = d.Cmd("service", "inspect", "--format", "{{ .Spec.EndpointSpec.Ports }}", tc.name)
-		c.Assert(err, checker.IsNil)
-		c.Assert(strings.TrimSpace(out), checker.Equals, tc.ports)
-	}
+	out, err = d.Cmd("service", "inspect", "--format", "{{ .Spec.EndpointSpec.Ports }}", name)
+	c.Assert(err, checker.IsNil)
+	c.Assert(strings.TrimSpace(out), checker.Equals, "[{ tcp 80 80 ingress}]")
 }
 }
 
 
 func (s *DockerSwarmSuite) TestSwarmServiceWithGroup(c *check.C) {
 func (s *DockerSwarmSuite) TestSwarmServiceWithGroup(c *check.C) {
@@ -1413,24 +1375,6 @@ func (s *DockerSwarmSuite) TestSwarmNetworkIPAMOptions(c *check.C) {
 	c.Assert(strings.TrimSpace(out), checker.Equals, "map[foo:bar]")
 	c.Assert(strings.TrimSpace(out), checker.Equals, "map[foo:bar]")
 }
 }
 
 
-// TODO: migrate to a unit test
-// This test could be migrated to unit test and save costly integration test,
-// once PR #29143 is merged.
-func (s *DockerSwarmSuite) TestSwarmUpdateWithoutArgs(c *check.C) {
-	d := s.AddDaemon(c, true, true)
-
-	expectedOutput := `
-Usage:	docker swarm update [OPTIONS]
-
-Update the swarm
-
-Options:`
-
-	out, err := d.Cmd("swarm", "update")
-	c.Assert(err, checker.IsNil, check.Commentf("out: %v", out))
-	c.Assert(out, checker.Contains, expectedOutput, check.Commentf(out))
-}
-
 func (s *DockerTrustedSwarmSuite) TestTrustedServiceCreate(c *check.C) {
 func (s *DockerTrustedSwarmSuite) TestTrustedServiceCreate(c *check.C) {
 	d := s.swarmSuite.AddDaemon(c, true, true)
 	d := s.swarmSuite.AddDaemon(c, true, true)
 
 

+ 20 - 0
pkg/testutil/assert/assert.go

@@ -7,6 +7,7 @@ import (
 	"reflect"
 	"reflect"
 	"runtime"
 	"runtime"
 	"strings"
 	"strings"
+	"unicode"
 
 
 	"github.com/davecgh/go-spew/spew"
 	"github.com/davecgh/go-spew/spew"
 )
 )
@@ -25,6 +26,25 @@ func Equal(t TestingT, actual, expected interface{}) {
 	}
 	}
 }
 }
 
 
+// EqualNormalizedString compare the actual value to the expected value after applying the specified
+// transform function. It fails the test if these two transformed string are not equal.
+// For example `EqualNormalizedString(t, RemoveSpace, "foo\n", "foo")` wouldn't fail the test as
+// spaces (and thus '\n') are removed before comparing the string.
+func EqualNormalizedString(t TestingT, transformFun func(rune) rune, actual, expected string) {
+	if strings.Map(transformFun, actual) != strings.Map(transformFun, expected) {
+		fatal(t, "Expected '%v' got '%v'", expected, expected, actual, actual)
+	}
+}
+
+// RemoveSpace returns -1 if the specified runes is considered as a space (unicode)
+// and the rune itself otherwise.
+func RemoveSpace(r rune) rune {
+	if unicode.IsSpace(r) {
+		return -1
+	}
+	return r
+}
+
 //EqualStringSlice compares two slices and fails the test if they do not contain
 //EqualStringSlice compares two slices and fails the test if they do not contain
 // the same items.
 // the same items.
 func EqualStringSlice(t TestingT, actual, expected []string) {
 func EqualStringSlice(t TestingT, actual, expected []string) {

+ 28 - 0
pkg/testutil/golden/golden.go

@@ -0,0 +1,28 @@
+// Package golden provides function and helpers to use golden file for
+// testing purpose.
+package golden
+
+import (
+	"flag"
+	"io/ioutil"
+	"path/filepath"
+	"testing"
+)
+
+var update = flag.Bool("test.update", false, "update golden file")
+
+// Get returns the golden file content. If the `test.update` is specified, it updates the
+// file with the current output and returns it.
+func Get(t *testing.T, actual []byte, filename string) []byte {
+	golden := filepath.Join("testdata", filename)
+	if *update {
+		if err := ioutil.WriteFile(golden, actual, 0644); err != nil {
+			t.Fatal(err)
+		}
+	}
+	expected, err := ioutil.ReadFile(golden)
+	if err != nil {
+		t.Fatal(err)
+	}
+	return expected
+}