Browse Source

Merge pull request #1003 from aiordache/compose_run_cmd

Add local `compose run` command
Guillaume Tardif 4 years ago
parent
commit
b2b9ce0a53

+ 4 - 0
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
+}

+ 4 - 0
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
+}

+ 13 - 0
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

+ 2 - 1
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
 }
 

+ 114 - 0
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
+
+}

+ 4 - 0
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")
+}

+ 29 - 0
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
+}

+ 3 - 0
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
+}

+ 1 - 0
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

+ 6 - 6
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
 	}

+ 23 - 7
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),

+ 4 - 3
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

+ 1 - 0
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"

+ 93 - 0
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()
+}

+ 55 - 0
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)
 

+ 24 - 0
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: