mirror of
https://github.com/docker/cli.git
synced 2026-11-06 13:10:41 -04:00
cmd/docker: Allow interactive cloud context resolution
Forward stdin and stderr to cloud context providers when both are terminals, allowing prompts during --cloud resolution. Pass file handles directly to preserve terminal detection and leave piped or redirected input available to the requested command. Use the standard console handles when Windows terminal wrappers hide their underlying files. Keep stdout reserved for the JSON response. Signed-off-by: Nicolas Beck <nicolas.beck@docker.com>
This commit is contained in:
+26
-2
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -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"}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user