Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions cli-plugins/metadata/metadata.go
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down Expand Up @@ -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 changes: 13 additions & 0 deletions cli/command/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
13 changes: 13 additions & 0 deletions cli/command/cli_options.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
58 changes: 58 additions & 0 deletions cli/command/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
256 changes: 256 additions & 0 deletions cmd/docker/cloud.go
Original file line number Diff line number Diff line change
@@ -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])
Comment thread
vvoland marked this conversation as resolved.
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
Comment on lines +199 to +202

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah not sure theres a better way. We can't just scan the whole command line.. what about something like compose run web some-command--help ?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think there's a good way around this due to the early context resolution that needs to happen before invoking the plugin. We could do a naive/full scan for --help and skip context resolution entirely perhaps?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

-h might be too broad. For --help that would break for usages where for example you call a container with a cmd that involves --help (although thats probably a big edge case).

Comment thread
vvoland marked this conversation as resolved.
}

cmd.InitDefaultHelpFlag()
flags := cmd.Flags()
Comment thread
vvoland marked this conversation as resolved.
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
Comment on lines +210 to +217
})
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) {
Comment thread
vvoland marked this conversation as resolved.
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)
Comment thread
vvoland marked this conversation as resolved.
}
return nil
}); err != nil {
return osArgs, err
}

if flags.ArgsLenAtDash() >= 0 {
result = append(result, "--")
}

return append(result, flags.Args()...), nil
}
Loading
Loading