diff --git a/README.md b/README.md index f6fefc5c8eb..8e18680d396 100644 --- a/README.md +++ b/README.md @@ -331,6 +331,7 @@ It does not necessarily mean that the corresponding features are missing in cont - [:whale: nerdctl compose up](#whale-nerdctl-compose-up) - [:whale: nerdctl compose logs](#whale-nerdctl-compose-logs) - [:whale: nerdctl compose build](#whale-nerdctl-compose-build) + - [:whale: nerdctl compose create](#whale-nerdctl-compose-create) - [:whale: nerdctl compose exec](#whale-nerdctl-compose-exec) - [:whale: nerdctl compose down](#whale-nerdctl-compose-down) - [:whale: nerdctl compose images](#whale-nerdctl-compose-images) @@ -1445,6 +1446,20 @@ Flags: Unimplemented `docker-compose build` (V1) flags: `--compress`, `--force-rm`, `--memory`, `--no-rm`, `--parallel`, `--pull`, `--quiet` +### :whale: nerdctl compose create + +Creates containers for one or more services. + +Usage: `nerdctl compose create [OPTIONS] [SERVICE...]` + +Flags: + +- :whale: `--build`: Build images before starting containers +- :whale: `--force-recreate`: Recreate containers even if their configuration and image haven't changed +- :whale: `--no-build`: Don't build an image even if it's missing, conflict with `--build` +- :whale: `--no-recreate`: Don't recreate containers if they exist, conflict with `--force-recreate` +- :whale: `--pull`: Pull images before running. (support always|missing|never) (default "missing") + ### :whale: nerdctl compose exec Execute a command on a running container of the service. @@ -1687,7 +1702,7 @@ Registry: - `docker search` Compose: -- `docker-compose create|events|scale` +- `docker-compose events|scale` Others: - `docker system df` diff --git a/cmd/nerdctl/compose.go b/cmd/nerdctl/compose.go index 18ce267e19a..236a67e3c2a 100644 --- a/cmd/nerdctl/compose.go +++ b/cmd/nerdctl/compose.go @@ -75,6 +75,7 @@ func newComposeCommand() *cobra.Command { newComposePauseCommand(), newComposeUnpauseCommand(), newComposeTopCommand(), + newComposeCreateCommand(), ) return composeCommand diff --git a/cmd/nerdctl/compose_create.go b/cmd/nerdctl/compose_create.go new file mode 100644 index 00000000000..66993a7ced5 --- /dev/null +++ b/cmd/nerdctl/compose_create.go @@ -0,0 +1,93 @@ +/* + 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 ( + "errors" + + "github.com/containerd/nerdctl/pkg/composer" + "github.com/spf13/cobra" +) + +func newComposeCreateCommand() *cobra.Command { + var composeCreateCommand = &cobra.Command{ + Use: "create [flags] [SERVICE...]", + Short: "Creates containers for one or more services", + RunE: composeCreateAction, + SilenceUsage: true, + SilenceErrors: true, + } + composeCreateCommand.Flags().Bool("build", false, "Build images before starting containers.") + composeCreateCommand.Flags().Bool("no-build", false, "Don't build an image even if it's missing, conflict with --build.") + composeCreateCommand.Flags().Bool("force-recreate", false, "Recreate containers even if their configuration and image haven't changed.") + composeCreateCommand.Flags().Bool("no-recreate", false, "Don't recreate containers if they exist, conflict with --force-recreate.") + composeCreateCommand.Flags().String("pull", "missing", "Pull images before running. (support always|missing|never)") + return composeCreateCommand +} + +func composeCreateAction(cmd *cobra.Command, args []string) error { + build, err := cmd.Flags().GetBool("build") + if err != nil { + return err + } + noBuild, err := cmd.Flags().GetBool("no-build") + if err != nil { + return err + } + if build && noBuild { + return errors.New("flag --build and --no-build cannot be specified together") + } + forceRecreate, err := cmd.Flags().GetBool("force-recreate") + if err != nil { + return err + } + noRecreate, err := cmd.Flags().GetBool("no-recreate") + if err != nil { + return nil + } + if forceRecreate && noRecreate { + return errors.New("flag --force-recreate and --no-recreate cannot be specified together") + } + + client, ctx, cancel, err := newClient(cmd) + if err != nil { + return err + } + defer cancel() + + c, err := getComposer(cmd, client) + if err != nil { + return err + } + + opt := composer.CreateOptions{ + Build: build, + NoBuild: noBuild, + ForceRecreate: forceRecreate, + NoRecreate: noRecreate, + } + + if cmd.Flags().Changed("pull") { + pull, err := cmd.Flags().GetString("pull") + if err != nil { + return err + } + opt.Pull = &pull + } + + return c.Create(ctx, opt, args) +} diff --git a/cmd/nerdctl/compose_create_linux_test.go b/cmd/nerdctl/compose_create_linux_test.go new file mode 100644 index 00000000000..7935d437c36 --- /dev/null +++ b/cmd/nerdctl/compose_create_linux_test.go @@ -0,0 +1,152 @@ +/* + 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" + "testing" + + "github.com/containerd/nerdctl/pkg/testutil" +) + +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' + +services: + svc0: + image: %s +`, testutil.AlpineImage) + + comp := testutil.NewComposeDir(t, dockerComposeYAML) + defer comp.CleanUp() + projectName := comp.ProjectName() + t.Logf("projectName=%q", projectName) + + defer base.ComposeCmd("-f", comp.YAMLFullPath(), "down", "-v").AssertOK() + + // 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") + // 1.2 created container can be started by `compose start` + base.ComposeCmd("-f", comp.YAMLFullPath(), "start").AssertOK() +} + +func TestComposeCreateDependency(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' + +services: + svc0: + image: %s + depends_on: + - "svc1" + svc1: + image: %s +`, testutil.CommonImage, testutil.CommonImage) + + comp := testutil.NewComposeDir(t, dockerComposeYAML) + defer comp.CleanUp() + projectName := comp.ProjectName() + t.Logf("projectName=%q", projectName) + + defer base.ComposeCmd("-f", comp.YAMLFullPath(), "down", "-v").AssertOK() + + // `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") +} + +func TestComposeCreatePull(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' + +services: + svc0: + image: %s +`, testutil.AlpineImage) + + comp := testutil.NewComposeDir(t, dockerComposeYAML) + defer comp.CleanUp() + projectName := comp.ProjectName() + t.Logf("projectName=%q", projectName) + + defer base.ComposeCmd("-f", comp.YAMLFullPath(), "down", "-v").AssertOK() + + // `compose create --pull never` should fail: no such image + base.Cmd("rmi", "-f", testutil.AlpineImage).Run() + base.ComposeCmd("-f", comp.YAMLFullPath(), "create", "--pull", "never").AssertFail() + // `compose create --pull missing(default)|always` should succeed: image is pulled and container is created + base.Cmd("rmi", "-f", testutil.AlpineImage).Run() + 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") +} + +func TestComposeCreateBuild(t *testing.T) { + // docker-compose v1 depecreated this command + // docker-compose v2 reimplemented this command + testutil.DockerIncompatible(t) + + const imageSvc0 = "composebuild_svc0" + + dockerComposeYAML := fmt.Sprintf(` +services: + svc0: + build: . + image: %s +`, imageSvc0) + + dockerfile := fmt.Sprintf(`FROM %s`, testutil.AlpineImage) + + testutil.RequiresBuild(t) + base := testutil.NewBase(t) + defer base.Cmd("builder", "prune").Run() + + comp := testutil.NewComposeDir(t, dockerComposeYAML) + defer comp.CleanUp() + comp.WriteFile("Dockerfile", dockerfile) + projectName := comp.ProjectName() + t.Logf("projectName=%q", projectName) + + defer base.Cmd("rmi", imageSvc0).Run() + defer base.ComposeCmd("-f", comp.YAMLFullPath(), "down", "-v").AssertOK() + + // `compose create --no-build` should fail if service image needs build + base.ComposeCmd("-f", comp.YAMLFullPath(), "create", "--no-build").AssertFail() + // `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") +} diff --git a/pkg/composer/create.go b/pkg/composer/create.go new file mode 100644 index 00000000000..a7aff1f5482 --- /dev/null +++ b/pkg/composer/create.go @@ -0,0 +1,217 @@ +/* + 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 composer + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/compose-spec/compose-go/types" + "github.com/containerd/nerdctl/pkg/composer/serviceparser" + "github.com/containerd/nerdctl/pkg/labels" + "github.com/sirupsen/logrus" + "golang.org/x/sync/errgroup" +) + +// FYI: https://github.com/docker/compose/blob/v2.14.1/pkg/api/api.go#L423 +const ( + // RecreateNever specifies never recreating existing service containers + RecreateNever = "never" + // RecreateForce specifies always force-recreating service containers + RecreateForce = "force" + // RecreateDiverged specifies only recreating service containers which diverges from compose model. + // (Unimplemented, currently equal to `RecreateNever`) In docker-compose, + // service config is hashed and stored in a label. + // FYI: https://github.com/docker/compose/blob/v2.14.1/pkg/compose/convergence.go#L244 + RecreateDiverged = "diverged" +) + +// CreateOptions stores all option input from `nerdctl compose create` +type CreateOptions struct { + Build bool + NoBuild bool + ForceRecreate bool + NoRecreate bool + Pull *string +} + +func (opts CreateOptions) recreateStrategy() string { + switch { + case opts.ForceRecreate: + return RecreateForce + case opts.NoRecreate: + return RecreateNever + default: + return RecreateDiverged + } +} + +// Create creates containers for given services. +func (c *Composer) Create(ctx context.Context, opt CreateOptions, services []string) error { + // preprocess services based on options (for all project services, in case + // there are dependencies not in `services`) + for i, service := range c.project.Services { + if opt.Pull != nil { + service.PullPolicy = *opt.Pull + } + if opt.Build && service.Build != nil { + service.PullPolicy = types.PullPolicyBuild + } + if opt.NoBuild { + service.Build = nil + if service.Image == "" { + service.Image = fmt.Sprintf("%s_%s", c.project.Name, service.Name) + } + } + c.project.Services[i] = service + } + + // prepare other components (networks, volumes, configs) + for shortName := range c.project.Networks { + if err := c.upNetwork(ctx, shortName); err != nil { + return err + } + } + + for shortName := range c.project.Volumes { + if err := c.upVolume(ctx, shortName); err != nil { + return err + } + } + + for shortName, secret := range c.project.Secrets { + obj := types.FileObjectConfig(secret) + if err := validateFileObjectConfig(obj, shortName, "service", c.project); err != nil { + return err + } + } + + for shortName, config := range c.project.Configs { + obj := types.FileObjectConfig(config) + if err := validateFileObjectConfig(obj, shortName, "config", c.project); err != nil { + return err + } + } + + // ensure images + // TODO: parallelize loop for ensuring images (make sure not to mess up tty) + parsedServices, err := c.Services(ctx, services...) + if err != nil { + return err + } + for _, ps := range parsedServices { + if err := c.ensureServiceImage(ctx, ps, !opt.NoBuild, opt.Build, BuildOptions{}, false); err != nil { + return err + } + } + + for _, ps := range parsedServices { + if err := c.createService(ctx, ps, opt); err != nil { + return err + } + } + + return nil +} + +func (c *Composer) createService(ctx context.Context, ps *serviceparser.Service, opt CreateOptions) error { + recreate := opt.recreateStrategy() + var runEG errgroup.Group + for _, container := range ps.Containers { + container := container + runEG.Go(func() error { + _, err := c.createServiceContainer(ctx, ps, container, recreate) + if err != nil { + return err + } + return nil + }) + } + if err := runEG.Wait(); err != nil { + return err + } + return nil +} + +// createServiceContainer must be called after ensureServiceImage +// createServiceContainer returns container ID +// TODO(djdongjin): refactor needed: +// 1. the logic is similar to `upServiceContainer`, need to decouple some of the logic. +// 2. ideally, `compose up` should equal to `compose create` + `compose start`, we should decouple and reuse the logic in `compose up`. +// 3. it'll be easier to refactor after related `compose` logic are moved to `pkg` from `cmd`. +func (c *Composer) createServiceContainer(ctx context.Context, service *serviceparser.Service, container serviceparser.Container, recreate string) (string, error) { + // check if container already exists + exists, err := c.containerExists(ctx, container.Name, service.Unparsed.Name) + if err != nil { + return "", fmt.Errorf("error while checking for containers with name %q: %s", container.Name, err) + } + + // delete container if it already exists and force-recreate is enabled + if exists { + if recreate != RecreateForce { + logrus.Infof("Container %s exists, skipping", container.Name) + return "", nil + } + + logrus.Debugf("Container %q already exists and force-created is enabled, deleting", container.Name) + delCmd := c.createNerdctlCmd(ctx, "rm", "-f", container.Name) + if err = delCmd.Run(); err != nil { + return "", fmt.Errorf("could not delete container %q: %s", container.Name, err) + } + logrus.Infof("Re-creating container %s", container.Name) + } else { + logrus.Infof("Creating container %s", container.Name) + } + + tempDir, err := os.MkdirTemp(os.TempDir(), "compose-") + if err != nil { + return "", fmt.Errorf("error while creating/re-creating container %s: %w", container.Name, err) + } + defer os.RemoveAll(tempDir) + cidFilename := filepath.Join(tempDir, "cid") + + //add metadata labels to container https://github.com/compose-spec/compose-spec/blob/master/spec.md#labels + container.RunArgs = append([]string{ + "--cidfile=" + cidFilename, + fmt.Sprintf("-l=%s=%s", labels.ComposeProject, c.project.Name), + fmt.Sprintf("-l=%s=%s", labels.ComposeService, service.Unparsed.Name), + }, container.RunArgs...) + + cmd := c.createNerdctlCmd(ctx, append([]string{"create"}, container.RunArgs...)...) + if c.DebugPrintFull { + logrus.Debugf("Running %v", cmd.Args) + } + + // FIXME + if service.Unparsed.StdinOpen != service.Unparsed.Tty { + return "", fmt.Errorf("currently StdinOpen(-i) and Tty(-t) should be same") + } + + err = cmd.Run() + if err != nil { + return "", fmt.Errorf("error while creating container %s: %w", container.Name, err) + } + + cid, err := os.ReadFile(cidFilename) + if err != nil { + return "", fmt.Errorf("error while creating container %s: %w", container.Name, err) + } + return strings.TrimSpace(string(cid)), nil +}