From b34f340346f8042dc2968f412c490d301f2658b3 Mon Sep 17 00:00:00 2001 From: Simon Ferquel Date: Mon, 17 Dec 2018 11:27:07 +0100 Subject: [PATCH 1/2] Introduce docker context store This PR adds a store to the CLI, that can be leveraged to persist and retrieve credentials for various API endpoints, as well as context-specific settings (initially, default stack orchestrator, but we could expand that). This comes with the logic to persist and retrieve endpoints configs for both Docker and Kubernetes APIs. Signed-off-by: Simon Ferquel --- cli/command/cli.go | 257 +++++++++++----- cli/command/cli_test.go | 1 + cli/command/context.go | 27 ++ cli/command/orchestrator.go | 15 +- cli/command/orchestrator_test.go | 69 ++--- cli/command/stack/cmd.go | 13 +- cli/command/stack/kubernetes/cli.go | 15 +- cli/command/system/version.go | 24 +- cli/config/config.go | 6 + cli/config/configfile/file.go | 1 + cli/context/docker/constants.go | 6 + cli/context/docker/load.go | 172 +++++++++++ cli/context/endpoint.go | 7 + cli/context/kubernetes/constants.go | 6 + cli/context/kubernetes/endpoint_test.go | 183 ++++++++++++ cli/context/kubernetes/load.go | 95 ++++++ cli/context/kubernetes/save.go | 79 +++++ .../kubernetes/testdata/eks-kubeconfig | 23 ++ .../kubernetes/testdata/gke-kubeconfig | 23 ++ cli/context/store/doc.go | 21 ++ cli/context/store/metadata_test.go | 142 +++++++++ cli/context/store/metadatastore.go | 146 +++++++++ cli/context/store/store.go | 282 ++++++++++++++++++ cli/context/store/store_test.go | 100 +++++++ cli/context/store/storeconfig.go | 38 +++ cli/context/store/tlsstore.go | 99 ++++++ cli/context/store/tlsstore_test.go | 79 +++++ cli/context/tlsdata.go | 98 ++++++ cli/flags/common.go | 3 + 29 files changed, 1885 insertions(+), 145 deletions(-) create mode 100644 cli/command/context.go create mode 100644 cli/context/docker/constants.go create mode 100644 cli/context/docker/load.go create mode 100644 cli/context/endpoint.go create mode 100644 cli/context/kubernetes/constants.go create mode 100644 cli/context/kubernetes/endpoint_test.go create mode 100644 cli/context/kubernetes/load.go create mode 100644 cli/context/kubernetes/save.go create mode 100644 cli/context/kubernetes/testdata/eks-kubeconfig create mode 100644 cli/context/kubernetes/testdata/gke-kubeconfig create mode 100644 cli/context/store/doc.go create mode 100644 cli/context/store/metadata_test.go create mode 100644 cli/context/store/metadatastore.go create mode 100644 cli/context/store/store.go create mode 100644 cli/context/store/store_test.go create mode 100644 cli/context/store/storeconfig.go create mode 100644 cli/context/store/tlsstore.go create mode 100644 cli/context/store/tlsstore_test.go create mode 100644 cli/context/tlsdata.go diff --git a/cli/command/cli.go b/cli/command/cli.go index 2e051440fdf3..d325d5156c14 100644 --- a/cli/command/cli.go +++ b/cli/command/cli.go @@ -3,26 +3,25 @@ package command import ( "context" "io" - "net" - "net/http" "os" "path/filepath" "runtime" "strconv" - "time" "github.com/docker/cli/cli" "github.com/docker/cli/cli/config" cliconfig "github.com/docker/cli/cli/config" "github.com/docker/cli/cli/config/configfile" - "github.com/docker/cli/cli/connhelper" + dcontext "github.com/docker/cli/cli/context" + "github.com/docker/cli/cli/context/docker" + kubcontext "github.com/docker/cli/cli/context/kubernetes" + "github.com/docker/cli/cli/context/store" cliflags "github.com/docker/cli/cli/flags" manifeststore "github.com/docker/cli/cli/manifest/store" registryclient "github.com/docker/cli/cli/registry/client" "github.com/docker/cli/cli/trust" dopts "github.com/docker/cli/opts" clitypes "github.com/docker/cli/types" - "github.com/docker/docker/api" "github.com/docker/docker/api/types" registrytypes "github.com/docker/docker/api/types/registry" "github.com/docker/docker/client" @@ -34,6 +33,9 @@ import ( "github.com/theupdateframework/notary/passphrase" ) +// ContextDockerHost is the reported context when DOCKER_HOST env var or -H flag is set +const ContextDockerHost = "" + // Streams is an interface which exposes the standard input and output streams type Streams interface { In() *InStream @@ -57,6 +59,9 @@ type Cli interface { RegistryClient(bool) registryclient.RegistryClient ContentTrustEnabled() bool NewContainerizedEngineClient(sockPath string) (clitypes.ContainerizedClient, error) + ContextStore() store.Store + CurrentContext() string + StackOrchestrator(flagValue string) (Orchestrator, error) } // DockerCli is an instance the docker command line client. @@ -71,8 +76,16 @@ type DockerCli struct { clientInfo ClientInfo contentTrust bool newContainerizeClient func(string) (clitypes.ContainerizedClient, error) + contextStore store.Store + currentContext string } +var storeConfig = store.NewConfig( + func() interface{} { return &DockerContext{} }, + store.EndpointTypeGetter(docker.DockerEndpoint, func() interface{} { return &docker.EndpointMeta{} }), + store.EndpointTypeGetter(kubcontext.KubernetesEndpoint, func() interface{} { return &kubcontext.EndpointMeta{} }), +) + // DefaultVersion returns api.defaultVersion or DOCKER_API_VERSION if specified. func (cli *DockerCli) DefaultVersion() string { return cli.clientInfo.DefaultVersion @@ -167,14 +180,23 @@ func (cli *DockerCli) RegistryClient(allowInsecure bool) registryclient.Registry // line flags are parsed. func (cli *DockerCli) Initialize(opts *cliflags.ClientOptions) error { cli.configFile = cliconfig.LoadDefaultConfigFile(cli.err) - var err error - cli.client, err = NewAPIClientFromFlags(opts.Common, cli.configFile) + cli.contextStore = store.New(cliconfig.ContextStoreDir(), storeConfig) + cli.currentContext, err = resolveContextName(opts.Common, cli.configFile) + if err != nil { + return err + } + endpoint, err := resolveDockerEndpoint(cli.contextStore, cli.currentContext, opts.Common) + if err != nil { + return errors.Wrap(err, "unable to resolve docker endpoint") + } + + cli.client, err = newAPIClientFromEndpoint(endpoint, cli.configFile) if tlsconfig.IsErrEncryptedKey(err) { passRetriever := passphrase.PromptRetrieverWithInOut(cli.In(), cli.Out(), nil) newClient := func(password string) (client.APIClient, error) { - opts.Common.TLSOptions.Passphrase = password - return NewAPIClientFromFlags(opts.Common, cli.configFile) + endpoint.TLSPassword = password + return newAPIClientFromEndpoint(endpoint, cli.configFile) } cli.client, err = getClientWithPassword(passRetriever, newClient) } @@ -198,6 +220,75 @@ func (cli *DockerCli) Initialize(opts *cliflags.ClientOptions) error { return nil } +// NewAPIClientFromFlags creates a new APIClient from command line flags +func NewAPIClientFromFlags(opts *cliflags.CommonOptions, configFile *configfile.ConfigFile) (client.APIClient, error) { + store := store.New(cliconfig.ContextStoreDir(), storeConfig) + contextName, err := resolveContextName(opts, configFile) + if err != nil { + return nil, err + } + endpoint, err := resolveDockerEndpoint(store, contextName, opts) + if err != nil { + return nil, errors.Wrap(err, "unable to resolve docker endpoint") + } + return newAPIClientFromEndpoint(endpoint, configFile) +} + +func newAPIClientFromEndpoint(ep docker.Endpoint, configFile *configfile.ConfigFile) (client.APIClient, error) { + clientOpts, err := ep.ClientOpts() + if err != nil { + return nil, err + } + customHeaders := configFile.HTTPHeaders + if customHeaders == nil { + customHeaders = map[string]string{} + } + customHeaders["User-Agent"] = UserAgent() + clientOpts = append(clientOpts, client.WithHTTPHeaders(customHeaders)) + return client.NewClientWithOpts(clientOpts...) +} + +func resolveDockerEndpoint(s store.Store, contextName string, opts *cliflags.CommonOptions) (docker.Endpoint, error) { + if contextName != ContextDockerHost { + ctxMeta, err := s.GetContextMetadata(contextName) + if err != nil { + return docker.Endpoint{}, err + } + epMeta, err := docker.EndpointFromContext(ctxMeta) + if err != nil { + return docker.Endpoint{}, err + } + return epMeta.WithTLSData(s, contextName) + } + host, err := getServerHost(opts.Hosts, opts.TLSOptions) + if err != nil { + return docker.Endpoint{}, err + } + + var ( + skipTLSVerify bool + tlsData *dcontext.TLSData + ) + + if opts.TLSOptions != nil { + skipTLSVerify = opts.TLSOptions.InsecureSkipVerify + tlsData, err = dcontext.TLSDataFromFiles(opts.TLSOptions.CAFile, opts.TLSOptions.CertFile, opts.TLSOptions.KeyFile) + if err != nil { + return docker.Endpoint{}, err + } + } + + return docker.Endpoint{ + EndpointMeta: docker.EndpointMeta{ + EndpointMetaBase: dcontext.EndpointMetaBase{ + Host: host, + SkipTLSVerify: skipTLSVerify, + }, + }, + TLSData: tlsData, + }, nil +} + func isEnabled(value string) (bool, error) { switch value { case "enabled": @@ -253,6 +344,51 @@ func (cli *DockerCli) NewContainerizedEngineClient(sockPath string) (clitypes.Co return cli.newContainerizeClient(sockPath) } +// ContextStore returns the ContextStore +func (cli *DockerCli) ContextStore() store.Store { + return cli.contextStore +} + +// CurrentContext returns the current context name +func (cli *DockerCli) CurrentContext() string { + return cli.currentContext +} + +// StackOrchestrator resolves which stack orchestrator is in use +func (cli *DockerCli) StackOrchestrator(flagValue string) (Orchestrator, error) { + var ctxOrchestrator string + + configFile := cli.configFile + if configFile == nil { + configFile = cliconfig.LoadDefaultConfigFile(cli.Err()) + } + + currentContext := cli.CurrentContext() + if currentContext == "" { + currentContext = configFile.CurrentContext + } + if currentContext == "" { + currentContext = ContextDockerHost + } + if currentContext != ContextDockerHost { + contextstore := cli.contextStore + if contextstore == nil { + contextstore = store.New(cliconfig.ContextStoreDir(), storeConfig) + } + ctxRaw, err := contextstore.GetContextMetadata(currentContext) + if err != nil { + return "", err + } + ctxMeta, err := GetDockerContext(ctxRaw) + if err != nil { + return "", err + } + ctxOrchestrator = string(ctxMeta.StackOrchestrator) + } + + return GetStackOrchestrator(flagValue, ctxOrchestrator, configFile.StackOrchestrator, cli.Err()) +} + // ServerInfo stores details about the supported features and platform of the // server type ServerInfo struct { @@ -272,51 +408,6 @@ func NewDockerCli(in io.ReadCloser, out, err io.Writer, isTrusted bool, containe return &DockerCli{in: NewInStream(in), out: NewOutStream(out), err: err, contentTrust: isTrusted, newContainerizeClient: containerizedFn} } -// NewAPIClientFromFlags creates a new APIClient from command line flags -func NewAPIClientFromFlags(opts *cliflags.CommonOptions, configFile *configfile.ConfigFile) (client.APIClient, error) { - host, err := getServerHost(opts.Hosts, opts.TLSOptions) - if err != nil { - return &client.Client{}, err - } - var clientOpts []func(*client.Client) error - helper, err := connhelper.GetConnectionHelper(host) - if err != nil { - return &client.Client{}, err - } - if helper == nil { - clientOpts = append(clientOpts, withHTTPClient(opts.TLSOptions)) - clientOpts = append(clientOpts, client.WithHost(host)) - } else { - clientOpts = append(clientOpts, func(c *client.Client) error { - httpClient := &http.Client{ - // No tls - // No proxy - Transport: &http.Transport{ - DialContext: helper.Dialer, - }, - } - return client.WithHTTPClient(httpClient)(c) - }) - clientOpts = append(clientOpts, client.WithHost(helper.Host)) - clientOpts = append(clientOpts, client.WithDialContext(helper.Dialer)) - } - - customHeaders := configFile.HTTPHeaders - if customHeaders == nil { - customHeaders = map[string]string{} - } - customHeaders["User-Agent"] = UserAgent() - clientOpts = append(clientOpts, client.WithHTTPHeaders(customHeaders)) - - verStr := api.DefaultVersion - if tmpStr := os.Getenv("DOCKER_API_VERSION"); tmpStr != "" { - verStr = tmpStr - } - clientOpts = append(clientOpts, client.WithVersion(verStr)) - - return client.NewClientWithOpts(clientOpts...) -} - func getServerHost(hosts []string, tlsOptions *tlsconfig.Options) (string, error) { var host string switch len(hosts) { @@ -331,35 +422,37 @@ func getServerHost(hosts []string, tlsOptions *tlsconfig.Options) (string, error return dopts.ParseHost(tlsOptions != nil, host) } -func withHTTPClient(tlsOpts *tlsconfig.Options) func(*client.Client) error { - return func(c *client.Client) error { - if tlsOpts == nil { - // Use the default HTTPClient - return nil - } - - opts := *tlsOpts - opts.ExclusiveRootPools = true - tlsConfig, err := tlsconfig.Client(opts) - if err != nil { - return err - } - - httpClient := &http.Client{ - Transport: &http.Transport{ - TLSClientConfig: tlsConfig, - DialContext: (&net.Dialer{ - KeepAlive: 30 * time.Second, - Timeout: 30 * time.Second, - }).DialContext, - }, - CheckRedirect: client.CheckRedirect, - } - return client.WithHTTPClient(httpClient)(c) - } -} - // UserAgent returns the user agent string used for making API requests func UserAgent() string { return "Docker-Client/" + cli.Version + " (" + runtime.GOOS + ")" } + +// resolveContextName resolves the current context name with the following rules: +// - setting both --context and --host flags is ambiguous +// - if --context is set, use this value +// - if --host flag or DOCKER_HOST is set, fallbacks to use the same logic as before context-store was added +// for backward compatibility with existing scripts +// - if DOCKER_CONTEXT is set, use this value +// - if Config file has a globally set "CurrentContext", use this value +// - fallbacks to default HOST, uses TLS config from flags/env vars +func resolveContextName(opts *cliflags.CommonOptions, config *configfile.ConfigFile) (string, error) { + if opts.Context != "" && len(opts.Hosts) > 0 { + return "", errors.New("Conflicting options: either specify --host or --context, not bot") + } + if opts.Context != "" { + return opts.Context, nil + } + if len(opts.Hosts) > 0 { + return ContextDockerHost, nil + } + if _, present := os.LookupEnv("DOCKER_HOST"); present { + return ContextDockerHost, nil + } + if ctxName, ok := os.LookupEnv("DOCKER_CONTEXT"); ok { + return ctxName, nil + } + if config != nil && config.CurrentContext != "" { + return config.CurrentContext, nil + } + return ContextDockerHost, nil +} diff --git a/cli/command/cli_test.go b/cli/command/cli_test.go index 6ac120fa227b..71029e9107f3 100644 --- a/cli/command/cli_test.go +++ b/cli/command/cli_test.go @@ -66,6 +66,7 @@ func TestNewAPIClientFromFlagsForDefaultSchema(t *testing.T) { func TestNewAPIClientFromFlagsWithAPIVersionFromEnv(t *testing.T) { customVersion := "v3.3.3" defer env.Patch(t, "DOCKER_API_VERSION", customVersion)() + defer env.Patch(t, "DOCKER_HOST", ":2375")() opts := &flags.CommonOptions{} configFile := &configfile.ConfigFile{} diff --git a/cli/command/context.go b/cli/command/context.go new file mode 100644 index 000000000000..2b4c76ed152e --- /dev/null +++ b/cli/command/context.go @@ -0,0 +1,27 @@ +package command + +import ( + "errors" + + "github.com/docker/cli/cli/context/store" +) + +// DockerContext is a typed representation of what we put in Context metadata +type DockerContext struct { + Description string `json:"description,omitempty"` + StackOrchestrator Orchestrator `json:"stack_orchestrator,omitempty"` +} + +// GetDockerContext extracts metadata from stored context metadata +func GetDockerContext(storeMetadata store.ContextMetadata) (DockerContext, error) { + if storeMetadata.Metadata == nil { + // can happen if we save endpoints before assigning a context metadata + // it is totally valid, and we should return a default initialized value + return DockerContext{}, nil + } + res, ok := storeMetadata.Metadata.(DockerContext) + if !ok { + return DockerContext{}, errors.New("context metadata is not a valid DockerContext") + } + return res, nil +} diff --git a/cli/command/orchestrator.go b/cli/command/orchestrator.go index 5f3e4462055f..c71b3f8963d3 100644 --- a/cli/command/orchestrator.go +++ b/cli/command/orchestrator.go @@ -44,7 +44,7 @@ func normalize(value string) (Orchestrator, error) { return OrchestratorKubernetes, nil case "swarm": return OrchestratorSwarm, nil - case "": + case "", "unset": return orchestratorUnset, nil case "all": return OrchestratorAll, nil @@ -53,9 +53,14 @@ func normalize(value string) (Orchestrator, error) { } } +// NormalizeOrchestrator parses an orchestrator value and checks if it is valid +func NormalizeOrchestrator(value string) (Orchestrator, error) { + return normalize(value) +} + // GetStackOrchestrator checks DOCKER_STACK_ORCHESTRATOR environment variable and configuration file // orchestrator value and returns user defined Orchestrator. -func GetStackOrchestrator(flagValue, value string, stderr io.Writer) (Orchestrator, error) { +func GetStackOrchestrator(flagValue, contextValue, globalDefault string, stderr io.Writer) (Orchestrator, error) { // Check flag if o, err := normalize(flagValue); o != orchestratorUnset { return o, err @@ -68,8 +73,10 @@ func GetStackOrchestrator(flagValue, value string, stderr io.Writer) (Orchestrat if o, err := normalize(env); o != orchestratorUnset { return o, err } - // Check specified orchestrator - if o, err := normalize(value); o != orchestratorUnset { + if o, err := normalize(contextValue); o != orchestratorUnset { + return o, err + } + if o, err := normalize(globalDefault); o != orchestratorUnset { return o, err } // Nothing set, use default orchestrator diff --git a/cli/command/orchestrator_test.go b/cli/command/orchestrator_test.go index 322e8a916979..141c27e43494 100644 --- a/cli/command/orchestrator_test.go +++ b/cli/command/orchestrator_test.go @@ -2,87 +2,82 @@ package command import ( "io/ioutil" - "os" "testing" - cliconfig "github.com/docker/cli/cli/config" - "github.com/docker/cli/cli/flags" "gotest.tools/assert" is "gotest.tools/assert/cmp" "gotest.tools/env" - "gotest.tools/fs" ) func TestOrchestratorSwitch(t *testing.T) { - defaultVersion := "v0.00" - var testcases = []struct { doc string - configfile string + globalOrchestrator string envOrchestrator string flagOrchestrator string + contextOrchestrator string expectedOrchestrator string expectedKubernetes bool expectedSwarm bool }{ { - doc: "default", - configfile: `{ - }`, + doc: "default", expectedOrchestrator: "swarm", expectedKubernetes: false, expectedSwarm: true, }, { - doc: "kubernetesConfigFile", - configfile: `{ - "stackOrchestrator": "kubernetes" - }`, + doc: "kubernetesConfigFile", + globalOrchestrator: "kubernetes", expectedOrchestrator: "kubernetes", expectedKubernetes: true, expectedSwarm: false, }, { - doc: "kubernetesEnv", - configfile: `{ - }`, + doc: "kubernetesEnv", envOrchestrator: "kubernetes", expectedOrchestrator: "kubernetes", expectedKubernetes: true, expectedSwarm: false, }, { - doc: "kubernetesFlag", - configfile: `{ - }`, + doc: "kubernetesFlag", flagOrchestrator: "kubernetes", expectedOrchestrator: "kubernetes", expectedKubernetes: true, expectedSwarm: false, }, { - doc: "allOrchestratorFlag", - configfile: `{ - }`, + doc: "allOrchestratorFlag", flagOrchestrator: "all", expectedOrchestrator: "all", expectedKubernetes: true, expectedSwarm: true, }, { - doc: "envOverridesConfigFile", - configfile: `{ - "stackOrchestrator": "kubernetes" - }`, + doc: "kubernetesContext", + contextOrchestrator: "kubernetes", + expectedOrchestrator: "kubernetes", + expectedKubernetes: true, + }, + { + doc: "contextOverridesConfigFile", + globalOrchestrator: "kubernetes", + contextOrchestrator: "swarm", + expectedOrchestrator: "swarm", + expectedKubernetes: false, + expectedSwarm: true, + }, + { + doc: "envOverridesConfigFile", + globalOrchestrator: "kubernetes", envOrchestrator: "swarm", expectedOrchestrator: "swarm", expectedKubernetes: false, expectedSwarm: true, }, { - doc: "flagOverridesEnv", - configfile: `{ - }`, + doc: "flagOverridesEnv", envOrchestrator: "kubernetes", flagOrchestrator: "swarm", expectedOrchestrator: "swarm", @@ -93,22 +88,10 @@ func TestOrchestratorSwitch(t *testing.T) { for _, testcase := range testcases { t.Run(testcase.doc, func(t *testing.T) { - dir := fs.NewDir(t, testcase.doc, fs.WithFile("config.json", testcase.configfile)) - defer dir.Remove() - apiclient := &fakeClient{ - version: defaultVersion, - } if testcase.envOrchestrator != "" { defer env.Patch(t, "DOCKER_STACK_ORCHESTRATOR", testcase.envOrchestrator)() } - - cli := &DockerCli{client: apiclient, err: os.Stderr} - cliconfig.SetDir(dir.Path()) - options := flags.NewClientOptions() - err := cli.Initialize(options) - assert.NilError(t, err) - - orchestrator, err := GetStackOrchestrator(testcase.flagOrchestrator, cli.ConfigFile().StackOrchestrator, ioutil.Discard) + orchestrator, err := GetStackOrchestrator(testcase.flagOrchestrator, testcase.contextOrchestrator, testcase.globalOrchestrator, ioutil.Discard) assert.NilError(t, err) assert.Check(t, is.Equal(testcase.expectedKubernetes, orchestrator.HasKubernetes())) assert.Check(t, is.Equal(testcase.expectedSwarm, orchestrator.HasSwarm())) diff --git a/cli/command/stack/cmd.go b/cli/command/stack/cmd.go index 851ac13c4af8..1570080d1eb1 100644 --- a/cli/command/stack/cmd.go +++ b/cli/command/stack/cmd.go @@ -3,13 +3,10 @@ package stack import ( "errors" "fmt" - "io" "strings" "github.com/docker/cli/cli" "github.com/docker/cli/cli/command" - cliconfig "github.com/docker/cli/cli/config" - "github.com/docker/cli/cli/config/configfile" "github.com/spf13/cobra" "github.com/spf13/pflag" ) @@ -28,11 +25,7 @@ func NewStackCommand(dockerCli command.Cli) *cobra.Command { Short: "Manage Docker stacks", Args: cli.NoArgs, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { - configFile := dockerCli.ConfigFile() - if configFile == nil { - configFile = cliconfig.LoadDefaultConfigFile(dockerCli.Err()) - } - orchestrator, err := getOrchestrator(configFile, cmd, dockerCli.Err()) + orchestrator, err := getOrchestrator(dockerCli, cmd) if err != nil { return err } @@ -81,12 +74,12 @@ func NewTopLevelDeployCommand(dockerCli command.Cli) *cobra.Command { return cmd } -func getOrchestrator(config *configfile.ConfigFile, cmd *cobra.Command, stderr io.Writer) (command.Orchestrator, error) { +func getOrchestrator(dockerCli command.Cli, cmd *cobra.Command) (command.Orchestrator, error) { var orchestratorFlag string if o, err := cmd.Flags().GetString("orchestrator"); err == nil { orchestratorFlag = o } - return command.GetStackOrchestrator(orchestratorFlag, config.StackOrchestrator, stderr) + return dockerCli.StackOrchestrator(orchestratorFlag) } func hideOrchestrationFlags(cmd *cobra.Command, orchestrator command.Orchestrator) { diff --git a/cli/command/stack/kubernetes/cli.go b/cli/command/stack/kubernetes/cli.go index f98b4c4f3a67..a531846809cc 100644 --- a/cli/command/stack/kubernetes/cli.go +++ b/cli/command/stack/kubernetes/cli.go @@ -7,12 +7,14 @@ import ( "os" "github.com/docker/cli/cli/command" + kubecontext "github.com/docker/cli/cli/context/kubernetes" kubernetes "github.com/docker/compose-on-kubernetes/api" cliv1beta1 "github.com/docker/compose-on-kubernetes/api/client/clientset/typed/compose/v1beta1" "github.com/pkg/errors" flag "github.com/spf13/pflag" kubeclient "k8s.io/client-go/kubernetes" restclient "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" ) // KubeCli holds kubernetes specifics (client, namespace) with the command.Cli @@ -55,7 +57,18 @@ func WrapCli(dockerCli command.Cli, opts Options) (*KubeCli, error) { cli := &KubeCli{ Cli: dockerCli, } - clientConfig := kubernetes.NewKubernetesConfig(opts.Config) + var ( + clientConfig clientcmd.ClientConfig + err error + ) + if dockerCli.CurrentContext() == "" { + clientConfig = kubernetes.NewKubernetesConfig(opts.Config) + } else { + clientConfig, err = kubecontext.ConfigFromContext(dockerCli.CurrentContext(), dockerCli.ContextStore()) + } + if err != nil { + return nil, err + } cli.kubeNamespace = opts.Namespace if opts.Namespace == "" { diff --git a/cli/command/system/version.go b/cli/command/system/version.go index 97c50f1bdc57..19407a14261d 100644 --- a/cli/command/system/version.go +++ b/cli/command/system/version.go @@ -11,6 +11,7 @@ import ( "github.com/docker/cli/cli" "github.com/docker/cli/cli/command" + kubecontext "github.com/docker/cli/cli/context/kubernetes" "github.com/docker/cli/templates" kubernetes "github.com/docker/compose-on-kubernetes/api" "github.com/docker/docker/api/types" @@ -18,6 +19,7 @@ import ( "github.com/sirupsen/logrus" "github.com/spf13/cobra" kubernetesClient "k8s.io/client-go/kubernetes" + "k8s.io/client-go/tools/clientcmd" ) var versionTemplate = `{{with .Client -}} @@ -126,7 +128,7 @@ func runVersion(dockerCli command.Cli, opts *versionOptions) error { return cli.StatusError{StatusCode: 64, Status: err.Error()} } - orchestrator, err := command.GetStackOrchestrator("", dockerCli.ConfigFile().StackOrchestrator, dockerCli.Err()) + orchestrator, err := dockerCli.StackOrchestrator("") if err != nil { return cli.StatusError{StatusCode: 64, Status: err.Error()} } @@ -151,7 +153,7 @@ func runVersion(dockerCli command.Cli, opts *versionOptions) error { vd.Server = &sv var kubeVersion *kubernetesVersion if orchestrator.HasKubernetes() { - kubeVersion = getKubernetesVersion(opts.kubeConfig) + kubeVersion = getKubernetesVersion(dockerCli, opts.kubeConfig) } foundEngine := false foundKubernetes := false @@ -230,17 +232,29 @@ func getDetailsOrder(v types.ComponentVersion) []string { return out } -func getKubernetesVersion(kubeConfig string) *kubernetesVersion { +func getKubernetesVersion(dockerCli command.Cli, kubeConfig string) *kubernetesVersion { version := kubernetesVersion{ Kubernetes: "Unknown", StackAPI: "Unknown", } - clientConfig := kubernetes.NewKubernetesConfig(kubeConfig) - config, err := clientConfig.ClientConfig() + var ( + clientConfig clientcmd.ClientConfig + err error + ) + if dockerCli.CurrentContext() == command.ContextDockerHost { + clientConfig = kubernetes.NewKubernetesConfig(kubeConfig) + } else { + clientConfig, err = kubecontext.ConfigFromContext(dockerCli.CurrentContext(), dockerCli.ContextStore()) + } if err != nil { logrus.Debugf("failed to get Kubernetes configuration: %s", err) return &version } + config, err := clientConfig.ClientConfig() + if err != nil { + logrus.Debugf("failed to get Kubernetes client config: %s", err) + return &version + } kubeClient, err := kubernetesClient.NewForConfig(config) if err != nil { logrus.Debugf("failed to get Kubernetes client: %s", err) diff --git a/cli/config/config.go b/cli/config/config.go index 9161921a2d4a..64f8d3b49c67 100644 --- a/cli/config/config.go +++ b/cli/config/config.go @@ -18,6 +18,7 @@ const ( ConfigFileName = "config.json" configFileDir = ".docker" oldConfigfile = ".dockercfg" + contextsDir = "contexts" ) var ( @@ -35,6 +36,11 @@ func Dir() string { return configDir } +// ContextStoreDir returns the directory the docker contexts are stored in +func ContextStoreDir() string { + return filepath.Join(Dir(), contextsDir) +} + // SetDir sets the directory the configuration file is stored in func SetDir(dir string) { configDir = dir diff --git a/cli/config/configfile/file.go b/cli/config/configfile/file.go index 7fa9b734b949..d815570362a8 100644 --- a/cli/config/configfile/file.go +++ b/cli/config/configfile/file.go @@ -48,6 +48,7 @@ type ConfigFile struct { Experimental string `json:"experimental,omitempty"` StackOrchestrator string `json:"stackOrchestrator,omitempty"` Kubernetes *KubernetesConfig `json:"kubernetes,omitempty"` + CurrentContext string `json:"currentContext,omitempty"` } // ProxyConfig contains proxy configuration settings diff --git a/cli/context/docker/constants.go b/cli/context/docker/constants.go new file mode 100644 index 000000000000..1db5556d5ff2 --- /dev/null +++ b/cli/context/docker/constants.go @@ -0,0 +1,6 @@ +package docker + +const ( + // DockerEndpoint is the name of the docker endpoint in a stored context + DockerEndpoint = "docker" +) diff --git a/cli/context/docker/load.go b/cli/context/docker/load.go new file mode 100644 index 000000000000..4e0316a2383f --- /dev/null +++ b/cli/context/docker/load.go @@ -0,0 +1,172 @@ +package docker + +import ( + "crypto/tls" + "crypto/x509" + "encoding/pem" + "fmt" + "net" + "net/http" + "os" + "time" + + "github.com/docker/cli/cli/connhelper" + "github.com/docker/cli/cli/context" + "github.com/docker/cli/cli/context/store" + "github.com/docker/docker/client" + "github.com/docker/go-connections/tlsconfig" + "github.com/pkg/errors" +) + +// EndpointMeta is a typed wrapper around a context-store generic endpoint describing +// a Docker Engine endpoint, without its tls config +type EndpointMeta struct { + context.EndpointMetaBase + APIVersion string `json:"api_version,omitempty"` +} + +// Endpoint is a typed wrapper around a context-store generic endpoint describing +// a Docker Engine endpoint, with its tls data +type Endpoint struct { + EndpointMeta + TLSData *context.TLSData + TLSPassword string +} + +// WithTLSData loads TLS materials for the endpoint +func (c *EndpointMeta) WithTLSData(s store.Store, contextName string) (Endpoint, error) { + tlsData, err := context.LoadTLSData(s, contextName, DockerEndpoint) + if err != nil { + return Endpoint{}, err + } + return Endpoint{ + EndpointMeta: *c, + TLSData: tlsData, + }, nil +} + +// tlsConfig extracts a context docker endpoint TLS config +func (c *Endpoint) tlsConfig() (*tls.Config, error) { + if c.TLSData == nil && !c.SkipTLSVerify { + // there is no specific tls config + return nil, nil + } + var tlsOpts []func(*tls.Config) + if c.TLSData != nil && c.TLSData.CA != nil { + certPool := x509.NewCertPool() + if !certPool.AppendCertsFromPEM(c.TLSData.CA) { + return nil, errors.New("failed to retrieve context tls info: ca.pem seems invalid") + } + tlsOpts = append(tlsOpts, func(cfg *tls.Config) { + cfg.RootCAs = certPool + }) + } + if c.TLSData != nil && c.TLSData.Key != nil && c.TLSData.Cert != nil { + keyBytes := c.TLSData.Key + pemBlock, _ := pem.Decode(keyBytes) + if pemBlock == nil { + return nil, fmt.Errorf("no valid private key found") + } + + var err error + if x509.IsEncryptedPEMBlock(pemBlock) { + keyBytes, err = x509.DecryptPEMBlock(pemBlock, []byte(c.TLSPassword)) + if err != nil { + return nil, errors.Wrap(err, "private key is encrypted, but could not decrypt it") + } + keyBytes = pem.EncodeToMemory(&pem.Block{Type: pemBlock.Type, Bytes: keyBytes}) + } + + x509cert, err := tls.X509KeyPair(c.TLSData.Cert, keyBytes) + if err != nil { + return nil, errors.Wrap(err, "failed to retrieve context tls info") + } + tlsOpts = append(tlsOpts, func(cfg *tls.Config) { + cfg.Certificates = []tls.Certificate{x509cert} + }) + } + if c.SkipTLSVerify { + tlsOpts = append(tlsOpts, func(cfg *tls.Config) { + cfg.InsecureSkipVerify = true + }) + } + return tlsconfig.ClientDefault(tlsOpts...), nil +} + +// ClientOpts returns a slice of Client options to configure an API client with this endpoint +func (c *Endpoint) ClientOpts() ([]func(*client.Client) error, error) { + var result []func(*client.Client) error + if c.Host != "" { + helper, err := connhelper.GetConnectionHelper(c.Host) + if err != nil { + return nil, err + } + if helper == nil { + tlsConfig, err := c.tlsConfig() + if err != nil { + return nil, err + } + result = append(result, + client.WithHost(c.Host), + withHTTPClient(tlsConfig), + ) + + } else { + httpClient := &http.Client{ + // No tls + // No proxy + Transport: &http.Transport{ + DialContext: helper.Dialer, + }, + } + result = append(result, + client.WithHTTPClient(httpClient), + client.WithHost(helper.Host), + client.WithDialContext(helper.Dialer), + ) + } + } + + version := os.Getenv("DOCKER_API_VERSION") + if version == "" { + version = c.APIVersion + } + if version != "" { + result = append(result, client.WithVersion(version)) + } + return result, nil +} + +func withHTTPClient(tlsConfig *tls.Config) func(*client.Client) error { + return func(c *client.Client) error { + if tlsConfig == nil { + // Use the default HTTPClient + return nil + } + + httpClient := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: tlsConfig, + DialContext: (&net.Dialer{ + KeepAlive: 30 * time.Second, + Timeout: 30 * time.Second, + }).DialContext, + }, + CheckRedirect: client.CheckRedirect, + } + return client.WithHTTPClient(httpClient)(c) + } +} + +// EndpointFromContext parses a context docker endpoint metadata into a typed EndpointMeta structure +func EndpointFromContext(metadata store.ContextMetadata) (EndpointMeta, error) { + ep, ok := metadata.Endpoints[DockerEndpoint] + if !ok { + return EndpointMeta{}, errors.New("cannot find docker endpoint in context") + } + typed, ok := ep.(EndpointMeta) + if !ok { + return EndpointMeta{}, errors.Errorf("endpoint %q is not of type EndpointMeta", DockerEndpoint) + } + return typed, nil +} diff --git a/cli/context/endpoint.go b/cli/context/endpoint.go new file mode 100644 index 000000000000..806a8524ef85 --- /dev/null +++ b/cli/context/endpoint.go @@ -0,0 +1,7 @@ +package context + +// EndpointMetaBase contains fields we expect to be common for most context endpoints +type EndpointMetaBase struct { + Host string `json:"host,omitempty"` + SkipTLSVerify bool `json:"skip_tls_verify"` +} diff --git a/cli/context/kubernetes/constants.go b/cli/context/kubernetes/constants.go new file mode 100644 index 000000000000..8998de989af0 --- /dev/null +++ b/cli/context/kubernetes/constants.go @@ -0,0 +1,6 @@ +package kubernetes + +const ( + // KubernetesEndpoint is the kubernetes endpoint name in a stored context + KubernetesEndpoint = "kubernetes" +) diff --git a/cli/context/kubernetes/endpoint_test.go b/cli/context/kubernetes/endpoint_test.go new file mode 100644 index 000000000000..57b308b8f739 --- /dev/null +++ b/cli/context/kubernetes/endpoint_test.go @@ -0,0 +1,183 @@ +package kubernetes + +import ( + "io/ioutil" + "os" + "testing" + + "github.com/docker/cli/cli/context" + "github.com/docker/cli/cli/context/store" + "gotest.tools/assert" + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" +) + +func testEndpoint(server, defaultNamespace string, ca, cert, key []byte, skipTLSVerify bool) *Endpoint { + var tlsData *context.TLSData + if ca != nil || cert != nil || key != nil { + tlsData = &context.TLSData{ + CA: ca, + Cert: cert, + Key: key, + } + } + return &Endpoint{ + EndpointMeta: EndpointMeta{ + EndpointMetaBase: context.EndpointMetaBase{ + Host: server, + SkipTLSVerify: skipTLSVerify, + }, + DefaultNamespace: defaultNamespace, + }, + TLSData: tlsData, + } +} + +var testStoreCfg = store.NewConfig( + func() interface{} { + return &map[string]interface{}{} + }, + store.EndpointTypeGetter(KubernetesEndpoint, func() interface{} { return &EndpointMeta{} }), +) + +func TestSaveLoadContexts(t *testing.T) { + storeDir, err := ioutil.TempDir("", "test-load-save-k8-context") + assert.NilError(t, err) + defer os.RemoveAll(storeDir) + store := store.New(storeDir, testStoreCfg) + assert.NilError(t, testEndpoint("https://test", "test", nil, nil, nil, false).Save(store, "raw-notls")) + assert.NilError(t, testEndpoint("https://test", "test", nil, nil, nil, true).Save(store, "raw-notls-skip")) + assert.NilError(t, testEndpoint("https://test", "test", []byte("ca"), []byte("cert"), []byte("key"), true).Save(store, "raw-tls")) + + kcFile, err := ioutil.TempFile(os.TempDir(), "test-load-save-k8-context") + assert.NilError(t, err) + defer os.Remove(kcFile.Name()) + defer kcFile.Close() + cfg := clientcmdapi.NewConfig() + cfg.AuthInfos["user"] = clientcmdapi.NewAuthInfo() + cfg.Contexts["context1"] = clientcmdapi.NewContext() + cfg.Clusters["cluster1"] = clientcmdapi.NewCluster() + cfg.Contexts["context2"] = clientcmdapi.NewContext() + cfg.Clusters["cluster2"] = clientcmdapi.NewCluster() + cfg.AuthInfos["user"].ClientCertificateData = []byte("cert") + cfg.AuthInfos["user"].ClientKeyData = []byte("key") + cfg.Clusters["cluster1"].Server = "https://server1" + cfg.Clusters["cluster1"].InsecureSkipTLSVerify = true + cfg.Clusters["cluster2"].Server = "https://server2" + cfg.Clusters["cluster2"].CertificateAuthorityData = []byte("ca") + cfg.Contexts["context1"].AuthInfo = "user" + cfg.Contexts["context1"].Cluster = "cluster1" + cfg.Contexts["context1"].Namespace = "namespace1" + cfg.Contexts["context2"].AuthInfo = "user" + cfg.Contexts["context2"].Cluster = "cluster2" + cfg.Contexts["context2"].Namespace = "namespace2" + cfg.CurrentContext = "context1" + cfgData, err := clientcmd.Write(*cfg) + assert.NilError(t, err) + _, err = kcFile.Write(cfgData) + assert.NilError(t, err) + kcFile.Close() + + epDefault, err := FromKubeConfig(kcFile.Name(), "", "") + assert.NilError(t, err) + epContext2, err := FromKubeConfig(kcFile.Name(), "context2", "namespace-override") + assert.NilError(t, err) + assert.NilError(t, epDefault.Save(store, "embed-default-context")) + assert.NilError(t, epContext2.Save(store, "embed-context2")) + + rawNoTLSMeta, err := store.GetContextMetadata("raw-notls") + assert.NilError(t, err) + rawNoTLSSkipMeta, err := store.GetContextMetadata("raw-notls-skip") + assert.NilError(t, err) + rawTLSMeta, err := store.GetContextMetadata("raw-tls") + assert.NilError(t, err) + embededDefaultMeta, err := store.GetContextMetadata("embed-default-context") + assert.NilError(t, err) + embededContext2Meta, err := store.GetContextMetadata("embed-context2") + assert.NilError(t, err) + + rawNoTLS := EndpointFromContext(rawNoTLSMeta) + rawNoTLSSkip := EndpointFromContext(rawNoTLSSkipMeta) + rawTLS := EndpointFromContext(rawTLSMeta) + embededDefault := EndpointFromContext(embededDefaultMeta) + embededContext2 := EndpointFromContext(embededContext2Meta) + + rawNoTLSEP, err := rawNoTLS.WithTLSData(store, "raw-notls") + assert.NilError(t, err) + checkClientConfig(t, store, rawNoTLSEP, "https://test", "test", nil, nil, nil, false) + rawNoTLSSkipEP, err := rawNoTLSSkip.WithTLSData(store, "raw-notls-skip") + assert.NilError(t, err) + checkClientConfig(t, store, rawNoTLSSkipEP, "https://test", "test", nil, nil, nil, true) + rawTLSEP, err := rawTLS.WithTLSData(store, "raw-tls") + assert.NilError(t, err) + checkClientConfig(t, store, rawTLSEP, "https://test", "test", []byte("ca"), []byte("cert"), []byte("key"), true) + embededDefaultEP, err := embededDefault.WithTLSData(store, "embed-default-context") + assert.NilError(t, err) + checkClientConfig(t, store, embededDefaultEP, "https://server1", "namespace1", nil, []byte("cert"), []byte("key"), true) + embededContext2EP, err := embededContext2.WithTLSData(store, "embed-context2") + assert.NilError(t, err) + checkClientConfig(t, store, embededContext2EP, "https://server2", "namespace-override", []byte("ca"), []byte("cert"), []byte("key"), false) +} + +func checkClientConfig(t *testing.T, s store.Store, ep Endpoint, server, namespace string, ca, cert, key []byte, skipTLSVerify bool) { + config := ep.KubernetesConfig() + cfg, err := config.ClientConfig() + assert.NilError(t, err) + ns, _, _ := config.Namespace() + assert.Equal(t, server, cfg.Host) + assert.Equal(t, namespace, ns) + assert.DeepEqual(t, ca, cfg.CAData) + assert.DeepEqual(t, cert, cfg.CertData) + assert.DeepEqual(t, key, cfg.KeyData) + assert.Equal(t, skipTLSVerify, cfg.Insecure) +} + +func TestSaveLoadGKEConfig(t *testing.T) { + storeDir, err := ioutil.TempDir("", t.Name()) + assert.NilError(t, err) + defer os.RemoveAll(storeDir) + store := store.New(storeDir, testStoreCfg) + cfg, err := clientcmd.LoadFromFile("testdata/gke-kubeconfig") + assert.NilError(t, err) + clientCfg := clientcmd.NewDefaultClientConfig(*cfg, &clientcmd.ConfigOverrides{}) + expectedCfg, err := clientCfg.ClientConfig() + assert.NilError(t, err) + ep, err := FromKubeConfig("testdata/gke-kubeconfig", "", "") + assert.NilError(t, err) + assert.NilError(t, ep.Save(store, "gke-context")) + persistedMetadata, err := store.GetContextMetadata("gke-context") + assert.NilError(t, err) + persistedEPMeta := EndpointFromContext(persistedMetadata) + assert.Check(t, persistedEPMeta != nil) + persistedEP, err := persistedEPMeta.WithTLSData(store, "gke-context") + assert.NilError(t, err) + persistedCfg := persistedEP.KubernetesConfig() + actualCfg, err := persistedCfg.ClientConfig() + assert.NilError(t, err) + assert.DeepEqual(t, expectedCfg.AuthProvider, actualCfg.AuthProvider) +} + +func TestSaveLoadEKSConfig(t *testing.T) { + storeDir, err := ioutil.TempDir("", t.Name()) + assert.NilError(t, err) + defer os.RemoveAll(storeDir) + store := store.New(storeDir, testStoreCfg) + cfg, err := clientcmd.LoadFromFile("testdata/eks-kubeconfig") + assert.NilError(t, err) + clientCfg := clientcmd.NewDefaultClientConfig(*cfg, &clientcmd.ConfigOverrides{}) + expectedCfg, err := clientCfg.ClientConfig() + assert.NilError(t, err) + ep, err := FromKubeConfig("testdata/eks-kubeconfig", "", "") + assert.NilError(t, err) + assert.NilError(t, ep.Save(store, "eks-context")) + persistedMetadata, err := store.GetContextMetadata("eks-context") + assert.NilError(t, err) + persistedEPMeta := EndpointFromContext(persistedMetadata) + assert.Check(t, persistedEPMeta != nil) + persistedEP, err := persistedEPMeta.WithTLSData(store, "eks-context") + assert.NilError(t, err) + persistedCfg := persistedEP.KubernetesConfig() + actualCfg, err := persistedCfg.ClientConfig() + assert.NilError(t, err) + assert.DeepEqual(t, expectedCfg.ExecProvider, actualCfg.ExecProvider) +} diff --git a/cli/context/kubernetes/load.go b/cli/context/kubernetes/load.go new file mode 100644 index 000000000000..1898f5742254 --- /dev/null +++ b/cli/context/kubernetes/load.go @@ -0,0 +1,95 @@ +package kubernetes + +import ( + "github.com/docker/cli/cli/context" + "github.com/docker/cli/cli/context/store" + "github.com/docker/cli/kubernetes" + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" +) + +// EndpointMeta is a typed wrapper around a context-store generic endpoint describing +// a Kubernetes endpoint, without TLS data +type EndpointMeta struct { + context.EndpointMetaBase + DefaultNamespace string `json:"default_namespace,omitempty"` + AuthProvider *clientcmdapi.AuthProviderConfig `json:"auth_provider,omitempty"` + Exec *clientcmdapi.ExecConfig `json:"exec,omitempty"` +} + +// Endpoint is a typed wrapper around a context-store generic endpoint describing +// a Kubernetes endpoint, with TLS data +type Endpoint struct { + EndpointMeta + TLSData *context.TLSData +} + +// WithTLSData loads TLS materials for the endpoint +func (c *EndpointMeta) WithTLSData(s store.Store, contextName string) (Endpoint, error) { + tlsData, err := context.LoadTLSData(s, contextName, KubernetesEndpoint) + if err != nil { + return Endpoint{}, err + } + return Endpoint{ + EndpointMeta: *c, + TLSData: tlsData, + }, nil +} + +// KubernetesConfig creates the kubernetes client config from the endpoint +func (c *Endpoint) KubernetesConfig() clientcmd.ClientConfig { + cfg := clientcmdapi.NewConfig() + cluster := clientcmdapi.NewCluster() + cluster.Server = c.Host + cluster.InsecureSkipTLSVerify = c.SkipTLSVerify + authInfo := clientcmdapi.NewAuthInfo() + if c.TLSData != nil { + cluster.CertificateAuthorityData = c.TLSData.CA + authInfo.ClientCertificateData = c.TLSData.Cert + authInfo.ClientKeyData = c.TLSData.Key + } + authInfo.AuthProvider = c.AuthProvider + authInfo.Exec = c.Exec + cfg.Clusters["cluster"] = cluster + cfg.AuthInfos["authInfo"] = authInfo + ctx := clientcmdapi.NewContext() + ctx.AuthInfo = "authInfo" + ctx.Cluster = "cluster" + ctx.Namespace = c.DefaultNamespace + cfg.Contexts["context"] = ctx + cfg.CurrentContext = "context" + return clientcmd.NewDefaultClientConfig(*cfg, &clientcmd.ConfigOverrides{}) +} + +// EndpointFromContext extracts kubernetes endpoint info from current context +func EndpointFromContext(metadata store.ContextMetadata) *EndpointMeta { + ep, ok := metadata.Endpoints[KubernetesEndpoint] + if !ok { + return nil + } + typed, ok := ep.(EndpointMeta) + if !ok { + return nil + } + return &typed +} + +// ConfigFromContext resolves a kubernetes client config for the specified context. +// If kubeconfigOverride is specified, use this config file instead of the context defaults.ConfigFromContext +// if command.ContextDockerHost is specified as the context name, fallsback to the default user's kubeconfig file +func ConfigFromContext(name string, s store.Store) (clientcmd.ClientConfig, error) { + ctxMeta, err := s.GetContextMetadata(name) + if err != nil { + return nil, err + } + epMeta := EndpointFromContext(ctxMeta) + if epMeta != nil { + ep, err := epMeta.WithTLSData(s, name) + if err != nil { + return nil, err + } + return ep.KubernetesConfig(), nil + } + // context has no kubernetes endpoint + return kubernetes.NewKubernetesConfig(""), nil +} diff --git a/cli/context/kubernetes/save.go b/cli/context/kubernetes/save.go new file mode 100644 index 000000000000..35646bc57ca5 --- /dev/null +++ b/cli/context/kubernetes/save.go @@ -0,0 +1,79 @@ +package kubernetes + +import ( + "io/ioutil" + + "github.com/docker/cli/cli/context" + "github.com/docker/cli/cli/context/store" + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" +) + +// FromKubeConfig creates a Kubernetes endpoint from a Kubeconfig file +func FromKubeConfig(kubeconfig, kubeContext, namespaceOverride string) (Endpoint, error) { + cfg := clientcmd.NewNonInteractiveDeferredLoadingClientConfig( + &clientcmd.ClientConfigLoadingRules{ExplicitPath: kubeconfig}, + &clientcmd.ConfigOverrides{CurrentContext: kubeContext, Context: clientcmdapi.Context{Namespace: namespaceOverride}}) + ns, _, err := cfg.Namespace() + if err != nil { + return Endpoint{}, err + } + clientcfg, err := cfg.ClientConfig() + if err != nil { + return Endpoint{}, err + } + var ca, key, cert []byte + if ca, err = readFileOrDefault(clientcfg.CAFile, clientcfg.CAData); err != nil { + return Endpoint{}, err + } + if key, err = readFileOrDefault(clientcfg.KeyFile, clientcfg.KeyData); err != nil { + return Endpoint{}, err + } + if cert, err = readFileOrDefault(clientcfg.CertFile, clientcfg.CertData); err != nil { + return Endpoint{}, err + } + var tlsData *context.TLSData + if ca != nil || cert != nil || key != nil { + tlsData = &context.TLSData{ + CA: ca, + Cert: cert, + Key: key, + } + } + return Endpoint{ + EndpointMeta: EndpointMeta{ + EndpointMetaBase: context.EndpointMetaBase{ + Host: clientcfg.Host, + SkipTLSVerify: clientcfg.Insecure, + }, + DefaultNamespace: ns, + AuthProvider: clientcfg.AuthProvider, + Exec: clientcfg.ExecProvider, + }, + TLSData: tlsData, + }, nil +} + +func readFileOrDefault(path string, defaultValue []byte) ([]byte, error) { + if path != "" { + return ioutil.ReadFile(path) + } + return defaultValue, nil +} + +// Save the endpoint metadata and TLS bundle in the context store +func (ep *Endpoint) Save(s store.Store, contextName string) error { + tlsData := ep.TLSData.ToStoreTLSData() + existingContext, err := s.GetContextMetadata(contextName) + if err != nil && !store.IsErrContextDoesNotExist(err) { + return err + } + if existingContext.Endpoints == nil { + existingContext.Endpoints = make(map[string]interface{}) + } + existingContext.Endpoints[KubernetesEndpoint] = ep.EndpointMeta + if err := s.CreateOrUpdateContext(contextName, existingContext); err != nil { + return err + } + return s.ResetContextEndpointTLSMaterial(contextName, KubernetesEndpoint, tlsData) +} diff --git a/cli/context/kubernetes/testdata/eks-kubeconfig b/cli/context/kubernetes/testdata/eks-kubeconfig new file mode 100644 index 000000000000..deed186a8ab4 --- /dev/null +++ b/cli/context/kubernetes/testdata/eks-kubeconfig @@ -0,0 +1,23 @@ + apiVersion: v1 + clusters: + - cluster: + server: https://some-server + name: kubernetes + contexts: + - context: + cluster: kubernetes + user: aws + name: aws + current-context: aws + kind: Config + preferences: {} + users: + - name: aws + user: + exec: + apiVersion: client.authentication.k8s.io/v1alpha1 + command: heptio-authenticator-aws + args: + - "token" + - "-i" + - "eks-cf" \ No newline at end of file diff --git a/cli/context/kubernetes/testdata/gke-kubeconfig b/cli/context/kubernetes/testdata/gke-kubeconfig new file mode 100644 index 000000000000..5a6384cbae9b --- /dev/null +++ b/cli/context/kubernetes/testdata/gke-kubeconfig @@ -0,0 +1,23 @@ +apiVersion: v1 +clusters: +- cluster: + server: https://some-server + name: gke_sample +contexts: +- context: + cluster: gke_sample + user: gke_sample + name: gke_sample +current-context: gke_sample +kind: Config +preferences: {} +users: +- name: gke_sample + user: + auth-provider: + config: + cmd-args: config config-helper --format=json + cmd-path: /google/google-cloud-sdk/bin/gcloud + expiry-key: '{.credential.token_expiry}' + token-key: '{.credential.access_token}' + name: gcp diff --git a/cli/context/store/doc.go b/cli/context/store/doc.go new file mode 100644 index 000000000000..e432dae3b9b9 --- /dev/null +++ b/cli/context/store/doc.go @@ -0,0 +1,21 @@ +// Package store provides a generic way to store credentials to connect to virtually any kind of remote system. +// The term `context` comes from the similar feature in Kubernetes kubectl config files. +// +// Conceptually, a context is a set of metadata and TLS data, that can be used to connect to various endpoints +// of a remote system. TLS data and metadata are stored separately, so that in the future, we will be able to store sensitive +// information in a more secure way, depending on the os we are running on (e.g.: on Windows we could use the user Certificate Store, on Mac OS the user Keychain...). +// +// Current implementation is purely file based with the following structure: +// ${CONTEXT_ROOT} +// - meta/ +// - context1/meta.json: contains context medata (key/value pairs) as well as a list of endpoints (themselves containing key/value pair metadata) +// - contexts/can/also/be/folded/like/this/meta.json: same as context1, but for a context named `contexts/can/also/be/folded/like/this` +// - tls/ +// - context1/endpoint1/: directory containing TLS data for the endpoint1 in context1 +// +// The context store itself has absolutely no knowledge about what a docker or a kubernetes endpoint should contain in term of metadata or TLS config. +// Client code is responsible for generating and parsing endpoint metadata and TLS files. +// The multi-endpoints approach of this package allows to combine many different endpoints in the same "context" (e.g., the Docker CLI +// is able for a single context to define both a docker endpoint and a Kubernetes endpoint for the same cluster, and also specify which +// orchestrator to use by default when deploying a compose stack on this cluster). +package store diff --git a/cli/context/store/metadata_test.go b/cli/context/store/metadata_test.go new file mode 100644 index 000000000000..f8121ec20cf8 --- /dev/null +++ b/cli/context/store/metadata_test.go @@ -0,0 +1,142 @@ +package store + +import ( + "io/ioutil" + "os" + "path/filepath" + "testing" + + "gotest.tools/assert" + "gotest.tools/assert/cmp" +) + +var testMetadata = ContextMetadata{ + Endpoints: map[string]interface{}{ + "ep1": endpoint{Foo: "bar"}, + }, + Metadata: context{Bar: "baz"}, +} + +func TestMetadataGetNotExisting(t *testing.T) { + testDir, err := ioutil.TempDir("", t.Name()) + assert.NilError(t, err) + defer os.RemoveAll(testDir) + testee := metadataStore{root: testDir, config: testCfg} + _, err = testee.get("noexist") + assert.Assert(t, IsErrContextDoesNotExist(err)) +} + +func TestMetadataCreateGetRemove(t *testing.T) { + testDir, err := ioutil.TempDir("", t.Name()) + assert.NilError(t, err) + defer os.RemoveAll(testDir) + testee := metadataStore{root: testDir, config: testCfg} + expected2 := ContextMetadata{ + Endpoints: map[string]interface{}{ + "ep1": endpoint{Foo: "baz"}, + "ep2": endpoint{Foo: "bee"}, + }, + Metadata: context{Bar: "foo"}, + } + err = testee.createOrUpdate("test-context", testMetadata) + assert.NilError(t, err) + // create a new instance to check it does not depend on some sort of state + testee = metadataStore{root: testDir, config: testCfg} + meta, err := testee.get("test-context") + assert.NilError(t, err) + assert.DeepEqual(t, meta, testMetadata) + + // update + + err = testee.createOrUpdate("test-context", expected2) + assert.NilError(t, err) + meta, err = testee.get("test-context") + assert.NilError(t, err) + assert.DeepEqual(t, meta, expected2) + + assert.NilError(t, testee.remove("test-context")) + assert.NilError(t, testee.remove("test-context")) // support duplicate remove + _, err = testee.get("test-context") + assert.Assert(t, IsErrContextDoesNotExist(err)) +} + +func TestMetadataRespectJsonAnnotation(t *testing.T) { + testDir, err := ioutil.TempDir("", t.Name()) + assert.NilError(t, err) + defer os.RemoveAll(testDir) + testee := metadataStore{root: testDir, config: testCfg} + assert.NilError(t, testee.createOrUpdate("test", testMetadata)) + bytes, err := ioutil.ReadFile(filepath.Join(testDir, "test", "meta.json")) + assert.NilError(t, err) + assert.Assert(t, cmp.Contains(string(bytes), "a_very_recognizable_field_name")) + assert.Assert(t, cmp.Contains(string(bytes), "another_very_recognizable_field_name")) +} + +func TestMetadataList(t *testing.T) { + testDir, err := ioutil.TempDir("", t.Name()) + assert.NilError(t, err) + defer os.RemoveAll(testDir) + testee := metadataStore{root: testDir, config: testCfg} + wholeData := map[string]ContextMetadata{ + "simple": testMetadata, + "simple2": testMetadata, + "nested/context": testMetadata, + "nestedwith-parent/context": testMetadata, + "nestedwith-parent": testMetadata, + } + + for k, s := range wholeData { + err = testee.createOrUpdate(k, s) + assert.NilError(t, err) + } + + data, err := testee.list() + assert.NilError(t, err) + assert.DeepEqual(t, data, wholeData) +} + +func TestEmptyConfig(t *testing.T) { + testDir, err := ioutil.TempDir("", t.Name()) + assert.NilError(t, err) + defer os.RemoveAll(testDir) + testee := metadataStore{root: testDir} + wholeData := map[string]ContextMetadata{ + "simple": testMetadata, + "simple2": testMetadata, + "nested/context": testMetadata, + "nestedwith-parent/context": testMetadata, + "nestedwith-parent": testMetadata, + } + + for k, s := range wholeData { + err = testee.createOrUpdate(k, s) + assert.NilError(t, err) + } + + data, err := testee.list() + assert.NilError(t, err) + assert.Equal(t, len(data), len(wholeData)) +} + +type contextWithEmbedding struct { + embeddedStruct +} +type embeddedStruct struct { + Val string +} + +func TestWithEmbedding(t *testing.T) { + testDir, err := ioutil.TempDir("", t.Name()) + assert.NilError(t, err) + defer os.RemoveAll(testDir) + testee := metadataStore{root: testDir, config: NewConfig(func() interface{} { return &contextWithEmbedding{} })} + testCtxMeta := contextWithEmbedding{ + embeddedStruct: embeddedStruct{ + Val: "Hello", + }, + } + assert.NilError(t, testee.createOrUpdate("test", ContextMetadata{Metadata: testCtxMeta})) + res, err := testee.get("test") + assert.NilError(t, err) + assert.Equal(t, testCtxMeta, res.Metadata) +} diff --git a/cli/context/store/metadatastore.go b/cli/context/store/metadatastore.go new file mode 100644 index 000000000000..178cf9e2887e --- /dev/null +++ b/cli/context/store/metadatastore.go @@ -0,0 +1,146 @@ +package store + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "os" + "path/filepath" + "reflect" +) + +const ( + metadataDir = "meta" + metaFile = "meta.json" +) + +type metadataStore struct { + root string + config Config +} + +func (s *metadataStore) contextDir(name string) string { + return filepath.Join(s.root, name) +} + +func (s *metadataStore) createOrUpdate(name string, meta ContextMetadata) error { + contextDir := s.contextDir(name) + if err := os.MkdirAll(contextDir, 0755); err != nil { + return err + } + bytes, err := json.Marshal(&meta) + if err != nil { + return err + } + return ioutil.WriteFile(filepath.Join(contextDir, metaFile), bytes, 0644) +} + +func parseTypedOrMap(payload []byte, getter TypeGetter) (interface{}, error) { + if len(payload) == 0 || string(payload) == "null" { + return nil, nil + } + if getter == nil { + var res map[string]interface{} + if err := json.Unmarshal(payload, &res); err != nil { + return nil, err + } + return res, nil + } + typed := getter() + if err := json.Unmarshal(payload, typed); err != nil { + return nil, err + } + return reflect.ValueOf(typed).Elem().Interface(), nil +} + +func (s *metadataStore) get(name string) (ContextMetadata, error) { + contextDir := s.contextDir(name) + bytes, err := ioutil.ReadFile(filepath.Join(contextDir, metaFile)) + if err != nil { + return ContextMetadata{}, convertContextDoesNotExist(name, err) + } + var untyped untypedContextMetadata + r := ContextMetadata{ + Endpoints: make(map[string]interface{}), + } + if err := json.Unmarshal(bytes, &untyped); err != nil { + return ContextMetadata{}, err + } + if r.Metadata, err = parseTypedOrMap(untyped.Metadata, s.config.contextType); err != nil { + return ContextMetadata{}, err + } + for k, v := range untyped.Endpoints { + if r.Endpoints[k], err = parseTypedOrMap(v, s.config.endpointTypes[k]); err != nil { + return ContextMetadata{}, err + } + } + return r, err +} + +func (s *metadataStore) remove(name string) error { + contextDir := s.contextDir(name) + return os.RemoveAll(contextDir) +} + +func (s *metadataStore) list() (map[string]ContextMetadata, error) { + ctxNames, err := listRecursivelyMetadataDirs(s.root) + if err != nil { + if os.IsNotExist(err) { + // store is empty, meta dir does not exist yet + // this should not be considered an error + return map[string]ContextMetadata{}, nil + } + return nil, err + } + res := make(map[string]ContextMetadata) + for _, name := range ctxNames { + res[name], err = s.get(name) + if err != nil { + return nil, err + } + } + return res, nil +} + +func isContextDir(path string) bool { + s, err := os.Stat(filepath.Join(path, metaFile)) + if err != nil { + return false + } + return !s.IsDir() +} + +func listRecursivelyMetadataDirs(root string) ([]string, error) { + fis, err := ioutil.ReadDir(root) + if err != nil { + return nil, err + } + var result []string + for _, fi := range fis { + if fi.IsDir() { + if isContextDir(filepath.Join(root, fi.Name())) { + result = append(result, fi.Name()) + } + subs, err := listRecursivelyMetadataDirs(filepath.Join(root, fi.Name())) + if err != nil { + return nil, err + } + for _, s := range subs { + result = append(result, fmt.Sprintf("%s/%s", fi.Name(), s)) + } + } + } + return result, nil +} + +func convertContextDoesNotExist(name string, err error) error { + if os.IsNotExist(err) { + return &contextDoesNotExistError{name: name} + } + return err +} + +type untypedContextMetadata struct { + Metadata json.RawMessage `json:"metadata,omitempty"` + Endpoints map[string]json.RawMessage `json:"endpoints,omitempty"` +} diff --git a/cli/context/store/store.go b/cli/context/store/store.go new file mode 100644 index 000000000000..9238a92a23fb --- /dev/null +++ b/cli/context/store/store.go @@ -0,0 +1,282 @@ +package store + +import ( + "archive/tar" + "encoding/json" + "errors" + "fmt" + "io" + "io/ioutil" + "path" + "path/filepath" + "strings" +) + +// Store provides a context store for easily remembering endpoints configuration +type Store interface { + ListContexts() (map[string]ContextMetadata, error) + CreateOrUpdateContext(name string, meta ContextMetadata) error + RemoveContext(name string) error + GetContextMetadata(name string) (ContextMetadata, error) + ResetContextTLSMaterial(name string, data *ContextTLSData) error + ResetContextEndpointTLSMaterial(contextName string, endpointName string, data *EndpointTLSData) error + ListContextTLSFiles(name string) (map[string]EndpointFiles, error) + GetContextTLSData(contextName, endpointName, fileName string) ([]byte, error) +} + +// ContextMetadata contains metadata about a context and its endpoints +type ContextMetadata struct { + Metadata interface{} `json:"metadata,omitempty"` + Endpoints map[string]interface{} `json:"endpoints,omitempty"` +} + +// EndpointTLSData represents tls data for a given endpoint +type EndpointTLSData struct { + Files map[string][]byte +} + +// ContextTLSData represents tls data for a whole context +type ContextTLSData struct { + Endpoints map[string]EndpointTLSData +} + +// New creates a store from a given directory. +// If the directory does not exist or is empty, initialize it +func New(dir string, cfg Config) Store { + metaRoot := filepath.Join(dir, metadataDir) + tlsRoot := filepath.Join(dir, tlsDir) + + return &store{ + meta: &metadataStore{ + root: metaRoot, + config: cfg, + }, + tls: &tlsStore{ + root: tlsRoot, + }, + } +} + +type store struct { + meta *metadataStore + tls *tlsStore +} + +func (s *store) ListContexts() (map[string]ContextMetadata, error) { + return s.meta.list() +} + +func (s *store) CreateOrUpdateContext(name string, meta ContextMetadata) error { + return s.meta.createOrUpdate(name, meta) +} + +func (s *store) RemoveContext(name string) error { + if err := s.meta.remove(name); err != nil { + return err + } + return s.tls.removeAllContextData(name) +} + +func (s *store) GetContextMetadata(name string) (ContextMetadata, error) { + return s.meta.get(name) +} + +func (s *store) ResetContextTLSMaterial(name string, data *ContextTLSData) error { + if err := s.tls.removeAllContextData(name); err != nil { + return err + } + if data == nil { + return nil + } + for ep, files := range data.Endpoints { + for fileName, data := range files.Files { + if err := s.tls.createOrUpdate(name, ep, fileName, data); err != nil { + return err + } + } + } + return nil +} + +func (s *store) ResetContextEndpointTLSMaterial(contextName string, endpointName string, data *EndpointTLSData) error { + if err := s.tls.removeAllEndpointData(contextName, endpointName); err != nil { + return err + } + if data == nil { + return nil + } + for fileName, data := range data.Files { + if err := s.tls.createOrUpdate(contextName, endpointName, fileName, data); err != nil { + return err + } + } + return nil +} + +func (s *store) ListContextTLSFiles(name string) (map[string]EndpointFiles, error) { + return s.tls.listContextData(name) +} + +func (s *store) GetContextTLSData(contextName, endpointName, fileName string) ([]byte, error) { + return s.tls.getData(contextName, endpointName, fileName) +} + +// Export exports an existing namespace into an opaque data stream +// This stream is actually a tarball containing context metadata and TLS materials, but it does +// not map 1:1 the layout of the context store (don't try to restore it manually without calling store.Import) +func Export(name string, s Store) io.ReadCloser { + reader, writer := io.Pipe() + go func() { + tw := tar.NewWriter(writer) + defer tw.Close() + defer writer.Close() + meta, err := s.GetContextMetadata(name) + if err != nil { + writer.CloseWithError(err) + return + } + metaBytes, err := json.Marshal(&meta) + if err != nil { + writer.CloseWithError(err) + return + } + if err = tw.WriteHeader(&tar.Header{ + Name: metaFile, + Mode: 0644, + Size: int64(len(metaBytes)), + }); err != nil { + writer.CloseWithError(err) + return + } + if _, err = tw.Write(metaBytes); err != nil { + writer.CloseWithError(err) + return + } + tlsFiles, err := s.ListContextTLSFiles(name) + if err != nil { + writer.CloseWithError(err) + return + } + if err = tw.WriteHeader(&tar.Header{ + Name: "tls", + Mode: 0700, + Size: 0, + Typeflag: tar.TypeDir, + }); err != nil { + writer.CloseWithError(err) + return + } + for endpointName, endpointFiles := range tlsFiles { + if err = tw.WriteHeader(&tar.Header{ + Name: path.Join("tls", endpointName), + Mode: 0700, + Size: 0, + Typeflag: tar.TypeDir, + }); err != nil { + writer.CloseWithError(err) + return + } + for _, fileName := range endpointFiles { + data, err := s.GetContextTLSData(name, endpointName, fileName) + if err != nil { + writer.CloseWithError(err) + return + } + if err = tw.WriteHeader(&tar.Header{ + Name: path.Join("tls", endpointName, fileName), + Mode: 0600, + Size: int64(len(data)), + }); err != nil { + writer.CloseWithError(err) + return + } + if _, err = tw.Write(data); err != nil { + writer.CloseWithError(err) + return + } + } + } + }() + return reader +} + +// Import imports an exported context into a store +func Import(name string, s Store, reader io.Reader) error { + tr := tar.NewReader(reader) + tlsData := ContextTLSData{ + Endpoints: map[string]EndpointTLSData{}, + } + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return err + } + if hdr.Typeflag == tar.TypeDir { + // skip this entry, only taking files into account + continue + } + if hdr.Name == metaFile { + data, err := ioutil.ReadAll(tr) + if err != nil { + return err + } + var meta ContextMetadata + if err := json.Unmarshal(data, &meta); err != nil { + return err + } + if err := s.CreateOrUpdateContext(name, meta); err != nil { + return err + } + } else if strings.HasPrefix(hdr.Name, "tls/") { + relative := strings.TrimPrefix(hdr.Name, "tls/") + parts := strings.SplitN(relative, "/", 2) + if len(parts) != 2 { + return errors.New("archive format is invalid") + } + endpointName := parts[0] + fileName := parts[1] + data, err := ioutil.ReadAll(tr) + if err != nil { + return err + } + if _, ok := tlsData.Endpoints[endpointName]; !ok { + tlsData.Endpoints[endpointName] = EndpointTLSData{ + Files: map[string][]byte{}, + } + } + tlsData.Endpoints[endpointName].Files[fileName] = data + } + } + return s.ResetContextTLSMaterial(name, &tlsData) +} + +type contextDoesNotExistError struct { + name string +} + +func (e *contextDoesNotExistError) Error() string { + return fmt.Sprintf("context %q does not exist", e.name) +} + +type tlsDataDoesNotExistError struct { + context, endpoint, file string +} + +func (e *tlsDataDoesNotExistError) Error() string { + return fmt.Sprintf("tls data for %s/%s/%s does not exist", e.context, e.endpoint, e.file) +} + +// IsErrContextDoesNotExist checks if the given error is a "context does not exist" condition +func IsErrContextDoesNotExist(err error) bool { + _, ok := err.(*contextDoesNotExistError) + return ok +} + +// IsErrTLSDataDoesNotExist checks if the given error is a "context does not exist" condition +func IsErrTLSDataDoesNotExist(err error) bool { + _, ok := err.(*tlsDataDoesNotExistError) + return ok +} diff --git a/cli/context/store/store_test.go b/cli/context/store/store_test.go new file mode 100644 index 000000000000..c1994d0441be --- /dev/null +++ b/cli/context/store/store_test.go @@ -0,0 +1,100 @@ +package store + +import ( + "io/ioutil" + "os" + "testing" + + "gotest.tools/assert" +) + +type endpoint struct { + Foo string `json:"a_very_recognizable_field_name"` +} + +type context struct { + Bar string `json:"another_very_recognizable_field_name"` +} + +var testCfg = NewConfig(func() interface{} { return &context{} }, + EndpointTypeGetter("ep1", func() interface{} { return &endpoint{} }), + EndpointTypeGetter("ep2", func() interface{} { return &endpoint{} }), +) + +func TestExportImport(t *testing.T) { + testDir, err := ioutil.TempDir("", t.Name()) + assert.NilError(t, err) + defer os.RemoveAll(testDir) + s := New(testDir, testCfg) + err = s.CreateOrUpdateContext("source", + ContextMetadata{ + Endpoints: map[string]interface{}{ + "ep1": endpoint{Foo: "bar"}, + }, + Metadata: context{Bar: "baz"}, + }) + assert.NilError(t, err) + err = s.ResetContextEndpointTLSMaterial("source", "ep1", &EndpointTLSData{ + Files: map[string][]byte{ + "file1": []byte("test-data"), + }, + }) + assert.NilError(t, err) + r := Export("source", s) + defer r.Close() + err = Import("dest", s, r) + assert.NilError(t, err) + srcMeta, err := s.GetContextMetadata("source") + assert.NilError(t, err) + destMeta, err := s.GetContextMetadata("dest") + assert.NilError(t, err) + assert.DeepEqual(t, destMeta, srcMeta) + srcFileList, err := s.ListContextTLSFiles("source") + assert.NilError(t, err) + destFileList, err := s.ListContextTLSFiles("dest") + assert.NilError(t, err) + assert.DeepEqual(t, srcFileList, destFileList) + srcData, err := s.GetContextTLSData("source", "ep1", "file1") + assert.NilError(t, err) + assert.Equal(t, "test-data", string(srcData)) + destData, err := s.GetContextTLSData("dest", "ep1", "file1") + assert.NilError(t, err) + assert.Equal(t, "test-data", string(destData)) +} + +func TestRemove(t *testing.T) { + testDir, err := ioutil.TempDir("", t.Name()) + assert.NilError(t, err) + defer os.RemoveAll(testDir) + s := New(testDir, testCfg) + err = s.CreateOrUpdateContext("source", + ContextMetadata{ + Endpoints: map[string]interface{}{ + "ep1": endpoint{Foo: "bar"}, + }, + Metadata: context{Bar: "baz"}, + }) + assert.NilError(t, err) + assert.NilError(t, s.ResetContextEndpointTLSMaterial("source", "ep1", &EndpointTLSData{ + Files: map[string][]byte{ + "file1": []byte("test-data"), + }, + })) + assert.NilError(t, s.RemoveContext("source")) + _, err = s.GetContextMetadata("source") + assert.Check(t, IsErrContextDoesNotExist(err)) + f, err := s.ListContextTLSFiles("source") + assert.NilError(t, err) + assert.Equal(t, 0, len(f)) +} + +func TestListEmptyStore(t *testing.T) { + testDir, err := ioutil.TempDir("", t.Name()) + assert.NilError(t, err) + defer os.RemoveAll(testDir) + store := New(testDir, testCfg) + result, err := store.ListContexts() + assert.NilError(t, err) + assert.Check(t, result != nil) + assert.Check(t, len(result) == 0) +} diff --git a/cli/context/store/storeconfig.go b/cli/context/store/storeconfig.go new file mode 100644 index 000000000000..9746d93d7738 --- /dev/null +++ b/cli/context/store/storeconfig.go @@ -0,0 +1,38 @@ +package store + +// TypeGetter is a func used to determine the concrete type of a context or +// endpoint metadata by returning a pointer to an instance of the object +// eg: for a context of type DockerContext, the corresponding TypeGetter should return new(DockerContext) +type TypeGetter func() interface{} + +// NamedTypeGetter is a TypeGetter associated with a name +type NamedTypeGetter struct { + name string + typeGetter TypeGetter +} + +// EndpointTypeGetter returns a NamedTypeGetter with the spcecified name and getter +func EndpointTypeGetter(name string, getter TypeGetter) NamedTypeGetter { + return NamedTypeGetter{ + name: name, + typeGetter: getter, + } +} + +// Config is used to configure the metadata marshaler of the context store +type Config struct { + contextType TypeGetter + endpointTypes map[string]TypeGetter +} + +// NewConfig creates a config object +func NewConfig(contextType TypeGetter, endpoints ...NamedTypeGetter) Config { + res := Config{ + contextType: contextType, + endpointTypes: make(map[string]TypeGetter), + } + for _, e := range endpoints { + res.endpointTypes[e.name] = e.typeGetter + } + return res +} diff --git a/cli/context/store/tlsstore.go b/cli/context/store/tlsstore.go new file mode 100644 index 000000000000..0d978df1293f --- /dev/null +++ b/cli/context/store/tlsstore.go @@ -0,0 +1,99 @@ +package store + +import ( + "io/ioutil" + "os" + "path/filepath" +) + +const tlsDir = "tls" + +type tlsStore struct { + root string +} + +func (s *tlsStore) contextDir(name string) string { + return filepath.Join(s.root, name) +} + +func (s *tlsStore) endpointDir(contextName, name string) string { + return filepath.Join(s.root, contextName, name) +} + +func (s *tlsStore) filePath(contextName, endpointName, filename string) string { + return filepath.Join(s.root, contextName, endpointName, filename) +} + +func (s *tlsStore) createOrUpdate(contextName, endpointName, filename string, data []byte) error { + epdir := s.endpointDir(contextName, endpointName) + parentOfRoot := filepath.Dir(s.root) + if err := os.MkdirAll(parentOfRoot, 0755); err != nil { + return err + } + if err := os.MkdirAll(epdir, 0700); err != nil { + return err + } + return ioutil.WriteFile(s.filePath(contextName, endpointName, filename), data, 0600) +} + +func (s *tlsStore) getData(contextName, endpointName, filename string) ([]byte, error) { + data, err := ioutil.ReadFile(s.filePath(contextName, endpointName, filename)) + if err != nil { + return nil, convertTLSDataDoesNotExist(contextName, endpointName, filename, err) + } + return data, nil +} + +func (s *tlsStore) remove(contextName, endpointName, filename string) error { + err := os.Remove(s.filePath(contextName, endpointName, filename)) + if os.IsNotExist(err) { + return nil + } + return err +} + +func (s *tlsStore) removeAllEndpointData(contextName, endpointName string) error { + return os.RemoveAll(s.endpointDir(contextName, endpointName)) +} + +func (s *tlsStore) removeAllContextData(contextName string) error { + return os.RemoveAll(s.contextDir(contextName)) +} + +func (s *tlsStore) listContextData(contextName string) (map[string]EndpointFiles, error) { + epFSs, err := ioutil.ReadDir(s.contextDir(contextName)) + if err != nil { + if os.IsNotExist(err) { + return map[string]EndpointFiles{}, nil + } + return nil, err + } + r := make(map[string]EndpointFiles) + for _, epFS := range epFSs { + if epFS.IsDir() { + epDir := s.endpointDir(contextName, epFS.Name()) + fss, err := ioutil.ReadDir(epDir) + if err != nil { + return nil, err + } + var files EndpointFiles + for _, fs := range fss { + if !fs.IsDir() { + files = append(files, fs.Name()) + } + } + r[epFS.Name()] = files + } + } + return r, nil +} + +// EndpointFiles is a slice of strings representing file names +type EndpointFiles []string + +func convertTLSDataDoesNotExist(context, endpoint, file string, err error) error { + if os.IsNotExist(err) { + return &tlsDataDoesNotExistError{context: context, endpoint: endpoint, file: file} + } + return err +} diff --git a/cli/context/store/tlsstore_test.go b/cli/context/store/tlsstore_test.go new file mode 100644 index 000000000000..6079de0f8e7c --- /dev/null +++ b/cli/context/store/tlsstore_test.go @@ -0,0 +1,79 @@ +package store + +import ( + "io/ioutil" + "os" + "testing" + + "gotest.tools/assert" +) + +func TestTlsCreateUpdateGetRemove(t *testing.T) { + testDir, err := ioutil.TempDir("", "TestTlsCreateUpdateGetRemove") + assert.NilError(t, err) + defer os.RemoveAll(testDir) + testee := tlsStore{root: testDir} + _, err = testee.getData("test-ctx", "test-ep", "test-data") + assert.Equal(t, true, IsErrTLSDataDoesNotExist(err)) + + err = testee.createOrUpdate("test-ctx", "test-ep", "test-data", []byte("data")) + assert.NilError(t, err) + data, err := testee.getData("test-ctx", "test-ep", "test-data") + assert.NilError(t, err) + assert.Equal(t, string(data), "data") + err = testee.createOrUpdate("test-ctx", "test-ep", "test-data", []byte("data2")) + assert.NilError(t, err) + data, err = testee.getData("test-ctx", "test-ep", "test-data") + assert.NilError(t, err) + assert.Equal(t, string(data), "data2") + + err = testee.remove("test-ctx", "test-ep", "test-data") + assert.NilError(t, err) + err = testee.remove("test-ctx", "test-ep", "test-data") + assert.NilError(t, err) + + _, err = testee.getData("test-ctx", "test-ep", "test-data") + assert.Equal(t, true, IsErrTLSDataDoesNotExist(err)) +} + +func TestTlsListAndBatchRemove(t *testing.T) { + testDir, err := ioutil.TempDir("", "TestTlsListAndBatchRemove") + assert.NilError(t, err) + defer os.RemoveAll(testDir) + testee := tlsStore{root: testDir} + + all := map[string]EndpointFiles{ + "ep1": {"f1", "f2", "f3"}, + "ep2": {"f1", "f2", "f3"}, + "ep3": {"f1", "f2", "f3"}, + } + + ep1ep2 := map[string]EndpointFiles{ + "ep1": {"f1", "f2", "f3"}, + "ep2": {"f1", "f2", "f3"}, + } + + for name, files := range all { + for _, file := range files { + err = testee.createOrUpdate("test-ctx", name, file, []byte("data")) + assert.NilError(t, err) + } + } + + resAll, err := testee.listContextData("test-ctx") + assert.NilError(t, err) + assert.DeepEqual(t, resAll, all) + + err = testee.removeAllEndpointData("test-ctx", "ep3") + assert.NilError(t, err) + resEp1ep2, err := testee.listContextData("test-ctx") + assert.NilError(t, err) + assert.DeepEqual(t, resEp1ep2, ep1ep2) + + err = testee.removeAllContextData("test-ctx") + assert.NilError(t, err) + resEmpty, err := testee.listContextData("test-ctx") + assert.NilError(t, err) + assert.DeepEqual(t, resEmpty, map[string]EndpointFiles{}) + +} diff --git a/cli/context/tlsdata.go b/cli/context/tlsdata.go new file mode 100644 index 000000000000..6bd05fbb78f3 --- /dev/null +++ b/cli/context/tlsdata.go @@ -0,0 +1,98 @@ +package context + +import ( + "io/ioutil" + + "github.com/docker/cli/cli/context/store" + "github.com/pkg/errors" + "github.com/sirupsen/logrus" +) + +const ( + caKey = "ca.pem" + certKey = "cert.pem" + keyKey = "key.pem" +) + +// TLSData holds ca/cert/key raw data +type TLSData struct { + CA []byte + Key []byte + Cert []byte +} + +// ToStoreTLSData converts TLSData to the store representation +func (data *TLSData) ToStoreTLSData() *store.EndpointTLSData { + if data == nil { + return nil + } + result := store.EndpointTLSData{ + Files: make(map[string][]byte), + } + if data.CA != nil { + result.Files[caKey] = data.CA + } + if data.Cert != nil { + result.Files[certKey] = data.Cert + } + if data.Key != nil { + result.Files[keyKey] = data.Key + } + return &result +} + +// LoadTLSData loads TLS data from the store +func LoadTLSData(s store.Store, contextName, endpointName string) (*TLSData, error) { + tlsFiles, err := s.ListContextTLSFiles(contextName) + if err != nil { + return nil, errors.Wrapf(err, "failed to retrieve context tls files for context %q", contextName) + } + if epTLSFiles, ok := tlsFiles[endpointName]; ok { + var tlsData TLSData + for _, f := range epTLSFiles { + data, err := s.GetContextTLSData(contextName, endpointName, f) + if err != nil { + return nil, errors.Wrapf(err, "failed to retrieve context tls data for file %q of context %q", f, contextName) + } + switch f { + case caKey: + tlsData.CA = data + case certKey: + tlsData.Cert = data + case keyKey: + tlsData.Key = data + default: + logrus.Warnf("unknown file %s in context %s tls bundle", f, contextName) + } + } + return &tlsData, nil + } + return nil, nil +} + +// TLSDataFromFiles reads files into a TLSData struct (or returns nil if all paths are empty) +func TLSDataFromFiles(caPath, certPath, keyPath string) (*TLSData, error) { + var ( + ca, cert, key []byte + err error + ) + if caPath != "" { + if ca, err = ioutil.ReadFile(caPath); err != nil { + return nil, err + } + } + if certPath != "" { + if cert, err = ioutil.ReadFile(certPath); err != nil { + return nil, err + } + } + if keyPath != "" { + if key, err = ioutil.ReadFile(keyPath); err != nil { + return nil, err + } + } + if ca == nil && cert == nil && key == nil { + return nil, nil + } + return &TLSData{CA: ca, Cert: cert, Key: key}, nil +} diff --git a/cli/flags/common.go b/cli/flags/common.go index 22faf12ca634..a3bbf2957135 100644 --- a/cli/flags/common.go +++ b/cli/flags/common.go @@ -37,6 +37,7 @@ type CommonOptions struct { TLS bool TLSVerify bool TLSOptions *tlsconfig.Options + Context string } // NewCommonOptions returns a new CommonOptions @@ -70,6 +71,8 @@ func (commonOpts *CommonOptions) InstallFlags(flags *pflag.FlagSet) { // opts.ValidateHost is not used here, so as to allow connection helpers hostOpt := opts.NewNamedListOptsRef("hosts", &commonOpts.Hosts, nil) flags.VarP(hostOpt, "host", "H", "Daemon socket(s) to connect to") + flags.StringVarP(&commonOpts.Context, "context", "c", "", + `Name of the context to use to connect to the daemon (overrides DOCKER_HOST env var and default context set with "docker context use")`) } // SetDefaultOptions sets default values for options after flag parsing is From 591385a1d0431b6ec7b02e26b0768f7b96d31df9 Mon Sep 17 00:00:00 2001 From: Simon Ferquel Date: Fri, 9 Nov 2018 15:10:41 +0100 Subject: [PATCH 2/2] Fast Context Switch: commands Signed-off-by: Simon Ferquel --- cli/command/cli.go | 50 ++-- cli/command/commands/commands.go | 4 + cli/command/context.go | 4 +- cli/command/context/cmd.go | 49 ++++ cli/command/context/create.go | 139 +++++++++++ cli/command/context/create_test.go | 175 ++++++++++++++ cli/command/context/export-import_test.go | 110 +++++++++ cli/command/context/export.go | 108 +++++++++ cli/command/context/import.go | 48 ++++ cli/command/context/inspect.go | 67 ++++++ cli/command/context/inspect_test.go | 24 ++ cli/command/context/list.go | 109 +++++++++ cli/command/context/list_test.go | 62 +++++ cli/command/context/options.go | 221 ++++++++++++++++++ cli/command/context/remove.go | 66 ++++++ cli/command/context/remove_test.go | 64 +++++ cli/command/context/testdata/inspect.golden | 31 +++ cli/command/context/testdata/list.golden | 5 + .../context/testdata/list.no-context.golden | 2 + .../context/testdata/quiet-list.golden | 2 + cli/command/context/testdata/test-kubeconfig | 19 ++ cli/command/context/update.go | 142 +++++++++++ cli/command/context/update_test.go | 102 ++++++++ cli/command/context/use.go | 39 ++++ cli/command/context/use_test.go | 49 ++++ cli/command/formatter/context.go | 90 +++++++ cli/command/orchestrator.go | 4 +- cli/command/system/version.go | 2 +- cli/context/docker/load.go | 12 +- cli/context/endpoint.go | 4 +- cli/context/kubernetes/endpoint_test.go | 31 ++- cli/context/kubernetes/load.go | 6 +- cli/context/kubernetes/save.go | 18 -- cli/context/store/metadata_test.go | 67 +++--- cli/context/store/metadatastore.go | 45 ++-- cli/context/store/store.go | 102 ++++++-- cli/context/store/store_test.go | 20 +- cli/context/store/tlsstore.go | 46 ++-- docs/reference/commandline/cli.md | 2 + docs/reference/commandline/context_create.md | 75 ++++++ docs/reference/commandline/context_export.md | 31 +++ docs/reference/commandline/context_import.md | 22 ++ docs/reference/commandline/context_ls.md | 30 +++ docs/reference/commandline/context_rm.md | 28 +++ docs/reference/commandline/context_update.md | 60 +++++ docs/reference/commandline/context_use.md | 25 ++ docs/reference/commandline/index.md | 12 + internal/test/cli.go | 40 ++++ 48 files changed, 2295 insertions(+), 168 deletions(-) create mode 100644 cli/command/context/cmd.go create mode 100644 cli/command/context/create.go create mode 100644 cli/command/context/create_test.go create mode 100644 cli/command/context/export-import_test.go create mode 100644 cli/command/context/export.go create mode 100644 cli/command/context/import.go create mode 100644 cli/command/context/inspect.go create mode 100644 cli/command/context/inspect_test.go create mode 100644 cli/command/context/list.go create mode 100644 cli/command/context/list_test.go create mode 100644 cli/command/context/options.go create mode 100644 cli/command/context/remove.go create mode 100644 cli/command/context/remove_test.go create mode 100644 cli/command/context/testdata/inspect.golden create mode 100644 cli/command/context/testdata/list.golden create mode 100644 cli/command/context/testdata/list.no-context.golden create mode 100644 cli/command/context/testdata/quiet-list.golden create mode 100644 cli/command/context/testdata/test-kubeconfig create mode 100644 cli/command/context/update.go create mode 100644 cli/command/context/update_test.go create mode 100644 cli/command/context/use.go create mode 100644 cli/command/context/use_test.go create mode 100644 cli/command/formatter/context.go create mode 100644 docs/reference/commandline/context_create.md create mode 100644 docs/reference/commandline/context_export.md create mode 100644 docs/reference/commandline/context_import.md create mode 100644 docs/reference/commandline/context_ls.md create mode 100644 docs/reference/commandline/context_rm.md create mode 100644 docs/reference/commandline/context_update.md create mode 100644 docs/reference/commandline/context_use.md diff --git a/cli/command/cli.go b/cli/command/cli.go index d325d5156c14..5da0f01ddcf3 100644 --- a/cli/command/cli.go +++ b/cli/command/cli.go @@ -33,9 +33,6 @@ import ( "github.com/theupdateframework/notary/passphrase" ) -// ContextDockerHost is the reported context when DOCKER_HOST env var or -H flag is set -const ContextDockerHost = "" - // Streams is an interface which exposes the standard input and output streams type Streams interface { In() *InStream @@ -62,6 +59,7 @@ type Cli interface { ContextStore() store.Store CurrentContext() string StackOrchestrator(flagValue string) (Orchestrator, error) + DockerEndpoint() docker.Endpoint } // DockerCli is an instance the docker command line client. @@ -78,6 +76,7 @@ type DockerCli struct { newContainerizeClient func(string) (clitypes.ContainerizedClient, error) contextStore store.Store currentContext string + dockerEndpoint docker.Endpoint } var storeConfig = store.NewConfig( @@ -182,7 +181,7 @@ func (cli *DockerCli) Initialize(opts *cliflags.ClientOptions) error { cli.configFile = cliconfig.LoadDefaultConfigFile(cli.err) var err error cli.contextStore = store.New(cliconfig.ContextStoreDir(), storeConfig) - cli.currentContext, err = resolveContextName(opts.Common, cli.configFile) + cli.currentContext, err = resolveContextName(opts.Common, cli.configFile, cli.contextStore) if err != nil { return err } @@ -190,6 +189,7 @@ func (cli *DockerCli) Initialize(opts *cliflags.ClientOptions) error { if err != nil { return errors.Wrap(err, "unable to resolve docker endpoint") } + cli.dockerEndpoint = endpoint cli.client, err = newAPIClientFromEndpoint(endpoint, cli.configFile) if tlsconfig.IsErrEncryptedKey(err) { @@ -223,7 +223,7 @@ func (cli *DockerCli) Initialize(opts *cliflags.ClientOptions) error { // NewAPIClientFromFlags creates a new APIClient from command line flags func NewAPIClientFromFlags(opts *cliflags.CommonOptions, configFile *configfile.ConfigFile) (client.APIClient, error) { store := store.New(cliconfig.ContextStoreDir(), storeConfig) - contextName, err := resolveContextName(opts, configFile) + contextName, err := resolveContextName(opts, configFile, store) if err != nil { return nil, err } @@ -249,7 +249,7 @@ func newAPIClientFromEndpoint(ep docker.Endpoint, configFile *configfile.ConfigF } func resolveDockerEndpoint(s store.Store, contextName string, opts *cliflags.CommonOptions) (docker.Endpoint, error) { - if contextName != ContextDockerHost { + if contextName != "" { ctxMeta, err := s.GetContextMetadata(contextName) if err != nil { return docker.Endpoint{}, err @@ -258,7 +258,7 @@ func resolveDockerEndpoint(s store.Store, contextName string, opts *cliflags.Com if err != nil { return docker.Endpoint{}, err } - return epMeta.WithTLSData(s, contextName) + return docker.WithTLSData(s, contextName, epMeta) } host, err := getServerHost(opts.Hosts, opts.TLSOptions) if err != nil { @@ -280,10 +280,8 @@ func resolveDockerEndpoint(s store.Store, contextName string, opts *cliflags.Com return docker.Endpoint{ EndpointMeta: docker.EndpointMeta{ - EndpointMetaBase: dcontext.EndpointMetaBase{ - Host: host, - SkipTLSVerify: skipTLSVerify, - }, + Host: host, + SkipTLSVerify: skipTLSVerify, }, TLSData: tlsData, }, nil @@ -367,15 +365,16 @@ func (cli *DockerCli) StackOrchestrator(flagValue string) (Orchestrator, error) if currentContext == "" { currentContext = configFile.CurrentContext } - if currentContext == "" { - currentContext = ContextDockerHost - } - if currentContext != ContextDockerHost { + if currentContext != "" { contextstore := cli.contextStore if contextstore == nil { contextstore = store.New(cliconfig.ContextStoreDir(), storeConfig) } ctxRaw, err := contextstore.GetContextMetadata(currentContext) + if store.IsErrContextDoesNotExist(err) { + // case where the currentContext has been removed (CLI behavior is to fallback to using DOCKER_HOST based resolution) + return GetStackOrchestrator(flagValue, "", configFile.StackOrchestrator, cli.Err()) + } if err != nil { return "", err } @@ -389,6 +388,11 @@ func (cli *DockerCli) StackOrchestrator(flagValue string) (Orchestrator, error) return GetStackOrchestrator(flagValue, ctxOrchestrator, configFile.StackOrchestrator, cli.Err()) } +// DockerEndpoint returns the current docker endpoint +func (cli *DockerCli) DockerEndpoint() docker.Endpoint { + return cli.dockerEndpoint +} + // ServerInfo stores details about the supported features and platform of the // server type ServerInfo struct { @@ -435,24 +439,28 @@ func UserAgent() string { // - if DOCKER_CONTEXT is set, use this value // - if Config file has a globally set "CurrentContext", use this value // - fallbacks to default HOST, uses TLS config from flags/env vars -func resolveContextName(opts *cliflags.CommonOptions, config *configfile.ConfigFile) (string, error) { +func resolveContextName(opts *cliflags.CommonOptions, config *configfile.ConfigFile, contextstore store.Store) (string, error) { if opts.Context != "" && len(opts.Hosts) > 0 { - return "", errors.New("Conflicting options: either specify --host or --context, not bot") + return "", errors.New("Conflicting options: either specify --host or --context, not both") } if opts.Context != "" { return opts.Context, nil } if len(opts.Hosts) > 0 { - return ContextDockerHost, nil + return "", nil } if _, present := os.LookupEnv("DOCKER_HOST"); present { - return ContextDockerHost, nil + return "", nil } if ctxName, ok := os.LookupEnv("DOCKER_CONTEXT"); ok { return ctxName, nil } if config != nil && config.CurrentContext != "" { - return config.CurrentContext, nil + _, err := contextstore.GetContextMetadata(config.CurrentContext) + if store.IsErrContextDoesNotExist(err) { + return "", errors.Errorf("Current context %q is not found on the file system, please check your config file at %s", config.CurrentContext, config.Filename) + } + return config.CurrentContext, err } - return ContextDockerHost, nil + return "", nil } diff --git a/cli/command/commands/commands.go b/cli/command/commands/commands.go index ca2f6ad0966f..59999bda5d52 100644 --- a/cli/command/commands/commands.go +++ b/cli/command/commands/commands.go @@ -9,6 +9,7 @@ import ( "github.com/docker/cli/cli/command/checkpoint" "github.com/docker/cli/cli/command/config" "github.com/docker/cli/cli/command/container" + "github.com/docker/cli/cli/command/context" "github.com/docker/cli/cli/command/engine" "github.com/docker/cli/cli/command/image" "github.com/docker/cli/cli/command/manifest" @@ -86,6 +87,9 @@ func AddCommands(cmd *cobra.Command, dockerCli command.Cli) { // volume volume.NewVolumeCommand(dockerCli), + // context + context.NewContextCommand(dockerCli), + // legacy commands may be hidden hide(system.NewEventsCommand(dockerCli)), hide(system.NewInfoCommand(dockerCli)), diff --git a/cli/command/context.go b/cli/command/context.go index 2b4c76ed152e..4f9e8e8513c7 100644 --- a/cli/command/context.go +++ b/cli/command/context.go @@ -8,8 +8,8 @@ import ( // DockerContext is a typed representation of what we put in Context metadata type DockerContext struct { - Description string `json:"description,omitempty"` - StackOrchestrator Orchestrator `json:"stack_orchestrator,omitempty"` + Description string `json:",omitempty"` + StackOrchestrator Orchestrator `json:",omitempty"` } // GetDockerContext extracts metadata from stored context metadata diff --git a/cli/command/context/cmd.go b/cli/command/context/cmd.go new file mode 100644 index 000000000000..1b6898456d04 --- /dev/null +++ b/cli/command/context/cmd.go @@ -0,0 +1,49 @@ +package context + +import ( + "errors" + "fmt" + "regexp" + + "github.com/docker/cli/cli" + "github.com/docker/cli/cli/command" + "github.com/spf13/cobra" +) + +// NewContextCommand returns the context cli subcommand +func NewContextCommand(dockerCli command.Cli) *cobra.Command { + cmd := &cobra.Command{ + Use: "context", + Short: "Manage contexts", + Args: cli.NoArgs, + RunE: command.ShowHelp(dockerCli.Err()), + } + cmd.AddCommand( + newCreateCommand(dockerCli), + newListCommand(dockerCli), + newUseCommand(dockerCli), + newExportCommand(dockerCli), + newImportCommand(dockerCli), + newRemoveCommand(dockerCli), + newUpdateCommand(dockerCli), + newInspectCommand(dockerCli), + ) + return cmd +} + +const restrictedNamePattern = "^[a-zA-Z0-9][a-zA-Z0-9_.+-]+$" + +var restrictedNameRegEx = regexp.MustCompile(restrictedNamePattern) + +func validateContextName(name string) error { + if name == "" { + return errors.New("context name cannot be empty") + } + if name == "default" { + return errors.New(`"default" is a reserved context name`) + } + if !restrictedNameRegEx.MatchString(name) { + return fmt.Errorf("context name %q is invalid, names are validated against regexp %q", name, restrictedNamePattern) + } + return nil +} diff --git a/cli/command/context/create.go b/cli/command/context/create.go new file mode 100644 index 000000000000..c51d5214b503 --- /dev/null +++ b/cli/command/context/create.go @@ -0,0 +1,139 @@ +package context + +import ( + "bytes" + "fmt" + "text/tabwriter" + + "github.com/docker/cli/cli" + "github.com/docker/cli/cli/command" + "github.com/docker/cli/cli/context/docker" + "github.com/docker/cli/cli/context/kubernetes" + "github.com/docker/cli/cli/context/store" + "github.com/pkg/errors" + "github.com/spf13/cobra" +) + +type createOptions struct { + name string + description string + defaultStackOrchestrator string + docker map[string]string + kubernetes map[string]string +} + +func longCreateDescription() string { + buf := bytes.NewBuffer(nil) + buf.WriteString("Create a context\n\nDocker endpoint config:\n\n") + tw := tabwriter.NewWriter(buf, 20, 1, 3, ' ', 0) + fmt.Fprintln(tw, "NAME\tDESCRIPTION") + for _, d := range dockerConfigKeysDescriptions { + fmt.Fprintf(tw, "%s\t%s\n", d.name, d.description) + } + tw.Flush() + buf.WriteString("\nKubernetes endpoint config:\n\n") + tw = tabwriter.NewWriter(buf, 20, 1, 3, ' ', 0) + fmt.Fprintln(tw, "NAME\tDESCRIPTION") + for _, d := range kubernetesConfigKeysDescriptions { + fmt.Fprintf(tw, "%s\t%s\n", d.name, d.description) + } + tw.Flush() + buf.WriteString("\nExample:\n\n$ docker context create my-context --description \"some description\" --docker \"host=tcp://myserver:2376,ca=~/ca-file,cert=~/cert-file,key=~/key-file\"\n") + return buf.String() +} + +func newCreateCommand(dockerCli command.Cli) *cobra.Command { + opts := &createOptions{} + cmd := &cobra.Command{ + Use: "create [OPTIONS] CONTEXT", + Short: "Create a context", + Args: cli.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + opts.name = args[0] + return runCreate(dockerCli, opts) + }, + Long: longCreateDescription(), + } + flags := cmd.Flags() + flags.StringVar(&opts.description, "description", "", "Description of the context") + flags.StringVar( + &opts.defaultStackOrchestrator, + "default-stack-orchestrator", "", + "Default orchestrator for stack operations to use with this context (swarm|kubernetes|all)") + flags.StringToStringVar(&opts.docker, "docker", nil, "set the docker endpoint") + flags.StringToStringVar(&opts.kubernetes, "kubernetes", nil, "set the kubernetes endpoint") + return cmd +} + +func runCreate(cli command.Cli, o *createOptions) error { + s := cli.ContextStore() + if err := checkContextNameForCreation(s, o.name); err != nil { + return err + } + stackOrchestrator, err := command.NormalizeOrchestrator(o.defaultStackOrchestrator) + if err != nil { + return errors.Wrap(err, "unable to parse default-stack-orchestrator") + } + contextMetadata := store.ContextMetadata{ + Endpoints: make(map[string]interface{}), + Metadata: command.DockerContext{ + Description: o.description, + StackOrchestrator: stackOrchestrator, + }, + Name: o.name, + } + if o.docker == nil { + return errors.New("docker endpoint configuration is required") + } + contextTLSData := store.ContextTLSData{ + Endpoints: make(map[string]store.EndpointTLSData), + } + dockerEP, dockerTLS, err := getDockerEndpointMetadataAndTLS(cli, o.docker) + if err != nil { + return errors.Wrap(err, "unable to create docker endpoint config") + } + contextMetadata.Endpoints[docker.DockerEndpoint] = dockerEP + if dockerTLS != nil { + contextTLSData.Endpoints[docker.DockerEndpoint] = *dockerTLS + } + if o.kubernetes != nil { + kubernetesEP, kubernetesTLS, err := getKubernetesEndpointMetadataAndTLS(cli, o.kubernetes) + if err != nil { + return errors.Wrap(err, "unable to create kubernetes endpoint config") + } + if kubernetesEP == nil && stackOrchestrator.HasKubernetes() { + return errors.Errorf("cannot specify orchestrator %q without configuring a Kubernetes endpoint", stackOrchestrator) + } + if kubernetesEP != nil { + contextMetadata.Endpoints[kubernetes.KubernetesEndpoint] = kubernetesEP + } + if kubernetesTLS != nil { + contextTLSData.Endpoints[kubernetes.KubernetesEndpoint] = *kubernetesTLS + } + } + if err := validateEndpointsAndOrchestrator(contextMetadata); err != nil { + return err + } + if err := s.CreateOrUpdateContext(contextMetadata); err != nil { + return err + } + if err := s.ResetContextTLSMaterial(o.name, &contextTLSData); err != nil { + return err + } + fmt.Fprintln(cli.Out(), o.name) + fmt.Fprintf(cli.Err(), "Successfully created context %q\n", o.name) + return nil +} + +func checkContextNameForCreation(s store.Store, name string) error { + if err := validateContextName(name); err != nil { + return err + } + if _, err := s.GetContextMetadata(name); !store.IsErrContextDoesNotExist(err) { + if err != nil { + return errors.Wrap(err, "error while getting existing contexts") + } + return errors.Errorf("context %q already exists", name) + } + return nil +} diff --git a/cli/command/context/create_test.go b/cli/command/context/create_test.go new file mode 100644 index 000000000000..52521f9f7e96 --- /dev/null +++ b/cli/command/context/create_test.go @@ -0,0 +1,175 @@ +package context + +import ( + "io/ioutil" + "os" + "testing" + + "github.com/docker/cli/cli/command" + "github.com/docker/cli/cli/config/configfile" + "github.com/docker/cli/cli/context/docker" + "github.com/docker/cli/cli/context/kubernetes" + "github.com/docker/cli/cli/context/store" + "github.com/docker/cli/internal/test" + "gotest.tools/assert" + "gotest.tools/env" +) + +func makeFakeCli(t *testing.T, opts ...func(*test.FakeCli)) (*test.FakeCli, func()) { + dir, err := ioutil.TempDir("", t.Name()) + assert.NilError(t, err) + storeConfig := store.NewConfig( + func() interface{} { return &command.DockerContext{} }, + store.EndpointTypeGetter(docker.DockerEndpoint, func() interface{} { return &docker.EndpointMeta{} }), + store.EndpointTypeGetter(kubernetes.KubernetesEndpoint, func() interface{} { return &kubernetes.EndpointMeta{} }), + ) + store := store.New(dir, storeConfig) + cleanup := func() { + os.RemoveAll(dir) + } + result := test.NewFakeCli(nil, opts...) + for _, o := range opts { + o(result) + } + result.SetContextStore(store) + return result, cleanup +} + +func withCliConfig(configFile *configfile.ConfigFile) func(*test.FakeCli) { + return func(m *test.FakeCli) { + m.SetConfigFile(configFile) + } +} + +func TestCreateInvalids(t *testing.T) { + cli, cleanup := makeFakeCli(t) + defer cleanup() + assert.NilError(t, cli.ContextStore().CreateOrUpdateContext(store.ContextMetadata{Name: "existing-context"})) + tests := []struct { + options createOptions + expecterErr string + }{ + { + expecterErr: `context name cannot be empty`, + }, + { + options: createOptions{ + name: " ", + }, + expecterErr: `context name " " is invalid`, + }, + { + options: createOptions{ + name: "existing-context", + }, + expecterErr: `context "existing-context" already exists`, + }, + { + options: createOptions{ + name: "invalid-docker-host", + docker: map[string]string{ + keyHost: "some///invalid/host", + }, + }, + expecterErr: `unable to parse docker host`, + }, + { + options: createOptions{ + name: "invalid-orchestrator", + defaultStackOrchestrator: "invalid", + }, + expecterErr: `specified orchestrator "invalid" is invalid, please use either kubernetes, swarm or all`, + }, + { + options: createOptions{ + name: "orchestrator-swarm-no-endpoint", + defaultStackOrchestrator: "swarm", + }, + expecterErr: `docker endpoint configuration is required`, + }, + { + options: createOptions{ + name: "orchestrator-kubernetes-no-endpoint", + defaultStackOrchestrator: "kubernetes", + docker: map[string]string{}, + }, + expecterErr: `cannot specify orchestrator "kubernetes" without configuring a Kubernetes endpoint`, + }, + { + options: createOptions{ + name: "orchestrator-all-no-endpoint", + defaultStackOrchestrator: "all", + docker: map[string]string{}, + }, + expecterErr: `cannot specify orchestrator "all" without configuring a Kubernetes endpoint`, + }, + } + for _, tc := range tests { + tc := tc + t.Run(tc.options.name, func(t *testing.T) { + err := runCreate(cli, &tc.options) + assert.ErrorContains(t, err, tc.expecterErr) + }) + } +} + +func TestCreateOrchestratorSwarm(t *testing.T) { + cli, cleanup := makeFakeCli(t) + defer cleanup() + + err := runCreate(cli, &createOptions{ + name: "test", + defaultStackOrchestrator: "swarm", + docker: map[string]string{}, + }) + assert.NilError(t, err) + assert.Equal(t, "test\n", cli.OutBuffer().String()) + assert.Equal(t, "Successfully created context \"test\"\n", cli.ErrBuffer().String()) +} + +func TestCreateOrchestratorEmpty(t *testing.T) { + cli, cleanup := makeFakeCli(t) + defer cleanup() + + err := runCreate(cli, &createOptions{ + name: "test", + docker: map[string]string{}, + }) + assert.NilError(t, err) +} + +func validateTestKubeEndpoint(t *testing.T, s store.Store, name string) { + t.Helper() + ctxMetadata, err := s.GetContextMetadata(name) + assert.NilError(t, err) + kubeMeta := ctxMetadata.Endpoints[kubernetes.KubernetesEndpoint].(kubernetes.EndpointMeta) + kubeEP, err := kubeMeta.WithTLSData(s, name) + assert.NilError(t, err) + assert.Equal(t, "https://someserver", kubeEP.Host) + assert.Equal(t, "the-ca", string(kubeEP.TLSData.CA)) + assert.Equal(t, "the-cert", string(kubeEP.TLSData.Cert)) + assert.Equal(t, "the-key", string(kubeEP.TLSData.Key)) +} + +func createTestContextWithKube(t *testing.T, cli command.Cli) { + t.Helper() + revert := env.Patch(t, "KUBECONFIG", "./testdata/test-kubeconfig") + defer revert() + + err := runCreate(cli, &createOptions{ + name: "test", + defaultStackOrchestrator: "all", + kubernetes: map[string]string{ + keyFromCurrent: "true", + }, + docker: map[string]string{}, + }) + assert.NilError(t, err) +} + +func TestCreateOrchestratorAllKubernetesEndpointFromCurrent(t *testing.T) { + cli, cleanup := makeFakeCli(t) + defer cleanup() + createTestContextWithKube(t, cli) + validateTestKubeEndpoint(t, cli.ContextStore(), "test") +} diff --git a/cli/command/context/export-import_test.go b/cli/command/context/export-import_test.go new file mode 100644 index 000000000000..aac9beddeb1c --- /dev/null +++ b/cli/command/context/export-import_test.go @@ -0,0 +1,110 @@ +package context + +import ( + "bytes" + "fmt" + "io/ioutil" + "os" + "path/filepath" + "testing" + + "github.com/docker/cli/cli/command" + "gotest.tools/assert" +) + +func TestExportImportWithFile(t *testing.T) { + contextDir, err := ioutil.TempDir("", t.Name()+"context") + assert.NilError(t, err) + defer os.RemoveAll(contextDir) + contextFile := filepath.Join(contextDir, "exported") + cli, cleanup := makeFakeCli(t) + defer cleanup() + createTestContextWithKube(t, cli) + cli.ErrBuffer().Reset() + assert.NilError(t, runExport(cli, &exportOptions{ + contextName: "test", + dest: contextFile, + })) + assert.Equal(t, cli.ErrBuffer().String(), fmt.Sprintf("Written file %q\n", contextFile)) + cli.OutBuffer().Reset() + cli.ErrBuffer().Reset() + assert.NilError(t, runImport(cli, "test2", contextFile)) + context1, err := cli.ContextStore().GetContextMetadata("test") + assert.NilError(t, err) + context2, err := cli.ContextStore().GetContextMetadata("test2") + assert.NilError(t, err) + assert.DeepEqual(t, context1.Endpoints, context2.Endpoints) + assert.DeepEqual(t, context1.Metadata, context2.Metadata) + assert.Equal(t, "test", context1.Name) + assert.Equal(t, "test2", context2.Name) + + assert.Equal(t, "test2\n", cli.OutBuffer().String()) + assert.Equal(t, "Successfully imported context \"test2\"\n", cli.ErrBuffer().String()) +} + +func TestExportImportPipe(t *testing.T) { + cli, cleanup := makeFakeCli(t) + defer cleanup() + createTestContextWithKube(t, cli) + cli.ErrBuffer().Reset() + cli.OutBuffer().Reset() + assert.NilError(t, runExport(cli, &exportOptions{ + contextName: "test", + dest: "-", + })) + assert.Equal(t, cli.ErrBuffer().String(), "") + cli.SetIn(command.NewInStream(ioutil.NopCloser(bytes.NewBuffer(cli.OutBuffer().Bytes())))) + cli.OutBuffer().Reset() + cli.ErrBuffer().Reset() + assert.NilError(t, runImport(cli, "test2", "-")) + context1, err := cli.ContextStore().GetContextMetadata("test") + assert.NilError(t, err) + context2, err := cli.ContextStore().GetContextMetadata("test2") + assert.NilError(t, err) + assert.DeepEqual(t, context1.Endpoints, context2.Endpoints) + assert.DeepEqual(t, context1.Metadata, context2.Metadata) + assert.Equal(t, "test", context1.Name) + assert.Equal(t, "test2", context2.Name) + + assert.Equal(t, "test2\n", cli.OutBuffer().String()) + assert.Equal(t, "Successfully imported context \"test2\"\n", cli.ErrBuffer().String()) +} + +func TestExportKubeconfig(t *testing.T) { + contextDir, err := ioutil.TempDir("", t.Name()+"context") + assert.NilError(t, err) + defer os.RemoveAll(contextDir) + contextFile := filepath.Join(contextDir, "exported") + cli, cleanup := makeFakeCli(t) + defer cleanup() + createTestContextWithKube(t, cli) + cli.ErrBuffer().Reset() + assert.NilError(t, runExport(cli, &exportOptions{ + contextName: "test", + dest: contextFile, + kubeconfig: true, + })) + assert.Equal(t, cli.ErrBuffer().String(), fmt.Sprintf("Written file %q\n", contextFile)) + assert.NilError(t, runCreate(cli, &createOptions{ + name: "test2", + kubernetes: map[string]string{ + keyKubeconfig: contextFile, + }, + docker: map[string]string{}, + })) + validateTestKubeEndpoint(t, cli.ContextStore(), "test2") +} + +func TestExportExistingFile(t *testing.T) { + contextDir, err := ioutil.TempDir("", t.Name()+"context") + assert.NilError(t, err) + defer os.RemoveAll(contextDir) + contextFile := filepath.Join(contextDir, "exported") + cli, cleanup := makeFakeCli(t) + defer cleanup() + createTestContextWithKube(t, cli) + cli.ErrBuffer().Reset() + assert.NilError(t, ioutil.WriteFile(contextFile, []byte{}, 0644)) + err = runExport(cli, &exportOptions{contextName: "test", dest: contextFile}) + assert.Assert(t, os.IsExist(err)) +} diff --git a/cli/command/context/export.go b/cli/command/context/export.go new file mode 100644 index 000000000000..060abf977dcd --- /dev/null +++ b/cli/command/context/export.go @@ -0,0 +1,108 @@ +package context + +import ( + "bytes" + "errors" + "fmt" + "io" + "os" + + "github.com/docker/cli/cli" + "github.com/docker/cli/cli/command" + "github.com/docker/cli/cli/context/kubernetes" + "github.com/docker/cli/cli/context/store" + "github.com/spf13/cobra" + "k8s.io/client-go/tools/clientcmd" +) + +type exportOptions struct { + kubeconfig bool + contextName string + dest string +} + +func newExportCommand(dockerCli command.Cli) *cobra.Command { + opts := &exportOptions{} + cmd := &cobra.Command{ + Use: "export [OPTIONS] CONTEXT [FILE|-]", + Short: "Export a context to a tar or kubeconfig file", + Args: cli.RequiresRangeArgs(1, 2), + RunE: func(cmd *cobra.Command, args []string) error { + opts.contextName = args[0] + if len(args) == 2 { + opts.dest = args[1] + } else { + opts.dest = opts.contextName + if opts.kubeconfig { + opts.dest += ".kubeconfig" + } else { + opts.dest += ".dockercontext" + } + } + return runExport(dockerCli, opts) + }, + } + + flags := cmd.Flags() + flags.BoolVar(&opts.kubeconfig, "kubeconfig", false, "Export as a kubeconfig file") + return cmd +} + +func writeTo(dockerCli command.Cli, reader io.Reader, dest string) error { + var writer io.Writer + var printDest bool + if dest == "-" { + if dockerCli.Out().IsTerminal() { + return errors.New("cowardly refusing to export to a terminal, please specify a file path") + } + writer = dockerCli.Out() + } else { + f, err := os.OpenFile(dest, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0600) + if err != nil { + return err + } + defer f.Close() + writer = f + printDest = true + } + if _, err := io.Copy(writer, reader); err != nil { + return err + } + if printDest { + fmt.Fprintf(dockerCli.Err(), "Written file %q\n", dest) + } + return nil +} + +func runExport(dockerCli command.Cli, opts *exportOptions) error { + if err := validateContextName(opts.contextName); err != nil { + return err + } + ctxMeta, err := dockerCli.ContextStore().GetContextMetadata(opts.contextName) + if err != nil { + return err + } + if !opts.kubeconfig { + reader := store.Export(opts.contextName, dockerCli.ContextStore()) + defer reader.Close() + return writeTo(dockerCli, reader, opts.dest) + } + kubernetesEndpointMeta := kubernetes.EndpointFromContext(ctxMeta) + if kubernetesEndpointMeta == nil { + return fmt.Errorf("context %q has no kubernetes endpoint", opts.contextName) + } + kubernetesEndpoint, err := kubernetesEndpointMeta.WithTLSData(dockerCli.ContextStore(), opts.contextName) + if err != nil { + return err + } + kubeConfig := kubernetesEndpoint.KubernetesConfig() + rawCfg, err := kubeConfig.RawConfig() + if err != nil { + return err + } + data, err := clientcmd.Write(rawCfg) + if err != nil { + return err + } + return writeTo(dockerCli, bytes.NewBuffer(data), opts.dest) +} diff --git a/cli/command/context/import.go b/cli/command/context/import.go new file mode 100644 index 000000000000..b1f68ec4eec2 --- /dev/null +++ b/cli/command/context/import.go @@ -0,0 +1,48 @@ +package context + +import ( + "fmt" + "io" + "os" + + "github.com/docker/cli/cli" + "github.com/docker/cli/cli/command" + "github.com/docker/cli/cli/context/store" + "github.com/spf13/cobra" +) + +func newImportCommand(dockerCli command.Cli) *cobra.Command { + cmd := &cobra.Command{ + Use: "import CONTEXT FILE|-", + Short: "Import a context from a tar file", + Args: cli.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + return runImport(dockerCli, args[0], args[1]) + }, + } + return cmd +} + +func runImport(dockerCli command.Cli, name string, source string) error { + if err := checkContextNameForCreation(dockerCli.ContextStore(), name); err != nil { + return err + } + var reader io.Reader + if source == "-" { + reader = dockerCli.In() + } else { + f, err := os.Open(source) + if err != nil { + return err + } + defer f.Close() + reader = f + } + + if err := store.Import(name, dockerCli.ContextStore(), reader); err != nil { + return err + } + fmt.Fprintln(dockerCli.Out(), name) + fmt.Fprintf(dockerCli.Err(), "Successfully imported context %q\n", name) + return nil +} diff --git a/cli/command/context/inspect.go b/cli/command/context/inspect.go new file mode 100644 index 000000000000..678b818f4c38 --- /dev/null +++ b/cli/command/context/inspect.go @@ -0,0 +1,67 @@ +package context + +import ( + "errors" + + "github.com/docker/cli/cli/command" + "github.com/docker/cli/cli/command/inspect" + "github.com/docker/cli/cli/context/store" + "github.com/spf13/cobra" +) + +type inspectOptions struct { + format string + refs []string +} + +// newInspectCommand creates a new cobra.Command for `docker image inspect` +func newInspectCommand(dockerCli command.Cli) *cobra.Command { + var opts inspectOptions + + cmd := &cobra.Command{ + Use: "inspect [OPTIONS] [CONTEXT] [CONTEXT...]", + Short: "Display detailed information on one or more contexts", + RunE: func(cmd *cobra.Command, args []string) error { + opts.refs = args + if len(opts.refs) == 0 { + if dockerCli.CurrentContext() == "" { + return errors.New("no context specified") + } + opts.refs = []string{dockerCli.CurrentContext()} + } + return runInspect(dockerCli, opts) + }, + } + + flags := cmd.Flags() + flags.StringVarP(&opts.format, "format", "f", "", "Format the output using the given Go template") + return cmd +} + +func runInspect(dockerCli command.Cli, opts inspectOptions) error { + getRefFunc := func(ref string) (interface{}, []byte, error) { + if ref == "default" { + return nil, nil, errors.New(`context "default" cannot be inspected`) + } + c, err := dockerCli.ContextStore().GetContextMetadata(ref) + if err != nil { + return nil, nil, err + } + tlsListing, err := dockerCli.ContextStore().ListContextTLSFiles(ref) + if err != nil { + return nil, nil, err + } + return contextWithTLSListing{ + ContextMetadata: c, + TLSMaterial: tlsListing, + Storage: dockerCli.ContextStore().GetContextStorageInfo(ref), + }, nil, nil + } + return inspect.Inspect(dockerCli.Out(), opts.refs, opts.format, getRefFunc) +} + +type contextWithTLSListing struct { + store.ContextMetadata + TLSMaterial map[string]store.EndpointFiles + Storage store.ContextStorageInfo +} diff --git a/cli/command/context/inspect_test.go b/cli/command/context/inspect_test.go new file mode 100644 index 000000000000..f417b5f8b1c1 --- /dev/null +++ b/cli/command/context/inspect_test.go @@ -0,0 +1,24 @@ +package context + +import ( + "strings" + "testing" + + "gotest.tools/assert" + "gotest.tools/golden" +) + +func TestInspect(t *testing.T) { + cli, cleanup := makeFakeCli(t) + defer cleanup() + createTestContextWithKubeAndSwarm(t, cli, "current", "all") + cli.OutBuffer().Reset() + assert.NilError(t, runInspect(cli, inspectOptions{ + refs: []string{"current"}, + })) + expected := string(golden.Get(t, "inspect.golden")) + si := cli.ContextStore().GetContextStorageInfo("current") + expected = strings.Replace(expected, "", strings.Replace(si.MetadataPath, `\`, `\\`, -1), 1) + expected = strings.Replace(expected, "", strings.Replace(si.TLSPath, `\`, `\\`, -1), 1) + assert.Equal(t, cli.OutBuffer().String(), expected) +} diff --git a/cli/command/context/list.go b/cli/command/context/list.go new file mode 100644 index 000000000000..1cda69767d6b --- /dev/null +++ b/cli/command/context/list.go @@ -0,0 +1,109 @@ +package context + +import ( + "fmt" + "sort" + + "github.com/docker/cli/cli" + "github.com/docker/cli/cli/command" + "github.com/docker/cli/cli/command/formatter" + "github.com/docker/cli/cli/context/docker" + kubecontext "github.com/docker/cli/cli/context/kubernetes" + "github.com/docker/cli/kubernetes" + "github.com/spf13/cobra" + "vbom.ml/util/sortorder" +) + +type listOptions struct { + format string + quiet bool +} + +func newListCommand(dockerCli command.Cli) *cobra.Command { + opts := &listOptions{} + cmd := &cobra.Command{ + Use: "ls [OPTIONS]", + Aliases: []string{"list"}, + Short: "List contexts", + Args: cli.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runList(dockerCli, opts) + }, + } + + flags := cmd.Flags() + flags.StringVar(&opts.format, "format", "", "Pretty-print contexts using a Go template") + flags.BoolVarP(&opts.quiet, "quiet", "q", false, "Only show context names") + return cmd +} + +func runList(dockerCli command.Cli, opts *listOptions) error { + if opts.format == "" { + opts.format = formatter.TableFormatKey + } + curContext := dockerCli.CurrentContext() + contextMap, err := dockerCli.ContextStore().ListContexts() + if err != nil { + return err + } + var contexts []*formatter.ClientContext + for _, rawMeta := range contextMap { + meta, err := command.GetDockerContext(rawMeta) + if err != nil { + return err + } + dockerEndpoint, err := docker.EndpointFromContext(rawMeta) + if err != nil { + return err + } + kubernetesEndpoint := kubecontext.EndpointFromContext(rawMeta) + kubEndpointText := "" + if kubernetesEndpoint != nil { + kubEndpointText = fmt.Sprintf("%s (%s)", kubernetesEndpoint.Host, kubernetesEndpoint.DefaultNamespace) + } + desc := formatter.ClientContext{ + Name: rawMeta.Name, + Current: rawMeta.Name == curContext, + Description: meta.Description, + StackOrchestrator: string(meta.StackOrchestrator), + DockerEndpoint: dockerEndpoint.Host, + KubernetesEndpoint: kubEndpointText, + } + contexts = append(contexts, &desc) + } + if !opts.quiet { + desc := &formatter.ClientContext{ + Name: "default", + Description: "Current DOCKER_HOST based configuration", + } + if dockerCli.CurrentContext() == "" { + orchestrator, _ := dockerCli.StackOrchestrator("") + kubEndpointText := "" + kubeconfig := kubernetes.NewKubernetesConfig("") + if cfg, err := kubeconfig.ClientConfig(); err == nil { + ns, _, _ := kubeconfig.Namespace() + if ns == "" { + ns = "default" + } + kubEndpointText = fmt.Sprintf("%s (%s)", cfg.Host, ns) + } + desc.Current = true + desc.StackOrchestrator = string(orchestrator) + desc.DockerEndpoint = dockerCli.DockerEndpoint().Host + desc.KubernetesEndpoint = kubEndpointText + } + contexts = append(contexts, desc) + } + sort.Slice(contexts, func(i, j int) bool { + return sortorder.NaturalLess(contexts[i].Name, contexts[j].Name) + }) + return format(dockerCli, opts, contexts) +} + +func format(dockerCli command.Cli, opts *listOptions, contexts []*formatter.ClientContext) error { + contextCtx := formatter.Context{ + Output: dockerCli.Out(), + Format: formatter.NewClientContextFormat(opts.format, opts.quiet), + } + return formatter.ClientContextWrite(contextCtx, contexts) +} diff --git a/cli/command/context/list_test.go b/cli/command/context/list_test.go new file mode 100644 index 000000000000..1edf34ae2a9c --- /dev/null +++ b/cli/command/context/list_test.go @@ -0,0 +1,62 @@ +package context + +import ( + "testing" + + "github.com/docker/cli/cli/command" + "github.com/docker/cli/cli/context/docker" + "gotest.tools/assert" + "gotest.tools/env" + "gotest.tools/golden" +) + +func createTestContextWithKubeAndSwarm(t *testing.T, cli command.Cli, name string, orchestrator string) { + revert := env.Patch(t, "KUBECONFIG", "./testdata/test-kubeconfig") + defer revert() + + err := runCreate(cli, &createOptions{ + name: name, + defaultStackOrchestrator: orchestrator, + description: "description of " + name, + kubernetes: map[string]string{keyFromCurrent: "true"}, + docker: map[string]string{keyHost: "https://someswarmserver"}, + }) + assert.NilError(t, err) +} + +func TestList(t *testing.T) { + cli, cleanup := makeFakeCli(t) + defer cleanup() + createTestContextWithKubeAndSwarm(t, cli, "current", "all") + createTestContextWithKubeAndSwarm(t, cli, "other", "all") + createTestContextWithKubeAndSwarm(t, cli, "unset", "unset") + cli.SetCurrentContext("current") + cli.OutBuffer().Reset() + assert.NilError(t, runList(cli, &listOptions{})) + golden.Assert(t, cli.OutBuffer().String(), "list.golden") +} + +func TestListNoContext(t *testing.T) { + cli, cleanup := makeFakeCli(t) + defer cleanup() + defer env.Patch(t, "KUBECONFIG", "./testdata/test-kubeconfig")() + cli.SetDockerEndpoint(docker.Endpoint{ + EndpointMeta: docker.EndpointMeta{ + Host: "https://someswarmserver", + }, + }) + cli.OutBuffer().Reset() + assert.NilError(t, runList(cli, &listOptions{})) + golden.Assert(t, cli.OutBuffer().String(), "list.no-context.golden") +} + +func TestListQuiet(t *testing.T) { + cli, cleanup := makeFakeCli(t) + defer cleanup() + createTestContextWithKubeAndSwarm(t, cli, "current", "all") + createTestContextWithKubeAndSwarm(t, cli, "other", "all") + cli.SetCurrentContext("current") + cli.OutBuffer().Reset() + assert.NilError(t, runList(cli, &listOptions{quiet: true})) + golden.Assert(t, cli.OutBuffer().String(), "quiet-list.golden") +} diff --git a/cli/command/context/options.go b/cli/command/context/options.go new file mode 100644 index 000000000000..338e808835d4 --- /dev/null +++ b/cli/command/context/options.go @@ -0,0 +1,221 @@ +package context + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/docker/cli/cli/command" + "github.com/docker/cli/cli/context" + "github.com/docker/cli/cli/context/docker" + "github.com/docker/cli/cli/context/kubernetes" + "github.com/docker/cli/cli/context/store" + "github.com/docker/docker/client" + "github.com/docker/docker/pkg/homedir" + "github.com/pkg/errors" +) + +const ( + keyFromCurrent = "from-current" + keyHost = "host" + keyCA = "ca" + keyCert = "cert" + keyKey = "key" + keySkipTLSVerify = "skip-tls-verify" + keyKubeconfig = "config-file" + keyKubecontext = "context-override" + keyKubenamespace = "namespace-override" +) + +type configKeyDescription struct { + name string + description string +} + +var ( + allowedDockerConfigKeys = map[string]struct{}{ + keyFromCurrent: {}, + keyHost: {}, + keyCA: {}, + keyCert: {}, + keyKey: {}, + keySkipTLSVerify: {}, + } + allowedKubernetesConfigKeys = map[string]struct{}{ + keyFromCurrent: {}, + keyKubeconfig: {}, + keyKubecontext: {}, + keyKubenamespace: {}, + } + dockerConfigKeysDescriptions = []configKeyDescription{ + { + name: keyFromCurrent, + description: "Copy current Docker endpoint configuration", + }, + { + name: keyHost, + description: "Docker endpoint on which to connect", + }, + { + name: keyCA, + description: "Trust certs signed only by this CA", + }, + { + name: keyCert, + description: "Path to TLS certificate file", + }, + { + name: keyKey, + description: "Path to TLS key file", + }, + { + name: keySkipTLSVerify, + description: "Skip TLS certificate validation", + }, + } + kubernetesConfigKeysDescriptions = []configKeyDescription{ + { + name: keyFromCurrent, + description: "Copy current Kubernetes endpoint configuration", + }, + { + name: keyKubeconfig, + description: "Path to a Kubernetes config file", + }, + { + name: keyKubecontext, + description: "Overrides the context set in the kubernetes config file", + }, + { + name: keyKubenamespace, + description: "Overrides the namespace set in the kubernetes config file", + }, + } +) + +func parseBool(config map[string]string, name string) (bool, error) { + strVal, ok := config[name] + if !ok { + return false, nil + } + res, err := strconv.ParseBool(strVal) + return res, errors.Wrap(err, name) +} + +func validateConfig(config map[string]string, allowedKeys map[string]struct{}) error { + var errs []string + for k := range config { + if _, ok := allowedKeys[k]; !ok { + errs = append(errs, fmt.Sprintf("%s: unrecognized config key", k)) + } + } + if len(errs) == 0 { + return nil + } + return errors.New(strings.Join(errs, "\n")) +} + +func getDockerEndpoint(dockerCli command.Cli, config map[string]string) (docker.Endpoint, error) { + if err := validateConfig(config, allowedDockerConfigKeys); err != nil { + return docker.Endpoint{}, err + } + fromCurrent, err := parseBool(config, keyFromCurrent) + if err != nil { + return docker.Endpoint{}, err + } + if fromCurrent { + return dockerCli.DockerEndpoint(), nil + } + tlsData, err := context.TLSDataFromFiles(config[keyCA], config[keyCert], config[keyKey]) + if err != nil { + return docker.Endpoint{}, err + } + skipTLSVerify, err := parseBool(config, keySkipTLSVerify) + if err != nil { + return docker.Endpoint{}, err + } + ep := docker.Endpoint{ + EndpointMeta: docker.EndpointMeta{ + Host: config[keyHost], + SkipTLSVerify: skipTLSVerify, + }, + TLSData: tlsData, + } + // try to resolve a docker client, validating the configuration + opts, err := ep.ClientOpts() + if err != nil { + return docker.Endpoint{}, errors.Wrap(err, "invalid docker endpoint options") + } + if _, err := client.NewClientWithOpts(opts...); err != nil { + return docker.Endpoint{}, errors.Wrap(err, "unable to apply docker endpoint options") + } + return ep, nil +} + +func getDockerEndpointMetadataAndTLS(dockerCli command.Cli, config map[string]string) (docker.EndpointMeta, *store.EndpointTLSData, error) { + ep, err := getDockerEndpoint(dockerCli, config) + if err != nil { + return docker.EndpointMeta{}, nil, err + } + return ep.EndpointMeta, ep.TLSData.ToStoreTLSData(), nil +} + +func getKubernetesEndpoint(dockerCli command.Cli, config map[string]string) (*kubernetes.Endpoint, error) { + if err := validateConfig(config, allowedKubernetesConfigKeys); err != nil { + return nil, err + } + if len(config) == 0 { + return nil, nil + } + fromCurrent, err := parseBool(config, keyFromCurrent) + if err != nil { + return nil, err + } + if fromCurrent { + if dockerCli.CurrentContext() != "" { + ctxMeta, err := dockerCli.ContextStore().GetContextMetadata(dockerCli.CurrentContext()) + if err != nil { + return nil, err + } + endpointMeta := kubernetes.EndpointFromContext(ctxMeta) + if endpointMeta != nil { + res, err := endpointMeta.WithTLSData(dockerCli.ContextStore(), dockerCli.CurrentContext()) + if err != nil { + return nil, err + } + return &res, nil + } + } + // fallback to env-based kubeconfig + kubeconfig := os.Getenv("KUBECONFIG") + if kubeconfig == "" { + kubeconfig = filepath.Join(homedir.Get(), ".kube/config") + } + ep, err := kubernetes.FromKubeConfig(kubeconfig, "", "") + if err != nil { + return nil, err + } + return &ep, nil + } + if config[keyKubeconfig] != "" { + ep, err := kubernetes.FromKubeConfig(config[keyKubeconfig], config[keyKubecontext], config[keyKubenamespace]) + if err != nil { + return nil, err + } + return &ep, nil + } + return nil, nil +} + +func getKubernetesEndpointMetadataAndTLS(dockerCli command.Cli, config map[string]string) (*kubernetes.EndpointMeta, *store.EndpointTLSData, error) { + ep, err := getKubernetesEndpoint(dockerCli, config) + if err != nil { + return nil, nil, err + } + if ep == nil { + return nil, nil, err + } + return &ep.EndpointMeta, ep.TLSData.ToStoreTLSData(), nil +} diff --git a/cli/command/context/remove.go b/cli/command/context/remove.go new file mode 100644 index 000000000000..bacff0b0b821 --- /dev/null +++ b/cli/command/context/remove.go @@ -0,0 +1,66 @@ +package context + +import ( + "errors" + "fmt" + "strings" + + "github.com/docker/cli/cli" + "github.com/docker/cli/cli/command" + "github.com/spf13/cobra" +) + +type removeOptions struct { + force bool +} + +func newRemoveCommand(dockerCli command.Cli) *cobra.Command { + var opts removeOptions + cmd := &cobra.Command{ + Use: "rm CONTEXT [CONTEXT...]", + Aliases: []string{"remove"}, + Short: "Remove one or more contexts", + Args: cli.RequiresMinArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runRemove(dockerCli, opts, args) + }, + } + cmd.Flags().BoolVarP(&opts.force, "force", "f", false, "Force the removal of a context in use") + return cmd +} + +func runRemove(dockerCli command.Cli, opts removeOptions, names []string) error { + var errs []string + currentCtx := dockerCli.CurrentContext() + for _, name := range names { + if name == "default" { + errs = append(errs, `default: context "default" cannot be removed`) + } else if err := doRemove(dockerCli, name, name == currentCtx, opts.force); err != nil { + errs = append(errs, fmt.Sprintf("%s: %s", name, err)) + } else { + fmt.Fprintln(dockerCli.Out(), name) + } + } + if len(errs) > 0 { + return errors.New(strings.Join(errs, "\n")) + } + return nil +} + +func doRemove(dockerCli command.Cli, name string, isCurrent, force bool) error { + if _, err := dockerCli.ContextStore().GetContextMetadata(name); err != nil { + return err + } + if isCurrent { + if !force { + return errors.New("context is in use, set -f flag to force remove") + } + // fallback to DOCKER_HOST + cfg := dockerCli.ConfigFile() + cfg.CurrentContext = "" + if err := cfg.Save(); err != nil { + return err + } + } + return dockerCli.ContextStore().RemoveContext(name) +} diff --git a/cli/command/context/remove_test.go b/cli/command/context/remove_test.go new file mode 100644 index 000000000000..bc6438fb787e --- /dev/null +++ b/cli/command/context/remove_test.go @@ -0,0 +1,64 @@ +package context + +import ( + "io/ioutil" + "os" + "path/filepath" + "testing" + + "github.com/docker/cli/cli/config" + "github.com/docker/cli/cli/config/configfile" + "github.com/docker/cli/cli/context/store" + "gotest.tools/assert" +) + +func TestRemove(t *testing.T) { + cli, cleanup := makeFakeCli(t) + defer cleanup() + createTestContextWithKubeAndSwarm(t, cli, "current", "all") + createTestContextWithKubeAndSwarm(t, cli, "other", "all") + assert.NilError(t, runRemove(cli, removeOptions{}, []string{"other"})) + _, err := cli.ContextStore().GetContextMetadata("current") + assert.NilError(t, err) + _, err = cli.ContextStore().GetContextMetadata("other") + assert.Check(t, store.IsErrContextDoesNotExist(err)) +} + +func TestRemoveNotAContext(t *testing.T) { + cli, cleanup := makeFakeCli(t) + defer cleanup() + createTestContextWithKubeAndSwarm(t, cli, "current", "all") + createTestContextWithKubeAndSwarm(t, cli, "other", "all") + err := runRemove(cli, removeOptions{}, []string{"not-a-context"}) + assert.ErrorContains(t, err, `context "not-a-context" does not exist`) +} + +func TestRemoveCurrent(t *testing.T) { + cli, cleanup := makeFakeCli(t) + defer cleanup() + createTestContextWithKubeAndSwarm(t, cli, "current", "all") + createTestContextWithKubeAndSwarm(t, cli, "other", "all") + cli.SetCurrentContext("current") + err := runRemove(cli, removeOptions{}, []string{"current"}) + assert.ErrorContains(t, err, "current: context is in use, set -f flag to force remove") +} + +func TestRemoveCurrentForce(t *testing.T) { + configDir, err := ioutil.TempDir("", t.Name()+"config") + assert.NilError(t, err) + defer os.RemoveAll(configDir) + configFilePath := filepath.Join(configDir, "config.json") + testCfg := configfile.New(configFilePath) + testCfg.CurrentContext = "current" + assert.NilError(t, testCfg.Save()) + + cli, cleanup := makeFakeCli(t, withCliConfig(testCfg)) + defer cleanup() + createTestContextWithKubeAndSwarm(t, cli, "current", "all") + createTestContextWithKubeAndSwarm(t, cli, "other", "all") + cli.SetCurrentContext("current") + assert.NilError(t, runRemove(cli, removeOptions{force: true}, []string{"current"})) + reloadedConfig, err := config.Load(configDir) + assert.NilError(t, err) + assert.Equal(t, "", reloadedConfig.CurrentContext) +} diff --git a/cli/command/context/testdata/inspect.golden b/cli/command/context/testdata/inspect.golden new file mode 100644 index 000000000000..d520b4f93ca3 --- /dev/null +++ b/cli/command/context/testdata/inspect.golden @@ -0,0 +1,31 @@ +[ + { + "Name": "current", + "Metadata": { + "Description": "description of current", + "StackOrchestrator": "all" + }, + "Endpoints": { + "docker": { + "Host": "https://someswarmserver", + "SkipTLSVerify": false + }, + "kubernetes": { + "Host": "https://someserver", + "SkipTLSVerify": false, + "DefaultNamespace": "default" + } + }, + "TLSMaterial": { + "kubernetes": [ + "ca.pem", + "cert.pem", + "key.pem" + ] + }, + "Storage": { + "MetadataPath": "", + "TLSPath": "" + } + } +] diff --git a/cli/command/context/testdata/list.golden b/cli/command/context/testdata/list.golden new file mode 100644 index 000000000000..c32be2e28c2c --- /dev/null +++ b/cli/command/context/testdata/list.golden @@ -0,0 +1,5 @@ +NAME DESCRIPTION DOCKER ENDPOINT KUBERNETES ENDPOINT ORCHESTRATOR +current * description of current https://someswarmserver https://someserver (default) all +default Current DOCKER_HOST based configuration +other description of other https://someswarmserver https://someserver (default) all +unset description of unset https://someswarmserver https://someserver (default) diff --git a/cli/command/context/testdata/list.no-context.golden b/cli/command/context/testdata/list.no-context.golden new file mode 100644 index 000000000000..5e11422f00f3 --- /dev/null +++ b/cli/command/context/testdata/list.no-context.golden @@ -0,0 +1,2 @@ +NAME DESCRIPTION DOCKER ENDPOINT KUBERNETES ENDPOINT ORCHESTRATOR +default * Current DOCKER_HOST based configuration https://someswarmserver https://someserver (default) swarm diff --git a/cli/command/context/testdata/quiet-list.golden b/cli/command/context/testdata/quiet-list.golden new file mode 100644 index 000000000000..c9bef2c3e473 --- /dev/null +++ b/cli/command/context/testdata/quiet-list.golden @@ -0,0 +1,2 @@ +current +other diff --git a/cli/command/context/testdata/test-kubeconfig b/cli/command/context/testdata/test-kubeconfig new file mode 100644 index 000000000000..f6baf8e843de --- /dev/null +++ b/cli/command/context/testdata/test-kubeconfig @@ -0,0 +1,19 @@ +apiVersion: v1 +clusters: +- cluster: + certificate-authority-data: dGhlLWNh + server: https://someserver + name: test-cluster +contexts: +- context: + cluster: test-cluster + user: test-user + name: test +current-context: test +kind: Config +preferences: {} +users: +- name: test-user + user: + client-certificate-data: dGhlLWNlcnQ= + client-key-data: dGhlLWtleQ== diff --git a/cli/command/context/update.go b/cli/command/context/update.go new file mode 100644 index 000000000000..24fae3b61f3b --- /dev/null +++ b/cli/command/context/update.go @@ -0,0 +1,142 @@ +package context + +import ( + "bytes" + "fmt" + "text/tabwriter" + + "github.com/docker/cli/cli" + "github.com/docker/cli/cli/command" + "github.com/docker/cli/cli/context/docker" + "github.com/docker/cli/cli/context/kubernetes" + "github.com/docker/cli/cli/context/store" + "github.com/pkg/errors" + "github.com/spf13/cobra" +) + +type updateOptions struct { + name string + description string + defaultStackOrchestrator string + docker map[string]string + kubernetes map[string]string +} + +func longUpdateDescription() string { + buf := bytes.NewBuffer(nil) + buf.WriteString("Update a context\n\nDocker endpoint config:\n\n") + tw := tabwriter.NewWriter(buf, 20, 1, 3, ' ', 0) + fmt.Fprintln(tw, "NAME\tDESCRIPTION") + for _, d := range dockerConfigKeysDescriptions { + fmt.Fprintf(tw, "%s\t%s\n", d.name, d.description) + } + tw.Flush() + buf.WriteString("\nKubernetes endpoint config:\n\n") + tw = tabwriter.NewWriter(buf, 20, 1, 3, ' ', 0) + fmt.Fprintln(tw, "NAME\tDESCRIPTION") + for _, d := range kubernetesConfigKeysDescriptions { + fmt.Fprintf(tw, "%s\t%s\n", d.name, d.description) + } + tw.Flush() + buf.WriteString("\nExample:\n\n$ docker context update my-context --description \"some description\" --docker \"host=tcp://myserver:2376,ca=~/ca-file,cert=~/cert-file,key=~/key-file\"\n") + return buf.String() +} + +func newUpdateCommand(dockerCli command.Cli) *cobra.Command { + opts := &updateOptions{} + cmd := &cobra.Command{ + Use: "update [OPTIONS] CONTEXT", + Short: "Update a context", + Args: cli.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + opts.name = args[0] + return runUpdate(dockerCli, opts) + }, + Long: longUpdateDescription(), + } + flags := cmd.Flags() + flags.StringVar(&opts.description, "description", "", "Description of the context") + flags.StringVar( + &opts.defaultStackOrchestrator, + "default-stack-orchestrator", "", + "Default orchestrator for stack operations to use with this context (swarm|kubernetes|all)") + flags.StringToStringVar(&opts.docker, "docker", nil, "set the docker endpoint") + flags.StringToStringVar(&opts.kubernetes, "kubernetes", nil, "set the kubernetes endpoint") + return cmd +} + +func runUpdate(cli command.Cli, o *updateOptions) error { + if err := validateContextName(o.name); err != nil { + return err + } + s := cli.ContextStore() + c, err := s.GetContextMetadata(o.name) + if err != nil { + return err + } + dockerContext, err := command.GetDockerContext(c) + if err != nil { + return err + } + if o.defaultStackOrchestrator != "" { + stackOrchestrator, err := command.NormalizeOrchestrator(o.defaultStackOrchestrator) + if err != nil { + return errors.Wrap(err, "unable to parse default-stack-orchestrator") + } + dockerContext.StackOrchestrator = stackOrchestrator + } + if o.description != "" { + dockerContext.Description = o.description + } + + c.Metadata = dockerContext + + tlsDataToReset := make(map[string]*store.EndpointTLSData) + + if o.docker != nil { + dockerEP, dockerTLS, err := getDockerEndpointMetadataAndTLS(cli, o.docker) + if err != nil { + return errors.Wrap(err, "unable to create docker endpoint config") + } + c.Endpoints[docker.DockerEndpoint] = dockerEP + tlsDataToReset[docker.DockerEndpoint] = dockerTLS + } + if o.kubernetes != nil { + kubernetesEP, kubernetesTLS, err := getKubernetesEndpointMetadataAndTLS(cli, o.kubernetes) + if err != nil { + return errors.Wrap(err, "unable to create kubernetes endpoint config") + } + if kubernetesEP == nil { + delete(c.Endpoints, kubernetes.KubernetesEndpoint) + } else { + c.Endpoints[kubernetes.KubernetesEndpoint] = kubernetesEP + tlsDataToReset[kubernetes.KubernetesEndpoint] = kubernetesTLS + } + } + if err := validateEndpointsAndOrchestrator(c); err != nil { + return err + } + if err := s.CreateOrUpdateContext(c); err != nil { + return err + } + for ep, tlsData := range tlsDataToReset { + if err := s.ResetContextEndpointTLSMaterial(o.name, ep, tlsData); err != nil { + return err + } + } + + fmt.Fprintln(cli.Out(), o.name) + fmt.Fprintf(cli.Err(), "Successfully updated context %q\n", o.name) + return nil +} + +func validateEndpointsAndOrchestrator(c store.ContextMetadata) error { + dockerContext, err := command.GetDockerContext(c) + if err != nil { + return err + } + if _, ok := c.Endpoints[kubernetes.KubernetesEndpoint]; !ok && dockerContext.StackOrchestrator.HasKubernetes() { + return errors.Errorf("cannot specify orchestrator %q without configuring a Kubernetes endpoint", dockerContext.StackOrchestrator) + } + return nil +} diff --git a/cli/command/context/update_test.go b/cli/command/context/update_test.go new file mode 100644 index 000000000000..49109bf9dd4d --- /dev/null +++ b/cli/command/context/update_test.go @@ -0,0 +1,102 @@ +package context + +import ( + "testing" + + "github.com/docker/cli/cli/command" + "github.com/docker/cli/cli/context/docker" + "github.com/docker/cli/cli/context/kubernetes" + "gotest.tools/assert" + "gotest.tools/assert/cmp" +) + +func TestUpdateDescriptionOnly(t *testing.T) { + cli, cleanup := makeFakeCli(t) + defer cleanup() + err := runCreate(cli, &createOptions{ + name: "test", + defaultStackOrchestrator: "swarm", + docker: map[string]string{}, + }) + assert.NilError(t, err) + cli.OutBuffer().Reset() + cli.ErrBuffer().Reset() + assert.NilError(t, runUpdate(cli, &updateOptions{ + name: "test", + description: "description", + })) + c, err := cli.ContextStore().GetContextMetadata("test") + assert.NilError(t, err) + dc, err := command.GetDockerContext(c) + assert.NilError(t, err) + assert.Equal(t, dc.StackOrchestrator, command.OrchestratorSwarm) + assert.Equal(t, dc.Description, "description") + + assert.Equal(t, "test\n", cli.OutBuffer().String()) + assert.Equal(t, "Successfully updated context \"test\"\n", cli.ErrBuffer().String()) +} + +func TestUpdateDockerOnly(t *testing.T) { + cli, cleanup := makeFakeCli(t) + defer cleanup() + createTestContextWithKubeAndSwarm(t, cli, "test", "swarm") + assert.NilError(t, runUpdate(cli, &updateOptions{ + name: "test", + docker: map[string]string{ + keyHost: "tcp://some-host", + }, + })) + c, err := cli.ContextStore().GetContextMetadata("test") + assert.NilError(t, err) + dc, err := command.GetDockerContext(c) + assert.NilError(t, err) + assert.Equal(t, dc.StackOrchestrator, command.OrchestratorSwarm) + assert.Equal(t, dc.Description, "description of test") + assert.Check(t, cmp.Contains(c.Endpoints, kubernetes.KubernetesEndpoint)) + assert.Check(t, cmp.Contains(c.Endpoints, docker.DockerEndpoint)) + assert.Equal(t, c.Endpoints[docker.DockerEndpoint].(docker.EndpointMeta).Host, "tcp://some-host") +} + +func TestUpdateStackOrchestratorStrategy(t *testing.T) { + cli, cleanup := makeFakeCli(t) + defer cleanup() + err := runCreate(cli, &createOptions{ + name: "test", + defaultStackOrchestrator: "swarm", + docker: map[string]string{}, + }) + assert.NilError(t, err) + err = runUpdate(cli, &updateOptions{ + name: "test", + defaultStackOrchestrator: "kubernetes", + }) + assert.ErrorContains(t, err, `cannot specify orchestrator "kubernetes" without configuring a Kubernetes endpoint`) +} + +func TestUpdateStackOrchestratorStrategyRemoveKubeEndpoint(t *testing.T) { + cli, cleanup := makeFakeCli(t) + defer cleanup() + createTestContextWithKubeAndSwarm(t, cli, "test", "kubernetes") + err := runUpdate(cli, &updateOptions{ + name: "test", + kubernetes: map[string]string{}, + }) + assert.ErrorContains(t, err, `cannot specify orchestrator "kubernetes" without configuring a Kubernetes endpoint`) +} + +func TestUpdateInvalidDockerHost(t *testing.T) { + cli, cleanup := makeFakeCli(t) + defer cleanup() + err := runCreate(cli, &createOptions{ + name: "test", + docker: map[string]string{}, + }) + assert.NilError(t, err) + err = runUpdate(cli, &updateOptions{ + name: "test", + docker: map[string]string{ + keyHost: "some///invalid/host", + }, + }) + assert.ErrorContains(t, err, "unable to parse docker host") +} diff --git a/cli/command/context/use.go b/cli/command/context/use.go new file mode 100644 index 000000000000..bdffda3c9fb6 --- /dev/null +++ b/cli/command/context/use.go @@ -0,0 +1,39 @@ +package context + +import ( + "fmt" + + "github.com/docker/cli/cli/command" + "github.com/spf13/cobra" +) + +func newUseCommand(dockerCli command.Cli) *cobra.Command { + cmd := &cobra.Command{ + Use: "use CONTEXT", + Short: "Set the current docker context", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + name := args[0] + + if err := validateContextName(name); err != nil && name != "default" { + return err + } + if _, err := dockerCli.ContextStore().GetContextMetadata(name); err != nil && name != "default" { + return err + } + configValue := name + if configValue == "default" { + configValue = "" + } + dockerConfig := dockerCli.ConfigFile() + dockerConfig.CurrentContext = configValue + if err := dockerConfig.Save(); err != nil { + return err + } + fmt.Fprintln(dockerCli.Out(), name) + fmt.Fprintf(dockerCli.Err(), "Current context is now %q\n", name) + return nil + }, + } + return cmd +} diff --git a/cli/command/context/use_test.go b/cli/command/context/use_test.go new file mode 100644 index 000000000000..7fd309b47af4 --- /dev/null +++ b/cli/command/context/use_test.go @@ -0,0 +1,49 @@ +package context + +import ( + "io/ioutil" + "os" + "path/filepath" + "testing" + + "github.com/docker/cli/cli/config" + "github.com/docker/cli/cli/config/configfile" + "github.com/docker/cli/cli/context/store" + "gotest.tools/assert" +) + +func TestUse(t *testing.T) { + configDir, err := ioutil.TempDir("", t.Name()+"config") + assert.NilError(t, err) + defer os.RemoveAll(configDir) + configFilePath := filepath.Join(configDir, "config.json") + testCfg := configfile.New(configFilePath) + cli, cleanup := makeFakeCli(t, withCliConfig(testCfg)) + defer cleanup() + err = runCreate(cli, &createOptions{ + name: "test", + docker: map[string]string{}, + }) + assert.NilError(t, err) + assert.NilError(t, newUseCommand(cli).RunE(nil, []string{"test"})) + reloadedConfig, err := config.Load(configDir) + assert.NilError(t, err) + assert.Equal(t, "test", reloadedConfig.CurrentContext) + + // switch back to default + cli.OutBuffer().Reset() + cli.ErrBuffer().Reset() + assert.NilError(t, newUseCommand(cli).RunE(nil, []string{"default"})) + reloadedConfig, err = config.Load(configDir) + assert.NilError(t, err) + assert.Equal(t, "", reloadedConfig.CurrentContext) + assert.Equal(t, "default\n", cli.OutBuffer().String()) + assert.Equal(t, "Current context is now \"default\"\n", cli.ErrBuffer().String()) +} + +func TestUseNoExist(t *testing.T) { + cli, cleanup := makeFakeCli(t) + defer cleanup() + err := newUseCommand(cli).RunE(nil, []string{"test"}) + assert.Check(t, store.IsErrContextDoesNotExist(err)) +} diff --git a/cli/command/formatter/context.go b/cli/command/formatter/context.go new file mode 100644 index 000000000000..93f86f6a2030 --- /dev/null +++ b/cli/command/formatter/context.go @@ -0,0 +1,90 @@ +package formatter + +const ( + // ClientContextTableFormat is the default client context format + ClientContextTableFormat = "table {{.Name}}{{if .Current}} *{{end}}\t{{.Description}}\t{{.DockerEndpoint}}\t{{.KubernetesEndpoint}}\t{{.StackOrchestrator}}" + + dockerEndpointHeader = "DOCKER ENDPOINT" + kubernetesEndpointHeader = "KUBERNETES ENDPOINT" + stackOrchestrastorHeader = "ORCHESTRATOR" + quietContextFormat = "{{.Name}}" +) + +// NewClientContextFormat returns a Format for rendering using a Context +func NewClientContextFormat(source string, quiet bool) Format { + if quiet { + return Format(quietContextFormat) + } + if source == TableFormatKey { + return Format(ClientContextTableFormat) + } + return Format(source) +} + +// ClientContext is a context for display +type ClientContext struct { + Name string + Description string + DockerEndpoint string + KubernetesEndpoint string + StackOrchestrator string + Current bool +} + +// ClientContextWrite writes formatted contexts using the Context +func ClientContextWrite(ctx Context, contexts []*ClientContext) error { + render := func(format func(subContext SubContext) error) error { + for _, context := range contexts { + if err := format(&clientContextContext{c: context}); err != nil { + return err + } + } + return nil + } + return ctx.Write(newClientContextContext(), render) +} + +type clientContextContext struct { + HeaderContext + c *ClientContext +} + +func newClientContextContext() *clientContextContext { + ctx := clientContextContext{} + ctx.Header = SubHeaderContext{ + "Name": NameHeader, + "Description": DescriptionHeader, + "DockerEndpoint": dockerEndpointHeader, + "KubernetesEndpoint": kubernetesEndpointHeader, + "StackOrchestrator": stackOrchestrastorHeader, + } + return &ctx +} + +func (c *clientContextContext) MarshalJSON() ([]byte, error) { + return MarshalJSON(c) +} + +func (c *clientContextContext) Current() bool { + return c.c.Current +} + +func (c *clientContextContext) Name() string { + return c.c.Name +} + +func (c *clientContextContext) Description() string { + return c.c.Description +} + +func (c *clientContextContext) DockerEndpoint() string { + return c.c.DockerEndpoint +} + +func (c *clientContextContext) KubernetesEndpoint() string { + return c.c.KubernetesEndpoint +} + +func (c *clientContextContext) StackOrchestrator() string { + return c.c.StackOrchestrator +} diff --git a/cli/command/orchestrator.go b/cli/command/orchestrator.go index c71b3f8963d3..b051c4a20735 100644 --- a/cli/command/orchestrator.go +++ b/cli/command/orchestrator.go @@ -16,7 +16,7 @@ const ( OrchestratorSwarm = Orchestrator("swarm") // OrchestratorAll orchestrator OrchestratorAll = Orchestrator("all") - orchestratorUnset = Orchestrator("unset") + orchestratorUnset = Orchestrator("") defaultOrchestrator = OrchestratorSwarm envVarDockerStackOrchestrator = "DOCKER_STACK_ORCHESTRATOR" @@ -44,7 +44,7 @@ func normalize(value string) (Orchestrator, error) { return OrchestratorKubernetes, nil case "swarm": return OrchestratorSwarm, nil - case "", "unset": + case "", "unset": // unset is the old value for orchestratorUnset. Keep accepting this for backward compat return orchestratorUnset, nil case "all": return OrchestratorAll, nil diff --git a/cli/command/system/version.go b/cli/command/system/version.go index 19407a14261d..b6c7db30cf7b 100644 --- a/cli/command/system/version.go +++ b/cli/command/system/version.go @@ -241,7 +241,7 @@ func getKubernetesVersion(dockerCli command.Cli, kubeConfig string) *kubernetesV clientConfig clientcmd.ClientConfig err error ) - if dockerCli.CurrentContext() == command.ContextDockerHost { + if dockerCli.CurrentContext() == "" { clientConfig = kubernetes.NewKubernetesConfig(kubeConfig) } else { clientConfig, err = kubecontext.ConfigFromContext(dockerCli.CurrentContext(), dockerCli.ContextStore()) diff --git a/cli/context/docker/load.go b/cli/context/docker/load.go index 4e0316a2383f..5661fa915444 100644 --- a/cli/context/docker/load.go +++ b/cli/context/docker/load.go @@ -20,10 +20,7 @@ import ( // EndpointMeta is a typed wrapper around a context-store generic endpoint describing // a Docker Engine endpoint, without its tls config -type EndpointMeta struct { - context.EndpointMetaBase - APIVersion string `json:"api_version,omitempty"` -} +type EndpointMeta = context.EndpointMetaBase // Endpoint is a typed wrapper around a context-store generic endpoint describing // a Docker Engine endpoint, with its tls data @@ -34,13 +31,13 @@ type Endpoint struct { } // WithTLSData loads TLS materials for the endpoint -func (c *EndpointMeta) WithTLSData(s store.Store, contextName string) (Endpoint, error) { +func WithTLSData(s store.Store, contextName string, m EndpointMeta) (Endpoint, error) { tlsData, err := context.LoadTLSData(s, contextName, DockerEndpoint) if err != nil { return Endpoint{}, err } return Endpoint{ - EndpointMeta: *c, + EndpointMeta: m, TLSData: tlsData, }, nil } @@ -128,9 +125,6 @@ func (c *Endpoint) ClientOpts() ([]func(*client.Client) error, error) { } version := os.Getenv("DOCKER_API_VERSION") - if version == "" { - version = c.APIVersion - } if version != "" { result = append(result, client.WithVersion(version)) } diff --git a/cli/context/endpoint.go b/cli/context/endpoint.go index 806a8524ef85..f2735246eafa 100644 --- a/cli/context/endpoint.go +++ b/cli/context/endpoint.go @@ -2,6 +2,6 @@ package context // EndpointMetaBase contains fields we expect to be common for most context endpoints type EndpointMetaBase struct { - Host string `json:"host,omitempty"` - SkipTLSVerify bool `json:"skip_tls_verify"` + Host string `json:",omitempty"` + SkipTLSVerify bool } diff --git a/cli/context/kubernetes/endpoint_test.go b/cli/context/kubernetes/endpoint_test.go index 57b308b8f739..da124851a504 100644 --- a/cli/context/kubernetes/endpoint_test.go +++ b/cli/context/kubernetes/endpoint_test.go @@ -12,7 +12,7 @@ import ( clientcmdapi "k8s.io/client-go/tools/clientcmd/api" ) -func testEndpoint(server, defaultNamespace string, ca, cert, key []byte, skipTLSVerify bool) *Endpoint { +func testEndpoint(server, defaultNamespace string, ca, cert, key []byte, skipTLSVerify bool) Endpoint { var tlsData *context.TLSData if ca != nil || cert != nil || key != nil { tlsData = &context.TLSData{ @@ -21,7 +21,7 @@ func testEndpoint(server, defaultNamespace string, ca, cert, key []byte, skipTLS Key: key, } } - return &Endpoint{ + return Endpoint{ EndpointMeta: EndpointMeta{ EndpointMetaBase: context.EndpointMetaBase{ Host: server, @@ -45,9 +45,9 @@ func TestSaveLoadContexts(t *testing.T) { assert.NilError(t, err) defer os.RemoveAll(storeDir) store := store.New(storeDir, testStoreCfg) - assert.NilError(t, testEndpoint("https://test", "test", nil, nil, nil, false).Save(store, "raw-notls")) - assert.NilError(t, testEndpoint("https://test", "test", nil, nil, nil, true).Save(store, "raw-notls-skip")) - assert.NilError(t, testEndpoint("https://test", "test", []byte("ca"), []byte("cert"), []byte("key"), true).Save(store, "raw-tls")) + assert.NilError(t, save(store, testEndpoint("https://test", "test", nil, nil, nil, false), "raw-notls")) + assert.NilError(t, save(store, testEndpoint("https://test", "test", nil, nil, nil, true), "raw-notls-skip")) + assert.NilError(t, save(store, testEndpoint("https://test", "test", []byte("ca"), []byte("cert"), []byte("key"), true), "raw-tls")) kcFile, err := ioutil.TempFile(os.TempDir(), "test-load-save-k8-context") assert.NilError(t, err) @@ -82,8 +82,8 @@ func TestSaveLoadContexts(t *testing.T) { assert.NilError(t, err) epContext2, err := FromKubeConfig(kcFile.Name(), "context2", "namespace-override") assert.NilError(t, err) - assert.NilError(t, epDefault.Save(store, "embed-default-context")) - assert.NilError(t, epContext2.Save(store, "embed-context2")) + assert.NilError(t, save(store, epDefault, "embed-default-context")) + assert.NilError(t, save(store, epContext2, "embed-context2")) rawNoTLSMeta, err := store.GetContextMetadata("raw-notls") assert.NilError(t, err) @@ -132,6 +132,19 @@ func checkClientConfig(t *testing.T, s store.Store, ep Endpoint, server, namespa assert.Equal(t, skipTLSVerify, cfg.Insecure) } +func save(s store.Store, ep Endpoint, name string) error { + meta := store.ContextMetadata{ + Endpoints: map[string]interface{}{ + KubernetesEndpoint: ep.EndpointMeta, + }, + Name: name, + } + if err := s.CreateOrUpdateContext(meta); err != nil { + return err + } + return s.ResetContextEndpointTLSMaterial(name, KubernetesEndpoint, ep.TLSData.ToStoreTLSData()) +} + func TestSaveLoadGKEConfig(t *testing.T) { storeDir, err := ioutil.TempDir("", t.Name()) assert.NilError(t, err) @@ -144,7 +157,7 @@ func TestSaveLoadGKEConfig(t *testing.T) { assert.NilError(t, err) ep, err := FromKubeConfig("testdata/gke-kubeconfig", "", "") assert.NilError(t, err) - assert.NilError(t, ep.Save(store, "gke-context")) + assert.NilError(t, save(store, ep, "gke-context")) persistedMetadata, err := store.GetContextMetadata("gke-context") assert.NilError(t, err) persistedEPMeta := EndpointFromContext(persistedMetadata) @@ -169,7 +182,7 @@ func TestSaveLoadEKSConfig(t *testing.T) { assert.NilError(t, err) ep, err := FromKubeConfig("testdata/eks-kubeconfig", "", "") assert.NilError(t, err) - assert.NilError(t, ep.Save(store, "eks-context")) + assert.NilError(t, save(store, ep, "eks-context")) persistedMetadata, err := store.GetContextMetadata("eks-context") assert.NilError(t, err) persistedEPMeta := EndpointFromContext(persistedMetadata) diff --git a/cli/context/kubernetes/load.go b/cli/context/kubernetes/load.go index 1898f5742254..803fd8c812e2 100644 --- a/cli/context/kubernetes/load.go +++ b/cli/context/kubernetes/load.go @@ -12,9 +12,9 @@ import ( // a Kubernetes endpoint, without TLS data type EndpointMeta struct { context.EndpointMetaBase - DefaultNamespace string `json:"default_namespace,omitempty"` - AuthProvider *clientcmdapi.AuthProviderConfig `json:"auth_provider,omitempty"` - Exec *clientcmdapi.ExecConfig `json:"exec,omitempty"` + DefaultNamespace string `json:",omitempty"` + AuthProvider *clientcmdapi.AuthProviderConfig `json:",omitempty"` + Exec *clientcmdapi.ExecConfig `json:",omitempty"` } // Endpoint is a typed wrapper around a context-store generic endpoint describing diff --git a/cli/context/kubernetes/save.go b/cli/context/kubernetes/save.go index 35646bc57ca5..464a68caf474 100644 --- a/cli/context/kubernetes/save.go +++ b/cli/context/kubernetes/save.go @@ -4,7 +4,6 @@ import ( "io/ioutil" "github.com/docker/cli/cli/context" - "github.com/docker/cli/cli/context/store" "k8s.io/client-go/tools/clientcmd" clientcmdapi "k8s.io/client-go/tools/clientcmd/api" ) @@ -60,20 +59,3 @@ func readFileOrDefault(path string, defaultValue []byte) ([]byte, error) { } return defaultValue, nil } - -// Save the endpoint metadata and TLS bundle in the context store -func (ep *Endpoint) Save(s store.Store, contextName string) error { - tlsData := ep.TLSData.ToStoreTLSData() - existingContext, err := s.GetContextMetadata(contextName) - if err != nil && !store.IsErrContextDoesNotExist(err) { - return err - } - if existingContext.Endpoints == nil { - existingContext.Endpoints = make(map[string]interface{}) - } - existingContext.Endpoints[KubernetesEndpoint] = ep.EndpointMeta - if err := s.CreateOrUpdateContext(contextName, existingContext); err != nil { - return err - } - return s.ResetContextEndpointTLSMaterial(contextName, KubernetesEndpoint, tlsData) -} diff --git a/cli/context/store/metadata_test.go b/cli/context/store/metadata_test.go index f8121ec20cf8..ef77cc4eea86 100644 --- a/cli/context/store/metadata_test.go +++ b/cli/context/store/metadata_test.go @@ -10,11 +10,14 @@ import ( "gotest.tools/assert/cmp" ) -var testMetadata = ContextMetadata{ - Endpoints: map[string]interface{}{ - "ep1": endpoint{Foo: "bar"}, - }, - Metadata: context{Bar: "baz"}, +func testMetadata(name string) ContextMetadata { + return ContextMetadata{ + Endpoints: map[string]interface{}{ + "ep1": endpoint{Foo: "bar"}, + }, + Metadata: context{Bar: "baz"}, + Name: name, + } } func TestMetadataGetNotExisting(t *testing.T) { @@ -37,26 +40,28 @@ func TestMetadataCreateGetRemove(t *testing.T) { "ep2": endpoint{Foo: "bee"}, }, Metadata: context{Bar: "foo"}, + Name: "test-context", } - err = testee.createOrUpdate("test-context", testMetadata) + testMeta := testMetadata("test-context") + err = testee.createOrUpdate(testMeta) assert.NilError(t, err) // create a new instance to check it does not depend on some sort of state testee = metadataStore{root: testDir, config: testCfg} - meta, err := testee.get("test-context") + meta, err := testee.get(contextdirOf("test-context")) assert.NilError(t, err) - assert.DeepEqual(t, meta, testMetadata) + assert.DeepEqual(t, meta, testMeta) // update - err = testee.createOrUpdate("test-context", expected2) + err = testee.createOrUpdate(expected2) assert.NilError(t, err) - meta, err = testee.get("test-context") + meta, err = testee.get(contextdirOf("test-context")) assert.NilError(t, err) assert.DeepEqual(t, meta, expected2) - assert.NilError(t, testee.remove("test-context")) - assert.NilError(t, testee.remove("test-context")) // support duplicate remove - _, err = testee.get("test-context") + assert.NilError(t, testee.remove(contextdirOf("test-context"))) + assert.NilError(t, testee.remove(contextdirOf("test-context"))) // support duplicate remove + _, err = testee.get(contextdirOf("test-context")) assert.Assert(t, IsErrContextDoesNotExist(err)) } @@ -65,8 +70,8 @@ func TestMetadataRespectJsonAnnotation(t *testing.T) { assert.NilError(t, err) defer os.RemoveAll(testDir) testee := metadataStore{root: testDir, config: testCfg} - assert.NilError(t, testee.createOrUpdate("test", testMetadata)) - bytes, err := ioutil.ReadFile(filepath.Join(testDir, "test", "meta.json")) + assert.NilError(t, testee.createOrUpdate(testMetadata("test"))) + bytes, err := ioutil.ReadFile(filepath.Join(testDir, string(contextdirOf("test")), "meta.json")) assert.NilError(t, err) assert.Assert(t, cmp.Contains(string(bytes), "a_very_recognizable_field_name")) assert.Assert(t, cmp.Contains(string(bytes), "another_very_recognizable_field_name")) @@ -77,16 +82,14 @@ func TestMetadataList(t *testing.T) { assert.NilError(t, err) defer os.RemoveAll(testDir) testee := metadataStore{root: testDir, config: testCfg} - wholeData := map[string]ContextMetadata{ - "simple": testMetadata, - "simple2": testMetadata, - "nested/context": testMetadata, - "nestedwith-parent/context": testMetadata, - "nestedwith-parent": testMetadata, + wholeData := []ContextMetadata{ + testMetadata("context1"), + testMetadata("context2"), + testMetadata("context3"), } - for k, s := range wholeData { - err = testee.createOrUpdate(k, s) + for _, s := range wholeData { + err = testee.createOrUpdate(s) assert.NilError(t, err) } @@ -100,16 +103,14 @@ func TestEmptyConfig(t *testing.T) { assert.NilError(t, err) defer os.RemoveAll(testDir) testee := metadataStore{root: testDir} - wholeData := map[string]ContextMetadata{ - "simple": testMetadata, - "simple2": testMetadata, - "nested/context": testMetadata, - "nestedwith-parent/context": testMetadata, - "nestedwith-parent": testMetadata, + wholeData := []ContextMetadata{ + testMetadata("context1"), + testMetadata("context2"), + testMetadata("context3"), } - for k, s := range wholeData { - err = testee.createOrUpdate(k, s) + for _, s := range wholeData { + err = testee.createOrUpdate(s) assert.NilError(t, err) } @@ -135,8 +136,8 @@ func TestWithEmbedding(t *testing.T) { Val: "Hello", }, } - assert.NilError(t, testee.createOrUpdate("test", ContextMetadata{Metadata: testCtxMeta})) - res, err := testee.get("test") + assert.NilError(t, testee.createOrUpdate(ContextMetadata{Metadata: testCtxMeta, Name: "test"})) + res, err := testee.get(contextdirOf("test")) assert.NilError(t, err) assert.Equal(t, testCtxMeta, res.Metadata) } diff --git a/cli/context/store/metadatastore.go b/cli/context/store/metadatastore.go index 178cf9e2887e..47aacdc5f8ee 100644 --- a/cli/context/store/metadatastore.go +++ b/cli/context/store/metadatastore.go @@ -7,6 +7,9 @@ import ( "os" "path/filepath" "reflect" + "sort" + + "vbom.ml/util/sortorder" ) const ( @@ -19,12 +22,12 @@ type metadataStore struct { config Config } -func (s *metadataStore) contextDir(name string) string { - return filepath.Join(s.root, name) +func (s *metadataStore) contextDir(id contextdir) string { + return filepath.Join(s.root, string(id)) } -func (s *metadataStore) createOrUpdate(name string, meta ContextMetadata) error { - contextDir := s.contextDir(name) +func (s *metadataStore) createOrUpdate(meta ContextMetadata) error { + contextDir := s.contextDir(contextdirOf(meta.Name)) if err := os.MkdirAll(contextDir, 0755); err != nil { return err } @@ -53,11 +56,11 @@ func parseTypedOrMap(payload []byte, getter TypeGetter) (interface{}, error) { return reflect.ValueOf(typed).Elem().Interface(), nil } -func (s *metadataStore) get(name string) (ContextMetadata, error) { - contextDir := s.contextDir(name) +func (s *metadataStore) get(id contextdir) (ContextMetadata, error) { + contextDir := s.contextDir(id) bytes, err := ioutil.ReadFile(filepath.Join(contextDir, metaFile)) if err != nil { - return ContextMetadata{}, convertContextDoesNotExist(name, err) + return ContextMetadata{}, convertContextDoesNotExist(err) } var untyped untypedContextMetadata r := ContextMetadata{ @@ -66,6 +69,7 @@ func (s *metadataStore) get(name string) (ContextMetadata, error) { if err := json.Unmarshal(bytes, &untyped); err != nil { return ContextMetadata{}, err } + r.Name = untyped.Name if r.Metadata, err = parseTypedOrMap(untyped.Metadata, s.config.contextType); err != nil { return ContextMetadata{}, err } @@ -77,28 +81,30 @@ func (s *metadataStore) get(name string) (ContextMetadata, error) { return r, err } -func (s *metadataStore) remove(name string) error { - contextDir := s.contextDir(name) +func (s *metadataStore) remove(id contextdir) error { + contextDir := s.contextDir(id) return os.RemoveAll(contextDir) } -func (s *metadataStore) list() (map[string]ContextMetadata, error) { - ctxNames, err := listRecursivelyMetadataDirs(s.root) +func (s *metadataStore) list() ([]ContextMetadata, error) { + ctxDirs, err := listRecursivelyMetadataDirs(s.root) if err != nil { if os.IsNotExist(err) { - // store is empty, meta dir does not exist yet - // this should not be considered an error - return map[string]ContextMetadata{}, nil + return nil, nil } return nil, err } - res := make(map[string]ContextMetadata) - for _, name := range ctxNames { - res[name], err = s.get(name) + var res []ContextMetadata + for _, dir := range ctxDirs { + c, err := s.get(contextdir(dir)) if err != nil { return nil, err } + res = append(res, c) } + sort.Slice(res, func(i, j int) bool { + return sortorder.NaturalLess(res[i].Name, res[j].Name) + }) return res, nil } @@ -133,9 +139,9 @@ func listRecursivelyMetadataDirs(root string) ([]string, error) { return result, nil } -func convertContextDoesNotExist(name string, err error) error { +func convertContextDoesNotExist(err error) error { if os.IsNotExist(err) { - return &contextDoesNotExistError{name: name} + return &contextDoesNotExistError{} } return err } @@ -143,4 +149,5 @@ func convertContextDoesNotExist(name string, err error) error { type untypedContextMetadata struct { Metadata json.RawMessage `json:"metadata,omitempty"` Endpoints map[string]json.RawMessage `json:"endpoints,omitempty"` + Name string `json:"name,omitempty"` } diff --git a/cli/context/store/store.go b/cli/context/store/store.go index 9238a92a23fb..5afb30749de2 100644 --- a/cli/context/store/store.go +++ b/cli/context/store/store.go @@ -2,6 +2,7 @@ package store import ( "archive/tar" + _ "crypto/sha256" // ensure ids can be computed "encoding/json" "errors" "fmt" @@ -10,24 +11,34 @@ import ( "path" "path/filepath" "strings" + + "github.com/opencontainers/go-digest" ) // Store provides a context store for easily remembering endpoints configuration type Store interface { - ListContexts() (map[string]ContextMetadata, error) - CreateOrUpdateContext(name string, meta ContextMetadata) error + ListContexts() ([]ContextMetadata, error) + CreateOrUpdateContext(meta ContextMetadata) error RemoveContext(name string) error GetContextMetadata(name string) (ContextMetadata, error) ResetContextTLSMaterial(name string, data *ContextTLSData) error ResetContextEndpointTLSMaterial(contextName string, endpointName string, data *EndpointTLSData) error ListContextTLSFiles(name string) (map[string]EndpointFiles, error) GetContextTLSData(contextName, endpointName, fileName string) ([]byte, error) + GetContextStorageInfo(contextName string) ContextStorageInfo } // ContextMetadata contains metadata about a context and its endpoints type ContextMetadata struct { - Metadata interface{} `json:"metadata,omitempty"` - Endpoints map[string]interface{} `json:"endpoints,omitempty"` + Name string `json:",omitempty"` + Metadata interface{} `json:",omitempty"` + Endpoints map[string]interface{} `json:",omitempty"` +} + +// ContextStorageInfo contains data about where a given context is stored +type ContextStorageInfo struct { + MetadataPath string + TLSPath string } // EndpointTLSData represents tls data for a given endpoint @@ -62,36 +73,40 @@ type store struct { tls *tlsStore } -func (s *store) ListContexts() (map[string]ContextMetadata, error) { +func (s *store) ListContexts() ([]ContextMetadata, error) { return s.meta.list() } -func (s *store) CreateOrUpdateContext(name string, meta ContextMetadata) error { - return s.meta.createOrUpdate(name, meta) +func (s *store) CreateOrUpdateContext(meta ContextMetadata) error { + return s.meta.createOrUpdate(meta) } func (s *store) RemoveContext(name string) error { - if err := s.meta.remove(name); err != nil { - return err + id := contextdirOf(name) + if err := s.meta.remove(id); err != nil { + return patchErrContextName(err, name) } - return s.tls.removeAllContextData(name) + return patchErrContextName(s.tls.removeAllContextData(id), name) } func (s *store) GetContextMetadata(name string) (ContextMetadata, error) { - return s.meta.get(name) + res, err := s.meta.get(contextdirOf(name)) + patchErrContextName(err, name) + return res, err } func (s *store) ResetContextTLSMaterial(name string, data *ContextTLSData) error { - if err := s.tls.removeAllContextData(name); err != nil { - return err + id := contextdirOf(name) + if err := s.tls.removeAllContextData(id); err != nil { + return patchErrContextName(err, name) } if data == nil { return nil } for ep, files := range data.Endpoints { for fileName, data := range files.Files { - if err := s.tls.createOrUpdate(name, ep, fileName, data); err != nil { - return err + if err := s.tls.createOrUpdate(id, ep, fileName, data); err != nil { + return patchErrContextName(err, name) } } } @@ -99,26 +114,37 @@ func (s *store) ResetContextTLSMaterial(name string, data *ContextTLSData) error } func (s *store) ResetContextEndpointTLSMaterial(contextName string, endpointName string, data *EndpointTLSData) error { - if err := s.tls.removeAllEndpointData(contextName, endpointName); err != nil { - return err + id := contextdirOf(contextName) + if err := s.tls.removeAllEndpointData(id, endpointName); err != nil { + return patchErrContextName(err, contextName) } if data == nil { return nil } for fileName, data := range data.Files { - if err := s.tls.createOrUpdate(contextName, endpointName, fileName, data); err != nil { - return err + if err := s.tls.createOrUpdate(id, endpointName, fileName, data); err != nil { + return patchErrContextName(err, contextName) } } return nil } func (s *store) ListContextTLSFiles(name string) (map[string]EndpointFiles, error) { - return s.tls.listContextData(name) + res, err := s.tls.listContextData(contextdirOf(name)) + return res, patchErrContextName(err, name) } func (s *store) GetContextTLSData(contextName, endpointName, fileName string) ([]byte, error) { - return s.tls.getData(contextName, endpointName, fileName) + res, err := s.tls.getData(contextdirOf(contextName), endpointName, fileName) + return res, patchErrContextName(err, contextName) +} + +func (s *store) GetContextStorageInfo(contextName string) ContextStorageInfo { + dir := contextdirOf(contextName) + return ContextStorageInfo{ + MetadataPath: s.meta.contextDir(dir), + TLSPath: s.tls.contextDir(dir), + } } // Export exports an existing namespace into an opaque data stream @@ -227,7 +253,8 @@ func Import(name string, s Store, reader io.Reader) error { if err := json.Unmarshal(data, &meta); err != nil { return err } - if err := s.CreateOrUpdateContext(name, meta); err != nil { + meta.Name = name + if err := s.CreateOrUpdateContext(meta); err != nil { return err } } else if strings.HasPrefix(hdr.Name, "tls/") { @@ -253,6 +280,10 @@ func Import(name string, s Store, reader io.Reader) error { return s.ResetContextTLSMaterial(name, &tlsData) } +type setContextName interface { + setContext(name string) +} + type contextDoesNotExistError struct { name string } @@ -261,6 +292,13 @@ func (e *contextDoesNotExistError) Error() string { return fmt.Sprintf("context %q does not exist", e.name) } +func (e *contextDoesNotExistError) setContext(name string) { + e.name = name +} + +// NotFound satisfies interface github.com/docker/docker/errdefs.ErrNotFound +func (e *contextDoesNotExistError) NotFound() {} + type tlsDataDoesNotExistError struct { context, endpoint, file string } @@ -269,6 +307,13 @@ func (e *tlsDataDoesNotExistError) Error() string { return fmt.Sprintf("tls data for %s/%s/%s does not exist", e.context, e.endpoint, e.file) } +func (e *tlsDataDoesNotExistError) setContext(name string) { + e.context = name +} + +// NotFound satisfies interface github.com/docker/docker/errdefs.ErrNotFound +func (e *tlsDataDoesNotExistError) NotFound() {} + // IsErrContextDoesNotExist checks if the given error is a "context does not exist" condition func IsErrContextDoesNotExist(err error) bool { _, ok := err.(*contextDoesNotExistError) @@ -280,3 +325,16 @@ func IsErrTLSDataDoesNotExist(err error) bool { _, ok := err.(*tlsDataDoesNotExistError) return ok } + +type contextdir string + +func contextdirOf(name string) contextdir { + return contextdir(digest.FromString(name).Encoded()) +} + +func patchErrContextName(err error, name string) error { + if typed, ok := err.(setContextName); ok { + typed.setContext(name) + } + return err +} diff --git a/cli/context/store/store_test.go b/cli/context/store/store_test.go index c1994d0441be..c18bcbb7b547 100644 --- a/cli/context/store/store_test.go +++ b/cli/context/store/store_test.go @@ -26,12 +26,13 @@ func TestExportImport(t *testing.T) { assert.NilError(t, err) defer os.RemoveAll(testDir) s := New(testDir, testCfg) - err = s.CreateOrUpdateContext("source", + err = s.CreateOrUpdateContext( ContextMetadata{ Endpoints: map[string]interface{}{ "ep1": endpoint{Foo: "bar"}, }, Metadata: context{Bar: "baz"}, + Name: "source", }) assert.NilError(t, err) err = s.ResetContextEndpointTLSMaterial("source", "ep1", &EndpointTLSData{ @@ -48,7 +49,8 @@ func TestExportImport(t *testing.T) { assert.NilError(t, err) destMeta, err := s.GetContextMetadata("dest") assert.NilError(t, err) - assert.DeepEqual(t, destMeta, srcMeta) + assert.DeepEqual(t, destMeta.Metadata, srcMeta.Metadata) + assert.DeepEqual(t, destMeta.Endpoints, srcMeta.Endpoints) srcFileList, err := s.ListContextTLSFiles("source") assert.NilError(t, err) destFileList, err := s.ListContextTLSFiles("dest") @@ -67,12 +69,13 @@ func TestRemove(t *testing.T) { assert.NilError(t, err) defer os.RemoveAll(testDir) s := New(testDir, testCfg) - err = s.CreateOrUpdateContext("source", + err = s.CreateOrUpdateContext( ContextMetadata{ Endpoints: map[string]interface{}{ "ep1": endpoint{Foo: "bar"}, }, Metadata: context{Bar: "baz"}, + Name: "source", }) assert.NilError(t, err) assert.NilError(t, s.ResetContextEndpointTLSMaterial("source", "ep1", &EndpointTLSData{ @@ -95,6 +98,15 @@ func TestListEmptyStore(t *testing.T) { store := New(testDir, testCfg) result, err := store.ListContexts() assert.NilError(t, err) - assert.Check(t, result != nil) assert.Check(t, len(result) == 0) } + +func TestErrHasCorrectContext(t *testing.T) { + testDir, err := ioutil.TempDir("", t.Name()) + assert.NilError(t, err) + defer os.RemoveAll(testDir) + store := New(testDir, testCfg) + _, err = store.GetContextMetadata("no-exists") + assert.ErrorContains(t, err, "no-exists") + assert.Check(t, IsErrContextDoesNotExist(err)) +} diff --git a/cli/context/store/tlsstore.go b/cli/context/store/tlsstore.go index 0d978df1293f..1188ce2df798 100644 --- a/cli/context/store/tlsstore.go +++ b/cli/context/store/tlsstore.go @@ -12,20 +12,20 @@ type tlsStore struct { root string } -func (s *tlsStore) contextDir(name string) string { - return filepath.Join(s.root, name) +func (s *tlsStore) contextDir(id contextdir) string { + return filepath.Join(s.root, string(id)) } -func (s *tlsStore) endpointDir(contextName, name string) string { - return filepath.Join(s.root, contextName, name) +func (s *tlsStore) endpointDir(contextID contextdir, name string) string { + return filepath.Join(s.root, string(contextID), name) } -func (s *tlsStore) filePath(contextName, endpointName, filename string) string { - return filepath.Join(s.root, contextName, endpointName, filename) +func (s *tlsStore) filePath(contextID contextdir, endpointName, filename string) string { + return filepath.Join(s.root, string(contextID), endpointName, filename) } -func (s *tlsStore) createOrUpdate(contextName, endpointName, filename string, data []byte) error { - epdir := s.endpointDir(contextName, endpointName) +func (s *tlsStore) createOrUpdate(contextID contextdir, endpointName, filename string, data []byte) error { + epdir := s.endpointDir(contextID, endpointName) parentOfRoot := filepath.Dir(s.root) if err := os.MkdirAll(parentOfRoot, 0755); err != nil { return err @@ -33,35 +33,35 @@ func (s *tlsStore) createOrUpdate(contextName, endpointName, filename string, da if err := os.MkdirAll(epdir, 0700); err != nil { return err } - return ioutil.WriteFile(s.filePath(contextName, endpointName, filename), data, 0600) + return ioutil.WriteFile(s.filePath(contextID, endpointName, filename), data, 0600) } -func (s *tlsStore) getData(contextName, endpointName, filename string) ([]byte, error) { - data, err := ioutil.ReadFile(s.filePath(contextName, endpointName, filename)) +func (s *tlsStore) getData(contextID contextdir, endpointName, filename string) ([]byte, error) { + data, err := ioutil.ReadFile(s.filePath(contextID, endpointName, filename)) if err != nil { - return nil, convertTLSDataDoesNotExist(contextName, endpointName, filename, err) + return nil, convertTLSDataDoesNotExist(endpointName, filename, err) } return data, nil } -func (s *tlsStore) remove(contextName, endpointName, filename string) error { - err := os.Remove(s.filePath(contextName, endpointName, filename)) +func (s *tlsStore) remove(contextID contextdir, endpointName, filename string) error { + err := os.Remove(s.filePath(contextID, endpointName, filename)) if os.IsNotExist(err) { return nil } return err } -func (s *tlsStore) removeAllEndpointData(contextName, endpointName string) error { - return os.RemoveAll(s.endpointDir(contextName, endpointName)) +func (s *tlsStore) removeAllEndpointData(contextID contextdir, endpointName string) error { + return os.RemoveAll(s.endpointDir(contextID, endpointName)) } -func (s *tlsStore) removeAllContextData(contextName string) error { - return os.RemoveAll(s.contextDir(contextName)) +func (s *tlsStore) removeAllContextData(contextID contextdir) error { + return os.RemoveAll(s.contextDir(contextID)) } -func (s *tlsStore) listContextData(contextName string) (map[string]EndpointFiles, error) { - epFSs, err := ioutil.ReadDir(s.contextDir(contextName)) +func (s *tlsStore) listContextData(contextID contextdir) (map[string]EndpointFiles, error) { + epFSs, err := ioutil.ReadDir(s.contextDir(contextID)) if err != nil { if os.IsNotExist(err) { return map[string]EndpointFiles{}, nil @@ -71,7 +71,7 @@ func (s *tlsStore) listContextData(contextName string) (map[string]EndpointFiles r := make(map[string]EndpointFiles) for _, epFS := range epFSs { if epFS.IsDir() { - epDir := s.endpointDir(contextName, epFS.Name()) + epDir := s.endpointDir(contextID, epFS.Name()) fss, err := ioutil.ReadDir(epDir) if err != nil { return nil, err @@ -91,9 +91,9 @@ func (s *tlsStore) listContextData(contextName string) (map[string]EndpointFiles // EndpointFiles is a slice of strings representing file names type EndpointFiles []string -func convertTLSDataDoesNotExist(context, endpoint, file string, err error) error { +func convertTLSDataDoesNotExist(endpoint, file string, err error) error { if os.IsNotExist(err) { - return &tlsDataDoesNotExistError{context: context, endpoint: endpoint, file: file} + return &tlsDataDoesNotExistError{endpoint: endpoint, file: file} } return err } diff --git a/docs/reference/commandline/cli.md b/docs/reference/commandline/cli.md index eaf60cc60e45..193745867480 100644 --- a/docs/reference/commandline/cli.md +++ b/docs/reference/commandline/cli.md @@ -27,6 +27,7 @@ A self-sufficient runtime for containers. Options: --config string Location of client config files (default "/root/.docker") + -c, --context string Name of the context to use to connect to the daemon (overrides DOCKER_HOST env var and default context set with "docker context use") -D, --debug Enable debug mode --help Print usage -H, --host value Daemon socket(s) to connect to (default []) @@ -78,6 +79,7 @@ by the `docker` command line: `docker pull`) in `docker help` output, and only `Management commands` per object-type (e.g., `docker container`) are printed. This may become the default in a future release, at which point this environment-variable is removed. * `DOCKER_TMPDIR` Location for temporary Docker files. +* `DOCKER_CONTEXT` Specify the context to use (overrides DOCKER_HOST env var and default context set with "docker context use") Because Docker is developed using Go, you can also use any environment variables used by the Go runtime. In particular, you may find these useful: diff --git a/docs/reference/commandline/context_create.md b/docs/reference/commandline/context_create.md new file mode 100644 index 000000000000..171f284289f7 --- /dev/null +++ b/docs/reference/commandline/context_create.md @@ -0,0 +1,75 @@ +--- +title: "context create" +description: "The context create command description and usage" +keywords: "context, create" +--- + + + +# context create + +```markdown +Usage: docker context create [OPTIONS] CONTEXT + +Create a context + +Docker endpoint config: + +NAME DESCRIPTION +from-current Copy current Docker endpoint configuration +host Docker endpoint on which to connect +ca Trust certs signed only by this CA +cert Path to TLS certificate file +key Path to TLS key file +skip-tls-verify Skip TLS certificate validation + +Kubernetes endpoint config: + +NAME DESCRIPTION +from-current Copy current Kubernetes endpoint configuration +config-file Path to a Kubernetes config file +context-override Overrides the context set in the kubernetes config file +namespace-override Overrides the namespace set in the kubernetes config file + +Example: + +$ docker context create my-context --description "some description" --docker "host=tcp://myserver:2376,ca=~/ca-file,cert=~/cert-file,key=~/key-file" + +Options: + --default-stack-orchestrator string Default orchestrator for + stack operations to use with + this context + (swarm|kubernetes|all) + --description string Description of the context + --docker stringToString set the docker endpoint + (default []) + --kubernetes stringToString set the kubernetes endpoint + (default []) +``` + +## Description + +Creates a new `context`. This will allow you to quickly switch the cli configuration to connect to different clusters or single nodes. + +To create a `context` out of an existing `DOCKER_HOST` based script, you can use the `from-current` config key: + +```bash +$ source my-setup-script.sh +$ docker context create my-context --docker "from-current=true" +``` + +Similarly, to reference the currently active Kubernetes configuration, you can use `--kubernetes "from-current=true"`: + +```bash +$ export KUBECONFIG=/path/to/my/kubeconfig +$ docker context create my-context --kubernetes "from-current=true" --docker "host=/var/run/docker.sock" +``` + +Docker and Kubernetes endpoints configurations, as well as default stack orchestrator and description can be modified with `docker context update` \ No newline at end of file diff --git a/docs/reference/commandline/context_export.md b/docs/reference/commandline/context_export.md new file mode 100644 index 000000000000..758e98299bae --- /dev/null +++ b/docs/reference/commandline/context_export.md @@ -0,0 +1,31 @@ +--- +title: "context export" +description: "The context export command description and usage" +keywords: "context, export" +--- + + + +# context export + +```markdown +Usage: docker context export [OPTIONS] CONTEXT [FILE|-] + +Export a context to a tar or kubeconfig file + +Options: + --kubeconfig Export as a kubeconfig file +``` + +## Description + +Exports a context in a file that can then be used with `docker context import` (or with `kubectl` if `--kubeconfig` is set). +Default output filename is `.dockercontext`, or `.kubeconfig` if `--kubeconfig` is set. +To export to `STDOUT`, you can run `docker context export my-context -`. diff --git a/docs/reference/commandline/context_import.md b/docs/reference/commandline/context_import.md new file mode 100644 index 000000000000..0b040291a579 --- /dev/null +++ b/docs/reference/commandline/context_import.md @@ -0,0 +1,22 @@ +--- +title: "context import" +description: "The context import command description and usage" +keywords: "context, import" +--- + + + +# context import + +```markdown +Usage: docker context import [OPTIONS] CONTEXT FILE|- + +Import a context from a tar file +``` \ No newline at end of file diff --git a/docs/reference/commandline/context_ls.md b/docs/reference/commandline/context_ls.md new file mode 100644 index 000000000000..3599f3af4684 --- /dev/null +++ b/docs/reference/commandline/context_ls.md @@ -0,0 +1,30 @@ +--- +title: "context ls" +description: "The context ls command description and usage" +keywords: "context, ls" +--- + + + +# context ls + +```markdown +Usage: docker context ls [OPTIONS] + +List contexts + +Aliases: + ls, list + +Options: + --format string Pretty-print contexts using a Go template + (default "table") + -q, --quiet Only show context names +``` \ No newline at end of file diff --git a/docs/reference/commandline/context_rm.md b/docs/reference/commandline/context_rm.md new file mode 100644 index 000000000000..559501c64bab --- /dev/null +++ b/docs/reference/commandline/context_rm.md @@ -0,0 +1,28 @@ +--- +title: "context rm" +description: "The context rm command description and usage" +keywords: "context, rm" +--- + + + +# context rm + +```markdown +Usage: docker context rm CONTEXT [CONTEXT...] + +Remove one or more contexts + +Aliases: + rm, remove + +Options: + -f, --force Force the removal of a context in use +``` \ No newline at end of file diff --git a/docs/reference/commandline/context_update.md b/docs/reference/commandline/context_update.md new file mode 100644 index 000000000000..94add9011284 --- /dev/null +++ b/docs/reference/commandline/context_update.md @@ -0,0 +1,60 @@ +--- +title: "context update" +description: "The context update command description and usage" +keywords: "context, update" +--- + + + +# context update + +```markdown +Usage: docker context update [OPTIONS] CONTEXT + +Update a context + +Docker endpoint config: + +NAME DESCRIPTION +from-current Copy current Docker endpoint configuration +host Docker endpoint on which to connect +ca Trust certs signed only by this CA +cert Path to TLS certificate file +key Path to TLS key file +skip-tls-verify Skip TLS certificate validation + +Kubernetes endpoint config: + +NAME DESCRIPTION +from-current Copy current Kubernetes endpoint configuration +config-file Path to a Kubernetes config file +context-override Overrides the context set in the kubernetes config file +namespace-override Overrides the namespace set in the kubernetes config file + +Example: + +$ docker context update my-context --description "some description" --docker "host=tcp://myserver:2376,ca=~/ca-file,cert=~/cert-file,key=~/key-file" + +Options: + --default-stack-orchestrator string Default orchestrator for + stack operations to use with + this context + (swarm|kubernetes|all) + --description string Description of the context + --docker stringToString set the docker endpoint + (default []) + --kubernetes stringToString set the kubernetes endpoint + (default []) +``` + +## Description + +Updates an existing `context`. +See [context create](context_create.md) \ No newline at end of file diff --git a/docs/reference/commandline/context_use.md b/docs/reference/commandline/context_use.md new file mode 100644 index 000000000000..197c3ef0a2b1 --- /dev/null +++ b/docs/reference/commandline/context_use.md @@ -0,0 +1,25 @@ +--- +title: "context use" +description: "The context use command description and usage" +keywords: "context, use" +--- + + + +# context use + +```markdown +Usage: docker context use CONTEXT + +Set the current docker context +``` + +## Description +Set the default context to use, when `DOCKER_HOST`, `DOCKER_CONTEXT` environment variables and `--host`, `--context` global options are not set. \ No newline at end of file diff --git a/docs/reference/commandline/index.md b/docs/reference/commandline/index.md index ddfdbb994834..00bacf3bfe6c 100644 --- a/docs/reference/commandline/index.md +++ b/docs/reference/commandline/index.md @@ -182,3 +182,15 @@ read the [`dockerd`](dockerd.md) reference page. | [plugin push](plugin_push.md) | Push a plugin to a registry | | [plugin rm](plugin_rm.md) | Remove a plugin | | [plugin set](plugin_set.md) | Change settings for a plugin | + +### Context commands +| Command | Description | +|:--------|:-------------------------------------------------------------------| +| [context create](context_create.md) | Create a context | +| [context export](context_export.md) | Export a context | +| [context import](context_import.md) | Import a context | +| [context ls](context_ls.md) | List contexts | +| [context rm](context_rm.md) | Remove one or more contexts | +| [context update](context_update.md) | Update a context | +| [context use](context_use.md) | Set the current docker context | + diff --git a/internal/test/cli.go b/internal/test/cli.go index a7445881f8c4..164488ca2e10 100644 --- a/internal/test/cli.go +++ b/internal/test/cli.go @@ -9,6 +9,8 @@ import ( "github.com/docker/cli/cli/command" "github.com/docker/cli/cli/config/configfile" + "github.com/docker/cli/cli/context/docker" + "github.com/docker/cli/cli/context/store" manifeststore "github.com/docker/cli/cli/manifest/store" registryclient "github.com/docker/cli/cli/registry/client" "github.com/docker/cli/cli/trust" @@ -38,6 +40,9 @@ type FakeCli struct { registryClient registryclient.RegistryClient contentTrust bool containerizedEngineClientFunc containerizedEngineFuncType + contextStore store.Store + currentContext string + dockerEndpoint docker.Endpoint } // NewFakeCli returns a fake for the command.Cli interface @@ -70,11 +75,31 @@ func (c *FakeCli) SetErr(err *bytes.Buffer) { c.err = err } +// SetOut sets the stdout stream for the cli to the specified io.Writer +func (c *FakeCli) SetOut(out *command.OutStream) { + c.out = out +} + // SetConfigFile sets the "fake" config file func (c *FakeCli) SetConfigFile(configfile *configfile.ConfigFile) { c.configfile = configfile } +// SetContextStore sets the "fake" context store +func (c *FakeCli) SetContextStore(store store.Store) { + c.contextStore = store +} + +// SetCurrentContext sets the "fake" current context +func (c *FakeCli) SetCurrentContext(name string) { + c.currentContext = name +} + +// SetDockerEndpoint sets the "fake" docker endpoint +func (c *FakeCli) SetDockerEndpoint(ep docker.Endpoint) { + c.dockerEndpoint = ep +} + // Client returns a docker API client func (c *FakeCli) Client() client.APIClient { return c.client @@ -100,6 +125,21 @@ func (c *FakeCli) ConfigFile() *configfile.ConfigFile { return c.configfile } +// ContextStore returns the cli context store +func (c *FakeCli) ContextStore() store.Store { + return c.contextStore +} + +// CurrentContext returns the cli context +func (c *FakeCli) CurrentContext() string { + return c.currentContext +} + +// DockerEndpoint returns the current DockerEndpoint +func (c *FakeCli) DockerEndpoint() docker.Endpoint { + return c.dockerEndpoint +} + // ServerInfo returns API server information for the server used by this client func (c *FakeCli) ServerInfo() command.ServerInfo { return c.server