Fix docker ps --format with templating functions

Before this patch, using a template that used templating functions (such as
`lower` or `json`) caused the command to fail in the pre-processor step (in
`buildContainerListOptions`):

    docker ps --format='{{upper .Names}}'
    template: :1:8: executing "" at <.Names>: invalid value; expected string

This problem was due to the pre-processing using a different "context" type than
was used in the actual template, and custom functions to not be defined when
instantiating the Go template.

With this patch, using functions in templates works correctly:

    docker ps --format='{{upper .Names}}'
    MUSING_NEUMANN
    ELOQUENT_MEITNER

Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
This commit is contained in:
Sebastiaan van Stijn 2019-12-24 16:30:23 +01:00
parent ba63a92655
commit 69f216f6e4
No known key found for this signature in database
GPG Key ID: 76698F39D527CE8C
5 changed files with 98 additions and 62 deletions

View File

@ -10,6 +10,7 @@ import (
"github.com/docker/cli/opts" "github.com/docker/cli/opts"
"github.com/docker/cli/templates" "github.com/docker/cli/templates"
"github.com/docker/docker/api/types" "github.com/docker/docker/api/types"
"github.com/pkg/errors"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@ -58,27 +59,6 @@ func newListCommand(dockerCli command.Cli) *cobra.Command {
return &cmd return &cmd
} }
// listOptionsProcessor is used to set any container list options which may only
// be embedded in the format template.
// This is passed directly into tmpl.Execute in order to allow the preprocessor
// to set any list options that were not provided by flags (e.g. `.Size`).
// It is using a `map[string]bool` so that unknown fields passed into the
// template format do not cause errors. These errors will get picked up when
// running through the actual template processor.
type listOptionsProcessor map[string]bool
// Size sets the size of the map when called by a template execution.
func (o listOptionsProcessor) Size() bool {
o["size"] = true
return true
}
// Label is needed here as it allows the correct pre-processing
// because Label() is a method with arguments
func (o listOptionsProcessor) Label(name string) string {
return ""
}
func buildContainerListOptions(opts *psOptions) (*types.ContainerListOptions, error) { func buildContainerListOptions(opts *psOptions) (*types.ContainerListOptions, error) {
options := &types.ContainerListOptions{ options := &types.ContainerListOptions{
All: opts.all, All: opts.all,
@ -91,20 +71,32 @@ func buildContainerListOptions(opts *psOptions) (*types.ContainerListOptions, er
options.Limit = 1 options.Limit = 1
} }
tmpl, err := templates.Parse(opts.format) options.Size = opts.size
if !options.Size && len(opts.format) > 0 {
// The --size option isn't set, but .Size may be used in the template.
// Parse and execute the given template to detect if the .Size field is
// used. If it is, then automatically enable the --size option. See #24696
//
// Only requesting container size information when needed is an optimization,
// because calculating the size is a costly operation.
tmpl, err := templates.NewParse("", opts.format)
if err != nil { if err != nil {
return nil, err return nil, errors.Wrap(err, "failed to parse template")
} }
optionsProcessor := listOptionsProcessor{} optionsProcessor := formatter.NewContainerContext()
// This shouldn't error out but swallowing the error makes it harder // This shouldn't error out but swallowing the error makes it harder
// to track down if preProcessor issues come up. Ref #24696 // to track down if preProcessor issues come up.
if err := tmpl.Execute(ioutil.Discard, optionsProcessor); err != nil { if err := tmpl.Execute(ioutil.Discard, optionsProcessor); err != nil {
return nil, err return nil, errors.Wrap(err, "failed to execute template")
}
if _, ok := optionsProcessor.FieldsUsed["Size"]; ok {
options.Size = true
}
} }
// At the moment all we need is to capture .Size for preprocessor
options.Size = opts.size || optionsProcessor["size"]
return options, nil return options, nil
} }

View File

@ -78,7 +78,7 @@ func TestContainerListBuildContainerListOptions(t *testing.T) {
last: 5, last: 5,
filter: filters, filter: filters,
// With .Size, size should be true // With .Size, size should be true
format: "{{.Size}} {{.CreatedAt}} {{.Networks}}", format: "{{.Size}} {{.CreatedAt}} {{upper .Networks}}",
}, },
expectedAll: true, expectedAll: true,
expectedSize: true, expectedSize: true,

