From 1d4b4e3c8ebd60c2d3f71cb8f26801fee43b8b3f Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Wed, 9 Mar 2022 17:52:30 +0100 Subject: [PATCH] use docker/cli RunExec and RunStart to handle all the interactive/tty/* terminal logic Signed-off-by: Nicolas De Loof --- cmd/compose/run.go | 4 ++ cmd/main.go | 2 +- pkg/api/api.go | 1 + pkg/compose/exec.go | 128 ++++++---------------------------- pkg/compose/resize.go | 74 -------------------- pkg/compose/run.go | 133 ++++-------------------------------- pkg/e2e/compose_run_test.go | 16 ++--- pkg/e2e/toto.sh | 1 + 8 files changed, 49 insertions(+), 310 deletions(-) delete mode 100644 pkg/compose/resize.go create mode 100755 pkg/e2e/toto.sh diff --git a/cmd/compose/run.go b/cmd/compose/run.go index 365fd3544..f618e04eb 100644 --- a/cmd/compose/run.go +++ b/cmd/compose/run.go @@ -62,6 +62,9 @@ func (opts runOptions) apply(project *types.Project) error { if err != nil { return err } + + target.Tty = !opts.noTty + target.StdinOpen = opts.interactive if !opts.servicePorts { target.Ports = []types.ServicePortConfig{} } @@ -207,6 +210,7 @@ func runRun(ctx context.Context, backend api.Service, project *types.Project, op Detach: opts.Detach, AutoRemove: opts.Remove, Tty: !opts.noTty, + Interactive: opts.interactive, WorkingDir: opts.workdir, User: opts.user, Environment: opts.environment, diff --git a/cmd/main.go b/cmd/main.go index 55cd71f07..9df52d0b6 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -68,7 +68,7 @@ func pluginMain() { } func main() { - if commands.RunningAsStandalone() { + if plugin.RunningStandalone() { os.Args = append([]string{"docker"}, compatibility.Convert(os.Args[1:])...) } pluginMain() diff --git a/pkg/api/api.go b/pkg/api/api.go index 9aac0ec7a..fdc42fee6 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -216,6 +216,7 @@ type RunOptions struct { Detach bool AutoRemove bool Tty bool + Interactive bool WorkingDir string User string Environment []string diff --git a/pkg/compose/exec.go b/pkg/compose/exec.go index 03a90a6ad..cbc6c0a11 100644 --- a/pkg/compose/exec.go +++ b/pkg/compose/exec.go @@ -19,123 +19,41 @@ package compose import ( "context" "fmt" - "io" + "github.com/docker/cli/cli" + "github.com/docker/cli/cli/command/container" + "github.com/docker/compose/v2/pkg/api" moby "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/filters" - "github.com/docker/docker/pkg/stdcopy" - "github.com/moby/term" - - "github.com/docker/compose/v2/pkg/api" ) func (s *composeService) Exec(ctx context.Context, project string, opts api.RunOptions) (int, error) { - container, err := s.getExecTarget(ctx, project, opts) + target, err := s.getExecTarget(ctx, project, opts) if err != nil { return 0, err } - exec, err := s.apiClient().ContainerExecCreate(ctx, container.ID, moby.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 0, err - } - - if opts.Detach { - return 0, s.apiClient().ContainerExecStart(ctx, exec.ID, moby.ExecStartCheck{ - Detach: true, - Tty: opts.Tty, - }) - } - - resp, err := s.apiClient().ContainerExecAttach(ctx, exec.ID, moby.ExecStartCheck{ - Tty: opts.Tty, - }) - if err != nil { - return 0, err - } - defer resp.Close() //nolint:errcheck - - if opts.Tty { - s.monitorTTySize(ctx, exec.ID, s.apiClient().ContainerExecResize) + exec := container.NewExecOptions() + exec.Interactive = opts.Interactive + exec.TTY = opts.Tty + exec.Detach = opts.Detach + exec.User = opts.User + exec.Privileged = opts.Privileged + exec.Workdir = opts.WorkingDir + exec.Container = target.ID + exec.Command = opts.Command + for _, v := range opts.Environment { + err := exec.Env.Set(v) if err != nil { return 0, err } } - err = s.interactiveExec(ctx, opts, resp) - if err != nil { - return 0, err - } - - return s.getExecExitStatus(ctx, exec.ID) -} - -// inspired by https://github.com/docker/cli/blob/master/cli/command/container/exec.go#L116 -func (s *composeService) interactiveExec(ctx context.Context, opts api.RunOptions, resp moby.HijackedResponse) error { - outputDone := make(chan error) - inputDone := make(chan error) - - stdout := ContainerStdout{HijackedResponse: resp} - stdin := ContainerStdin{HijackedResponse: resp} - r, err := s.getEscapeKeyProxy(s.stdin(), opts.Tty) - if err != nil { - return err - } - - in := s.stdin() - if in.IsTerminal() && opts.Tty { - state, err := term.SetRawTerminal(in.FD()) - if err != nil { - return err - } - defer term.RestoreTerminal(in.FD(), state) //nolint:errcheck - } - - go func() { - if opts.Tty { - _, err := io.Copy(s.stdout(), stdout) - outputDone <- err - } else { - _, err := stdcopy.StdCopy(s.stdout(), s.stderr(), stdout) - outputDone <- err - } - stdout.Close() //nolint:errcheck - }() - - go func() { - _, err := io.Copy(stdin, r) - inputDone <- err - stdin.Close() //nolint:errcheck - }() - - for { - select { - case err := <-outputDone: - return err - case err := <-inputDone: - if _, ok := err.(term.EscapeError); ok { - return nil - } - if err != nil { - return err - } - // Wait for output to complete streaming - case <-ctx.Done(): - return ctx.Err() - } + err = container.RunExec(s.dockerCli, exec) + if sterr, ok := err.(cli.StatusError); ok { + return sterr.StatusCode, nil } + return 0, err } func (s *composeService) getExecTarget(ctx context.Context, projectName string, opts api.RunOptions) (moby.Container, error) { @@ -155,11 +73,3 @@ func (s *composeService) getExecTarget(ctx context.Context, projectName string, container := containers[0] return container, nil } - -func (s *composeService) getExecExitStatus(ctx context.Context, execID string) (int, error) { - resp, err := s.apiClient().ContainerExecInspect(ctx, execID) - if err != nil { - return 0, err - } - return resp.ExitCode, nil -} diff --git a/pkg/compose/resize.go b/pkg/compose/resize.go deleted file mode 100644 index 0c9723810..000000000 --- a/pkg/compose/resize.go +++ /dev/null @@ -1,74 +0,0 @@ -/* - 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" - gosignal "os/signal" - "runtime" - "time" - - "github.com/buger/goterm" - moby "github.com/docker/docker/api/types" - "github.com/docker/docker/pkg/signal" -) - -func (s *composeService) monitorTTySize(ctx context.Context, container string, resize func(context.Context, string, moby.ResizeOptions) error) { - err := resize(ctx, container, moby.ResizeOptions{ // nolint:errcheck - Height: uint(goterm.Height()), - Width: uint(goterm.Width()), - }) - if err != nil { - return - } - - sigchan := make(chan os.Signal, 1) - gosignal.Notify(sigchan, signal.SIGWINCH) - - if runtime.GOOS == "windows" { - // Windows has no SIGWINCH support, so we have to poll tty size ¯\_(ツ)_/¯ - go func() { - prevH := goterm.Height() - prevW := goterm.Width() - for { - time.Sleep(time.Millisecond * 250) - h := goterm.Height() - w := goterm.Width() - if prevW != w || prevH != h { - sigchan <- signal.SIGWINCH - } - prevH = h - prevW = w - } - }() - } - - go func() { - for { - select { - case <-sigchan: - resize(ctx, container, moby.ResizeOptions{ // nolint:errcheck - Height: uint(goterm.Height()), - Width: uint(goterm.Width()), - }) - case <-ctx.Done(): - return - } - } - }() -} diff --git a/pkg/compose/run.go b/pkg/compose/run.go index 2ff1e5d6f..b1802e455 100644 --- a/pkg/compose/run.go +++ b/pkg/compose/run.go @@ -19,16 +19,11 @@ package compose import ( "context" "fmt" - "io" - "github.com/compose-spec/compose-go/types" + "github.com/docker/cli/cli" + cmd "github.com/docker/cli/cli/command/container" "github.com/docker/compose/v2/pkg/api" - moby "github.com/docker/docker/api/types" - "github.com/docker/docker/api/types/container" - "github.com/docker/docker/pkg/ioutils" - "github.com/docker/docker/pkg/stdcopy" "github.com/docker/docker/pkg/stringid" - "github.com/moby/term" ) func (s *composeService) RunOneOffContainer(ctx context.Context, project *types.Project, opts api.RunOptions) (int, error) { @@ -37,98 +32,16 @@ func (s *composeService) RunOneOffContainer(ctx context.Context, project *types. return 0, err } - if opts.Detach { - err := s.apiClient().ContainerStart(ctx, containerID, moby.ContainerStartOptions{}) - if err != nil { - return 0, err - } - fmt.Fprintln(s.stdout(), containerID) - return 0, nil + start := cmd.NewStartOptions() + start.OpenStdin = !opts.Detach && opts.Interactive + start.Attach = !opts.Detach + start.Containers = []string{containerID} + + err = cmd.RunStart(s.dockerCli, &start) + if sterr, ok := err.(cli.StatusError); ok { + return sterr.StatusCode, nil } - - return s.runInteractive(ctx, containerID, opts) -} - -func (s *composeService) runInteractive(ctx context.Context, containerID string, opts api.RunOptions) (int, error) { - in := s.stdin() - r, err := s.getEscapeKeyProxy(in, opts.Tty) - if err != nil { - return 0, err - } - - stdin, stdout, err := s.getContainerStreams(ctx, containerID) - if err != nil { - return 0, err - } - - if in.IsTerminal() && opts.Tty { - state, err := term.SetRawTerminal(in.FD()) - if err != nil { - return 0, err - } - defer term.RestoreTerminal(in.FD(), state) //nolint:errcheck - } - - outputDone := make(chan error) - inputDone := make(chan error) - - go func() { - if opts.Tty { - _, err := io.Copy(s.stdout(), stdout) //nolint:errcheck - outputDone <- err - } else { - _, err := stdcopy.StdCopy(s.stdout(), s.stderr(), stdout) //nolint:errcheck - outputDone <- err - } - stdout.Close() //nolint:errcheck - }() - - go func() { - _, err := io.Copy(stdin, r) - inputDone <- err - stdin.Close() //nolint:errcheck - }() - - err = s.apiClient().ContainerStart(ctx, containerID, moby.ContainerStartOptions{}) - if err != nil { - return 0, err - } - - s.monitorTTySize(ctx, containerID, s.apiClient().ContainerResize) - - for { - select { - case err := <-outputDone: - if err != nil { - return 0, err - } - return s.terminateRun(ctx, containerID, opts) - case err := <-inputDone: - if _, ok := err.(term.EscapeError); ok { - return 0, nil - } - if err != nil { - return 0, err - } - // Wait for output to complete streaming - case <-ctx.Done(): - return 0, ctx.Err() - } - } -} - -func (s *composeService) terminateRun(ctx context.Context, containerID string, opts api.RunOptions) (exitCode int, err error) { - exitCh, errCh := s.apiClient().ContainerWait(ctx, containerID, container.WaitConditionNotRunning) - select { - case exit := <-exitCh: - exitCode = int(exit.StatusCode) - case err = <-errCh: - return - } - if opts.AutoRemove { - err = s.apiClient().ContainerRemove(ctx, containerID, moby.ContainerRemoveOptions{}) - } - return + return 0, err } func (s *composeService) prepareRun(ctx context.Context, project *types.Project, opts api.RunOptions) (string, error) { @@ -147,7 +60,6 @@ func (s *composeService) prepareRun(ctx context.Context, project *types.Project, service.ContainerName = fmt.Sprintf("%s_%s_run_%s", project.Name, service.Name, stringid.TruncateID(slug)) } service.Scale = 1 - service.StdinOpen = true service.Restart = "" if service.Deploy != nil { service.Deploy.RestartPolicy = nil @@ -171,32 +83,17 @@ func (s *composeService) prepareRun(ctx context.Context, project *types.Project, } updateServices(&service, observedState) - created, err := s.createContainer(ctx, project, service, service.ContainerName, 1, opts.Detach && opts.AutoRemove, opts.UseNetworkAliases, true) + created, err := s.createContainer(ctx, project, service, service.ContainerName, 1, + opts.Detach && opts.AutoRemove, opts.UseNetworkAliases, opts.Interactive) if err != nil { return "", err } - containerID := created.ID - return containerID, nil -} - -func (s *composeService) getEscapeKeyProxy(r io.ReadCloser, isTty bool) (io.ReadCloser, error) { - if !isTty { - return r, nil - } - var escapeKeys = []byte{16, 17} - if s.configFile().DetachKeys != "" { - customEscapeKeys, err := term.ToBytes(s.configFile().DetachKeys) - if err != nil { - return nil, err - } - escapeKeys = customEscapeKeys - } - return ioutils.NewReadCloserWrapper(term.NewEscapeProxy(r, escapeKeys), r.Close), nil + return created.ID, nil } func applyRunOptions(project *types.Project, service *types.ServiceConfig, opts api.RunOptions) { service.Tty = opts.Tty - service.StdinOpen = true + service.StdinOpen = opts.Interactive service.ContainerName = opts.Name if len(opts.Command) > 0 { diff --git a/pkg/e2e/compose_run_test.go b/pkg/e2e/compose_run_test.go index fdd5803d7..4bdb72c81 100644 --- a/pkg/e2e/compose_run_test.go +++ b/pkg/e2e/compose_run_test.go @@ -29,11 +29,11 @@ func TestLocalComposeRun(t *testing.T) { c := NewParallelE2eCLI(t, binDir) t.Run("compose run", func(t *testing.T) { - res := c.RunDockerComposeCmd("-f", "./fixtures/run-test/compose.yaml", "run", "back") + res := c.RunDockerComposeCmd("-f", "./fixtures/run-test/compose.yaml", "run", "-T", "back") lines := Lines(res.Stdout()) assert.Equal(t, lines[len(lines)-1], "Hello there!!", res.Stdout()) assert.Assert(t, !strings.Contains(res.Combined(), "orphan")) - res = c.RunDockerComposeCmd("-f", "./fixtures/run-test/compose.yaml", "run", "back", "echo", "Hello one more time") + res = c.RunDockerComposeCmd("-f", "./fixtures/run-test/compose.yaml", "run", "-T", "back", "echo", "Hello one more time") lines = Lines(res.Stdout()) assert.Equal(t, lines[len(lines)-1], "Hello one more time", res.Stdout()) assert.Assert(t, !strings.Contains(res.Combined(), "orphan")) @@ -68,7 +68,7 @@ func TestLocalComposeRun(t *testing.T) { }) t.Run("compose run --rm", func(t *testing.T) { - res := c.RunDockerComposeCmd("-f", "./fixtures/run-test/compose.yaml", "run", "--rm", "back", "echo", "Hello again") + res := c.RunDockerComposeCmd("-f", "./fixtures/run-test/compose.yaml", "run", "-T", "--rm", "back", "echo", "Hello again") lines := Lines(res.Stdout()) assert.Equal(t, lines[len(lines)-1], "Hello again", res.Stdout()) @@ -85,7 +85,7 @@ func TestLocalComposeRun(t *testing.T) { t.Run("compose run --volumes", func(t *testing.T) { wd, err := os.Getwd() assert.NilError(t, err) - res := c.RunDockerComposeCmd("-f", "./fixtures/run-test/compose.yaml", "run", "--volumes", wd+":/foo", "back", "/bin/sh", "-c", "ls /foo") + res := c.RunDockerComposeCmd("-f", "./fixtures/run-test/compose.yaml", "run", "-T", "--volumes", wd+":/foo", "back", "/bin/sh", "-c", "ls /foo") res.Assert(t, icmd.Expected{Out: "compose_run_test.go"}) res = c.RunDockerCmd("ps", "--all") @@ -93,18 +93,18 @@ func TestLocalComposeRun(t *testing.T) { }) t.Run("compose run --publish", func(t *testing.T) { - c.RunDockerComposeCmd("-f", "./fixtures/run-test/compose.yaml", "run", "--publish", "8081:80", "-d", "back", "/bin/sh", "-c", "sleep 1") + c.RunDockerComposeCmd("-f", "./fixtures/run-test/compose.yaml", "run", "-T", "--publish", "8081:80", "-d", "back", "/bin/sh", "-c", "sleep 1") res := c.RunDockerCmd("ps") assert.Assert(t, strings.Contains(res.Stdout(), "8081->80/tcp"), res.Stdout()) }) t.Run("compose run orphan", func(t *testing.T) { // Use different compose files to get an orphan container - c.RunDockerComposeCmd("-f", "./fixtures/run-test/orphan.yaml", "run", "simple") - res := c.RunDockerComposeCmd("-f", "./fixtures/run-test/compose.yaml", "run", "back", "echo", "Hello") + c.RunDockerComposeCmd("-f", "./fixtures/run-test/orphan.yaml", "run", "-T", "simple") + res := c.RunDockerComposeCmd("-f", "./fixtures/run-test/compose.yaml", "run", "-T", "back", "echo", "Hello") assert.Assert(t, strings.Contains(res.Combined(), "orphan")) - cmd := c.NewDockerCmd("compose", "-f", "./fixtures/run-test/compose.yaml", "run", "back", "echo", "Hello") + cmd := c.NewDockerCmd("compose", "-f", "./fixtures/run-test/compose.yaml", "run", "-T", "back", "echo", "Hello") res = icmd.RunCmd(cmd, func(cmd *icmd.Cmd) { cmd.Env = append(cmd.Env, "COMPOSE_IGNORE_ORPHANS=True") }) diff --git a/pkg/e2e/toto.sh b/pkg/e2e/toto.sh new file mode 100755 index 000000000..26117c2d8 --- /dev/null +++ b/pkg/e2e/toto.sh @@ -0,0 +1 @@ +../../bin/docker-compose -f ./fixtures/run-test/compose.yaml run --volumes $(pwd):/foo back ls /foo