diff --git a/api/client/cli.go b/api/client/cli.go index 47258b278d..d3895a9a18 100644 --- a/api/client/cli.go +++ b/api/client/cli.go @@ -68,11 +68,17 @@ func (cli *DockerCli) CheckTtyInput(attachStdin, ttyMode bool) error { } // PsFormat returns the format string specified in the configuration. -// String contains columns and format specification, for example {{ID}\t{{Name}}. +// String contains columns and format specification, for example {{ID}}\t{{Name}}. func (cli *DockerCli) PsFormat() string { return cli.configFile.PsFormat } +// ImagesFormat returns the format string specified in the configuration. +// String contains columns and format specification, for example {{ID}}\t{{Name}}. +func (cli *DockerCli) ImagesFormat() string { + return cli.configFile.ImagesFormat +} + // NewDockerCli returns a DockerCli instance with IO output and error streams set by in, out and err. // The key file, protocol (i.e. unix) and address are passed in as strings, along with the tls.Config. If the tls.Config // is set the client scheme will be set to https. diff --git a/api/client/ps/custom.go b/api/client/formatter/custom.go similarity index 61% rename from api/client/ps/custom.go rename to api/client/formatter/custom.go index 3c30fe0691..97cdbc1896 100644 --- a/api/client/ps/custom.go +++ b/api/client/formatter/custom.go @@ -1,4 +1,4 @@ -package ps +package formatter import ( "fmt" @@ -16,26 +16,31 @@ import ( const ( tableKey = "table" - idHeader = "CONTAINER ID" - imageHeader = "IMAGE" - namesHeader = "NAMES" - commandHeader = "COMMAND" - createdAtHeader = "CREATED AT" - runningForHeader = "CREATED" - statusHeader = "STATUS" - portsHeader = "PORTS" - sizeHeader = "SIZE" - labelsHeader = "LABELS" + containerIDHeader = "CONTAINER ID" + imageHeader = "IMAGE" + namesHeader = "NAMES" + commandHeader = "COMMAND" + createdSinceHeader = "CREATED" + createdAtHeader = "CREATED AT" + runningForHeader = "CREATED" + statusHeader = "STATUS" + portsHeader = "PORTS" + sizeHeader = "SIZE" + labelsHeader = "LABELS" + imageIDHeader = "IMAGE ID" + repositoryHeader = "REPOSITORY" + tagHeader = "TAG" + digestHeader = "DIGEST" ) type containerContext struct { - trunc bool - header []string - c types.Container + baseSubContext + trunc bool + c types.Container } func (c *containerContext) ID() string { - c.addHeader(idHeader) + c.addHeader(containerIDHeader) if c.trunc { return stringid.TruncateID(c.c.ID) } @@ -137,14 +142,71 @@ func (c *containerContext) Label(name string) string { return c.c.Labels[name] } -func (c *containerContext) fullHeader() string { +type imageContext struct { + baseSubContext + trunc bool + i types.Image + repo string + tag string + digest string +} + +func (c *imageContext) ID() string { + c.addHeader(imageIDHeader) + if c.trunc { + return stringid.TruncateID(c.i.ID) + } + return c.i.ID +} + +func (c *imageContext) Repository() string { + c.addHeader(repositoryHeader) + return c.repo +} + +func (c *imageContext) Tag() string { + c.addHeader(tagHeader) + return c.tag +} + +func (c *imageContext) Digest() string { + c.addHeader(digestHeader) + return c.digest +} + +func (c *imageContext) CreatedSince() string { + c.addHeader(createdSinceHeader) + createdAt := time.Unix(int64(c.i.Created), 0) + return units.HumanDuration(time.Now().UTC().Sub(createdAt)) +} + +func (c *imageContext) CreatedAt() string { + c.addHeader(createdAtHeader) + return time.Unix(int64(c.i.Created), 0).String() +} + +func (c *imageContext) Size() string { + c.addHeader(sizeHeader) + return units.HumanSize(float64(c.i.Size)) +} + +type subContext interface { + fullHeader() string + addHeader(header string) +} + +type baseSubContext struct { + header []string +} + +func (c *baseSubContext) fullHeader() string { if c.header == nil { return "" } return strings.Join(c.header, "\t") } -func (c *containerContext) addHeader(header string) { +func (c *baseSubContext) addHeader(header string) { if c.header == nil { c.header = []string{} } diff --git a/api/client/ps/custom_test.go b/api/client/formatter/custom_test.go similarity index 56% rename from api/client/ps/custom_test.go rename to api/client/formatter/custom_test.go index c0b2eb2e1c..fee3ba889f 100644 --- a/api/client/ps/custom_test.go +++ b/api/client/formatter/custom_test.go @@ -1,4 +1,4 @@ -package ps +package formatter import ( "reflect" @@ -22,8 +22,8 @@ func TestContainerPsContext(t *testing.T) { expHeader string call func() string }{ - {types.Container{ID: containerID}, true, stringid.TruncateID(containerID), idHeader, ctx.ID}, - {types.Container{ID: containerID}, false, containerID, idHeader, ctx.ID}, + {types.Container{ID: containerID}, true, stringid.TruncateID(containerID), containerIDHeader, ctx.ID}, + {types.Container{ID: containerID}, false, containerID, containerIDHeader, ctx.ID}, {types.Container{Names: []string{"/foobar_baz"}}, true, "foobar_baz", namesHeader, ctx.Names}, {types.Container{Image: "ubuntu"}, true, "ubuntu", imageHeader, ctx.Image}, {types.Container{Image: "verylongimagename"}, true, "verylongimagename", imageHeader, ctx.Image}, @@ -62,24 +62,7 @@ func TestContainerPsContext(t *testing.T) { ctx = containerContext{c: c.container, trunc: c.trunc} v := c.call() if strings.Contains(v, ",") { - // comma-separated values means probably a map input, which won't - // be guaranteed to have the same order as our expected value - // We'll create maps and use reflect.DeepEquals to check instead: - entriesMap := make(map[string]string) - expMap := make(map[string]string) - entries := strings.Split(v, ",") - expectedEntries := strings.Split(c.expValue, ",") - for _, entry := range entries { - keyval := strings.Split(entry, "=") - entriesMap[keyval[0]] = keyval[1] - } - for _, expected := range expectedEntries { - keyval := strings.Split(expected, "=") - expMap[keyval[0]] = keyval[1] - } - if !reflect.DeepEqual(expMap, entriesMap) { - t.Fatalf("Expected entries: %v, got: %v", c.expValue, v) - } + compareMultipleValues(t, v, c.expValue) } else if v != c.expValue { t.Fatalf("Expected %s, was %s\n", c.expValue, v) } @@ -124,3 +107,86 @@ func TestContainerPsContext(t *testing.T) { } } + +func TestImagesContext(t *testing.T) { + imageID := stringid.GenerateRandomID() + unix := time.Now().Unix() + + var ctx imageContext + cases := []struct { + imageCtx imageContext + expValue string + expHeader string + call func() string + }{ + {imageContext{ + i: types.Image{ID: imageID}, + trunc: true, + }, stringid.TruncateID(imageID), imageIDHeader, ctx.ID}, + {imageContext{ + i: types.Image{ID: imageID}, + trunc: false, + }, imageID, imageIDHeader, ctx.ID}, + {imageContext{ + i: types.Image{Size: 10}, + trunc: true, + }, "10 B", sizeHeader, ctx.Size}, + {imageContext{ + i: types.Image{Created: unix}, + trunc: true, + }, time.Unix(unix, 0).String(), createdAtHeader, ctx.CreatedAt}, + // FIXME + // {imageContext{ + // i: types.Image{Created: unix}, + // trunc: true, + // }, units.HumanDuration(time.Unix(unix, 0)), createdSinceHeader, ctx.CreatedSince}, + {imageContext{ + i: types.Image{}, + repo: "busybox", + }, "busybox", repositoryHeader, ctx.Repository}, + {imageContext{ + i: types.Image{}, + tag: "latest", + }, "latest", tagHeader, ctx.Tag}, + {imageContext{ + i: types.Image{}, + digest: "sha256:d149ab53f8718e987c3a3024bb8aa0e2caadf6c0328f1d9d850b2a2a67f2819a", + }, "sha256:d149ab53f8718e987c3a3024bb8aa0e2caadf6c0328f1d9d850b2a2a67f2819a", digestHeader, ctx.Digest}, + } + + for _, c := range cases { + ctx = c.imageCtx + 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 compareMultipleValues(t *testing.T, value, expected string) { + // comma-separated values means probably a map input, which won't + // be guaranteed to have the same order as our expected value + // We'll create maps and use reflect.DeepEquals to check instead: + entriesMap := make(map[string]string) + expMap := make(map[string]string) + entries := strings.Split(value, ",") + expectedEntries := strings.Split(expected, ",") + for _, entry := range entries { + keyval := strings.Split(entry, "=") + entriesMap[keyval[0]] = keyval[1] + } + for _, expected := range expectedEntries { + keyval := strings.Split(expected, "=") + expMap[keyval[0]] = keyval[1] + } + if !reflect.DeepEqual(expMap, entriesMap) { + t.Fatalf("Expected entries: %v, got: %v", expected, value) + } +} diff --git a/api/client/formatter/formatter.go b/api/client/formatter/formatter.go new file mode 100644 index 0000000000..2e19e9396a --- /dev/null +++ b/api/client/formatter/formatter.go @@ -0,0 +1,254 @@ +package formatter + +import ( + "bytes" + "fmt" + "io" + "strings" + "text/tabwriter" + "text/template" + + "github.com/docker/docker/api/types" + "github.com/docker/docker/reference" +) + +const ( + tableFormatKey = "table" + rawFormatKey = "raw" + + defaultContainerTableFormat = "table {{.ID}}\t{{.Image}}\t{{.Command}}\t{{.RunningFor}} ago\t{{.Status}}\t{{.Ports}}\t{{.Names}}" + defaultImageTableFormat = "table {{.Repository}}\t{{.Tag}}\t{{.ID}}\t{{.CreatedSince}} ago\t{{.Size}}" + defaultImageTableFormatWithDigest = "table {{.Repository}}\t{{.Tag}}\t{{.Digest}}\t{{.ID}}\t{{.CreatedSince}} ago\t{{.Size}}" + defaultQuietFormat = "{{.ID}}" +) + +// Context contains information required by the formatter to print the output as desired. +type Context struct { + // Output is the output stream to which the formatted string is written. + Output io.Writer + // Format is used to choose raw, table or custom format for the output. + Format string + // Quiet when set to true will simply print minimal information. + Quiet bool + // Trunc when set to true will truncate the output of certain fields such as Container ID. + Trunc bool + + // internal element + table bool + finalFormat string + header string + buffer *bytes.Buffer +} + +func (c *Context) preformat() { + c.finalFormat = c.Format + + if strings.HasPrefix(c.Format, tableKey) { + c.table = true + c.finalFormat = c.finalFormat[len(tableKey):] + } + + c.finalFormat = strings.Trim(c.finalFormat, " ") + r := strings.NewReplacer(`\t`, "\t", `\n`, "\n") + c.finalFormat = r.Replace(c.finalFormat) +} + +func (c *Context) parseFormat() (*template.Template, error) { + tmpl, err := template.New("").Parse(c.finalFormat) + if err != nil { + c.buffer.WriteString(fmt.Sprintf("Template parsing error: %v\n", err)) + c.buffer.WriteTo(c.Output) + } + return tmpl, err +} + +func (c *Context) postformat(tmpl *template.Template, subContext subContext) { + if c.table { + if len(c.header) == 0 { + // if we still don't have a header, we didn't have any containers so we need to fake it to get the right headers from the template + tmpl.Execute(bytes.NewBufferString(""), subContext) + c.header = subContext.fullHeader() + } + + t := tabwriter.NewWriter(c.Output, 20, 1, 3, ' ', 0) + t.Write([]byte(c.header)) + t.Write([]byte("\n")) + c.buffer.WriteTo(t) + t.Flush() + } else { + c.buffer.WriteTo(c.Output) + } +} + +func (c *Context) contextFormat(tmpl *template.Template, subContext subContext) error { + if err := tmpl.Execute(c.buffer, subContext); err != nil { + c.buffer = bytes.NewBufferString(fmt.Sprintf("Template parsing error: %v\n", err)) + c.buffer.WriteTo(c.Output) + return err + } + if c.table && len(c.header) == 0 { + c.header = subContext.fullHeader() + } + c.buffer.WriteString("\n") + return nil +} + +// ContainerContext contains container specific information required by the formater, encapsulate a Context struct. +type ContainerContext struct { + Context + // Size when set to true will display the size of the output. + Size bool + // Containers + Containers []types.Container +} + +// ImageContext contains image specific information required by the formater, encapsulate a Context struct. +type ImageContext struct { + Context + Digest bool + // Images + Images []types.Image +} + +func (ctx ContainerContext) Write() { + switch ctx.Format { + case tableFormatKey: + ctx.Format = defaultContainerTableFormat + if ctx.Quiet { + ctx.Format = defaultQuietFormat + } + case rawFormatKey: + if ctx.Quiet { + ctx.Format = `container_id: {{.ID}}` + } else { + ctx.Format = `container_id: {{.ID}} +image: {{.Image}} +command: {{.Command}} +created_at: {{.CreatedAt}} +status: {{.Status}} +names: {{.Names}} +labels: {{.Labels}} +ports: {{.Ports}} +` + if ctx.Size { + ctx.Format += `size: {{.Size}} +` + } + } + } + + ctx.buffer = bytes.NewBufferString("") + ctx.preformat() + if ctx.table && ctx.Size { + ctx.finalFormat += "\t{{.Size}}" + } + + tmpl, err := ctx.parseFormat() + if err != nil { + return + } + + for _, container := range ctx.Containers { + containerCtx := &containerContext{ + trunc: ctx.Trunc, + c: container, + } + err = ctx.contextFormat(tmpl, containerCtx) + if err != nil { + return + } + } + + ctx.postformat(tmpl, &containerContext{}) +} + +func (ctx ImageContext) Write() { + switch ctx.Format { + case tableFormatKey: + ctx.Format = defaultImageTableFormat + if ctx.Digest { + ctx.Format = defaultImageTableFormatWithDigest + } + if ctx.Quiet { + ctx.Format = defaultQuietFormat + } + case rawFormatKey: + if ctx.Quiet { + ctx.Format = `image_id: {{.ID}}` + } else { + if ctx.Digest { + ctx.Format = `repository: {{ .Repository }} +tag: {{.Tag}} +digest: {{.Digest}} +image_id: {{.ID}} +created_at: {{.CreatedAt}} +virtual_size: {{.Size}} +` + } else { + ctx.Format = `repository: {{ .Repository }} +tag: {{.Tag}} +image_id: {{.ID}} +created_at: {{.CreatedAt}} +virtual_size: {{.Size}} +` + } + } + } + + ctx.buffer = bytes.NewBufferString("") + ctx.preformat() + if ctx.table && ctx.Digest && !strings.Contains(ctx.Format, "{{.Digest}}") { + ctx.finalFormat += "\t{{.Digest}}" + } + + tmpl, err := ctx.parseFormat() + if err != nil { + return + } + + for _, image := range ctx.Images { + + repoTags := image.RepoTags + repoDigests := image.RepoDigests + + if len(repoTags) == 1 && repoTags[0] == ":" && len(repoDigests) == 1 && repoDigests[0] == "@" { + // dangling image - clear out either repoTags or repoDigests so we only show it once below + repoDigests = []string{} + } + // combine the tags and digests lists + tagsAndDigests := append(repoTags, repoDigests...) + for _, repoAndRef := range tagsAndDigests { + repo := "" + tag := "" + digest := "" + + if !strings.HasPrefix(repoAndRef, "") { + ref, err := reference.ParseNamed(repoAndRef) + if err != nil { + continue + } + repo = ref.Name() + + switch x := ref.(type) { + case reference.Canonical: + digest = x.Digest().String() + case reference.NamedTagged: + tag = x.Tag() + } + } + imageCtx := &imageContext{ + trunc: ctx.Trunc, + i: image, + repo: repo, + tag: tag, + digest: digest, + } + err = ctx.contextFormat(tmpl, imageCtx) + if err != nil { + return + } + } + } + + ctx.postformat(tmpl, &imageContext{}) +} diff --git a/api/client/formatter/formatter_test.go b/api/client/formatter/formatter_test.go new file mode 100644 index 0000000000..6d7185311c --- /dev/null +++ b/api/client/formatter/formatter_test.go @@ -0,0 +1,535 @@ +package formatter + +import ( + "bytes" + "fmt" + "testing" + "time" + + "github.com/docker/docker/api/types" +) + +func TestContainerContextWrite(t *testing.T) { + unixTime := time.Now().AddDate(0, 0, -1).Unix() + expectedTime := time.Unix(unixTime, 0).String() + + contexts := []struct { + context ContainerContext + expected string + }{ + // Errors + { + ContainerContext{ + Context: Context{ + Format: "{{InvalidFunction}}", + }, + }, + `Template parsing error: template: :1: function "InvalidFunction" not defined +`, + }, + { + ContainerContext{ + Context: Context{ + Format: "{{nil}}", + }, + }, + `Template parsing error: template: :1:2: executing "" at : nil is not a command +`, + }, + // Table Format + { + ContainerContext{ + Context: Context{ + Format: "table", + }, + }, + `CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES +containerID1 ubuntu "" 24 hours ago foobar_baz +containerID2 ubuntu "" 24 hours ago foobar_bar +`, + }, + { + ContainerContext{ + Context: Context{ + Format: "table {{.Image}}", + }, + }, + "IMAGE\nubuntu\nubuntu\n", + }, + { + ContainerContext{ + Context: Context{ + Format: "table {{.Image}}", + }, + Size: true, + }, + "IMAGE SIZE\nubuntu 0 B\nubuntu 0 B\n", + }, + { + ContainerContext{ + Context: Context{ + Format: "table {{.Image}}", + Quiet: true, + }, + }, + "IMAGE\nubuntu\nubuntu\n", + }, + { + ContainerContext{ + Context: Context{ + Format: "table", + Quiet: true, + }, + }, + "containerID1\ncontainerID2\n", + }, + // Raw Format + { + ContainerContext{ + Context: Context{ + Format: "raw", + }, + }, + fmt.Sprintf(`container_id: containerID1 +image: ubuntu +command: "" +created_at: %s +status: +names: foobar_baz +labels: +ports: + +container_id: containerID2 +image: ubuntu +command: "" +created_at: %s +status: +names: foobar_bar +labels: +ports: + +`, expectedTime, expectedTime), + }, + { + ContainerContext{ + Context: Context{ + Format: "raw", + }, + Size: true, + }, + fmt.Sprintf(`container_id: containerID1 +image: ubuntu +command: "" +created_at: %s +status: +names: foobar_baz +labels: +ports: +size: 0 B + +container_id: containerID2 +image: ubuntu +command: "" +created_at: %s +status: +names: foobar_bar +labels: +ports: +size: 0 B + +`, expectedTime, expectedTime), + }, + { + ContainerContext{ + Context: Context{ + Format: "raw", + Quiet: true, + }, + }, + "container_id: containerID1\ncontainer_id: containerID2\n", + }, + // Custom Format + { + ContainerContext{ + Context: Context{ + Format: "{{.Image}}", + }, + }, + "ubuntu\nubuntu\n", + }, + { + ContainerContext{ + Context: Context{ + Format: "{{.Image}}", + }, + Size: true, + }, + "ubuntu\nubuntu\n", + }, + } + + for _, context := range contexts { + containers := []types.Container{ + {ID: "containerID1", Names: []string{"/foobar_baz"}, Image: "ubuntu", Created: unixTime}, + {ID: "containerID2", Names: []string{"/foobar_bar"}, Image: "ubuntu", Created: unixTime}, + } + out := bytes.NewBufferString("") + context.context.Output = out + context.context.Containers = containers + context.context.Write() + actual := out.String() + if actual != context.expected { + t.Fatalf("Expected \n%s, got \n%s", context.expected, actual) + } + // Clean buffer + out.Reset() + } +} + +func TestContainerContextWriteWithNoContainers(t *testing.T) { + out := bytes.NewBufferString("") + containers := []types.Container{} + + contexts := []struct { + context ContainerContext + expected string + }{ + { + ContainerContext{ + Context: Context{ + Format: "{{.Image}}", + Output: out, + }, + }, + "", + }, + { + ContainerContext{ + Context: Context{ + Format: "table {{.Image}}", + Output: out, + }, + }, + "IMAGE\n", + }, + { + ContainerContext{ + Context: Context{ + Format: "{{.Image}}", + Output: out, + }, + Size: true, + }, + "", + }, + { + ContainerContext{ + Context: Context{ + Format: "table {{.Image}}", + Output: out, + }, + Size: true, + }, + "IMAGE SIZE\n", + }, + } + + for _, context := range contexts { + context.context.Containers = containers + context.context.Write() + actual := out.String() + if actual != context.expected { + t.Fatalf("Expected \n%s, got \n%s", context.expected, actual) + } + // Clean buffer + out.Reset() + } +} + +func TestImageContextWrite(t *testing.T) { + unixTime := time.Now().AddDate(0, 0, -1).Unix() + expectedTime := time.Unix(unixTime, 0).String() + + contexts := []struct { + context ImageContext + expected string + }{ + // Errors + { + ImageContext{ + Context: Context{ + Format: "{{InvalidFunction}}", + }, + }, + `Template parsing error: template: :1: function "InvalidFunction" not defined +`, + }, + { + ImageContext{ + Context: Context{ + Format: "{{nil}}", + }, + }, + `Template parsing error: template: :1:2: executing "" at : nil is not a command +`, + }, + // Table Format + { + ImageContext{ + Context: Context{ + Format: "table", + }, + }, + `REPOSITORY TAG IMAGE ID CREATED SIZE +image tag1 imageID1 24 hours ago 0 B +image imageID1 24 hours ago 0 B +image tag2 imageID2 24 hours ago 0 B + imageID3 24 hours ago 0 B +`, + }, + { + ImageContext{ + Context: Context{ + Format: "table {{.Repository}}", + }, + }, + "REPOSITORY\nimage\nimage\nimage\n\n", + }, + { + ImageContext{ + Context: Context{ + Format: "table {{.Repository}}", + }, + Digest: true, + }, + `REPOSITORY DIGEST +image +image sha256:cbbf2f9a99b47fc460d422812b6a5adff7dfee951d8fa2e4a98caa0382cfbdbf +image + +`, + }, + { + ImageContext{ + Context: Context{ + Format: "table {{.Repository}}", + Quiet: true, + }, + }, + "REPOSITORY\nimage\nimage\nimage\n\n", + }, + { + ImageContext{ + Context: Context{ + Format: "table", + Quiet: true, + }, + }, + "imageID1\nimageID1\nimageID2\nimageID3\n", + }, + { + ImageContext{ + Context: Context{ + Format: "table", + Quiet: false, + }, + Digest: true, + }, + `REPOSITORY TAG DIGEST IMAGE ID CREATED SIZE +image tag1 imageID1 24 hours ago 0 B +image sha256:cbbf2f9a99b47fc460d422812b6a5adff7dfee951d8fa2e4a98caa0382cfbdbf imageID1 24 hours ago 0 B +image tag2 imageID2 24 hours ago 0 B + imageID3 24 hours ago 0 B +`, + }, + { + ImageContext{ + Context: Context{ + Format: "table", + Quiet: true, + }, + Digest: true, + }, + "imageID1\nimageID1\nimageID2\nimageID3\n", + }, + // Raw Format + { + ImageContext{ + Context: Context{ + Format: "raw", + }, + }, + fmt.Sprintf(`repository: image +tag: tag1 +image_id: imageID1 +created_at: %s +virtual_size: 0 B + +repository: image +tag: +image_id: imageID1 +created_at: %s +virtual_size: 0 B + +repository: image +tag: tag2 +image_id: imageID2 +created_at: %s +virtual_size: 0 B + +repository: +tag: +image_id: imageID3 +created_at: %s +virtual_size: 0 B + +`, expectedTime, expectedTime, expectedTime, expectedTime), + }, + { + ImageContext{ + Context: Context{ + Format: "raw", + }, + Digest: true, + }, + fmt.Sprintf(`repository: image +tag: tag1 +digest: +image_id: imageID1 +created_at: %s +virtual_size: 0 B + +repository: image +tag: +digest: sha256:cbbf2f9a99b47fc460d422812b6a5adff7dfee951d8fa2e4a98caa0382cfbdbf +image_id: imageID1 +created_at: %s +virtual_size: 0 B + +repository: image +tag: tag2 +digest: +image_id: imageID2 +created_at: %s +virtual_size: 0 B + +repository: +tag: +digest: +image_id: imageID3 +created_at: %s +virtual_size: 0 B + +`, expectedTime, expectedTime, expectedTime, expectedTime), + }, + { + ImageContext{ + Context: Context{ + Format: "raw", + Quiet: true, + }, + }, + `image_id: imageID1 +image_id: imageID1 +image_id: imageID2 +image_id: imageID3 +`, + }, + // Custom Format + { + ImageContext{ + Context: Context{ + Format: "{{.Repository}}", + }, + }, + "image\nimage\nimage\n\n", + }, + { + ImageContext{ + Context: Context{ + Format: "{{.Repository}}", + }, + Digest: true, + }, + "image\nimage\nimage\n\n", + }, + } + + for _, context := range contexts { + images := []types.Image{ + {ID: "imageID1", RepoTags: []string{"image:tag1"}, RepoDigests: []string{"image@sha256:cbbf2f9a99b47fc460d422812b6a5adff7dfee951d8fa2e4a98caa0382cfbdbf"}, Created: unixTime}, + {ID: "imageID2", RepoTags: []string{"image:tag2"}, Created: unixTime}, + {ID: "imageID3", RepoTags: []string{":"}, RepoDigests: []string{"@"}, Created: unixTime}, + } + out := bytes.NewBufferString("") + context.context.Output = out + context.context.Images = images + context.context.Write() + actual := out.String() + if actual != context.expected { + t.Fatalf("Expected \n%s, got \n%s", context.expected, actual) + } + // Clean buffer + out.Reset() + } +} + +func TestImageContextWriteWithNoImage(t *testing.T) { + out := bytes.NewBufferString("") + images := []types.Image{} + + contexts := []struct { + context ImageContext + expected string + }{ + { + ImageContext{ + Context: Context{ + Format: "{{.Repository}}", + Output: out, + }, + }, + "", + }, + { + ImageContext{ + Context: Context{ + Format: "table {{.Repository}}", + Output: out, + }, + }, + "REPOSITORY\n", + }, + { + ImageContext{ + Context: Context{ + Format: "{{.Repository}}", + Output: out, + }, + Digest: true, + }, + "", + }, + { + ImageContext{ + Context: Context{ + Format: "table {{.Repository}}", + Output: out, + }, + Digest: true, + }, + "REPOSITORY DIGEST\n", + }, + } + + for _, context := range contexts { + context.context.Images = images + context.context.Write() + actual := out.String() + if actual != context.expected { + t.Fatalf("Expected \n%s, got \n%s", context.expected, actual) + } + // Clean buffer + out.Reset() + } +} diff --git a/api/client/images.go b/api/client/images.go index d2d04f8131..ef19bd32f3 100644 --- a/api/client/images.go +++ b/api/client/images.go @@ -1,19 +1,12 @@ package client import ( - "fmt" - "strings" - "text/tabwriter" - "time" - + "github.com/docker/docker/api/client/formatter" "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/filters" Cli "github.com/docker/docker/cli" "github.com/docker/docker/opts" flag "github.com/docker/docker/pkg/mflag" - "github.com/docker/docker/pkg/stringid" - "github.com/docker/docker/reference" - "github.com/docker/go-units" ) // CmdImages lists the images in a specified repository, or all top-level images if no repository is specified. @@ -25,6 +18,7 @@ func (cli *DockerCli) CmdImages(args ...string) error { all := cmd.Bool([]string{"a", "-all"}, false, "Show all images (default hides intermediate images)") noTrunc := cmd.Bool([]string{"-no-trunc"}, false, "Don't truncate output") showDigests := cmd.Bool([]string{"-digests"}, false, "Show digests") + format := cmd.String([]string{"-format"}, "", "Pretty-print images using a Go template") flFilter := opts.NewListOpts(nil) cmd.Var(&flFilter, []string{"f", "-filter"}, "Filter output based on conditions provided") @@ -59,66 +53,27 @@ func (cli *DockerCli) CmdImages(args ...string) error { return err } - w := tabwriter.NewWriter(cli.out, 20, 1, 3, ' ', 0) - if !*quiet { - if *showDigests { - fmt.Fprintln(w, "REPOSITORY\tTAG\tDIGEST\tIMAGE ID\tCREATED\tSIZE") + f := *format + if len(f) == 0 { + if len(cli.ImagesFormat()) > 0 && !*quiet { + f = cli.ImagesFormat() } else { - fmt.Fprintln(w, "REPOSITORY\tTAG\tIMAGE ID\tCREATED\tSIZE") + f = "table" } } - for _, image := range images { - ID := image.ID - if !*noTrunc { - ID = stringid.TruncateID(ID) - } - - repoTags := image.RepoTags - repoDigests := image.RepoDigests - - if len(repoTags) == 1 && repoTags[0] == ":" && len(repoDigests) == 1 && repoDigests[0] == "@" { - // dangling image - clear out either repoTags or repoDigsts so we only show it once below - repoDigests = []string{} - } - - // combine the tags and digests lists - tagsAndDigests := append(repoTags, repoDigests...) - for _, repoAndRef := range tagsAndDigests { - // default repo, tag, and digest to none - if there's a value, it'll be set below - repo := "" - tag := "" - digest := "" - - if !strings.HasPrefix(repoAndRef, "") { - ref, err := reference.ParseNamed(repoAndRef) - if err != nil { - return err - } - repo = ref.Name() - - switch x := ref.(type) { - case reference.Canonical: - digest = x.Digest().String() - case reference.NamedTagged: - tag = x.Tag() - } - } - - if !*quiet { - if *showDigests { - fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s ago\t%s\n", repo, tag, digest, ID, units.HumanDuration(time.Now().UTC().Sub(time.Unix(int64(image.Created), 0))), units.HumanSize(float64(image.Size))) - } else { - fmt.Fprintf(w, "%s\t%s\t%s\t%s ago\t%s\n", repo, tag, ID, units.HumanDuration(time.Now().UTC().Sub(time.Unix(int64(image.Created), 0))), units.HumanSize(float64(image.Size))) - } - } else { - fmt.Fprintln(w, ID) - } - } + imagesCtx := formatter.ImageContext{ + Context: formatter.Context{ + Output: cli.out, + Format: f, + Quiet: *quiet, + Trunc: !*noTrunc, + }, + Digest: *showDigests, + Images: images, } - if !*quiet { - w.Flush() - } + imagesCtx.Write() + return nil } diff --git a/api/client/ps.go b/api/client/ps.go index 90c585a513..3f02fa1d60 100644 --- a/api/client/ps.go +++ b/api/client/ps.go @@ -1,7 +1,7 @@ package client import ( - "github.com/docker/docker/api/client/ps" + "github.com/docker/docker/api/client/formatter" "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/filters" Cli "github.com/docker/docker/cli" @@ -70,15 +70,18 @@ func (cli *DockerCli) CmdPs(args ...string) error { } } - psCtx := ps.Context{ - Output: cli.out, - Format: f, - Quiet: *quiet, - Size: *size, - Trunc: !*noTrunc, + psCtx := formatter.ContainerContext{ + Context: formatter.Context{ + Output: cli.out, + Format: f, + Quiet: *quiet, + Trunc: !*noTrunc, + }, + Size: *size, + Containers: containers, } - ps.Format(psCtx, containers) + psCtx.Write() return nil } diff --git a/api/client/ps/formatter.go b/api/client/ps/formatter.go deleted file mode 100644 index 2a45bfcf56..0000000000 --- a/api/client/ps/formatter.go +++ /dev/null @@ -1,140 +0,0 @@ -package ps - -import ( - "bytes" - "fmt" - "io" - "strings" - "text/tabwriter" - "text/template" - - "github.com/docker/docker/api/types" -) - -const ( - tableFormatKey = "table" - rawFormatKey = "raw" - - defaultTableFormat = "table {{.ID}}\t{{.Image}}\t{{.Command}}\t{{.RunningFor}} ago\t{{.Status}}\t{{.Ports}}\t{{.Names}}" - defaultQuietFormat = "{{.ID}}" -) - -// Context contains information required by the formatter to print the output as desired. -type Context struct { - // Output is the output stream to which the formatted string is written. - Output io.Writer - // Format is used to choose raw, table or custom format for the output. - Format string - // Size when set to true will display the size of the output. - Size bool - // Quiet when set to true will simply print minimal information. - Quiet bool - // Trunc when set to true will truncate the output of certain fields such as Container ID. - Trunc bool -} - -// Format helps to format the output using the parameters set in the Context. -// Currently Format allow to display in raw, table or custom format the output. -func Format(ctx Context, containers []types.Container) { - switch ctx.Format { - case tableFormatKey: - tableFormat(ctx, containers) - case rawFormatKey: - rawFormat(ctx, containers) - default: - customFormat(ctx, containers) - } -} - -func rawFormat(ctx Context, containers []types.Container) { - if ctx.Quiet { - ctx.Format = `container_id: {{.ID}}` - } else { - ctx.Format = `container_id: {{.ID}} -image: {{.Image}} -command: {{.Command}} -created_at: {{.CreatedAt}} -status: {{.Status}} -names: {{.Names}} -labels: {{.Labels}} -ports: {{.Ports}} -` - if ctx.Size { - ctx.Format += `size: {{.Size}} -` - } - } - - customFormat(ctx, containers) -} - -func tableFormat(ctx Context, containers []types.Container) { - ctx.Format = defaultTableFormat - if ctx.Quiet { - ctx.Format = defaultQuietFormat - } - - customFormat(ctx, containers) -} - -func customFormat(ctx Context, containers []types.Container) { - var ( - table bool - header string - format = ctx.Format - buffer = bytes.NewBufferString("") - ) - - if strings.HasPrefix(ctx.Format, tableKey) { - table = true - format = format[len(tableKey):] - } - - format = strings.Trim(format, " ") - r := strings.NewReplacer(`\t`, "\t", `\n`, "\n") - format = r.Replace(format) - - if table && ctx.Size { - format += "\t{{.Size}}" - } - - tmpl, err := template.New("").Parse(format) - if err != nil { - buffer.WriteString(fmt.Sprintf("Template parsing error: %v\n", err)) - buffer.WriteTo(ctx.Output) - return - } - - for _, container := range containers { - containerCtx := &containerContext{ - trunc: ctx.Trunc, - c: container, - } - if err := tmpl.Execute(buffer, containerCtx); err != nil { - buffer = bytes.NewBufferString(fmt.Sprintf("Template parsing error: %v\n", err)) - buffer.WriteTo(ctx.Output) - return - } - if table && len(header) == 0 { - header = containerCtx.fullHeader() - } - buffer.WriteString("\n") - } - - if table { - if len(header) == 0 { - // if we still don't have a header, we didn't have any containers so we need to fake it to get the right headers from the template - containerCtx := &containerContext{} - tmpl.Execute(bytes.NewBufferString(""), containerCtx) - header = containerCtx.fullHeader() - } - - t := tabwriter.NewWriter(ctx.Output, 20, 1, 3, ' ', 0) - t.Write([]byte(header)) - t.Write([]byte("\n")) - buffer.WriteTo(t) - t.Flush() - } else { - buffer.WriteTo(ctx.Output) - } -} diff --git a/api/client/ps/formatter_test.go b/api/client/ps/formatter_test.go deleted file mode 100644 index 008a0c3271..0000000000 --- a/api/client/ps/formatter_test.go +++ /dev/null @@ -1,213 +0,0 @@ -package ps - -import ( - "bytes" - "fmt" - "testing" - "time" - - "github.com/docker/docker/api/types" -) - -func TestFormat(t *testing.T) { - unixTime := time.Now().Add(-50 * time.Hour).Unix() - expectedTime := time.Unix(unixTime, 0).String() - - contexts := []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: "table", - }, - `CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES -containerID1 ubuntu "" 2 days ago foobar_baz -containerID2 ubuntu "" 2 days ago foobar_bar -`, - }, - { - Context{ - Format: "table {{.Image}}", - }, - "IMAGE\nubuntu\nubuntu\n", - }, - { - Context{ - Format: "table {{.Image}}", - Size: true, - }, - "IMAGE SIZE\nubuntu 0 B\nubuntu 0 B\n", - }, - { - Context{ - Format: "table {{.Image}}", - Quiet: true, - }, - "IMAGE\nubuntu\nubuntu\n", - }, - { - Context{ - Format: "table", - Quiet: true, - }, - "containerID1\ncontainerID2\n", - }, - // Raw Format - { - Context{ - Format: "raw", - }, - fmt.Sprintf(`container_id: containerID1 -image: ubuntu -command: "" -created_at: %s -status: -names: foobar_baz -labels: -ports: - -container_id: containerID2 -image: ubuntu -command: "" -created_at: %s -status: -names: foobar_bar -labels: -ports: - -`, expectedTime, expectedTime), - }, - { - Context{ - Format: "raw", - Size: true, - }, - fmt.Sprintf(`container_id: containerID1 -image: ubuntu -command: "" -created_at: %s -status: -names: foobar_baz -labels: -ports: -size: 0 B - -container_id: containerID2 -image: ubuntu -command: "" -created_at: %s -status: -names: foobar_bar -labels: -ports: -size: 0 B - -`, expectedTime, expectedTime), - }, - { - Context{ - Format: "raw", - Quiet: true, - }, - "container_id: containerID1\ncontainer_id: containerID2\n", - }, - // Custom Format - { - Context{ - Format: "{{.Image}}", - }, - "ubuntu\nubuntu\n", - }, - { - Context{ - Format: "{{.Image}}", - Size: true, - }, - "ubuntu\nubuntu\n", - }, - } - - for _, context := range contexts { - containers := []types.Container{ - {ID: "containerID1", Names: []string{"/foobar_baz"}, Image: "ubuntu", Created: unixTime}, - {ID: "containerID2", Names: []string{"/foobar_bar"}, Image: "ubuntu", Created: unixTime}, - } - out := bytes.NewBufferString("") - context.context.Output = out - Format(context.context, containers) - actual := out.String() - if actual != context.expected { - t.Fatalf("Expected \n%s, got \n%s", context.expected, actual) - } - // Clean buffer - out.Reset() - } -} - -func TestCustomFormatNoContainers(t *testing.T) { - out := bytes.NewBufferString("") - containers := []types.Container{} - - contexts := []struct { - context Context - expected string - }{ - { - Context{ - Format: "{{.Image}}", - Output: out, - }, - "", - }, - { - Context{ - Format: "table {{.Image}}", - Output: out, - }, - "IMAGE\n", - }, - { - Context{ - Format: "{{.Image}}", - Output: out, - Size: true, - }, - "", - }, - { - Context{ - Format: "table {{.Image}}", - Output: out, - Size: true, - }, - "IMAGE SIZE\n", - }, - } - - for _, context := range contexts { - customFormat(context.context, containers) - actual := out.String() - if actual != context.expected { - t.Fatalf("Expected \n%s, got \n%s", context.expected, actual) - } - // Clean buffer - out.Reset() - } -} diff --git a/cliconfig/config.go b/cliconfig/config.go index e4ea948768..16e875cc7e 100644 --- a/cliconfig/config.go +++ b/cliconfig/config.go @@ -47,10 +47,11 @@ func SetConfigDir(dir string) { // ConfigFile ~/.docker/config.json file info type ConfigFile struct { - AuthConfigs map[string]types.AuthConfig `json:"auths"` - HTTPHeaders map[string]string `json:"HttpHeaders,omitempty"` - PsFormat string `json:"psFormat,omitempty"` - filename string // Note: not serialized - for internal use only + AuthConfigs map[string]types.AuthConfig `json:"auths"` + HTTPHeaders map[string]string `json:"HttpHeaders,omitempty"` + PsFormat string `json:"psFormat,omitempty"` + ImagesFormat string `json:"imagesFormat,omitempty"` + filename string // Note: not serialized - for internal use only } // NewConfigFile initializes an empty configuration file for the given filename 'fn' diff --git a/contrib/completion/bash/docker b/contrib/completion/bash/docker index 4a1b2f39e4..1cb006e0eb 100644 --- a/contrib/completion/bash/docker +++ b/contrib/completion/bash/docker @@ -927,6 +927,9 @@ _docker_images() { fi return ;; + --format) + return + ;; esac case "${words[$cword-2]}$prev=" in @@ -941,7 +944,7 @@ _docker_images() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--all -a --digests --filter -f --help --no-trunc --quiet -q" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--all -a --digests --filter -f --format --help --no-trunc --quiet -q" -- "$cur" ) ) ;; =) return diff --git a/contrib/completion/zsh/_docker b/contrib/completion/zsh/_docker index 142d87036f..11f26521c7 100644 --- a/contrib/completion/zsh/_docker +++ b/contrib/completion/zsh/_docker @@ -692,8 +692,9 @@ __docker_subcommand() { _arguments $(__docker_arguments) \ $opts_help \ "($help -a --all)"{-a,--all}"[Show all images]" \ - "($help)--digest[Show digests]" \ + "($help)--digests[Show digests]" \ "($help)*"{-f=,--filter=}"[Filter values]:filter: " \ + "($help)--format[Pretty-print containers using a Go template]:format: " \ "($help)--no-trunc[Do not truncate output]" \ "($help -q --quiet)"{-q,--quiet}"[Only show numeric IDs]" \ "($help -): :__docker_repositories" && ret=0 diff --git a/docs/reference/commandline/cli.md b/docs/reference/commandline/cli.md index 6a9a1d85fb..608cb1275a 100644 --- a/docs/reference/commandline/cli.md +++ b/docs/reference/commandline/cli.md @@ -103,6 +103,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 ps` documentation](ps.md) +The property `imagesFormat` specifies the default format for `docker images` output. +When the `--format` flag is not provided with the `docker images` 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 images` documentation](images.md) + Following is a sample `config.json` file: { @@ -110,6 +116,7 @@ Following is a sample `config.json` file: "MyHeader": "MyValue" }, "psFormat": "table {{.ID}}\\t{{.Image}}\\t{{.Command}}\\t{{.Labels}}" + "imagesFormat": "table {{.ID}}\\t{{.Repository}}\\t{{.Tag}}\\t{{.CreatedAt}}" } ### Notary diff --git a/docs/reference/commandline/images.md b/docs/reference/commandline/images.md index 651522c841..2385bc97c0 100644 --- a/docs/reference/commandline/images.md +++ b/docs/reference/commandline/images.md @@ -177,3 +177,53 @@ In this example, with the `0.1` value, it returns an empty set because no matche $ docker images --filter "label=com.example.version=0.1" REPOSITORY TAG IMAGE ID CREATED VIRTUAL SIZE + +## Formatting + +The formatting option (`--format`) will pretty print container output +using a Go template. + +Valid placeholders for the Go template are listed below: + +Placeholder | Description +---- | ---- +`.ID` | Image ID +`.Repository` | Image repository +`.Tag` | Image tag +`.Digest` | Image digest +`.CreatedSince` | Elapsed time since the image was created. +`.CreatedAt` | Time when the image was created. +`.Size` | Image disk size. + +When using the `--format` option, the `image` 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 `Repository` entries separated by a colon for all images: + + $ docker images --format "{{.ID}}: {{.Repository}}" + 77af4d6b9913: + b6fa739cedf5: committ + 78a85c484f71: + 30557a29d5ab: docker + 5ed6274db6ce: + 746b819f315e: postgres + 746b819f315e: postgres + 746b819f315e: postgres + 746b819f315e: postgres + +To list all images with their repository and tag in a table format you +can use: + + $ docker images --format "table {{.ID}}\t{{.Repository}}\t{{.Tag}}" + IMAGE ID REPOSITORY TAG + 77af4d6b9913 + b6fa739cedf5 committ latest + 78a85c484f71 + 30557a29d5ab docker latest + 5ed6274db6ce + 746b819f315e postgres 9 + 746b819f315e postgres 9.3 + 746b819f315e postgres 9.3.5 + 746b819f315e postgres latest diff --git a/integration-cli/docker_cli_images_test.go b/integration-cli/docker_cli_images_test.go index d821150929..c44b9e1ce3 100644 --- a/integration-cli/docker_cli_images_test.go +++ b/integration-cli/docker_cli_images_test.go @@ -2,6 +2,9 @@ package main import ( "fmt" + "io/ioutil" + "os" + "path/filepath" "reflect" "sort" "strings" @@ -48,17 +51,17 @@ func (s *DockerSuite) TestImagesOrderedByCreationDate(c *check.C) { testRequires(c, DaemonIsLinux) id1, err := buildImage("order:test_a", `FROM scratch - MAINTAINER dockerio1`, true) + MAINTAINER dockerio1`, true) c.Assert(err, checker.IsNil) time.Sleep(1 * time.Second) id2, err := buildImage("order:test_c", `FROM scratch - MAINTAINER dockerio2`, true) + MAINTAINER dockerio2`, true) c.Assert(err, checker.IsNil) time.Sleep(1 * time.Second) id3, err := buildImage("order:test_b", `FROM scratch - MAINTAINER dockerio3`, true) + MAINTAINER dockerio3`, true) c.Assert(err, checker.IsNil) out, _ := dockerCmd(c, "images", "-q", "--no-trunc") @@ -81,17 +84,17 @@ func (s *DockerSuite) TestImagesFilterLabelMatch(c *check.C) { imageName3 := "images_filter_test3" image1ID, err := buildImage(imageName1, `FROM scratch - LABEL match me`, true) + LABEL match me`, true) c.Assert(err, check.IsNil) image2ID, err := buildImage(imageName2, `FROM scratch - LABEL match="me too"`, true) + LABEL match="me too"`, true) c.Assert(err, check.IsNil) image3ID, err := buildImage(imageName3, `FROM scratch - LABEL nomatch me`, true) + LABEL nomatch me`, true) c.Assert(err, check.IsNil) out, _ := dockerCmd(c, "images", "--no-trunc", "-q", "-f", "label=match") @@ -123,9 +126,9 @@ func (s *DockerSuite) TestImagesFilterSpaceTrimCase(c *check.C) { imageName := "images_filter_test" buildImage(imageName, `FROM scratch - RUN touch /test/foo - RUN touch /test/bar - RUN touch /test/baz`, true) + RUN touch /test/foo + RUN touch /test/bar + RUN touch /test/baz`, true) filters := []string{ "dangling=true", @@ -233,3 +236,46 @@ func (s *DockerSuite) TestImagesFilterNameWithPort(c *check.C) { out, _ = dockerCmd(c, "images", tag+":no-such-tag") c.Assert(out, checker.Not(checker.Contains), tag) } + +func (s *DockerSuite) TestImagesFormat(c *check.C) { + // testRequires(c, DaemonIsLinux) + tag := "myimage" + dockerCmd(c, "tag", "busybox", tag+":v1") + dockerCmd(c, "tag", "busybox", tag+":v2") + + out, _ := dockerCmd(c, "images", "--format", "{{.Repository}}", tag) + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + + expected := []string{"myimage", "myimage"} + var names []string + for _, l := range lines { + names = append(names, l) + } + c.Assert(expected, checker.DeepEquals, names, check.Commentf("Expected array with truncated names: %v, got: %v", expected, names)) +} + +// ImagesDefaultFormatAndQuiet +func (s *DockerSuite) TestImagesFormatDefaultFormat(c *check.C) { + testRequires(c, DaemonIsLinux) + + // create container 1 + out, _ := dockerCmd(c, "run", "-d", "busybox", "true") + containerID1 := strings.TrimSpace(out) + + // tag as foobox + out, _ = dockerCmd(c, "commit", containerID1, "myimage") + imageID := stringid.TruncateID(strings.TrimSpace(out)) + + config := `{ + "imagesFormat": "{{ .ID }} default" +}` + d, err := ioutil.TempDir("", "integration-cli-") + c.Assert(err, checker.IsNil) + defer os.RemoveAll(d) + + err = ioutil.WriteFile(filepath.Join(d, "config.json"), []byte(config), 0644) + c.Assert(err, checker.IsNil) + + out, _ = dockerCmd(c, "--config", d, "images", "-q", "myimage") + c.Assert(out, checker.Equals, imageID+"\n", check.Commentf("Expected to print only the image id, got %v\n", out)) +} diff --git a/integration-cli/docker_cli_ps_test.go b/integration-cli/docker_cli_ps_test.go index cf006efc31..b7c234e028 100644 --- a/integration-cli/docker_cli_ps_test.go +++ b/integration-cli/docker_cli_ps_test.go @@ -568,7 +568,7 @@ func (s *DockerSuite) TestPsFormatHeaders(c *check.C) { func (s *DockerSuite) TestPsDefaultFormatAndQuiet(c *check.C) { testRequires(c, DaemonIsLinux) config := `{ - "psFormat": "{{ .ID }} default" + "psFormat": "default {{ .ID }}" }` d, err := ioutil.TempDir("", "integration-cli-") c.Assert(err, checker.IsNil) diff --git a/man/docker-images.1.md b/man/docker-images.1.md index de4fee05c9..921a141684 100644 --- a/man/docker-images.1.md +++ b/man/docker-images.1.md @@ -40,6 +40,17 @@ versions. **-f**, **--filter**=[] Filters the output. The dangling=true filter finds unused images. While label=com.foo=amd64 filters for images with a com.foo value of amd64. The label=com.foo filter finds images with the label com.foo of any value. +**--format**="*TEMPLATE*" + Pretty-print containers using a Go template. + Valid placeholders: + .ID - Image ID + .Repository - Image repository + .Tag - Image tag + .Digest - Image digest + .CreatedSince - Elapsed time since the image was created. + .CreatedAt - Time when the image was created.. + .Size - Image disk size. + **--help** Print usage statement