From 17a03b02961dd38408fc12a3deb8f4b1adff96ff Mon Sep 17 00:00:00 2001 From: Claudio Netto Date: Fri, 16 Feb 2024 13:45:02 -0300 Subject: [PATCH] refactor: covering dynamic credentials w/ unit tests --- .../v1alpha1/clustergateway_types_secret.go | 2 +- .../clustergateway_types_secret_test.go | 177 ++++++++++++++++++ pkg/util/exec/exec.go | 20 +- 3 files changed, 193 insertions(+), 6 deletions(-) diff --git a/pkg/apis/cluster/v1alpha1/clustergateway_types_secret.go b/pkg/apis/cluster/v1alpha1/clustergateway_types_secret.go index dbd3716b..18107bb7 100644 --- a/pkg/apis/cluster/v1alpha1/clustergateway_types_secret.go +++ b/pkg/apis/cluster/v1alpha1/clustergateway_types_secret.go @@ -301,7 +301,7 @@ func buildCredentialFromExecConfig(secret *v1.Secret, ep *ClusterEndpoint) (*Clu var ec clientcmdapi.ExecConfig if err := json.Unmarshal(execConfigRaw, &ec); err != nil { - return nil, fmt.Errorf("failed to decode exec config from secret data: %v", err) + return nil, fmt.Errorf("failed to decode exec config JSON from secret data: %v", err) } cluster := &clientauthentication.Cluster{ diff --git a/pkg/apis/cluster/v1alpha1/clustergateway_types_secret_test.go b/pkg/apis/cluster/v1alpha1/clustergateway_types_secret_test.go index 45be3272..23acb3e4 100644 --- a/pkg/apis/cluster/v1alpha1/clustergateway_types_secret_test.go +++ b/pkg/apis/cluster/v1alpha1/clustergateway_types_secret_test.go @@ -617,3 +617,180 @@ 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: "missing command property within exec config", + secret: func(s *corev1.Secret) *corev1.Secret { + s.Data["exec"] = []byte(`{}`) + return s + }, + expectedError: "missing command key in ExecConfig object", + }, + + { + name: "failed to run external command: command not found", + secret: func(s *corev1.Secret) *corev1.Secret { + s.Data["exec"] = []byte(`{"command": "/path/to/command/not/found"}`) + return s + }, + expectedError: "exec: executable /path/to/command/not/found not found", + }, + + { + name: "failed to run external command: finished with non-zero exit code", + secret: func(s *corev1.Secret) *corev1.Secret { + s.Data["exec"] = []byte(`{"command": "false"}`) + return s + }, + expectedError: "exec: executable /usr/bin/false failed with exit code 1", + }, + + { + name: "missing API version in exec config", + secret: func(s *corev1.Secret) *corev1.Secret { + s.Data["exec"] = []byte(`{"command": "true"}`) + return s + }, + expectedError: "exec plugin: invalid apiVersion \"\"", + }, + + { + name: "invalid API version in exec config", + secret: func(s *corev1.Secret) *corev1.Secret { + s.Data["exec"] = []byte(`{"apiVersion": "example.org/v1", "command": "true"}`) + return s + }, + expectedError: "exec plugin: invalid apiVersion \"example.org/v1\"", + }, + + { + name: "invalid external command output", + secret: func(s *corev1.Secret) *corev1.Secret { + s.Data["exec"] = []byte(`{"apiVersion": "client.authentication.k8s.io/v1", "command": "echo", "args": ["invalid output"]}`) + return s + }, + expectedError: "decoding stdout: couldn't get version/kind; json parse error: json: cannot unmarshal string into Go value of type struct { APIVersion string \"json:\\\"apiVersion,omitempty\\\"\"; Kind string \"json:\\\"kind,omitempty\\\"\" }", + }, + + { + name: "API version mismatch between exec config and command output", + 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/v1beta1\"}"]}`) + return s + }, + expectedError: "exec plugin is configured to use API version client.authentication.k8s.io/v1, plugin returned version client.authentication.k8s.io/v1beta1", + }, + + { + name: "missing status property on external command output", + 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\"}"]}`) + return s + }, + expectedError: "exec plugin didn't return a status field", + }, + + { + name: "missing any auth credential on status", + 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\": {}}"]}`) + return s + }, + expectedError: "exec plugin didn't return a token or cert/key pair", + }, + + { + name: "has cert but no private key", + 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\"}}"]}`) + return s + }, + expectedError: "exec plugin returned only certificate or key, not both", + }, + + { + 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: CredentialTypeServiceAccountToken, + 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: CredentialTypeX509Certificate, + 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) + } + + cluster := &ClusterEndpoint{ + Type: ClusterEndpointTypeConst, + Const: &ClusterEndpointConst{ + Address: testEndpoint, + CABundle: []byte(testCAData), + }, + } + if tt.cluster != nil { + cluster = tt.cluster(cluster) + } + + got, err := buildCredentialFromExecConfig(secret, cluster) + 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/util/exec/exec.go b/pkg/util/exec/exec.go index 3cfa4b65..04ac5be5 100644 --- a/pkg/util/exec/exec.go +++ b/pkg/util/exec/exec.go @@ -2,6 +2,7 @@ package exec import ( "bytes" + "errors" "fmt" "os" "os/exec" @@ -34,7 +35,16 @@ func init() { } func GetToken(ec *clientcmdapi.ExecConfig, cluster *clientauthentication.Cluster) (*clientauthentication.ExecCredential, error) { - cmd := exec.Command(ec.Command, ec.Args...) + if ec.Command == "" { + return nil, errors.New("missing command key in ExecConfig 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 { @@ -46,7 +56,7 @@ func GetToken(ec *clientcmdapi.ExecConfig, cluster *clientauthentication.Cluster cmd.Stdout = &stdout if err := cmd.Run(); err != nil { - return nil, wrapCmdRunErrorLocked(cmd, err) + return nil, unwrapExecCommandError(command, err) } ecgv, err := schema.ParseGroupVersion(ec.APIVersion) @@ -94,14 +104,14 @@ func GetToken(ec *clientcmdapi.ExecConfig, cluster *clientauthentication.Cluster return cred, nil } -func wrapCmdRunErrorLocked(cmd *exec.Cmd, err error) error { +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", cmd.Path) + 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", cmd.Path, e.ProcessState.ExitCode()) + return fmt.Errorf("exec: executable %s failed with exit code %d", path, e.ProcessState.ExitCode()) default: return fmt.Errorf("exec: %v", err)