Repository: docker/compose-cli
Pull request #1341 (2021-02-23 09:41:01)

introduce a few more `compose run` options
**What I did**
extended compose run with a few more options

*  --name NAME Assign a name to the container
* --entrypoint CMD Override the entrypoint of the image.
* -e KEY=VAL Set an environment variable (can be used multiple times)
* -l, --label KEY=VAL Add or override a label (can be used multiple times)
* -u, --user="" Run as specified username or uid
* -T Disable pseudo-tty allocation. By default docker-compose run allocates a TTY.
* -w, --workdir="" Working directory inside the container




Patch:
diff --git a/api/compose/api.go b/api/compose/api.go
index a144ea54..2c927d6c 100644
--- a/api/compose/api.go
+++ b/api/compose/api.go
@@ -19,6 +19,7 @@ package compose
 import (
 	"context"
 	"io"
+	"strings"
 	"time"
 
 	"github.com/compose-spec/compose-go/types"
@@ -130,20 +131,38 @@ type RemoveOptions struct {
 
 // RunOptions options to execute compose run
 type RunOptions struct {
-	Service    string
-	Command    []string
-	Detach     bool
-	AutoRemove bool
-	Writer     io.Writer
-	Reader     io.Reader
-
-	// used by exec
+	Name        string
+	Service     string
+	Command     []string
+	Entrypoint  []string
+	Detach      bool
+	AutoRemove  bool
+	Writer      io.Writer
+	Reader      io.Reader
 	Tty         bool
 	WorkingDir  string
 	User        string
 	Environment []string
+	Labels      types.Labels
 	Privileged  bool
-	Index       int
+	// used by exec
+	Index int
+}
+
+// EnvironmentMap return RunOptions.Environment as a MappingWithEquals
+func (opts *RunOptions) EnvironmentMap() types.MappingWithEquals {
+	environment := types.MappingWithEquals{}
+	for _, s := range opts.Environment {
+		parts := strings.SplitN(s, "=", 2)
+		key := parts[0]
+		switch {
+		case len(parts) == 1:
+			environment[key] = nil
+		default:
+			environment[key] = &parts[1]
+		}
+	}
+	return environment
 }
 
 // PsOptions group options of the Ps API
diff --git a/cli/cmd/compose/create.go b/cli/cmd/compose/create.go
index 87ef5089..4380c6bf 100644
--- a/cli/cmd/compose/create.go
+++ b/cli/cmd/compose/create.go
@@ -17,8 +17,6 @@
 package compose
 
 import (
-	"fmt"
-
 	"github.com/spf13/cobra"
 )
 
@@ -36,17 +34,10 @@ func createCommand(p *projectOptions) *cobra.Command {
 		Use:   "create [SERVICE...]",
 		Short: "Creates containers for a service.",
 		RunE: func(cmd *cobra.Command, args []string) error {
-			if opts.Build && opts.noBuild {
-				return fmt.Errorf("--build and --no-build are incompatible")
-			}
-			if opts.forceRecreate && opts.noRecreate {
-				return fmt.Errorf("--force-recreate and --no-recreate are incompatible")
-			}
 			return runCreateStart(cmd.Context(), upOptions{
 				composeOptions: &composeOptions{
 					projectOptions: p,
 					Build:          opts.Build,
-					noBuild:        opts.noBuild,
 				},
 				noStart:       true,
 				forceRecreate: opts.forceRecreate,
@@ -56,7 +47,6 @@ func createCommand(p *projectOptions) *cobra.Command {
 	}
 	flags := cmd.Flags()
 	flags.BoolVar(&opts.Build, "build", false, "Build images before starting containers.")
-	flags.BoolVar(&opts.noBuild, "no-build", false, "Don't build an image, even if it's missing.")
 	flags.BoolVar(&opts.forceRecreate, "force-recreate", false, "Recreate containers even if their configuration and image haven't changed.")
 	flags.BoolVar(&opts.noRecreate, "no-recreate", false, "If containers already exist, don't recreate them. Incompatible with --force-recreate.")
 	return cmd
diff --git a/cli/cmd/compose/run.go b/cli/cmd/compose/run.go
index b94bbc94..ea27368a 100644
--- a/cli/cmd/compose/run.go
+++ b/cli/cmd/compose/run.go
@@ -18,9 +18,12 @@ package compose
 
 import (
 	"context"
+	"fmt"
 	"os"
+	"strings"
 
 	"github.com/compose-spec/compose-go/types"
+	"github.com/mattn/go-shellwords"
 	"github.com/spf13/cobra"
 
 	"github.com/docker/compose-cli/api/client"
@@ -33,9 +36,15 @@ type runOptions struct {
 	*composeOptions
 	Service     string
 	Command     []string
-	Environment []string
+	environment []string
 	Detach      bool
 	Remove      bool
+	noTty       bool
+	user        string
+	workdir     string
+	entrypoint  string
+	labels      []string
+	name        string
 }
 
 func runCommand(p *projectOptions) *cobra.Command {
@@ -44,7 +53,7 @@ func runCommand(p *projectOptions) *cobra.Command {
 			projectOptions: p,
 		},
 	}
-	runCmd := &cobra.Command{
+	cmd := &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),
@@ -56,12 +65,19 @@ func runCommand(p *projectOptions) *cobra.Command {
 			return runRun(cmd.Context(), opts)
 		},
 	}
-	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")
+	flags := cmd.Flags()
+	flags.BoolVarP(&opts.Detach, "detach", "d", false, "Run container in background and print container ID")
+	flags.StringArrayVarP(&opts.environment, "env", "e", []string{}, "Set environment variables")
+	flags.StringArrayVarP(&opts.labels, "labels", "l", []string{}, "Add or override a label")
+	flags.BoolVar(&opts.Remove, "rm", false, "Automatically remove the container when it exits")
+	flags.BoolVarP(&opts.noTty, "no-TTY", "T", false, "Disable pseudo-tty allocation. By default docker compose run allocates a TTY")
+	flags.StringVar(&opts.name, "name", "", " Assign a name to the container")
+	flags.StringVarP(&opts.user, "user", "u", "", "Run as specified username or uid")
+	flags.StringVarP(&opts.workdir, "workdir", "w", "", "Working directory inside the container")
+	flags.StringVar(&opts.entrypoint, "entrypoint", "", "Override the entrypoint of the image")
 
-	runCmd.Flags().SetInterspersed(false)
-	return runCmd
+	flags.SetInterspersed(false)
+	return cmd
 }
 
 func runRun(ctx context.Context, opts runOptions) error {
@@ -77,14 +93,39 @@ func runRun(ctx context.Context, opts runOptions) error {
 		return err
 	}
 
+	var entrypoint []string
+	if opts.entrypoint != "" {
+		entrypoint, err = shellwords.Parse(opts.entrypoint)
+		if err != nil {
+			return err
+		}
+	}
+
+	labels := types.Labels{}
+	for _, s := range opts.labels {
+		parts := strings.SplitN(s, "=", 2)
+		if len(parts) != 2 {
+			return fmt.Errorf("label must be set as KEY=VALUE")
+		}
+		labels[parts[0]] = parts[1]
+	}
+
 	// start container and attach to container streams
 	runOpts := compose.RunOptions{
-		Service:    opts.Service,
-		Command:    opts.Command,
-		Detach:     opts.Detach,
-		AutoRemove: opts.Remove,
-		Writer:     os.Stdout,
-		Reader:     os.Stdin,
+		Name:        opts.name,
+		Service:     opts.Service,
+		Command:     opts.Command,
+		Detach:      opts.Detach,
+		AutoRemove:  opts.Remove,
+		Writer:      os.Stdout,
+		Reader:      os.Stdin,
+		Tty:         !opts.noTty,
+		WorkingDir:  opts.workdir,
+		User:        opts.user,
+		Environment: opts.environment,
+		Entrypoint:  entrypoint,
+		Labels:      labels,
+		Index:       0,
 	}
 	exitCode, err := c.ComposeService().RunOneOffContainer(ctx, project, runOpts)
 	if exitCode != 0 {
diff --git a/cli/cmd/compose/up.go b/cli/cmd/compose/up.go
index 2cff80c4..a52ffb09 100644
--- a/cli/cmd/compose/up.go
+++ b/cli/cmd/compose/up.go
@@ -42,8 +42,7 @@ import (
 // composeOptions hold options common to `up` and `run` to run compose project
 type composeOptions struct {
 	*projectOptions
-	Build   bool
-	noBuild bool
+	Build bool
 	// ACI only
 	DomainName string
 }
@@ -88,9 +87,6 @@ func upCommand(p *projectOptions, contextType string) *cobra.Command {
 				if opts.exitCodeFrom != "" {
 					opts.cascadeStop = true
 				}
-				if opts.Build && opts.noBuild {
-					return fmt.Errorf("--build and --no-build are incompatible")
-				}
 				if opts.cascadeStop && opts.Detach {
 					return fmt.Errorf("--abort-on-container-exit and --detach are incompatible")
 				}
@@ -107,7 +103,6 @@ func upCommand(p *projectOptions, contextType string) *cobra.Command {
 	flags.StringArrayVarP(&opts.Environment, "environment", "e", []string{}, "Environment variables")
 	flags.BoolVarP(&opts.Detach, "detach", "d", false, "Detached mode: Run containers in the background")
 	flags.BoolVar(&opts.Build, "build", false, "Build images before starting containers.")
-	flags.BoolVar(&opts.noBuild, "no-build", false, "Don't build an image, even if it's missing.")
 	flags.BoolVar(&opts.removeOrphans, "remove-orphans", false, "Remove containers for services not defined in the Compose file.")
 	flags.StringArrayVar(&opts.scale, "scale", []string{}, "Scale SERVICE to NUM instances. Overrides the `scale` setting in the Compose file if present.")
 	flags.BoolVar(&opts.noColor, "no-color", false, "Produce monochrome output.")
@@ -289,18 +284,10 @@ func setup(ctx context.Context, opts composeOptions, services []string) (*client
 		project.Services[0].DomainName = opts.DomainName
 	}
 	if opts.Build {
-		for i, service := range project.Services {
+		for _, service := range project.Services {
 			service.PullPolicy = types.PullPolicyBuild
-			project.Services[i] = service
-		}
-	}
-	if opts.noBuild {
-		for i, service := range project.Services {
-			service.Build = nil
-			project.Services[i] = service
 		}
 	}
-
 	if opts.EnvFile != "" {
 		var services types.Services
 		for _, s := range project.Services {
diff --git a/go.mod b/go.mod
index 22188f73..6926a55c 100644
--- a/go.mod
+++ b/go.mod
@@ -39,6 +39,7 @@ require (
 	github.com/joho/godotenv v1.3.0
 	github.com/labstack/echo v3.3.10+incompatible
 	github.com/labstack/gommon v0.3.0 // indirect
+	github.com/mattn/go-shellwords v1.0.11
 	github.com/moby/buildkit v0.8.1-0.20201205083753-0af7b1b9c693
 	github.com/moby/term v0.0.0-20201110203204-bea5bbe245bf
 	github.com/morikuni/aec v1.0.0
diff --git a/local/compose/build.go b/local/compose/build.go
index 78f5426a..9c88430f 100644
--- a/local/compose/build.go
+++ b/local/compose/build.go
@@ -56,7 +56,7 @@ func (s *composeService) ensureImagesExists(ctx context.Context, project *types.
 		if err != nil {
 			return err
 		}
-
+		// TODO build vs pull should be controlled by pull policy, see https://github.com/compose-spec/compose-spec/issues/26
 		if service.Image != "" {
 			if localImagePresent {
 				continue
diff --git a/local/compose/run.go b/local/compose/run.go
index bd9c7b9f..9f093d4d 100644
--- a/local/compose/run.go
+++ b/local/compose/run.go
@@ -30,37 +30,32 @@ import (
 )
 
 func (s *composeService) RunOneOffContainer(ctx context.Context, project *types.Project, opts compose.RunOptions) (int, error) {
-	originalServices := project.Services
-	var requestedService types.ServiceConfig
-	for _, service := range originalServices {
-		if service.Name == opts.Service {
-			requestedService = service
-		}
+	service, err := project.GetService(opts.Service)
+	if err != nil {
+		return 0, err
 	}
 
-	project.Services = originalServices
-	if len(opts.Command) > 0 {
-		requestedService.Command = opts.Command
-	}
-	requestedService.Scale = 1
-	requestedService.Tty = true
-	requestedService.StdinOpen = true
+	applyRunOptions(&service, opts)
 
 	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 service.ContainerName == "" {
+		service.ContainerName = fmt.Sprintf("%s_%s_run_%s", project.Name, service.Name, moby.TruncateID(slug))
+	}
+	service.Scale = 1
+	service.StdinOpen = true
+	service.Labels = service.Labels.Add(slugLabel, slug)
+	service.Labels = service.Labels.Add(oneoffLabel, "True")
 
-	if err := s.ensureImagesExists(ctx, project); err != nil { // all dependencies already checked, but might miss requestedService img
+	if err := s.ensureImagesExists(ctx, project); err != nil { // all dependencies already checked, but might miss service img
 		return 0, err
 	}
-	if err := s.waitDependencies(ctx, project, requestedService); err != nil {
+	if err := s.waitDependencies(ctx, project, service); err != nil {
 		return 0, err
 	}
-	if err := s.createContainer(ctx, project, requestedService, requestedService.ContainerName, 1, opts.AutoRemove); err != nil {
+	if err := s.createContainer(ctx, project, service, service.ContainerName, 1, opts.AutoRemove); err != nil {
 		return 0, err
 	}
-	containerID := requestedService.ContainerName
+	containerID := service.ContainerName
 
 	if opts.Detach {
 		err := s.apiClient.ContainerStart(ctx, containerID, apitypes.ContainerStartOptions{})
@@ -81,7 +76,7 @@ func (s *composeService) RunOneOffContainer(ctx context.Context, project *types.
 		return 0, err
 	}
 	oneoffContainer := containers[0]
-	err = s.attachContainerStreams(ctx, oneoffContainer, true, opts.Reader, opts.Writer)
+	err = s.attachContainerStreams(ctx, oneoffContainer, service.Tty, opts.Reader, opts.Writer)
 	if err != nil {
 		return 0, err
 	}
@@ -100,3 +95,27 @@ func (s *composeService) RunOneOffContainer(ctx context.Context, project *types.
 	}
 
 }
+
+func applyRunOptions(service *types.ServiceConfig, opts compose.RunOptions) {
+	service.Tty = opts.Tty
+	service.ContainerName = opts.Name
+
+	if len(opts.Command) > 0 {
+		service.Command = opts.Command
+	}
+	if len(opts.User) > 0 {
+		service.User = opts.User
+	}
+	if len(opts.WorkingDir) > 0 {
+		service.WorkingDir = opts.WorkingDir
+	}
+	if len(opts.Entrypoint) > 0 {
+		service.Entrypoint = opts.Entrypoint
+	}
+	if len(opts.Environment) > 0 {
+		service.Environment.OverrideBy(opts.EnvironmentMap())
+	}
+	for k, v := range opts.Labels {
+		service.Labels.Add(k, v)
+	}
+}


Test patch:
diff --git a/api/compose/api_test.go b/api/compose/api_test.go
new file mode 100644
index 00000000..ddbe25d6
--- /dev/null
+++ b/api/compose/api_test.go
@@ -0,0 +1,37 @@
+/*
+   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 (
+	"testing"
+
+	"gotest.tools/v3/assert"
+)
+
+func TestRunOptionsEnvironmentMap(t *testing.T) {
+	opts := RunOptions{
+		Environment: []string{
+			"FOO=BAR",
+			"ZOT=",
+			"QIX",
+		},
+	}
+	env := opts.EnvironmentMap()
+	assert.Equal(t, *env["FOO"], "BAR")
+	assert.Equal(t, *env["ZOT"], "")
+	assert.Check(t, env["QIX"] == nil)
+}