diff --git a/README.md b/README.md index 7a3b750569..d5c36ab321 100644 --- a/README.md +++ b/README.md @@ -332,6 +332,7 @@ It does not necessarily mean that the corresponding features are missing in cont - [:whale: nerdctl compose logs](#whale-nerdctl-compose-logs) - [:whale: nerdctl compose build](#whale-nerdctl-compose-build) - [:whale: nerdctl compose down](#whale-nerdctl-compose-down) + - [:whale: nerdctl compose images](#whale-nerdctl-compose-images) - [:whale: nerdctl compose stop](#whale-nerdctl-compose-stop) - [:whale: nerdctl compose ps](#whale-nerdctl-compose-ps) - [:whale: nerdctl compose pull](#whale-nerdctl-compose-pull) @@ -1446,6 +1447,16 @@ Flags: Unimplemented `docker-compose down` (V1) flags: `--rmi`, `--remove-orphans`, `--timeout` +### :whale: nerdctl compose images + +List images used by created containers in services + +Usage: `nerdctl compose images [OPTIONS] [SERVICE...]` + +Flags: + +- :whale: `-q, --quiet`: Only show numeric image IDs + ### :whale: nerdctl compose stop Stop containers in services without removing them. @@ -1614,7 +1625,7 @@ Registry: - `docker search` Compose: -- `docker-compose create|events|exec|images|pause|port|scale|start|top|unpause` +- `docker-compose create|events|exec|pause|port|scale|start|top|unpause` Others: - `docker system df` diff --git a/cmd/nerdctl/compose.go b/cmd/nerdctl/compose.go index 89acea04ed..339da0f241 100644 --- a/cmd/nerdctl/compose.go +++ b/cmd/nerdctl/compose.go @@ -58,6 +58,7 @@ func newComposeCommand() *cobra.Command { newComposeLogsCommand(), newComposeConfigCommand(), newComposeBuildCommand(), + newComposeImagesCommand(), newComposePushCommand(), newComposePullCommand(), newComposeDownCommand(), diff --git a/cmd/nerdctl/compose_images.go b/cmd/nerdctl/compose_images.go new file mode 100644 index 0000000000..91a50d761f --- /dev/null +++ b/cmd/nerdctl/compose_images.go @@ -0,0 +1,190 @@ +/* + Copyright The containerd 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 main + +import ( + "context" + "fmt" + "strings" + "sync" + "text/tabwriter" + + "github.com/containerd/containerd" + "github.com/containerd/containerd/pkg/progress" + "github.com/containerd/containerd/snapshots" + "github.com/containerd/nerdctl/pkg/imgutil" + "github.com/containerd/nerdctl/pkg/labels" + "github.com/containerd/nerdctl/pkg/strutil" + "github.com/spf13/cobra" + "golang.org/x/sync/errgroup" +) + +func newComposeImagesCommand() *cobra.Command { + var composeImagesCommand = &cobra.Command{ + Use: "images [flags] [SERVICE...]", + Short: "List images used by created containers in services", + RunE: composeImagesAction, + SilenceUsage: true, + SilenceErrors: true, + } + composeImagesCommand.Flags().BoolP("quiet", "q", false, "Only show numeric image IDs") + + return composeImagesCommand +} + +func composeImagesAction(cmd *cobra.Command, args []string) error { + quiet, err := cmd.Flags().GetBool("quiet") + if err != nil { + return err + } + + client, ctx, cancel, err := newClient(cmd) + if err != nil { + return err + } + defer cancel() + + c, err := getComposer(cmd, client) + if err != nil { + return err + } + + serviceNames, err := c.ServiceNames(args...) + if err != nil { + return err + } + + containers, err := c.Containers(ctx, serviceNames...) + if err != nil { + return err + } + + if quiet { + return printComposeImageIDs(ctx, containers) + } + + snapshotter, err := cmd.Flags().GetString("snapshotter") + if err != nil { + return err + } + sn := client.SnapshotService(snapshotter) + + return printComposeImages(ctx, cmd, containers, sn) +} + +func printComposeImageIDs(ctx context.Context, containers []containerd.Container) error { + ids := []string{} + for _, c := range containers { + image, err := c.Image(ctx) + if err != nil { + return err + } + metaImage := image.Metadata() + id := metaImage.Target.Digest.String() + if !strutil.InStringSlice(ids, id) { + ids = append(ids, id) + } + } + + for _, id := range ids { + // always truncate image ids. + fmt.Println(strings.Split(id, ":")[1][:12]) + } + return nil +} + +func printComposeImages(ctx context.Context, cmd *cobra.Command, containers []containerd.Container, sn snapshots.Snapshotter) error { + type composeImagePrintable struct { + ContainerName string + Repository string + Tag string + ImageID string + Size string + } + + var ( + imagePrintables = []composeImagePrintable{} + mu sync.Mutex + ) + + eg, ctx := errgroup.WithContext(ctx) + for _, c := range containers { + c := c + eg.Go(func() error { + info, err := c.Info(ctx, containerd.WithoutRefreshedMetadata) + if err != nil { + return err + } + containerName := info.Labels[labels.Name] + + image, err := c.Image(ctx) + if err != nil { + return err + } + + size, err := unpackedImageSize(ctx, sn, image) + if err != nil { + return err + } + + metaImage := image.Metadata() + repository, tag := imgutil.ParseRepoTag(metaImage.Name) + imageID := metaImage.Target.Digest.String() + if repository == "" { + repository = "" + } + if tag == "" { + tag = "" + } + imageID = strings.Split(imageID, ":")[1][:12] + + printable := composeImagePrintable{ + ContainerName: containerName, + Repository: repository, + Tag: tag, + ImageID: imageID, + Size: progress.Bytes(size).String(), + } + + mu.Lock() + defer mu.Unlock() + imagePrintables = append(imagePrintables, printable) + + return nil + }) + } + + if err := eg.Wait(); err != nil { + return err + } + + w := tabwriter.NewWriter(cmd.OutOrStdout(), 4, 8, 4, ' ', 0) + fmt.Fprintln(w, "Container\tRepository\tTag\tImage Id\tSize") + for _, p := range imagePrintables { + if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", + p.ContainerName, + p.Repository, + p.Tag, + p.ImageID, + p.Size, + ); err != nil { + return err + } + } + + return w.Flush() +} diff --git a/cmd/nerdctl/compose_images_linux_test.go b/cmd/nerdctl/compose_images_linux_test.go new file mode 100644 index 0000000000..0fd7684044 --- /dev/null +++ b/cmd/nerdctl/compose_images_linux_test.go @@ -0,0 +1,86 @@ +/* + Copyright The containerd 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 main + +import ( + "fmt" + "strings" + "testing" + + "github.com/containerd/nerdctl/pkg/testutil" +) + +func TestComposeImages(t *testing.T) { + base := testutil.NewBase(t) + var dockerComposeYAML = fmt.Sprintf(` +version: '3.1' + +services: + wordpress: + image: %s + ports: + - 8080:80 + environment: + WORDPRESS_DB_HOST: db + WORDPRESS_DB_USER: exampleuser + WORDPRESS_DB_PASSWORD: examplepass + WORDPRESS_DB_NAME: exampledb + volumes: + - wordpress:/var/www/html + db: + image: %s + environment: + MYSQL_DATABASE: exampledb + MYSQL_USER: exampleuser + MYSQL_PASSWORD: examplepass + MYSQL_RANDOM_ROOT_PASSWORD: '1' + volumes: + - db:/var/lib/mysql + +volumes: + wordpress: + db: +`, testutil.WordpressImage, testutil.MariaDBImage) + + comp := testutil.NewComposeDir(t, dockerComposeYAML) + defer comp.CleanUp() + projectName := comp.ProjectName() + t.Logf("projectName=%q", projectName) + + base.ComposeCmd("-f", comp.YAMLFullPath(), "up", "-d").AssertOK() + defer base.ComposeCmd("-f", comp.YAMLFullPath(), "down", "-v").Run() + + ImageAssertHandler := func(svc string, image string, exist bool) func(stdout string) error { + return func(stdout string) error { + if strings.Contains(stdout, image) != exist { + return fmt.Errorf("image %s from service %s: expect in output (%t), actual (%t)", image, svc, exist, !exist) + } + return nil + } + } + + wordpressImageName := strings.Split(testutil.WordpressImage, ":")[0] + dbImageName := strings.Split(testutil.MariaDBImage, ":")[0] + + // check one service image + base.ComposeCmd("-f", comp.YAMLFullPath(), "images", "db").AssertOutWithFunc(ImageAssertHandler("db", dbImageName, true)) + base.ComposeCmd("-f", comp.YAMLFullPath(), "images", "db").AssertOutWithFunc(ImageAssertHandler("wordpress", wordpressImageName, false)) + + // check all service images + base.ComposeCmd("-f", comp.YAMLFullPath(), "images").AssertOutWithFunc(ImageAssertHandler("db", dbImageName, true)) + base.ComposeCmd("-f", comp.YAMLFullPath(), "images").AssertOutWithFunc(ImageAssertHandler("wordpress", wordpressImageName, true)) +}