diff --git a/aci/compose.go b/aci/compose.go index 92b5d76c2..b3fe0210b 100644 --- a/aci/compose.go +++ b/aci/compose.go @@ -201,3 +201,7 @@ func (cs *aciComposeService) Logs(ctx context.Context, projectName string, consu func (cs *aciComposeService) Convert(ctx context.Context, project *types.Project, options compose.ConvertOptions) ([]byte, error) { return nil, errdefs.ErrNotImplemented } + +func (cs *aciComposeService) RunOneOffContainer(ctx context.Context, project *types.Project, opts compose.RunOptions) error { + return errdefs.ErrNotImplemented +} diff --git a/api/client/compose.go b/api/client/compose.go index aac8d56eb..8f4c199a0 100644 --- a/api/client/compose.go +++ b/api/client/compose.go @@ -71,3 +71,7 @@ func (c *composeService) List(context.Context, string) ([]compose.Stack, error) func (c *composeService) Convert(context.Context, *types.Project, compose.ConvertOptions) ([]byte, error) { return nil, errdefs.ErrNotImplemented } + +func (c *composeService) RunOneOffContainer(ctx context.Context, project *types.Project, opts compose.RunOptions) error { + return errdefs.ErrNotImplemented +} diff --git a/api/compose/api.go b/api/compose/api.go index 5687cc019..c6faa5c3f 100644 --- a/api/compose/api.go +++ b/api/compose/api.go @@ -18,6 +18,7 @@ package compose import ( "context" + "io" "github.com/compose-spec/compose-go/types" ) @@ -46,6 +47,8 @@ type Service interface { List(ctx context.Context, projectName string) ([]Stack, error) // Convert translate compose model into backend's native format Convert(ctx context.Context, project *types.Project, options ConvertOptions) ([]byte, error) + // RunOneOffContainer creates a service oneoff container and starts its dependencies + RunOneOffContainer(ctx context.Context, project *types.Project, opts RunOptions) error } // UpOptions group options of the Up API @@ -66,6 +69,16 @@ type ConvertOptions struct { Format string } +// RunOptions options to execute compose run +type RunOptions struct { + Name string + Command []string + Detach bool + AutoRemove bool + Writer io.Writer + Reader io.Reader +} + // PortPublisher hold status about published port type PortPublisher struct { URL string diff --git a/cli/cmd/compose/compose.go b/cli/cmd/compose/compose.go index 2881060cf..32b314d42 100644 --- a/cli/cmd/compose/compose.go +++ b/cli/cmd/compose/compose.go @@ -90,6 +90,7 @@ func Command(contextType string) *cobra.Command { listCommand(), logsCommand(), convertCommand(), + runCommand(), ) if contextType == store.LocalContextType || contextType == store.DefaultContextType { @@ -99,7 +100,7 @@ func Command(contextType string) *cobra.Command { pullCommand(), ) } - + command.Flags().SetInterspersed(false) return command } diff --git a/cli/cmd/compose/run.go b/cli/cmd/compose/run.go new file mode 100644 index 000000000..8b6f41c8e --- /dev/null +++ b/cli/cmd/compose/run.go @@ -0,0 +1,114 @@ +/* + 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" + "os" + + "github.com/compose-spec/compose-go/types" + "github.com/spf13/cobra" + + "github.com/docker/compose-cli/api/client" + "github.com/docker/compose-cli/api/compose" + "github.com/docker/compose-cli/progress" +) + +type runOptions struct { + Name string + Command []string + WorkingDir string + ConfigPaths []string + Environment []string + Detach bool + Remove bool +} + +func runCommand() *cobra.Command { + opts := runOptions{} + runCmd := &cobra.Command{ + Use: "run [options] [-v VOLUME...] [-p PORT...] [-e KEY=VAL...] [-l KEY=VALUE...] SERVICE [COMMAND] [ARGS...]", + Short: "Run a one-off command on a service.", + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 1 { + opts.Command = args[1:] + } + opts.Name = args[0] + return runRun(cmd.Context(), opts) + }, + } + runCmd.Flags().StringVar(&opts.WorkingDir, "workdir", "", "Work dir") + runCmd.Flags().StringArrayVarP(&opts.ConfigPaths, "file", "f", []string{}, "Compose configuration files") + runCmd.Flags().BoolVarP(&opts.Detach, "detach", "d", false, "Run container in background and print container ID") + runCmd.Flags().StringArrayVarP(&opts.Environment, "env", "e", []string{}, "Set environment variables") + runCmd.Flags().BoolVar(&opts.Remove, "rm", false, "Automatically remove the container when it exits") + + runCmd.Flags().SetInterspersed(false) + return runCmd +} + +func runRun(ctx context.Context, opts runOptions) error { + projectOpts := composeOptions{ + ConfigPaths: opts.ConfigPaths, + WorkingDir: opts.WorkingDir, + Environment: opts.Environment, + } + c, project, err := setup(ctx, projectOpts, []string{opts.Name}) + if err != nil { + return err + } + + originalServices := project.Services + _, err = progress.Run(ctx, func(ctx context.Context) (string, error) { + return "", startDependencies(ctx, c, project, opts.Name) + }) + if err != nil { + return err + } + + project.Services = originalServices + // start container and attach to container streams + runOpts := compose.RunOptions{ + Name: opts.Name, + Command: opts.Command, + Detach: opts.Detach, + AutoRemove: opts.Remove, + Writer: os.Stdout, + Reader: os.Stdin, + } + return c.ComposeService().RunOneOffContainer(ctx, project, runOpts) +} + +func startDependencies(ctx context.Context, c *client.Client, project *types.Project, requestedService string) error { + originalServices := project.Services + dependencies := types.Services{} + for _, service := range originalServices { + if service.Name != requestedService { + dependencies = append(dependencies, service) + } + } + project.Services = dependencies + if err := c.ComposeService().Create(ctx, project); err != nil { + return err + } + if err := c.ComposeService().Start(ctx, project, nil); err != nil { + return err + } + return nil + +} diff --git a/ecs/local/compose.go b/ecs/local/compose.go index 1dbaa7fe4..959f1c587 100644 --- a/ecs/local/compose.go +++ b/ecs/local/compose.go @@ -27,6 +27,7 @@ import ( "github.com/compose-spec/compose-go/types" "github.com/docker/compose-cli/api/compose" "github.com/docker/compose-cli/errdefs" + "github.com/pkg/errors" "github.com/sanathkr/go-yaml" ) @@ -162,3 +163,6 @@ func (e ecsLocalSimulation) Ps(ctx context.Context, projectName string) ([]compo func (e ecsLocalSimulation) List(ctx context.Context, projectName string) ([]compose.Stack, error) { return e.compose.List(ctx, projectName) } +func (e ecsLocalSimulation) RunOneOffContainer(ctx context.Context, project *types.Project, opts compose.RunOptions) error { + return errors.Wrap(errdefs.ErrNotImplemented, "use docker-compose run") +} diff --git a/ecs/run.go b/ecs/run.go new file mode 100644 index 000000000..6e3f8bf9a --- /dev/null +++ b/ecs/run.go @@ -0,0 +1,29 @@ +/* + 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/compose-spec/compose-go/types" + "github.com/docker/compose-cli/api/compose" + "github.com/docker/compose-cli/errdefs" +) + +func (b *ecsAPIService) RunOneOffContainer(ctx context.Context, project *types.Project, opts compose.RunOptions) error { + return errdefs.ErrNotImplemented +} diff --git a/example/backend.go b/example/backend.go index 9e585bd39..e0c62a6b7 100644 --- a/example/backend.go +++ b/example/backend.go @@ -182,3 +182,6 @@ func (cs *composeService) Logs(ctx context.Context, projectName string, consumer func (cs *composeService) Convert(ctx context.Context, project *types.Project, options compose.ConvertOptions) ([]byte, error) { return nil, errdefs.ErrNotImplemented } +func (cs *composeService) RunOneOffContainer(ctx context.Context, project *types.Project, opts compose.RunOptions) error { + return errdefs.ErrNotImplemented +} diff --git a/local/compose/attach.go b/local/compose/attach.go index fac55eed2..3866cf9af 100644 --- a/local/compose/attach.go +++ b/local/compose/attach.go @@ -120,6 +120,7 @@ func (s *composeService) getContainerStreams(ctx context.Context, container moby Stdin: true, Stdout: true, Stderr: true, + Logs: true, }) if err != nil { return nil, nil, err diff --git a/local/compose/convergence.go b/local/compose/convergence.go index 53df364db..8e23d4e9e 100644 --- a/local/compose/convergence.go +++ b/local/compose/convergence.go @@ -62,7 +62,7 @@ func (s *composeService) ensureService(ctx context.Context, project *types.Proje number := next + i name := fmt.Sprintf("%s_%s_%d", project.Name, service.Name, number) eg.Go(func() error { - return s.createContainer(ctx, project, service, name, number) + return s.createContainer(ctx, project, service, name, number, false) }) } } @@ -163,10 +163,10 @@ func getScale(config types.ServiceConfig) int { return 1 } -func (s *composeService) createContainer(ctx context.Context, project *types.Project, service types.ServiceConfig, name string, number int) error { +func (s *composeService) createContainer(ctx context.Context, project *types.Project, service types.ServiceConfig, name string, number int, autoRemove bool) error { w := progress.ContextWriter(ctx) w.Event(progress.CreatingEvent(name)) - err := s.runContainer(ctx, project, service, name, number, nil) + err := s.createMobyContainer(ctx, project, service, name, number, nil, autoRemove) if err != nil { return err } @@ -191,7 +191,7 @@ func (s *composeService) recreateContainer(ctx context.Context, project *types.P if err != nil { return err } - err = s.runContainer(ctx, project, service, name, number, &container) + err = s.createMobyContainer(ctx, project, service, name, number, &container, false) if err != nil { return err } @@ -228,8 +228,8 @@ func (s *composeService) restartContainer(ctx context.Context, container moby.Co return nil } -func (s *composeService) runContainer(ctx context.Context, project *types.Project, service types.ServiceConfig, name string, number int, container *moby.Container) error { - containerConfig, hostConfig, networkingConfig, err := getContainerCreateOptions(project, service, number, container) +func (s *composeService) createMobyContainer(ctx context.Context, project *types.Project, service types.ServiceConfig, name string, number int, container *moby.Container, autoRemove bool) error { + containerConfig, hostConfig, networkingConfig, err := getCreateOptions(project, service, number, container, autoRemove) if err != nil { return err } diff --git a/local/compose/create.go b/local/compose/create.go index 2eafbf2b7..b97a8173f 100644 --- a/local/compose/create.go +++ b/local/compose/create.go @@ -44,6 +44,20 @@ func (s *composeService) Create(ctx context.Context, project *types.Project) err return err } + if err := s.ensureProjectNetworks(ctx, project); err != nil { + return err + } + + if err := s.ensureProjectVolumes(ctx, project); err != nil { + return err + } + + return InDependencyOrder(ctx, project, func(c context.Context, service types.ServiceConfig) error { + return s.ensureService(c, project, service) + }) +} + +func (s *composeService) ensureProjectNetworks(ctx context.Context, project *types.Project) error { for k, network := range project.Networks { if !network.External.External && network.Name != "" { network.Name = fmt.Sprintf("%s_%s", project.Name, k) @@ -57,7 +71,10 @@ func (s *composeService) Create(ctx context.Context, project *types.Project) err return err } } + return nil +} +func (s *composeService) ensureProjectVolumes(ctx context.Context, project *types.Project) error { for k, volume := range project.Volumes { if !volume.External.External && volume.Name != "" { volume.Name = fmt.Sprintf("%s_%s", project.Name, k) @@ -71,13 +88,10 @@ func (s *composeService) Create(ctx context.Context, project *types.Project) err return err } } - - return InDependencyOrder(ctx, project, func(c context.Context, service types.ServiceConfig) error { - return s.ensureService(c, project, service) - }) + return nil } -func getContainerCreateOptions(p *types.Project, s types.ServiceConfig, number int, inherit *moby.Container) (*container.Config, *container.HostConfig, *network.NetworkingConfig, error) { +func getCreateOptions(p *types.Project, s types.ServiceConfig, number int, inherit *moby.Container, autoRemove bool) (*container.Config, *container.HostConfig, *network.NetworkingConfig, error) { hash, err := jsonHash(s) if err != nil { return nil, nil, nil, err @@ -88,11 +102,12 @@ func getContainerCreateOptions(p *types.Project, s types.ServiceConfig, number i labels[k] = v } - // TODO: change oneoffLabel value for containers started with `docker compose run` labels[projectLabel] = p.Name labels[serviceLabel] = s.Name labels[versionLabel] = ComposeVersion - labels[oneoffLabel] = "False" + if _, ok := s.Labels[oneoffLabel]; !ok { + labels[oneoffLabel] = "False" + } labels[configHashLabel] = hash labels[workingDirLabel] = p.WorkingDir labels[configFilesLabel] = strings.Join(p.ComposeFiles, ",") @@ -152,6 +167,7 @@ func getContainerCreateOptions(p *types.Project, s types.ServiceConfig, number i networkMode := getNetworkMode(p, s) hostConfig := container.HostConfig{ + AutoRemove: autoRemove, Mounts: mountOptions, CapAdd: strslice.StrSlice(s.CapAdd), CapDrop: strslice.StrSlice(s.CapDrop), diff --git a/local/compose/down.go b/local/compose/down.go index 0227ddb2d..704f832c0 100644 --- a/local/compose/down.go +++ b/local/compose/down.go @@ -91,16 +91,17 @@ func (s *composeService) Down(ctx context.Context, projectName string, options c func (s *composeService) removeContainers(ctx context.Context, w progress.Writer, eg *errgroup.Group, containers []moby.Container) error { for _, container := range containers { + toDelete := container eg.Go(func() error { - eventName := "Container " + getContainerName(container) + eventName := "Container " + getContainerName(toDelete) w.Event(progress.StoppingEvent(eventName)) - err := s.apiClient.ContainerStop(ctx, container.ID, nil) + err := s.apiClient.ContainerStop(ctx, toDelete.ID, nil) if err != nil { w.Event(progress.ErrorMessageEvent(eventName, "Error while Stopping")) return err } w.Event(progress.RemovingEvent(eventName)) - err = s.apiClient.ContainerRemove(ctx, container.ID, moby.ContainerRemoveOptions{}) + err = s.apiClient.ContainerRemove(ctx, toDelete.ID, moby.ContainerRemoveOptions{}) if err != nil { w.Event(progress.ErrorMessageEvent(eventName, "Error while Removing")) return err diff --git a/local/compose/labels.go b/local/compose/labels.go index ce36bf49a..0928f26f0 100644 --- a/local/compose/labels.go +++ b/local/compose/labels.go @@ -25,6 +25,7 @@ import ( const ( containerNumberLabel = "com.docker.compose.container-number" oneoffLabel = "com.docker.compose.oneoff" + slugLabel = "com.docker.compose.slug" projectLabel = "com.docker.compose.project" volumeLabel = "com.docker.compose.volume" workingDirLabel = "com.docker.compose.project.working_dir" diff --git a/local/compose/run.go b/local/compose/run.go new file mode 100644 index 000000000..0f5e2564b --- /dev/null +++ b/local/compose/run.go @@ -0,0 +1,93 @@ +/* + 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" + + "github.com/compose-spec/compose-go/types" + "github.com/docker/compose-cli/api/compose" + apitypes "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/filters" + "golang.org/x/sync/errgroup" + + moby "github.com/docker/docker/pkg/stringid" +) + +func (s *composeService) RunOneOffContainer(ctx context.Context, project *types.Project, opts compose.RunOptions) error { + originalServices := project.Services + var requestedService types.ServiceConfig + for _, service := range originalServices { + if service.Name == opts.Name { + requestedService = service + } + } + + project.Services = originalServices + if len(opts.Command) > 0 { + requestedService.Command = opts.Command + } + requestedService.Scale = 1 + requestedService.Tty = true + requestedService.StdinOpen = true + + slug := moby.GenerateRandomID() + requestedService.ContainerName = fmt.Sprintf("%s_%s_run_%s", project.Name, requestedService.Name, moby.TruncateID(slug)) + requestedService.Labels = requestedService.Labels.Add(slugLabel, slug) + requestedService.Labels = requestedService.Labels.Add(oneoffLabel, "True") + + if err := s.ensureImagesExists(ctx, project); err != nil { // all dependencies already checked, but might miss requestedService img + return err + } + if err := s.waitDependencies(ctx, project, requestedService); err != nil { + return err + } + if err := s.createContainer(ctx, project, requestedService, requestedService.ContainerName, 1, opts.AutoRemove); err != nil { + return err + } + containerID := requestedService.ContainerName + + if opts.Detach { + err := s.apiClient.ContainerStart(ctx, containerID, apitypes.ContainerStartOptions{}) + if err != nil { + return err + } + fmt.Fprintln(opts.Writer, containerID) + return nil + } + + containers, err := s.apiClient.ContainerList(ctx, apitypes.ContainerListOptions{ + Filters: filters.NewArgs( + filters.Arg("label", fmt.Sprintf("%s=%s", slugLabel, slug)), + ), + All: true, + }) + if err != nil { + return err + } + oneoffContainer := containers[0] + eg := errgroup.Group{} + eg.Go(func() error { + return s.attachContainerStreams(ctx, oneoffContainer, true, opts.Reader, opts.Writer) + }) + + if err = s.apiClient.ContainerStart(ctx, containerID, apitypes.ContainerStartOptions{}); err != nil { + return err + } + return eg.Wait() +} diff --git a/tests/compose-e2e/compose_test.go b/tests/compose-e2e/compose_test.go index ddf99b51e..3c8117c52 100644 --- a/tests/compose-e2e/compose_test.go +++ b/tests/compose-e2e/compose_test.go @@ -103,6 +103,61 @@ func TestLocalComposeUp(t *testing.T) { }) } +func TestLocalComposeRun(t *testing.T) { + c := NewParallelE2eCLI(t, binDir) + + t.Run("compose run", func(t *testing.T) { + res := c.RunDockerCmd("compose", "run", "-f", "./fixtures/run-test/docker-compose.yml", "back") + lines := Lines(res.Stdout()) + assert.Equal(t, lines[len(lines)-1], "Hello there!!", res.Stdout()) + }) + + t.Run("check run container exited", func(t *testing.T) { + res := c.RunDockerCmd("ps", "--all") + lines := Lines(res.Stdout()) + var runContainerID string + var truncatedSlug string + for _, line := range lines { + fields := strings.Fields(line) + containerID := fields[len(fields)-1] + assert.Assert(t, !strings.HasPrefix(containerID, "run-test_front")) + if strings.HasPrefix(containerID, "run-test_back") { + //only the one-off container for back service + assert.Assert(t, strings.HasPrefix(containerID, "run-test_back_run_"), containerID) + truncatedSlug = strings.Replace(containerID, "run-test_back_run_", "", 1) + runContainerID = containerID + assert.Assert(t, strings.Contains(line, "Exited"), line) + } + if strings.HasPrefix(containerID, "run-test_db_1") { + assert.Assert(t, strings.Contains(line, "Up"), line) + } + } + assert.Assert(t, runContainerID != "") + res = c.RunDockerCmd("inspect", runContainerID) + res.Assert(t, icmd.Expected{Out: `"com.docker.compose.container-number": "1"`}) + res.Assert(t, icmd.Expected{Out: `"com.docker.compose.project": "run-test"`}) + res.Assert(t, icmd.Expected{Out: `"com.docker.compose.oneoff": "True",`}) + res.Assert(t, icmd.Expected{Out: `"com.docker.compose.slug": "` + truncatedSlug}) + }) + + t.Run("compose run --rm", func(t *testing.T) { + res := c.RunDockerCmd("compose", "run", "-f", "./fixtures/run-test/docker-compose.yml", "--rm", "back", "/bin/sh", "-c", "echo Hello again") + lines := Lines(res.Stdout()) + assert.Equal(t, lines[len(lines)-1], "Hello again", res.Stdout()) + }) + + t.Run("check run container removed", func(t *testing.T) { + res := c.RunDockerCmd("ps", "--all") + assert.Assert(t, strings.Contains(res.Stdout(), "run-test_back"), res.Stdout()) + }) + + t.Run("down", func(t *testing.T) { + c.RunDockerCmd("compose", "down", "-f", "./fixtures/run-test/docker-compose.yml") + res := c.RunDockerCmd("ps", "--all") + assert.Assert(t, !strings.Contains(res.Stdout(), "run-test"), res.Stdout()) + }) +} + func TestLocalComposeBuild(t *testing.T) { c := NewParallelE2eCLI(t, binDir) diff --git a/tests/compose-e2e/fixtures/run-test/docker-compose.yml b/tests/compose-e2e/fixtures/run-test/docker-compose.yml new file mode 100644 index 000000000..7b6e3ac6b --- /dev/null +++ b/tests/compose-e2e/fixtures/run-test/docker-compose.yml @@ -0,0 +1,24 @@ +version: '3.8' +services: + back: + image: alpine + command: echo "Hello there!!" + depends_on: + - db + networks: + - backnet + db: + image: nginx + networks: + - backnet + volumes: + - data:/test + front: + image: nginx + networks: + - frontnet +networks: + frontnet: + backnet: +volumes: + data: