diff --git a/cmd/nerdctl/compose_create_linux_test.go b/cmd/nerdctl/compose_create_linux_test.go index 7935d437c36..5e5ce315ec7 100644 --- a/cmd/nerdctl/compose_create_linux_test.go +++ b/cmd/nerdctl/compose_create_linux_test.go @@ -24,10 +24,6 @@ import ( ) func TestComposeCreate(t *testing.T) { - // docker-compose v1 depecreated this command - // docker-compose v2 reimplemented this command - testutil.DockerIncompatible(t) - base := testutil.NewBase(t) var dockerComposeYAML = fmt.Sprintf(` version: '3.1' @@ -46,7 +42,7 @@ services: // 1.1 `compose create` should create service container (in `created` status) base.ComposeCmd("-f", comp.YAMLFullPath(), "create").AssertOK() - base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "svc0").AssertOutContainsAny("Created", "created") + base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "svc0", "-a").AssertOutContainsAny("Created", "created") // 1.2 created container can be started by `compose start` base.ComposeCmd("-f", comp.YAMLFullPath(), "start").AssertOK() } @@ -78,8 +74,8 @@ services: // `compose create` should create containers for both services and their dependencies base.ComposeCmd("-f", comp.YAMLFullPath(), "create", "svc0").AssertOK() - base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "svc0").AssertOutContainsAny("Created", "created") - base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "svc1").AssertOutContainsAny("Created", "created") + base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "svc0", "-a").AssertOutContainsAny("Created", "created") + base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "svc1", "-a").AssertOutContainsAny("Created", "created") } func TestComposeCreatePull(t *testing.T) { @@ -111,7 +107,7 @@ services: base.ComposeCmd("-f", comp.YAMLFullPath(), "create").AssertOK() base.Cmd("rmi", "-f", testutil.AlpineImage).Run() base.ComposeCmd("-f", comp.YAMLFullPath(), "create", "--pull", "always").AssertOK() - base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "svc0").AssertOutContainsAny("Created", "created") + base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "svc0", "-a").AssertOutContainsAny("Created", "created") } func TestComposeCreateBuild(t *testing.T) { @@ -148,5 +144,5 @@ services: // `compose create --build` should succeed: image is built and container is created base.ComposeCmd("-f", comp.YAMLFullPath(), "create", "--build").AssertOK() base.ComposeCmd("-f", comp.YAMLFullPath(), "images", "svc0").AssertOutContains(imageSvc0) - base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "svc0").AssertOutContainsAny("Created", "created") + base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "svc0", "-a").AssertOutContainsAny("Created", "created") } diff --git a/cmd/nerdctl/compose_down_linux_test.go b/cmd/nerdctl/compose_down_linux_test.go index 5b3943f3782..64c55479408 100644 --- a/cmd/nerdctl/compose_down_linux_test.go +++ b/cmd/nerdctl/compose_down_linux_test.go @@ -99,5 +99,5 @@ services: defer base.ComposeCmd("-p", projectName, "-f", compFull.YAMLFullPath(), "down", "-v").Run() base.ComposeCmd("-p", projectName, "-f", compOrphan.YAMLFullPath(), "down", "--remove-orphans").AssertOK() - base.ComposeCmd("-p", projectName, "-f", compFull.YAMLFullPath(), "ps").AssertOutNotContains(orphanContainer) + base.ComposeCmd("-p", projectName, "-f", compFull.YAMLFullPath(), "ps", "-a").AssertOutNotContains(orphanContainer) } diff --git a/cmd/nerdctl/compose_kill_linux_test.go b/cmd/nerdctl/compose_kill_linux_test.go index 3d948ebadb8..cdcc6245e41 100644 --- a/cmd/nerdctl/compose_kill_linux_test.go +++ b/cmd/nerdctl/compose_kill_linux_test.go @@ -73,6 +73,6 @@ volumes: base.ComposeCmd("-f", comp.YAMLFullPath(), "kill", "db").AssertOK() time.Sleep(3 * time.Second) // Docker Compose v1: "Exit 137", v2: "exited (137)" - base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "db").AssertOutContainsAny(" 137", "(137)") + base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "db", "-a").AssertOutContainsAny(" 137", "(137)") base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "wordpress").AssertOutContainsAny("Up", "running") } diff --git a/cmd/nerdctl/compose_pause_linux_test.go b/cmd/nerdctl/compose_pause_linux_test.go index aaccca155bd..e6fe615ec4d 100644 --- a/cmd/nerdctl/compose_pause_linux_test.go +++ b/cmd/nerdctl/compose_pause_linux_test.go @@ -52,7 +52,7 @@ services: // pause a service should (only) pause its own container base.ComposeCmd("-f", comp.YAMLFullPath(), "pause", "svc0").AssertOK() - base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "svc0").AssertOutContainsAny("Paused", "paused") + base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "svc0", "-a").AssertOutContainsAny("Paused", "paused") base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "svc1").AssertOutContainsAny("Up", "running") // unpause should be able to recover the paused service container diff --git a/cmd/nerdctl/compose_ps.go b/cmd/nerdctl/compose_ps.go index 54f0ba1e596..88c4437720b 100644 --- a/cmd/nerdctl/compose_ps.go +++ b/cmd/nerdctl/compose_ps.go @@ -19,9 +19,13 @@ package main import ( "context" "fmt" + "strings" "text/tabwriter" + "time" "github.com/containerd/containerd" + "github.com/containerd/containerd/errdefs" + "github.com/containerd/containerd/runtime/restart" gocni "github.com/containerd/go-cni" "github.com/containerd/nerdctl/pkg/clientutil" "github.com/containerd/nerdctl/pkg/cmd/compose" @@ -42,7 +46,12 @@ func newComposePsCommand() *cobra.Command { SilenceUsage: true, SilenceErrors: true, } - composePsCommand.Flags().String("format", "", "Format the output. Supported values: [json]") + composePsCommand.Flags().String("format", "table", "Format the output. Supported values: [table|json]") + composePsCommand.Flags().String("filter", "", "Filter matches containers based on given conditions") + composePsCommand.Flags().StringArray("status", []string{}, "Filter services by status. Values: [paused | restarting | removing | running | dead | created | exited]") + composePsCommand.Flags().BoolP("quiet", "q", false, "Only display container IDs") + composePsCommand.Flags().Bool("services", false, "Display services") + composePsCommand.Flags().BoolP("all", "a", false, "Show all containers (default shows just running)") return composePsCommand } @@ -71,8 +80,40 @@ func composePsAction(cmd *cobra.Command, args []string) error { if err != nil { return err } - if format != "json" && format != "" { - return fmt.Errorf("unsupported format %s, supported formats are: [json]", format) + if format != "json" && format != "table" { + return fmt.Errorf("unsupported format %s, supported formats are: [table|json]", format) + } + status, err := cmd.Flags().GetStringArray("status") + if err != nil { + return err + } + quiet, err := cmd.Flags().GetBool("quiet") + if err != nil { + return err + } + displayServices, err := cmd.Flags().GetBool("services") + if err != nil { + return err + } + filter, err := cmd.Flags().GetString("filter") + if err != nil { + return err + } + if filter != "" { + splited := strings.SplitN(filter, "=", 2) + if len(splited) != 2 { + return fmt.Errorf("invalid argument \"%s\" for \"-f, --filter\": bad format of filter (expected name=value)", filter) + } + // currently only the 'status' filter is supported + if splited[0] != "status" { + return fmt.Errorf("invalid filter '%s'", splited[0]) + } + status = append(status, splited[1]) + } + + all, err := cmd.Flags().GetBool("all") + if err != nil { + return err } client, ctx, cancel, err := clientutil.NewClient(cmd.Context(), globalOptions.Namespace, globalOptions.Address) @@ -97,6 +138,41 @@ func composePsAction(cmd *cobra.Command, args []string) error { return err } + if !all { + var upContainers []containerd.Container + for _, container := range containers { + // cStatus := formatter.ContainerStatus(ctx, c) + cStatus, err := containerutil.ContainerStatus(ctx, container) + if err != nil { + continue + } + if cStatus.Status == containerd.Running { + upContainers = append(upContainers, container) + } + } + containers = upContainers + } + + if len(status) != 0 { + var filterdContainers []containerd.Container + for _, container := range containers { + cStatus := statusForFilter(ctx, container) + for _, s := range status { + if cStatus == s { + filterdContainers = append(filterdContainers, container) + } + } + } + containers = filterdContainers + } + + if quiet { + for _, c := range containers { + fmt.Fprintln(cmd.OutOrStdout(), c.ID()) + } + return nil + } + containersPrintable := make([]composeContainerPrintable, len(containers)) eg, ctx := errgroup.WithContext(ctx) for i, container := range containers { @@ -121,6 +197,12 @@ func composePsAction(cmd *cobra.Command, args []string) error { return err } + if displayServices { + for _, p := range containersPrintable { + fmt.Fprintln(cmd.OutOrStdout(), p.Service) + } + return nil + } if format == "json" { outJSON, err := formatter.ToJSON(containersPrintable, "", "") if err != nil { @@ -198,9 +280,11 @@ func composeContainerPrintableJSON(ctx context.Context, container containerd.Con if err == nil { // show exitCode only when container is exited/stopped if status.Status == containerd.Stopped { + state = "exited" exitCode = status.ExitStatus + } else { + state = string(status.Status) } - state = string(status.Status) } else { state = string(containerd.Unknown) } @@ -255,3 +339,41 @@ func formatPublishers(labelMap map[string]string) []PortPublisher { } return dockerPorts } + +// statusForFilter returns the status value to be matched with the 'status' filter +func statusForFilter(ctx context.Context, c containerd.Container) string { + // Just in case, there is something wrong in server. + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + task, err := c.Task(ctx, nil) + if err != nil { + // NOTE: NotFound doesn't mean that container hasn't started. + // In docker/CRI-containerd plugin, the task will be deleted + // when it exits. So, the status will be "created" for this + // case. + if errdefs.IsNotFound(err) { + return string(containerd.Created) + } + return string(containerd.Unknown) + } + + status, err := task.Status(ctx) + if err != nil { + return string(containerd.Unknown) + } + labels, err := c.Labels(ctx) + if err != nil { + return string(containerd.Unknown) + } + + switch s := status.Status; s { + case containerd.Stopped: + if labels[restart.StatusLabel] == string(containerd.Running) && restart.Reconcile(status, labels) { + return "restarting" + } + return "exited" + default: + return string(s) + } +} diff --git a/cmd/nerdctl/compose_ps_linux_test.go b/cmd/nerdctl/compose_ps_linux_test.go index 7fdda4a47ae..453829bbba4 100644 --- a/cmd/nerdctl/compose_ps_linux_test.go +++ b/cmd/nerdctl/compose_ps_linux_test.go @@ -21,13 +21,92 @@ import ( "fmt" "strings" "testing" + "time" + "github.com/containerd/nerdctl/pkg/tabutil" "github.com/containerd/nerdctl/pkg/testutil" + "gotest.tools/v3/assert" ) +func TestComposePs(t *testing.T) { + base := testutil.NewBase(t) + var dockerComposeYAML = fmt.Sprintf(` +version: '3.1' + +services: + wordpress: + image: %s + container_name: wordpress_container + 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 + container_name: db_container + environment: + MYSQL_DATABASE: exampledb + MYSQL_USER: exampleuser + MYSQL_PASSWORD: examplepass + MYSQL_RANDOM_ROOT_PASSWORD: '1' + volumes: + - db:/var/lib/mysql + alpine: + image: %s + container_name: alpine_container + +volumes: + wordpress: + db: +`, testutil.WordpressImage, testutil.MariaDBImage, testutil.AlpineImage) + 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() + + assertHandler := func(expectedName, expectedImage string) func(stdout string) error { + return func(stdout string) error { + lines := strings.Split(strings.TrimSpace(stdout), "\n") + if len(lines) < 2 { + return fmt.Errorf("expected at least 2 lines, got %d", len(lines)) + } + + tab := tabutil.NewReader("NAME\tIMAGE\tCOMMAND\tSERVICE\tSTATUS\tPORTS") + err := tab.ParseHeader(lines[0]) + if err != nil { + return fmt.Errorf("failed to parse header: %v", err) + } + + container, _ := tab.ReadRow(lines[1], "NAME") + assert.Equal(t, container, expectedName) + + image, _ := tab.ReadRow(lines[1], "IMAGE") + assert.Equal(t, image, expectedImage) + + return nil + } + + } + + time.Sleep(3 * time.Second) + base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "wordpress").AssertOutWithFunc(assertHandler("wordpress_container", testutil.WordpressImage)) + base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "db").AssertOutWithFunc(assertHandler("db_container", testutil.MariaDBImage)) + base.ComposeCmd("-f", comp.YAMLFullPath(), "ps").AssertOutNotContains(testutil.AlpineImage) + base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "alpine", "-a").AssertOutWithFunc(assertHandler("alpine_container", testutil.AlpineImage)) + base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "-a", "--filter", "status=exited").AssertOutWithFunc(assertHandler("alpine_container", testutil.AlpineImage)) + base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "--services", "-a").AssertOutContainsAll("wordpress\n", "db\n", "alpine\n") +} + func TestComposePsJSON(t *testing.T) { - // `--format` is only supported in docker compose v2. - // Currently, CI is using docker compose v1. + // docker parses unknown 'format' as a Go template and won't output an error testutil.DockerIncompatible(t) base := testutil.NewBase(t) @@ -101,8 +180,8 @@ volumes: AssertOutWithFunc(assertHandler("wordpress", 1, `"Service":"wordpress"`, `"State":"running"`, `"TargetPort":80`, `"PublishedPort":8080`)) // check wordpress is stopped base.ComposeCmd("-f", comp.YAMLFullPath(), "stop", "wordpress").AssertOK() - base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "--format", "json", "wordpress"). - AssertOutWithFunc(assertHandler("wordpress", 1, `"Service":"wordpress"`, `"State":"stopped"`)) + base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "--format", "json", "wordpress", "-a"). + AssertOutWithFunc(assertHandler("wordpress", 1, `"Service":"wordpress"`, `"State":"exited"`)) // check wordpress is removed base.ComposeCmd("-f", comp.YAMLFullPath(), "rm", "-f", "wordpress").AssertOK() base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "--format", "json", "wordpress"). diff --git a/cmd/nerdctl/compose_restart_linux_test.go b/cmd/nerdctl/compose_restart_linux_test.go index 8de3513fd41..ed1d2bd1466 100644 --- a/cmd/nerdctl/compose_restart_linux_test.go +++ b/cmd/nerdctl/compose_restart_linux_test.go @@ -24,9 +24,6 @@ import ( ) func TestComposeRestart(t *testing.T) { - // docker-compose v2 hides exited containers in `compose ps`, and shows - // them if `-a` is passed, which is not supported yet by `nerdctl compose`. - testutil.DockerIncompatible(t) base := testutil.NewBase(t) var dockerComposeYAML = fmt.Sprintf(` version: '3.1' @@ -68,13 +65,13 @@ volumes: // stop and restart a single service. base.ComposeCmd("-f", comp.YAMLFullPath(), "stop", "db").AssertOK() - base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "db").AssertOutContainsAny("Exit", "exited") + base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "db", "-a").AssertOutContainsAny("Exit", "exited") base.ComposeCmd("-f", comp.YAMLFullPath(), "restart", "db").AssertOK() base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "db").AssertOutContainsAny("Up", "running") // stop one service and restart all (also check `--timeout` arg). base.ComposeCmd("-f", comp.YAMLFullPath(), "stop", "db").AssertOK() - base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "db").AssertOutContainsAny("Exit", "exited") + base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "db", "-a").AssertOutContainsAny("Exit", "exited") base.ComposeCmd("-f", comp.YAMLFullPath(), "restart", "--timeout", "5").AssertOK() base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "db").AssertOutContainsAny("Up", "running") base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "wordpress").AssertOutContainsAny("Up", "running") diff --git a/cmd/nerdctl/compose_stop_linux_test.go b/cmd/nerdctl/compose_stop_linux_test.go index cadff7f30fd..35377ad5da0 100644 --- a/cmd/nerdctl/compose_stop_linux_test.go +++ b/cmd/nerdctl/compose_stop_linux_test.go @@ -70,11 +70,11 @@ volumes: // stop should (only) stop the given service. base.ComposeCmd("-f", comp.YAMLFullPath(), "stop", "db").AssertOK() - base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "db").AssertOutContainsAny("Exit", "exited") + base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "db", "-a").AssertOutContainsAny("Exit", "exited") base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "wordpress").AssertOutContainsAny("Up", "running") // `--timeout` arg should work properly. base.ComposeCmd("-f", comp.YAMLFullPath(), "stop", "--timeout", "5", "wordpress").AssertOK() - base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "wordpress").AssertOutContainsAny("Exit", "exited") + base.ComposeCmd("-f", comp.YAMLFullPath(), "ps", "wordpress", "-a").AssertOutContainsAny("Exit", "exited") } diff --git a/docs/command-reference.md b/docs/command-reference.md index 057681e7af0..e9a9a0675d4 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -1490,9 +1490,17 @@ List containers of services Usage: `nerdctl compose ps [OPTIONS] [SERVICE...]` -Unimplemented `docker-compose ps` (V1) flags: `--quiet`, `--services`, `--filter`, `--all` - -Unimplemented `docker compose ps` (V2) flags: `--status` +- :whale: `-a, --all`: Show all containers (default shows just running) +- :whale: `-q, --quiet`: Only display container IDs +- :whale: `--format`: Format the output + - :whale: `--format=table` (default): Table + - :whale: `--format=json'`: JSON +- :whale: `-f, --filter`: Filter containers based on given conditions + - :whale: `--filter status=`: One of `created, running, paused, + restarting, exited, pausing, unknown`. Note that `removing, dead` are + not supported and will be ignored +- :whale: `--services`: Print the service names, one per line +- :whale: `--status`: Filter containers by status. Values: [paused | restarting | running | created | exited | pausing | unknown] ### :whale: nerdctl compose pull