From 6bc1f345af77fcf8f1f34b66ae51c314cc63401e Mon Sep 17 00:00:00 2001 From: Jeremy Chambers Date: Sun, 12 Feb 2017 13:22:01 -0600 Subject: [PATCH] Implements --format option for docker history command by creating a formatter Signed-off-by: Jeremy Chambers Adds to history documentation for --format Signed-off-by: Jeremy Chambers Adds MarshalJSON to historyContext for {{json .}} format Signed-off-by: Jeremy Chambers Adds back the --human option to history command Signed-off-by: Jeremy Chambers Cleans up formatter around --human option for history, Adds integration test for --format option of history Signed-off-by: Jeremy Chambers Adds test for history formatter checking full table results, Runs go fmt on touched files Signed-off-by: Jeremy Chambers Fixes lint errors in formatter/history Signed-off-by: Jeremy Chambers Runs go fmt on cli/command/formatter/history.go Signed-off-by: Jeremy Chambers sRemoves integration test for --format option of history Merges Created and CreatedSince in docker history formatter, Updates docs and tests --- cli/command/formatter/history.go | 113 ++++++++++++++ cli/command/formatter/history_test.go | 213 ++++++++++++++++++++++++++ cli/command/image/history.go | 57 ++----- docs/reference/commandline/history.md | 48 +++++- 4 files changed, 381 insertions(+), 50 deletions(-) create mode 100644 cli/command/formatter/history.go create mode 100644 cli/command/formatter/history_test.go diff --git a/cli/command/formatter/history.go b/cli/command/formatter/history.go new file mode 100644 index 0000000000..2b7de399a0 --- /dev/null +++ b/cli/command/formatter/history.go @@ -0,0 +1,113 @@ +package formatter + +import ( + "strconv" + "strings" + "time" + + "github.com/docker/docker/api/types/image" + "github.com/docker/docker/pkg/stringid" + "github.com/docker/docker/pkg/stringutils" + units "github.com/docker/go-units" +) + +const ( + defaultHistoryTableFormat = "table {{.ID}}\t{{.CreatedSince}}\t{{.CreatedBy}}\t{{.Size}}\t{{.Comment}}" + nonHumanHistoryTableFormat = "table {{.ID}}\t{{.CreatedAt}}\t{{.CreatedBy}}\t{{.Size}}\t{{.Comment}}" + + historyIDHeader = "IMAGE" + createdByHeader = "CREATED BY" + commentHeader = "COMMENT" +) + +// NewHistoryFormat returns a format for rendering an HistoryContext +func NewHistoryFormat(source string, quiet bool, human bool) Format { + switch source { + case TableFormatKey: + switch { + case quiet: + return defaultQuietFormat + case !human: + return nonHumanHistoryTableFormat + default: + return defaultHistoryTableFormat + } + } + + return Format(source) +} + +// HistoryWrite writes the context +func HistoryWrite(ctx Context, human bool, histories []image.HistoryResponseItem) error { + render := func(format func(subContext subContext) error) error { + for _, history := range histories { + historyCtx := &historyContext{trunc: ctx.Trunc, h: history, human: human} + if err := format(historyCtx); err != nil { + return err + } + } + return nil + } + historyCtx := &historyContext{} + historyCtx.header = map[string]string{ + "ID": historyIDHeader, + "CreatedSince": createdSinceHeader, + "CreatedAt": createdAtHeader, + "CreatedBy": createdByHeader, + "Size": sizeHeader, + "Comment": commentHeader, + } + return ctx.Write(historyCtx, render) +} + +type historyContext struct { + HeaderContext + trunc bool + human bool + h image.HistoryResponseItem +} + +func (c *historyContext) MarshalJSON() ([]byte, error) { + return marshalJSON(c) +} + +func (c *historyContext) ID() string { + if c.trunc { + return stringid.TruncateID(c.h.ID) + } + return c.h.ID +} + +func (c *historyContext) CreatedAt() string { + var created string + created = units.HumanDuration(time.Now().UTC().Sub(time.Unix(int64(c.h.Created), 0))) + return created +} + +func (c *historyContext) CreatedSince() string { + var created string + created = units.HumanDuration(time.Now().UTC().Sub(time.Unix(int64(c.h.Created), 0))) + return created + " ago" +} + +func (c *historyContext) CreatedBy() string { + createdBy := strings.Replace(c.h.CreatedBy, "\t", " ", -1) + if c.trunc { + createdBy = stringutils.Ellipsis(createdBy, 45) + } + return createdBy +} + +func (c *historyContext) Size() string { + size := "" + if c.human { + size = units.HumanSizeWithPrecision(float64(c.h.Size), 3) + } else { + size = strconv.FormatInt(c.h.Size, 10) + } + return size +} + +func (c *historyContext) Comment() string { + return c.h.Comment +} diff --git a/cli/command/formatter/history_test.go b/cli/command/formatter/history_test.go new file mode 100644 index 0000000000..299fb1135b --- /dev/null +++ b/cli/command/formatter/history_test.go @@ -0,0 +1,213 @@ +package formatter + +import ( + "strconv" + "strings" + "testing" + "time" + + "bytes" + "github.com/docker/docker/api/types/image" + "github.com/docker/docker/pkg/stringid" + "github.com/docker/docker/pkg/stringutils" + "github.com/docker/docker/pkg/testutil/assert" +) + +type historyCase struct { + historyCtx historyContext + expValue string + call func() string +} + +func TestHistoryContext_ID(t *testing.T) { + id := stringid.GenerateRandomID() + + var ctx historyContext + cases := []historyCase{ + { + historyContext{ + h: image.HistoryResponseItem{ID: id}, + trunc: false, + }, id, ctx.ID, + }, + { + historyContext{ + h: image.HistoryResponseItem{ID: id}, + trunc: true, + }, stringid.TruncateID(id), ctx.ID, + }, + } + + for _, c := range cases { + ctx = c.historyCtx + 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 TestHistoryContext_CreatedSince(t *testing.T) { + unixTime := time.Now().AddDate(0, 0, -7).Unix() + expected := "7 days ago" + + var ctx historyContext + cases := []historyCase{ + { + historyContext{ + h: image.HistoryResponseItem{Created: unixTime}, + trunc: false, + human: true, + }, expected, ctx.CreatedSince, + }, + } + + for _, c := range cases { + ctx = c.historyCtx + 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 TestHistoryContext_CreatedBy(t *testing.T) { + withTabs := `/bin/sh -c apt-key adv --keyserver hkp://pgp.mit.edu:80 --recv-keys 573BFD6B3D8FBC641079A6ABABF5BD827BD9BF62 && echo "deb http://nginx.org/packages/mainline/debian/ jessie nginx" >> /etc/apt/sources.list && apt-get update && apt-get install --no-install-recommends --no-install-suggests -y ca-certificates nginx=${NGINX_VERSION} nginx-module-xslt nginx-module-geoip nginx-module-image-filter nginx-module-perl nginx-module-njs gettext-base && rm -rf /var/lib/apt/lists/*` + expected := `/bin/sh -c apt-key adv --keyserver hkp://pgp.mit.edu:80 --recv-keys 573BFD6B3D8FBC641079A6ABABF5BD827BD9BF62 && echo "deb http://nginx.org/packages/mainline/debian/ jessie nginx" >> /etc/apt/sources.list && apt-get update && apt-get install --no-install-recommends --no-install-suggests -y ca-certificates nginx=${NGINX_VERSION} nginx-module-xslt nginx-module-geoip nginx-module-image-filter nginx-module-perl nginx-module-njs gettext-base && rm -rf /var/lib/apt/lists/*` + + var ctx historyContext + cases := []historyCase{ + { + historyContext{ + h: image.HistoryResponseItem{CreatedBy: withTabs}, + trunc: false, + }, expected, ctx.CreatedBy, + }, + { + historyContext{ + h: image.HistoryResponseItem{CreatedBy: withTabs}, + trunc: true, + }, stringutils.Ellipsis(expected, 45), ctx.CreatedBy, + }, + } + + for _, c := range cases { + ctx = c.historyCtx + 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 TestHistoryContext_Size(t *testing.T) { + size := int64(182964289) + expected := "183MB" + + var ctx historyContext + cases := []historyCase{ + { + historyContext{ + h: image.HistoryResponseItem{Size: size}, + trunc: false, + human: true, + }, expected, ctx.Size, + }, { + historyContext{ + h: image.HistoryResponseItem{Size: size}, + trunc: false, + human: false, + }, strconv.Itoa(182964289), ctx.Size, + }, + } + + for _, c := range cases { + ctx = c.historyCtx + 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 TestHistoryContext_Comment(t *testing.T) { + comment := "Some comment" + + var ctx historyContext + cases := []historyCase{ + { + historyContext{ + h: image.HistoryResponseItem{Comment: comment}, + trunc: false, + }, comment, ctx.Comment, + }, + } + + for _, c := range cases { + ctx = c.historyCtx + 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 TestHistoryContext_Table(t *testing.T) { + out := bytes.NewBufferString("") + unixTime := time.Now().AddDate(0, 0, -1).Unix() + histories := []image.HistoryResponseItem{ + {ID: "imageID1", Created: unixTime, CreatedBy: "/bin/bash ls && npm i && npm run test && karma -c karma.conf.js start && npm start && more commands here && the list goes on", Size: int64(182964289), Comment: "Hi", Tags: []string{"image:tag2"}}, + {ID: "imageID2", Created: unixTime, CreatedBy: "/bin/bash echo", Size: int64(182964289), Comment: "Hi", Tags: []string{"image:tag2"}}, + {ID: "imageID3", Created: unixTime, CreatedBy: "/bin/bash ls", Size: int64(182964289), Comment: "Hi", Tags: []string{"image:tag2"}}, + {ID: "imageID4", Created: unixTime, CreatedBy: "/bin/bash grep", Size: int64(182964289), Comment: "Hi", Tags: []string{"image:tag2"}}, + } + expectedNoTrunc := `IMAGE CREATED CREATED BY SIZE COMMENT +imageID1 24 hours ago /bin/bash ls && npm i && npm run test && karma -c karma.conf.js start && npm start && more commands here && the list goes on 183MB Hi +imageID2 24 hours ago /bin/bash echo 183MB Hi +imageID3 24 hours ago /bin/bash ls 183MB Hi +imageID4 24 hours ago /bin/bash grep 183MB Hi +` + expectedTrunc := `IMAGE CREATED CREATED BY SIZE COMMENT +imageID1 24 hours ago /bin/bash ls && npm i && npm run test && k... 183MB Hi +imageID2 24 hours ago /bin/bash echo 183MB Hi +imageID3 24 hours ago /bin/bash ls 183MB Hi +imageID4 24 hours ago /bin/bash grep 183MB Hi +` + + contexts := []struct { + context Context + expected string + }{ + {Context{ + Format: NewHistoryFormat("table", false, true), + Trunc: true, + Output: out, + }, + expectedTrunc, + }, + {Context{ + Format: NewHistoryFormat("table", false, true), + Trunc: false, + Output: out, + }, + expectedNoTrunc, + }, + } + + for _, context := range contexts { + HistoryWrite(context.context, true, histories) + assert.Equal(t, out.String(), context.expected) + // Clean buffer + out.Reset() + } +} diff --git a/cli/command/image/history.go b/cli/command/image/history.go index 91c8f75a63..4d964b4d40 100644 --- a/cli/command/image/history.go +++ b/cli/command/image/history.go @@ -1,19 +1,11 @@ package image import ( - "fmt" - "strconv" - "strings" - "text/tabwriter" - "time" - "golang.org/x/net/context" "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/go-units" + "github.com/docker/docker/cli/command/formatter" "github.com/spf13/cobra" ) @@ -23,6 +15,7 @@ type historyOptions struct { human bool quiet bool noTrunc bool + format string } // NewHistoryCommand creates a new `docker history` command @@ -44,6 +37,7 @@ func NewHistoryCommand(dockerCli *command.DockerCli) *cobra.Command { flags.BoolVarP(&opts.human, "human", "H", true, "Print sizes and dates in human readable format") flags.BoolVarP(&opts.quiet, "quiet", "q", false, "Only show numeric IDs") flags.BoolVar(&opts.noTrunc, "no-trunc", false, "Don't truncate output") + flags.StringVar(&opts.format, "format", "", "Pretty-print images using a Go template") return cmd } @@ -56,44 +50,15 @@ func runHistory(dockerCli *command.DockerCli, opts historyOptions) error { return err } - w := tabwriter.NewWriter(dockerCli.Out(), 20, 1, 3, ' ', 0) - - if opts.quiet { - for _, entry := range history { - if opts.noTrunc { - fmt.Fprintf(w, "%s\n", entry.ID) - } else { - fmt.Fprintf(w, "%s\n", stringid.TruncateID(entry.ID)) - } - } - w.Flush() - return nil + format := opts.format + if len(format) == 0 { + format = formatter.TableFormatKey } - var imageID string - var createdBy string - var created string - var size string - - fmt.Fprintln(w, "IMAGE\tCREATED\tCREATED BY\tSIZE\tCOMMENT") - for _, entry := range history { - imageID = entry.ID - createdBy = strings.Replace(entry.CreatedBy, "\t", " ", -1) - if !opts.noTrunc { - createdBy = stringutils.Ellipsis(createdBy, 45) - imageID = stringid.TruncateID(entry.ID) - } - - if opts.human { - created = units.HumanDuration(time.Now().UTC().Sub(time.Unix(entry.Created, 0))) + " ago" - size = units.HumanSizeWithPrecision(float64(entry.Size), 3) - } else { - created = time.Unix(entry.Created, 0).Format(time.RFC3339) - size = strconv.FormatInt(entry.Size, 10) - } - - fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", imageID, created, createdBy, size, entry.Comment) + historyCtx := formatter.Context{ + Output: dockerCli.Out(), + Format: formatter.NewHistoryFormat(format, opts.quiet, opts.human), + Trunc: !opts.noTrunc, } - w.Flush() - return nil + return formatter.HistoryWrite(historyCtx, opts.human, history) } diff --git a/docs/reference/commandline/history.md b/docs/reference/commandline/history.md index b68cc8695d..cfc52f83b7 100644 --- a/docs/reference/commandline/history.md +++ b/docs/reference/commandline/history.md @@ -21,10 +21,11 @@ Usage: docker history [OPTIONS] IMAGE Show the history of an image Options: - --help Print usage - -H, --human Print sizes and dates in human readable format (default true) - --no-trunc Don't truncate output - -q, --quiet Only show numeric IDs + --format string Pretty-print images using a Go template + --help Print usage + -H, --human Print sizes and dates in human readable format (default true) + --no-trunc Don't truncate output + -q, --quiet Only show numeric IDs ``` @@ -54,3 +55,42 @@ IMAGE CREATED CREATED BY c69cab00d6ef 5 months ago /bin/sh -c #(nop) MAINTAINER Lokesh Mandvekar 0 B 511136ea3c5a 19 months ago 0 B Imported from - ``` + +### Format the output + +The formatting option (`--format`) will pretty print history output +using a Go template. + +Valid placeholders for the Go template are listed below: + +| Placeholder | Description| +| ---- | ---- | +| `.ID` | Image ID | +| `.CreatedSince` | Elapsed time since the image was created if --human=true, otherwise timestamp of when image was created | +| `.CreatedAt` | Timestamp of when image was created | +| `.CreatedBy` | Command that was used to create the image | +| `.Size` | Image disk size | +| `.Comment` | Comment for image | + +When using the `--format` option, the `history` command will either +output the data exactly as the template declares or, when using the +`table` directive, will include column headers as well. + +The following example uses a template without headers and outputs the +`ID` and `CreatedSince` entries separated by a colon for all images: + +```bash +{% raw %} +$ docker images --format "{{.ID}}: {{.Created}} ago" + +cc1b61406712: 2 weeks ago +: 2 weeks ago +: 2 weeks ago +: 2 weeks ago +: 2 weeks ago +: 3 weeks ago +: 3 weeks ago +: 3 weeks ago + +{% endraw %} +``` \ No newline at end of file