Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions api/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ const (
TokenURLArg = "--token-url="
UsePKCEArg = "--use-pkce"
CustomersPrefix = "/Customers/"
AuthCmdName = "auth"
ClientCredentialsCmdName = "client-credentials"
OIDCCmdName = "oidc"
)
Expand Down
4 changes: 3 additions & 1 deletion auth/auth.go
Original file line number Diff line number Diff line change
@@ -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."`
Expand All @@ -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
21 changes: 7 additions & 14 deletions auth/client_credentials.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
}
}
84 changes: 2 additions & 82 deletions auth/cluster.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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) {
Expand Down
117 changes: 42 additions & 75 deletions auth/cluster_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
}
Loading
Loading