diff --git a/api/compose/api.go b/api/compose/api.go index a144ea546..2c927d6ca 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/api/compose/api_test.go b/api/compose/api_test.go new file mode 100644 index 000000000..ddbe25d6a --- /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) +} diff --git a/cli/cmd/compose/run.go b/cli/cmd/compose/run.go index b94bbc948..ea27368ad 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/go.mod b/go.mod index 22188f73e..6926a55c0 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/run.go b/local/compose/run.go index bd9c7b9f4..9f093d4de 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) + } +}