diff --git a/tool/tsh/common/db.go b/tool/tsh/common/db.go index 82ad01b98fe4e..ebcb96dc8422a 100644 --- a/tool/tsh/common/db.go +++ b/tool/tsh/common/db.go @@ -1586,7 +1586,8 @@ func formatAmbiguousDB(cf *CLIConf, selectors resourceSelectors, matchedDBs type showDatabasesAsText(&sb, cf.SiteName, matchedDBs, activeDBs, checker, verbose) listCommand := formatDatabaseListCommand(cf.SiteName) - return formatAmbiguityErrTemplate(cf, selectors, listCommand, sb.String()) + fullNameExample := matchedDBs[0].GetName() + return formatAmbiguityErrTemplate(cf, selectors, listCommand, sb.String(), fullNameExample) } // resourceSelectors is a helper struct for gathering up the selectors for a @@ -1616,15 +1617,21 @@ func (r resourceSelectors) String() string { return strings.TrimSpace(out) } +// IsEmpty returns whether the selectors (except kind) are empty. +func (r resourceSelectors) IsEmpty() bool { + return r.name == "" && r.labels == "" && r.query == "" +} + // formatAmbiguityErrTemplate is a helper func that formats an ambiguous // resource error message. -func formatAmbiguityErrTemplate(cf *CLIConf, selectors resourceSelectors, listCommand, matchTable string) string { +func formatAmbiguityErrTemplate(cf *CLIConf, selectors resourceSelectors, listCommand, matchTable, fullNameExample string) string { data := map[string]any{ "command": cf.CommandWithBinary(), "selectors": strings.TrimSpace(selectors.String()), "listCommand": strings.TrimSpace(listCommand), "kind": strings.TrimSpace(selectors.kind), "matchTable": strings.TrimSpace(matchTable), + "example": strings.TrimSpace(fullNameExample), } var sb strings.Builder _ = ambiguityErrTemplate.Execute(&sb, data) @@ -1692,7 +1699,7 @@ You can start a local proxy for database GUI clients: {{ .matchTable }} Hint: use '{{ .listCommand }} -v' or '{{ .listCommand }} --format=[json|yaml]' to list all {{ .kind }}s with full details. -Hint: try selecting the {{ .kind }} with a more specific name (ex: {{ .command }} full-{{ .kind }}-name). +Hint: try selecting the {{ .kind }} with a more specific name (ex: {{ .command }} {{ .example }}). Hint: try selecting the {{ .kind }} with additional --labels or --query predicate. `)) ) diff --git a/tool/tsh/common/kube.go b/tool/tsh/common/kube.go index 8991022a325a5..f687025767f8f 100644 --- a/tool/tsh/common/kube.go +++ b/tool/tsh/common/kube.go @@ -1175,6 +1175,8 @@ type kubeLoginCommand struct { overrideContextName string disableAccessRequest bool requestReason string + labels string + predicateExpression string } func newKubeLoginCommand(parent *kingpin.CmdClause) *kubeLoginCommand { @@ -1183,11 +1185,13 @@ func newKubeLoginCommand(parent *kingpin.CmdClause) *kubeLoginCommand { } c.Flag("cluster", clusterHelp).Short('c').StringVar(&c.siteName) c.Arg("kube-cluster", "Name of the Kubernetes cluster to login to. Check 'tsh kube ls' for a list of available clusters.").StringVar(&c.kubeCluster) + c.Flag("labels", labelHelp).StringVar(&c.labels) + c.Flag("query", queryHelp).StringVar(&c.predicateExpression) c.Flag("as", "Configure custom Kubernetes user impersonation.").StringVar(&c.impersonateUser) c.Flag("as-groups", "Configure custom Kubernetes group impersonation.").StringsVar(&c.impersonateGroups) // TODO (tigrato): move this back to namespace once teleport drops the namespace flag. c.Flag("kube-namespace", "Configure the default Kubernetes namespace.").Short('n').StringVar(&c.namespace) - c.Flag("all", "Generate a kubeconfig with every cluster the user has access to.").BoolVar(&c.all) + c.Flag("all", "Generate a kubeconfig with every cluster the user has access to. Mutually exclusive with --labels or --query.").BoolVar(&c.all) c.Flag("set-context-name", "Define a custom context name. To use it with --all include \"{{.KubeName}}\""). // Use the default context name template if --set-context-name is not set. // This works as an hint to the user that the context name can be customized. @@ -1200,17 +1204,31 @@ func newKubeLoginCommand(parent *kingpin.CmdClause) *kubeLoginCommand { } func (c *kubeLoginCommand) run(cf *CLIConf) error { - if c.kubeCluster == "" && !c.all { + switch { + case c.all && (c.labels != "" || c.predicateExpression != ""): + return trace.BadParameter("cannot use --labels or --query with --all") + case !c.all && c.getSelectors().IsEmpty(): return trace.BadParameter("kube-cluster name is required. Check 'tsh kube ls' for a list of available clusters.") } - // If --all and --set-context-name are set, ensure that the template is valid - // and can produce distinct context names for each cluster before proceeding. - if err := kubeconfig.CheckContextOverrideTemplate(c.overrideContextName); err != nil && c.all { - return trace.Wrap(err) + // If --all, --query, or --labels and --set-context-name are set, ensure + // that the template is valid and can produce distinct context names for + // each cluster before proceeding. + if c.all || c.labels != "" || c.predicateExpression != "" { + err := kubeconfig.CheckContextOverrideTemplate(c.overrideContextName) + if err != nil { + return trace.Wrap(err) + } } - // Set CLIConf.KubernetesCluster so that the kube cluster's context is automatically selected. - cf.KubernetesCluster = c.kubeCluster + // NOTE: in case relogin-retry logic is used, we want to avoid having + // cf.KubernetesCluster set because kube cluster selection by prefix name is + // not supported in that flow + // (it's equivalent to tsh login --kube-cluster=). + // We will set that flag later, after listing the kube clusters and choosing + // one by prefix/labels/query (if a cluster name/prefix was given). + cf.Labels = c.labels + cf.PredicateExpression = c.predicateExpression + cf.SiteName = c.siteName cf.kubernetesImpersonationConfig = impersonationConfig{ kubernetesUser: c.impersonateUser, @@ -1225,73 +1243,144 @@ func (c *kubeLoginCommand) run(cf *CLIConf) error { return trace.Wrap(err) } + var kubeStatus *kubernetesStatus err = retryWithAccessRequest(cf, tc, func() error { // Check that this kube cluster exists. - currentTeleportCluster, kubeClusters, err := fetchKubeClusters(cf.Context, tc) + var err error + kubeStatus, err = fetchKubeStatus(cf.Context, tc) if err != nil { return trace.Wrap(err) } - clusterNames := kubeClustersToStrings(kubeClusters) - // If the user is trying to login to a specific cluster, check that it exists. - switch { - case c.kubeCluster != "" && !slices.Contains(clusterNames, c.kubeCluster): - return trace.AccessDenied("kubernetes cluster %q not found, check 'tsh kube ls' for a list of known clusters", c.kubeCluster) - case cf.ListAll && len(clusterNames) == 0: - return trace.AccessDenied("no kubernetes clusters found, check 'tsh kube ls' for a list of known clusters") - } - - // Update default kubeconfig file located at ~/.kube/config or the value of - // KUBECONFIG env var even if the context exists. - if err := updateKubeConfig(cf, tc, "", c.overrideContextName); err != nil { + err = c.checkClusterSelection(cf, tc, kubeStatus.kubeClusters) + if err != nil { + if trace.IsNotFound(err) { + // rewrap not found error as access denied, so we can retry + // fetching clusters with an access request. + return trace.AccessDenied(err.Error()) + } return trace.Wrap(err) } + return nil + }, c.accessRequestForKubeCluster, c.selectorsOrWildcard()) + if err != nil { + return trace.Wrap(err) + } - // Generate a profile specific kubeconfig which can be used - // by setting the kubeconfig environment variable (with `tsh env`) - profileKubeconfigPath := keypaths.KubeConfigPath( - profile.FullProfilePath(cf.HomePath), tc.WebProxyHost(), tc.Username, currentTeleportCluster, c.kubeCluster, - ) - if err := updateKubeConfig(cf, tc, profileKubeconfigPath, c.overrideContextName); err != nil { - return trace.Wrap(err) - } + // Update default kubeconfig file located at ~/.kube/config or the value of + // KUBECONFIG env var even if the context exists. + if err := updateKubeConfig(cf, tc, "", c.overrideContextName, kubeStatus); err != nil { + return trace.Wrap(err) + } - c.printUserMessage(cf, tc) - return nil - }, - accessRequestForKubeCluster, - resourceNameOrWildcard(c.kubeCluster, c.all), + // Generate a profile specific kubeconfig which can be used + // by setting the kubeconfig environment variable (with `tsh env`) + profileKubeconfigPath := keypaths.KubeConfigPath( + profile.FullProfilePath(cf.HomePath), tc.WebProxyHost(), tc.Username, kubeStatus.teleportClusterName, c.kubeCluster, ) - return trace.Wrap(err) + if err := updateKubeConfig(cf, tc, profileKubeconfigPath, c.overrideContextName, kubeStatus); err != nil { + return trace.Wrap(err) + } + + c.printUserMessage(cf, tc, kubeClustersToStrings(kubeStatus.kubeClusters)) + return nil } -func resourceNameOrWildcard(clusterName string, listAll bool) string { - if clusterName != "" { - return clusterName - } else if listAll { +func (c *kubeLoginCommand) selectorsOrWildcard() string { + selectors := c.getSelectors() + if !selectors.IsEmpty() { + return selectors.String() + } + if c.all { return "*" } return "" } -func (c *kubeLoginCommand) printUserMessage(cf *CLIConf, tc *client.TeleportClient) { +// checkClusterSelection checks the kube clusters selected by user input. +func (c *kubeLoginCommand) checkClusterSelection(cf *CLIConf, tc *client.TeleportClient, clusters types.KubeClusters) error { + clusters = filterKubeClusters(c.kubeCluster, clusters) + switch { + // If the user is trying to login to a specific cluster, check that it + // exists and that a cluster matched the name/prefix unambiguously. + case c.kubeCluster != "" && len(clusters) == 1: + // Populate settings using the selected kube cluster, which contains + // the full cluster name. + c.kubeCluster = clusters[0].GetName() + // Set CLIConf.KubernetesCluster so that the kube cluster's context + // is automatically selected. + cf.KubernetesCluster = c.kubeCluster + tc.KubernetesCluster = c.kubeCluster + return nil + // allow multiple selection without a name. + case c.kubeCluster == "" && len(clusters) > 0: + return nil + } + + // anything else is an error. + selectors := c.getSelectors() + if len(clusters) == 0 { + if selectors.IsEmpty() { + return trace.NotFound("no kubernetes clusters found, check 'tsh kube ls' for a list of known clusters") + } + return trace.NotFound("%v not found, check 'tsh kube ls' for a list of known clusters", selectors.String()) + } + errMsg := formatAmbiguousKubeCluster(cf, selectors, clusters) + return trace.BadParameter(errMsg) +} + +func (c *kubeLoginCommand) getSelectors() resourceSelectors { + return resourceSelectors{ + kind: "kubernetes cluster", + name: c.kubeCluster, + labels: c.labels, + query: c.predicateExpression, + } +} + +func filterKubeClusters(nameOrPrefix string, clusters types.KubeClusters) types.KubeClusters { + if nameOrPrefix == "" { + return clusters + } + var prefixMatches types.KubeClusters + for _, kc := range clusters { + if kc.GetName() == nameOrPrefix { + return types.KubeClusters{kc} + } + if strings.HasPrefix(kc.GetName(), nameOrPrefix) { + prefixMatches = append(prefixMatches, kc) + } + } + return prefixMatches +} + +func (c *kubeLoginCommand) printUserMessage(cf *CLIConf, tc *client.TeleportClient, names []string) { if tc.Profile().RequireKubeLocalProxy() { - c.printLocalProxyUserMessage(cf) + c.printLocalProxyUserMessage(cf, names) return } - if c.kubeCluster != "" { + switch { + case c.kubeCluster != "": fmt.Fprintf(cf.Stdout(), "Logged into Kubernetes cluster %q. Try 'kubectl version' to test the connection.\n", c.kubeCluster) - } else { + case c.labels != "" || c.predicateExpression != "": + fmt.Fprintf(cf.Stdout(), `Logged into Kubernetes clusters: +%v + +Select a context and try 'kubectl version' to test the connection. +`, strings.Join(names, "\n")) + case c.all: fmt.Fprintf(cf.Stdout(), "Created kubeconfig with every Kubernetes cluster available. Select a context and try 'kubectl version' to test the connection.\n") } } -func (c *kubeLoginCommand) printLocalProxyUserMessage(cf *CLIConf) { +func (c *kubeLoginCommand) printLocalProxyUserMessage(cf *CLIConf, names []string) { switch { case c.kubeCluster != "": fmt.Fprintf(cf.Stdout(), `Logged into Kubernetes cluster %q.`, c.kubeCluster) - - default: + case c.labels != "" || c.predicateExpression != "": + fmt.Fprintf(cf.Stdout(), `Logged into Kubernetes clusters: +%v`, strings.Join(names, "\n")) + case c.all: fmt.Fprintf(cf.Stdout(), "Logged into all Kubernetes clusters available.") } @@ -1415,12 +1504,14 @@ func buildKubeConfigUpdate(cf *CLIConf, kubeStatus *kubernetesStatus, overrideCo clusterNames := kubeClustersToStrings(kubeStatus.kubeClusters) // Validate if cf.KubernetesCluster is part of the returned list of clusters - if cf.KubernetesCluster != "" && !slices.Contains(clusterNames, cf.KubernetesCluster) { - return nil, trace.NotFound("Kubernetes cluster %q is not registered in this Teleport cluster; you can list registered Kubernetes clusters using 'tsh kube ls'.", cf.KubernetesCluster) - } - // If ListAll is not enabled, update only cf.KubernetesCluster cluster. - if cf.KubernetesCluster != "" && !cf.ListAll { - clusterNames = []string{cf.KubernetesCluster} + if cf.KubernetesCluster != "" { + if !slices.Contains(clusterNames, cf.KubernetesCluster) { + return nil, trace.NotFound("Kubernetes cluster %q is not registered in this Teleport cluster; you can list registered Kubernetes clusters using 'tsh kube ls'.", cf.KubernetesCluster) + } + // If ListAll or labels/query is not enabled, update only cf.KubernetesCluster cluster. + if !cf.ListAll && cf.Labels == "" && cf.PredicateExpression == "" { + clusterNames = []string{cf.KubernetesCluster} + } } v.KubeClusters = clusterNames @@ -1448,7 +1539,7 @@ type impersonationConfig struct { // updateKubeConfig adds Teleport configuration to the users's kubeconfig based on the CLI // parameters and the kubernetes services in the current Teleport cluster. If no path for // the kubeconfig is given, it will use environment values or known defaults to get a path. -func updateKubeConfig(cf *CLIConf, tc *client.TeleportClient, path string, overrideContext string) error { +func updateKubeConfig(cf *CLIConf, tc *client.TeleportClient, path, overrideContext string, status *kubernetesStatus) error { // Fetch proxy's advertised ports to check for k8s support. if _, err := tc.Ping(cf.Context); err != nil { return trace.Wrap(err) @@ -1458,16 +1549,11 @@ func updateKubeConfig(cf *CLIConf, tc *client.TeleportClient, path string, overr return nil } - kubeStatus, err := fetchKubeStatus(cf.Context, tc) - if err != nil { - return trace.Wrap(err) - } - if cf.Proxy == "" { cf.Proxy = tc.WebProxyAddr } - values, err := buildKubeConfigUpdate(cf, kubeStatus, overrideContext) + values, err := buildKubeConfigUpdate(cf, status, overrideContext) if err != nil { return trace.Wrap(err) } @@ -1520,34 +1606,26 @@ func init() { // accessRequestForKubeCluster attempts to create a resource access request for the case // where "tsh kube login" was attempted and access was denied -func accessRequestForKubeCluster(ctx context.Context, cf *CLIConf, tc *client.TeleportClient) (types.AccessRequest, error) { - if tc.KubernetesCluster == "" && !cf.ListAll { - return nil, trace.BadParameter("no KubernetesCluster specified") - } +func (c *kubeLoginCommand) accessRequestForKubeCluster(ctx context.Context, cf *CLIConf, tc *client.TeleportClient) (types.AccessRequest, error) { clt, err := tc.ConnectToCluster(ctx) if err != nil { return nil, trace.Wrap(err) } defer clt.Close() - // Match on cluster name - expr := "" - if !cf.ListAll { - expr = fmt.Sprintf(`resource.metadata.name == "%s"`, tc.KubernetesCluster) - } kubes, err := apiclient.GetAllResources[types.KubeCluster](ctx, clt.AuthClient, &proto.ListResourcesRequest{ Namespace: apidefaults.Namespace, ResourceType: types.KindKubernetesCluster, UseSearchAsRoles: true, - PredicateExpression: expr, + PredicateExpression: tc.PredicateExpression, + Labels: tc.Labels, }) - switch { - case err != nil: + if err != nil { + return nil, trace.Wrap(err) + } + + if err := c.checkClusterSelection(cf, tc, kubes); err != nil { return nil, trace.Wrap(err) - case len(kubes) == 0: - return nil, trace.NotFound("kubernetes cluster %q not found, unable to request access", tc.KubernetesCluster) - case len(kubes) > 1 && !cf.ListAll: - return nil, trace.BadParameter("more than one kubernetes cluster matched %q", tc.KubernetesCluster) } requestResourceIDs := make([]types.ResourceID, len(kubes)) @@ -1580,3 +1658,24 @@ func accessRequestForKubeCluster(ctx context.Context, cf *CLIConf, tc *client.Te return req, nil } + +// formatAmbiguousKubeCluster is a helper func that formats an ambiguous kube +// cluster error message. +func formatAmbiguousKubeCluster(cf *CLIConf, selectors resourceSelectors, kubeClusters types.KubeClusters) string { + // dont mark the selected cluster + selectedCluster := "" + // verbose output to show full names and labels + quiet := false + verbose := true + table := formatKubeClustersAsText(kubeClusters, selectedCluster, quiet, verbose) + listCommand := formatKubeListCommand(cf.SiteName) + fullNameExample := kubeClusters[0].GetName() + return formatAmbiguityErrTemplate(cf, selectors, listCommand, table, fullNameExample) +} + +func formatKubeListCommand(clusterFlag string) string { + if clusterFlag == "" { + return "tsh kube ls" + } + return fmt.Sprintf("tsh kube ls --cluster=%v", clusterFlag) +} diff --git a/tool/tsh/common/kube_test.go b/tool/tsh/common/kube_test.go index 23fe71e6556e9..ae624da58bc85 100644 --- a/tool/tsh/common/kube_test.go +++ b/tool/tsh/common/kube_test.go @@ -19,6 +19,7 @@ package common import ( "bytes" "context" + "fmt" "path/filepath" "reflect" "strings" @@ -28,18 +29,23 @@ import ( "github.com/google/go-cmp/cmp" "github.com/gravitational/trace" "github.com/stretchr/testify/require" + "golang.org/x/exp/slices" "golang.org/x/sync/errgroup" "k8s.io/client-go/tools/clientcmd" clientcmdapi "k8s.io/client-go/tools/clientcmd/api" + "github.com/gravitational/teleport/api/profile" "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/api/utils/keypaths" "github.com/gravitational/teleport/lib" "github.com/gravitational/teleport/lib/asciitable" + "github.com/gravitational/teleport/lib/kube/kubeconfig" kubeserver "github.com/gravitational/teleport/lib/kube/proxy/testing/kube_server" "github.com/gravitational/teleport/lib/modules" "github.com/gravitational/teleport/lib/service/servicecfg" "github.com/gravitational/teleport/lib/utils" "github.com/gravitational/teleport/tool/common" + "github.com/gravitational/teleport/tool/teleport/testenv" ) func TestKube(t *testing.T) { @@ -247,7 +253,7 @@ func (p *kubeTestPack) testListKube(t *testing.T) { } } -func TestKubeLoginAccessRequest(t *testing.T) { +func TestKubeLogin(t *testing.T) { modules.SetTestModules(t, &modules.TestModules{ TestBuildType: modules.BuildEnterprise, @@ -256,9 +262,190 @@ func TestKubeLoginAccessRequest(t *testing.T) { }, }, ) - lib.SetInsecureDevMode(true) - t.Cleanup(func() { lib.SetInsecureDevMode(false) }) + testenv.WithInsecureDevMode(t, true) + testenv.WithResyncInterval(t, 0) + t.Run("complex filters", testKubeLoginWithFilters) + t.Run("access request", testKubeLoginAccessRequest) +} + +func testKubeLoginWithFilters(t *testing.T) { + t.Parallel() + ctx := context.Background() + kubeFoo := "foo" + kubeFooBar := "foo-bar" + kubeBaz := "baz" + staticLabels := map[string]string{ + "env": "root", + } + allKubes := []string{kubeFoo, kubeFooBar, kubeBaz} + + s := newTestSuite(t, + withRootConfigFunc(func(cfg *servicecfg.Config) { + cfg.Auth.NetworkingConfig.SetProxyListenerMode(types.ProxyListenerMode_Multiplex) + cfg.Kube.Enabled = true + cfg.Kube.ListenAddr = utils.MustParseAddr(localListenerAddr()) + cfg.Kube.KubeconfigPath = newKubeConfigFile(t, allKubes...) + cfg.Kube.StaticLabels = staticLabels + }), + withValidationFunc(func(s *suite) bool { + rootClusters, err := s.root.GetAuthServer().GetKubernetesServers(ctx) + require.NoError(t, err) + return len(rootClusters) == 3 + }), + ) + + tests := []struct { + desc string + args []string + wantLoggedIn []string + wantSelected string + wantErrContains string + }{ + { + desc: "login with exact name and set current context", + args: []string{"foo"}, + wantLoggedIn: []string{"foo"}, + wantSelected: "foo", + }, + { + desc: "login with prefix name and set current context", + args: []string{"foo-b"}, + wantLoggedIn: []string{"foo-bar"}, + wantSelected: "foo-bar", + }, + { + desc: "login with all", + args: []string{"--all"}, + wantLoggedIn: []string{"foo", "foo-bar", "baz"}, + wantSelected: "", + }, + { + desc: "login with labels", + args: []string{"--labels", "env=root"}, + wantLoggedIn: []string{"foo", "foo-bar", "baz"}, + wantSelected: "", + }, + { + desc: "login with query", + args: []string{"--query", `name == "foo"`}, + wantLoggedIn: []string{"foo"}, + wantSelected: "", + }, + { + desc: "login to multiple with all and set current context by name", + args: []string{"foo", "--all"}, + wantLoggedIn: []string{"foo", "foo-bar", "baz"}, + wantSelected: "foo", + }, + { + desc: "login to multiple with labels and set current context by name", + args: []string{"foo", "--labels", "env=root"}, + wantLoggedIn: []string{"foo", "foo-bar", "baz"}, + wantSelected: "foo", + }, + { + desc: "login to multiple with query and set current context by prefix name", + args: []string{"foo-b", "--query", `name == "foo-bar" || name == "foo"`}, + wantLoggedIn: []string{"foo", "foo-bar"}, + wantSelected: "foo-bar", + }, + { + desc: "all with labels is an error", + args: []string{"xxx", "--all", "--labels", `env=root`}, + wantErrContains: "cannot use", + }, + { + desc: "all with query is an error", + args: []string{"xxx", "--all", "--query", `name == "foo-bar" || name == "foo"`}, + wantErrContains: "cannot use", + }, + { + desc: "missing required args is an error", + args: []string{}, + wantErrContains: "required", + }, + } + + tshHome, _ := mustLogin(t, s) + webProxyAddr, err := utils.ParseAddr(s.root.Config.Proxy.WebAddr.String()) + require.NoError(t, err) + // profile kube config path depends on web proxy host + webProxyHost := webProxyAddr.Host() + + for _, test := range tests { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + // clone the login dir for each parallel test to avoid profile kube config file races. + tshHome := mustCloneTempDir(t, tshHome) + kubeConfigPath := filepath.Join(t.TempDir(), "kubeconfig") + err := Run( + context.Background(), + append([]string{ + "--insecure", + "kube", + "login", + }, + test.args..., + ), + setHomePath(tshHome), + // set a custom empty kube config for each test, as we do + // not want parallel (or even shuffled sequential) tests + // potentially racing on the same config + setKubeConfigPath(kubeConfigPath), + ) + if test.wantErrContains != "" { + require.ErrorContains(t, err, test.wantErrContains) + return + } + require.NoError(t, err) + + config, err := kubeconfig.Load(kubeConfigPath) + require.NoError(t, err) + if test.wantSelected == "" { + require.Empty(t, config.CurrentContext) + } else { + require.Equal(t, kubeconfig.ContextName("root", test.wantSelected), config.CurrentContext) + } + for _, name := range allKubes { + contextName := kubeconfig.ContextName("root", name) + if !slices.Contains(test.wantLoggedIn, name) { + require.NotContains(t, config.AuthInfos, contextName, "unexpected kube cluster %v in config update", name) + return + } + require.Contains(t, config.AuthInfos, contextName, "kube cluster %v not in config update", name) + authInfo := config.AuthInfos[contextName] + require.NotNil(t, authInfo) + require.Contains(t, authInfo.Exec.Args, fmt.Sprintf("--kube-cluster=%v", name)) + } + + // ensure the profile config only contains one + profileKubeConfigPath := keypaths.KubeConfigPath( + profile.FullProfilePath(tshHome), + webProxyHost, + s.user.GetName(), + s.root.Config.Auth.ClusterName.GetClusterName(), + test.wantSelected, + ) + profileConfig, err := kubeconfig.Load(profileKubeConfigPath) + require.NoError(t, err) + for _, name := range allKubes { + contextName := kubeconfig.ContextName("root", name) + if name != test.wantSelected { + require.NotContains(t, profileConfig.AuthInfos, contextName, "unexpected kube cluster %v in profile config update", name) + return + } + require.Contains(t, profileConfig.AuthInfos, contextName, "kube cluster %v not in profile config update", name) + authInfo := profileConfig.AuthInfos[contextName] + require.NotNil(t, authInfo) + require.Contains(t, authInfo.Exec.Args, fmt.Sprintf("--kube-cluster=%v", name)) + } + }) + } +} +func testKubeLoginAccessRequest(t *testing.T) { + t.Parallel() const ( roleName = "requester" kubeCluster = "root-cluster" @@ -301,7 +488,7 @@ func TestKubeLoginAccessRequest(t *testing.T) { }), ) // login as the user. - tshHome, kubeConfig := mustLoginSetEnv(t, s) + tshHome, kubeConfig := mustLogin(t, s) // Run the login command in a goroutine so we can check if the access // request was created and approved. @@ -314,7 +501,8 @@ func TestKubeLoginAccessRequest(t *testing.T) { "--insecure", "kube", "login", - kubeCluster, + // use a prefix of the kube cluster name + "root-c", "--request-reason", "test", }, @@ -348,7 +536,7 @@ func TestKubeLoginAccessRequest(t *testing.T) { accessRequestID = accessRequests[0].GetName() return equal - }, 10*time.Second, 1*time.Second) + }, 10*time.Second, 500*time.Millisecond) // Approve the access request to release the login command lock. err = s.root.GetAuthServer().SetAccessRequestState(context.Background(), types.AccessRequestUpdate{ RequestID: accessRequestID, diff --git a/tool/tsh/common/tsh.go b/tool/tsh/common/tsh.go index 4b7ca2f663586..c4854f87fac19 100644 --- a/tool/tsh/common/tsh.go +++ b/tool/tsh/common/tsh.go @@ -4876,7 +4876,15 @@ func updateKubeConfigOnLogin(cf *CLIConf, tc *client.TeleportClient) error { if len(cf.KubernetesCluster) == 0 { return nil } - err := updateKubeConfig(cf, tc, "" /* update the default kubeconfig */, "" /* do not override the context name */) + kubeStatus, err := fetchKubeStatus(cf.Context, tc) + if err != nil { + return trace.Wrap(err) + } + // update the default kubeconfig + kubeConfigPath := "" + // do not override the context name + overrideContextName := "" + err = updateKubeConfig(cf, tc, kubeConfigPath, overrideContextName, kubeStatus) return trace.Wrap(err) }