Browse code

Add `--format` flag for `docker plugin ls`

This fix tries to address the enhancement discussed in 28735 to add
`--format` for the output of `docker plugin ls`.

This fix
1. Add `--format` and `--quiet` flags to `docker plugin ls`
2. Convert the current implementation to use `formatter`, consistent with
other docker list commands.
3. Add `pluginsFormat` for config.json.

Related docs has been updated.

Several unit tests have been added to cover the changes.

This fix is related to 28708 and 28735.

Signed-off-by: Yong Tang <yong.tang.github@outlook.com>

Yong Tang authored on 2016/11/23 09:23:21
Showing 7 changed files
1 1
new file mode 100644
... ...
@@ -0,0 +1,87 @@
0
+package formatter
1
+
2
+import (
3
+	"strings"
4
+
5
+	"github.com/docker/docker/api/types"
6
+	"github.com/docker/docker/pkg/stringid"
7
+	"github.com/docker/docker/pkg/stringutils"
8
+)
9
+
10
+const (
11
+	defaultPluginTableFormat = "table {{.ID}}\t{{.Name}}\t{{.Description}}\t{{.Enabled}}"
12
+
13
+	pluginIDHeader    = "ID"
14
+	descriptionHeader = "DESCRIPTION"
15
+	enabledHeader     = "ENABLED"
16
+)
17
+
18
+// NewPluginFormat returns a Format for rendering using a plugin Context
19
+func NewPluginFormat(source string, quiet bool) Format {
20
+	switch source {
21
+	case TableFormatKey:
22
+		if quiet {
23
+			return defaultQuietFormat
24
+		}
25
+		return defaultPluginTableFormat
26
+	case RawFormatKey:
27
+		if quiet {
28
+			return `plugin_id: {{.ID}}`
29
+		}
30
+		return `plugin_id: {{.ID}}\nname: {{.Name}}\ndescription: {{.Description}}\nenabled: {{.Enabled}}\n`
31
+	}
32
+	return Format(source)
33
+}
34
+
35
+// PluginWrite writes the context
36
+func PluginWrite(ctx Context, plugins []*types.Plugin) error {
37
+	render := func(format func(subContext subContext) error) error {
38
+		for _, plugin := range plugins {
39
+			pluginCtx := &pluginContext{trunc: ctx.Trunc, p: *plugin}
40
+			if err := format(pluginCtx); err != nil {
41
+				return err
42
+			}
43
+		}
44
+		return nil
45
+	}
46
+	return ctx.Write(&pluginContext{}, render)
47
+}
48
+
49
+type pluginContext struct {
50
+	HeaderContext
51
+	trunc bool
52
+	p     types.Plugin
53
+}
54
+
55
+func (c *pluginContext) MarshalJSON() ([]byte, error) {
56
+	return marshalJSON(c)
57
+}
58
+
59
+func (c *pluginContext) ID() string {
60
+	c.AddHeader(pluginIDHeader)
61
+	if c.trunc {
62
+		return stringid.TruncateID(c.p.ID)
63
+	}
64
+	return c.p.ID
65
+}
66
+
67
+func (c *pluginContext) Name() string {
68
+	c.AddHeader(nameHeader)
69
+	return c.p.Name
70
+}
71
+
72
+func (c *pluginContext) Description() string {
73
+	c.AddHeader(descriptionHeader)
74
+	desc := strings.Replace(c.p.Config.Description, "\n", "", -1)
75
+	desc = strings.Replace(desc, "\r", "", -1)
76
+	if c.trunc {
77
+		desc = stringutils.Ellipsis(desc, 45)
78
+	}
79
+
80
+	return desc
81
+}
82
+
83
+func (c *pluginContext) Enabled() bool {
84
+	c.AddHeader(enabledHeader)
85
+	return c.p.Enabled
86
+}
0 87
new file mode 100644
... ...
@@ -0,0 +1,188 @@
0
+package formatter
1
+
2
+import (
3
+	"bytes"
4
+	"encoding/json"
5
+	"strings"
6
+	"testing"
7
+
8
+	"github.com/docker/docker/api/types"
9
+	"github.com/docker/docker/pkg/stringid"
10
+	"github.com/docker/docker/pkg/testutil/assert"
11
+)
12
+
13
+func TestPluginContext(t *testing.T) {
14
+	pluginID := stringid.GenerateRandomID()
15
+
16
+	var ctx pluginContext
17
+	cases := []struct {
18
+		pluginCtx pluginContext
19
+		expValue  string
20
+		expHeader string
21
+		call      func() string
22
+	}{
23
+		{pluginContext{
24
+			p:     types.Plugin{ID: pluginID},
25
+			trunc: false,
26
+		}, pluginID, pluginIDHeader, ctx.ID},
27
+		{pluginContext{
28
+			p:     types.Plugin{ID: pluginID},
29
+			trunc: true,
30
+		}, stringid.TruncateID(pluginID), pluginIDHeader, ctx.ID},
31
+		{pluginContext{
32
+			p: types.Plugin{Name: "plugin_name"},
33
+		}, "plugin_name", nameHeader, ctx.Name},
34
+		{pluginContext{
35
+			p: types.Plugin{Config: types.PluginConfig{Description: "plugin_description"}},
36
+		}, "plugin_description", descriptionHeader, ctx.Description},
37
+	}
38
+
39
+	for _, c := range cases {
40
+		ctx = c.pluginCtx
41
+		v := c.call()
42
+		if strings.Contains(v, ",") {
43
+			compareMultipleValues(t, v, c.expValue)
44
+		} else if v != c.expValue {
45
+			t.Fatalf("Expected %s, was %s\n", c.expValue, v)
46
+		}
47
+
48
+		h := ctx.FullHeader()
49
+		if h != c.expHeader {
50
+			t.Fatalf("Expected %s, was %s\n", c.expHeader, h)
51
+		}
52
+	}
53
+}
54
+
55
+func TestPluginContextWrite(t *testing.T) {
56
+	cases := []struct {
57
+		context  Context
58
+		expected string
59
+	}{
60
+
61
+		// Errors
62
+		{
63
+			Context{Format: "{{InvalidFunction}}"},
64
+			`Template parsing error: template: :1: function "InvalidFunction" not defined
65
+`,
66
+		},
67
+		{
68
+			Context{Format: "{{nil}}"},
69
+			`Template parsing error: template: :1:2: executing "" at <nil>: nil is not a command
70
+`,
71
+		},
72
+		// Table format
73
+		{
74
+			Context{Format: NewPluginFormat("table", false)},
75
+			`ID                  NAME                DESCRIPTION         ENABLED
76
+pluginID1           foobar_baz          description 1       true
77
+pluginID2           foobar_bar          description 2       false
78
+`,
79
+		},
80
+		{
81
+			Context{Format: NewPluginFormat("table", true)},
82
+			`pluginID1
83
+pluginID2
84
+`,
85
+		},
86
+		{
87
+			Context{Format: NewPluginFormat("table {{.Name}}", false)},
88
+			`NAME
89
+foobar_baz
90
+foobar_bar
91
+`,
92
+		},
93
+		{
94
+			Context{Format: NewPluginFormat("table {{.Name}}", true)},
95
+			`NAME
96
+foobar_baz
97
+foobar_bar
98
+`,
99
+		},
100
+		// Raw Format
101
+		{
102
+			Context{Format: NewPluginFormat("raw", false)},
103
+			`plugin_id: pluginID1
104
+name: foobar_baz
105
+description: description 1
106
+enabled: true
107
+
108
+plugin_id: pluginID2
109
+name: foobar_bar
110
+description: description 2
111
+enabled: false
112
+
113
+`,
114
+		},
115
+		{
116
+			Context{Format: NewPluginFormat("raw", true)},
117
+			`plugin_id: pluginID1
118
+plugin_id: pluginID2
119
+`,
120
+		},
121
+		// Custom Format
122
+		{
123
+			Context{Format: NewPluginFormat("{{.Name}}", false)},
124
+			`foobar_baz
125
+foobar_bar
126
+`,
127
+		},
128
+	}
129
+
130
+	for _, testcase := range cases {
131
+		plugins := []*types.Plugin{
132
+			{ID: "pluginID1", Name: "foobar_baz", Config: types.PluginConfig{Description: "description 1"}, Enabled: true},
133
+			{ID: "pluginID2", Name: "foobar_bar", Config: types.PluginConfig{Description: "description 2"}, Enabled: false},
134
+		}
135
+		out := bytes.NewBufferString("")
136
+		testcase.context.Output = out
137
+		err := PluginWrite(testcase.context, plugins)
138
+		if err != nil {
139
+			assert.Error(t, err, testcase.expected)
140
+		} else {
141
+			assert.Equal(t, out.String(), testcase.expected)
142
+		}
143
+	}
144
+}
145
+
146
+func TestPluginContextWriteJSON(t *testing.T) {
147
+	plugins := []*types.Plugin{
148
+		{ID: "pluginID1", Name: "foobar_baz"},
149
+		{ID: "pluginID2", Name: "foobar_bar"},
150
+	}
151
+	expectedJSONs := []map[string]interface{}{
152
+		{"Description": "", "Enabled": false, "ID": "pluginID1", "Name": "foobar_baz"},
153
+		{"Description": "", "Enabled": false, "ID": "pluginID2", "Name": "foobar_bar"},
154
+	}
155
+
156
+	out := bytes.NewBufferString("")
157
+	err := PluginWrite(Context{Format: "{{json .}}", Output: out}, plugins)
158
+	if err != nil {
159
+		t.Fatal(err)
160
+	}
161
+	for i, line := range strings.Split(strings.TrimSpace(out.String()), "\n") {
162
+		var m map[string]interface{}
163
+		if err := json.Unmarshal([]byte(line), &m); err != nil {
164
+			t.Fatal(err)
165
+		}
166
+		assert.DeepEqual(t, m, expectedJSONs[i])
167
+	}
168
+}
169
+
170
+func TestPluginContextWriteJSONField(t *testing.T) {
171
+	plugins := []*types.Plugin{
172
+		{ID: "pluginID1", Name: "foobar_baz"},
173
+		{ID: "pluginID2", Name: "foobar_bar"},
174
+	}
175
+	out := bytes.NewBufferString("")
176
+	err := PluginWrite(Context{Format: "{{json .ID}}", Output: out}, plugins)
177
+	if err != nil {
178
+		t.Fatal(err)
179
+	}
180
+	for i, line := range strings.Split(strings.TrimSpace(out.String()), "\n") {
181
+		var s string
182
+		if err := json.Unmarshal([]byte(line), &s); err != nil {
183
+			t.Fatal(err)
184
+		}
185
+		assert.Equal(t, s, plugins[i].ID)
186
+	}
187
+}
... ...
@@ -1,20 +1,17 @@
1 1
 package plugin
