From b1f2c1dc060370c54e73d04f859f1107d8112ff8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gronowski?= Date: Thu, 1 Oct 2026 19:01:36 +0200 Subject: [PATCH 1/3] cli-plugins/metadata: Add Features for optional plugin contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugins had no generic way to declare support for optional CLI contracts, so each one would need its own top-level metadata field. Add Features, keyed by feature name. Values are arbitrary JSON so a feature can carry more than a boolean. Signed-off-by: Paweł Gronowski --- cli-plugins/metadata/metadata.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cli-plugins/metadata/metadata.go b/cli-plugins/metadata/metadata.go index 7061486a7056..c35a5ad02890 100644 --- a/cli-plugins/metadata/metadata.go +++ b/cli-plugins/metadata/metadata.go @@ -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"` } From b53372e86a35d4ca3c1e2b7b99018654962209c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gronowski?= Date: Thu, 1 Oct 2026 19:01:41 +0200 Subject: [PATCH 2/3] cli/command: Add WithContextResolver to override context selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Callers that choose the context from configuration had to do it before Initialize. That meant loading the config file and context store themselves and handling --config a second time. WithContextResolver runs a callback inside Initialize after the config file and context store are loaded, but before telemetry and endpoint initialization. A non-empty result replaces the selected context, an empty result keeps normal selection, and an error aborts initialization. The API client and endpoint are resolved lazily, so they use the overridden context. Signed-off-by: Paweł Gronowski --- cli/command/cli.go | 13 +++++++++ cli/command/cli_options.go | 13 +++++++++ cli/command/cli_test.go | 58 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+) diff --git a/cli/command/cli.go b/cli/command/cli.go index e405c2cc4f6d..0d2f4b506f63 100644 --- a/cli/command/cli.go +++ b/cli/command/cli.go @@ -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) diff --git a/cli/command/cli_options.go b/cli/command/cli_options.go index 469932f9c399..733cfbdf64c7 100644 --- a/cli/command/cli_options.go +++ b/cli/command/cli_options.go @@ -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 { diff --git a/cli/command/cli_test.go b/cli/command/cli_test.go index 27be8d348cdc..09c093d5ddef 100644 --- a/cli/command/cli_test.go +++ b/cli/command/cli_test.go @@ -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 From fb1cc07c08300cf77fec2652072a907ad0f653c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gronowski?= Date: Thu, 1 Oct 2026 19:01:46 +0200 Subject: [PATCH 3/3] cmd/docker: Add --cloud with configurable context resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve it through the offload plugin by default, with a provider override in features.cloud in the Docker config. Require the selected plugin to declare the cloud-context-resolver feature in its metadata before showing the flag in help and completion or invoking its __resolve-context operation. Resolve through WithContextResolver, after the config file and context store are loaded but before client initialization, without wrapping or re-executing Docker. Forward --context to downstream plugins so they need not support --cloud or repeat provisioning. Skip provisioning for help, version, and completion requests. Report unknown flags and missing flag values before provisioning, so a mistyped invocation fails without creating cloud resources or contacting the engine selected before resolution. Signed-off-by: Paweł Gronowski --- cmd/docker/cloud.go | 256 ++++++++++++++++++ cmd/docker/cloud_test.go | 542 +++++++++++++++++++++++++++++++++++++++ cmd/docker/docker.go | 18 +- 3 files changed, 815 insertions(+), 1 deletion(-) create mode 100644 cmd/docker/cloud.go create mode 100644 cmd/docker/cloud_test.go diff --git a/cmd/docker/cloud.go b/cmd/docker/cloud.go new file mode 100644 index 000000000000..f2a127bb4219 --- /dev/null +++ b/cmd/docker/cloud.go @@ -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- --config= __resolve-context -- +// +// 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= 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 +} diff --git a/cmd/docker/cloud_test.go b/cmd/docker/cloud_test.go new file mode 100644 index 000000000000..f1ce8a60a840 --- /dev/null +++ b/cmd/docker/cloud_test.go @@ -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) +} diff --git a/cmd/docker/docker.go b/cmd/docker/docker.go index 2341570b91a1..47c720e941f5 100644 --- a/cmd/docker/docker.go +++ b/cmd/docker/docker.go @@ -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