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
9 changes: 6 additions & 3 deletions cmd/docker/cloud.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down Expand Up @@ -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 {
Expand Down
35 changes: 35 additions & 0 deletions cmd/docker/cloud_io.go
Original file line number Diff line number Diff line change
@@ -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
}
}
77 changes: 77 additions & 0 deletions cmd/docker/cloud_io_test.go
Original file line number Diff line number Diff line change
@@ -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))
})
}
}
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