diff --git a/docs/local-run.md b/docs/local-run.md index 0330ba7d..01dd1200 100644 --- a/docs/local-run.md +++ b/docs/local-run.md @@ -167,6 +167,21 @@ data: token: "..." # working jwt token ``` +2.3. (Alternatively) Create a secret containing an exec config to dynamically fetch the cluster credential from an external command: + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: managed1 + labels: + cluster.core.oam.dev/cluster-credential-type: Dynamic +type: Opaque # <--- Has to be opaque +data: + endpoint: "..." # ditto + exec: "..." # an exec config in JSON format; see ExecConfig (https://github.com/kubernetes/kubernetes/blob/2016fab3085562b4132e6d3774b6ded5ba9939fd/staging/src/k8s.io/client-go/tools/clientcmd/api/types.go#L206, https://kubernetes.io/docs/reference/access-authn-authz/authentication/#configuration) +``` + 3. Proxy to cluster `managed1`'s `/healthz` endpoint ```shell @@ -195,4 +210,4 @@ KUBECONFIG=/tmp/hub-managed1.kubeconfig kubectl get ns ```shell $ kind delete cluster --name tmp -``` \ No newline at end of file +``` diff --git a/pkg/apis/cluster/v1alpha1/clustergateway_types.go b/pkg/apis/cluster/v1alpha1/clustergateway_types.go index fcac4eeb..65e610aa 100644 --- a/pkg/apis/cluster/v1alpha1/clustergateway_types.go +++ b/pkg/apis/cluster/v1alpha1/clustergateway_types.go @@ -79,6 +79,10 @@ const ( // CredentialTypeX509Certificate means the cluster is accessible via // X509 certificate and key. CredentialTypeX509Certificate CredentialType = "X509Certificate" + // CredentialTypeDynamic means that a credential will be issued before + // accessing the cluster. The generated credential can be either a service + // account token or X509 certificate and key. + CredentialTypeDynamic CredentialType = "Dynamic" ) type ClusterEndpointType string diff --git a/pkg/apis/cluster/v1alpha1/clustergateway_types_secret.go b/pkg/apis/cluster/v1alpha1/clustergateway_types_secret.go index 7622306d..ed88a846 100644 --- a/pkg/apis/cluster/v1alpha1/clustergateway_types_secret.go +++ b/pkg/apis/cluster/v1alpha1/clustergateway_types_secret.go @@ -2,17 +2,21 @@ package v1alpha1 import ( "context" + "encoding/json" "fmt" "strconv" "strings" "k8s.io/apimachinery/pkg/util/yaml" + "k8s.io/apiserver/pkg/registry/rest" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" "k8s.io/utils/pointer" "github.com/oam-dev/cluster-gateway/pkg/common" "github.com/oam-dev/cluster-gateway/pkg/config" "github.com/oam-dev/cluster-gateway/pkg/featuregates" "github.com/oam-dev/cluster-gateway/pkg/options" + "github.com/oam-dev/cluster-gateway/pkg/util/exec" "github.com/oam-dev/cluster-gateway/pkg/util/singleton" "github.com/pkg/errors" @@ -22,7 +26,6 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/apiserver/pkg/registry/rest" utilfeature "k8s.io/apiserver/pkg/util/feature" "k8s.io/klog/v2" clusterv1 "open-cluster-management.io/api/cluster/v1" @@ -176,11 +179,11 @@ func getEndpointFromSecret(secret *v1.Secret) ([]byte, string, error) { func convert(caData []byte, apiServerEndpoint string, insecure bool, secret *v1.Secret) (*ClusterGateway, error) { c := &ClusterGateway{ ObjectMeta: metav1.ObjectMeta{ - Name: secret.Name, + Name: secret.Name, + CreationTimestamp: secret.CreationTimestamp, }, Spec: ClusterGatewaySpec{ - Provider: "", - Access: ClusterAccess{}, + Access: ClusterAccess{}, }, } @@ -242,11 +245,21 @@ func convert(caData []byte, apiServerEndpoint string, insecure bool, secret *v1. PrivateKey: secret.Data[v1.TLSPrivateKeyKey], }, } + case CredentialTypeServiceAccountToken: c.Spec.Access.Credential = &ClusterAccessCredential{ Type: CredentialTypeServiceAccountToken, ServiceAccountToken: string(secret.Data[v1.ServiceAccountTokenKey]), } + + case CredentialTypeDynamic: + credential, err := buildCredentialFromExecConfig(secret) + if err != nil { + return nil, fmt.Errorf("failed to issue credential from external command: %s", err) + } + + c.Spec.Access.Credential = credential + default: return nil, fmt.Errorf("unrecognized secret credential type %v", credentialType) } @@ -278,3 +291,40 @@ func convert(caData []byte, apiServerEndpoint string, insecure bool, secret *v1. return c, nil } + +func buildCredentialFromExecConfig(secret *v1.Secret) (*ClusterAccessCredential, error) { + execConfigRaw := secret.Data["exec"] + if len(execConfigRaw) == 0 { + return nil, errors.New("missing secret data key: exec") + } + + var ec clientcmdapi.ExecConfig + if err := json.Unmarshal(execConfigRaw, &ec); err != nil { + return nil, fmt.Errorf("failed to decode exec config JSON from secret data: %v", err) + } + + cred, err := exec.IssueClusterCredential(secret.Name, &ec) + if err != nil { + return nil, err + } + + if token := cred.Status.Token; len(token) > 0 { + return &ClusterAccessCredential{ + Type: CredentialTypeDynamic, + ServiceAccountToken: token, + }, nil + } + + if cert, key := cred.Status.ClientCertificateData, cred.Status.ClientKeyData; len(cert) > 0 && len(key) > 0 { + return &ClusterAccessCredential{ + Type: CredentialTypeDynamic, + X509: &X509{ + Certificate: []byte(cert), + PrivateKey: []byte(key), + }, + }, nil + + } + + return nil, fmt.Errorf("no credential type available") +} diff --git a/pkg/apis/cluster/v1alpha1/clustergateway_types_secret_test.go b/pkg/apis/cluster/v1alpha1/clustergateway_types_secret_test.go index 0c489350..df8b268e 100644 --- a/pkg/apis/cluster/v1alpha1/clustergateway_types_secret_test.go +++ b/pkg/apis/cluster/v1alpha1/clustergateway_types_secret_test.go @@ -27,13 +27,29 @@ import ( ) var ( - testNamespace = "foo" - testName = "bar" - testCAData = "caData" - testCertData = "certData" - testKeyData = "keyData" - testToken = "token" - testEndpoint = "https://localhost:443" + testNamespace = "foo" + testName = "bar" + testCAData = "caData" + testCertData = "certData" + testKeyData = "keyData" + testToken = "token" + testEndpoint = "https://localhost:443" + testExecConfigForToken = `{ + "apiVersion": "client.authentication.k8s.io/v1beta1", + "kind": "ExecConfig", + "command": "echo", + "args": [ + "{\"apiVersion\": \"client.authentication.k8s.io/v1beta1\", \"kind\": \"ExecCredential\", \"status\": {\"token\": \"token\"}}" + ] +}` + testExecConfigForX509 = `{ + "apiVersion": "client.authentication.k8s.io/v1beta1", + "kind": "ExecConfig", + "command": "echo", + "args": [ + "{\"apiVersion\": \"client.authentication.k8s.io/v1beta1\", \"kind\": \"ExecCredential\", \"status\": {\"clientCertificateData\": \"certData\", \"clientKeyData\": \"keyData\"}}" + ] +}` ) func TestConvertSecretToGateway(t *testing.T) { @@ -260,6 +276,101 @@ func TestConvertSecretToGateway(t *testing.T) { }, }, }, + { + name: "dynamic service account token issued from external command", + inputSecret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: testName, + Namespace: testNamespace, + Labels: map[string]string{ + common.LabelKeyClusterCredentialType: string(CredentialTypeDynamic), + }, + }, + Data: map[string][]byte{ + "endpoint": []byte(testEndpoint), + "ca.crt": []byte(testCAData), + "exec": []byte(testExecConfigForToken), + }, + }, + expected: &ClusterGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: testName, + }, + Spec: ClusterGatewaySpec{ + Access: ClusterAccess{ + Credential: &ClusterAccessCredential{ + Type: CredentialTypeDynamic, + ServiceAccountToken: testToken, + }, + Endpoint: &ClusterEndpoint{ + Type: ClusterEndpointTypeConst, + Const: &ClusterEndpointConst{ + CABundle: []byte(testCAData), + Address: testEndpoint, + }, + }, + }, + }, + }, + }, + { + name: "dynamic x509 cert-key pair issued from external command", + inputSecret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: testName, + Namespace: testNamespace, + Labels: map[string]string{ + common.LabelKeyClusterCredentialType: string(CredentialTypeDynamic), + }, + }, + Data: map[string][]byte{ + "endpoint": []byte(testEndpoint), + "ca.crt": []byte(testCAData), + "exec": []byte(testExecConfigForX509), + }, + }, + expected: &ClusterGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: testName, + }, + Spec: ClusterGatewaySpec{ + Access: ClusterAccess{ + Credential: &ClusterAccessCredential{ + Type: CredentialTypeDynamic, + X509: &X509{ + Certificate: []byte(testCertData), + PrivateKey: []byte(testKeyData), + }, + }, + Endpoint: &ClusterEndpoint{ + Type: ClusterEndpointTypeConst, + Const: &ClusterEndpointConst{ + CABundle: []byte(testCAData), + Address: testEndpoint, + }, + }, + }, + }, + }, + }, + { + name: "failed to fetch cluster credential from dynamic auth mode", + inputSecret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: testName, + Namespace: testNamespace, + Labels: map[string]string{ + common.LabelKeyClusterCredentialType: string(CredentialTypeDynamic), + }, + }, + Data: map[string][]byte{ + "endpoint": []byte(testEndpoint), + "ca.crt": []byte(testCAData), + "exec": []byte("invalid exec config format"), + }, + }, + expectedFailure: true, + }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -524,3 +635,79 @@ func TestListHybridClusterGateway(t *testing.T) { } assert.Equal(t, expectedNames, actualNames) } + +func TestBuildCredentialFromExecConfig(t *testing.T) { + cases := []struct { + name string + secret func(s *corev1.Secret) *corev1.Secret + cluster func(ce *ClusterEndpoint) *ClusterEndpoint + expectedError string + expected *ClusterAccessCredential + }{ + { + name: "missing exec config", + expectedError: "missing secret data key: exec", + }, + + { + name: "invalid exec config format", + secret: func(s *corev1.Secret) *corev1.Secret { + s.Data["exec"] = []byte("some invalid exec config") + return s + }, + expectedError: "failed to decode exec config JSON from secret data: invalid character 's' looking for beginning of value", + }, + + { + name: "returns successfully a service account token", + secret: func(s *corev1.Secret) *corev1.Secret { + s.Data["exec"] = []byte(`{"apiVersion": "client.authentication.k8s.io/v1", "command": "echo", "args": ["{\"apiVersion\": \"client.authentication.k8s.io/v1\", \"status\": {\"token\": \"token\"}}"]}`) + return s + }, + expected: &ClusterAccessCredential{ + Type: CredentialTypeDynamic, + ServiceAccountToken: testToken, + }, + }, + + { + name: "returns successfully a X509 client certificate", + secret: func(s *corev1.Secret) *corev1.Secret { + s.Data["exec"] = []byte(`{"apiVersion": "client.authentication.k8s.io/v1", "command": "echo", "args": ["{\"apiVersion\": \"client.authentication.k8s.io/v1\", \"status\": {\"clientCertificateData\": \"certData\", \"clientKeyData\": \"keyData\"}}"]}`) + return s + }, + expected: &ClusterAccessCredential{ + Type: CredentialTypeDynamic, + X509: &X509{ + Certificate: []byte(testCertData), + PrivateKey: []byte(testKeyData), + }, + }, + }, + } + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: testName, + Namespace: testNamespace, + }, + Data: map[string][]byte{}, + } + if tt.secret != nil { + secret = tt.secret(secret) + } + + got, err := buildCredentialFromExecConfig(secret) + if tt.expectedError != "" { + assert.Error(t, err) + assert.EqualError(t, err, tt.expectedError) + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.expected, got) + }) + } +} diff --git a/pkg/apis/cluster/v1alpha1/transport.go b/pkg/apis/cluster/v1alpha1/transport.go index f15698fd..3a1f399a 100644 --- a/pkg/apis/cluster/v1alpha1/transport.go +++ b/pkg/apis/cluster/v1alpha1/transport.go @@ -94,8 +94,19 @@ func NewConfigFromCluster(ctx context.Context, c *ClusterGateway) (*restclient.C } // setting up credentials switch c.Spec.Access.Credential.Type { + case CredentialTypeDynamic: + if token := c.Spec.Access.Credential.ServiceAccountToken; token != "" { + cfg.BearerToken = token + } + + if c.Spec.Access.Credential.X509 != nil && len(c.Spec.Access.Credential.X509.Certificate) > 0 && len(c.Spec.Access.Credential.X509.PrivateKey) > 0 { + cfg.CertData = c.Spec.Access.Credential.X509.Certificate + cfg.KeyData = c.Spec.Access.Credential.X509.PrivateKey + } + case CredentialTypeServiceAccountToken: cfg.BearerToken = c.Spec.Access.Credential.ServiceAccountToken + case CredentialTypeX509Certificate: cfg.CertData = c.Spec.Access.Credential.X509.Certificate cfg.KeyData = c.Spec.Access.Credential.X509.PrivateKey diff --git a/pkg/apis/cluster/v1alpha1/transport_test.go b/pkg/apis/cluster/v1alpha1/transport_test.go index bc517f2c..2c3436e7 100644 --- a/pkg/apis/cluster/v1alpha1/transport_test.go +++ b/pkg/apis/cluster/v1alpha1/transport_test.go @@ -215,6 +215,74 @@ func TestClusterRestConfigConversion(t *testing.T) { }, }, }, + { + name: "dynamic credential: service account token", + clusterGateway: &ClusterGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-cluster", + }, + Spec: ClusterGatewaySpec{ + Access: ClusterAccess{ + Endpoint: &ClusterEndpoint{ + Type: ClusterEndpointTypeConst, + Const: &ClusterEndpointConst{ + Address: "https://k8s.example.com", + CABundle: testCAData, + }, + }, + Credential: &ClusterAccessCredential{ + Type: CredentialTypeDynamic, + ServiceAccountToken: testToken, + }, + }, + }, + }, + expectedCfg: &rest.Config{ + Host: "https://k8s.example.com", + Timeout: 40 * time.Second, + BearerToken: testToken, + TLSClientConfig: rest.TLSClientConfig{ + ServerName: "k8s.example.com", + CAData: testCAData, + }, + }, + }, + { + name: "dynamic credential: certificate + private key", + clusterGateway: &ClusterGateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-cluster", + }, + Spec: ClusterGatewaySpec{ + Access: ClusterAccess{ + Endpoint: &ClusterEndpoint{ + Type: ClusterEndpointTypeConst, + Const: &ClusterEndpointConst{ + Address: "https://k8s.example.com", + CABundle: testCAData, + }, + }, + Credential: &ClusterAccessCredential{ + Type: CredentialTypeDynamic, + X509: &X509{ + Certificate: testCertData, + PrivateKey: testKeyData, + }, + }, + }, + }, + }, + expectedCfg: &rest.Config{ + Host: "https://k8s.example.com", + Timeout: 40 * time.Second, + TLSClientConfig: rest.TLSClientConfig{ + ServerName: "k8s.example.com", + CertData: testCertData, + KeyData: testKeyData, + CAData: testCAData, + }, + }, + }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { diff --git a/pkg/util/exec/exec.go b/pkg/util/exec/exec.go new file mode 100644 index 00000000..bb8821ed --- /dev/null +++ b/pkg/util/exec/exec.go @@ -0,0 +1,158 @@ +package exec + +import ( + "bytes" + "errors" + "fmt" + "os" + "os/exec" + "sync" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/runtime/serializer" + + "k8s.io/client-go/pkg/apis/clientauthentication" + "k8s.io/client-go/pkg/apis/clientauthentication/install" + clientauthenticationv1 "k8s.io/client-go/pkg/apis/clientauthentication/v1" + clientauthenticationv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" +) + +var ( + scheme = runtime.NewScheme() + + codecs = serializer.NewCodecFactory(scheme) + + apiVersions = map[string]schema.GroupVersion{ + clientauthenticationv1beta1.SchemeGroupVersion.String(): clientauthenticationv1beta1.SchemeGroupVersion, + clientauthenticationv1.SchemeGroupVersion.String(): clientauthenticationv1.SchemeGroupVersion, + } + + credentials sync.Map +) + +func init() { + install.Install(scheme) +} + +func IssueClusterCredential(name string, ec *clientcmdapi.ExecConfig) (*clientauthentication.ExecCredential, error) { + if name == "" { + return nil, errors.New("cluster name not provided") + } + + value, found := credentials.Load(name) + if found { + cred, ok := value.(*clientauthentication.ExecCredential) + if !ok { + return nil, errors.New("failed to convert item in cache to ExecCredential") + } + + now := &metav1.Time{Time: time.Now().Add(time.Minute)} // expires a minute early + + if cred.Status != nil && cred.Status.ExpirationTimestamp.Before(now) { + credentials.Delete(name) + return IssueClusterCredential(name, ec) // credential expired, calling function again + } + + return cred, nil // credential on cache still valid + } + + cred, err := issueClusterCredential(ec) + if err != nil { + return nil, err + } + + if cred.Status != nil && !cred.Status.ExpirationTimestamp.IsZero() { + credentials.Store(name, cred) // storing credential in cache + } + + return cred, nil +} + +func issueClusterCredential(ec *clientcmdapi.ExecConfig) (*clientauthentication.ExecCredential, error) { + if ec == nil { + return nil, errors.New("exec config not provided") + } + + if ec.Command == "" { + return nil, errors.New("missing \"command\" property on exec config object") + } + + command, err := exec.LookPath(ec.Command) + if err != nil { + return nil, unwrapExecCommandError(ec.Command, err) + } + + cmd := exec.Command(command, ec.Args...) + cmd.Env = os.Environ() + + for _, env := range ec.Env { + cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", env.Name, env.Value)) + } + + var stderr, stdout bytes.Buffer + cmd.Stderr = &stderr + cmd.Stdout = &stdout + + if err := cmd.Run(); err != nil { + return nil, unwrapExecCommandError(command, err) + } + + ecgv, err := schema.ParseGroupVersion(ec.APIVersion) + if err != nil { + return nil, fmt.Errorf("failed to parse exec config API version: %v", err) + } + + cred := &clientauthentication.ExecCredential{ + TypeMeta: metav1.TypeMeta{ + APIVersion: ec.APIVersion, + Kind: "ExecCredential", + }, + Spec: clientauthentication.ExecCredentialSpec{}, + } + + gv, ok := apiVersions[ec.APIVersion] + if !ok { + return nil, fmt.Errorf("exec plugin: invalid apiVersion %q", ec.APIVersion) + } + + _, gvk, err := codecs.UniversalDecoder(gv).Decode(stdout.Bytes(), nil, cred) + if err != nil { + return nil, fmt.Errorf("decoding stdout: %v", err) + } + + if gvk.Group != ecgv.Group || gvk.Version != ecgv.Version { + return nil, fmt.Errorf("exec plugin is configured to use API version %s, plugin returned version %s", ecgv, schema.GroupVersion{Group: gvk.Group, Version: gvk.Version}) + } + + if cred.Status == nil { + return nil, fmt.Errorf("exec plugin didn't return a status field") + } + + if cred.Status.Token == "" && cred.Status.ClientCertificateData == "" && cred.Status.ClientKeyData == "" { + return nil, fmt.Errorf("exec plugin didn't return a token or cert/key pair") + } + + if (cred.Status.ClientCertificateData == "") != (cred.Status.ClientKeyData == "") { + return nil, fmt.Errorf("exec plugin returned only certificate or key, not both") + } + + return cred, nil +} + +func unwrapExecCommandError(path string, err error) error { + switch err.(type) { + case *exec.Error: // Binary does not exist (see exec.Error). + return fmt.Errorf("exec: executable %s not found", path) + + case *exec.ExitError: // Binary execution failed (see exec.Cmd.Run()). + e := err.(*exec.ExitError) + return fmt.Errorf("exec: executable %s failed with exit code %d", path, e.ProcessState.ExitCode()) + + default: + return fmt.Errorf("exec: %v", err) + } +} diff --git a/pkg/util/exec/exec_test.go b/pkg/util/exec/exec_test.go new file mode 100644 index 00000000..cdbe913a --- /dev/null +++ b/pkg/util/exec/exec_test.go @@ -0,0 +1,284 @@ +//go:build unix + +package exec + +import ( + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/pkg/apis/clientauthentication" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" +) + +var ( + testClusterName = "my-cluster" +) + +func TestIssueClusterCredential(t *testing.T) { + t0 := time.Now() + + cases := map[string]struct { + clusterName string + execConfig *clientcmdapi.ExecConfig + expected *clientauthentication.ExecCredential + expectedError string + setup func(t *testing.T) + }{ + "missing cluster name": { + expectedError: "cluster name not provided", + }, + + "missing exec config": { + clusterName: testClusterName, + expectedError: "exec config not provided", + }, + + "missing command property within exec config": { + clusterName: testClusterName, + execConfig: &clientcmdapi.ExecConfig{}, + expectedError: "missing \"command\" property on exec config object", + }, + + "failed to run external command: command not found": { + clusterName: testClusterName, + execConfig: &clientcmdapi.ExecConfig{ + Command: "/path/to/command/not/found", + }, + expectedError: "exec: executable /path/to/command/not/found not found", + }, + + "failed to run external command: finished with non-zero exit code": { + clusterName: testClusterName, + execConfig: &clientcmdapi.ExecConfig{ + APIVersion: "client.authentication.k8s.io/v1", + Command: "false", + }, + expectedError: "exec: executable /usr/bin/false failed with exit code 1", + }, + + "missing API version in exec config": { + clusterName: testClusterName, + execConfig: &clientcmdapi.ExecConfig{ + Command: "true", + }, + expectedError: `exec plugin: invalid apiVersion ""`, + }, + + "invalid API version in exec config": { + clusterName: testClusterName, + execConfig: &clientcmdapi.ExecConfig{ + APIVersion: "example.org/v1", + Command: "true", + }, + expectedError: `exec plugin: invalid apiVersion "example.org/v1"`, + }, + + "invalid exec credential JSON": { + clusterName: testClusterName, + execConfig: &clientcmdapi.ExecConfig{ + APIVersion: "client.authentication.k8s.io/v1", + Command: "echo", + Args: []string{"-n", `[]`}, + }, + expectedError: "decoding stdout: couldn't get version/kind; json parse error: json: cannot unmarshal array into Go value of type struct { APIVersion string \"json:\\\"apiVersion,omitempty\\\"\"; Kind string \"json:\\\"kind,omitempty\\\"\" }", + }, + + "cannot parse de API version": { + clusterName: testClusterName, + execConfig: &clientcmdapi.ExecConfig{ + APIVersion: "a/b/c/d/e", + Command: "true", + }, + expectedError: "failed to parse exec config API version: unexpected GroupVersion string: a/b/c/d/e", + }, + + "API version mismatch": { + clusterName: testClusterName, + execConfig: &clientcmdapi.ExecConfig{ + APIVersion: "client.authentication.k8s.io/v1", + Command: "echo", + Args: []string{"-n", `{ + "apiVersion": "client.authentication.k8s.io/v1beta1", + "kind": "ExecCredential", + "status": { + "token": "testToken" + } +}`}, + }, + expectedError: "exec plugin is configured to use API version client.authentication.k8s.io/v1, plugin returned version client.authentication.k8s.io/v1beta1", + }, + + "missing status property on external command output": { + clusterName: testClusterName, + execConfig: &clientcmdapi.ExecConfig{ + APIVersion: "client.authentication.k8s.io/v1", + Command: "echo", + Args: []string{"-n", `{"apiVersion": "client.authentication.k8s.io/v1", "kind": "ExecCredential"}`}, + }, + expectedError: "exec plugin didn't return a status field", + }, + + "missing any auth credential on status": { + clusterName: testClusterName, + execConfig: &clientcmdapi.ExecConfig{ + APIVersion: "client.authentication.k8s.io/v1", + Command: "echo", + Args: []string{"-n", `{"apiVersion": "client.authentication.k8s.io/v1", "kind": "ExecCredential", "status": {}}`}, + }, + expectedError: "exec plugin didn't return a token or cert/key pair", + }, + + "has cert but no private key": { + clusterName: testClusterName, + execConfig: &clientcmdapi.ExecConfig{ + APIVersion: "client.authentication.k8s.io/v1", + Command: "echo", + Args: []string{"-n", `{"apiVersion": "client.authentication.k8s.io/v1", "kind": "ExecCredential", "status": {"clientCertificateData": "certData"}}`}, + }, + expectedError: "exec plugin returned only certificate or key, not both", + }, + + "invalid exec credential item on cache": { + setup: func(t *testing.T) { + credentials.Store(testClusterName, "invalid exec credential") + }, + clusterName: testClusterName, + execConfig: &clientcmdapi.ExecConfig{ + APIVersion: "client.authentication.k8s.io/v1", + Command: "should_be_ignored", + }, + expectedError: "failed to convert item in cache to ExecCredential", + }, + + "MISS credential from cache, should issue a new credential": { + clusterName: testClusterName, + execConfig: &clientcmdapi.ExecConfig{ + APIVersion: "client.authentication.k8s.io/v1", + Env: []clientcmdapi.ExecEnvVar{ + {Name: "TOKEN", Value: "testToken"}, + }, + Command: "echo", + Args: []string{"-n", `{ + "apiVersion": "client.authentication.k8s.io/v1", + "kind": "ExecCredential", + "status": { + "token": "testToken" + } +}`}, + }, + expected: &clientauthentication.ExecCredential{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "client.authentication.k8s.io/v1", + Kind: "ExecCredential", + }, + Status: &clientauthentication.ExecCredentialStatus{ + Token: "testToken", + }, + }, + }, + + "HIT credential from cache": { + setup: func(t *testing.T) { + credentials.Store(testClusterName, &clientauthentication.ExecCredential{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "client.authentication.k8s.io/v1", + Kind: "ExecCredential", + }, + Status: &clientauthentication.ExecCredentialStatus{ + ExpirationTimestamp: &metav1.Time{Time: t0.Add(time.Hour).Local().Truncate(time.Second)}, + Token: "testToken", + }, + }) + }, + clusterName: testClusterName, + execConfig: &clientcmdapi.ExecConfig{ + APIVersion: "client.authentication.k8s.io/v1", + Command: "should_be_ignored", + }, + expected: &clientauthentication.ExecCredential{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "client.authentication.k8s.io/v1", + Kind: "ExecCredential", + }, + Status: &clientauthentication.ExecCredentialStatus{ + ExpirationTimestamp: &metav1.Time{Time: t0.Add(time.Hour).Local().Truncate(time.Second)}, + Token: "testToken", + }, + }, + }, + + "expired credential on cache, should issue a new credential": { + setup: func(t *testing.T) { + credentials.Store(testClusterName, &clientauthentication.ExecCredential{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "client.authentication.k8s.io/v1", + Kind: "ExecCredential", + }, + Status: &clientauthentication.ExecCredentialStatus{ + ExpirationTimestamp: &metav1.Time{Time: t0}, + Token: "oldToken", + }, + }) + }, + clusterName: testClusterName, + execConfig: &clientcmdapi.ExecConfig{ + APIVersion: "client.authentication.k8s.io/v1", + Command: "echo", + Args: []string{ + "-n", + fmt.Sprintf(`{ + "apiVersion": "client.authentication.k8s.io/v1", + "kind": "ExecCredential", + "status": { + "expirationTimestamp": %q, + "token": "newToken" + } +}`, t0.Add(24*time.Hour).Format(time.RFC3339)), + }, + }, + expected: &clientauthentication.ExecCredential{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "client.authentication.k8s.io/v1", + Kind: "ExecCredential", + }, + Status: &clientauthentication.ExecCredentialStatus{ + ExpirationTimestamp: &metav1.Time{Time: t0.Add(24 * time.Hour).Local().Truncate(time.Second)}, + Token: "newToken", + }, + }, + }, + } + + for name, tt := range cases { + t.Run(name, func(t *testing.T) { + cleanAllCache(t) + + if tt.setup != nil { + tt.setup(t) + } + + cred, err := IssueClusterCredential(tt.clusterName, tt.execConfig) + if tt.expectedError != "" { + assert.Error(t, err) + assert.EqualError(t, err, tt.expectedError) + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.expected, cred) + }) + } +} + +func cleanAllCache(t *testing.T) { + t.Helper() + + credentials.Range(func(key, value any) bool { + credentials.Delete(key) + return true + }) +}