From e326203291fc3793f479c74c5c74db6b492a6c44 Mon Sep 17 00:00:00 2001 From: Marc Khouzam Date: Mon, 29 Apr 2024 09:56:23 -0400 Subject: [PATCH] Add a new `tanzu context current [--short]` command to easily show the current context (#750) --- docs/quickstart/quickstart.md | 3 + go.mod | 2 +- go.sum | 4 +- pkg/command/context.go | 123 ++++++++++++++- pkg/command/context_test.go | 283 ++++++++++++++++++++++++++++++++++ pkg/command/root.go | 11 ++ 6 files changed, 421 insertions(+), 5 deletions(-) diff --git a/docs/quickstart/quickstart.md b/docs/quickstart/quickstart.md index ac6676d37..438bcd629 100644 --- a/docs/quickstart/quickstart.md +++ b/docs/quickstart/quickstart.md @@ -254,6 +254,9 @@ CLI can target. There are various ways to create Contexts, such as providing an endpoint to the Tanzu Mission Control service, or providing a kubeconfig to an existing Tanzu Cluster as shown above. +Note: The `tanzu context current --short` command prints a compact form of the current context. This can be used +in prompts to help users keep track of which context the tanzu CLI is currently interacting with. + #### Creating a Tanzu Context The context of type "tanzu" can be created using interactive login (default mechanism) or by utilizing an API Token diff --git a/go.mod b/go.mod index ecfd2a03b..584bc46de 100644 --- a/go.mod +++ b/go.mod @@ -42,7 +42,7 @@ require ( github.com/vmware-tanzu/carvel-ytt v0.40.0 github.com/vmware-tanzu/tanzu-cli/test/e2e/framework v0.0.0-00010101000000-000000000000 github.com/vmware-tanzu/tanzu-framework/capabilities/client v0.0.0-20230523145612-1c6fbba34686 - github.com/vmware-tanzu/tanzu-plugin-runtime v1.3.0-dev.0.20240419212412-88223225a6aa + github.com/vmware-tanzu/tanzu-plugin-runtime v1.3.0-dev.0.20240426182051-2590b16de5ae go.pinniped.dev v0.20.0 golang.org/x/mod v0.12.0 golang.org/x/oauth2 v0.8.0 diff --git a/go.sum b/go.sum index b4bb71d99..df95a4697 100644 --- a/go.sum +++ b/go.sum @@ -738,8 +738,8 @@ github.com/vmware-tanzu/tanzu-framework/apis/run v0.0.0-20230419030809-7081502eb github.com/vmware-tanzu/tanzu-framework/apis/run v0.0.0-20230419030809-7081502ebf68/go.mod h1:e1Uef+Ux5BIHpYwqbeP2ZZmOzehBcez2vUEWXHe+xHE= github.com/vmware-tanzu/tanzu-framework/capabilities/client v0.0.0-20230523145612-1c6fbba34686 h1:VcuXqUXFxm5WDqWkzAlU/6cJXua0ozELnqD59fy7J6E= github.com/vmware-tanzu/tanzu-framework/capabilities/client v0.0.0-20230523145612-1c6fbba34686/go.mod h1:AFGOXZD4tH+KhpmtV0VjWjllXhr8y57MvOsIxTtywc4= -github.com/vmware-tanzu/tanzu-plugin-runtime v1.3.0-dev.0.20240419212412-88223225a6aa h1:iTfXc8CaDaFlKT/9UQFzkmFmgqZCI+k5ljTfR7/CB3A= -github.com/vmware-tanzu/tanzu-plugin-runtime v1.3.0-dev.0.20240419212412-88223225a6aa/go.mod h1:5m73y796B4EoeXZtvkq8jbQPPQXeYkLPLS2BBbxZp7o= +github.com/vmware-tanzu/tanzu-plugin-runtime v1.3.0-dev.0.20240426182051-2590b16de5ae h1:/queEiLNkAA2NdeNfAaBTnJTZ7x7bsn1txV2B+90GK8= +github.com/vmware-tanzu/tanzu-plugin-runtime v1.3.0-dev.0.20240426182051-2590b16de5ae/go.mod h1:5m73y796B4EoeXZtvkq8jbQPPQXeYkLPLS2BBbxZp7o= github.com/xanzy/go-gitlab v0.83.0 h1:37p0MpTPNbsTMKX/JnmJtY8Ch1sFiJzVF342+RvZEGw= github.com/xanzy/go-gitlab v0.83.0/go.mod h1:5ryv+MnpZStBH8I/77HuQBsMbBGANtVpLWC15qOjWAw= github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= diff --git a/pkg/command/context.go b/pkg/command/context.go index 6a760c001..ff9a8953c 100644 --- a/pkg/command/context.go +++ b/pkg/command/context.go @@ -48,7 +48,7 @@ import ( ) var ( - stderrOnly, forceCSP, staging, onlyCurrent, skipTLSVerify, showAllColumns bool + stderrOnly, forceCSP, staging, onlyCurrent, skipTLSVerify, showAllColumns, shortCtx bool ctxName, endpoint, apiToken, kubeConfig, kubeContext, getOutputFmt, endpointCACertPath string projectStr, projectIDStr, spaceStr, clustergroupStr string @@ -98,6 +98,7 @@ func init() { createCtxCmd, listCtxCmd, getCtxCmd, + newCurrentCtxCmd(), deleteCtxCmd, useCtxCmd, unsetCtxCmd, @@ -1155,6 +1156,124 @@ func getValues(m map[configtypes.ContextType]*configtypes.Context) []*configtype return values } +func newCurrentCtxCmd() *cobra.Command { + var currentCtxCmd = &cobra.Command{ + Use: "current", + Short: "Display the current context", + Args: cobra.NoArgs, + ValidArgsFunction: noMoreCompletions, + RunE: func(cmd *cobra.Command, args []string) error { + currentCtxMap, err := config.GetAllActiveContextsMap() + if err != nil { + return err + } + + if len(currentCtxMap) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "There is no active context") + return nil + } + + ignoreTMCCtx := false + if len(currentCtxMap) > 1 { + // If there are multiple contexts, which means 2 of them, ignore the TMC context + // and prioritize the tanzu or k8s context (only one of those two will be present) + ignoreTMCCtx = true + } + + for ctxType, ctx := range currentCtxMap { + if ignoreTMCCtx && ctxType == configtypes.ContextTypeTMC { + continue + } + + if shortCtx { + printShortContext(cmd.OutOrStdout(), ctx) + } else { + printContext(cmd.OutOrStdout(), ctx) + } + } + return nil + }, + } + + currentCtxCmd.Flags().BoolVarP(&shortCtx, "short", "", false, "prints the context in compact form") + + return currentCtxCmd +} + +func printShortContext(writer io.Writer, ctx *configtypes.Context) { + if ctx == nil { + return + } + + var ctxStr strings.Builder + ctxStr.WriteString(ctx.Name) + + // For a tanzu context, print the project, space, and cluster group + if ctx.ContextType == configtypes.ContextTypeTanzu { + resources, err := config.GetTanzuContextActiveResource(ctx.Name) + if err == nil { + if resources.ProjectName != "" { + ctxStr.WriteString(fmt.Sprintf(":%s", resources.ProjectName)) + if resources.SpaceName != "" { + ctxStr.WriteString(fmt.Sprintf(":%s", resources.SpaceName)) + } else if resources.ClusterGroupName != "" { + ctxStr.WriteString(fmt.Sprintf(":%s", resources.ClusterGroupName)) + } + } + } + } + fmt.Fprintln(writer, ctxStr.String()) +} + +func printContext(writer io.Writer, ctx *configtypes.Context) { + if ctx == nil { + return + } + + // Use a ListTable format to get nice alignment + columns := []string{"Name", "Type"} + row := []interface{}{ctx.Name, string(ctx.ContextType)} + + if ctx.ContextType == configtypes.ContextTypeTanzu { + resources, err := config.GetTanzuContextActiveResource(ctx.Name) + if err == nil { + columns = append(columns, "Organization") + row = append(row, fmt.Sprintf("%s (%s)", resources.OrgName, resources.OrgID)) + + columns = append(columns, "Project") + if resources.ProjectName != "" { + row = append(row, fmt.Sprintf("%s (%s)", resources.ProjectName, resources.ProjectID)) + } else { + row = append(row, "none set") + } + + if resources.SpaceName != "" { + columns = append(columns, "Space") + row = append(row, resources.SpaceName) + } else if resources.ClusterGroupName != "" { + columns = append(columns, "Cluster Group") + row = append(row, resources.ClusterGroupName) + } + } + } + + if ctx.ContextType != configtypes.ContextTypeTMC { + var kubeconfig, kubeCtx string + if ctx.ClusterOpts != nil { + kubeconfig = ctx.ClusterOpts.Path + kubeCtx = ctx.ClusterOpts.Context + } + columns = append(columns, "Kube Config") + row = append(row, kubeconfig) + columns = append(columns, "Kube Context") + row = append(row, kubeCtx) + } + + outputWriter := component.NewOutputWriterWithOptions(writer, string(component.ListTableOutputType), []component.OutputWriterOption{}, columns...) + outputWriter.AddRow(row...) + outputWriter.Render() +} + var deleteCtxCmd = &cobra.Command{ Use: "delete CONTEXT_NAME", Short: "Delete a context from the config", @@ -1530,7 +1649,7 @@ func displayContextListOutputWithDynamicColumns(cfg *configtypes.ClientConfig, w if !showAllColumns { fmt.Println() - log.Info("Use '--wide' flag to view additional columns.") + log.Info("Use '--wide' to view additional columns.") } } diff --git a/pkg/command/context_test.go b/pkg/command/context_test.go index ceefa2a99..11484d416 100644 --- a/pkg/command/context_test.go +++ b/pkg/command/context_test.go @@ -1323,6 +1323,15 @@ func TestCompletionContext(t *testing.T) { expected: expectedOutForOutputFlag + ":4\n", }, // ===================== + // tanzu context current + // ===================== + { + test: "no completion after the current command", + args: []string{"__complete", "context", "current", ""}, + // ":4" is the value of the ShellCompDirectiveNoFileComp + expected: "_activeHelp_ " + compNoMoreArgsMsg + "\n:4\n", + }, + // ===================== // tanzu context delete // ===================== { @@ -1552,6 +1561,279 @@ func TestCompletionContext(t *testing.T) { os.Unsetenv("TANZU_ACTIVE_HELP") } +func TestContextCurrentCmd(t *testing.T) { + ctxK8s := &configtypes.Context{ + Name: "tkg", + ContextType: configtypes.ContextTypeK8s, + ClusterOpts: &configtypes.ClusterServer{ + Endpoint: "https://example.com/myendpoint/k8s/1", + Context: "kube-context-name", + Path: "/home/user/.kube/config", + }, + } + ctxTMC := &configtypes.Context{ + Name: "tmc", + ContextType: configtypes.ContextTypeTMC, + GlobalOpts: &configtypes.GlobalServer{Endpoint: "https://example.com/myendpoint/tmc/1"}, + } + ctxTanzuNoOrg := &configtypes.Context{ + Name: "tanzu", + ContextType: configtypes.ContextTypeTanzu, + ClusterOpts: &configtypes.ClusterServer{ + Endpoint: "https://example.com/myendpoint/tanzu/1", + Context: "kube-context-name", + Path: "/home/user/.kube/config", + }, + } + ctxTanzuNoProject := &configtypes.Context{ + Name: "tanzu", + ContextType: configtypes.ContextTypeTanzu, + ClusterOpts: &configtypes.ClusterServer{ + Endpoint: "https://example.com/myendpoint/tanzu/1", + Context: "kube-context-name", + Path: "/home/user/.kube/config", + }, + AdditionalMetadata: map[string]interface{}{ + config.OrgIDKey: "org-id", + config.OrgNameKey: "org-name", + }, + } + ctxTanzuNoSpace := &configtypes.Context{ + Name: "tanzu", + ContextType: configtypes.ContextTypeTanzu, + ClusterOpts: &configtypes.ClusterServer{ + Endpoint: "https://example.com/myendpoint/tanzu/1", + Context: "kube-context-name", + Path: "/home/user/.kube/config", + }, + AdditionalMetadata: map[string]interface{}{ + config.OrgIDKey: "org-id", + config.OrgNameKey: "org-name", + config.ProjectNameKey: "project-name", + config.ProjectIDKey: "project-id", + }, + } + ctxTanzuSpace := &configtypes.Context{ + Name: "tanzu", + ContextType: configtypes.ContextTypeTanzu, + ClusterOpts: &configtypes.ClusterServer{ + Endpoint: "https://example.com/myendpoint/tanzu/1", + Context: "kube-context-name", + Path: "/home/user/.kube/config", + }, + AdditionalMetadata: map[string]interface{}{ + config.OrgIDKey: "org-id", + config.OrgNameKey: "org-name", + config.ProjectNameKey: "project-name", + config.ProjectIDKey: "project-id", + config.SpaceNameKey: "space-name", + }, + } + ctxTanzuClustergroup := &configtypes.Context{ + Name: "tanzu", + ContextType: configtypes.ContextTypeTanzu, + ClusterOpts: &configtypes.ClusterServer{ + Endpoint: "https://example.com/myendpoint/tanzu/1", + Context: "kube-context-name", + Path: "/home/user/.kube/config", + }, + AdditionalMetadata: map[string]interface{}{ + config.OrgIDKey: "org-id", + config.OrgNameKey: "org-name", + config.ProjectNameKey: "project-name", + config.ProjectIDKey: "project-id", + config.ClusterGroupNameKey: "clustergroup-name", + }, + } + + tests := []struct { + test string + activeContexts []*configtypes.Context + short bool + expected string + }{ + { + test: "no active context", + expected: "There is no active context\n", + }, + { + test: "no active context short", + short: true, + expected: "There is no active context\n", + }, + { + test: "single k8s active context", + activeContexts: []*configtypes.Context{ctxK8s}, + expected: ` Name: tkg + Type: kubernetes + Kube Config: /home/user/.kube/config + Kube Context: kube-context-name +`, + }, + { + test: "single k8s active context short", + short: true, + activeContexts: []*configtypes.Context{ctxK8s}, + expected: "tkg\n", + }, + { + test: "single tmc active context", + activeContexts: []*configtypes.Context{ctxTMC}, + expected: ` Name: tmc + Type: mission-control +`, + }, + { + test: "single tmc active context short", + short: true, + activeContexts: []*configtypes.Context{ctxTMC}, + expected: "tmc\n", + }, + { + test: "both k8s and tmc active contexts", + activeContexts: []*configtypes.Context{ctxK8s, ctxTMC}, + expected: ` Name: tkg + Type: kubernetes + Kube Config: /home/user/.kube/config + Kube Context: kube-context-name +`, + }, + { + test: "both k8s and tmc active contexts short", + short: true, + activeContexts: []*configtypes.Context{ctxK8s, ctxTMC}, + expected: "tkg\n", + }, + { + test: "tanzu no org", + activeContexts: []*configtypes.Context{ctxTanzuNoOrg, ctxTMC}, + expected: ` Name: tanzu + Type: tanzu + Kube Config: /home/user/.kube/config + Kube Context: kube-context-name +`, + }, + { + test: "tanzu no org short", + short: true, + activeContexts: []*configtypes.Context{ctxTanzuNoOrg, ctxTMC}, + expected: "tanzu\n", + }, + { + test: "tanzu just org", + activeContexts: []*configtypes.Context{ctxTanzuNoProject, ctxTMC}, + expected: ` Name: tanzu + Type: tanzu + Organization: org-name (org-id) + Project: none set + Kube Config: /home/user/.kube/config + Kube Context: kube-context-name +`, + }, + { + test: "tanzu just org short", + short: true, + activeContexts: []*configtypes.Context{ctxTanzuNoProject, ctxTMC}, + expected: "tanzu\n", + }, + { + test: "tanzu just project", + activeContexts: []*configtypes.Context{ctxTanzuNoSpace}, + expected: ` Name: tanzu + Type: tanzu + Organization: org-name (org-id) + Project: project-name (project-id) + Kube Config: /home/user/.kube/config + Kube Context: kube-context-name +`, + }, + { + test: "tanzu just project short", + short: true, + activeContexts: []*configtypes.Context{ctxTanzuNoSpace}, + expected: "tanzu:project-name\n", + }, + { + test: "tanzu with space", + activeContexts: []*configtypes.Context{ctxTanzuSpace}, + expected: ` Name: tanzu + Type: tanzu + Organization: org-name (org-id) + Project: project-name (project-id) + Space: space-name + Kube Config: /home/user/.kube/config + Kube Context: kube-context-name +`, + }, + { + test: "tanzu with space short", + short: true, + activeContexts: []*configtypes.Context{ctxTanzuSpace}, + expected: "tanzu:project-name:space-name\n", + }, + { + test: "tanzu with clustergroup", + activeContexts: []*configtypes.Context{ctxTanzuClustergroup, ctxTMC}, + expected: ` Name: tanzu + Type: tanzu + Organization: org-name (org-id) + Project: project-name (project-id) + Cluster Group: clustergroup-name + Kube Config: /home/user/.kube/config + Kube Context: kube-context-name +`, + }, + { + test: "tanzu with clustergroup short", + short: true, + activeContexts: []*configtypes.Context{ctxTanzuClustergroup, ctxTMC}, + expected: "tanzu:project-name:clustergroup-name\n", + }, + } + + for _, spec := range tests { + t.Run(spec.test, func(t *testing.T) { + assert := assert.New(t) + + // Setup a temporary configuration + configFile, err := os.CreateTemp("", "config") + assert.Nil(err) + os.Setenv("TANZU_CONFIG", configFile.Name()) + configFileNG, err := os.CreateTemp("", "config_ng") + assert.Nil(err) + os.Setenv("TANZU_CONFIG_NEXT_GEN", configFileNG.Name()) + + // Add some active contexts + for i := range spec.activeContexts { + _ = config.SetContext(spec.activeContexts[i], true) + } + + rootCmd, err := NewRootCmd() + assert.Nil(err) + + var out bytes.Buffer + rootCmd.SetOut(&out) + args := []string{"context", "current"} + if spec.short { + args = append(args, "--short") + } + rootCmd.SetArgs(args) + + err = rootCmd.Execute() + assert.Nil(err) + + assert.Equal(spec.expected, out.String()) + + resetContextCommandFlags() + + os.Unsetenv("TANZU_CONFIG") + os.Unsetenv("TANZU_CONFIG_NEXT_GEN") + os.RemoveAll(configFile.Name()) + os.RemoveAll(configFileNG.Name()) + }) + } +} + func resetContextCommandFlags() { ctxName = "" endpoint = "" @@ -1566,6 +1848,7 @@ func resetContextCommandFlags() { targetStr = "" contextTypeStr = "" outputFormat = "" + shortCtx = false } func TestMapTanzuEndpointToTMCEndpoint(t *testing.T) { diff --git a/pkg/command/root.go b/pkg/command/root.go index d44366eeb..d1e7df69c 100644 --- a/pkg/command/root.go +++ b/pkg/command/root.go @@ -572,6 +572,8 @@ func shouldSkipTelemetryCollection(cmd *cobra.Command) bool { "tanzu completion", // Common first command to run, "tanzu version", + // Can be used to set the prompt on every shell command + "tanzu context current", // should skip telemetry for "telemetry" plugin "tanzu telemetry", } @@ -597,6 +599,8 @@ func shouldSkipPrompts(cmd *cobra.Command) bool { // get to see the prompts and the kubectl command execution just gets stuck, and it // is very hard for users to figure out what is going wrong "tanzu pinniped-auth", + // Can be used to set the prompt on every shell command + "tanzu context current", } return isSkipCommand(skipCommands, cmd.CommandPath()) } @@ -613,6 +617,9 @@ func shouldSkipEssentialPlugins(cmd *cobra.Command) bool { "tanzu config set", + // Can be used to set the prompt on every shell command + "tanzu context current", + "tanzu config eula", "tanzu ceip-participation set", // This command is being invoked by the kubectl exec binary where the user doesn't @@ -640,6 +647,8 @@ func shouldSkipVersionCheck(cmd *cobra.Command) bool { "tanzu completion", // Common first command to run, let's not recommend a new version of the CLI "tanzu version", + // Can be used to set the prompt on every shell command + "tanzu context current", } return isSkipCommand(skipVersionCheckCommands, cmd.CommandPath()) } @@ -654,6 +663,8 @@ func shouldSkipGlobalInit(cmd *cobra.Command) bool { "tanzu completion", // Common first command to run, let's not perform extra tasks "tanzu version", + // Can be used to set the prompt on every shell command + "tanzu context current", } return isSkipCommand(skipGlobalInitCommands, cmd.CommandPath()) }