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 +}