Merge pull request #7343 from vvoland/work-cloud

cmd/docker: Add --cloud with configurable context resolution
This commit is contained in:
Sebastiaan van Stijn
2026-10-01 20:56:40 +02:00
committed by GitHub
7 changed files with 905 additions and 1 deletions
+6
View File
@@ -1,3 +1,6 @@
// FIXME(thaJeztah): remove once we are a module; the go:build directive prevents go from downgrading language version to go1.16:
//go:build go1.26
package metadata
const (
@@ -35,4 +38,7 @@ type Metadata struct {
URL string `json:",omitempty"`
// Hidden hides the plugin in completion and help message output.
Hidden bool `json:",omitempty"`
// Features declares optional contracts supported by the plugin, keyed by
// feature name.
Features map[string]any `json:",omitempty"`
}
+13
View File
@@ -68,6 +68,7 @@ type DockerCli struct {
serverInfo ServerInfo
contextStore store.Store
currentContext string
contextResolver func(*DockerCli) (string, error)
init sync.Once
initErr error
dockerEndpoint docker.Endpoint
@@ -208,6 +209,8 @@ func (cli *DockerCli) HooksEnabled() bool {
// Initialize the dockerCli runs initialization that must happen after command
// line flags are parsed.
//
//nolint:gocyclo
func (cli *DockerCli) Initialize(opts *cliflags.ClientOptions, ops ...CLIOption) error {
for _, o := range ops {
if err := o(cli); err != nil {
@@ -257,6 +260,16 @@ func (cli *DockerCli) Initialize(opts *cliflags.ClientOptions, ops ...CLIOption)
},
}
if cli.contextResolver != nil {
contextName, err := cli.contextResolver(cli)
if err != nil {
return err
}
if contextName != "" {
cli.currentContext = contextName
}
}
// TODO(krissetto): pass ctx to the funcs instead of using this
if cli.enableGlobalMeter {
cli.createGlobalMeterProvider(cli.baseCtx)
+13
View File
@@ -84,6 +84,19 @@ func WithDefaultContextStoreConfig() CLIOption {
}
}
// WithContextResolver overrides context selection during [DockerCli.Initialize].
// The resolver runs after configuration and the context store are loaded, but
// before telemetry and endpoint initialization.
// An empty result preserves normal context selection; an error aborts
// initialization.
// The resolver must not initialize the API client.
func WithContextResolver(resolve func(*DockerCli) (string, error)) CLIOption {
return func(cli *DockerCli) error {
cli.contextResolver = resolve
return nil
}
}
// WithAPIClient configures the cli to use the given API client.
func WithAPIClient(c client.APIClient) CLIOption {
return func(cli *DockerCli) error {
+58
View File
@@ -325,6 +325,64 @@ func TestInitializeShouldAlwaysCreateTheContextStore(t *testing.T) {
assert.Check(t, cli.ContextStore() != nil)
}
func TestInitializeContextResolver(t *testing.T) {
resolverErr := errors.New("resolver failed")
for _, tc := range []struct {
name string
context string
err error
}{
{name: "resolved context", context: "resolved"},
{name: "normal selection"},
{name: "resolver failure", err: resolverErr},
} {
t.Run(tc.name, func(t *testing.T) {
originalConfigDir := config.Dir()
t.Cleanup(func() { config.SetDir(originalConfigDir) })
config.SetDir(t.TempDir())
configDir := t.TempDir()
cfg := configfile.New(filepath.Join(configDir, config.ConfigFileName))
cfg.Features = map[string]string{"cloud": "foobar"}
assert.NilError(t, cfg.Save())
cli, err := NewDockerCli()
assert.NilError(t, err)
var loadedConfig *configfile.ConfigFile
calls := 0
err = cli.Initialize(&flags.ClientOptions{ConfigDir: configDir, Context: "original"},
WithContextResolver(func(cli *DockerCli) (string, error) {
calls++
assert.Equal(t, config.Dir(), configDir)
loadedConfig = cli.ConfigFile()
assert.Equal(t, loadedConfig.Features["cloud"], "foobar")
assert.Assert(t, cli.ContextStore() != nil)
assert.Assert(t, cli.client == nil)
return tc.context, tc.err
}),
)
assert.Equal(t, calls, 1)
assert.Assert(t, cli.ConfigFile() == loadedConfig)
assert.Assert(t, cli.client == nil)
if tc.err != nil {
assert.ErrorIs(t, err, tc.err)
return
}
assert.NilError(t, err)
wantContext := tc.context
if wantContext == "" {
wantContext = "original"
}
assert.Equal(t, cli.CurrentContext(), wantContext)
})
}
}
func TestHooksEnabled(t *testing.T) {
t.Run("disabled by default", func(t *testing.T) {
// Make sure we don't depend on any existing ~/.docker/config.json
+256
View File
@@ -0,0 +1,256 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"strconv"
"strings"
"github.com/docker/cli/cli"
pluginmanager "github.com/docker/cli/cli-plugins/manager"
"github.com/docker/cli/cli-plugins/metadata"
"github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/config"
contextdocker "github.com/docker/cli/cli/context/docker"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
// processCloud resolves the global --cloud[=NAME] option after configuration is
// loaded, but before telemetry and endpoint initialization.
// The option appears in help and completion only when the selected provider is
// installed, valid, and declares support for the resolver contract.
// The provider defaults to the offload plugin; features.cloud in the effective
// Docker config.json can override the plugin name, not its executable path.
// Explicit use also requires the provider's capability declaration.
// Discovery for visibility does not provision a context or connect to a daemon.
//
// # Provider contract
//
// The provider must include "Features": {"cloud-context-resolver": true} in its
// docker-cli-plugin-metadata response to declare support for this contract.
// Older plugins without this declaration leave the flag hidden and cannot be
// used for resolution.
// The CLI uses normal plugin discovery and invokes:
//
// docker-<provider> --config=<dir> <provider> __resolve-context -- <name>
//
// Bare --cloud and --cloud= pass default as the target name.
// 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.
// Stdout must contain exactly one JSON object:
//
// {"DOCKER_CONTEXT":"provisioned-context"}
//
// The returned context must exist, must not be the virtual default context, and
// must have a Docker endpoint with a nonempty host.
// Resolver failures abort command execution without falling back to another
// context.
// Cancellation terminates the resolver process; provisioning subprocesses and
// resource cleanup remain the provider's responsibility.
// Downstream plugins receive --context=<resolved-name> instead of the global
// --cloud option.
// The provider implementation ships separately and must support this operation
// before enabling the integration.
func processCloud(ctx context.Context, dockerCli *command.DockerCli, cmd *cobra.Command, args, osArgs []string) ([]string, error) {
if !cmd.Flags().Changed("cloud") {
return osArgs, nil
}
if cmd.Flags().Changed("context") || cmd.Flags().Changed("host") {
return osArgs, errors.New("conflicting options: cannot specify --cloud together with --context or --host")
}
help, err := cloudHelpRequest(cmd, args)
if err != nil {
return osArgs, err
}
var contextName string
if !help {
name, _ := cmd.Flags().GetString("cloud")
if name == "" {
name = "default"
}
contextName, err = resolveCloudContext(ctx, dockerCli, cmd, name)
if err != nil {
return osArgs, fmt.Errorf("--cloud: %w", err)
}
if err := cmd.Flags().Set("context", contextName); err != nil {
return osArgs, err
}
}
return cloudPluginArgs(cmd, osArgs, contextName)
}
// cloudResolverFeature is the plugin metadata feature that declares support for
// the __resolve-context contract.
const cloudResolverFeature = "cloud-context-resolver"
func cloudProvider(dockerCli config.Provider, rootCmd *cobra.Command) (*pluginmanager.Plugin, error) {
provider := dockerCli.ConfigFile().Features["cloud"]
if provider == "" {
provider = "offload"
}
plugin, err := pluginmanager.GetPlugin(provider, dockerCli, rootCmd)
if err != nil {
return nil, fmt.Errorf("cloud resolver plugin %q unavailable: %w", provider, err)
}
if plugin.Err != nil {
return nil, fmt.Errorf("invalid cloud resolver plugin %q: %w", provider, plugin.Err)
}
if supported, _ := plugin.Features[cloudResolverFeature].(bool); !supported {
return nil, fmt.Errorf("plugin %q does not support cloud context resolution", provider)
}
return plugin, nil
}
// updateCloudFlagVisibility runs only for help and completion, avoiding plugin
// discovery on ordinary invocations that do not use --cloud.
func updateCloudFlagVisibility(dockerCli config.Provider, rootCmd *cobra.Command) {
flag := rootCmd.Flags().Lookup("cloud")
if flag == nil {
return
}
_, err := cloudProvider(dockerCli, rootCmd)
flag.Hidden = err != nil
}
func resolveCloudContext(ctx context.Context, dockerCli *command.DockerCli, rootCmd *cobra.Command, name string) (string, error) {
if err := ctx.Err(); err != nil {
return "", err
}
plugin, err := cloudProvider(dockerCli, rootCmd)
if err != nil {
return "", err
}
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()
out, err := cmd.Output()
if err != nil {
if ctx.Err() != nil {
return "", ctx.Err()
}
return "", fmt.Errorf("cloud resolver failed: %w", err)
}
var response struct {
DockerContext string `json:"DOCKER_CONTEXT"`
}
if err := json.Unmarshal(out, &response); err != nil {
return "", fmt.Errorf("invalid cloud resolver response: %w", err)
}
response.DockerContext = strings.TrimSpace(response.DockerContext)
if response.DockerContext == "" || response.DockerContext == command.DefaultContextName {
return "", errors.New("cloud resolver must return a non-default DOCKER_CONTEXT")
}
// Do not allow a missing context or endpoint to fall back to the local engine.
meta, err := dockerCli.ContextStore().GetMetadata(response.DockerContext)
if err != nil {
return "", fmt.Errorf("loading resolved context %q: %w", response.DockerContext, err)
}
endpoint, err := contextdocker.EndpointFromContext(meta)
if err != nil {
return "", fmt.Errorf("invalid resolved context %q: %w", response.DockerContext, err)
}
if endpoint.Host == "" {
return "", fmt.Errorf("resolved context %q has no Docker endpoint host", response.DockerContext)
}
return response.DockerContext, nil
}
// cloudHelpRequest avoids provisioning for help and shell completion.
// Parse known command flags rather than scanning argv: --help may be an option
// value or an argument to a container, not a request for Docker help.
// Unknown flags and missing flag values return a usage error, so a mistyped
// invocation fails without provisioning or falling back to the local engine.
// Flag values are validated only by the command's own parse, after resolution.
func cloudHelpRequest(rootCmd *cobra.Command, args []string) (bool, error) {
help, _ := rootCmd.PersistentFlags().GetBool("help")
version, _ := rootCmd.Flags().GetBool("version")
if help || version || len(args) == 0 {
return true, nil
}
switch args[0] {
case "help", "completion", cobra.ShellCompRequestCmd, cobra.ShellCompNoDescRequestCmd:
return true, nil
}
cmd, remaining, err := rootCmd.Find(args)
if err != nil || cmd == rootCmd || pluginmanager.IsPluginCommand(cmd) {
// Plugin flags are opaque. Only recognize help immediately after the
// plugin name; deeper help is available through "docker help PLUGIN".
return len(args) > 1 && (args[1] == "--help" || args[1] == "-h" || args[1] == "--help=true"), nil
}
cmd.InitDefaultHelpFlag()
flags := cmd.Flags()
flags.AddFlagSet(cmd.PersistentFlags())
flags.AddFlagSet(cmd.InheritedFlags())
err = flags.ParseAll(remaining, func(flag *pflag.Flag, value string) error {
if flag.Name == "help" {
var err error
if help, err = strconv.ParseBool(value); err != nil {
return fmt.Errorf("invalid argument %q for \"--help\" flag: %w", value, err)
}
}
return nil
})
if err != nil {
// Format the usage error directly. The root command's FlagErrorFunc
// first checks whether the daemon supports the command, which would
// connect to the engine selected before --cloud is resolved.
return false, cli.FlagErrorFunc(cmd, err)
}
return help, nil
}
// cloudPluginArgs only rewrites the global prefix, leaving subcommand arguments
// untouched. Parsing also distinguishes --cloud from another flag's value.
func cloudPluginArgs(cmd *cobra.Command, osArgs []string, contextName string) ([]string, error) {
flags := pflag.NewFlagSet(cmd.Name(), pflag.ContinueOnError)
flags.SetInterspersed(false)
flags.AddFlagSet(cmd.Flags())
flags.AddFlagSet(cmd.PersistentFlags())
result := []string{osArgs[0]}
if contextName != "" {
result = append(result, "--context="+contextName)
}
if err := flags.ParseAll(osArgs[1:], func(flag *pflag.Flag, value string) error {
if flag.Name != "cloud" {
result = append(result, "--"+flag.Name+"="+value)
}
return nil
}); err != nil {
return osArgs, err
}
if flags.ArgsLenAtDash() >= 0 {
result = append(result, "--")
}
return append(result, flags.Args()...), nil
}
+542
View File
@@ -0,0 +1,542 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/docker/cli/cli-plugins/metadata"
"github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/config"
"github.com/docker/cli/cli/config/configfile"
contextdocker "github.com/docker/cli/cli/context/docker"
"github.com/docker/cli/cli/context/store"
"gotest.tools/v3/assert"
is "gotest.tools/v3/assert/cmp"
)
func TestCloudFlagHidden(t *testing.T) {
dockerCli, err := command.NewDockerCli()
assert.NilError(t, err)
tcmd := newDockerCommand(dockerCli)
tcmd.SetArgs([]string{"--cloud=team", "--help"})
cmd, _, err := tcmd.HandleGlobalFlags()
assert.NilError(t, err)
name, err := cmd.Flags().GetString("cloud")
assert.NilError(t, err)
assert.Equal(t, name, "team")
assert.Assert(t, cmd.Flags().Lookup("cloud").Hidden)
assert.Assert(t, !strings.Contains(cmd.UsageString(), "--cloud"))
}
func TestCloudHelpRequest(t *testing.T) {
for _, tc := range []struct {
name string
args []string
help bool
}{
{name: "no command", help: true},
{name: "global help", args: []string{"--help"}, help: true},
{name: "global version", args: []string{"--version"}, help: true},
{name: "help command", args: []string{"help", "run"}, help: true},
{name: "completion request", args: []string{"__complete", "run", ""}, help: true},
{name: "completion script", args: []string{"completion", "fish"}, help: true},
{name: "builtin help", args: []string{"run", "--help"}, help: true},
{name: "boolean help value", args: []string{"run", "--help=1"}, help: true},
{name: "nested help", args: []string{"container", "run", "--help"}, help: true},
{name: "plugin help", args: []string{"compose", "--help"}, help: true},
{name: "command execution", args: []string{"ps"}},
{name: "container help", args: []string{"run", "alpine", "--help"}},
{name: "flag value", args: []string{"run", "--name", "--help", "alpine"}},
{name: "help disabled", args: []string{"run", "--help=false", "alpine"}},
} {
t.Run(tc.name, func(t *testing.T) {
dockerCli, err := command.NewDockerCli()
assert.NilError(t, err)
tcmd := newDockerCommand(dockerCli)
tcmd.SetArgs(append([]string{"--cloud"}, tc.args...))
cmd, args, err := tcmd.HandleGlobalFlags()
assert.NilError(t, err)
help, err := cloudHelpRequest(cmd, args)
assert.NilError(t, err)
assert.Equal(t, help, tc.help)
})
}
}
// Flag errors are reported before cloud resolution, so they must not run the
// command's daemon feature checks against the engine selected before it.
func TestCloudFlagErrorSkipsDaemon(t *testing.T) {
originalConfig := config.Dir()
t.Cleanup(func() { config.SetDir(originalConfig) })
config.SetDir(t.TempDir())
var pings atomic.Int32
daemon := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
pings.Add(1)
w.Header().Set("Docker-Experimental", "false")
}))
defer daemon.Close()
t.Setenv("DOCKER_HOST", "tcp://"+daemon.Listener.Addr().String())
dockerCli, err := command.NewDockerCli()
assert.NilError(t, err)
tcmd := newDockerCommand(dockerCli)
tcmd.SetArgs([]string{"--cloud", "checkpoint", "ls", "--unknown"})
cmd, args, err := tcmd.HandleGlobalFlags()
assert.NilError(t, err)
assert.NilError(t, tcmd.Initialize())
_, err = cloudHelpRequest(cmd, args)
assert.Check(t, is.ErrorContains(err, "unknown flag: --unknown"))
assert.Check(t, is.Equal(pings.Load(), int32(0)))
}
func TestCloudPluginArgs(t *testing.T) {
for _, tc := range []struct {
name string
args []string
context string
want []string
}{
{
name: "global flag only",
args: []string{"docker", "--cloud=foo", "compose", "up", "--cloud=child"},
context: "cloud-foo",
want: []string{"docker", "--context=cloud-foo", "compose", "up", "--cloud=child"},
},
{
name: "flag value is not cloud flag",
args: []string{"docker", "--config", "--cloud", "--cloud", "-D", "compose", "up"},
context: "cloud-default",
want: []string{"docker", "--context=cloud-default", "--config=--cloud", "--debug=true", "compose", "up"},
},
{
name: "delimiter preserved",
args: []string{"docker", "--cloud=foo", "--", "compose", "--", "--cloud"},
context: "cloud-foo",
want: []string{"docker", "--context=cloud-foo", "--", "compose", "--", "--cloud"},
},
{
name: "repeated cloud flags",
args: []string{"docker", "--cloud=foo", "--cloud=bar", "compose", "up"},
context: "cloud-bar",
want: []string{"docker", "--context=cloud-bar", "compose", "up"},
},
{
name: "help without resolution",
args: []string{"docker", "--cloud", "compose", "--help"},
want: []string{"docker", "compose", "--help"},
},
} {
t.Run(tc.name, func(t *testing.T) {
dockerCli, err := command.NewDockerCli()
assert.NilError(t, err)
tcmd := newDockerCommand(dockerCli)
tcmd.SetArgs(tc.args[1:])
cmd, _, err := tcmd.HandleGlobalFlags()
assert.NilError(t, err)
got, err := cloudPluginArgs(cmd, tc.args, tc.context)
assert.NilError(t, err)
assert.DeepEqual(t, got, tc.want)
})
}
}
// Exercise the real startup and subprocess boundary without a daemon or cloud
// service, including failures that must never dispatch the requested command.
func TestCloudResolution(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("fixture plugins use shell scripts")
}
executable, err := os.Executable()
assert.NilError(t, err)
for _, tc := range []struct {
name string
args []string
response string
exit int
wantName string
wantErr string
plugin bool
skip bool
provider string
noProvider bool
legacyProvider bool
command []string
visible bool
}{
{
name: "default target",
args: []string{"--cloud"},
wantName: "default",
},
{
name: "empty target",
args: []string{"--cloud="},
wantName: "default",
},
{
name: "named target",
args: []string{"--cloud=team"},
wantName: "team",
},
{
name: "plugin dispatch",
args: []string{"--cloud=team"},
wantName: "team",
plugin: true,
},
{
name: "nonzero exit",
args: []string{"--cloud"},
exit: 1,
wantErr: "cloud resolver failed",
},
{
name: "malformed JSON",
args: []string{"--cloud"},
response: "not JSON",
wantErr: "invalid cloud resolver response",
},
{
name: "multiple responses",
args: []string{"--cloud"},
response: `{} {}`,
wantErr: "invalid cloud resolver response",
},
{
name: "missing field",
args: []string{"--cloud"},
response: `{}`,
wantErr: "must return a non-default DOCKER_CONTEXT",
},
{
name: "local context",
args: []string{"--cloud"},
response: `{"DOCKER_CONTEXT":"default"}`,
wantErr: "must return a non-default DOCKER_CONTEXT",
},
{
name: "missing context",
args: []string{"--cloud"},
response: `{"DOCKER_CONTEXT":"missing"}`,
wantErr: `loading resolved context "missing"`,
},
{
name: "missing endpoint host",
args: []string{"--cloud"},
response: `{"DOCKER_CONTEXT":"empty-host"}`,
wantErr: "has no Docker endpoint host",
},
{
name: "explicit context conflict",
args: []string{"--cloud", "--context=other"},
wantErr: "conflicting options",
skip: true,
},
{
name: "explicit host conflict",
args: []string{"--cloud", "--host=tcp://localhost:2375"},
wantErr: "conflicting options",
skip: true,
},
{
name: "invalid flag before help",
args: []string{"--cloud"},
command: []string{"run", "--unknown", "--help"},
wantErr: "unknown flag: --unknown",
skip: true,
},
{
name: "default provider",
args: []string{"--cloud"},
noProvider: true,
wantName: "default",
},
{
name: "legacy default provider",
args: []string{"--cloud"},
noProvider: true,
legacyProvider: true,
wantErr: `plugin "offload" does not support cloud context resolution`,
skip: true,
},
{
name: "legacy configured provider",
args: []string{"--cloud"},
legacyProvider: true,
wantErr: `plugin "foobar" does not support cloud context resolution`,
skip: true,
},
{
name: "configured provider unavailable",
args: []string{"--cloud"},
provider: "missing",
wantErr: `cloud resolver plugin "missing" unavailable`,
skip: true,
},
{
name: "provider cannot be a path",
args: []string{"--cloud"},
provider: "../foobar",
wantErr: `cloud resolver plugin "../foobar" unavailable`,
skip: true,
},
{
name: "no cloud option",
noProvider: true,
skip: true,
},
{
name: "help",
args: []string{"--cloud", "--help"},
noProvider: true,
skip: true,
},
{
name: "version",
args: []string{"--cloud", "--version"},
noProvider: true,
skip: true,
},
{
name: "help exposes supported provider",
command: []string{"--help"},
noProvider: true,
visible: true,
skip: true,
},
{
name: "help hides legacy provider",
command: []string{"--help"},
noProvider: true,
legacyProvider: true,
skip: true,
},
{
name: "help hides missing provider",
command: []string{"--help"},
provider: "missing",
skip: true,
},
{
name: "completion exposes supported provider",
command: []string{"__complete", "--cl"},
noProvider: true,
visible: true,
skip: true,
},
{
name: "completion hides legacy provider",
command: []string{"__complete", "--cl"},
noProvider: true,
legacyProvider: true,
skip: true,
},
{
name: "completion hides missing provider",
command: []string{"__complete", "--cl"},
provider: "missing",
skip: true,
},
} {
t.Run(tc.name, func(t *testing.T) {
// Explicit --cloud must override inherited endpoint selection.
t.Setenv("DOCKER_CONTEXT", "inherited")
t.Setenv("DOCKER_HOST", "tcp://127.0.0.1:1")
t.Setenv("DOCKER_CLI_HOOKS", "false")
t.Setenv("DOCKER_CONFIG", t.TempDir())
configDir := t.TempDir()
installedProvider := "offload"
if !tc.noProvider {
installedProvider = "foobar"
cfg := configfile.New(filepath.Join(configDir, config.ConfigFileName))
provider := tc.provider
if provider == "" {
provider = "foobar"
}
cfg.Features = map[string]string{"cloud": provider}
assert.NilError(t, cfg.Save())
}
pluginDir := filepath.Join(configDir, "cli-plugins")
assert.NilError(t, os.MkdirAll(pluginDir, 0o755))
response := tc.response
if response == "" {
response = `{"DOCKER_CONTEXT":"resolved"}`
}
providerMetadata := metadata.Metadata{SchemaVersion: "0.1.0", Vendor: "test"}
if !tc.legacyProvider {
providerMetadata.Features = map[string]any{cloudResolverFeature: true}
}
pluginMetadata, err := json.Marshal(providerMetadata)
assert.NilError(t, err)
script := fmt.Sprintf(`#!/bin/sh
if [ "$1" = docker-cli-plugin-metadata ]; then
echo '%s'
exit 0
fi
printf '%%s\n' "$@" >> "$DOCKER_CONFIG/resolver-args"
echo provisioning >&2
printf '%%s\n' '%s'
exit %d
`, pluginMetadata, response, tc.exit)
assert.NilError(t, os.WriteFile(filepath.Join(pluginDir, "docker-"+installedProvider), []byte(script), 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
printf '%s\n' "$@" > "$CLOUD_TEST_ARGS"
`), 0o755))
dispatchFile := filepath.Join(configDir, "dispatch-args")
t.Setenv("CLOUD_TEST_ARGS", dispatchFile)
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"}},
}))
assert.NilError(t, contextStore.CreateOrUpdate(store.Metadata{
Name: "empty-host",
Endpoints: map[string]any{contextdocker.DockerEndpoint: contextdocker.EndpointMeta{}},
}))
args := append([]string{"docker", "--config=" + configDir}, tc.args...)
switch {
case tc.command != nil:
args = append(args, tc.command...)
case tc.plugin || tc.wantErr != "":
args = append(args, "cloudtest", "up", "--cloud=child")
default:
args = append(args, "context", "show")
}
payload, err := json.Marshal(args)
assert.NilError(t, err)
var stdout, stderr bytes.Buffer
cmd := exec.CommandContext(t.Context(), executable, "-test.run=^TestCloudCommandProcess$")
cmd.Env = append(os.Environ(), "CLOUD_TEST_COMMAND="+string(payload))
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err = cmd.Run()
if tc.wantErr != "" {
assert.Assert(t, err != nil)
assert.Check(t, is.Contains(stderr.String(), tc.wantErr))
_, statErr := os.Stat(dispatchFile)
assert.Assert(t, os.IsNotExist(statErr))
assert.Equal(t, stdout.String(), "")
} else {
assert.NilError(t, err, stderr.String())
if tc.command != nil {
assert.Equal(t, strings.Contains(stdout.String(), "--cloud"), tc.visible, stdout.String())
}
if !tc.skip {
if tc.plugin {
args, err := os.ReadFile(dispatchFile)
assert.NilError(t, err)
assert.Equal(t, string(args), "--context=resolved\n--config="+configDir+"\ncloudtest\nup\n--cloud=child\n")
} else {
assert.Equal(t, stdout.String(), "resolved\n")
}
}
}
invocation, err := os.ReadFile(filepath.Join(configDir, "resolver-args"))
if tc.skip {
assert.Assert(t, os.IsNotExist(err))
} else {
assert.NilError(t, err)
assert.Check(t, is.Contains(stderr.String(), "provisioning"))
name := tc.wantName
if name == "" {
name = "default"
}
assert.Equal(t, string(invocation), "--config="+configDir+"\n"+installedProvider+"\n__resolve-context\n--\n"+name+"\n")
}
})
}
}
// Run startup in a separate process because it installs process-wide signal
// handlers that intentionally outlive an individual command.
func TestCloudCommandProcess(t *testing.T) {
payload := os.Getenv("CLOUD_TEST_COMMAND")
if payload == "" {
return
}
assert.NilError(t, json.Unmarshal([]byte(payload), &os.Args))
err := dockerMain(context.Background())
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, err)
}
if err == nil {
os.Exit(0)
}
os.Exit(getExitCode(err))
}
func TestCloudResolutionCancellation(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("fixture plugin uses a shell script")
}
originalConfig := config.Dir()
t.Cleanup(func() { config.SetDir(originalConfig) })
config.SetDir(t.TempDir())
cfg := configfile.New(filepath.Join(config.Dir(), config.ConfigFileName))
cfg.Features = map[string]string{"cloud": "foobar"}
assert.NilError(t, cfg.Save())
pluginDir := filepath.Join(config.Dir(), "cli-plugins")
assert.NilError(t, os.MkdirAll(pluginDir, 0o755))
assert.NilError(t, os.WriteFile(filepath.Join(pluginDir, "docker-foobar"), []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
exec sleep 30
`), 0o755))
dockerCli, err := command.NewDockerCli()
assert.NilError(t, err)
tcmd := newDockerCommand(dockerCli)
tcmd.SetArgs(strings.Fields("--cloud context show"))
cmd, _, err := tcmd.HandleGlobalFlags()
assert.NilError(t, err)
ctx, cancel := context.WithTimeout(t.Context(), time.Second)
defer cancel()
_, err = resolveCloudContext(ctx, dockerCli, cmd, "default")
assert.ErrorIs(t, err, context.DeadlineExceeded)
}
+17 -1
View File
@@ -178,6 +178,9 @@ func newDockerCommand(dockerCli *command.DockerCli) *cli.TopLevelCommand {
_ = cmd.RegisterFlagCompletionFunc("log-level", completeLogLevels)
cmd.Flags().BoolP("version", "v", false, "Print version information and quit")
cmd.Flags().String("cloud", "", "Use a cloud context, provisioning it if necessary (optionally --cloud=NAME)")
cmd.Flags().Lookup("cloud").NoOptDefVal = "default"
cmd.Flags().Lookup("cloud").Hidden = true
setFlagErrorFunc(dockerCli, cmd)
setupHelpCommand(dockerCli, cmd, helpCmd)
@@ -250,6 +253,7 @@ func setHelpFunc(dockerCli command.Cli, cmd *cobra.Command) {
ccmd.Println(err)
return
}
updateCloudFlagVisibility(dockerCli, ccmd.Root())
if len(args) >= 1 {
err := tryRunPluginHelp(dockerCli, ccmd, args)
@@ -508,7 +512,18 @@ func runDocker(ctx context.Context, dockerCli *command.DockerCli) error {
return err
}
if err := tcmd.Initialize(command.WithEnableGlobalMeterProvider(), command.WithEnableGlobalTracerProvider()); err != nil {
if err := tcmd.Initialize(
command.WithEnableGlobalMeterProvider(),
command.WithEnableGlobalTracerProvider(),
command.WithContextResolver(func(cli *command.DockerCli) (string, error) {
var err error
os.Args, err = processCloud(ctx, cli, cmd, args, os.Args)
if err != nil {
return "", err
}
return cmd.Flags().GetString("context")
}),
); err != nil {
return err
}
@@ -538,6 +553,7 @@ func runDocker(ctx context.Context, dockerCli *command.DockerCli) error {
if err := pluginmanager.AddPluginCommandStubs(dockerCli, cmd); err != nil {
return err
}
updateCloudFlagVisibility(dockerCli, cmd)
}
var subCommand *cobra.Command