From b75858eb097c32332c6d479a32484499ebf4e9c6 Mon Sep 17 00:00:00 2001 From: cyli Date: Mon, 8 May 2017 10:48:24 -0700 Subject: [PATCH] Propagate the swarm cluster and node TLS info provided by the REST API responses to the CLI. In `node ls`, display only whether the nodes' TLS info matches the cluster's TLS info, or whether the node needs cert rotation. Signed-off-by: Ying Li --- cli/command/formatter/node.go | 43 +++++- cli/command/formatter/node_test.go | 238 +++++++++++++++++++++++------ cli/command/system/info.go | 1 + 3 files changed, 233 insertions(+), 49 deletions(-) diff --git a/cli/command/formatter/node.go b/cli/command/formatter/node.go index 85cf8010..9b5953ca 100644 --- a/cli/command/formatter/node.go +++ b/cli/command/formatter/node.go @@ -1,7 +1,9 @@ package formatter import ( + "encoding/base64" "fmt" + "reflect" "strings" "github.com/docker/cli/cli/command" @@ -61,12 +63,20 @@ Engine Labels: {{- range $k, $v := .EngineLabels }} - {{ $k }}{{if $v }}={{ $v }}{{ end }} {{- end }}{{- end }} +{{- if .HasTLSInfo}} +TLS Info: + TrustRoot: +{{.TLSInfoTrustRoot}} + Issuer Subject: {{.TLSInfoCertIssuerSubject}} + Issuer Public Key: {{.TLSInfoCertIssuerPublicKey}} +{{- end}} ` nodeIDHeader = "ID" selfHeader = "" hostnameHeader = "HOSTNAME" availabilityHeader = "AVAILABILITY" managerStatusHeader = "MANAGER STATUS" + tlsStatusHeader = "TLS STATUS" ) // NewNodeFormat returns a Format for rendering using a node Context @@ -99,15 +109,17 @@ func NodeWrite(ctx Context, nodes []swarm.Node, info types.Info) error { } return nil } - nodeCtx := nodeContext{} - nodeCtx.header = nodeHeaderContext{ + header := nodeHeaderContext{ "ID": nodeIDHeader, "Self": selfHeader, "Hostname": hostnameHeader, "Status": statusHeader, "Availability": availabilityHeader, "ManagerStatus": managerStatusHeader, + "TLSStatus": tlsStatusHeader, } + nodeCtx := nodeContext{} + nodeCtx.header = header return ctx.Write(&nodeCtx, render) } @@ -155,6 +167,16 @@ func (c *nodeContext) ManagerStatus() string { return command.PrettyPrint(reachability) } +func (c *nodeContext) TLSStatus() string { + if c.info.Swarm.Cluster == nil || reflect.DeepEqual(c.info.Swarm.Cluster.TLSInfo, swarm.TLSInfo{}) || reflect.DeepEqual(c.n.Description.TLSInfo, swarm.TLSInfo{}) { + return "Unknown" + } + if reflect.DeepEqual(c.n.Description.TLSInfo, c.info.Swarm.Cluster.TLSInfo) { + return "Ready" + } + return "Needs Rotation" +} + // NodeInspectWrite renders the context for a list of services func NodeInspectWrite(ctx Context, refs []string, getRef inspect.GetRefFunc) error { if ctx.Format != nodeInspectPrettyTemplate { @@ -290,3 +312,20 @@ func (ctx *nodeInspectContext) EngineLabels() map[string]string { func (ctx *nodeInspectContext) EngineVersion() string { return ctx.Node.Description.Engine.EngineVersion } + +func (ctx *nodeInspectContext) HasTLSInfo() bool { + tlsInfo := ctx.Node.Description.TLSInfo + return !reflect.DeepEqual(tlsInfo, swarm.TLSInfo{}) +} + +func (ctx *nodeInspectContext) TLSInfoTrustRoot() string { + return ctx.Node.Description.TLSInfo.TrustRoot +} + +func (ctx *nodeInspectContext) TLSInfoCertIssuerPublicKey() string { + return base64.StdEncoding.EncodeToString(ctx.Node.Description.TLSInfo.CertIssuerPublicKey) +} + +func (ctx *nodeInspectContext) TLSInfoCertIssuerSubject() string { + return base64.StdEncoding.EncodeToString(ctx.Node.Description.TLSInfo.CertIssuerSubject) +} diff --git a/cli/command/formatter/node_test.go b/cli/command/formatter/node_test.go index 8326e93c..11784dea 100644 --- a/cli/command/formatter/node_test.go +++ b/cli/command/formatter/node_test.go @@ -51,53 +51,81 @@ func TestNodeContext(t *testing.T) { func TestNodeContextWrite(t *testing.T) { cases := []struct { - context Context - expected string + context Context + expected string + clusterInfo swarm.ClusterInfo }{ // Errors { - Context{Format: "{{InvalidFunction}}"}, - `Template parsing error: template: :1: function "InvalidFunction" not defined + context: Context{Format: "{{InvalidFunction}}"}, + expected: `Template parsing error: template: :1: function "InvalidFunction" not defined `, + clusterInfo: swarm.ClusterInfo{TLSInfo: swarm.TLSInfo{TrustRoot: "hi"}}, }, { - Context{Format: "{{nil}}"}, - `Template parsing error: template: :1:2: executing "" at : nil is not a command + context: Context{Format: "{{nil}}"}, + expected: `Template parsing error: template: :1:2: executing "" at : nil is not a command `, + clusterInfo: swarm.ClusterInfo{TLSInfo: swarm.TLSInfo{TrustRoot: "hi"}}, }, // Table format { - Context{Format: NewNodeFormat("table", false)}, - `ID HOSTNAME STATUS AVAILABILITY MANAGER STATUS + context: Context{Format: NewNodeFormat("table", false)}, + expected: `ID HOSTNAME STATUS AVAILABILITY MANAGER STATUS nodeID1 foobar_baz Foo Drain Leader nodeID2 foobar_bar Bar Active Reachable -`, +nodeID3 foobar_boo Boo Active ` + "\n", // (to preserve whitespace) + clusterInfo: swarm.ClusterInfo{TLSInfo: swarm.TLSInfo{TrustRoot: "hi"}}, }, { - Context{Format: NewNodeFormat("table", true)}, - `nodeID1 + context: Context{Format: NewNodeFormat("table", true)}, + expected: `nodeID1 nodeID2 +nodeID3 `, + clusterInfo: swarm.ClusterInfo{TLSInfo: swarm.TLSInfo{TrustRoot: "hi"}}, }, { - Context{Format: NewNodeFormat("table {{.Hostname}}", false)}, - `HOSTNAME + context: Context{Format: NewNodeFormat("table {{.Hostname}}", false)}, + expected: `HOSTNAME foobar_baz foobar_bar +foobar_boo `, + clusterInfo: swarm.ClusterInfo{TLSInfo: swarm.TLSInfo{TrustRoot: "hi"}}, }, { - Context{Format: NewNodeFormat("table {{.Hostname}}", true)}, - `HOSTNAME + context: Context{Format: NewNodeFormat("table {{.Hostname}}", true)}, + expected: `HOSTNAME foobar_baz foobar_bar +foobar_boo `, + clusterInfo: swarm.ClusterInfo{TLSInfo: swarm.TLSInfo{TrustRoot: "hi"}}, + }, + { + context: Context{Format: NewNodeFormat("table {{.ID}}\t{{.Hostname}}\t{{.TLSStatus}}", false)}, + expected: `ID HOSTNAME TLS STATUS +nodeID1 foobar_baz Needs Rotation +nodeID2 foobar_bar Ready +nodeID3 foobar_boo Unknown +`, + clusterInfo: swarm.ClusterInfo{TLSInfo: swarm.TLSInfo{TrustRoot: "hi"}}, + }, + { // no cluster TLS status info, TLS status for all nodes is unknown + context: Context{Format: NewNodeFormat("table {{.ID}}\t{{.Hostname}}\t{{.TLSStatus}}", false)}, + expected: `ID HOSTNAME TLS STATUS +nodeID1 foobar_baz Unknown +nodeID2 foobar_bar Unknown +nodeID3 foobar_boo Unknown +`, + clusterInfo: swarm.ClusterInfo{}, }, // Raw Format { - Context{Format: NewNodeFormat("raw", false)}, - `node_id: nodeID1 + context: Context{Format: NewNodeFormat("raw", false)}, + expected: `node_id: nodeID1 hostname: foobar_baz status: Foo availability: Drain @@ -109,46 +137,67 @@ status: Bar availability: Active manager_status: Reachable -`, +node_id: nodeID3 +hostname: foobar_boo +status: Boo +availability: Active +manager_status: ` + "\n\n", // to preserve whitespace + clusterInfo: swarm.ClusterInfo{TLSInfo: swarm.TLSInfo{TrustRoot: "hi"}}, }, { - Context{Format: NewNodeFormat("raw", true)}, - `node_id: nodeID1 + context: Context{Format: NewNodeFormat("raw", true)}, + expected: `node_id: nodeID1 node_id: nodeID2 +node_id: nodeID3 `, + clusterInfo: swarm.ClusterInfo{TLSInfo: swarm.TLSInfo{TrustRoot: "hi"}}, }, // Custom Format { - Context{Format: NewNodeFormat("{{.Hostname}}", false)}, - `foobar_baz -foobar_bar + context: Context{Format: NewNodeFormat("{{.Hostname}} {{.TLSStatus}}", false)}, + expected: `foobar_baz Needs Rotation +foobar_bar Ready +foobar_boo Unknown `, + clusterInfo: swarm.ClusterInfo{TLSInfo: swarm.TLSInfo{TrustRoot: "hi"}}, }, } for _, testcase := range cases { nodes := []swarm.Node{ { - ID: "nodeID1", - Description: swarm.NodeDescription{Hostname: "foobar_baz"}, + ID: "nodeID1", + Description: swarm.NodeDescription{ + Hostname: "foobar_baz", + TLSInfo: swarm.TLSInfo{TrustRoot: "no"}, + }, Status: swarm.NodeStatus{State: swarm.NodeState("foo")}, Spec: swarm.NodeSpec{Availability: swarm.NodeAvailability("drain")}, ManagerStatus: &swarm.ManagerStatus{Leader: true}, }, { - ID: "nodeID2", - Description: swarm.NodeDescription{Hostname: "foobar_bar"}, - Status: swarm.NodeStatus{State: swarm.NodeState("bar")}, - Spec: swarm.NodeSpec{Availability: swarm.NodeAvailability("active")}, + ID: "nodeID2", + Description: swarm.NodeDescription{ + Hostname: "foobar_bar", + TLSInfo: swarm.TLSInfo{TrustRoot: "hi"}, + }, + Status: swarm.NodeStatus{State: swarm.NodeState("bar")}, + Spec: swarm.NodeSpec{Availability: swarm.NodeAvailability("active")}, ManagerStatus: &swarm.ManagerStatus{ Leader: false, Reachability: swarm.Reachability("Reachable"), }, }, + { + ID: "nodeID3", + Description: swarm.NodeDescription{Hostname: "foobar_boo"}, + Status: swarm.NodeStatus{State: swarm.NodeState("boo")}, + Spec: swarm.NodeSpec{Availability: swarm.NodeAvailability("active")}, + }, } out := bytes.NewBufferString("") testcase.context.Output = out - err := NodeWrite(testcase.context, nodes, types.Info{}) + err := NodeWrite(testcase.context, nodes, types.Info{Swarm: swarm.Info{Cluster: &testcase.clusterInfo}}) if err != nil { assert.EqualError(t, err, testcase.expected) } else { @@ -158,27 +207,54 @@ foobar_bar } func TestNodeContextWriteJSON(t *testing.T) { - nodes := []swarm.Node{ - {ID: "nodeID1", Description: swarm.NodeDescription{Hostname: "foobar_baz"}}, - {ID: "nodeID2", Description: swarm.NodeDescription{Hostname: "foobar_bar"}}, - } - expectedJSONs := []map[string]interface{}{ - {"Availability": "", "Hostname": "foobar_baz", "ID": "nodeID1", "ManagerStatus": "", "Status": "", "Self": false}, - {"Availability": "", "Hostname": "foobar_bar", "ID": "nodeID2", "ManagerStatus": "", "Status": "", "Self": false}, + cases := []struct { + expected []map[string]interface{} + info types.Info + }{ + { + expected: []map[string]interface{}{ + {"Availability": "", "Hostname": "foobar_baz", "ID": "nodeID1", "ManagerStatus": "", "Status": "", "Self": false, "TLSStatus": "Unknown"}, + {"Availability": "", "Hostname": "foobar_bar", "ID": "nodeID2", "ManagerStatus": "", "Status": "", "Self": false, "TLSStatus": "Unknown"}, + {"Availability": "", "Hostname": "foobar_boo", "ID": "nodeID3", "ManagerStatus": "", "Status": "", "Self": false, "TLSStatus": "Unknown"}, + }, + info: types.Info{}, + }, + { + expected: []map[string]interface{}{ + {"Availability": "", "Hostname": "foobar_baz", "ID": "nodeID1", "ManagerStatus": "", "Status": "", "Self": false, "TLSStatus": "Ready"}, + {"Availability": "", "Hostname": "foobar_bar", "ID": "nodeID2", "ManagerStatus": "", "Status": "", "Self": false, "TLSStatus": "Needs Rotation"}, + {"Availability": "", "Hostname": "foobar_boo", "ID": "nodeID3", "ManagerStatus": "", "Status": "", "Self": false, "TLSStatus": "Unknown"}, + }, + info: types.Info{ + Swarm: swarm.Info{ + Cluster: &swarm.ClusterInfo{ + TLSInfo: swarm.TLSInfo{TrustRoot: "hi"}, + RootRotationInProgress: true, + }, + }, + }, + }, } - out := bytes.NewBufferString("") - err := NodeWrite(Context{Format: "{{json .}}", Output: out}, nodes, types.Info{}) - if err != nil { - t.Fatal(err) - } - for i, line := range strings.Split(strings.TrimSpace(out.String()), "\n") { - t.Logf("Output: line %d: %s", i, line) - var m map[string]interface{} - if err := json.Unmarshal([]byte(line), &m); err != nil { + for _, testcase := range cases { + nodes := []swarm.Node{ + {ID: "nodeID1", Description: swarm.NodeDescription{Hostname: "foobar_baz", TLSInfo: swarm.TLSInfo{TrustRoot: "hi"}}}, + {ID: "nodeID2", Description: swarm.NodeDescription{Hostname: "foobar_bar", TLSInfo: swarm.TLSInfo{TrustRoot: "no"}}}, + {ID: "nodeID3", Description: swarm.NodeDescription{Hostname: "foobar_boo"}}, + } + out := bytes.NewBufferString("") + err := NodeWrite(Context{Format: "{{json .}}", Output: out}, nodes, testcase.info) + if err != nil { t.Fatal(err) } - assert.Equal(t, expectedJSONs[i], m) + for i, line := range strings.Split(strings.TrimSpace(out.String()), "\n") { + t.Logf("Output: line %d: %s", i, line) + var m map[string]interface{} + if err := json.Unmarshal([]byte(line), &m); err != nil { + t.Fatal(err) + } + assert.Equal(t, testcase.expected[i], m) + } } } @@ -201,3 +277,71 @@ func TestNodeContextWriteJSONField(t *testing.T) { assert.Equal(t, nodes[i].ID, s) } } + +func TestNodeInspectWriteContext(t *testing.T) { + node := swarm.Node{ + ID: "nodeID1", + Description: swarm.NodeDescription{ + Hostname: "foobar_baz", + TLSInfo: swarm.TLSInfo{ + TrustRoot: "-----BEGIN CERTIFICATE-----\ndata\n-----END CERTIFICATE-----\n", + CertIssuerPublicKey: []byte("pubKey"), + CertIssuerSubject: []byte("subject"), + }, + Platform: swarm.Platform{ + OS: "linux", + Architecture: "amd64", + }, + Resources: swarm.Resources{ + MemoryBytes: 1, + }, + Engine: swarm.EngineDescription{ + EngineVersion: "0.1.1", + }, + }, + Status: swarm.NodeStatus{ + State: swarm.NodeState("ready"), + Addr: "1.1.1.1", + }, + Spec: swarm.NodeSpec{ + Availability: swarm.NodeAvailability("drain"), + Role: swarm.NodeRole("manager"), + }, + } + out := bytes.NewBufferString("") + context := Context{ + Format: NewNodeFormat("pretty", false), + Output: out, + } + err := NodeInspectWrite(context, []string{"nodeID1"}, func(string) (interface{}, []byte, error) { + return node, nil, nil + }) + if err != nil { + t.Fatal(err) + } + expected := `ID: nodeID1 +Hostname: foobar_baz +Joined at: 0001-01-01 00:00:00 +0000 utc +Status: + State: Ready + Availability: Drain + Address: 1.1.1.1 +Platform: + Operating System: linux + Architecture: amd64 +Resources: + CPUs: 0 + Memory: 1B +Engine Version: 0.1.1 +TLS Info: + TrustRoot: +-----BEGIN CERTIFICATE----- +data +-----END CERTIFICATE----- + + Issuer Subject: c3ViamVjdA== + Issuer Public Key: cHViS2V5 + +` + assert.Equal(t, expected, out.String()) +} diff --git a/cli/command/system/info.go b/cli/command/system/info.go index f59681ba..efb3c446 100644 --- a/cli/command/system/info.go +++ b/cli/command/system/info.go @@ -129,6 +129,7 @@ func prettyPrintInfo(dockerCli *command.DockerCli, info types.Info) error { fmt.Fprintf(dockerCli.Out(), " %s: %s\n", entry.Protocol, entry.URL) } } + fmt.Fprintf(dockerCli.Out(), " Root Rotation In Progress: %v\n", info.Swarm.Cluster.RootRotationInProgress) } fmt.Fprintf(dockerCli.Out(), " Node Address: %s\n", info.Swarm.NodeAddr) managers := []string{}