diff --git a/tools/auth/exec/exec.go b/tools/auth/exec/exec.go new file mode 100644 index 00000000..246de2ef --- /dev/null +++ b/tools/auth/exec/exec.go @@ -0,0 +1,110 @@ +/* +Copyright 2020 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package exec contains helper utilities for exec credential plugins. +package exec + +import ( + "errors" + "fmt" + "os" + + 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" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + "k8s.io/client-go/pkg/apis/clientauthentication" + "k8s.io/client-go/pkg/apis/clientauthentication/v1alpha1" + "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1" + "k8s.io/client-go/rest" +) + +const execInfoEnv = "KUBERNETES_EXEC_INFO" + +var scheme = runtime.NewScheme() +var codecs = serializer.NewCodecFactory(scheme) + +func init() { + metav1.AddToGroupVersion(scheme, schema.GroupVersion{Version: "v1"}) + utilruntime.Must(v1alpha1.AddToScheme(scheme)) + utilruntime.Must(v1beta1.AddToScheme(scheme)) + utilruntime.Must(clientauthentication.AddToScheme(scheme)) +} + +// LoadExecCredentialFromEnv is a helper-wrapper around LoadExecCredential that loads from the +// well-known KUBERNETES_EXEC_INFO environment variable. +// +// When the KUBERNETES_EXEC_INFO environment variable is not set or is empty, then this function +// will immediately return an error. +func LoadExecCredentialFromEnv() (runtime.Object, *rest.Config, error) { + env := os.Getenv(execInfoEnv) + if env == "" { + return nil, nil, errors.New("KUBERNETES_EXEC_INFO env var is unset or empty") + } + return LoadExecCredential([]byte(env)) +} + +// LoadExecCredential loads the configuration needed for an exec plugin to communicate with a +// cluster. +// +// LoadExecCredential expects the provided data to be a serialized client.authentication.k8s.io +// ExecCredential object (of any version). If the provided data is invalid (i.e., it cannot be +// unmarshalled into any known client.authentication.k8s.io ExecCredential version), an error will +// be returned. A successfully unmarshalled ExecCredential will be returned as the first return +// value. +// +// If the provided data is successfully unmarshalled, but it does not contain cluster information +// (i.e., ExecCredential.Spec.Cluster == nil), then the returned rest.Config and error will be nil. +// +// Note that the returned rest.Config will use anonymous authentication, since the exec plugin has +// not returned credentials for this cluster yet. +func LoadExecCredential(data []byte) (runtime.Object, *rest.Config, error) { + obj, gvk, err := codecs.UniversalDeserializer().Decode(data, nil, nil) + if err != nil { + return nil, nil, fmt.Errorf("decode: %w", err) + } + + expectedGK := schema.GroupKind{ + Group: clientauthentication.SchemeGroupVersion.Group, + Kind: "ExecCredential", + } + if gvk.GroupKind() != expectedGK { + return nil, nil, fmt.Errorf( + "invalid group/kind: wanted %s, got %s", + expectedGK.String(), + gvk.GroupKind().String(), + ) + } + + // Explicitly convert object here so that we can return a nicer error message above for when the + // data represents an invalid type. + var execCredential clientauthentication.ExecCredential + if err := scheme.Convert(obj, &execCredential, nil); err != nil { + return nil, nil, fmt.Errorf("cannot convert to ExecCredential: %w", err) + } + + if execCredential.Spec.Cluster == nil { + return nil, nil, errors.New("ExecCredential does not contain cluster information") + } + + restConfig, err := rest.ExecClusterToConfig(execCredential.Spec.Cluster) + if err != nil { + return nil, nil, fmt.Errorf("cannot create rest.Config: %w", err) + } + + return obj, restConfig, nil +} diff --git a/tools/auth/exec/exec_test.go b/tools/auth/exec/exec_test.go new file mode 100644 index 00000000..fd1b1b0b --- /dev/null +++ b/tools/auth/exec/exec_test.go @@ -0,0 +1,210 @@ +/* +Copyright 2020 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package exec + +import ( + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + clientauthenticationv1alpha1 "k8s.io/client-go/pkg/apis/clientauthentication/v1alpha1" + clientauthenticationv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1" + "k8s.io/client-go/rest" +) + +// restInfo holds the rest.Client fields that we care about for test assertions. +type restInfo struct { + host string + tlsClientConfig rest.TLSClientConfig + proxyURL string +} + +func TestLoadExecCredential(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + data []byte + wantExecCredential runtime.Object + wantRESTInfo restInfo + wantErrorPrefix string + }{ + { + name: "v1beta1 happy path", + data: marshal(t, clientauthenticationv1beta1.SchemeGroupVersion, &clientauthenticationv1beta1.ExecCredential{ + Spec: clientauthenticationv1beta1.ExecCredentialSpec{ + Cluster: &clientauthenticationv1beta1.Cluster{ + Server: "https://some-server/some/path", + TLSServerName: "some-server-name", + InsecureSkipTLSVerify: true, + CertificateAuthorityData: []byte("some-ca-data"), + ProxyURL: "https://some-proxy-url:12345", + Config: runtime.RawExtension{ + Raw: []byte(`{"apiVersion":"group/v1","kind":"PluginConfig","spec":{"names":["marshmallow","zelda"]}}`), + }, + }, + }, + }), + wantExecCredential: &clientauthenticationv1beta1.ExecCredential{ + TypeMeta: metav1.TypeMeta{ + Kind: "ExecCredential", + APIVersion: clientauthenticationv1beta1.SchemeGroupVersion.String(), + }, + Spec: clientauthenticationv1beta1.ExecCredentialSpec{ + Cluster: &clientauthenticationv1beta1.Cluster{ + Server: "https://some-server/some/path", + TLSServerName: "some-server-name", + InsecureSkipTLSVerify: true, + CertificateAuthorityData: []byte("some-ca-data"), + ProxyURL: "https://some-proxy-url:12345", + Config: runtime.RawExtension{ + Raw: []byte(`{"apiVersion":"group/v1","kind":"PluginConfig","spec":{"names":["marshmallow","zelda"]}}`), + }, + }, + }, + }, + wantRESTInfo: restInfo{ + host: "https://some-server/some/path", + tlsClientConfig: rest.TLSClientConfig{ + Insecure: true, + ServerName: "some-server-name", + CAData: []byte("some-ca-data"), + }, + proxyURL: "https://some-proxy-url:12345", + }, + }, + { + name: "v1beta1 nil config", + data: marshal(t, clientauthenticationv1beta1.SchemeGroupVersion, &clientauthenticationv1beta1.ExecCredential{ + Spec: clientauthenticationv1beta1.ExecCredentialSpec{ + Cluster: &clientauthenticationv1beta1.Cluster{ + Server: "https://some-server/some/path", + TLSServerName: "some-server-name", + InsecureSkipTLSVerify: true, + CertificateAuthorityData: []byte("some-ca-data"), + ProxyURL: "https://some-proxy-url:12345", + }, + }, + }), + wantExecCredential: &clientauthenticationv1beta1.ExecCredential{ + TypeMeta: metav1.TypeMeta{ + Kind: "ExecCredential", + APIVersion: clientauthenticationv1beta1.SchemeGroupVersion.String(), + }, + Spec: clientauthenticationv1beta1.ExecCredentialSpec{ + Cluster: &clientauthenticationv1beta1.Cluster{ + Server: "https://some-server/some/path", + TLSServerName: "some-server-name", + InsecureSkipTLSVerify: true, + CertificateAuthorityData: []byte("some-ca-data"), + ProxyURL: "https://some-proxy-url:12345", + }, + }, + }, + wantRESTInfo: restInfo{ + host: "https://some-server/some/path", + tlsClientConfig: rest.TLSClientConfig{ + Insecure: true, + ServerName: "some-server-name", + CAData: []byte("some-ca-data"), + }, + proxyURL: "https://some-proxy-url:12345", + }, + }, + { + name: "v1beta1 invalid cluster", + data: marshal(t, clientauthenticationv1beta1.SchemeGroupVersion, &clientauthenticationv1beta1.ExecCredential{ + Spec: clientauthenticationv1beta1.ExecCredentialSpec{ + Cluster: &clientauthenticationv1beta1.Cluster{ + ProxyURL: "invalid- url\n", + }, + }, + }), + wantErrorPrefix: "cannot create rest.Config", + }, + { + name: "v1beta1 nil cluster", + data: marshal(t, clientauthenticationv1beta1.SchemeGroupVersion, &clientauthenticationv1beta1.ExecCredential{}), + wantErrorPrefix: "ExecCredential does not contain cluster information", + }, + { + name: "v1alpha1", + data: marshal(t, clientauthenticationv1alpha1.SchemeGroupVersion, &clientauthenticationv1alpha1.ExecCredential{}), + wantErrorPrefix: "ExecCredential does not contain cluster information", + }, + { + name: "invalid object kind", + data: marshal(t, metav1.SchemeGroupVersion, &metav1.Status{}), + wantErrorPrefix: "invalid group/kind: wanted ExecCredential.client.authentication.k8s.io, got Status", + }, + { + name: "bad data", + data: []byte("bad data"), + wantErrorPrefix: "decode: ", + }, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + execCredential, restConfig, err := LoadExecCredential(test.data) + if test.wantErrorPrefix != "" { + if err == nil { + t.Error("wanted error, got success") + } else if !strings.HasPrefix(err.Error(), test.wantErrorPrefix) { + t.Errorf("wanted '%s', got '%s'", test.wantErrorPrefix, err.Error()) + } + } else if err != nil { + t.Error(err) + } else { + if diff := cmp.Diff(test.wantExecCredential, execCredential); diff != "" { + t.Error(diff) + } + + if diff := cmp.Diff(test.wantRESTInfo.host, restConfig.Host); diff != "" { + t.Error(diff) + } + if diff := cmp.Diff(test.wantRESTInfo.tlsClientConfig, restConfig.TLSClientConfig); diff != "" { + t.Error(diff) + } + + proxyURL, err := restConfig.Proxy(nil) + if err != nil { + t.Fatal(err) + } + if diff := cmp.Diff(test.wantRESTInfo.proxyURL, proxyURL.String()); diff != "" { + t.Error(diff) + } + } + }) + } +} + +func marshal(t *testing.T, gv schema.GroupVersion, obj runtime.Object) []byte { + t.Helper() + + data, err := runtime.Encode(codecs.LegacyCodec(gv), obj) + if err != nil { + t.Fatal(err) + } + + return data +} diff --git a/tools/auth/exec/types_test.go b/tools/auth/exec/types_test.go new file mode 100644 index 00000000..e4b95a34 --- /dev/null +++ b/tools/auth/exec/types_test.go @@ -0,0 +1,151 @@ +/* +Copyright 2020 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package exec + +import ( + "reflect" + "testing" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/sets" + clientauthenticationv1alpha1 "k8s.io/client-go/pkg/apis/clientauthentication/v1alpha1" + clientauthenticationv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1" + clientcmdv1 "k8s.io/client-go/tools/clientcmd/api/v1" +) + +// TestV1beta1ClusterTypesAreSynced ensures that clientauthenticationv1beta1.Cluster stays in sync +// with clientcmdv1.Cluster. +// +// We want clientauthenticationv1beta1.Cluster to offer the same knobs as clientcmdv1.Cluster to +// allow someone to connect to the kubernetes API. This test should fail if a new field is added to +// one of the structs without updating the other. +func TestV1beta1ClusterTypesAreSynced(t *testing.T) { + t.Parallel() + + execType := reflect.TypeOf(clientauthenticationv1beta1.Cluster{}) + clientcmdType := reflect.TypeOf(clientcmdv1.Cluster{}) + + t.Run("exec cluster fields match clientcmd cluster fields", func(t *testing.T) { + t.Parallel() + + // These are fields that are specific to Cluster and shouldn't be in clientcmdv1.Cluster. + execSkippedFieldNames := sets.NewString( + // Cluster uses Config to provide its cluster-specific configuration object. + "Config", + ) + + for i := 0; i < execType.NumField(); i++ { + execField := execType.Field(i) + if execSkippedFieldNames.Has(execField.Name) { + continue + } + + t.Run(execField.Name, func(t *testing.T) { + t.Parallel() + clientcmdField, ok := clientcmdType.FieldByName(execField.Name) + if !ok { + t.Errorf("unknown field (please add field to clientcmdv1.Cluster): '%s'", execField.Name) + } else if execField.Type != clientcmdField.Type { + t.Errorf( + "type mismatch (please update Cluster.%s field type to match clientcmdv1.Cluster.%s field type): %q != %q", + execField.Name, + clientcmdField.Name, + execField.Type, + clientcmdField.Type, + ) + } else if execField.Tag != clientcmdField.Tag { + t.Errorf( + "tag mismatch (please update Cluster.%s tag to match clientcmdv1.Cluster.%s tag): %q != %q", + execField.Name, + clientcmdField.Name, + execField.Tag, + clientcmdField.Tag, + ) + } + }) + } + }) + + t.Run("clientcmd cluster fields match exec cluster fields", func(t *testing.T) { + t.Parallel() + + // These are the fields that we don't want to shadow from clientcmdv1.Cluster. + clientcmdSkippedFieldNames := sets.NewString( + // CA data will be passed via CertificateAuthorityData, so we don't need this field. + "CertificateAuthority", + // Cluster uses Config to provide its cluster-specific configuration object. + "Extensions", + ) + + for i := 0; i < clientcmdType.NumField(); i++ { + clientcmdField := clientcmdType.Field(i) + if clientcmdSkippedFieldNames.Has(clientcmdField.Name) { + continue + } + + t.Run(clientcmdField.Name, func(t *testing.T) { + t.Parallel() + execField, ok := execType.FieldByName(clientcmdField.Name) + if !ok { + t.Errorf("unknown field (please add field to Cluster): '%s'", clientcmdField.Name) + } else if clientcmdField.Type != execField.Type { + t.Errorf( + "type mismatch (please update clientcmdv1.Cluster.%s field type to match Cluster.%s field type): %q != %q", + clientcmdField.Name, + execField.Name, + clientcmdField.Type, + execField.Type, + ) + } else if clientcmdField.Tag != execField.Tag { + t.Errorf( + "tag mismatch (please update clientcmdv1.Cluster.%s tag to match Cluster.%s tag): %q != %q", + clientcmdField.Name, + execField.Name, + clientcmdField.Tag, + execField.Tag, + ) + } + }) + } + }) +} + +// TestAllClusterTypesAreSynced is a TODO so that we remember to write a test similar to +// TestV1beta1ClusterTypesAreSynced for any future ExecCredential version. It should start failing +// when someone adds support for any other ExecCredential type to this package. +func TestAllClusterTypesAreSynced(t *testing.T) { + versionsThatDontNeedTests := sets.NewString( + // The internal Cluster type should only be used...internally...and therefore doesn't + // necessarily need to be synced with clientcmdv1. + runtime.APIVersionInternal, + // V1alpha1 does not contain a Cluster type. + clientauthenticationv1alpha1.SchemeGroupVersion.Version, + // We have a test for v1beta1 above. + clientauthenticationv1beta1.SchemeGroupVersion.Version, + ) + for gvk := range scheme.AllKnownTypes() { + if gvk.Group == clientauthenticationv1beta1.SchemeGroupVersion.Group && + gvk.Kind == "ExecCredential" { + if !versionsThatDontNeedTests.Has(gvk.Version) { + t.Errorf( + "TODO: add test similar to TestV1beta1ClusterTypesAreSynced for client.authentication.k8s.io/%s", + gvk.Version, + ) + } + } + } +}