From 66d3e9e1315d79caaeec2b66a028af29c655ef67 Mon Sep 17 00:00:00 2001 From: Scarab Systems Date: Sun, 19 Jul 2026 21:45:23 +0000 Subject: [PATCH] Add template formatting to ls and images Support Docker CLI-style Go templates for docker compose ls and docker compose images while keeping existing table and json output paths available. The formatter contexts expose the fields already shown by those commands and add coverage for custom templates plus json template rendering. Tests: - gofmt -s -w cmd/compose/list.go cmd/compose/images.go cmd/formatter/formats.go cmd/formatter/project.go cmd/formatter/image.go cmd/formatter/compose_format_test.go - GOTMPDIR=/workspace/workspace-compose-12948-20260719-01/cache/go-tmp go test ./cmd/... Signed-off-by: Scarab Systems --- cmd/compose/images.go | 39 +++++-- cmd/compose/list.go | 17 +-- cmd/formatter/compose_format_test.go | 119 +++++++++++++++++++++ cmd/formatter/formats.go | 29 +++++ cmd/formatter/image.go | 153 +++++++++++++++++++++++++++ cmd/formatter/project.go | 93 ++++++++++++++++ 6 files changed, 436 insertions(+), 14 deletions(-) create mode 100644 cmd/formatter/compose_format_test.go create mode 100644 cmd/formatter/formats.go create mode 100644 cmd/formatter/image.go create mode 100644 cmd/formatter/project.go diff --git a/cmd/compose/images.go b/cmd/compose/images.go index ead066b17a6..8b94416884e 100644 --- a/cmd/compose/images.go +++ b/cmd/compose/images.go @@ -27,6 +27,8 @@ import ( "github.com/containerd/platforms" "github.com/docker/cli/cli/command" + cliformatter "github.com/docker/cli/cli/command/formatter" + cliflags "github.com/docker/cli/cli/flags" "github.com/docker/go-units" "github.com/moby/moby/client/pkg/stringid" "github.com/spf13/cobra" @@ -54,7 +56,7 @@ func imagesCommand(p *ProjectOptions, dockerCli command.Cli, backendOptions *Bac }), ValidArgsFunction: completeServiceNames(dockerCli, p), } - imgCmd.Flags().StringVar(&opts.Format, "format", "table", "Format the output. Values: [table | json]") + imgCmd.Flags().StringVar(&opts.Format, "format", "table", cliflags.FormatHelp) imgCmd.Flags().BoolVarP(&opts.Quiet, "quiet", "q", false, "Only display IDs") return imgCmd } @@ -92,6 +94,7 @@ func runImages(ctx context.Context, dockerCli command.Cli, backendOptions *Backe } return nil } + imageList := imageListFromImages(images) if opts.Format == "json" { type img struct { @@ -105,11 +108,12 @@ func runImages(ctx context.Context, dockerCli command.Cli, backendOptions *Backe LastTagTime time.Time `json:"LastTagTime,omitzero"` } // Convert map to slice - var imageList []img - for ctr, i := range images { + var jsonImages []img + for _, image := range imageList { + i := image.Summary lastTagTime := i.LastTagTime - imageList = append(imageList, img{ - ContainerName: ctr, + jsonImages = append(jsonImages, img{ + ContainerName: image.ContainerName, ID: i.ID, Repository: i.Repository, Tag: i.Tag, @@ -119,18 +123,26 @@ func runImages(ctx context.Context, dockerCli command.Cli, backendOptions *Backe LastTagTime: lastTagTime, }) } - json, err := formatter.ToJSON(imageList, "", "") + json, err := formatter.ToJSON(jsonImages, "", "") if err != nil { return err } _, err = fmt.Fprintln(dockerCli.Out(), json) return err } + if !formatter.IsStandardFormat(opts.Format) { + imageCtx := cliformatter.Context{ + Output: dockerCli.Out(), + Format: formatter.NewImageFormat(opts.Format), + } + return formatter.ImageWrite(imageCtx, imageList) + } return formatter.Print(images, opts.Format, dockerCli.Out(), func(w io.Writer) { - for _, container := range slices.Sorted(maps.Keys(images)) { - img := images[container] + for _, image := range imageList { + container := image.ContainerName + img := image.Summary id := stringid.TruncateID(img.ID) size := units.HumanSizeWithPrecision(float64(img.Size), 3) repo := img.Repository @@ -151,3 +163,14 @@ func runImages(ctx context.Context, dockerCli command.Cli, backendOptions *Backe }, "CONTAINER", "REPOSITORY", "TAG", "PLATFORM", "IMAGE ID", "SIZE", "CREATED") } + +func imageListFromImages(images map[string]api.ImageSummary) []formatter.Image { + imageList := make([]formatter.Image, 0, len(images)) + for _, container := range slices.Sorted(maps.Keys(images)) { + imageList = append(imageList, formatter.Image{ + ContainerName: container, + Summary: images[container], + }) + } + return imageList +} diff --git a/cmd/compose/list.go b/cmd/compose/list.go index 8a7f875da2d..ea76c103b0f 100644 --- a/cmd/compose/list.go +++ b/cmd/compose/list.go @@ -25,6 +25,8 @@ import ( "strings" "github.com/docker/cli/cli/command" + cliformatter "github.com/docker/cli/cli/command/formatter" + cliflags "github.com/docker/cli/cli/flags" "github.com/docker/cli/opts" "github.com/moby/moby/client" "github.com/spf13/cobra" @@ -52,7 +54,7 @@ func listCommand(dockerCli command.Cli, backendOptions *BackendOptions) *cobra.C Args: cobra.NoArgs, ValidArgsFunction: noCompletion(), } - lsCmd.Flags().StringVar(&lsOpts.Format, "format", "table", "Format the output. Values: [table | json]") + lsCmd.Flags().StringVar(&lsOpts.Format, "format", "table", cliflags.FormatHelp) lsCmd.Flags().BoolVarP(&lsOpts.Quiet, "quiet", "q", false, "Only display project names") lsCmd.Flags().Var(&lsOpts.Filter, "filter", "Filter output based on conditions provided") lsCmd.Flags().BoolVarP(&lsOpts.All, "all", "a", false, "Show all stopped Compose projects") @@ -119,6 +121,13 @@ func runList(ctx context.Context, dockerCli command.Cli, backendOptions *Backend } view := viewFromStackList(stackList) + if !formatter.IsStandardFormat(lsOpts.Format) { + projectCtx := cliformatter.Context{ + Output: dockerCli.Out(), + Format: formatter.NewProjectFormat(lsOpts.Format), + } + return formatter.ProjectWrite(projectCtx, view) + } return formatter.Print(view, lsOpts.Format, dockerCli.Out(), func(w io.Writer) { for _, stack := range view { _, _ = fmt.Fprintf(w, "%s\t%s\t%s\n", stack.Name, stack.Status, stack.ConfigFiles) @@ -126,11 +135,7 @@ func runList(ctx context.Context, dockerCli command.Cli, backendOptions *Backend }, "NAME", "STATUS", "CONFIG FILES") } -type stackView struct { - Name string - Status string - ConfigFiles string -} +type stackView = formatter.Project func viewFromStackList(stackList []api.Stack) []stackView { retList := make([]stackView, len(stackList)) diff --git a/cmd/formatter/compose_format_test.go b/cmd/formatter/compose_format_test.go new file mode 100644 index 00000000000..bca5a924b7c --- /dev/null +++ b/cmd/formatter/compose_format_test.go @@ -0,0 +1,119 @@ +/* + Copyright 2026 Docker Compose CLI authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package formatter + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + "time" + + "github.com/containerd/platforms" + cliformatter "github.com/docker/cli/cli/command/formatter" + "gotest.tools/v3/assert" + + "github.com/docker/compose/v5/pkg/api" +) + +func TestProjectWriteCustomTemplate(t *testing.T) { + projects := []Project{ + {Name: "alpha", Status: "running(1)", ConfigFiles: "compose.yaml"}, + {Name: "beta", Status: "exited(1)", ConfigFiles: "compose.yaml"}, + } + + var out bytes.Buffer + err := ProjectWrite(cliformatter.Context{ + Output: &out, + Format: NewProjectFormat("{{.Name}}"), + }, projects) + + assert.NilError(t, err) + assert.Equal(t, out.String(), "alpha\nbeta\n") +} + +func TestProjectWriteJSONTemplate(t *testing.T) { + projects := []Project{{Name: "alpha", Status: "running(1)", ConfigFiles: "compose.yaml"}} + + var out bytes.Buffer + err := ProjectWrite(cliformatter.Context{ + Output: &out, + Format: NewProjectFormat("{{json .}}"), + }, projects) + + assert.NilError(t, err) + var rendered map[string]any + assert.NilError(t, json.Unmarshal([]byte(strings.TrimSpace(out.String())), &rendered)) + assert.Equal(t, rendered["Name"], "alpha") + assert.Equal(t, rendered["Status"], "running(1)") + assert.Equal(t, rendered["ConfigFiles"], "compose.yaml") +} + +func TestImageWriteCustomTemplate(t *testing.T) { + created := time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC) + images := []Image{ + { + ContainerName: "web-1", + Summary: api.ImageSummary{ + ID: "sha256:1234567890abcdef", + Repository: "example/web", + Tag: "latest", + Platform: platforms.MustParse("linux/amd64"), + Size: 239840, + Created: &created, + }, + }, + } + + var out bytes.Buffer + err := ImageWrite(cliformatter.Context{ + Output: &out, + Format: NewImageFormat("{{.ContainerName}} {{.Repository}}:{{.Tag}} {{.Platform}}"), + }, images) + + assert.NilError(t, err) + assert.Equal(t, out.String(), "web-1 example/web:latest linux/amd64\n") +} + +func TestImageWriteJSONTemplate(t *testing.T) { + images := []Image{ + { + ContainerName: "web-1", + Summary: api.ImageSummary{ + ID: "sha256:1234567890abcdef", + Repository: "example/web", + Tag: "latest", + Platform: platforms.MustParse("linux/amd64"), + Size: 239840, + }, + }, + } + + var out bytes.Buffer + err := ImageWrite(cliformatter.Context{ + Output: &out, + Format: NewImageFormat("{{json .}}"), + }, images) + + assert.NilError(t, err) + var rendered map[string]any + assert.NilError(t, json.Unmarshal([]byte(strings.TrimSpace(out.String())), &rendered)) + assert.Equal(t, rendered["ContainerName"], "web-1") + assert.Equal(t, rendered["Repository"], "example/web") + assert.Equal(t, rendered["Tag"], "latest") + assert.Equal(t, rendered["Platform"], "linux/amd64") +} diff --git a/cmd/formatter/formats.go b/cmd/formatter/formats.go new file mode 100644 index 00000000000..ee6d36beea9 --- /dev/null +++ b/cmd/formatter/formats.go @@ -0,0 +1,29 @@ +/* + Copyright 2026 Docker Compose CLI authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package formatter + +import "strings" + +// IsStandardFormat returns true for Compose's existing non-template formats. +func IsStandardFormat(format string) bool { + switch strings.ToLower(format) { + case "", TABLE, PRETTY, JSON, TemplateLegacyJSON: + return true + default: + return false + } +} diff --git a/cmd/formatter/image.go b/cmd/formatter/image.go new file mode 100644 index 00000000000..f2c3b17da3b --- /dev/null +++ b/cmd/formatter/image.go @@ -0,0 +1,153 @@ +/* + Copyright 2026 Docker Compose CLI authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package formatter + +import ( + "time" + + "github.com/containerd/platforms" + cliformatter "github.com/docker/cli/cli/command/formatter" + "github.com/docker/go-units" + "github.com/moby/moby/client/pkg/stringid" + + "github.com/docker/compose/v5/pkg/api" +) + +const ( + defaultComposeImageTableFormat = "table {{.ContainerName}}\t{{.Repository}}\t{{.Tag}}\t{{.Platform}}\t{{.ID}}\t{{.Size}}\t{{.Created}}" + + composeImageContainerHeader = "CONTAINER" + composeImageIDHeader = "IMAGE ID" + composeImageRepository = "REPOSITORY" + composeImageTag = "TAG" + composeImagePlatform = "PLATFORM" + composeImageSize = "SIZE" + composeImageCreated = "CREATED" + composeImageCreatedAt = "CREATED AT" + composeImageLastTagTime = "LAST TAG TIME" +) + +// Image is the display model for docker compose images. +type Image struct { + ContainerName string + Summary api.ImageSummary +} + +// NewImageFormat returns a Docker CLI formatter format for Compose images. +func NewImageFormat(source string) cliformatter.Format { + switch source { + case cliformatter.TableFormatKey, "": + return cliformatter.Format(defaultComposeImageTableFormat) + case cliformatter.RawFormatKey: + return `container_name: {{.ContainerName}} +repository: {{.Repository}} +tag: {{.Tag}} +platform: {{.Platform}} +image_id: {{.ID}} +size: {{.Size}} +created: {{.Created}} +` + default: + return cliformatter.Format(source) + } +} + +// ImageWrite writes formatted Compose images using Docker CLI templates. +func ImageWrite(ctx cliformatter.Context, images []Image) error { + render := func(format func(cliformatter.SubContext) error) error { + for _, image := range images { + if err := format(&imageContext{image: image}); err != nil { + return err + } + } + return nil + } + return ctx.Write(newImageContext(), render) +} + +type imageContext struct { + cliformatter.HeaderContext + image Image +} + +func newImageContext() *imageContext { + imageCtx := imageContext{} + imageCtx.Header = cliformatter.SubHeaderContext{ + "ContainerName": composeImageContainerHeader, + "ID": composeImageIDHeader, + "Repository": composeImageRepository, + "Tag": composeImageTag, + "Platform": composeImagePlatform, + "Size": composeImageSize, + "Created": composeImageCreated, + "CreatedAt": composeImageCreatedAt, + "LastTagTime": composeImageLastTagTime, + } + return &imageCtx +} + +func (c *imageContext) MarshalJSON() ([]byte, error) { + return cliformatter.MarshalJSON(c) +} + +func (c *imageContext) ContainerName() string { + return c.image.ContainerName +} + +func (c *imageContext) ID() string { + return stringid.TruncateID(c.image.Summary.ID) +} + +func (c *imageContext) Repository() string { + if c.image.Summary.Repository == "" { + return "" + } + return c.image.Summary.Repository +} + +func (c *imageContext) Tag() string { + if c.image.Summary.Tag == "" { + return "" + } + return c.image.Summary.Tag +} + +func (c *imageContext) Platform() string { + return platforms.Format(c.image.Summary.Platform) +} + +func (c *imageContext) Size() string { + return units.HumanSizeWithPrecision(float64(c.image.Summary.Size), 3) +} + +func (c *imageContext) Created() string { + if c.image.Summary.Created == nil { + return "N/A" + } + return units.HumanDuration(time.Now().UTC().Sub(*c.image.Summary.Created)) + " ago" +} + +func (c *imageContext) CreatedAt() string { + if c.image.Summary.Created == nil { + return "N/A" + } + return c.image.Summary.Created.Format(time.RFC3339Nano) +} + +func (c *imageContext) LastTagTime() string { + return c.image.Summary.LastTagTime.Format(time.RFC3339Nano) +} diff --git a/cmd/formatter/project.go b/cmd/formatter/project.go new file mode 100644 index 00000000000..4784991d1e1 --- /dev/null +++ b/cmd/formatter/project.go @@ -0,0 +1,93 @@ +/* + Copyright 2026 Docker Compose CLI authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package formatter + +import cliformatter "github.com/docker/cli/cli/command/formatter" + +const ( + defaultProjectTableFormat = "table {{.Name}}\t{{.Status}}\t{{.ConfigFiles}}" + + projectNameHeader = "NAME" + projectStatusHeader = "STATUS" + projectConfigFilesHeader = "CONFIG FILES" +) + +// Project is the display model for docker compose ls. +type Project struct { + Name string + Status string + ConfigFiles string +} + +// NewProjectFormat returns a Docker CLI formatter format for Compose projects. +func NewProjectFormat(source string) cliformatter.Format { + switch source { + case cliformatter.TableFormatKey, "": + return cliformatter.Format(defaultProjectTableFormat) + case cliformatter.RawFormatKey: + return `name: {{.Name}} +status: {{.Status}} +config_files: {{.ConfigFiles}} +` + default: + return cliformatter.Format(source) + } +} + +// ProjectWrite writes formatted Compose projects using Docker CLI templates. +func ProjectWrite(ctx cliformatter.Context, projects []Project) error { + render := func(format func(cliformatter.SubContext) error) error { + for _, project := range projects { + if err := format(&projectContext{project: project}); err != nil { + return err + } + } + return nil + } + return ctx.Write(newProjectContext(), render) +} + +type projectContext struct { + cliformatter.HeaderContext + project Project +} + +func newProjectContext() *projectContext { + projectCtx := projectContext{} + projectCtx.Header = cliformatter.SubHeaderContext{ + "Name": projectNameHeader, + "Status": projectStatusHeader, + "ConfigFiles": projectConfigFilesHeader, + } + return &projectCtx +} + +func (c *projectContext) MarshalJSON() ([]byte, error) { + return cliformatter.MarshalJSON(c) +} + +func (c *projectContext) Name() string { + return c.project.Name +} + +func (c *projectContext) Status() string { + return c.project.Status +} + +func (c *projectContext) ConfigFiles() string { + return c.project.ConfigFiles +}