diff --git a/cmd/docker/cloud.go b/cmd/docker/cloud.go index 72a37bd44d5b..8e95096af563 100644 --- a/cmd/docker/cloud.go +++ b/cmd/docker/cloud.go @@ -43,8 +43,11 @@ import ( // The effective config directory is also passed as DOCKER_CONFIG. // The provider must provision into that context store without changing the saved // current context or recursively forwarding --cloud. -// It inherits the environment, receives no interactive stdin, and sends progress -// to stderr. +// It inherits the environment and sends progress and prompts to stderr. Stdin +// is forwarded only when both stdin and stderr are terminals with file handles; +// wrapped Windows consoles use the corresponding standard handles. Otherwise +// the provider receives EOF and must not prompt or open a terminal +// separately. Piped and redirected input belongs to the requested command. // Stdout must contain exactly one JSON object: // // {"DOCKER_CONTEXT":"provisioned-context"} @@ -139,7 +142,7 @@ func resolveCloudContext(ctx context.Context, dockerCli *command.DockerCli, root cmd := exec.CommandContext(ctx, plugin.Path, "--config="+config.Dir(), plugin.Name, "__resolve-context", "--", name) // #nosec G204 -- executable validated through CLI plugin discovery cmd.Env = append(os.Environ(), config.EnvOverrideConfigDir+"="+config.Dir(), metadata.ReexecEnvvar+"="+os.Args[0]) - cmd.Stderr = dockerCli.Err() + configureCloudResolverIO(cmd, dockerCli) out, err := cmd.Output() if err != nil { diff --git a/cmd/docker/cloud_io.go b/cmd/docker/cloud_io.go new file mode 100644 index 000000000000..222cf19091d2 --- /dev/null +++ b/cmd/docker/cloud_io.go @@ -0,0 +1,35 @@ +package main + +import ( + "os" + "os/exec" + "runtime" + + "github.com/docker/cli/cli/command" +) + +func configureCloudResolverIO(cmd *exec.Cmd, dockerCli *command.DockerCli) { + cmd.Stderr = dockerCli.Err() + if !dockerCli.In().IsTerminal() || !dockerCli.Err().IsTerminal() { + return + } + + stdin, stdinFile := dockerCli.In().File() + stderr, stderrFile := dockerCli.Err().File() + if runtime.GOOS == "windows" { + // term.StdStreams can wrap console handles for terminal emulation, + // preventing File from exposing them to the child process. + if !stdinFile { + stdin, stdinFile = os.Stdin, true + } + if !stderrFile { + stderr, stderrFile = os.Stderr, true + } + } + if stdinFile && stderrFile { + // Pass files directly: wrapping them makes os/exec copy through pipes, + // hiding terminal identity and potentially consuming the command's input. + cmd.Stdin = stdin + cmd.Stderr = stderr + } +} diff --git a/cmd/docker/cloud_io_test.go b/cmd/docker/cloud_io_test.go new file mode 100644 index 000000000000..5feb9425d945 --- /dev/null +++ b/cmd/docker/cloud_io_test.go @@ -0,0 +1,77 @@ +package main + +import ( + "bytes" + "io" + "os" + "os/exec" + "runtime" + "testing" + + "github.com/docker/cli/cli/command" + "gotest.tools/v3/assert" + is "gotest.tools/v3/assert/cmp" +) + +func TestConfigureCloudResolverIO(t *testing.T) { + stdin, err := os.CreateTemp(t.TempDir(), "stdin") + assert.NilError(t, err) + t.Cleanup(func() { _ = stdin.Close() }) + stderr, err := os.CreateTemp(t.TempDir(), "stderr") + assert.NilError(t, err) + t.Cleanup(func() { _ = stderr.Close() }) + + for _, tc := range []struct { + name string + wrapStdin bool + wrapStderr bool + stdinTerminal bool + stderrTerminal bool + }{ + {name: "terminal files", stdinTerminal: true, stderrTerminal: true}, + {name: "wrapped stdin", wrapStdin: true, stdinTerminal: true, stderrTerminal: true}, + {name: "wrapped stderr", wrapStderr: true, stdinTerminal: true, stderrTerminal: true}, + {name: "wrapped terminals", wrapStdin: true, wrapStderr: true, stdinTerminal: true, stderrTerminal: true}, + {name: "redirected stdin", stderrTerminal: true}, + {name: "redirected stderr", stdinTerminal: true}, + {name: "redirected stdin with wrapped stderr", wrapStderr: true, stderrTerminal: true}, + {name: "redirected stderr with wrapped stdin", wrapStdin: true, stdinTerminal: true}, + {name: "nonterminal wrappers", wrapStdin: true, wrapStderr: true}, + } { + t.Run(tc.name, func(t *testing.T) { + var in io.ReadCloser = stdin + var errOut io.Writer = stderr + if tc.wrapStdin { + in = io.NopCloser(stdin) + } + if tc.wrapStderr { + errOut = struct{ io.Writer }{stderr} + } + dockerCli, err := command.NewDockerCli(command.WithInputStream(in), command.WithErrorStream(errOut)) + assert.NilError(t, err) + // Simulate terminal detection without requiring a console in the test runner. + dockerCli.In().SetIsTerminal(tc.stdinTerminal) + dockerCli.Err().SetIsTerminal(tc.stderrTerminal) + + var stdout bytes.Buffer + cmd := &exec.Cmd{Stdout: &stdout} + configureCloudResolverIO(cmd, dockerCli) + assert.Equal(t, cmd.Stdout, io.Writer(&stdout)) + if !tc.stdinTerminal || !tc.stderrTerminal || (runtime.GOOS != "windows" && (tc.wrapStdin || tc.wrapStderr)) { + assert.Assert(t, is.Nil(cmd.Stdin)) + assert.Equal(t, cmd.Stderr, io.Writer(dockerCli.Err())) + return + } + + wantStdin, wantStderr := stdin, stderr + if tc.wrapStdin { + wantStdin = os.Stdin + } + if tc.wrapStderr { + wantStderr = os.Stderr + } + assert.Equal(t, cmd.Stdin, io.Reader(wantStdin)) + assert.Equal(t, cmd.Stderr, io.Writer(wantStderr)) + }) + } +} diff --git a/cmd/docker/cloud_test.go b/cmd/docker/cloud_test.go index 9f6b1ab463d5..4f25a97b8b3e 100644 --- a/cmd/docker/cloud_test.go +++ b/cmd/docker/cloud_test.go @@ -16,6 +16,7 @@ import ( "testing" "time" + "github.com/creack/pty" "github.com/docker/cli/cli-plugins/metadata" "github.com/docker/cli/cli/command" "github.com/docker/cli/cli/config" @@ -488,6 +489,125 @@ printf '%s\n' "$@" > "$CLOUD_TEST_ARGS" } } +func TestCloudResolverInput(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("fixture plugins use shell scripts and pseudo-terminals") + } + + executable, err := os.Executable() + assert.NilError(t, err) + + for _, tc := range []struct { + name string + stdinType string + terminalErr bool + prompt bool + }{ + {name: "interactive", stdinType: "terminal", terminalErr: true, prompt: true}, + {name: "piped stdin", stdinType: "pipe", terminalErr: true}, + {name: "redirected stdin", stdinType: "file", terminalErr: true}, + {name: "redirected stderr", stdinType: "terminal"}, + {name: "noninteractive", stdinType: "pipe"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("DOCKER_CLI_HOOKS", "false") + configDir := t.TempDir() + pluginDir := filepath.Join(configDir, "cli-plugins") + assert.NilError(t, os.MkdirAll(pluginDir, 0o755)) + assert.NilError(t, os.WriteFile(filepath.Join(pluginDir, "docker-offload"), []byte(`#!/bin/sh +if [ "$1" = docker-cli-plugin-metadata ]; then + echo '{"SchemaVersion":"0.1.0","Vendor":"test","Features":{"cloud-context-resolver":true}}' + exit 0 +fi +if [ -t 0 ]; then + [ -t 2 ] || exit 1 + printf 'Continue? ' >&2 + IFS= read -r reply || exit 1 + printf '%s\n' "$reply" > "$DOCKER_CONFIG/resolver-input" +elif IFS= read -r unexpected; then + echo 'resolver consumed command input' >&2 + exit 1 +fi +echo '{"DOCKER_CONTEXT":"resolved"}' +`), 0o755)) + assert.NilError(t, os.WriteFile(filepath.Join(pluginDir, "docker-cloudtest"), []byte(`#!/bin/sh +if [ "$1" = docker-cli-plugin-metadata ]; then + echo '{"SchemaVersion":"0.1.0","Vendor":"test"}' + exit 0 +fi +IFS= read -r input || exit 1 +printf '%s\n' "$input" +`), 0o755)) + + contextStore := store.New(filepath.Join(configDir, "contexts"), command.DefaultContextStoreConfig()) + assert.NilError(t, contextStore.CreateOrUpdate(store.Metadata{ + Name: "resolved", + Endpoints: map[string]any{contextdocker.DockerEndpoint: contextdocker.EndpointMeta{Host: "tcp://127.0.0.1:1"}}, + })) + + terminal, tty, err := pty.Open() + assert.NilError(t, err) + t.Cleanup(func() { + _ = tty.Close() + _ = terminal.Close() + }) + + const commandInput = "input for the original command\n" + var stdin *os.File + switch tc.stdinType { + case "terminal": + stdin = tty + input := commandInput + if tc.prompt { + input = "yes\n" + input + } + _, err = terminal.WriteString(input) + assert.NilError(t, err) + case "pipe": + var writer *os.File + stdin, writer, err = os.Pipe() + assert.NilError(t, err) + t.Cleanup(func() { _ = stdin.Close() }) + t.Cleanup(func() { _ = writer.Close() }) + _, err = writer.WriteString(commandInput) + assert.NilError(t, err) + assert.NilError(t, writer.Close()) + case "file": + inputPath := filepath.Join(configDir, "command-input") + assert.NilError(t, os.WriteFile(inputPath, []byte(commandInput), 0o600)) + stdin, err = os.Open(inputPath) + assert.NilError(t, err) + t.Cleanup(func() { _ = stdin.Close() }) + } + + payload, err := json.Marshal([]string{"docker", "--config=" + configDir, "--cloud", "cloudtest"}) + assert.NilError(t, err) + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + var stdout, stderr bytes.Buffer + cmd := exec.CommandContext(ctx, executable, "-test.run=^TestCloudCommandProcess$") + cmd.Env = append(os.Environ(), "CLOUD_TEST_COMMAND="+string(payload)) + cmd.Stdin = stdin + cmd.Stdout = &stdout + cmd.Stderr = &stderr + cmd.WaitDelay = time.Second + if tc.terminalErr { + cmd.Stderr = tty + } + assert.NilError(t, cmd.Run(), stderr.String()) + assert.Equal(t, stdout.String(), commandInput) + + reply, err := os.ReadFile(filepath.Join(configDir, "resolver-input")) + if tc.prompt { + assert.NilError(t, err) + assert.Equal(t, string(reply), "yes\n") + } else { + assert.Assert(t, os.IsNotExist(err)) + } + }) + } +} + // Run startup in a separate process because it installs process-wide signal // handlers that intentionally outlive an individual command. func TestCloudCommandProcess(t *testing.T) {