diff --git a/aci/compose.go b/aci/compose.go index 7e057269c..62f9e1cc3 100644 --- a/aci/compose.go +++ b/aci/compose.go @@ -241,3 +241,7 @@ func (cs *aciComposeService) Events(ctx context.Context, project string, options func (cs *aciComposeService) Port(ctx context.Context, project string, service string, port int, options compose.PortOptions) (string, int, error) { return "", 0, errdefs.ErrNotImplemented } + +func (cs *aciComposeService) Images(ctx context.Context, projectName string, options compose.ImagesOptions) ([]compose.ImageSummary, error) { + return nil, errdefs.ErrNotImplemented +} diff --git a/api/client/compose.go b/api/client/compose.go index 0b0e49a12..dea38e1ed 100644 --- a/api/client/compose.go +++ b/api/client/compose.go @@ -115,3 +115,7 @@ func (c *composeService) Events(ctx context.Context, project string, options com func (c *composeService) Port(ctx context.Context, project string, service string, port int, options compose.PortOptions) (string, int, error) { return "", 0, errdefs.ErrNotImplemented } + +func (c *composeService) Images(ctx context.Context, projectName string, options compose.ImagesOptions) ([]compose.ImageSummary, error) { + return nil, errdefs.ErrNotImplemented +} diff --git a/api/compose/api.go b/api/compose/api.go index 1bf84f74a..48f83c949 100644 --- a/api/compose/api.go +++ b/api/compose/api.go @@ -72,6 +72,8 @@ type Service interface { Events(ctx context.Context, project string, options EventsOptions) error // Port executes the equivalent to a `compose port` Port(ctx context.Context, project string, service string, port int, options PortOptions) (string, int, error) + // Images executes the equivalent of a `compose images` + Images(ctx context.Context, projectName string, options ImagesOptions) ([]ImageSummary, error) } // BuildOptions group options of the Build API @@ -164,6 +166,11 @@ type PullOptions struct { IgnoreFailures bool } +// ImagesOptions group options of the Images API +type ImagesOptions struct { + Services []string +} + // KillOptions group options of the Kill API type KillOptions struct { // Signal to send to containers @@ -285,6 +292,15 @@ type ContainerProcSummary struct { Titles []string } +// ImageSummary holds container image description +type ImageSummary struct { + ID string + ContainerName string + Repository string + Tag string + Size int64 +} + // ServiceStatus hold status about a service type ServiceStatus struct { ID string diff --git a/cli/cmd/compose/compose.go b/cli/cmd/compose/compose.go index c7f32b6a1..e15911180 100644 --- a/cli/cmd/compose/compose.go +++ b/cli/cmd/compose/compose.go @@ -157,6 +157,7 @@ func Command(contextType string) *cobra.Command { topCommand(&opts), eventsCommand(&opts), portCommand(&opts), + imagesCommand(&opts), ) if contextType == store.LocalContextType || contextType == store.DefaultContextType { diff --git a/cli/cmd/compose/images.go b/cli/cmd/compose/images.go new file mode 100644 index 000000000..edcf8b07e --- /dev/null +++ b/cli/cmd/compose/images.go @@ -0,0 +1,107 @@ +/* + Copyright 2020 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 compose + +import ( + "context" + "fmt" + "io" + "os" + "sort" + "strings" + + "github.com/spf13/cobra" + + "github.com/docker/compose-cli/api/client" + "github.com/docker/compose-cli/api/compose" + "github.com/docker/compose-cli/cli/formatter" + "github.com/docker/compose-cli/utils" + "github.com/docker/docker/pkg/stringid" + + units "github.com/docker/go-units" +) + +type imageOptions struct { + *projectOptions + Quiet bool +} + +func imagesCommand(p *projectOptions) *cobra.Command { + opts := imageOptions{ + projectOptions: p, + } + imgCmd := &cobra.Command{ + Use: "images [SERVICE...]", + Short: "List images used by the created containers", + RunE: func(cmd *cobra.Command, args []string) error { + return runImages(cmd.Context(), opts, args) + }, + } + imgCmd.Flags().BoolVarP(&opts.Quiet, "quiet", "q", false, "Only display IDs") + return imgCmd +} + +func runImages(ctx context.Context, opts imageOptions, services []string) error { + c, err := client.New(ctx) + if err != nil { + return err + } + + projectName, err := opts.toProjectName() + if err != nil { + return err + } + + images, err := c.ComposeService().Images(ctx, projectName, compose.ImagesOptions{ + Services: services, + }) + if err != nil { + return err + } + + if opts.Quiet { + ids := []string{} + for _, img := range images { + id := img.ID + if i := strings.IndexRune(img.ID, ':'); i >= 0 { + id = id[i+1:] + } + if !utils.StringContains(ids, id) { + ids = append(ids, id) + } + } + for _, img := range ids { + fmt.Println(img) + } + return nil + } + + sort.Slice(images, func(i, j int) bool { + return images[i].ContainerName < images[j].ContainerName + }) + + return formatter.Print(images, formatter.PRETTY, os.Stdout, + func(w io.Writer) { + for _, img := range images { + id := stringid.TruncateID(img.ID) + size := units.HumanSizeWithPrecision(float64(img.Size), 3) + + _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", img.ContainerName, img.Repository, img.Tag, id, size) + } + }, + "Container", "Repository", "Tag", "Image Id", "Size") +} diff --git a/ecs/images.go b/ecs/images.go new file mode 100644 index 000000000..49447dc73 --- /dev/null +++ b/ecs/images.go @@ -0,0 +1,28 @@ +/* + Copyright 2020 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 ecs + +import ( + "context" + + "github.com/docker/compose-cli/api/compose" + "github.com/docker/compose-cli/api/errdefs" +) + +func (b *ecsAPIService) Images(ctx context.Context, projectName string, options compose.ImagesOptions) ([]compose.ImageSummary, error) { + return nil, errdefs.ErrNotImplemented +} diff --git a/ecs/local/compose.go b/ecs/local/compose.go index f6db2ed64..aca732ffc 100644 --- a/ecs/local/compose.go +++ b/ecs/local/compose.go @@ -207,3 +207,7 @@ func (e ecsLocalSimulation) Events(ctx context.Context, project string, options func (e ecsLocalSimulation) Port(ctx context.Context, project string, service string, port int, options compose.PortOptions) (string, int, error) { return "", 0, errdefs.ErrNotImplemented } + +func (e ecsLocalSimulation) Images(ctx context.Context, projectName string, options compose.ImagesOptions) ([]compose.ImageSummary, error) { + return nil, errdefs.ErrNotImplemented +} diff --git a/kube/compose.go b/kube/compose.go index 9ec914147..e72937fdc 100644 --- a/kube/compose.go +++ b/kube/compose.go @@ -271,3 +271,7 @@ func (s *composeService) Events(ctx context.Context, project string, options com func (s *composeService) Port(ctx context.Context, project string, service string, port int, options compose.PortOptions) (string, int, error) { return "", 0, errdefs.ErrNotImplemented } + +func (s *composeService) Images(ctx context.Context, projectName string, options compose.ImagesOptions) ([]compose.ImageSummary, error) { + return nil, errdefs.ErrNotImplemented +} diff --git a/local/compose/images.go b/local/compose/images.go new file mode 100644 index 000000000..e24168e9f --- /dev/null +++ b/local/compose/images.go @@ -0,0 +1,104 @@ +/* + Copyright 2020 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 compose + +import ( + "context" + "fmt" + "strings" + + moby "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/filters" + "golang.org/x/sync/errgroup" + + "github.com/docker/compose-cli/api/compose" + "github.com/docker/compose-cli/utils" +) + +func (s *composeService) Images(ctx context.Context, projectName string, options compose.ImagesOptions) ([]compose.ImageSummary, error) { + allContainers, err := s.apiClient.ContainerList(ctx, moby.ContainerListOptions{ + Filters: filters.NewArgs(projectFilter(projectName)), + }) + if err != nil { + return nil, err + } + containers := []moby.Container{} + if len(options.Services) > 0 { + // filter service containers + for _, c := range allContainers { + if utils.StringContains(options.Services, c.Labels[compose.ServiceTag]) { + containers = append(containers, c) + + } + } + } else { + containers = allContainers + } + + imageIDs := []string{} + // aggregate image IDs + for _, c := range containers { + if !utils.StringContains(imageIDs, c.ImageID) { + imageIDs = append(imageIDs, c.ImageID) + } + } + + images := map[string]moby.ImageInspect{} + eg, ctx := errgroup.WithContext(ctx) + for _, img := range imageIDs { + img := img + eg.Go(func() error { + inspect, _, err := s.apiClient.ImageInspectWithRaw(ctx, img) + if err != nil { + return err + } + images[img] = inspect + return nil + }) + } + err = eg.Wait() + + if err != nil { + return nil, err + } + summary := make([]compose.ImageSummary, len(containers)) + for i, container := range containers { + img, ok := images[container.ImageID] + if !ok { + return nil, fmt.Errorf("failed to retrieve image for container %s", getCanonicalContainerName(container)) + } + if len(img.RepoTags) == 0 { + return nil, fmt.Errorf("no image tag found for %s", img.ID) + } + tag := "" + repository := "" + repotag := strings.Split(img.RepoTags[0], ":") + repository = repotag[0] + if len(repotag) > 1 { + tag = repotag[1] + } + + summary[i] = compose.ImageSummary{ + ID: img.ID, + ContainerName: getCanonicalContainerName(container), + Repository: repository, + Tag: tag, + Size: img.Size, + } + } + return summary, nil +} diff --git a/local/e2e/compose/compose_test.go b/local/e2e/compose/compose_test.go index 86f2111f0..1664b39c9 100644 --- a/local/e2e/compose/compose_test.go +++ b/local/e2e/compose/compose_test.go @@ -109,6 +109,13 @@ func TestLocalComposeUp(t *testing.T) { res.Assert(t, icmd.Expected{Out: `compose-e2e-demo_db_1 db running 5432/tcp`}) }) + t.Run("images", func(t *testing.T) { + res := c.RunDockerCmd("compose", "-p", projectName, "images") + res.Assert(t, icmd.Expected{Out: `compose-e2e-demo_db_1 gtardif/sentences-db latest`}) + res.Assert(t, icmd.Expected{Out: `compose-e2e-demo_web_1 gtardif/sentences-web latest`}) + res.Assert(t, icmd.Expected{Out: `compose-e2e-demo_words_1 gtardif/sentences-api latest`}) + }) + t.Run("down", func(t *testing.T) { _ = c.RunDockerCmd("compose", "--project-name", projectName, "down") })