diff --git a/cli/command/formatter/plugin.go b/cli/command/formatter/plugin.go new file mode 100644 index 0000000000..5f94714a6b --- /dev/null +++ b/cli/command/formatter/plugin.go @@ -0,0 +1,87 @@ +package formatter + +import ( + "strings" + + "github.com/docker/docker/api/types" + "github.com/docker/docker/pkg/stringid" + "github.com/docker/docker/pkg/stringutils" +) + +const ( + defaultPluginTableFormat = "table {{.ID}}\t{{.Name}}\t{{.Description}}\t{{.Enabled}}" + + pluginIDHeader = "ID" + descriptionHeader = "DESCRIPTION" + enabledHeader = "ENABLED" +) + +// NewPluginFormat returns a Format for rendering using a plugin Context +func NewPluginFormat(source string, quiet bool) Format { + switch source { + case TableFormatKey: + if quiet { + return defaultQuietFormat + } + return defaultPluginTableFormat + case RawFormatKey: + if quiet { + return `plugin_id: {{.ID}}` + } + return `plugin_id: {{.ID}}\nname: {{.Name}}\ndescription: {{.Description}}\nenabled: {{.Enabled}}\n` + } + return Format(source) +} + +// PluginWrite writes the context +func PluginWrite(ctx Context, plugins []*types.Plugin) error { + render := func(format func(subContext subContext) error) error { + for _, plugin := range plugins { + pluginCtx := &pluginContext{trunc: ctx.Trunc, p: *plugin} + if err := format(pluginCtx); err != nil { + return err + } + } + return nil + } + return ctx.Write(&pluginContext{}, render) +} + +type pluginContext struct { + HeaderContext + trunc bool + p types.Plugin +} + +func (c *pluginContext) MarshalJSON() ([]byte, error) { + return marshalJSON(c) +} + +func (c *pluginContext) ID() string { + c.AddHeader(pluginIDHeader) + if c.trunc { + return stringid.TruncateID(c.p.ID) + } + return c.p.ID +} + +func (c *pluginContext) Name() string { + c.AddHeader(nameHeader) + return c.p.Name +} + +func (c *pluginContext) Description() string { + c.AddHeader(descriptionHeader) + desc := strings.Replace(c.p.Config.Description, "\n", "", -1) + desc = strings.Replace(desc, "\r", "", -1) + if c.trunc { + desc = stringutils.Ellipsis(desc, 45) + } + + return desc +} + +func (c *pluginContext) Enabled() bool { + c.AddHeader(enabledHeader) + return c.p.Enabled +} diff --git a/cli/command/formatter/plugin_test.go b/cli/command/formatter/plugin_test.go new file mode 100644 index 0000000000..9ddbe11dff --- /dev/null +++ b/cli/command/formatter/plugin_test.go @@ -0,0 +1,188 @@ +package formatter + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/docker/docker/api/types" + "github.com/docker/docker/pkg/stringid" + "github.com/docker/docker/pkg/testutil/assert" +) + +func TestPluginContext(t *testing.T) { + pluginID := stringid.GenerateRandomID() + + var ctx pluginContext + cases := []struct { + pluginCtx pluginContext + expValue string + expHeader string + call func() string + }{ + {pluginContext{ + p: types.Plugin{ID: pluginID}, + trunc: false, + }, pluginID, pluginIDHeader, ctx.ID}, + {pluginContext{ + p: types.Plugin{ID: pluginID}, + trunc: true, + }, stringid.TruncateID(pluginID), pluginIDHeader, ctx.ID}, + {pluginContext{ + p: types.Plugin{Name: "plugin_name"}, + }, "plugin_name", nameHeader, ctx.Name}, + {pluginContext{ + p: types.Plugin{Config: types.PluginConfig{Description: "plugin_description"}}, + }, "plugin_description", descriptionHeader, ctx.Description}, + } + + for _, c := range cases { + ctx = c.pluginCtx + v := c.call() + if strings.Contains(v, ",") { + compareMultipleValues(t, v, c.expValue) + } else if v != c.expValue { + t.Fatalf("Expected %s, was %s\n", c.expValue, v) + } + + h := ctx.FullHeader() + if h != c.expHeader { + t.Fatalf("Expected %s, was %s\n", c.expHeader, h) + } + } +} + +func TestPluginContextWrite(t *testing.T) { + cases := []struct { + context Context + expected string + }{ + + // Errors + { + Context{Format: "{{InvalidFunction}}"}, + `Template parsing error: template: :1: function "InvalidFunction" not defined +`, + }, + { + Context{Format: "{{nil}}"}, + `Template parsing error: template: :1:2: executing "" at : nil is not a command +`, + }, + // Table format + { + Context{Format: NewPluginFormat("table", false)}, + `ID NAME DESCRIPTION ENABLED +pluginID1 foobar_baz description 1 true +pluginID2 foobar_bar description 2 false +`, + }, + { + Context{Format: NewPluginFormat("table", true)}, + `pluginID1 +pluginID2 +`, + }, + { + Context{Format: NewPluginFormat("table {{.Name}}", false)}, + `NAME +foobar_baz +foobar_bar +`, + }, + { + Context{Format: NewPluginFormat("table {{.Name}}", true)}, + `NAME +foobar_baz +foobar_bar +`, + }, + // Raw Format + { + Context{Format: NewPluginFormat("raw", false)}, + `plugin_id: pluginID1 +name: foobar_baz +description: description 1 +enabled: true + +plugin_id: pluginID2 +name: foobar_bar +description: description 2 +enabled: false + +`, + }, + { + Context{Format: NewPluginFormat("raw", true)}, + `plugin_id: pluginID1 +plugin_id: pluginID2 +`, + }, + // Custom Format + { + Context{Format: NewPluginFormat("{{.Name}}", false)}, + `foobar_baz +foobar_bar +`, + }, + } + + for _, testcase := range cases { + plugins := []*types.Plugin{ + {ID: "pluginID1", Name: "foobar_baz", Config: types.PluginConfig{Description: "description 1"}, Enabled: true}, + {ID: "pluginID2", Name: "foobar_bar", Config: types.PluginConfig{Description: "description 2"}, Enabled: false}, + } + out := bytes.NewBufferString("") + testcase.context.Output = out + err := PluginWrite(testcase.context, plugins) + if err != nil { + assert.Error(t, err, testcase.expected) + } else { + assert.Equal(t, out.String(), testcase.expected) + } + } +} + +func TestPluginContextWriteJSON(t *testing.T) { + plugins := []*types.Plugin{ + {ID: "pluginID1", Name: "foobar_baz"}, + {ID: "pluginID2", Name: "foobar_bar"}, + } + expectedJSONs := []map[string]interface{}{ + {"Description": "", "Enabled": false, "ID": "pluginID1", "Name": "foobar_baz"}, + {"Description": "", "Enabled": false, "ID": "pluginID2", "Name": "foobar_bar"}, + } + + out := bytes.NewBufferString("") + err := PluginWrite(Context{Format: "{{json .}}", Output: out}, plugins) + if err != nil { + t.Fatal(err) + } + for i, line := range strings.Split(strings.TrimSpace(out.String()), "\n") { + var m map[string]interface{} + if err := json.Unmarshal([]byte(line), &m); err != nil { + t.Fatal(err) + } + assert.DeepEqual(t, m, expectedJSONs[i]) + } +} + +func TestPluginContextWriteJSONField(t *testing.T) { + plugins := []*types.Plugin{ + {ID: "pluginID1", Name: "foobar_baz"}, + {ID: "pluginID2", Name: "foobar_bar"}, + } + out := bytes.NewBufferString("") + err := PluginWrite(Context{Format: "{{json .ID}}", Output: out}, plugins) + if err != nil { + t.Fatal(err) + } + for i, line := range strings.Split(strings.TrimSpace(out.String()), "\n") { + var s string + if err := json.Unmarshal([]byte(line), &s); err != nil { + t.Fatal(err) + } + assert.Equal(t, s, plugins[i].ID) + } +} diff --git a/cli/command/plugin/list.go b/cli/command/plugin/list.go index 8fd16dae3f..51590224b0 100644 --- a/cli/command/plugin/list.go +++ b/cli/command/plugin/list.go @@ -1,20 +1,17 @@ package plugin import ( - "fmt" - "strings" - "text/tabwriter" - "github.com/docker/docker/cli" "github.com/docker/docker/cli/command" - "github.com/docker/docker/pkg/stringid" - "github.com/docker/docker/pkg/stringutils" + "github.com/docker/docker/cli/command/formatter" "github.com/spf13/cobra" "golang.org/x/net/context" ) type listOptions struct { + quiet bool noTrunc bool + format string } func newListCommand(dockerCli *command.DockerCli) *cobra.Command { @@ -32,7 +29,9 @@ func newListCommand(dockerCli *command.DockerCli) *cobra.Command { flags := cmd.Flags() + flags.BoolVarP(&opts.quiet, "quiet", "q", false, "Only display plugin IDs") flags.BoolVar(&opts.noTrunc, "no-trunc", false, "Don't truncate output") + flags.StringVar(&opts.format, "format", "", "Pretty-print plugins using a Go template") return cmd } @@ -43,21 +42,19 @@ func runList(dockerCli *command.DockerCli, opts listOptions) error { return err } - w := tabwriter.NewWriter(dockerCli.Out(), 20, 1, 3, ' ', 0) - fmt.Fprintf(w, "ID \tNAME \tDESCRIPTION\tENABLED") - fmt.Fprintf(w, "\n") - - for _, p := range plugins { - id := p.ID - desc := strings.Replace(p.Config.Description, "\n", " ", -1) - desc = strings.Replace(desc, "\r", " ", -1) - if !opts.noTrunc { - id = stringid.TruncateID(p.ID) - desc = stringutils.Ellipsis(desc, 45) + format := opts.format + if len(format) == 0 { + if len(dockerCli.ConfigFile().PluginsFormat) > 0 && !opts.quiet { + format = dockerCli.ConfigFile().PluginsFormat + } else { + format = formatter.TableFormatKey } - - fmt.Fprintf(w, "%s\t%s\t%s\t%v\n", id, p.Name, desc, p.Enabled) } - w.Flush() - return nil + + pluginsCtx := formatter.Context{ + Output: dockerCli.Out(), + Format: formatter.NewPluginFormat(format, opts.quiet), + Trunc: !opts.noTrunc, + } + return formatter.PluginWrite(pluginsCtx, plugins) } diff --git a/cli/config/configfile/file.go b/cli/config/configfile/file.go index 39097133a4..e8fe96e847 100644 --- a/cli/config/configfile/file.go +++ b/cli/config/configfile/file.go @@ -27,6 +27,7 @@ type ConfigFile struct { PsFormat string `json:"psFormat,omitempty"` ImagesFormat string `json:"imagesFormat,omitempty"` NetworksFormat string `json:"networksFormat,omitempty"` + PluginsFormat string `json:"pluginsFormat,omitempty"` VolumesFormat string `json:"volumesFormat,omitempty"` StatsFormat string `json:"statsFormat,omitempty"` DetachKeys string `json:"detachKeys,omitempty"` diff --git a/docs/reference/commandline/cli.md b/docs/reference/commandline/cli.md index 798fa5858d..30ae431bab 100644 --- a/docs/reference/commandline/cli.md +++ b/docs/reference/commandline/cli.md @@ -131,6 +131,12 @@ Docker's client uses this property. If this property is not set, the client falls back to the default table format. For a list of supported formatting directives, see the [**Formatting** section in the `docker images` documentation](images.md) +The property `pluginsFormat` specifies the default format for `docker plugin ls` output. +When the `--format` flag is not provided with the `docker plugin ls` command, +Docker's client uses this property. If this property is not set, the client +falls back to the default table format. For a list of supported formatting +directives, see the [**Formatting** section in the `docker plugin ls` documentation](plugin_ls.md) + The property `serviceInspectFormat` specifies the default format for `docker service inspect` output. When the `--format` flag is not provided with the `docker service inspect` command, Docker's client uses this property. If this @@ -186,6 +192,7 @@ Following is a sample `config.json` file: }, "psFormat": "table {{.ID}}\\t{{.Image}}\\t{{.Command}}\\t{{.Labels}}", "imagesFormat": "table {{.ID}}\\t{{.Repository}}\\t{{.Tag}}\\t{{.CreatedAt}}", + "pluginsFormat": "table {{.ID}}\t{{.Name}}\t{{.Enabled}}", "statsFormat": "table {{.Container}}\t{{.CPUPerc}}\t{{.MemUsage}}", "serviceInspectFormat": "pretty", "detachKeys": "ctrl-e,e", diff --git a/docs/reference/commandline/plugin_ls.md b/docs/reference/commandline/plugin_ls.md index e436213ecc..898b650d86 100644 --- a/docs/reference/commandline/plugin_ls.md +++ b/docs/reference/commandline/plugin_ls.md @@ -24,8 +24,10 @@ Aliases: ls, list Options: - --help Print usage - --no-trunc Don't truncate output + --format string Pretty-print plugins using a Go template + --help Print usage + --no-trunc Don't truncate output + -q, --quiet Only display plugin IDs ``` Lists all the plugins that are currently installed. You can install plugins @@ -40,6 +42,32 @@ ID NAME TAG DESCRIP 69553ca1d123 tiborvass/sample-volume-plugin latest A test plugin for Docker true ``` +## Formatting + +The formatting options (`--format`) pretty-prints plugins output +using a Go template. + +Valid placeholders for the Go template are listed below: + +Placeholder | Description +---------------|------------------------------------------------------------------------------------------ +`.ID` | Plugin ID +`.Name` | Plugin name +`.Description` | Plugin description +`.Enabled` | Whether plugin is enabled or not + +When using the `--format` option, the `plugin ls` command will either +output the data exactly as the template declares or, when using the +`table` directive, includes column headers as well. + +The following example uses a template without headers and outputs the +`ID` and `Name` entries separated by a colon for all plugins: + +```bash +$ docker plugin ls --format "{{.ID}}: {{.Name}}" +4be01827a72e: tiborvass/no-remove +``` + ## Related information * [plugin create](plugin_create.md) diff --git a/integration-cli/docker_cli_plugins_test.go b/integration-cli/docker_cli_plugins_test.go index 0f07e8b45b..e294fffd19 100644 --- a/integration-cli/docker_cli_plugins_test.go +++ b/integration-cli/docker_cli_plugins_test.go @@ -401,3 +401,29 @@ func (s *DockerSuite) TestPluginIDPrefix(c *check.C) { c.Assert(out, checker.Not(checker.Contains), pName) c.Assert(out, checker.Not(checker.Contains), pTag) } + +func (s *DockerSuite) TestPluginListDefaultFormat(c *check.C) { + testRequires(c, DaemonIsLinux, Network, IsAmd64) + + config, err := ioutil.TempDir("", "config-file-") + c.Assert(err, check.IsNil) + defer os.RemoveAll(config) + + err = ioutil.WriteFile(filepath.Join(config, "config.json"), []byte(`{"pluginsFormat": "raw"}`), 0644) + c.Assert(err, check.IsNil) + + out, _ := dockerCmd(c, "plugin", "install", "--grant-all-permissions", pName) + c.Assert(strings.TrimSpace(out), checker.Contains, pName) + + out, _ = dockerCmd(c, "plugin", "inspect", "--format", "{{.ID}}", pNameWithTag) + id := strings.TrimSpace(out) + + // We expect the format to be in `raw + --no-trunc` + expectedOutput := fmt.Sprintf(`plugin_id: %s +name: %s +description: A sample volume plugin for Docker +enabled: true`, id, pNameWithTag) + + out, _ = dockerCmd(c, "--config", config, "plugin", "ls", "--no-trunc") + c.Assert(strings.TrimSpace(out), checker.Contains, expectedOutput) +}