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"` } 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 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