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/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 new file mode 100644 index 00000000..04fcaab0 --- /dev/null +++ b/internal/kubeconfig/apiconfig.go @@ -0,0 +1,157 @@ +// 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..84b7f8f3 --- /dev/null +++ b/internal/kubeconfig/cluster.go @@ -0,0 +1,98 @@ +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..738a8baf --- /dev/null +++ b/internal/kubeconfig/login.go @@ -0,0 +1,86 @@ +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) + } + }) + } +} 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, }