diff --git a/cli/command/cli.go b/cli/command/cli.go index 2e051440fd..5da0f01ddc 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" @@ -57,6 +56,10 @@ 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) + DockerEndpoint() docker.Endpoint } // DockerCli is an instance the docker command line client. @@ -71,8 +74,17 @@ type DockerCli struct { clientInfo ClientInfo contentTrust bool newContainerizeClient func(string) (clitypes.ContainerizedClient, error) + contextStore store.Store + currentContext string + dockerEndpoint docker.Endpoint } +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 +179,24 @@ 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, cli.contextStore) + 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.dockerEndpoint = 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,73 @@ 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, store) + 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 != "" { + 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 docker.WithTLSData(s, contextName, epMeta) + } + 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{ + Host: host, + SkipTLSVerify: skipTLSVerify, + }, + TLSData: tlsData, + }, nil +} + func isEnabled(value string) (bool, error) { switch value { case "enabled": @@ -253,6 +342,57 @@ 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 != "" { + 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 + } + ctxMeta, err := GetDockerContext(ctxRaw) + if err != nil { + return "", err + } + ctxOrchestrator = string(ctxMeta.StackOrchestrator) + } + + 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 { @@ -272,51 +412,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 +426,41 @@ 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, contextstore store.Store) (string, error) { + if opts.Context != "" && len(opts.Hosts) > 0 { + 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 "", nil + } + if _, present := os.LookupEnv("DOCKER_HOST"); present { + return "", nil + } + if ctxName, ok := os.LookupEnv("DOCKER_CONTEXT"); ok { + return ctxName, nil + } + if config != nil && config.CurrentContext != "" { + _, 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 "", nil +} diff --git a/cli/command/cli_test.go b/cli/command/cli_test.go index 6ac120fa22..71029e9107 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/commands/commands.go b/cli/command/commands/commands.go index ca2f6ad096..59999bda5d 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 new file mode 100644 index 0000000000..4f9e8e8513 --- /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:",omitempty"` + StackOrchestrator Orchestrator `json:",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/context/cmd.go b/cli/command/context/cmd.go new file mode 100644 index 0000000000..1b6898456d --- /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 0000000000..c51d5214b5 --- /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 0000000000..52521f9f7e --- /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 0000000000..aac9beddeb --- /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 0000000000..060abf977d --- /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 0000000000..b1f68ec4ee --- /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 0000000000..678b818f4c --- /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 0000000000..f417b5f8b1 --- /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 0000000000..1cda69767d --- /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 0000000000..1edf34ae2a --- /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 0000000000..338e808835 --- /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 0000000000..bacff0b0b8 --- /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 0000000000..bc6438fb78 --- /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 0000000000..d520b4f93c --- /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 0000000000..c32be2e28c --- /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 0000000000..5e11422f00 --- /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 0000000000..c9bef2c3e4 --- /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 0000000000..f6baf8e843 --- /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 0000000000..24fae3b61f --- /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 0000000000..49109bf9dd --- /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 0000000000..bdffda3c9f --- /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 0000000000..7fd309b47a --- /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 0000000000..93f86f6a20 --- /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 5f3e446205..b051c4a207 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 "": + case "", "unset": // unset is the old value for orchestratorUnset. Keep accepting this for backward compat 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 322e8a9169..141c27e434 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 851ac13c4a..1570080d1e 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 f98b4c4f3a..a531846809 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 97c50f1bdc..b6c7db30cf 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() == "" { + 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 9161921a2d..64f8d3b49c 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 7fa9b734b9..d815570362 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 0000000000..1db5556d5f --- /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 0000000000..5661fa9154 --- /dev/null +++ b/cli/context/docker/load.go @@ -0,0 +1,166 @@ +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 = context.EndpointMetaBase + +// 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 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: m, + 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 != "" { + 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 0000000000..f2735246ea --- /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:",omitempty"` + SkipTLSVerify bool +} diff --git a/cli/context/kubernetes/constants.go b/cli/context/kubernetes/constants.go new file mode 100644 index 0000000000..8998de989a --- /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 0000000000..da124851a5 --- /dev/null +++ b/cli/context/kubernetes/endpoint_test.go @@ -0,0 +1,196 @@ +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, 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) + 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, save(store, epDefault, "embed-default-context")) + assert.NilError(t, save(store, epContext2, "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 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) + 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, save(store, ep, "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, save(store, ep, "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 0000000000..803fd8c812 --- /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:",omitempty"` + AuthProvider *clientcmdapi.AuthProviderConfig `json:",omitempty"` + Exec *clientcmdapi.ExecConfig `json:",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 0000000000..464a68caf4 --- /dev/null +++ b/cli/context/kubernetes/save.go @@ -0,0 +1,61 @@ +package kubernetes + +import ( + "io/ioutil" + + "github.com/docker/cli/cli/context" + "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 +} diff --git a/cli/context/kubernetes/testdata/eks-kubeconfig b/cli/context/kubernetes/testdata/eks-kubeconfig new file mode 100644 index 0000000000..deed186a8a --- /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 0000000000..5a6384cbae --- /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 0000000000..e432dae3b9 --- /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 0000000000..ef77cc4eea --- /dev/null +++ b/cli/context/store/metadata_test.go @@ -0,0 +1,143 @@ +package store + +import ( + "io/ioutil" + "os" + "path/filepath" + "testing" + + "gotest.tools/assert" + "gotest.tools/assert/cmp" +) + +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) { + 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"}, + Name: "test-context", + } + 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(contextdirOf("test-context")) + assert.NilError(t, err) + assert.DeepEqual(t, meta, testMeta) + + // update + + err = testee.createOrUpdate(expected2) + assert.NilError(t, err) + meta, err = testee.get(contextdirOf("test-context")) + assert.NilError(t, err) + assert.DeepEqual(t, meta, expected2) + + 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)) +} + +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(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")) +} + +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 := []ContextMetadata{ + testMetadata("context1"), + testMetadata("context2"), + testMetadata("context3"), + } + + for _, s := range wholeData { + err = testee.createOrUpdate(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 := []ContextMetadata{ + testMetadata("context1"), + testMetadata("context2"), + testMetadata("context3"), + } + + for _, s := range wholeData { + err = testee.createOrUpdate(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(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 new file mode 100644 index 0000000000..47aacdc5f8 --- /dev/null +++ b/cli/context/store/metadatastore.go @@ -0,0 +1,153 @@ +package store + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "os" + "path/filepath" + "reflect" + "sort" + + "vbom.ml/util/sortorder" +) + +const ( + metadataDir = "meta" + metaFile = "meta.json" +) + +type metadataStore struct { + root string + config Config +} + +func (s *metadataStore) contextDir(id contextdir) string { + return filepath.Join(s.root, string(id)) +} + +func (s *metadataStore) createOrUpdate(meta ContextMetadata) error { + contextDir := s.contextDir(contextdirOf(meta.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(id contextdir) (ContextMetadata, error) { + contextDir := s.contextDir(id) + bytes, err := ioutil.ReadFile(filepath.Join(contextDir, metaFile)) + if err != nil { + return ContextMetadata{}, convertContextDoesNotExist(err) + } + var untyped untypedContextMetadata + r := ContextMetadata{ + Endpoints: make(map[string]interface{}), + } + 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 + } + 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(id contextdir) error { + contextDir := s.contextDir(id) + return os.RemoveAll(contextDir) +} + +func (s *metadataStore) list() ([]ContextMetadata, error) { + ctxDirs, err := listRecursivelyMetadataDirs(s.root) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + 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 +} + +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(err error) error { + if os.IsNotExist(err) { + return &contextDoesNotExistError{} + } + return err +} + +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 new file mode 100644 index 0000000000..5afb30749d --- /dev/null +++ b/cli/context/store/store.go @@ -0,0 +1,340 @@ +package store + +import ( + "archive/tar" + _ "crypto/sha256" // ensure ids can be computed + "encoding/json" + "errors" + "fmt" + "io" + "io/ioutil" + "path" + "path/filepath" + "strings" + + "github.com/opencontainers/go-digest" +) + +// Store provides a context store for easily remembering endpoints configuration +type Store interface { + 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 { + 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 +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() ([]ContextMetadata, error) { + return s.meta.list() +} + +func (s *store) CreateOrUpdateContext(meta ContextMetadata) error { + return s.meta.createOrUpdate(meta) +} + +func (s *store) RemoveContext(name string) error { + id := contextdirOf(name) + if err := s.meta.remove(id); err != nil { + return patchErrContextName(err, name) + } + return patchErrContextName(s.tls.removeAllContextData(id), name) +} + +func (s *store) GetContextMetadata(name string) (ContextMetadata, error) { + res, err := s.meta.get(contextdirOf(name)) + patchErrContextName(err, name) + return res, err +} + +func (s *store) ResetContextTLSMaterial(name string, data *ContextTLSData) error { + 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(id, ep, fileName, data); err != nil { + return patchErrContextName(err, name) + } + } + } + return nil +} + +func (s *store) ResetContextEndpointTLSMaterial(contextName string, endpointName string, data *EndpointTLSData) error { + 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(id, endpointName, fileName, data); err != nil { + return patchErrContextName(err, contextName) + } + } + return nil +} + +func (s *store) ListContextTLSFiles(name string) (map[string]EndpointFiles, error) { + res, err := s.tls.listContextData(contextdirOf(name)) + return res, patchErrContextName(err, name) +} + +func (s *store) GetContextTLSData(contextName, endpointName, fileName string) ([]byte, error) { + 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 +// 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 + } + meta.Name = name + if err := s.CreateOrUpdateContext(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 setContextName interface { + setContext(name string) +} + +type contextDoesNotExistError struct { + name string +} + +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 +} + +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) + 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 +} + +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 new file mode 100644 index 0000000000..c18bcbb7b5 --- /dev/null +++ b/cli/context/store/store_test.go @@ -0,0 +1,112 @@ +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( + 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{ + 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.Metadata, srcMeta.Metadata) + assert.DeepEqual(t, destMeta.Endpoints, srcMeta.Endpoints) + 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( + 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{ + 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, 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/storeconfig.go b/cli/context/store/storeconfig.go new file mode 100644 index 0000000000..9746d93d77 --- /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 0000000000..1188ce2df7 --- /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(id contextdir) string { + return filepath.Join(s.root, string(id)) +} + +func (s *tlsStore) endpointDir(contextID contextdir, name string) string { + return filepath.Join(s.root, string(contextID), name) +} + +func (s *tlsStore) filePath(contextID contextdir, endpointName, filename string) string { + return filepath.Join(s.root, string(contextID), endpointName, filename) +} + +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 + } + if err := os.MkdirAll(epdir, 0700); err != nil { + return err + } + return ioutil.WriteFile(s.filePath(contextID, endpointName, filename), data, 0600) +} + +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(endpointName, filename, err) + } + return data, nil +} + +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(contextID contextdir, endpointName string) error { + return os.RemoveAll(s.endpointDir(contextID, endpointName)) +} + +func (s *tlsStore) removeAllContextData(contextID contextdir) error { + return os.RemoveAll(s.contextDir(contextID)) +} + +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 + } + return nil, err + } + r := make(map[string]EndpointFiles) + for _, epFS := range epFSs { + if epFS.IsDir() { + epDir := s.endpointDir(contextID, 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(endpoint, file string, err error) error { + if os.IsNotExist(err) { + return &tlsDataDoesNotExistError{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 0000000000..6079de0f8e --- /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 0000000000..6bd05fbb78 --- /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 22faf12ca6..a3bbf29571 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 diff --git a/docs/reference/commandline/cli.md b/docs/reference/commandline/cli.md index eaf60cc60e..1937458674 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 0000000000..171f284289 --- /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 0000000000..758e98299b --- /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 0000000000..0b040291a5 --- /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 0000000000..3599f3af46 --- /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 0000000000..559501c64b --- /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 0000000000..94add90112 --- /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 0000000000..197c3ef0a2 --- /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 ddfdbb9948..00bacf3bfe 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 a7445881f8..164488ca2e 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