mirror of
https://github.com/docker/cli.git
synced 2026-11-06 13:10:41 -04:00
cmd/docker: Add --cloud with configurable context resolution
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 <pawel.gronowski@docker.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user