diff --git a/cli/command/cli.go b/cli/command/cli.go index d325d5156c..5da0f01ddc 100644 --- a/cli/command/cli.go +++ b/cli/command/cli.go @@ -33,9 +33,6 @@ import ( "github.com/theupdateframework/notary/passphrase" ) -// ContextDockerHost is the reported context when DOCKER_HOST env var or -H flag is set -const ContextDockerHost = "" - // Streams is an interface which exposes the standard input and output streams type Streams interface { In() *InStream @@ -62,6 +59,7 @@ type Cli interface { ContextStore() store.Store CurrentContext() string StackOrchestrator(flagValue string) (Orchestrator, error) + DockerEndpoint() docker.Endpoint } // DockerCli is an instance the docker command line client. @@ -78,6 +76,7 @@ type DockerCli struct { newContainerizeClient func(string) (clitypes.ContainerizedClient, error) contextStore store.Store currentContext string + dockerEndpoint docker.Endpoint } var storeConfig = store.NewConfig( @@ -182,7 +181,7 @@ func (cli *DockerCli) Initialize(opts *cliflags.ClientOptions) error { cli.configFile = cliconfig.LoadDefaultConfigFile(cli.err) var err error cli.contextStore = store.New(cliconfig.ContextStoreDir(), storeConfig) - cli.currentContext, err = resolveContextName(opts.Common, cli.configFile) + cli.currentContext, err = resolveContextName(opts.Common, cli.configFile, cli.contextStore) if err != nil { return err } @@ -190,6 +189,7 @@ func (cli *DockerCli) Initialize(opts *cliflags.ClientOptions) error { if err != nil { return errors.Wrap(err, "unable to resolve docker endpoint") } + cli.dockerEndpoint = endpoint cli.client, err = newAPIClientFromEndpoint(endpoint, cli.configFile) if tlsconfig.IsErrEncryptedKey(err) { @@ -223,7 +223,7 @@ func (cli *DockerCli) Initialize(opts *cliflags.ClientOptions) error { // NewAPIClientFromFlags creates a new APIClient from command line flags func NewAPIClientFromFlags(opts *cliflags.CommonOptions, configFile *configfile.ConfigFile) (client.APIClient, error) { store := store.New(cliconfig.ContextStoreDir(), storeConfig) - contextName, err := resolveContextName(opts, configFile) + contextName, err := resolveContextName(opts, configFile, store) if err != nil { return nil, err } @@ -249,7 +249,7 @@ func newAPIClientFromEndpoint(ep docker.Endpoint, configFile *configfile.ConfigF } func resolveDockerEndpoint(s store.Store, contextName string, opts *cliflags.CommonOptions) (docker.Endpoint, error) { - if contextName != ContextDockerHost { + if contextName != "" { ctxMeta, err := s.GetContextMetadata(contextName) if err != nil { return docker.Endpoint{}, err @@ -258,7 +258,7 @@ func resolveDockerEndpoint(s store.Store, contextName string, opts *cliflags.Com if err != nil { return docker.Endpoint{}, err } - return epMeta.WithTLSData(s, contextName) + return docker.WithTLSData(s, contextName, epMeta) } host, err := getServerHost(opts.Hosts, opts.TLSOptions) if err != nil { @@ -280,10 +280,8 @@ func resolveDockerEndpoint(s store.Store, contextName string, opts *cliflags.Com return docker.Endpoint{ EndpointMeta: docker.EndpointMeta{ - EndpointMetaBase: dcontext.EndpointMetaBase{ - Host: host, - SkipTLSVerify: skipTLSVerify, - }, + Host: host, + SkipTLSVerify: skipTLSVerify, }, TLSData: tlsData, }, nil @@ -367,15 +365,16 @@ func (cli *DockerCli) StackOrchestrator(flagValue string) (Orchestrator, error) if currentContext == "" { currentContext = configFile.CurrentContext } - if currentContext == "" { - currentContext = ContextDockerHost - } - if currentContext != ContextDockerHost { + if currentContext != "" { contextstore := cli.contextStore if contextstore == nil { contextstore = store.New(cliconfig.ContextStoreDir(), storeConfig) } ctxRaw, err := contextstore.GetContextMetadata(currentContext) + if store.IsErrContextDoesNotExist(err) { + // case where the currentContext has been removed (CLI behavior is to fallback to using DOCKER_HOST based resolution) + return GetStackOrchestrator(flagValue, "", configFile.StackOrchestrator, cli.Err()) + } if err != nil { return "", err } @@ -389,6 +388,11 @@ func (cli *DockerCli) StackOrchestrator(flagValue string) (Orchestrator, error) return GetStackOrchestrator(flagValue, ctxOrchestrator, configFile.StackOrchestrator, cli.Err()) } +// DockerEndpoint returns the current docker endpoint +func (cli *DockerCli) DockerEndpoint() docker.Endpoint { + return cli.dockerEndpoint +} + // ServerInfo stores details about the supported features and platform of the // server type ServerInfo struct { @@ -435,24 +439,28 @@ func UserAgent() string { // - if DOCKER_CONTEXT is set, use this value // - if Config file has a globally set "CurrentContext", use this value // - fallbacks to default HOST, uses TLS config from flags/env vars -func resolveContextName(opts *cliflags.CommonOptions, config *configfile.ConfigFile) (string, error) { +func resolveContextName(opts *cliflags.CommonOptions, config *configfile.ConfigFile, contextstore store.Store) (string, error) { if opts.Context != "" && len(opts.Hosts) > 0 { - return "", errors.New("Conflicting options: either specify --host or --context, not bot") + return "", errors.New("Conflicting options: either specify --host or --context, not both") } if opts.Context != "" { return opts.Context, nil } if len(opts.Hosts) > 0 { - return ContextDockerHost, nil + return "", nil } if _, present := os.LookupEnv("DOCKER_HOST"); present { - return ContextDockerHost, nil + return "", nil } if ctxName, ok := os.LookupEnv("DOCKER_CONTEXT"); ok { return ctxName, nil } if config != nil && config.CurrentContext != "" { - return config.CurrentContext, nil + _, err := contextstore.GetContextMetadata(config.CurrentContext) + if store.IsErrContextDoesNotExist(err) { + return "", errors.Errorf("Current context %q is not found on the file system, please check your config file at %s", config.CurrentContext, config.Filename) + } + return config.CurrentContext, err } - return ContextDockerHost, nil + return "", nil } diff --git a/cli/command/commands/commands.go b/cli/command/commands/commands.go index 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 index 2b4c76ed15..4f9e8e8513 100644 --- a/cli/command/context.go +++ b/cli/command/context.go @@ -8,8 +8,8 @@ import ( // DockerContext is a typed representation of what we put in Context metadata type DockerContext struct { - Description string `json:"description,omitempty"` - StackOrchestrator Orchestrator `json:"stack_orchestrator,omitempty"` + Description string `json:",omitempty"` + StackOrchestrator Orchestrator `json:",omitempty"` } // GetDockerContext extracts metadata from stored context metadata diff --git a/cli/command/context/cmd.go b/cli/command/context/cmd.go new file mode 100644 index 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 c71b3f8963..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 "", "unset": + case "", "unset": // unset is the old value for orchestratorUnset. Keep accepting this for backward compat return orchestratorUnset, nil case "all": return OrchestratorAll, nil diff --git a/cli/command/system/version.go b/cli/command/system/version.go index 19407a1426..b6c7db30cf 100644 --- a/cli/command/system/version.go +++ b/cli/command/system/version.go @@ -241,7 +241,7 @@ func getKubernetesVersion(dockerCli command.Cli, kubeConfig string) *kubernetesV clientConfig clientcmd.ClientConfig err error ) - if dockerCli.CurrentContext() == command.ContextDockerHost { + if dockerCli.CurrentContext() == "" { clientConfig = kubernetes.NewKubernetesConfig(kubeConfig) } else { clientConfig, err = kubecontext.ConfigFromContext(dockerCli.CurrentContext(), dockerCli.ContextStore()) diff --git a/cli/context/docker/load.go b/cli/context/docker/load.go index 4e0316a238..5661fa9154 100644 --- a/cli/context/docker/load.go +++ b/cli/context/docker/load.go @@ -20,10 +20,7 @@ import ( // EndpointMeta is a typed wrapper around a context-store generic endpoint describing // a Docker Engine endpoint, without its tls config -type EndpointMeta struct { - context.EndpointMetaBase - APIVersion string `json:"api_version,omitempty"` -} +type EndpointMeta = context.EndpointMetaBase // Endpoint is a typed wrapper around a context-store generic endpoint describing // a Docker Engine endpoint, with its tls data @@ -34,13 +31,13 @@ type Endpoint struct { } // WithTLSData loads TLS materials for the endpoint -func (c *EndpointMeta) WithTLSData(s store.Store, contextName string) (Endpoint, error) { +func WithTLSData(s store.Store, contextName string, m EndpointMeta) (Endpoint, error) { tlsData, err := context.LoadTLSData(s, contextName, DockerEndpoint) if err != nil { return Endpoint{}, err } return Endpoint{ - EndpointMeta: *c, + EndpointMeta: m, TLSData: tlsData, }, nil } @@ -128,9 +125,6 @@ func (c *Endpoint) ClientOpts() ([]func(*client.Client) error, error) { } version := os.Getenv("DOCKER_API_VERSION") - if version == "" { - version = c.APIVersion - } if version != "" { result = append(result, client.WithVersion(version)) } diff --git a/cli/context/endpoint.go b/cli/context/endpoint.go index 806a8524ef..f2735246ea 100644 --- a/cli/context/endpoint.go +++ b/cli/context/endpoint.go @@ -2,6 +2,6 @@ package context // EndpointMetaBase contains fields we expect to be common for most context endpoints type EndpointMetaBase struct { - Host string `json:"host,omitempty"` - SkipTLSVerify bool `json:"skip_tls_verify"` + Host string `json:",omitempty"` + SkipTLSVerify bool } diff --git a/cli/context/kubernetes/endpoint_test.go b/cli/context/kubernetes/endpoint_test.go index 57b308b8f7..da124851a5 100644 --- a/cli/context/kubernetes/endpoint_test.go +++ b/cli/context/kubernetes/endpoint_test.go @@ -12,7 +12,7 @@ import ( clientcmdapi "k8s.io/client-go/tools/clientcmd/api" ) -func testEndpoint(server, defaultNamespace string, ca, cert, key []byte, skipTLSVerify bool) *Endpoint { +func testEndpoint(server, defaultNamespace string, ca, cert, key []byte, skipTLSVerify bool) Endpoint { var tlsData *context.TLSData if ca != nil || cert != nil || key != nil { tlsData = &context.TLSData{ @@ -21,7 +21,7 @@ func testEndpoint(server, defaultNamespace string, ca, cert, key []byte, skipTLS Key: key, } } - return &Endpoint{ + return Endpoint{ EndpointMeta: EndpointMeta{ EndpointMetaBase: context.EndpointMetaBase{ Host: server, @@ -45,9 +45,9 @@ func TestSaveLoadContexts(t *testing.T) { assert.NilError(t, err) defer os.RemoveAll(storeDir) store := store.New(storeDir, testStoreCfg) - assert.NilError(t, testEndpoint("https://test", "test", nil, nil, nil, false).Save(store, "raw-notls")) - assert.NilError(t, testEndpoint("https://test", "test", nil, nil, nil, true).Save(store, "raw-notls-skip")) - assert.NilError(t, testEndpoint("https://test", "test", []byte("ca"), []byte("cert"), []byte("key"), true).Save(store, "raw-tls")) + assert.NilError(t, save(store, testEndpoint("https://test", "test", nil, nil, nil, false), "raw-notls")) + assert.NilError(t, save(store, testEndpoint("https://test", "test", nil, nil, nil, true), "raw-notls-skip")) + assert.NilError(t, save(store, testEndpoint("https://test", "test", []byte("ca"), []byte("cert"), []byte("key"), true), "raw-tls")) kcFile, err := ioutil.TempFile(os.TempDir(), "test-load-save-k8-context") assert.NilError(t, err) @@ -82,8 +82,8 @@ func TestSaveLoadContexts(t *testing.T) { assert.NilError(t, err) epContext2, err := FromKubeConfig(kcFile.Name(), "context2", "namespace-override") assert.NilError(t, err) - assert.NilError(t, epDefault.Save(store, "embed-default-context")) - assert.NilError(t, epContext2.Save(store, "embed-context2")) + assert.NilError(t, save(store, epDefault, "embed-default-context")) + assert.NilError(t, save(store, epContext2, "embed-context2")) rawNoTLSMeta, err := store.GetContextMetadata("raw-notls") assert.NilError(t, err) @@ -132,6 +132,19 @@ func checkClientConfig(t *testing.T, s store.Store, ep Endpoint, server, namespa assert.Equal(t, skipTLSVerify, cfg.Insecure) } +func save(s store.Store, ep Endpoint, name string) error { + meta := store.ContextMetadata{ + Endpoints: map[string]interface{}{ + KubernetesEndpoint: ep.EndpointMeta, + }, + Name: name, + } + if err := s.CreateOrUpdateContext(meta); err != nil { + return err + } + return s.ResetContextEndpointTLSMaterial(name, KubernetesEndpoint, ep.TLSData.ToStoreTLSData()) +} + func TestSaveLoadGKEConfig(t *testing.T) { storeDir, err := ioutil.TempDir("", t.Name()) assert.NilError(t, err) @@ -144,7 +157,7 @@ func TestSaveLoadGKEConfig(t *testing.T) { assert.NilError(t, err) ep, err := FromKubeConfig("testdata/gke-kubeconfig", "", "") assert.NilError(t, err) - assert.NilError(t, ep.Save(store, "gke-context")) + assert.NilError(t, save(store, ep, "gke-context")) persistedMetadata, err := store.GetContextMetadata("gke-context") assert.NilError(t, err) persistedEPMeta := EndpointFromContext(persistedMetadata) @@ -169,7 +182,7 @@ func TestSaveLoadEKSConfig(t *testing.T) { assert.NilError(t, err) ep, err := FromKubeConfig("testdata/eks-kubeconfig", "", "") assert.NilError(t, err) - assert.NilError(t, ep.Save(store, "eks-context")) + assert.NilError(t, save(store, ep, "eks-context")) persistedMetadata, err := store.GetContextMetadata("eks-context") assert.NilError(t, err) persistedEPMeta := EndpointFromContext(persistedMetadata) diff --git a/cli/context/kubernetes/load.go b/cli/context/kubernetes/load.go index 1898f57422..803fd8c812 100644 --- a/cli/context/kubernetes/load.go +++ b/cli/context/kubernetes/load.go @@ -12,9 +12,9 @@ import ( // a Kubernetes endpoint, without TLS data type EndpointMeta struct { context.EndpointMetaBase - DefaultNamespace string `json:"default_namespace,omitempty"` - AuthProvider *clientcmdapi.AuthProviderConfig `json:"auth_provider,omitempty"` - Exec *clientcmdapi.ExecConfig `json:"exec,omitempty"` + DefaultNamespace string `json:",omitempty"` + AuthProvider *clientcmdapi.AuthProviderConfig `json:",omitempty"` + Exec *clientcmdapi.ExecConfig `json:",omitempty"` } // Endpoint is a typed wrapper around a context-store generic endpoint describing diff --git a/cli/context/kubernetes/save.go b/cli/context/kubernetes/save.go index 35646bc57c..464a68caf4 100644 --- a/cli/context/kubernetes/save.go +++ b/cli/context/kubernetes/save.go @@ -4,7 +4,6 @@ import ( "io/ioutil" "github.com/docker/cli/cli/context" - "github.com/docker/cli/cli/context/store" "k8s.io/client-go/tools/clientcmd" clientcmdapi "k8s.io/client-go/tools/clientcmd/api" ) @@ -60,20 +59,3 @@ func readFileOrDefault(path string, defaultValue []byte) ([]byte, error) { } return defaultValue, nil } - -// Save the endpoint metadata and TLS bundle in the context store -func (ep *Endpoint) Save(s store.Store, contextName string) error { - tlsData := ep.TLSData.ToStoreTLSData() - existingContext, err := s.GetContextMetadata(contextName) - if err != nil && !store.IsErrContextDoesNotExist(err) { - return err - } - if existingContext.Endpoints == nil { - existingContext.Endpoints = make(map[string]interface{}) - } - existingContext.Endpoints[KubernetesEndpoint] = ep.EndpointMeta - if err := s.CreateOrUpdateContext(contextName, existingContext); err != nil { - return err - } - return s.ResetContextEndpointTLSMaterial(contextName, KubernetesEndpoint, tlsData) -} diff --git a/cli/context/store/metadata_test.go b/cli/context/store/metadata_test.go index f8121ec20c..ef77cc4eea 100644 --- a/cli/context/store/metadata_test.go +++ b/cli/context/store/metadata_test.go @@ -10,11 +10,14 @@ import ( "gotest.tools/assert/cmp" ) -var testMetadata = ContextMetadata{ - Endpoints: map[string]interface{}{ - "ep1": endpoint{Foo: "bar"}, - }, - Metadata: context{Bar: "baz"}, +func testMetadata(name string) ContextMetadata { + return ContextMetadata{ + Endpoints: map[string]interface{}{ + "ep1": endpoint{Foo: "bar"}, + }, + Metadata: context{Bar: "baz"}, + Name: name, + } } func TestMetadataGetNotExisting(t *testing.T) { @@ -37,26 +40,28 @@ func TestMetadataCreateGetRemove(t *testing.T) { "ep2": endpoint{Foo: "bee"}, }, Metadata: context{Bar: "foo"}, + Name: "test-context", } - err = testee.createOrUpdate("test-context", testMetadata) + testMeta := testMetadata("test-context") + err = testee.createOrUpdate(testMeta) assert.NilError(t, err) // create a new instance to check it does not depend on some sort of state testee = metadataStore{root: testDir, config: testCfg} - meta, err := testee.get("test-context") + meta, err := testee.get(contextdirOf("test-context")) assert.NilError(t, err) - assert.DeepEqual(t, meta, testMetadata) + assert.DeepEqual(t, meta, testMeta) // update - err = testee.createOrUpdate("test-context", expected2) + err = testee.createOrUpdate(expected2) assert.NilError(t, err) - meta, err = testee.get("test-context") + meta, err = testee.get(contextdirOf("test-context")) assert.NilError(t, err) assert.DeepEqual(t, meta, expected2) - assert.NilError(t, testee.remove("test-context")) - assert.NilError(t, testee.remove("test-context")) // support duplicate remove - _, err = testee.get("test-context") + assert.NilError(t, testee.remove(contextdirOf("test-context"))) + assert.NilError(t, testee.remove(contextdirOf("test-context"))) // support duplicate remove + _, err = testee.get(contextdirOf("test-context")) assert.Assert(t, IsErrContextDoesNotExist(err)) } @@ -65,8 +70,8 @@ func TestMetadataRespectJsonAnnotation(t *testing.T) { assert.NilError(t, err) defer os.RemoveAll(testDir) testee := metadataStore{root: testDir, config: testCfg} - assert.NilError(t, testee.createOrUpdate("test", testMetadata)) - bytes, err := ioutil.ReadFile(filepath.Join(testDir, "test", "meta.json")) + assert.NilError(t, testee.createOrUpdate(testMetadata("test"))) + bytes, err := ioutil.ReadFile(filepath.Join(testDir, string(contextdirOf("test")), "meta.json")) assert.NilError(t, err) assert.Assert(t, cmp.Contains(string(bytes), "a_very_recognizable_field_name")) assert.Assert(t, cmp.Contains(string(bytes), "another_very_recognizable_field_name")) @@ -77,16 +82,14 @@ func TestMetadataList(t *testing.T) { assert.NilError(t, err) defer os.RemoveAll(testDir) testee := metadataStore{root: testDir, config: testCfg} - wholeData := map[string]ContextMetadata{ - "simple": testMetadata, - "simple2": testMetadata, - "nested/context": testMetadata, - "nestedwith-parent/context": testMetadata, - "nestedwith-parent": testMetadata, + wholeData := []ContextMetadata{ + testMetadata("context1"), + testMetadata("context2"), + testMetadata("context3"), } - for k, s := range wholeData { - err = testee.createOrUpdate(k, s) + for _, s := range wholeData { + err = testee.createOrUpdate(s) assert.NilError(t, err) } @@ -100,16 +103,14 @@ func TestEmptyConfig(t *testing.T) { assert.NilError(t, err) defer os.RemoveAll(testDir) testee := metadataStore{root: testDir} - wholeData := map[string]ContextMetadata{ - "simple": testMetadata, - "simple2": testMetadata, - "nested/context": testMetadata, - "nestedwith-parent/context": testMetadata, - "nestedwith-parent": testMetadata, + wholeData := []ContextMetadata{ + testMetadata("context1"), + testMetadata("context2"), + testMetadata("context3"), } - for k, s := range wholeData { - err = testee.createOrUpdate(k, s) + for _, s := range wholeData { + err = testee.createOrUpdate(s) assert.NilError(t, err) } @@ -135,8 +136,8 @@ func TestWithEmbedding(t *testing.T) { Val: "Hello", }, } - assert.NilError(t, testee.createOrUpdate("test", ContextMetadata{Metadata: testCtxMeta})) - res, err := testee.get("test") + assert.NilError(t, testee.createOrUpdate(ContextMetadata{Metadata: testCtxMeta, Name: "test"})) + res, err := testee.get(contextdirOf("test")) assert.NilError(t, err) assert.Equal(t, testCtxMeta, res.Metadata) } diff --git a/cli/context/store/metadatastore.go b/cli/context/store/metadatastore.go index 178cf9e288..47aacdc5f8 100644 --- a/cli/context/store/metadatastore.go +++ b/cli/context/store/metadatastore.go @@ -7,6 +7,9 @@ import ( "os" "path/filepath" "reflect" + "sort" + + "vbom.ml/util/sortorder" ) const ( @@ -19,12 +22,12 @@ type metadataStore struct { config Config } -func (s *metadataStore) contextDir(name string) string { - return filepath.Join(s.root, name) +func (s *metadataStore) contextDir(id contextdir) string { + return filepath.Join(s.root, string(id)) } -func (s *metadataStore) createOrUpdate(name string, meta ContextMetadata) error { - contextDir := s.contextDir(name) +func (s *metadataStore) createOrUpdate(meta ContextMetadata) error { + contextDir := s.contextDir(contextdirOf(meta.Name)) if err := os.MkdirAll(contextDir, 0755); err != nil { return err } @@ -53,11 +56,11 @@ func parseTypedOrMap(payload []byte, getter TypeGetter) (interface{}, error) { return reflect.ValueOf(typed).Elem().Interface(), nil } -func (s *metadataStore) get(name string) (ContextMetadata, error) { - contextDir := s.contextDir(name) +func (s *metadataStore) get(id contextdir) (ContextMetadata, error) { + contextDir := s.contextDir(id) bytes, err := ioutil.ReadFile(filepath.Join(contextDir, metaFile)) if err != nil { - return ContextMetadata{}, convertContextDoesNotExist(name, err) + return ContextMetadata{}, convertContextDoesNotExist(err) } var untyped untypedContextMetadata r := ContextMetadata{ @@ -66,6 +69,7 @@ func (s *metadataStore) get(name string) (ContextMetadata, error) { if err := json.Unmarshal(bytes, &untyped); err != nil { return ContextMetadata{}, err } + r.Name = untyped.Name if r.Metadata, err = parseTypedOrMap(untyped.Metadata, s.config.contextType); err != nil { return ContextMetadata{}, err } @@ -77,28 +81,30 @@ func (s *metadataStore) get(name string) (ContextMetadata, error) { return r, err } -func (s *metadataStore) remove(name string) error { - contextDir := s.contextDir(name) +func (s *metadataStore) remove(id contextdir) error { + contextDir := s.contextDir(id) return os.RemoveAll(contextDir) } -func (s *metadataStore) list() (map[string]ContextMetadata, error) { - ctxNames, err := listRecursivelyMetadataDirs(s.root) +func (s *metadataStore) list() ([]ContextMetadata, error) { + ctxDirs, err := listRecursivelyMetadataDirs(s.root) if err != nil { if os.IsNotExist(err) { - // store is empty, meta dir does not exist yet - // this should not be considered an error - return map[string]ContextMetadata{}, nil + return nil, nil } return nil, err } - res := make(map[string]ContextMetadata) - for _, name := range ctxNames { - res[name], err = s.get(name) + var res []ContextMetadata + for _, dir := range ctxDirs { + c, err := s.get(contextdir(dir)) if err != nil { return nil, err } + res = append(res, c) } + sort.Slice(res, func(i, j int) bool { + return sortorder.NaturalLess(res[i].Name, res[j].Name) + }) return res, nil } @@ -133,9 +139,9 @@ func listRecursivelyMetadataDirs(root string) ([]string, error) { return result, nil } -func convertContextDoesNotExist(name string, err error) error { +func convertContextDoesNotExist(err error) error { if os.IsNotExist(err) { - return &contextDoesNotExistError{name: name} + return &contextDoesNotExistError{} } return err } @@ -143,4 +149,5 @@ func convertContextDoesNotExist(name string, err error) error { type untypedContextMetadata struct { Metadata json.RawMessage `json:"metadata,omitempty"` Endpoints map[string]json.RawMessage `json:"endpoints,omitempty"` + Name string `json:"name,omitempty"` } diff --git a/cli/context/store/store.go b/cli/context/store/store.go index 9238a92a23..5afb30749d 100644 --- a/cli/context/store/store.go +++ b/cli/context/store/store.go @@ -2,6 +2,7 @@ package store import ( "archive/tar" + _ "crypto/sha256" // ensure ids can be computed "encoding/json" "errors" "fmt" @@ -10,24 +11,34 @@ import ( "path" "path/filepath" "strings" + + "github.com/opencontainers/go-digest" ) // Store provides a context store for easily remembering endpoints configuration type Store interface { - ListContexts() (map[string]ContextMetadata, error) - CreateOrUpdateContext(name string, meta ContextMetadata) error + ListContexts() ([]ContextMetadata, error) + CreateOrUpdateContext(meta ContextMetadata) error RemoveContext(name string) error GetContextMetadata(name string) (ContextMetadata, error) ResetContextTLSMaterial(name string, data *ContextTLSData) error ResetContextEndpointTLSMaterial(contextName string, endpointName string, data *EndpointTLSData) error ListContextTLSFiles(name string) (map[string]EndpointFiles, error) GetContextTLSData(contextName, endpointName, fileName string) ([]byte, error) + GetContextStorageInfo(contextName string) ContextStorageInfo } // ContextMetadata contains metadata about a context and its endpoints type ContextMetadata struct { - Metadata interface{} `json:"metadata,omitempty"` - Endpoints map[string]interface{} `json:"endpoints,omitempty"` + Name string `json:",omitempty"` + Metadata interface{} `json:",omitempty"` + Endpoints map[string]interface{} `json:",omitempty"` +} + +// ContextStorageInfo contains data about where a given context is stored +type ContextStorageInfo struct { + MetadataPath string + TLSPath string } // EndpointTLSData represents tls data for a given endpoint @@ -62,36 +73,40 @@ type store struct { tls *tlsStore } -func (s *store) ListContexts() (map[string]ContextMetadata, error) { +func (s *store) ListContexts() ([]ContextMetadata, error) { return s.meta.list() } -func (s *store) CreateOrUpdateContext(name string, meta ContextMetadata) error { - return s.meta.createOrUpdate(name, meta) +func (s *store) CreateOrUpdateContext(meta ContextMetadata) error { + return s.meta.createOrUpdate(meta) } func (s *store) RemoveContext(name string) error { - if err := s.meta.remove(name); err != nil { - return err + id := contextdirOf(name) + if err := s.meta.remove(id); err != nil { + return patchErrContextName(err, name) } - return s.tls.removeAllContextData(name) + return patchErrContextName(s.tls.removeAllContextData(id), name) } func (s *store) GetContextMetadata(name string) (ContextMetadata, error) { - return s.meta.get(name) + res, err := s.meta.get(contextdirOf(name)) + patchErrContextName(err, name) + return res, err } func (s *store) ResetContextTLSMaterial(name string, data *ContextTLSData) error { - if err := s.tls.removeAllContextData(name); err != nil { - return err + id := contextdirOf(name) + if err := s.tls.removeAllContextData(id); err != nil { + return patchErrContextName(err, name) } if data == nil { return nil } for ep, files := range data.Endpoints { for fileName, data := range files.Files { - if err := s.tls.createOrUpdate(name, ep, fileName, data); err != nil { - return err + if err := s.tls.createOrUpdate(id, ep, fileName, data); err != nil { + return patchErrContextName(err, name) } } } @@ -99,26 +114,37 @@ func (s *store) ResetContextTLSMaterial(name string, data *ContextTLSData) error } func (s *store) ResetContextEndpointTLSMaterial(contextName string, endpointName string, data *EndpointTLSData) error { - if err := s.tls.removeAllEndpointData(contextName, endpointName); err != nil { - return err + id := contextdirOf(contextName) + if err := s.tls.removeAllEndpointData(id, endpointName); err != nil { + return patchErrContextName(err, contextName) } if data == nil { return nil } for fileName, data := range data.Files { - if err := s.tls.createOrUpdate(contextName, endpointName, fileName, data); err != nil { - return err + if err := s.tls.createOrUpdate(id, endpointName, fileName, data); err != nil { + return patchErrContextName(err, contextName) } } return nil } func (s *store) ListContextTLSFiles(name string) (map[string]EndpointFiles, error) { - return s.tls.listContextData(name) + res, err := s.tls.listContextData(contextdirOf(name)) + return res, patchErrContextName(err, name) } func (s *store) GetContextTLSData(contextName, endpointName, fileName string) ([]byte, error) { - return s.tls.getData(contextName, endpointName, fileName) + res, err := s.tls.getData(contextdirOf(contextName), endpointName, fileName) + return res, patchErrContextName(err, contextName) +} + +func (s *store) GetContextStorageInfo(contextName string) ContextStorageInfo { + dir := contextdirOf(contextName) + return ContextStorageInfo{ + MetadataPath: s.meta.contextDir(dir), + TLSPath: s.tls.contextDir(dir), + } } // Export exports an existing namespace into an opaque data stream @@ -227,7 +253,8 @@ func Import(name string, s Store, reader io.Reader) error { if err := json.Unmarshal(data, &meta); err != nil { return err } - if err := s.CreateOrUpdateContext(name, meta); err != nil { + meta.Name = name + if err := s.CreateOrUpdateContext(meta); err != nil { return err } } else if strings.HasPrefix(hdr.Name, "tls/") { @@ -253,6 +280,10 @@ func Import(name string, s Store, reader io.Reader) error { return s.ResetContextTLSMaterial(name, &tlsData) } +type setContextName interface { + setContext(name string) +} + type contextDoesNotExistError struct { name string } @@ -261,6 +292,13 @@ func (e *contextDoesNotExistError) Error() string { return fmt.Sprintf("context %q does not exist", e.name) } +func (e *contextDoesNotExistError) setContext(name string) { + e.name = name +} + +// NotFound satisfies interface github.com/docker/docker/errdefs.ErrNotFound +func (e *contextDoesNotExistError) NotFound() {} + type tlsDataDoesNotExistError struct { context, endpoint, file string } @@ -269,6 +307,13 @@ func (e *tlsDataDoesNotExistError) Error() string { return fmt.Sprintf("tls data for %s/%s/%s does not exist", e.context, e.endpoint, e.file) } +func (e *tlsDataDoesNotExistError) setContext(name string) { + e.context = name +} + +// NotFound satisfies interface github.com/docker/docker/errdefs.ErrNotFound +func (e *tlsDataDoesNotExistError) NotFound() {} + // IsErrContextDoesNotExist checks if the given error is a "context does not exist" condition func IsErrContextDoesNotExist(err error) bool { _, ok := err.(*contextDoesNotExistError) @@ -280,3 +325,16 @@ func IsErrTLSDataDoesNotExist(err error) bool { _, ok := err.(*tlsDataDoesNotExistError) return ok } + +type contextdir string + +func contextdirOf(name string) contextdir { + return contextdir(digest.FromString(name).Encoded()) +} + +func patchErrContextName(err error, name string) error { + if typed, ok := err.(setContextName); ok { + typed.setContext(name) + } + return err +} diff --git a/cli/context/store/store_test.go b/cli/context/store/store_test.go index c1994d0441..c18bcbb7b5 100644 --- a/cli/context/store/store_test.go +++ b/cli/context/store/store_test.go @@ -26,12 +26,13 @@ func TestExportImport(t *testing.T) { assert.NilError(t, err) defer os.RemoveAll(testDir) s := New(testDir, testCfg) - err = s.CreateOrUpdateContext("source", + err = s.CreateOrUpdateContext( ContextMetadata{ Endpoints: map[string]interface{}{ "ep1": endpoint{Foo: "bar"}, }, Metadata: context{Bar: "baz"}, + Name: "source", }) assert.NilError(t, err) err = s.ResetContextEndpointTLSMaterial("source", "ep1", &EndpointTLSData{ @@ -48,7 +49,8 @@ func TestExportImport(t *testing.T) { assert.NilError(t, err) destMeta, err := s.GetContextMetadata("dest") assert.NilError(t, err) - assert.DeepEqual(t, destMeta, srcMeta) + assert.DeepEqual(t, destMeta.Metadata, srcMeta.Metadata) + assert.DeepEqual(t, destMeta.Endpoints, srcMeta.Endpoints) srcFileList, err := s.ListContextTLSFiles("source") assert.NilError(t, err) destFileList, err := s.ListContextTLSFiles("dest") @@ -67,12 +69,13 @@ func TestRemove(t *testing.T) { assert.NilError(t, err) defer os.RemoveAll(testDir) s := New(testDir, testCfg) - err = s.CreateOrUpdateContext("source", + err = s.CreateOrUpdateContext( ContextMetadata{ Endpoints: map[string]interface{}{ "ep1": endpoint{Foo: "bar"}, }, Metadata: context{Bar: "baz"}, + Name: "source", }) assert.NilError(t, err) assert.NilError(t, s.ResetContextEndpointTLSMaterial("source", "ep1", &EndpointTLSData{ @@ -95,6 +98,15 @@ func TestListEmptyStore(t *testing.T) { store := New(testDir, testCfg) result, err := store.ListContexts() assert.NilError(t, err) - assert.Check(t, result != nil) assert.Check(t, len(result) == 0) } + +func TestErrHasCorrectContext(t *testing.T) { + testDir, err := ioutil.TempDir("", t.Name()) + assert.NilError(t, err) + defer os.RemoveAll(testDir) + store := New(testDir, testCfg) + _, err = store.GetContextMetadata("no-exists") + assert.ErrorContains(t, err, "no-exists") + assert.Check(t, IsErrContextDoesNotExist(err)) +} diff --git a/cli/context/store/tlsstore.go b/cli/context/store/tlsstore.go index 0d978df129..1188ce2df7 100644 --- a/cli/context/store/tlsstore.go +++ b/cli/context/store/tlsstore.go @@ -12,20 +12,20 @@ type tlsStore struct { root string } -func (s *tlsStore) contextDir(name string) string { - return filepath.Join(s.root, name) +func (s *tlsStore) contextDir(id contextdir) string { + return filepath.Join(s.root, string(id)) } -func (s *tlsStore) endpointDir(contextName, name string) string { - return filepath.Join(s.root, contextName, name) +func (s *tlsStore) endpointDir(contextID contextdir, name string) string { + return filepath.Join(s.root, string(contextID), name) } -func (s *tlsStore) filePath(contextName, endpointName, filename string) string { - return filepath.Join(s.root, contextName, endpointName, filename) +func (s *tlsStore) filePath(contextID contextdir, endpointName, filename string) string { + return filepath.Join(s.root, string(contextID), endpointName, filename) } -func (s *tlsStore) createOrUpdate(contextName, endpointName, filename string, data []byte) error { - epdir := s.endpointDir(contextName, endpointName) +func (s *tlsStore) createOrUpdate(contextID contextdir, endpointName, filename string, data []byte) error { + epdir := s.endpointDir(contextID, endpointName) parentOfRoot := filepath.Dir(s.root) if err := os.MkdirAll(parentOfRoot, 0755); err != nil { return err @@ -33,35 +33,35 @@ func (s *tlsStore) createOrUpdate(contextName, endpointName, filename string, da if err := os.MkdirAll(epdir, 0700); err != nil { return err } - return ioutil.WriteFile(s.filePath(contextName, endpointName, filename), data, 0600) + return ioutil.WriteFile(s.filePath(contextID, endpointName, filename), data, 0600) } -func (s *tlsStore) getData(contextName, endpointName, filename string) ([]byte, error) { - data, err := ioutil.ReadFile(s.filePath(contextName, endpointName, filename)) +func (s *tlsStore) getData(contextID contextdir, endpointName, filename string) ([]byte, error) { + data, err := ioutil.ReadFile(s.filePath(contextID, endpointName, filename)) if err != nil { - return nil, convertTLSDataDoesNotExist(contextName, endpointName, filename, err) + return nil, convertTLSDataDoesNotExist(endpointName, filename, err) } return data, nil } -func (s *tlsStore) remove(contextName, endpointName, filename string) error { - err := os.Remove(s.filePath(contextName, endpointName, filename)) +func (s *tlsStore) remove(contextID contextdir, endpointName, filename string) error { + err := os.Remove(s.filePath(contextID, endpointName, filename)) if os.IsNotExist(err) { return nil } return err } -func (s *tlsStore) removeAllEndpointData(contextName, endpointName string) error { - return os.RemoveAll(s.endpointDir(contextName, endpointName)) +func (s *tlsStore) removeAllEndpointData(contextID contextdir, endpointName string) error { + return os.RemoveAll(s.endpointDir(contextID, endpointName)) } -func (s *tlsStore) removeAllContextData(contextName string) error { - return os.RemoveAll(s.contextDir(contextName)) +func (s *tlsStore) removeAllContextData(contextID contextdir) error { + return os.RemoveAll(s.contextDir(contextID)) } -func (s *tlsStore) listContextData(contextName string) (map[string]EndpointFiles, error) { - epFSs, err := ioutil.ReadDir(s.contextDir(contextName)) +func (s *tlsStore) listContextData(contextID contextdir) (map[string]EndpointFiles, error) { + epFSs, err := ioutil.ReadDir(s.contextDir(contextID)) if err != nil { if os.IsNotExist(err) { return map[string]EndpointFiles{}, nil @@ -71,7 +71,7 @@ func (s *tlsStore) listContextData(contextName string) (map[string]EndpointFiles r := make(map[string]EndpointFiles) for _, epFS := range epFSs { if epFS.IsDir() { - epDir := s.endpointDir(contextName, epFS.Name()) + epDir := s.endpointDir(contextID, epFS.Name()) fss, err := ioutil.ReadDir(epDir) if err != nil { return nil, err @@ -91,9 +91,9 @@ func (s *tlsStore) listContextData(contextName string) (map[string]EndpointFiles // EndpointFiles is a slice of strings representing file names type EndpointFiles []string -func convertTLSDataDoesNotExist(context, endpoint, file string, err error) error { +func convertTLSDataDoesNotExist(endpoint, file string, err error) error { if os.IsNotExist(err) { - return &tlsDataDoesNotExistError{context: context, endpoint: endpoint, file: file} + return &tlsDataDoesNotExistError{endpoint: endpoint, file: file} } return err } diff --git a/docs/reference/commandline/cli.md b/docs/reference/commandline/cli.md index 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