diff --git a/cli/command/formatter/search.go b/cli/command/formatter/search.go new file mode 100644 index 000000000000..c19318a831ab --- /dev/null +++ b/cli/command/formatter/search.go @@ -0,0 +1,104 @@ +package formatter + +import ( + "strconv" + "strings" + + registry "github.com/docker/docker/api/types/registry" + "github.com/docker/docker/pkg/stringutils" +) + +const ( + defaultSearchTableFormat = "table {{.Name}}\t{{.Description}}\t{{.StarCount}}\t{{.IsOfficial}}\t{{.IsAutomated}}" + + starsHeader = "STARS" + officialHeader = "OFFICIAL" + automatedHeader = "AUTOMATED" +) + +// NewSearchFormat returns a Format for rendering using a network Context +func NewSearchFormat(source string) Format { + switch source { + case "": + return defaultSearchTableFormat + case TableFormatKey: + return defaultSearchTableFormat + } + return Format(source) +} + +// SearchWrite writes the context +func SearchWrite(ctx Context, results []registry.SearchResult, auto bool, stars int) error { + render := func(format func(subContext subContext) error) error { + for _, result := range results { + // --automated and -s, --stars are deprecated since Docker 1.12 + if (auto && !result.IsAutomated) || (stars > result.StarCount) { + continue + } + searchCtx := &searchContext{trunc: ctx.Trunc, s: result} + if err := format(searchCtx); err != nil { + return err + } + } + return nil + } + searchCtx := searchContext{} + searchCtx.header = map[string]string{ + "Name": nameHeader, + "Description": descriptionHeader, + "StarCount": starsHeader, + "IsOfficial": officialHeader, + "IsAutomated": automatedHeader, + } + return ctx.Write(&searchCtx, render) +} + +type searchContext struct { + HeaderContext + trunc bool + json bool + s registry.SearchResult +} + +func (c *searchContext) MarshalJSON() ([]byte, error) { + c.json = true + return marshalJSON(c) +} + +func (c *searchContext) Name() string { + return c.s.Name +} + +func (c *searchContext) Description() string { + desc := strings.Replace(c.s.Description, "\n", " ", -1) + desc = strings.Replace(desc, "\r", " ", -1) + if c.trunc { + desc = stringutils.Ellipsis(desc, 45) + } + return desc +} + +func (c *searchContext) StarCount() string { + return strconv.Itoa(c.s.StarCount) +} + +func (c *searchContext) formatBool(value bool) string { + switch { + case value && c.json: + return "true" + case value: + return "[OK]" + case c.json: + return "false" + default: + return "" + } +} + +func (c *searchContext) IsOfficial() string { + return c.formatBool(c.s.IsOfficial) +} + +func (c *searchContext) IsAutomated() string { + return c.formatBool(c.s.IsAutomated) +} diff --git a/cli/command/formatter/search_test.go b/cli/command/formatter/search_test.go new file mode 100644 index 000000000000..4fa96f8ae860 --- /dev/null +++ b/cli/command/formatter/search_test.go @@ -0,0 +1,284 @@ +package formatter + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + registrytypes "github.com/docker/docker/api/types/registry" + "github.com/docker/docker/pkg/stringutils" + "github.com/stretchr/testify/assert" +) + +func TestSearchContext(t *testing.T) { + name := "nginx" + starCount := 5000 + + var ctx searchContext + cases := []struct { + searchCtx searchContext + expValue string + call func() string + }{ + {searchContext{ + s: registrytypes.SearchResult{Name: name}, + }, name, ctx.Name}, + {searchContext{ + s: registrytypes.SearchResult{StarCount: starCount}, + }, "5000", ctx.StarCount}, + {searchContext{ + s: registrytypes.SearchResult{IsOfficial: true}, + }, "[OK]", ctx.IsOfficial}, + {searchContext{ + s: registrytypes.SearchResult{IsOfficial: false}, + }, "", ctx.IsOfficial}, + {searchContext{ + s: registrytypes.SearchResult{IsAutomated: true}, + }, "[OK]", ctx.IsAutomated}, + {searchContext{ + s: registrytypes.SearchResult{IsAutomated: false}, + }, "", ctx.IsAutomated}, + } + + for _, c := range cases { + ctx = c.searchCtx + v := c.call() + if strings.Contains(v, ",") { + compareMultipleValues(t, v, c.expValue) + } else if v != c.expValue { + t.Fatalf("Expected %s, was %s\n", c.expValue, v) + } + } +} + +func TestSearchContextDescription(t *testing.T) { + shortDescription := "Official build of Nginx." + longDescription := "Automated Nginx reverse proxy for docker containers" + descriptionWReturns := "Automated\nNginx reverse\rproxy\rfor docker\ncontainers" + + var ctx searchContext + cases := []struct { + searchCtx searchContext + expValue string + call func() string + }{ + {searchContext{ + s: registrytypes.SearchResult{Description: shortDescription}, + trunc: true, + }, shortDescription, ctx.Description}, + {searchContext{ + s: registrytypes.SearchResult{Description: shortDescription}, + trunc: false, + }, shortDescription, ctx.Description}, + {searchContext{ + s: registrytypes.SearchResult{Description: longDescription}, + trunc: false, + }, longDescription, ctx.Description}, + {searchContext{ + s: registrytypes.SearchResult{Description: longDescription}, + trunc: true, + }, stringutils.Ellipsis(longDescription, 45), ctx.Description}, + {searchContext{ + s: registrytypes.SearchResult{Description: descriptionWReturns}, + trunc: false, + }, longDescription, ctx.Description}, + {searchContext{ + s: registrytypes.SearchResult{Description: descriptionWReturns}, + trunc: true, + }, stringutils.Ellipsis(longDescription, 45), ctx.Description}, + } + + for _, c := range cases { + ctx = c.searchCtx + v := c.call() + if strings.Contains(v, ",") { + compareMultipleValues(t, v, c.expValue) + } else if v != c.expValue { + t.Fatalf("Expected %s, was %s\n", c.expValue, v) + } + } +} + +func TestSearchContextWrite(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: NewSearchFormat("table")}, + `NAME DESCRIPTION STARS OFFICIAL AUTOMATED +result1 Official build 5000 [OK] +result2 Not official 5 [OK] +`, + }, + { + Context{Format: NewSearchFormat("table {{.Name}}")}, + `NAME +result1 +result2 +`, + }, + // Custom Format + { + Context{Format: NewSearchFormat("{{.Name}}")}, + `result1 +result2 +`, + }, + // Custom Format with CreatedAt + { + Context{Format: NewSearchFormat("{{.Name}} {{.StarCount}}")}, + `result1 5000 +result2 5 +`, + }, + } + + for _, testcase := range cases { + results := []registrytypes.SearchResult{ + {Name: "result1", Description: "Official build", StarCount: 5000, IsOfficial: true, IsAutomated: false}, + {Name: "result2", Description: "Not official", StarCount: 5, IsOfficial: false, IsAutomated: true}, + } + out := bytes.NewBufferString("") + testcase.context.Output = out + err := SearchWrite(testcase.context, results, false, 0) + if err != nil { + assert.Error(t, err, testcase.expected) + } else { + assert.Equal(t, out.String(), testcase.expected) + } + } +} + +func TestSearchContextWriteAutomated(t *testing.T) { + cases := []struct { + context Context + expected string + }{ + + // Table format + { + Context{Format: NewSearchFormat("table")}, + `NAME DESCRIPTION STARS OFFICIAL AUTOMATED +result2 Not official 5 [OK] +`, + }, + { + Context{Format: NewSearchFormat("table {{.Name}}")}, + `NAME +result2 +`, + }, + } + + for _, testcase := range cases { + results := []registrytypes.SearchResult{ + {Name: "result1", Description: "Official build", StarCount: 5000, IsOfficial: true, IsAutomated: false}, + {Name: "result2", Description: "Not official", StarCount: 5, IsOfficial: false, IsAutomated: true}, + } + out := bytes.NewBufferString("") + testcase.context.Output = out + err := SearchWrite(testcase.context, results, true, 0) + if err != nil { + assert.Error(t, err, testcase.expected) + } else { + assert.Equal(t, out.String(), testcase.expected) + } + } +} + +func TestSearchContextWriteStars(t *testing.T) { + cases := []struct { + context Context + expected string + }{ + + // Table format + { + Context{Format: NewSearchFormat("table")}, + `NAME DESCRIPTION STARS OFFICIAL AUTOMATED +result1 Official build 5000 [OK] +`, + }, + { + Context{Format: NewSearchFormat("table {{.Name}}")}, + `NAME +result1 +`, + }, + } + + for _, testcase := range cases { + results := []registrytypes.SearchResult{ + {Name: "result1", Description: "Official build", StarCount: 5000, IsOfficial: true, IsAutomated: false}, + {Name: "result2", Description: "Not official", StarCount: 5, IsOfficial: false, IsAutomated: true}, + } + out := bytes.NewBufferString("") + testcase.context.Output = out + err := SearchWrite(testcase.context, results, false, 6) + if err != nil { + assert.Error(t, err, testcase.expected) + } else { + assert.Equal(t, out.String(), testcase.expected) + } + } +} + +func TestSearchContextWriteJSON(t *testing.T) { + results := []registrytypes.SearchResult{ + {Name: "result1", Description: "Official build", StarCount: 5000, IsOfficial: true, IsAutomated: false}, + {Name: "result2", Description: "Not official", StarCount: 5, IsOfficial: false, IsAutomated: true}, + } + expectedJSONs := []map[string]interface{}{ + {"Name": "result1", "Description": "Official build", "StarCount": "5000", "IsOfficial": "true", "IsAutomated": "false"}, + {"Name": "result2", "Description": "Not official", "StarCount": "5", "IsOfficial": "false", "IsAutomated": "true"}, + } + + out := bytes.NewBufferString("") + err := SearchWrite(Context{Format: "{{json .}}", Output: out}, results, false, 0) + if err != nil { + t.Fatal(err) + } + for i, line := range strings.Split(strings.TrimSpace(out.String()), "\n") { + t.Logf("Output: line %d: %s", i, line) + var m map[string]interface{} + if err := json.Unmarshal([]byte(line), &m); err != nil { + t.Fatal(err) + } + assert.Equal(t, m, expectedJSONs[i]) + } +} + +func TestSearchContextWriteJSONField(t *testing.T) { + results := []registrytypes.SearchResult{ + {Name: "result1", Description: "Official build", StarCount: 5000, IsOfficial: true, IsAutomated: false}, + {Name: "result2", Description: "Not official", StarCount: 5, IsOfficial: false, IsAutomated: true}, + } + out := bytes.NewBufferString("") + err := SearchWrite(Context{Format: "{{json .Name}}", Output: out}, results, false, 0) + if err != nil { + t.Fatal(err) + } + for i, line := range strings.Split(strings.TrimSpace(out.String()), "\n") { + t.Logf("Output: line %d: %s", i, line) + var s string + if err := json.Unmarshal([]byte(line), &s); err != nil { + t.Fatal(err) + } + assert.Equal(t, s, results[i].Name) + } +} diff --git a/cli/command/registry/search.go b/cli/command/registry/search.go index 443c9a3fa8cc..49ac0f43cf04 100644 --- a/cli/command/registry/search.go +++ b/cli/command/registry/search.go @@ -1,23 +1,21 @@ package registry import ( - "fmt" "sort" - "strings" - "text/tabwriter" "github.com/docker/cli/cli" "github.com/docker/cli/cli/command" + "github.com/docker/cli/cli/command/formatter" "github.com/docker/cli/opts" "github.com/docker/docker/api/types" registrytypes "github.com/docker/docker/api/types/registry" - "github.com/docker/docker/pkg/stringutils" "github.com/docker/docker/registry" "github.com/spf13/cobra" "golang.org/x/net/context" ) type searchOptions struct { + format string term string noTrunc bool limit int @@ -47,6 +45,7 @@ func NewSearchCommand(dockerCli command.Cli) *cobra.Command { flags.BoolVar(&options.noTrunc, "no-trunc", false, "Don't truncate output") flags.VarP(&options.filter, "filter", "f", "Filter output based on conditions provided") flags.IntVar(&options.limit, "limit", registry.DefaultSearchLimit, "Max number of search results") + flags.StringVar(&options.format, "format", "", "Pretty-print search using a Go template") flags.BoolVar(&options.automated, "automated", false, "Only show automated builds") flags.UintVarP(&options.stars, "stars", "s", 0, "Only displays with at least x stars") @@ -89,32 +88,12 @@ func runSearch(dockerCli command.Cli, options searchOptions) error { results := searchResultsByStars(unorderedResults) sort.Sort(results) - - w := tabwriter.NewWriter(dockerCli.Out(), 10, 1, 3, ' ', 0) - fmt.Fprintf(w, "NAME\tDESCRIPTION\tSTARS\tOFFICIAL\tAUTOMATED\n") - for _, res := range results { - // --automated and -s, --stars are deprecated since Docker 1.12 - if (options.automated && !res.IsAutomated) || (int(options.stars) > res.StarCount) { - continue - } - desc := strings.Replace(res.Description, "\n", " ", -1) - desc = strings.Replace(desc, "\r", " ", -1) - if !options.noTrunc { - desc = stringutils.Ellipsis(desc, 45) - } - fmt.Fprintf(w, "%s\t%s\t%d\t", res.Name, desc, res.StarCount) - if res.IsOfficial { - fmt.Fprint(w, "[OK]") - - } - fmt.Fprint(w, "\t") - if res.IsAutomated { - fmt.Fprint(w, "[OK]") - } - fmt.Fprint(w, "\n") + searchCtx := formatter.Context{ + Output: dockerCli.Out(), + Format: formatter.NewSearchFormat(options.format), + Trunc: !options.noTrunc, } - w.Flush() - return nil + return formatter.SearchWrite(searchCtx, results, options.automated, int(options.stars)) } // searchResultsByStars sorts search results in descending order by number of stars. diff --git a/docs/reference/commandline/search.md b/docs/reference/commandline/search.md index 0482dbb143a1..c33521e8dab8 100644 --- a/docs/reference/commandline/search.md +++ b/docs/reference/commandline/search.md @@ -25,6 +25,7 @@ Options: - is-automated=(true|false) - is-official=(true|false) - stars= - image has at least 'number' stars + --format string Pretty-print images using a Go template --help Print usage --limit int Max number of search results (default 25) --no-trunc Don't truncate output @@ -144,3 +145,58 @@ NAME DESCRIPTION STARS O progrium/busybox 50 [OK] radial/busyboxplus Full-chain, Internet enabled, busybox made... 8 [OK] ``` + +### Format the output + +The formatting option (`--format`) pretty-prints search output +using a Go template. + +Valid placeholders for the Go template are: + +| Placeholder | Description | +| -------------- | --------------------------------- | +| `.Name` | Image Name | +| `.Description` | Image description | +| `.StarCount` | Number of stars for the image | +| `.IsOfficial` | "OK" if image is official | +| `.IsAutomated` | "OK" if image build was automated | + +When you use the `--format` option, the `search` command will +output the data exactly as the template declares. If you use the +`table` directive, column headers are included as well. + +The following example uses a template without headers and outputs the +`Name` and `StarCount` entries separated by a colon for all images: + +```bash +{% raw %} +$ docker search --format "{{.Name}}: {{.StarCount}}" nginx + +nginx: 5441 +jwilder/nginx-proxy: 953 +richarvey/nginx-php-fpm: 353 +million12/nginx-php: 75 +webdevops/php-nginx: 70 +h3nrik/nginx-ldap: 35 +bitnami/nginx: 23 +evild/alpine-nginx: 14 +million12/nginx: 9 +maxexcloo/nginx: 7 +{% endraw %} +``` + +This example outputs a table format: + +```bash +{% raw %} +$ docker search --format "table {{.Name}}\t{{.IsAutomated}}\t{{.IsOfficial}}" nginx + +NAME AUTOMATED OFFICIAL +nginx [OK] +jwilder/nginx-proxy [OK] +richarvey/nginx-php-fpm [OK] +jrcs/letsencrypt-nginx-proxy-companion [OK] +million12/nginx-php [OK] +webdevops/php-nginx [OK] +{% endraw %} +```