exec credential provider: k8s.io/client-go/tools/auth/exec helper

Exec plugin implementations should be able to call
LoadExecCredentialFromEnv() in order to get everything they need to
operate (i.e., cluster information (as long as it is passed in) and
optionally per-cluster configuration).

Signed-off-by: Andrew Keesler <akeesler@vmware.com>

Kubernetes-commit: 875a46bd7c1b79f1fae9cd189eec5fc9c3fbf1bc
This commit is contained in:
Andrew Keesler 2020-10-29 13:38:50 -04:00 committed by Kubernetes Publisher
parent a7ba87c612
commit 405010f17b
3 changed files with 471 additions and 0 deletions

110
tools/auth/exec/exec.go Normal file
View File

@ -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
}

View File

@ -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
}

View File

@ -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,
)
}
}
}
}