View File

@ -62,24 +62,32 @@ ports: {{- pad .Ports 1 0}}
func ContainerWrite(ctx Context, containers []types.Container) error { func ContainerWrite(ctx Context, containers []types.Container) error {
render := func(format func(subContext SubContext) error) error { render := func(format func(subContext SubContext) error) error {
for _, container := range containers { for _, container := range containers {
err := format(&containerContext{trunc: ctx.Trunc, c: container}) err := format(&ContainerContext{trunc: ctx.Trunc, c: container})
if err != nil { if err != nil {
return err return err
} }
} }
return nil return nil
} }
return ctx.Write(newContainerContext(), render) return ctx.Write(NewContainerContext(), render)
} }
type containerContext struct { // ContainerContext is a struct used for rendering a list of containers in a Go template.
type ContainerContext struct {
HeaderContext HeaderContext
trunc bool trunc bool
c types.Container c types.Container
// FieldsUsed is used in the pre-processing step to detect which fields are
// used in the template. It's currently only used to detect use of the .Size
// field which (if used) automatically sets the '--size' option when making
// the API call.
FieldsUsed map[string]interface{}
} }
func newContainerContext() *containerContext { // NewContainerContext creates a new context for rendering containers
containerCtx := containerContext{} func NewContainerContext() *ContainerContext {
containerCtx := ContainerContext{}
containerCtx.Header = SubHeaderContext{ containerCtx.Header = SubHeaderContext{
"ID": ContainerIDHeader, "ID": ContainerIDHeader,
"Names": namesHeader, "Names": namesHeader,
@ -99,18 +107,24 @@ func newContainerContext() *containerContext {
return &containerCtx return &containerCtx
} }
func (c *containerContext) MarshalJSON() ([]byte, error) { // MarshalJSON makes ContainerContext implement json.Marshaler
func (c *ContainerContext) MarshalJSON() ([]byte, error) {
return MarshalJSON(c) return MarshalJSON(c)
} }
func (c *containerContext) ID() string { // ID returns the container's ID as a string. Depending on the `--no-trunc`
// option being set, the full or truncated ID is returned.
func (c *ContainerContext) ID() string {
if c.trunc { if c.trunc {
return stringid.TruncateID(c.c.ID) return stringid.TruncateID(c.c.ID)
} }
return c.c.ID return c.c.ID
} }
func (c *containerContext) Names() string { // Names returns a comma-separated string of the container's names, with their
// slash (/) prefix stripped. Additional names for the container (related to the
// legacy `--link` feature) are omitted.
func (c *ContainerContext) Names() string {
names := stripNamePrefix(c.c.Names) names := stripNamePrefix(c.c.Names)
if c.trunc { if c.trunc {
for _, name := range names { for _, name := range names {
@ -123,7 +137,9 @@ func (c *containerContext) Names() string {
return strings.Join(names, ",") return strings.Join(names, ",")
} }
func (c *containerContext) Image() string { // Image returns the container's image reference. If the trunc option is set,
// the image's registry digest can be included.
func (c *ContainerContext) Image() string {
if c.c.Image == "" { if c.c.Image == "" {
return "<no image>" return "<no image>"
} }
@ -150,7 +166,9 @@ func (c *containerContext) Image() string {
return c.c.Image return c.c.Image
} }
func (c *containerContext) Command() string { // Command returns's the container's command. If the trunc option is set, the
// returned command is truncated (ellipsized).
func (c *ContainerContext) Command() string {
command := c.c.Command command := c.c.Command
if c.trunc { if c.trunc {
command = Ellipsis(command, 20) command = Ellipsis(command, 20)
@ -158,28 +176,46 @@ func (c *containerContext) Command() string {
return strconv.Quote(command) return strconv.Quote(command)
} }
func (c *containerContext) CreatedAt() string { // CreatedAt returns the "Created" date/time of the container as a unix timestamp.
func (c *ContainerContext) CreatedAt() string {
return time.Unix(c.c.Created, 0).String() return time.Unix(c.c.Created, 0).String()
} }
func (c *containerContext) RunningFor() string { // RunningFor returns a human-readable representation of the duration for which
// the container has been running.
//
// Note that this duration is calculated on the client, and as such is influenced
// by clock skew between the client and the daemon.
func (c *ContainerContext) RunningFor() string {
createdAt := time.Unix(c.c.Created, 0) createdAt := time.Unix(c.c.Created, 0)
return units.HumanDuration(time.Now().UTC().Sub(createdAt)) + " ago" return units.HumanDuration(time.Now().UTC().Sub(createdAt)) + " ago"
} }
func (c *containerContext) Ports() string { // Ports returns a comma-separated string representing open ports of the container
// e.g. "0.0.0.0:80->9090/tcp, 9988/tcp"
// it's used by command 'docker ps'
// Both published and exposed ports are included.
func (c *ContainerContext) Ports() string {
return DisplayablePorts(c.c.Ports) return DisplayablePorts(c.c.Ports)
} }
func (c *containerContext) State() string { // State returns the container's current state (e.g. "running" or "paused")
func (c *ContainerContext) State() string {
return c.c.State return c.c.State
} }
func (c *containerContext) Status() string { // Status returns the container's status in a human readable form (for example,
// "Up 24 hours" or "Exited (0) 8 days ago")
func (c *ContainerContext) Status() string {
return c.c.Status return c.c.Status
} }
func (c *containerContext) Size() string { // Size returns the container's size and virtual size (e.g. "2B (virtual 21.5MB)")
func (c *ContainerContext) Size() string {
if c.FieldsUsed == nil {
c.FieldsUsed = map[string]interface{}{}
}
c.FieldsUsed["Size"] = struct{}{}
srw := units.HumanSizeWithPrecision(float64(c.c.SizeRw), 3) srw := units.HumanSizeWithPrecision(float64(c.c.SizeRw), 3)
sv := units.HumanSizeWithPrecision(float64(c.c.SizeRootFs), 3) sv := units.HumanSizeWithPrecision(float64(c.c.SizeRootFs), 3)
@ -190,7 +226,8 @@ func (c *containerContext) Size() string {
return sf return sf
} }
func (c *containerContext) Labels() string { // Labels returns a comma-separated string of labels present on the container.
func (c *ContainerContext) Labels() string {
if c.c.Labels == nil { if c.c.Labels == nil {
return "" return ""
} }
@ -202,14 +239,18 @@ func (c *containerContext) Labels() string {
return strings.Join(joinLabels, ",") return strings.Join(joinLabels, ",")
} }
func (c *containerContext) Label(name string) string { // Label returns the value of the label with the given name or an empty string
// if the given label does not exist.
func (c *ContainerContext) Label(name string) string {
if c.c.Labels == nil { if c.c.Labels == nil {
return "" return ""
} }
return c.c.Labels[name] return c.c.Labels[name]
} }
func (c *containerContext) Mounts() string { // Mounts returns a comma-separated string of mount names present on the container.
// If the trunc option is set, names can be truncated (ellipsized).
func (c *ContainerContext) Mounts() string {
var name string var name string
var mounts []string var mounts []string
for _, m := range c.c.Mounts { for _, m := range c.c.Mounts {
@ -226,7 +267,8 @@ func (c *containerContext) Mounts() string {
return strings.Join(mounts, ",") return strings.Join(mounts, ",")
} }
func (c *containerContext) LocalVolumes() string { // LocalVolumes returns the number of volumes using the "local" volume driver.
func (c *ContainerContext) LocalVolumes() string {
count := 0 count := 0
for _, m := range c.c.Mounts { for _, m := range c.c.Mounts {
if m.Driver == "local" { if m.Driver == "local" {
@ -237,7 +279,9 @@ func (c *containerContext) LocalVolumes() string {
return fmt.Sprintf("%d", count) return fmt.Sprintf("%d", count)
} }
func (c *containerContext) Networks() string { // Networks returns a comma-separated string of networks that the container is
// attached to.
func (c *ContainerContext) Networks() string {
if c.c.NetworkSettings == nil { if c.c.NetworkSettings == nil {
return "" return ""
} }

View File

@ -19,7 +19,7 @@ func TestContainerPsContext(t *testing.T) {
containerID := stringid.GenerateRandomID() containerID := stringid.GenerateRandomID()
unix := time.Now().Add(-65 * time.Second).Unix() unix := time.Now().Add(-65 * time.Second).Unix()
var ctx containerContext var ctx ContainerContext
cases := []struct { cases := []struct {
container types.Container container types.Container
trunc bool trunc bool
@ -87,7 +87,7 @@ func TestContainerPsContext(t *testing.T) {
} }
for _, c := range cases { for _, c := range cases {
ctx = containerContext{c: c.container, trunc: c.trunc} ctx = ContainerContext{c: c.container, trunc: c.trunc}
v := c.call() v := c.call()
if strings.Contains(v, ",") { if strings.Contains(v, ",") {
compareMultipleValues(t, v, c.expValue) compareMultipleValues(t, v, c.expValue)
@ -97,7 +97,7 @@ func TestContainerPsContext(t *testing.T) {
} }
c1 := types.Container{Labels: map[string]string{"com.docker.swarm.swarm-id": "33", "com.docker.swarm.node_name": "ubuntu"}} c1 := types.Container{Labels: map[string]string{"com.docker.swarm.swarm-id": "33", "com.docker.swarm.node_name": "ubuntu"}}
ctx = containerContext{c: c1, trunc: true} ctx = ContainerContext{c: c1, trunc: true}
sid := ctx.Label("com.docker.swarm.swarm-id") sid := ctx.Label("com.docker.swarm.swarm-id")
node := ctx.Label("com.docker.swarm.node_name") node := ctx.Label("com.docker.swarm.node_name")
@ -110,7 +110,7 @@ func TestContainerPsContext(t *testing.T) {
} }
c2 := types.Container{} c2 := types.Container{}
ctx = containerContext{c: c2, trunc: true} ctx = ContainerContext{c: c2, trunc: true}
label := ctx.Label("anything.really") label := ctx.Label("anything.really")
if label != "" { if label != "" {

View File

@ -136,7 +136,7 @@ func (ctx *DiskUsageContext) Write() (err error) {
type diskUsageContext struct { type diskUsageContext struct {
Images []*imageContext Images []*imageContext
Containers []*containerContext Containers []*ContainerContext
Volumes []*volumeContext Volumes []*volumeContext
BuildCache []*buildCacheContext BuildCache []*buildCacheContext
} }
@ -144,7 +144,7 @@ type diskUsageContext struct {
func (ctx *DiskUsageContext) verboseWrite() error { func (ctx *DiskUsageContext) verboseWrite() error {
duc := &diskUsageContext{ duc := &diskUsageContext{
Images: make([]*imageContext, 0, len(ctx.Images)), Images: make([]*imageContext, 0, len(ctx.Images)),
Containers: make([]*containerContext, 0, len(ctx.Containers)), Containers: make([]*ContainerContext, 0, len(ctx.Containers)),
Volumes: make([]*volumeContext, 0, len(ctx.Volumes)), Volumes: make([]*volumeContext, 0, len(ctx.Volumes)),
BuildCache: make([]*buildCacheContext, 0, len(ctx.BuildCache)), BuildCache: make([]*buildCacheContext, 0, len(ctx.BuildCache)),
} }
@ -178,7 +178,7 @@ func (ctx *DiskUsageContext) verboseWrite() error {
for _, c := range ctx.Containers { for _, c := range ctx.Containers {
// Don't display the virtual size // Don't display the virtual size
c.SizeRootFs = 0 c.SizeRootFs = 0
duc.Containers = append(duc.Containers, &containerContext{trunc: trunc, c: *c}) duc.Containers = append(duc.Containers, &ContainerContext{trunc: trunc, c: *c})
} }
// And volumes // And volumes
@ -227,7 +227,7 @@ func (ctx *DiskUsageContext) verboseWriteTable(duc *diskUsageContext) error {
return err return err
} }
} }
ctx.postFormat(tmpl, newContainerContext()) ctx.postFormat(tmpl, NewContainerContext())
tmpl, err = ctx.startSubsection(defaultDiskUsageVolumeTableFormat) tmpl, err = ctx.startSubsection(defaultDiskUsageVolumeTableFormat)
if err != nil { if err != nil {