Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions cmd/docker/cloud.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"fmt"
"os"
"os/exec"
"runtime"
"strconv"
"strings"

Expand Down Expand Up @@ -43,8 +44,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"}
Expand Down Expand Up @@ -140,6 +144,26 @@ 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()
stdinTerminal := dockerCli.In().IsTerminal()
stderrTerminal := dockerCli.Err().IsTerminal()
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 stdinTerminal && !stdinFile {
stdin, stdinFile = os.Stdin, true
}
if stderrTerminal && !stderrFile {
stderr, stderrFile = os.Stderr, true
}
}
if stdinTerminal && stderrTerminal && 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
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ISTR there were some quirks on Windows where we need to pass os.StdIn directly because term.StdStreams() can wrap the Windows console streams. In that case IsTerminal() is true but File() does not return the underlying *os.File.

I'm not on Windows to verify, but quick draft from my LLM;

stdinTerminal := dockerCli.In().IsTerminal()
stderrTerminal := dockerCli.Err().IsTerminal()

stdin, stdinFile := dockerCli.In().File()
stderr, stderrFile := dockerCli.Err().File()

// term.StdStreams may wrap the Windows console streams, in which case
// File() cannot expose the underlying *os.File. Pass the standard handles
// directly when the streams are terminals.
if runtime.GOOS == "windows" {
	if stdinTerminal {
		stdin, stdinFile = os.Stdin, true
	}
	if stderrTerminal {
		stderr, stderrFile = os.Stderr, true
	}
}

if stdinTerminal && stderrTerminal && 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
}


out, err := cmd.Output()
if err != nil {
Expand Down
120 changes: 120 additions & 0 deletions cmd/docker/cloud_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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) {
Expand Down
Loading