2 2
 
3 3
 import (
4
-	"fmt"
5
-	"strings"
6
-	"text/tabwriter"
7
-
8 4
 	"github.com/docker/docker/cli"
9 5
 	"github.com/docker/docker/cli/command"
10
-	"github.com/docker/docker/pkg/stringid"
11
-	"github.com/docker/docker/pkg/stringutils"
6
+	"github.com/docker/docker/cli/command/formatter"
12 7
 	"github.com/spf13/cobra"
13 8
 	"golang.org/x/net/context"
14 9
 )
15 10
 
16 11
 type listOptions struct {
12
+	quiet   bool
17 13
 	noTrunc bool
14
+	format  string
18 15
 }
19 16
 
20 17
 func newListCommand(dockerCli *command.DockerCli) *cobra.Command {
... ...
@@ -32,7 +29,9 @@ func newListCommand(dockerCli *command.DockerCli) *cobra.Command {
32 32
 
33 33
 	flags := cmd.Flags()
34 34
 
35
+	flags.BoolVarP(&opts.quiet, "quiet", "q", false, "Only display plugin IDs")
35 36
 	flags.BoolVar(&opts.noTrunc, "no-trunc", false, "Don't truncate output")
37
+	flags.StringVar(&opts.format, "format", "", "Pretty-print plugins using a Go template")
36 38
 
37 39
 	return cmd
38 40
 }
... ...
@@ -43,21 +42,19 @@ func runList(dockerCli *command.DockerCli, opts listOptions) error {
43 43
 		return err
44 44
 	}
45 45
 
46
-	w := tabwriter.NewWriter(dockerCli.Out(), 20, 1, 3, ' ', 0)
47
-	fmt.Fprintf(w, "ID \tNAME \tDESCRIPTION\tENABLED")
48
-	fmt.Fprintf(w, "\n")
49
-
50
-	for _, p := range plugins {
51
-		id := p.ID
52
-		desc := strings.Replace(p.Config.Description, "\n", " ", -1)
53
-		desc = strings.Replace(desc, "\r", " ", -1)
54
-		if !opts.noTrunc {
55
-			id = stringid.TruncateID(p.ID)
56
-			desc = stringutils.Ellipsis(desc, 45)
46
+	format := opts.format
47
+	if len(format) == 0 {
48
+		if len(dockerCli.ConfigFile().PluginsFormat) > 0 && !opts.quiet {
49
+			format = dockerCli.ConfigFile().PluginsFormat
50
+		} else {
51
+			format = formatter.TableFormatKey
57 52
 		}
53
+	}
58 54
 
59
-		fmt.Fprintf(w, "%s\t%s\t%s\t%v\n", id, p.Name, desc, p.Enabled)
55
+	pluginsCtx := formatter.Context{
56
+		Output: dockerCli.Out(),
57
+		Format: formatter.NewPluginFormat(format, opts.quiet),
58
+		Trunc:  !opts.noTrunc,
60 59
 	}
61
-	w.Flush()
62
-	return nil
60
+	return formatter.PluginWrite(pluginsCtx, plugins)
63 61
 }
... ...
@@ -27,6 +27,7 @@ type ConfigFile struct {
27 27
 	PsFormat             string                      `json:"psFormat,omitempty"`
28 28
 	ImagesFormat         string                      `json:"imagesFormat,omitempty"`
29 29
 	NetworksFormat       string                      `json:"networksFormat,omitempty"`
30
+	PluginsFormat        string                      `json:"pluginsFormat,omitempty"`
30 31
 	VolumesFormat        string                      `json:"volumesFormat,omitempty"`
31 32
 	StatsFormat          string                      `json:"statsFormat,omitempty"`
32 33
 	DetachKeys           string                      `json:"detachKeys,omitempty"`
... ...
@@ -131,6 +131,12 @@ Docker's client uses this property. If this property is not set, the client
131 131
 falls back to the default table format. For a list of supported formatting
132 132
 directives, see the [**Formatting** section in the `docker images` documentation](images.md)
133 133
 
134
+The property `pluginsFormat` specifies the default format for `docker plugin ls` output.
135
+When the `--format` flag is not provided with the `docker plugin ls` command,
136
+Docker's client uses this property. If this property is not set, the client
137
+falls back to the default table format. For a list of supported formatting
138
+directives, see the [**Formatting** section in the `docker plugin ls` documentation](plugin_ls.md)
139
+
134 140
 The property `serviceInspectFormat` specifies the default format for `docker
135 141
 service inspect` output. When the `--format` flag is not provided with the
136 142
 `docker service inspect` command, Docker's client uses this property. If this
... ...
@@ -186,6 +192,7 @@ Following is a sample `config.json` file:
186 186
       },
187 187
       "psFormat": "table {{.ID}}\\t{{.Image}}\\t{{.Command}}\\t{{.Labels}}",
188 188
       "imagesFormat": "table {{.ID}}\\t{{.Repository}}\\t{{.Tag}}\\t{{.CreatedAt}}",
189
+      "pluginsFormat": "table {{.ID}}\t{{.Name}}\t{{.Enabled}}",
189 190
       "statsFormat": "table {{.Container}}\t{{.CPUPerc}}\t{{.MemUsage}}",
190 191
       "serviceInspectFormat": "pretty",
191 192
       "detachKeys": "ctrl-e,e",
... ...
@@ -24,8 +24,10 @@ Aliases:
24 24
   ls, list
25 25
 
26 26
 Options:
27
-      --help	   Print usage
28
-      --no-trunc   Don't truncate output
27
+      --format string   Pretty-print plugins using a Go template
28
+      --help            Print usage
29
+      --no-trunc        Don't truncate output
30
+  -q, --quiet           Only display plugin IDs
29 31
 ```
30 32
 
31 33
 Lists all the plugins that are currently installed. You can install plugins
... ...
@@ -40,6 +42,32 @@ ID                  NAME                             TAG                 DESCRIP
40 40
 69553ca1d123        tiborvass/sample-volume-plugin   latest              A test plugin for Docker   true
41 41
 ```
42 42
 
43
+## Formatting
44
+
45
+The formatting options (`--format`) pretty-prints plugins output
46
+using a Go template.
47
+
48
+Valid placeholders for the Go template are listed below:
49
+
50
+Placeholder    | Description
51
+---------------|------------------------------------------------------------------------------------------
52
+`.ID`          | Plugin ID
53
+`.Name`        | Plugin name
54
+`.Description` | Plugin description
55
+`.Enabled`     | Whether plugin is enabled or not
56
+
57
+When using the `--format` option, the `plugin ls` command will either
58
+output the data exactly as the template declares or, when using the
59
+`table` directive, includes column headers as well.
60
+
61
+The following example uses a template without headers and outputs the
62
+`ID` and `Name` entries separated by a colon for all plugins:
63
+
64
+```bash
65
+$ docker plugin ls --format "{{.ID}}: {{.Name}}"
66
+4be01827a72e: tiborvass/no-remove
67
+```
68
+
43 69
 ## Related information
44 70
 
45 71
 * [plugin create](plugin_create.md)
... ...
@@ -401,3 +401,29 @@ func (s *DockerSuite) TestPluginIDPrefix(c *check.C) {
401 401
 	c.Assert(out, checker.Not(checker.Contains), pName)
402 402
 	c.Assert(out, checker.Not(checker.Contains), pTag)
403 403
 }
404
+
405
+func (s *DockerSuite) TestPluginListDefaultFormat(c *check.C) {
406
+	testRequires(c, DaemonIsLinux, Network, IsAmd64)
407
+
408
+	config, err := ioutil.TempDir("", "config-file-")
409
+	c.Assert(err, check.IsNil)
410
+	defer os.RemoveAll(config)
411
+
412
+	err = ioutil.WriteFile(filepath.Join(config, "config.json"), []byte(`{"pluginsFormat": "raw"}`), 0644)
413
+	c.Assert(err, check.IsNil)
414
+
415
+	out, _ := dockerCmd(c, "plugin", "install", "--grant-all-permissions", pName)
416
+	c.Assert(strings.TrimSpace(out), checker.Contains, pName)
417
+
418
+	out, _ = dockerCmd(c, "plugin", "inspect", "--format", "{{.ID}}", pNameWithTag)
419
+	id := strings.TrimSpace(out)
420
+
421
+	// We expect the format to be in `raw + --no-trunc`
422
+	expectedOutput := fmt.Sprintf(`plugin_id: %s
423
+name: %s
424
+description: A sample volume plugin for Docker
425
+enabled: true`, id, pNameWithTag)
426
+
427
+	out, _ = dockerCmd(c, "--config", config, "plugin", "ls", "--no-trunc")
428
+	c.Assert(strings.TrimSpace(out), checker.Contains, expectedOutput)
429
+}