From 6ad1e86bb866dd519008ab6a52a2ba8006b9479e Mon Sep 17 00:00:00 2001 From: thde Date: Thu, 17 Sep 2026 09:34:52 +0200 Subject: [PATCH 1/2] refactor(auth): move kubeconfig machinery to internal/kubeconfig Building the kubeconfig entries (newAPIConfig with its options and the two exec plugin configs), merging them into the user's kubeconfig (login, mergeKubeConfig) and logging in to a KubernetesCluster were unexported helpers of the auth verb, which forced create to import auth for ClusterCmd. They now live in the non-verb package internal/kubeconfig as NewAPIConfig, Login and LoginCluster; auth keeps the kong commands and flag parsing and delegates to it. The exec plugin args need the name of the auth command, so the "auth" constant moves next to api.OIDCCmdName as api.AuthCmdName and auth.CmdName refers to it. The cluster login test moves with the logic; auth keeps a test for its name/project argument parsing. --- api/login.go | 1 + auth/auth.go | 4 +- auth/client_credentials.go | 21 +-- auth/cluster.go | 84 +----------- auth/cluster_test.go | 117 ++++++---------- auth/login.go | 186 +------------------------- auth/login_test.go | 15 +++ auth/oidc.go | 18 --- internal/kubeconfig/apiconfig.go | 163 ++++++++++++++++++++++ internal/kubeconfig/apiconfig_test.go | 119 ++++++++++++++++ internal/kubeconfig/cluster.go | 99 ++++++++++++++ internal/kubeconfig/cluster_test.go | 75 +++++++++++ internal/kubeconfig/login.go | 87 ++++++++++++ internal/kubeconfig/login_test.go | 101 ++++++++++++++ 14 files changed, 721 insertions(+), 369 deletions(-) create mode 100644 internal/kubeconfig/apiconfig.go create mode 100644 internal/kubeconfig/apiconfig_test.go create mode 100644 internal/kubeconfig/cluster.go create mode 100644 internal/kubeconfig/cluster_test.go create mode 100644 internal/kubeconfig/login.go create mode 100644 internal/kubeconfig/login_test.go diff --git a/api/login.go b/api/login.go index e60ea1fa..6b3e14e0 100644 --- a/api/login.go +++ b/api/login.go @@ -42,6 +42,7 @@ const ( TokenURLArg = "--token-url=" UsePKCEArg = "--use-pkce" CustomersPrefix = "/Customers/" + AuthCmdName = "auth" ClientCredentialsCmdName = "client-credentials" OIDCCmdName = "oidc" ) diff --git a/auth/auth.go b/auth/auth.go index b9f1a9e9..d528cb21 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -1,6 +1,8 @@ // Package auth provides commands for authentication and session management. package auth +import "github.com/ninech/nctl/api" + type Cmd struct { Login LoginCmd `cmd:"" help:"Login to nineapis.ch."` Logout LogoutCmd `cmd:"" help:"Logout from nineapis.ch."` @@ -13,4 +15,4 @@ type Cmd struct { PrintAccessToken PrintAccessTokenCmd `cmd:"" help:"Print short-lived access token to authenticate against the API to stdout and exit."` } -const CmdName = "auth" +const CmdName = api.AuthCmdName diff --git a/auth/client_credentials.go b/auth/client_credentials.go index 1250dcc6..0dc00254 100644 --- a/auth/client_credentials.go +++ b/auth/client_credentials.go @@ -7,12 +7,11 @@ import ( "os" "github.com/ninech/nctl/api" + "github.com/ninech/nctl/internal/kubeconfig" "golang.org/x/oauth2" "golang.org/x/oauth2/clientcredentials" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" clientauthenticationv1 "k8s.io/client-go/pkg/apis/clientauthentication/v1" - - clientcmdapi "k8s.io/client-go/tools/clientcmd/api" ) type API struct { @@ -76,17 +75,11 @@ func (a *API) UserInfo(ctx context.Context) (*api.UserInfo, error) { return api.GetUserInfoFromToken(token.AccessToken) } -func apiExecConfig(command string, apiInfo API) *clientcmdapi.ExecConfig { - return &clientcmdapi.ExecConfig{ - APIVersion: "client.authentication.k8s.io/v1", - InteractiveMode: clientcmdapi.NeverExecInteractiveMode, - Command: command, - Args: []string{ - CmdName, - ClientCredentialsCmdName, - api.ClientIDArg + apiInfo.ClientID, - api.ClientSecretArg + apiInfo.ClientSecret, - api.TokenURLArg + apiInfo.TokenURL, - }, +// clientCredentials returns the credentials the kubeconfig exec plugin uses. +func (a API) clientCredentials() kubeconfig.ClientCredentials { + return kubeconfig.ClientCredentials{ + ClientID: a.ClientID, + ClientSecret: a.ClientSecret, + TokenURL: a.TokenURL, } } diff --git a/auth/cluster.go b/auth/cluster.go index 20161f30..a5745c8c 100644 --- a/auth/cluster.go +++ b/auth/cluster.go @@ -2,17 +2,12 @@ package auth import ( "context" - "encoding/base64" "fmt" - "net/url" - "os" "strings" - infrastructure "github.com/ninech/apis/infrastructure/v1alpha1" "github.com/ninech/nctl/api" - "github.com/ninech/nctl/api/config" - "github.com/ninech/nctl/internal/cli" "github.com/ninech/nctl/internal/format" + "github.com/ninech/nctl/internal/kubeconfig" "k8s.io/apimachinery/pkg/types" ) @@ -29,82 +24,7 @@ func (a *ClusterCmd) Run(ctx context.Context, client *api.Client) error { return err } - cluster := &infrastructure.KubernetesCluster{} - if err := client.Get(ctx, name, cluster); err != nil { - return err - } - - apiEndpoint, err := url.Parse(cluster.Status.AtProvider.APIEndpoint) - if err != nil { - return cli.ErrorWithContext(fmt.Errorf("invalid cluster API endpoint: %w", err)). - WithExitCode(cli.ExitUsageError). - WithContext("Endpoint", cluster.Status.AtProvider.APIEndpoint). - WithSuggestions("The cluster API endpoint should be a valid URL") - } - - issuerURL, err := url.Parse(cluster.Status.AtProvider.OIDCIssuerURL) - if err != nil { - return cli.ErrorWithContext(fmt.Errorf("invalid cluster OIDC issuer URL: %w", err)). - WithExitCode(cli.ExitUsageError). - WithContext("IssuerURL", cluster.Status.AtProvider.OIDCIssuerURL). - WithSuggestions("The OIDC issuer URL should be a valid URL") - } - - caCert, err := base64.StdEncoding.DecodeString(cluster.Status.AtProvider.APICACert) - if err != nil { - return fmt.Errorf("unable to decode API CA certificate: %w", err) - } - - // not sure if this should ever happen but better than getting a panic - if len(os.Args) == 0 { - return fmt.Errorf("could not get command name from os.Args") - } - // we try to find out where the nctl binary is located - command, err := os.Executable() - if err != nil { - return fmt.Errorf("can not identify executable path of %s: %w", cli.Name, err) - } - - cfg, err := newAPIConfig( - apiEndpoint, - issuerURL, - command, - cluster.Status.AtProvider.OIDCClientID, - overrideName(config.ContextName(cluster)), - setCACert(caCert), - ) - if err != nil { - return fmt.Errorf("unable to create kubeconfig: %w", err) - } - - userInfo := &api.UserInfo{} - - if a.ExecPlugin { - authInfo, ok := cfg.AuthInfos[cfg.CurrentContext] - if !ok { - return fmt.Errorf("authInfo not found") - } - - if authInfo == nil || authInfo.Exec == nil { - return fmt.Errorf("no Exec found in authInfo") - } - - token, err := api.GetTokenFromExecConfig(ctx, authInfo.Exec) - if err != nil { - return err - } - - userInfo, err = api.GetUserInfoFromToken(token) - if err != nil { - return err - } - } - - if err := login(a.Writer, cfg, client.KubeconfigPath, userInfo.User, "", switchCurrentContext()); err != nil { - return fmt.Errorf("error logging in to cluster %s: %w", name, err) - } - - return nil + return kubeconfig.LoginCluster(ctx, client, a.Writer, name, a.ExecPlugin) } func clusterName(name, project string) (types.NamespacedName, error) { diff --git a/auth/cluster_test.go b/auth/cluster_test.go index 0b7d3b1c..1334bfda 100644 --- a/auth/cluster_test.go +++ b/auth/cluster_test.go @@ -1,88 +1,55 @@ package auth import ( - "io" - "log" - "os" "testing" - infrastructure "github.com/ninech/apis/infrastructure/v1alpha1" - "github.com/ninech/nctl/api/config" - "github.com/ninech/nctl/internal/test" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/client-go/tools/clientcmd" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/types" ) -const existingKubeconfig = ` -apiVersion: v1 -kind: Config -clusters: -- cluster: - server: https://existing.example.org - name: existing -users: -- name: existing -current-context: existing -contexts: -- context: - name: existing -` - -func TestClusterCmd(t *testing.T) { +func TestClusterName(t *testing.T) { t.Parallel() - // write our "existing" kubeconfig to a temp kubeconfig - kubeconfig, err := os.CreateTemp("", "*-kubeconfig.yaml") - if err != nil { - log.Fatal(err) - } - defer os.Remove(kubeconfig.Name()) - - if err := os.WriteFile(kubeconfig.Name(), []byte(existingKubeconfig), os.ModePerm); err != nil { - t.Fatal(err) - } - - cluster := newCluster() - apiClient := test.SetupClient(t, - test.WithObjects(cluster), - ) - apiClient.KubeconfigPath = kubeconfig.Name() - - // we run without the execPlugin, that would be something for an e2e test - cmd := &ClusterCmd{Name: config.ContextName(cluster), ExecPlugin: false} - if err := cmd.Run(t.Context(), apiClient); err != nil { - t.Fatal(err) - } - - // read out the kubeconfig again to test the contents - b, err := io.ReadAll(kubeconfig) - if err != nil { - t.Fatal(err) - } - - merged, err := clientcmd.Load(b) - if err != nil { - t.Fatal(err) - } - - checkConfig(t, merged, 2, config.ContextName(cluster)) -} - -func newCluster() *infrastructure.KubernetesCluster { - return &infrastructure.KubernetesCluster{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "test", + tests := []struct { + name string + arg string + defaultProject string + want types.NamespacedName + wantErr string + }{ + { + name: "name only uses the default project", + arg: "cluster", + defaultProject: "proj", + want: types.NamespacedName{Name: "cluster", Namespace: "proj"}, + }, + { + name: "name/project overrides the default project", + arg: "cluster/other", + defaultProject: "proj", + want: types.NamespacedName{Name: "cluster", Namespace: "other"}, }, - Spec: infrastructure.KubernetesClusterSpec{}, - Status: infrastructure.KubernetesClusterStatus{ - AtProvider: infrastructure.KubernetesClusterObservation{ - ClusterObservation: infrastructure.ClusterObservation{ - APIEndpoint: "https://new.example.org", - OIDCClientID: "some-client-id", - OIDCIssuerURL: "https://auth.example.org", - }, - }, + { + name: "name/project without a default project", + arg: "cluster/other", + want: types.NamespacedName{Name: "cluster", Namespace: "other"}, }, + { + name: "name only without a default project", + arg: "cluster", + wantErr: "project cannot be empty", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := clusterName(tt.arg, tt.defaultProject) + if tt.wantErr != "" { + require.EqualError(t, err, tt.wantErr) + return + } + require.NoError(t, err) + require.Equal(t, tt.want, got) + }) } } diff --git a/auth/login.go b/auth/login.go index 6695816f..eb8746db 100644 --- a/auth/login.go +++ b/auth/login.go @@ -4,19 +4,13 @@ import ( "context" "errors" "fmt" - "maps" "net/url" "os" - "strings" "github.com/alecthomas/kong" "github.com/ninech/nctl/api" - "github.com/ninech/nctl/api/config" - "github.com/ninech/nctl/internal/cli" "github.com/ninech/nctl/internal/format" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/client-go/tools/clientcmd" - clientcmdapi "k8s.io/client-go/tools/clientcmd/api" + "github.com/ninech/nctl/internal/kubeconfig" ) const ( @@ -66,11 +60,11 @@ func (cmd *LoginCmd) Run(ctx context.Context) error { if err != nil { return err } - cfg, err := newAPIConfig(apiURL, issuerURL, command, cmd.ClientID, useStaticToken(cmd.API.Token), withOrganization(cmd.Organization)) + cfg, err := kubeconfig.NewAPIConfig(apiURL, issuerURL, command, cmd.ClientID, kubeconfig.UseStaticToken(cmd.API.Token), kubeconfig.WithOrganization(cmd.Organization)) if err != nil { return err } - return login(cmd.Writer, cfg, loadingRules.GetDefaultFilename(), userInfo.User, "", project(cmd.Organization)) + return kubeconfig.Login(cmd.Writer, cfg, loadingRules.GetDefaultFilename(), userInfo.User, "", kubeconfig.WithProject(cmd.Organization)) } if cmd.API.ClientID != "" { @@ -85,7 +79,7 @@ func (cmd *LoginCmd) Run(ctx context.Context) error { if org == "" { org = userInfo.Orgs[0] } - cfg, err := newAPIConfig(apiURL, issuerURL, command, cmd.API.ClientID, useClientCredentials(cmd.API), withOrganization(org)) + cfg, err := kubeconfig.NewAPIConfig(apiURL, issuerURL, command, cmd.API.ClientID, kubeconfig.UseClientCredentials(cmd.API.clientCredentials()), kubeconfig.WithOrganization(org)) if err != nil { return err } @@ -93,7 +87,7 @@ func (cmd *LoginCmd) Run(ctx context.Context) error { if userInfo.Project != "" { proj = userInfo.Project } - return login(cmd.Writer, cfg, loadingRules.GetDefaultFilename(), userInfo.User, "", project(proj)) + return kubeconfig.Login(cmd.Writer, cfg, loadingRules.GetDefaultFilename(), userInfo.User, "", kubeconfig.WithProject(proj)) } if !cmd.ForceInteractiveEnvOverride && !format.IsInteractiveEnvironment(os.Stdout) { @@ -123,12 +117,12 @@ func (cmd *LoginCmd) Run(ctx context.Context) error { printAvailableOrgsString(cmd.Writer, org, userInfo.Orgs) } - cfg, err := newAPIConfig(apiURL, issuerURL, command, cmd.ClientID, withOrganization(org)) + cfg, err := kubeconfig.NewAPIConfig(apiURL, issuerURL, command, cmd.ClientID, kubeconfig.WithOrganization(org)) if err != nil { return err } - return login(cmd.Writer, cfg, loadingRules.GetDefaultFilename(), userInfo.User, "", project(org)) + return kubeconfig.Login(cmd.Writer, cfg, loadingRules.GetDefaultFilename(), userInfo.User, "", kubeconfig.WithProject(org)) } func printAvailableOrgsString(w format.Writer, currentorg string, orgs []string) { @@ -153,172 +147,6 @@ func (cmd *LoginCmd) tokenGetter() api.TokenGetter { return &api.DefaultTokenGetter{} } -type apiConfig struct { - name string - token string - api API - caCert []byte - organization string -} - -type apiConfigOption func(*apiConfig) - -func overrideName(name string) apiConfigOption { - return func(ac *apiConfig) { - ac.name = name - } -} - -func setCACert(caCert []byte) apiConfigOption { - return func(ac *apiConfig) { - ac.caCert = caCert - } -} - -func useStaticToken(token string) apiConfigOption { - return func(ac *apiConfig) { - ac.token = token - } -} - -func useClientCredentials(api API) apiConfigOption { - return func(ac *apiConfig) { - ac.api = api - } -} - -func withOrganization(organization string) apiConfigOption { - return func(ac *apiConfig) { - ac.organization = organization - } -} - -func newAPIConfig(apiURL, issuerURL *url.URL, command, clientID string, opts ...apiConfigOption) (*clientcmdapi.Config, error) { - cfg := &apiConfig{ - name: apiURL.Host, - } - - for _, opt := range opts { - opt(cfg) - } - - extension, err := config.NewExtension(cfg.organization).ToObject() - if err != nil { - return nil, err - } - - clientConfig := &clientcmdapi.Config{ - Clusters: map[string]*clientcmdapi.Cluster{ - cfg.name: { - Server: apiURL.String(), - CertificateAuthorityData: cfg.caCert, - }, - }, - Contexts: map[string]*clientcmdapi.Context{ - cfg.name: { - Cluster: cfg.name, - AuthInfo: cfg.name, - Extensions: map[string]runtime.Object{ - cli.Name: extension, - }, - }, - }, - AuthInfos: map[string]*clientcmdapi.AuthInfo{}, - CurrentContext: cfg.name, - } - - if len(cfg.token) != 0 { - clientConfig.AuthInfos[cfg.name] = &clientcmdapi.AuthInfo{ - Token: cfg.token, - } - return clientConfig, nil - } - - if cfg.api.ClientID != "" { - clientConfig.AuthInfos[cfg.name] = &clientcmdapi.AuthInfo{ - Exec: apiExecConfig(command, cfg.api), - } - return clientConfig, nil - } - - clientConfig.AuthInfos[cfg.name] = &clientcmdapi.AuthInfo{ - Exec: execConfig(command, clientID, issuerURL), - } - - return clientConfig, nil -} - -type loginConfig struct { - project string - switchCurrentContext bool -} - -type loginOption func(*loginConfig) - -// project overrides the project in the new config -func project(project string) loginOption { - return func(l *loginConfig) { - l.project = project - } -} - -// switchCurrentContext sets the context of the merged kubeconfig to the one -// defined in the newConfig -func switchCurrentContext() loginOption { - return func(l *loginConfig) { - l.switchCurrentContext = true - } -} - -func login(w format.Writer, newConfig *clientcmdapi.Config, kubeconfigPath, userName string, toOrg string, opts ...loginOption) error { - loginConfig := &loginConfig{} - for _, opt := range opts { - opt(loginConfig) - } - - if loginConfig.project != "" && newConfig.Contexts[newConfig.CurrentContext] != nil { - newConfig.Contexts[newConfig.CurrentContext].Namespace = loginConfig.project - } - - kubeconfig, err := clientcmd.LoadFromFile(kubeconfigPath) - if err != nil { - if !os.IsNotExist(err) { - return err - } - // kubeconfig file does not exist so we just use our new config - kubeconfig = newConfig - } - - mergeKubeConfig(newConfig, kubeconfig) - - if loginConfig.switchCurrentContext { - kubeconfig.CurrentContext = newConfig.CurrentContext - } - - if err := clientcmd.WriteToFile(*kubeconfig, kubeconfigPath); err != nil { - return err - } - - if toOrg != "" { - w.Successf("🏢", "switched to the organization %q", toOrg) - } - w.Successf("📋", "added %s to kubeconfig", newConfig.CurrentContext) - - loginMessage := fmt.Sprintf("logged into cluster %s", newConfig.CurrentContext) - if strings.TrimSpace(userName) != "" { - loginMessage = fmt.Sprintf("logged into cluster %s as %s", newConfig.CurrentContext, userName) - } - w.Success("🚀", loginMessage) - - return nil -} - -func mergeKubeConfig(from, to *clientcmdapi.Config) { - maps.Copy(to.Clusters, from.Clusters) - maps.Copy(to.AuthInfos, from.AuthInfos) - maps.Copy(to.Contexts, from.Contexts) -} - // LoginKongVars returns all variables which are used in the login command func LoginKongVars() kong.Vars { result := make(kong.Vars) diff --git a/auth/login_test.go b/auth/login_test.go index 282cff2d..42e04779 100644 --- a/auth/login_test.go +++ b/auth/login_test.go @@ -21,6 +21,21 @@ import ( clientcmdapi "k8s.io/client-go/tools/clientcmd/api" ) +const existingKubeconfig = ` +apiVersion: v1 +kind: Config +clusters: +- cluster: + server: https://existing.example.org + name: existing +users: +- name: existing +current-context: existing +contexts: +- context: + name: existing +` + type fakeTokenGetter struct{} func (f *fakeTokenGetter) GetTokenString(ctx context.Context, issuerURL, clientID string, usePKCE bool) (string, error) { diff --git a/auth/oidc.go b/auth/oidc.go index 7219fd55..fb3aac84 100644 --- a/auth/oidc.go +++ b/auth/oidc.go @@ -2,11 +2,9 @@ package auth import ( "context" - "net/url" "os" "github.com/ninech/nctl/api" - clientcmdapi "k8s.io/client-go/tools/clientcmd/api" ) type OIDCCmd struct { @@ -20,19 +18,3 @@ const OIDCCmdName = api.OIDCCmdName func (o *OIDCCmd) Run(ctx context.Context) error { return api.GetToken(ctx, o.IssuerURL, o.ClientID, o.UsePKCE, os.Stdout) } - -// execConfig returns an *clientcmdapi.ExecConfig that can be used to login to -// a kubernetes cluster using nctl. -func execConfig(command, clientID string, issuerURL *url.URL) *clientcmdapi.ExecConfig { - return &clientcmdapi.ExecConfig{ - APIVersion: "client.authentication.k8s.io/v1beta1", - Command: command, - Args: []string{ - CmdName, - OIDCCmdName, - api.IssuerURLArg + issuerURL.String(), - api.ClientIDArg + clientID, - api.UsePKCEArg, - }, - } -} diff --git a/internal/kubeconfig/apiconfig.go b/internal/kubeconfig/apiconfig.go new file mode 100644 index 00000000..9c7a70c5 --- /dev/null +++ b/internal/kubeconfig/apiconfig.go @@ -0,0 +1,163 @@ +// Package kubeconfig builds the kubeconfig entries nctl uses to talk to the +// Nine API and to Kubernetes clusters, and merges them into the user's +// kubeconfig. +package kubeconfig + +import ( + "net/url" + + "github.com/ninech/nctl/api" + "github.com/ninech/nctl/api/config" + "github.com/ninech/nctl/internal/cli" + "k8s.io/apimachinery/pkg/runtime" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" +) + +// ClientCredentials are the APIServiceAccount credentials the exec plugin +// uses to obtain a token. +type ClientCredentials struct { + ClientID string + ClientSecret string + TokenURL string +} + +type apiConfig struct { + name string + token string + credentials ClientCredentials + caCert []byte + organization string +} + +// APIConfigOption configures the kubeconfig built by [NewAPIConfig]. +type APIConfigOption func(*apiConfig) + +// OverrideName sets the name used for the cluster, context and user entry +// instead of the API URL host. +func OverrideName(name string) APIConfigOption { + return func(ac *apiConfig) { + ac.name = name + } +} + +// WithCACert sets the certificate authority data of the cluster entry. +func WithCACert(caCert []byte) APIConfigOption { + return func(ac *apiConfig) { + ac.caCert = caCert + } +} + +// UseStaticToken authenticates with a static token instead of an exec +// plugin. +func UseStaticToken(token string) APIConfigOption { + return func(ac *apiConfig) { + ac.token = token + } +} + +// UseClientCredentials authenticates through the client-credentials exec +// plugin instead of the interactive OIDC one. +func UseClientCredentials(credentials ClientCredentials) APIConfigOption { + return func(ac *apiConfig) { + ac.credentials = credentials + } +} + +// WithOrganization records the organization in the nctl context extension. +func WithOrganization(organization string) APIConfigOption { + return func(ac *apiConfig) { + ac.organization = organization + } +} + +// NewAPIConfig returns a kubeconfig with a single cluster, context and user +// for the API at apiURL. Unless a static token or client credentials are +// configured, the user authenticates through the OIDC exec plugin of the +// nctl binary at command. +func NewAPIConfig(apiURL, issuerURL *url.URL, command, clientID string, opts ...APIConfigOption) (*clientcmdapi.Config, error) { + cfg := &apiConfig{ + name: apiURL.Host, + } + + for _, opt := range opts { + opt(cfg) + } + + extension, err := config.NewExtension(cfg.organization).ToObject() + if err != nil { + return nil, err + } + + clientConfig := &clientcmdapi.Config{ + Clusters: map[string]*clientcmdapi.Cluster{ + cfg.name: { + Server: apiURL.String(), + CertificateAuthorityData: cfg.caCert, + }, + }, + Contexts: map[string]*clientcmdapi.Context{ + cfg.name: { + Cluster: cfg.name, + AuthInfo: cfg.name, + Extensions: map[string]runtime.Object{ + cli.Name: extension, + }, + }, + }, + AuthInfos: map[string]*clientcmdapi.AuthInfo{}, + CurrentContext: cfg.name, + } + + if len(cfg.token) != 0 { + clientConfig.AuthInfos[cfg.name] = &clientcmdapi.AuthInfo{ + Token: cfg.token, + } + return clientConfig, nil + } + + if cfg.credentials.ClientID != "" { + clientConfig.AuthInfos[cfg.name] = &clientcmdapi.AuthInfo{ + Exec: clientCredentialsExecConfig(command, cfg.credentials), + } + return clientConfig, nil + } + + clientConfig.AuthInfos[cfg.name] = &clientcmdapi.AuthInfo{ + Exec: oidcExecConfig(command, clientID, issuerURL), + } + + return clientConfig, nil +} + +// oidcExecConfig returns an exec config that obtains a token through the +// interactive OIDC login of nctl. +func oidcExecConfig(command, clientID string, issuerURL *url.URL) *clientcmdapi.ExecConfig { + return &clientcmdapi.ExecConfig{ + APIVersion: "client.authentication.k8s.io/v1beta1", + Command: command, + Args: []string{ + api.AuthCmdName, + api.OIDCCmdName, + api.IssuerURLArg + issuerURL.String(), + api.ClientIDArg + clientID, + api.UsePKCEArg, + }, + } +} + +// clientCredentialsExecConfig returns an exec config that obtains a token +// through the client-credentials login of nctl. +func clientCredentialsExecConfig(command string, credentials ClientCredentials) *clientcmdapi.ExecConfig { + return &clientcmdapi.ExecConfig{ + APIVersion: "client.authentication.k8s.io/v1", + InteractiveMode: clientcmdapi.NeverExecInteractiveMode, + Command: command, + Args: []string{ + api.AuthCmdName, + api.ClientCredentialsCmdName, + api.ClientIDArg + credentials.ClientID, + api.ClientSecretArg + credentials.ClientSecret, + api.TokenURLArg + credentials.TokenURL, + }, + } +} diff --git a/internal/kubeconfig/apiconfig_test.go b/internal/kubeconfig/apiconfig_test.go new file mode 100644 index 00000000..fcdb7f69 --- /dev/null +++ b/internal/kubeconfig/apiconfig_test.go @@ -0,0 +1,119 @@ +package kubeconfig + +import ( + "net/url" + "testing" + + "github.com/ninech/nctl/api" + "github.com/ninech/nctl/api/config" + "github.com/ninech/nctl/internal/cli" + "github.com/stretchr/testify/require" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" +) + +func TestNewAPIConfig(t *testing.T) { + t.Parallel() + + apiURL, err := url.Parse("https://api.example.org") + require.NoError(t, err) + issuerURL, err := url.Parse("https://auth.example.org") + require.NoError(t, err) + const command = "/usr/local/bin/nctl" + + tests := []struct { + name string + opts []APIConfigOption + wantName string + wantCACert []byte + wantOrg string + wantAuthInfo clientcmdapi.AuthInfo + }{ + { + name: "oidc exec plugin by default", + wantName: apiURL.Host, + wantAuthInfo: clientcmdapi.AuthInfo{ + Exec: &clientcmdapi.ExecConfig{ + APIVersion: "client.authentication.k8s.io/v1beta1", + Command: command, + Args: []string{ + api.AuthCmdName, + api.OIDCCmdName, + api.IssuerURLArg + issuerURL.String(), + api.ClientIDArg + "client", + api.UsePKCEArg, + }, + }, + }, + }, + { + name: "static token", + opts: []APIConfigOption{UseStaticToken("token"), WithOrganization("org")}, + wantName: apiURL.Host, + wantOrg: "org", + wantAuthInfo: clientcmdapi.AuthInfo{Token: "token"}, + }, + { + name: "client credentials exec plugin", + opts: []APIConfigOption{UseClientCredentials(ClientCredentials{ + ClientID: "id", ClientSecret: "secret", TokenURL: "https://token.example.org", + })}, + wantName: apiURL.Host, + wantAuthInfo: clientcmdapi.AuthInfo{ + Exec: &clientcmdapi.ExecConfig{ + APIVersion: "client.authentication.k8s.io/v1", + InteractiveMode: clientcmdapi.NeverExecInteractiveMode, + Command: command, + Args: []string{ + api.AuthCmdName, + api.ClientCredentialsCmdName, + api.ClientIDArg + "id", + api.ClientSecretArg + "secret", + api.TokenURLArg + "https://token.example.org", + }, + }, + }, + }, + { + name: "cluster entry with overridden name and CA", + opts: []APIConfigOption{OverrideName("cluster/project"), WithCACert([]byte("ca"))}, + wantName: "cluster/project", + wantCACert: []byte("ca"), + wantAuthInfo: clientcmdapi.AuthInfo{ + Exec: &clientcmdapi.ExecConfig{ + APIVersion: "client.authentication.k8s.io/v1beta1", + Command: command, + Args: []string{ + api.AuthCmdName, + api.OIDCCmdName, + api.IssuerURLArg + issuerURL.String(), + api.ClientIDArg + "client", + api.UsePKCEArg, + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + is := require.New(t) + + cfg, err := NewAPIConfig(apiURL, issuerURL, command, "client", tt.opts...) + is.NoError(err) + + is.Equal(tt.wantName, cfg.CurrentContext) + is.Len(cfg.Clusters, 1) + is.Len(cfg.Contexts, 1) + is.Len(cfg.AuthInfos, 1) + is.Equal(apiURL.String(), cfg.Clusters[tt.wantName].Server) + is.Equal(tt.wantCACert, cfg.Clusters[tt.wantName].CertificateAuthorityData) + is.Equal(tt.wantName, cfg.Contexts[tt.wantName].Cluster) + is.Equal(tt.wantName, cfg.Contexts[tt.wantName].AuthInfo) + is.Equal(&tt.wantAuthInfo, cfg.AuthInfos[tt.wantName]) + + wantExtension, err := config.NewExtension(tt.wantOrg).ToObject() + is.NoError(err) + is.Equal(wantExtension, cfg.Contexts[tt.wantName].Extensions[cli.Name]) + }) + } +} diff --git a/internal/kubeconfig/cluster.go b/internal/kubeconfig/cluster.go new file mode 100644 index 00000000..12a0f6d9 --- /dev/null +++ b/internal/kubeconfig/cluster.go @@ -0,0 +1,99 @@ +package kubeconfig + +import ( + "context" + "encoding/base64" + "fmt" + "net/url" + "os" + + infrastructure "github.com/ninech/apis/infrastructure/v1alpha1" + "github.com/ninech/nctl/api" + "github.com/ninech/nctl/api/config" + "github.com/ninech/nctl/internal/cli" + "github.com/ninech/nctl/internal/format" + "k8s.io/apimachinery/pkg/types" +) + +// LoginCluster adds the KubernetesCluster name to the kubeconfig of client +// and switches the current context to it. If execPlugin is set, the OIDC +// exec plugin is run right away so the user is logged in and named in the +// output. +func LoginCluster(ctx context.Context, client *api.Client, w format.Writer, name types.NamespacedName, execPlugin bool) error { + cluster := &infrastructure.KubernetesCluster{} + if err := client.Get(ctx, name, cluster); err != nil { + return err + } + + apiEndpoint, err := url.Parse(cluster.Status.AtProvider.APIEndpoint) + if err != nil { + return cli.ErrorWithContext(fmt.Errorf("invalid cluster API endpoint: %w", err)). + WithExitCode(cli.ExitUsageError). + WithContext("Endpoint", cluster.Status.AtProvider.APIEndpoint). + WithSuggestions("The cluster API endpoint should be a valid URL") + } + + issuerURL, err := url.Parse(cluster.Status.AtProvider.OIDCIssuerURL) + if err != nil { + return cli.ErrorWithContext(fmt.Errorf("invalid cluster OIDC issuer URL: %w", err)). + WithExitCode(cli.ExitUsageError). + WithContext("IssuerURL", cluster.Status.AtProvider.OIDCIssuerURL). + WithSuggestions("The OIDC issuer URL should be a valid URL") + } + + caCert, err := base64.StdEncoding.DecodeString(cluster.Status.AtProvider.APICACert) + if err != nil { + return fmt.Errorf("unable to decode API CA certificate: %w", err) + } + + // not sure if this should ever happen but better than getting a panic + if len(os.Args) == 0 { + return fmt.Errorf("could not get command name from os.Args") + } + // we try to find out where the nctl binary is located + command, err := os.Executable() + if err != nil { + return fmt.Errorf("can not identify executable path of %s: %w", cli.Name, err) + } + + cfg, err := NewAPIConfig( + apiEndpoint, + issuerURL, + command, + cluster.Status.AtProvider.OIDCClientID, + OverrideName(config.ContextName(cluster)), + WithCACert(caCert), + ) + if err != nil { + return fmt.Errorf("unable to create kubeconfig: %w", err) + } + + userInfo := &api.UserInfo{} + + if execPlugin { + authInfo, ok := cfg.AuthInfos[cfg.CurrentContext] + if !ok { + return fmt.Errorf("authInfo not found") + } + + if authInfo == nil || authInfo.Exec == nil { + return fmt.Errorf("no Exec found in authInfo") + } + + token, err := api.GetTokenFromExecConfig(ctx, authInfo.Exec) + if err != nil { + return err + } + + userInfo, err = api.GetUserInfoFromToken(token) + if err != nil { + return err + } + } + + if err := Login(w, cfg, client.KubeconfigPath, userInfo.User, "", SwitchCurrentContext()); err != nil { + return fmt.Errorf("error logging in to cluster %s: %w", name, err) + } + + return nil +} diff --git a/internal/kubeconfig/cluster_test.go b/internal/kubeconfig/cluster_test.go new file mode 100644 index 00000000..6b62ccab --- /dev/null +++ b/internal/kubeconfig/cluster_test.go @@ -0,0 +1,75 @@ +package kubeconfig + +import ( + "bytes" + "testing" + + infrastructure "github.com/ninech/apis/infrastructure/v1alpha1" + "github.com/ninech/nctl/api" + "github.com/ninech/nctl/api/config" + "github.com/ninech/nctl/internal/format" + "github.com/ninech/nctl/internal/test" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/tools/clientcmd" +) + +func TestLoginCluster(t *testing.T) { + t.Parallel() + is := require.New(t) + + cluster := &infrastructure.KubernetesCluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "test", + }, + Status: infrastructure.KubernetesClusterStatus{ + AtProvider: infrastructure.KubernetesClusterObservation{ + ClusterObservation: infrastructure.ClusterObservation{ + APIEndpoint: "https://new.example.org", + OIDCClientID: "some-client-id", + OIDCIssuerURL: "https://auth.example.org", + }, + }, + }, + } + // the test kubeconfig already contains one entry which has to survive + // the merge + apiClient := test.SetupClient(t, + test.WithKubeconfig(), + test.WithObjects(cluster), + ) + + out := &bytes.Buffer{} + // we run without the execPlugin, that would be something for an e2e test + is.NoError(LoginCluster(t.Context(), apiClient, format.NewWriter(out), api.ObjectName(cluster), false)) + + merged, err := clientcmd.LoadFromFile(apiClient.KubeconfigPath) + is.NoError(err) + + contextName := config.ContextName(cluster) + is.Len(merged.Clusters, 2) + is.Len(merged.Contexts, 2) + is.Len(merged.AuthInfos, 2) + is.Equal(contextName, merged.CurrentContext) + is.Equal(cluster.Status.AtProvider.APIEndpoint, merged.Clusters[contextName].Server) + is.Equal([]string{ + api.AuthCmdName, + api.OIDCCmdName, + api.IssuerURLArg + cluster.Status.AtProvider.OIDCIssuerURL, + api.ClientIDArg + cluster.Status.AtProvider.OIDCClientID, + api.UsePKCEArg, + }, merged.AuthInfos[contextName].Exec.Args) + is.Contains(out.String(), "added "+contextName+" to kubeconfig") + is.Contains(out.String(), "logged into cluster "+contextName) +} + +func TestLoginClusterNotFound(t *testing.T) { + t.Parallel() + + apiClient := test.SetupClient(t, test.WithKubeconfig()) + err := LoginCluster(t.Context(), apiClient, format.Writer{}, api.ObjectName(&infrastructure.KubernetesCluster{ + ObjectMeta: metav1.ObjectMeta{Name: "missing", Namespace: test.DefaultProject}, + }), false) + require.Error(t, err) +} diff --git a/internal/kubeconfig/login.go b/internal/kubeconfig/login.go new file mode 100644 index 00000000..1e23c5df --- /dev/null +++ b/internal/kubeconfig/login.go @@ -0,0 +1,87 @@ +package kubeconfig + +import ( + "fmt" + "maps" + "os" + "strings" + + "github.com/ninech/nctl/internal/format" + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" +) + +type loginConfig struct { + project string + switchCurrentContext bool +} + +// LoginOption configures [Login]. +type LoginOption func(*loginConfig) + +// WithProject sets the project (namespace) of the new config's context. +func WithProject(project string) LoginOption { + return func(l *loginConfig) { + l.project = project + } +} + +// SwitchCurrentContext sets the current context of the merged kubeconfig to +// the one of the new config. +func SwitchCurrentContext() LoginOption { + return func(l *loginConfig) { + l.switchCurrentContext = true + } +} + +// Login merges newConfig into the kubeconfig at kubeconfigPath, creating the +// file if it does not exist, and reports the result to w. userName and +// toOrg are only used in the messages and may be empty. +func Login(w format.Writer, newConfig *clientcmdapi.Config, kubeconfigPath, userName string, toOrg string, opts ...LoginOption) error { + loginConfig := &loginConfig{} + for _, opt := range opts { + opt(loginConfig) + } + + if loginConfig.project != "" && newConfig.Contexts[newConfig.CurrentContext] != nil { + newConfig.Contexts[newConfig.CurrentContext].Namespace = loginConfig.project + } + + kubeconfig, err := clientcmd.LoadFromFile(kubeconfigPath) + if err != nil { + if !os.IsNotExist(err) { + return err + } + // kubeconfig file does not exist so we just use our new config + kubeconfig = newConfig + } + + mergeKubeConfig(newConfig, kubeconfig) + + if loginConfig.switchCurrentContext { + kubeconfig.CurrentContext = newConfig.CurrentContext + } + + if err := clientcmd.WriteToFile(*kubeconfig, kubeconfigPath); err != nil { + return err + } + + if toOrg != "" { + w.Successf("🏢", "switched to the organization %q", toOrg) + } + w.Successf("📋", "added %s to kubeconfig", newConfig.CurrentContext) + + loginMessage := fmt.Sprintf("logged into cluster %s", newConfig.CurrentContext) + if strings.TrimSpace(userName) != "" { + loginMessage = fmt.Sprintf("logged into cluster %s as %s", newConfig.CurrentContext, userName) + } + w.Success("🚀", loginMessage) + + return nil +} + +func mergeKubeConfig(from, to *clientcmdapi.Config) { + maps.Copy(to.Clusters, from.Clusters) + maps.Copy(to.AuthInfos, from.AuthInfos) + maps.Copy(to.Contexts, from.Contexts) +} diff --git a/internal/kubeconfig/login_test.go b/internal/kubeconfig/login_test.go new file mode 100644 index 00000000..c0076992 --- /dev/null +++ b/internal/kubeconfig/login_test.go @@ -0,0 +1,101 @@ +package kubeconfig + +import ( + "bytes" + "path/filepath" + "testing" + + "github.com/ninech/nctl/internal/format" + "github.com/stretchr/testify/require" + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" +) + +func singleEntryConfig(name string) *clientcmdapi.Config { + return &clientcmdapi.Config{ + Clusters: map[string]*clientcmdapi.Cluster{name: {Server: "https://" + name}}, + AuthInfos: map[string]*clientcmdapi.AuthInfo{name: {Token: name}}, + Contexts: map[string]*clientcmdapi.Context{name: {Cluster: name, AuthInfo: name}}, + CurrentContext: name, + } +} + +func TestLogin(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + existing *clientcmdapi.Config + userName string + toOrg string + opts []LoginOption + wantContext string + wantEntries int + wantProject string + wantOutput []string + unwantedOutput []string + }{ + { + name: "creates the kubeconfig if it does not exist", + userName: "user", + wantContext: "new", + wantEntries: 1, + wantOutput: []string{"added new to kubeconfig", "logged into cluster new as user"}, + }, + { + name: "merges into an existing kubeconfig and keeps its current context", + existing: singleEntryConfig("existing"), + wantContext: "existing", + wantEntries: 2, + wantOutput: []string{"added new to kubeconfig", "logged into cluster new"}, + unwantedOutput: []string{" as ", "organization"}, + }, + { + name: "switches the current context and sets the project", + existing: singleEntryConfig("existing"), + toOrg: "org", + opts: []LoginOption{SwitchCurrentContext(), WithProject("project")}, + wantContext: "new", + wantEntries: 2, + wantProject: "project", + wantOutput: []string{`switched to the organization "org"`, "added new to kubeconfig"}, + }, + { + name: "overwrites an entry with the same name", + existing: singleEntryConfig("new"), + wantContext: "new", + wantEntries: 1, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + is := require.New(t) + + kubeconfigPath := filepath.Join(t.TempDir(), "kubeconfig") + if tt.existing != nil { + is.NoError(clientcmd.WriteToFile(*tt.existing, kubeconfigPath)) + } + + newConfig := singleEntryConfig("new") + newConfig.AuthInfos["new"].Token = "new-token" + out := &bytes.Buffer{} + is.NoError(Login(format.NewWriter(out), newConfig, kubeconfigPath, tt.userName, tt.toOrg, tt.opts...)) + + merged, err := clientcmd.LoadFromFile(kubeconfigPath) + is.NoError(err) + is.Equal(tt.wantContext, merged.CurrentContext) + is.Len(merged.Clusters, tt.wantEntries) + is.Len(merged.Contexts, tt.wantEntries) + is.Len(merged.AuthInfos, tt.wantEntries) + is.Equal("new-token", merged.AuthInfos["new"].Token) + is.Equal(tt.wantProject, merged.Contexts["new"].Namespace) + for _, want := range tt.wantOutput { + is.Contains(out.String(), want) + } + for _, unwanted := range tt.unwantedOutput { + is.NotContains(out.String(), unwanted) + } + }) + } +} From 5698c1e558a0d5c76242c5abcb8b9d786d24e919 Mon Sep 17 00:00:00 2001 From: thde Date: Thu, 17 Sep 2026 09:36:22 +0200 Subject: [PATCH 2/2] refactor(create): log in to a new vcluster without the auth verb create vcluster ran auth.ClusterCmd after the cluster became available, which was the last reason for create to import the auth verb. It now calls kubeconfig.LoginCluster directly. This also passes the command's own writer instead of the zero-value format.Writer of the constructed ClusterCmd, which wrote to io.Discard, so the "added ... to kubeconfig" and "logged into cluster ..." messages are no longer swallowed after `nctl create vcluster`. --- create/vcluster.go | 6 ++---- internal/kubeconfig/apiconfig.go | 32 +++++++++++++------------------- internal/kubeconfig/cluster.go | 5 ++--- internal/kubeconfig/login.go | 9 ++++----- main_test.go | 1 - 5 files changed, 21 insertions(+), 32 deletions(-) diff --git a/create/vcluster.go b/create/vcluster.go index 268cb336..5be4f6c8 100644 --- a/create/vcluster.go +++ b/create/vcluster.go @@ -7,8 +7,7 @@ import ( infrastructure "github.com/ninech/apis/infrastructure/v1alpha1" meta "github.com/ninech/apis/meta/v1alpha1" "github.com/ninech/nctl/api" - "github.com/ninech/nctl/api/config" - "github.com/ninech/nctl/auth" + "github.com/ninech/nctl/internal/kubeconfig" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/watch" ) @@ -50,8 +49,7 @@ func (cmd *vclusterCmd) Run(ctx context.Context, client *api.Client) error { return err } - clustercmd := auth.ClusterCmd{Name: config.ContextName(cluster), ExecPlugin: true} - return clustercmd.Run(ctx, client) + return kubeconfig.LoginCluster(ctx, client, cmd.Writer, api.ObjectName(cluster), true) } func (cmd *vclusterCmd) isAvailable(cluster *infrastructure.KubernetesCluster) bool { diff --git a/internal/kubeconfig/apiconfig.go b/internal/kubeconfig/apiconfig.go index 9c7a70c5..04fcaab0 100644 --- a/internal/kubeconfig/apiconfig.go +++ b/internal/kubeconfig/apiconfig.go @@ -1,6 +1,5 @@ -// Package kubeconfig builds the kubeconfig entries nctl uses to talk to the -// Nine API and to Kubernetes clusters, and merges them into the user's -// kubeconfig. +// Package kubeconfig builds the kubeconfig entries nctl uses to talk to the Nine API and to Kubernetes clusters, +// and merges them into the user's kubeconfig. package kubeconfig import ( @@ -13,8 +12,7 @@ import ( clientcmdapi "k8s.io/client-go/tools/clientcmd/api" ) -// ClientCredentials are the APIServiceAccount credentials the exec plugin -// uses to obtain a token. +// ClientCredentials are the APIServiceAccount credentials the exec plugin uses to obtain a token. type ClientCredentials struct { ClientID string ClientSecret string @@ -32,8 +30,8 @@ type apiConfig struct { // APIConfigOption configures the kubeconfig built by [NewAPIConfig]. type APIConfigOption func(*apiConfig) -// OverrideName sets the name used for the cluster, context and user entry -// instead of the API URL host. +// OverrideName sets the name used for the cluster, +// context and user entry instead of the API URL host. func OverrideName(name string) APIConfigOption { return func(ac *apiConfig) { ac.name = name @@ -47,16 +45,14 @@ func WithCACert(caCert []byte) APIConfigOption { } } -// UseStaticToken authenticates with a static token instead of an exec -// plugin. +// UseStaticToken authenticates with a static token instead of an exec plugin. func UseStaticToken(token string) APIConfigOption { return func(ac *apiConfig) { ac.token = token } } -// UseClientCredentials authenticates through the client-credentials exec -// plugin instead of the interactive OIDC one. +// UseClientCredentials authenticates through the client-credentials exec plugin instead of the interactive OIDC one. func UseClientCredentials(credentials ClientCredentials) APIConfigOption { return func(ac *apiConfig) { ac.credentials = credentials @@ -70,10 +66,10 @@ func WithOrganization(organization string) APIConfigOption { } } -// NewAPIConfig returns a kubeconfig with a single cluster, context and user -// for the API at apiURL. Unless a static token or client credentials are -// configured, the user authenticates through the OIDC exec plugin of the -// nctl binary at command. +// NewAPIConfig returns a kubeconfig with a single cluster, +// context and user for the API at apiURL. +// Unless a static token or client credentials are configured, +// the user authenticates through the OIDC exec plugin of the nctl binary at command. func NewAPIConfig(apiURL, issuerURL *url.URL, command, clientID string, opts ...APIConfigOption) (*clientcmdapi.Config, error) { cfg := &apiConfig{ name: apiURL.Host, @@ -129,8 +125,7 @@ func NewAPIConfig(apiURL, issuerURL *url.URL, command, clientID string, opts ... return clientConfig, nil } -// oidcExecConfig returns an exec config that obtains a token through the -// interactive OIDC login of nctl. +// oidcExecConfig returns an exec config that obtains a token through the interactive OIDC login of nctl. func oidcExecConfig(command, clientID string, issuerURL *url.URL) *clientcmdapi.ExecConfig { return &clientcmdapi.ExecConfig{ APIVersion: "client.authentication.k8s.io/v1beta1", @@ -145,8 +140,7 @@ func oidcExecConfig(command, clientID string, issuerURL *url.URL) *clientcmdapi. } } -// clientCredentialsExecConfig returns an exec config that obtains a token -// through the client-credentials login of nctl. +// clientCredentialsExecConfig returns an exec config that obtains a token through the client-credentials login of nctl. func clientCredentialsExecConfig(command string, credentials ClientCredentials) *clientcmdapi.ExecConfig { return &clientcmdapi.ExecConfig{ APIVersion: "client.authentication.k8s.io/v1", diff --git a/internal/kubeconfig/cluster.go b/internal/kubeconfig/cluster.go index 12a0f6d9..84b7f8f3 100644 --- a/internal/kubeconfig/cluster.go +++ b/internal/kubeconfig/cluster.go @@ -16,9 +16,8 @@ import ( ) // LoginCluster adds the KubernetesCluster name to the kubeconfig of client -// and switches the current context to it. If execPlugin is set, the OIDC -// exec plugin is run right away so the user is logged in and named in the -// output. +// and switches the current context to it. +// If execPlugin is set, the OIDC exec plugin is run right away so the user is logged in and named in the output. func LoginCluster(ctx context.Context, client *api.Client, w format.Writer, name types.NamespacedName, execPlugin bool) error { cluster := &infrastructure.KubernetesCluster{} if err := client.Get(ctx, name, cluster); err != nil { diff --git a/internal/kubeconfig/login.go b/internal/kubeconfig/login.go index 1e23c5df..738a8baf 100644 --- a/internal/kubeconfig/login.go +++ b/internal/kubeconfig/login.go @@ -26,17 +26,16 @@ func WithProject(project string) LoginOption { } } -// SwitchCurrentContext sets the current context of the merged kubeconfig to -// the one of the new config. +// SwitchCurrentContext sets the current context of the merged kubeconfig to the one of the new config. func SwitchCurrentContext() LoginOption { return func(l *loginConfig) { l.switchCurrentContext = true } } -// Login merges newConfig into the kubeconfig at kubeconfigPath, creating the -// file if it does not exist, and reports the result to w. userName and -// toOrg are only used in the messages and may be empty. +// Login merges newConfig into the kubeconfig at kubeconfigPath, +// creating the file if it does not exist, +// and reports the result to w. userName and toOrg are only used in the messages and may be empty. func Login(w format.Writer, newConfig *clientcmdapi.Config, kubeconfigPath, userName string, toOrg string, opts ...LoginOption) error { loginConfig := &loginConfig{} for _, opt := range opts { diff --git a/main_test.go b/main_test.go index b1dfba02..0a7b645e 100644 --- a/main_test.go +++ b/main_test.go @@ -143,7 +143,6 @@ type verbImport struct{ from, to string } // entry must be removed by the PR that breaks that edge; the test fails once // an entry is stale. var allowedVerbImports = map[verbImport]bool{ - {"create", "auth"}: true, {"update", "create"}: true, }