diff --git a/aci/compose.go b/aci/compose.go index c7f82aba8..36b88ba1f 100644 --- a/aci/compose.go +++ b/aci/compose.go @@ -214,3 +214,7 @@ func (cs *aciComposeService) RunOneOffContainer(ctx context.Context, project *ty func (cs *aciComposeService) Remove(ctx context.Context, project *types.Project, options compose.RemoveOptions) ([]string, error) { return nil, errdefs.ErrNotImplemented } + +func (cs *aciComposeService) Exec(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 063da2081..7ba66c47d 100644 --- a/api/client/compose.go +++ b/api/client/compose.go @@ -87,3 +87,7 @@ func (c *composeService) RunOneOffContainer(ctx context.Context, project *types. func (c *composeService) Remove(ctx context.Context, project *types.Project, options compose.RemoveOptions) ([]string, error) { return nil, errdefs.ErrNotImplemented } + +func (c *composeService) Exec(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 dc231cb8e..b266bdd10 100644 --- a/api/compose/api.go +++ b/api/compose/api.go @@ -55,6 +55,8 @@ type Service interface { RunOneOffContainer(ctx context.Context, project *types.Project, opts RunOptions) (int, error) // Remove executes the equivalent to a `compose rm` Remove(ctx context.Context, project *types.Project, options RemoveOptions) ([]string, error) + // Exec executes a command in a running service container + Exec(ctx context.Context, project *types.Project, opts RunOptions) error } // CreateOptions group options of the Create API @@ -117,6 +119,14 @@ type RunOptions struct { AutoRemove bool Writer io.Writer Reader io.Reader + + // used by exec + Tty bool + WorkingDir string + User string + Environment []string + Privileged bool + Index int } // PsOptions group options of the Ps API diff --git a/cli/cmd/compose/compose.go b/cli/cmd/compose/compose.go index 4a5c612c6..826dcdfba 100644 --- a/cli/cmd/compose/compose.go +++ b/cli/cmd/compose/compose.go @@ -117,6 +117,7 @@ func Command(contextType string) *cobra.Command { killCommand(&opts), runCommand(&opts), removeCommand(&opts), + execCommand(&opts), ) if contextType == store.LocalContextType || contextType == store.DefaultContextType { diff --git a/cli/cmd/compose/exec.go b/cli/cmd/compose/exec.go new file mode 100644 index 000000000..b9b92ae06 --- /dev/null +++ b/cli/cmd/compose/exec.go @@ -0,0 +1,118 @@ +/* + 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" + "os" + + "github.com/containerd/console" + "github.com/spf13/cobra" + + "github.com/docker/compose-cli/api/client" + "github.com/docker/compose-cli/api/compose" +) + +type execOpts struct { + *composeOptions + + service string + command []string + environment []string + workingDir string + + tty bool + user string + detach bool + index int + privileged bool +} + +func execCommand(p *projectOptions) *cobra.Command { + opts := execOpts{ + composeOptions: &composeOptions{ + projectOptions: p, + }, + } + runCmd := &cobra.Command{ + Use: "exec [options] [-e KEY=VAL...] [--] SERVICE COMMAND [ARGS...]", + Short: "Execute a command in a running container.", + Args: cobra.MinimumNArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 1 { + opts.command = args[1:] + } + opts.service = args[0] + return runExec(cmd.Context(), opts) + }, + } + + runCmd.Flags().BoolVarP(&opts.detach, "detach", "d", false, "Detached mode: Run command in the background.") + runCmd.Flags().StringArrayVarP(&opts.environment, "env", "e", []string{}, "Set environment variables") + runCmd.Flags().IntVar(&opts.index, "index", 1, "index of the container if there are multiple instances of a service [default: 1].") + runCmd.Flags().BoolVarP(&opts.privileged, "privileged", "", false, "Give extended privileges to the process.") + runCmd.Flags().StringVarP(&opts.user, "user", "u", "", "Run the command as this user.") + runCmd.Flags().BoolVarP(&opts.tty, "", "T", false, "Disable pseudo-tty allocation. By default `docker compose exec` allocates a TTY.") + runCmd.Flags().StringVarP(&opts.workingDir, "workdir", "w", "", "Path to workdir directory for this command.") + + runCmd.Flags().SetInterspersed(false) + return runCmd +} + +func runExec(ctx context.Context, opts execOpts) error { + c, err := client.NewWithDefaultLocalBackend(ctx) + if err != nil { + return err + } + + project, err := opts.toProject(nil) + if err != nil { + return err + } + + execOpts := compose.RunOptions{ + Service: opts.service, + Command: opts.command, + Environment: opts.environment, + Tty: !opts.tty, + User: opts.user, + Privileged: opts.privileged, + Index: opts.index, + Detach: opts.detach, + WorkingDir: opts.workingDir, + + Writer: os.Stdout, + Reader: os.Stdin, + } + + if execOpts.Tty { + con := console.Current() + if err := con.SetRaw(); err != nil { + return err + } + defer func() { + if err := con.Reset(); err != nil { + fmt.Println("Unable to close the console") + } + }() + + execOpts.Writer = con + execOpts.Reader = con + } + return c.ComposeService().Exec(ctx, project, execOpts) +} diff --git a/cli/main.go b/cli/main.go index 601657de0..9606c2ee0 100644 --- a/cli/main.go +++ b/cli/main.go @@ -158,6 +158,8 @@ func main() { opts.AddConfigFlags(flags) flags.BoolVarP(&opts.Version, "version", "v", false, "Print version information and quit") + flags.SetInterspersed(false) + walk(root, func(c *cobra.Command) { c.Flags().BoolP("help", "h", false, "Help for "+c.Name()) }) diff --git a/ecs/exec.go b/ecs/exec.go new file mode 100644 index 000000000..44f9c2e14 --- /dev/null +++ b/ecs/exec.go @@ -0,0 +1,30 @@ +/* + 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/api/errdefs" +) + +func (b *ecsAPIService) Exec(ctx context.Context, project *types.Project, opts compose.RunOptions) error { + return errdefs.ErrNotImplemented +} diff --git a/ecs/local/compose.go b/ecs/local/compose.go index 1cd0c9b60..037da0c43 100644 --- a/ecs/local/compose.go +++ b/ecs/local/compose.go @@ -179,3 +179,7 @@ func (e ecsLocalSimulation) RunOneOffContainer(ctx context.Context, project *typ func (e ecsLocalSimulation) Remove(ctx context.Context, project *types.Project, options compose.RemoveOptions) ([]string, error) { return e.compose.Remove(ctx, project, options) } + +func (e ecsLocalSimulation) Exec(ctx context.Context, project *types.Project, opts compose.RunOptions) error { + return errdefs.ErrNotImplemented +} diff --git a/kube/compose.go b/kube/compose.go index 77f1eceea..2ea639f5e 100644 --- a/kube/compose.go +++ b/kube/compose.go @@ -201,3 +201,8 @@ func (s *composeService) RunOneOffContainer(ctx context.Context, project *types. func (s *composeService) Remove(ctx context.Context, project *types.Project, options compose.RemoveOptions) ([]string, error) { return nil, errdefs.ErrNotImplemented } + +// Exec executes a command in a running service container +func (s *composeService) Exec(ctx context.Context, project *types.Project, opts compose.RunOptions) error { + return errdefs.ErrNotImplemented +} diff --git a/local/compose/exec.go b/local/compose/exec.go new file mode 100644 index 000000000..e5ddbec3d --- /dev/null +++ b/local/compose/exec.go @@ -0,0 +1,106 @@ +/* + 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" + "io" + + "github.com/compose-spec/compose-go/types" + apitypes "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/filters" + + "github.com/docker/compose-cli/api/compose" +) + +func (s *composeService) Exec(ctx context.Context, project *types.Project, opts compose.RunOptions) error { + service, err := project.GetService(opts.Service) + if err != nil { + return err + } + + containers, err := s.apiClient.ContainerList(ctx, apitypes.ContainerListOptions{ + Filters: filters.NewArgs( + projectFilter(project.Name), + serviceFilter(service.Name), + filters.Arg("label", fmt.Sprintf("%s=%d", containerNumberLabel, opts.Index)), + ), + }) + if err != nil { + return err + } + if len(containers) < 1 { + return fmt.Errorf("container %s not running", getContainerName(project.Name, service, opts.Index)) + } + container := containers[0] + + exec, err := s.apiClient.ContainerExecCreate(ctx, container.ID, apitypes.ExecConfig{ + Cmd: opts.Command, + Env: opts.Environment, + User: opts.User, + Privileged: opts.Privileged, + Tty: opts.Tty, + Detach: opts.Detach, + WorkingDir: opts.WorkingDir, + + AttachStdin: true, + AttachStdout: true, + AttachStderr: true, + }) + if err != nil { + return err + } + + if opts.Detach { + return s.apiClient.ContainerExecStart(ctx, exec.ID, apitypes.ExecStartCheck{ + Detach: true, + Tty: opts.Tty, + }) + } + + resp, err := s.apiClient.ContainerExecAttach(ctx, exec.ID, apitypes.ExecStartCheck{ + Detach: false, + Tty: opts.Tty, + }) + if err != nil { + return err + } + defer resp.Close() + + readChannel := make(chan error, 10) + writeChannel := make(chan error, 10) + + go func() { + _, err := io.Copy(opts.Writer, resp.Reader) + readChannel <- err + }() + + go func() { + _, err := io.Copy(resp.Conn, opts.Reader) + writeChannel <- err + }() + + for { + select { + case err := <-readChannel: + return err + case err := <-writeChannel: + return err + } + } +}