diff --git a/command/formatter/history.go b/command/formatter/history.go new file mode 100644 index 0000000000..2b7de399a0 --- /dev/null +++ b/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/command/formatter/history_test.go b/command/formatter/history_test.go new file mode 100644 index 0000000000..299fb1135b --- /dev/null +++ b/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/command/image/history.go b/command/image/history.go index 91c8f75a63..4d964b4d40 100644 --- a/command/image/history.go +++ b/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) }