crossplane-runtime/pkg/connection/store/kubernetes/store_test.go

898 lines
24 KiB
Go

/*
Copyright 2022 The Crossplane 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 kubernetes
import (
"context"
"testing"
"github.com/google/go-cmp/cmp"
corev1 "k8s.io/api/core/v1"
kerrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
v1 "github.com/crossplane/crossplane-runtime/apis/common/v1"
"github.com/crossplane/crossplane-runtime/pkg/connection/store"
"github.com/crossplane/crossplane-runtime/pkg/errors"
"github.com/crossplane/crossplane-runtime/pkg/resource"
"github.com/crossplane/crossplane-runtime/pkg/test"
)
var (
errBoom = errors.New("boom")
fakeSecretName = "fake"
fakeSecretNamespace = "fake-namespace"
fakeOwnerID = "00000000-0000-0000-0000-000000000000"
storeTypeKubernetes = v1.SecretStoreKubernetes
)
func fakeKV() map[string][]byte {
return map[string][]byte{
"key1": []byte("value1"),
"key2": []byte("value2"),
"key3": []byte("value3"),
}
}
func fakeLabels() map[string]string {
return map[string]string{
"environment": "unit-test",
"reason": "testing",
}
}
func fakeAnnotations() map[string]string {
return map[string]string{
"some-annotation-key": "some-annotation-value",
}
}
func TestSecretStoreReadKeyValues(t *testing.T) {
type args struct {
client resource.ClientApplicator
n store.ScopedName
}
type want struct {
result store.KeyValues
err error
}
cases := map[string]struct {
reason string
args
want
}{
"CannotGetSecret": {
reason: "Should return a proper error if cannot get the secret",
args: args{
client: resource.ClientApplicator{
Client: &test.MockClient{
MockGet: test.NewMockGetFn(errBoom),
},
},
n: store.ScopedName{
Name: fakeSecretName,
},
},
want: want{
err: errors.Wrap(errBoom, errGetSecret),
},
},
"SuccessfulRead": {
reason: "Should return all key values after a success read",
args: args{
client: resource.ClientApplicator{
Client: &test.MockClient{
MockGet: func(_ context.Context, key client.ObjectKey, obj client.Object) error {
if key.Name != fakeSecretName || key.Namespace != fakeSecretNamespace {
return errors.New("unexpected secret name or namespace to get the secret")
}
*obj.(*corev1.Secret) = corev1.Secret{
Data: fakeKV(),
}
return nil
},
},
},
n: store.ScopedName{
Name: fakeSecretName,
Scope: fakeSecretNamespace,
},
},
want: want{
result: store.KeyValues(fakeKV()),
},
},
"SecretNotFound": {
reason: "Should return nil as an error if secret is not found",
args: args{
client: resource.ClientApplicator{
Client: &test.MockClient{
MockGet: test.NewMockGetFn(kerrors.NewNotFound(schema.GroupResource{}, "")),
},
},
},
want: want{
err: nil,
},
},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
ss := &SecretStore{
client: tc.args.client,
}
s := &store.Secret{}
s.ScopedName = tc.args.n
err := ss.ReadKeyValues(context.Background(), tc.args.n, s)
if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" {
t.Errorf("\n%s\nss.ReadKeyValues(...): -want error, +got error:\n%s", tc.reason, diff)
}
if diff := cmp.Diff(tc.want.result, s.Data); diff != "" {
t.Errorf("\n%s\nss.ReadKeyValues(...): -want, +got:\n%s", tc.reason, diff)
}
})
}
}
func TestSecretStoreWriteKeyValues(t *testing.T) {
secretTypeOpaque := corev1.SecretTypeOpaque
type args struct {
client resource.ClientApplicator
defaultNamespace string
secret *store.Secret
wo []store.WriteOption
}
type want struct {
changed bool
err error
}
cases := map[string]struct {
reason string
args
want
}{
"ApplyFailed": {
reason: "Should return a proper error when cannot apply.",
args: args{
client: resource.ClientApplicator{
Applicator: resource.ApplyFn(func(_ context.Context, _ client.Object, _ ...resource.ApplyOption) error {
return errBoom
}),
},
secret: &store.Secret{
ScopedName: store.ScopedName{
Name: fakeSecretName,
Scope: fakeSecretNamespace,
},
Data: store.KeyValues(fakeKV()),
},
},
want: want{
err: errors.Wrap(errBoom, errApplySecret),
},
},
"FailedWriteOption": {
reason: "Should return a proper error if supplied write option fails",
args: args{
client: resource.ClientApplicator{
Applicator: resource.ApplyFn(func(ctx context.Context, obj client.Object, option ...resource.ApplyOption) error {
for _, fn := range option {
if err := fn(ctx, fakeConnectionSecret(withData(fakeKV())), obj); err != nil {
return err
}
}
return nil
}),
},
secret: &store.Secret{
ScopedName: store.ScopedName{
Name: fakeSecretName,
Scope: fakeSecretNamespace,
},
Data: store.KeyValues(fakeKV()),
},
wo: []store.WriteOption{
func(_ context.Context, _, _ *store.Secret) error {
return errBoom
},
},
},
want: want{
err: errors.Wrap(errBoom, errApplySecret),
},
},
"SuccessfulWriteOption": {
reason: "Should return a proper error if supplied write option fails",
args: args{
client: resource.ClientApplicator{
Applicator: resource.ApplyFn(func(ctx context.Context, obj client.Object, option ...resource.ApplyOption) error {
for _, fn := range option {
if err := fn(ctx, fakeConnectionSecret(withData(fakeKV())), obj); err != nil {
return err
}
}
return nil
}),
},
secret: &store.Secret{
ScopedName: store.ScopedName{
Name: fakeSecretName,
Scope: fakeSecretNamespace,
},
Data: store.KeyValues(fakeKV()),
},
wo: []store.WriteOption{
func(_ context.Context, _, desired *store.Secret) error {
desired.Data["customkey"] = []byte("customval")
desired.Metadata = &v1.ConnectionSecretMetadata{
Labels: map[string]string{
"foo": "baz",
},
}
return nil
},
},
},
want: want{
changed: true,
},
},
"SecretAlreadyUpToDate": {
reason: "Should not change secret if already up to date.",
args: args{
client: resource.ClientApplicator{
Applicator: resource.ApplyFn(func(ctx context.Context, obj client.Object, option ...resource.ApplyOption) error {
for _, fn := range option {
if err := fn(ctx, fakeConnectionSecret(withData(fakeKV())), obj); err != nil {
return err
}
}
return nil
}),
},
secret: &store.Secret{
ScopedName: store.ScopedName{
Name: fakeSecretName,
Scope: fakeSecretNamespace,
},
Data: store.KeyValues(fakeKV()),
},
},
},
"SecretUpdatedWithNewValue": {
reason: "Should update value for an existing key if changed.",
args: args{
client: resource.ClientApplicator{
Applicator: resource.ApplyFn(func(ctx context.Context, obj client.Object, option ...resource.ApplyOption) error {
if diff := cmp.Diff(fakeConnectionSecret(withData(map[string][]byte{
"existing-key": []byte("new-value"),
})), obj.(*corev1.Secret)); diff != "" {
t.Errorf("r: -want, +got:\n%s", diff)
}
for _, fn := range option {
if err := fn(ctx, fakeConnectionSecret(withData(map[string][]byte{
"existing-key": []byte("old-value"),
})), obj); err != nil {
return err
}
}
return nil
}),
},
secret: &store.Secret{
ScopedName: store.ScopedName{
Name: fakeSecretName,
Scope: fakeSecretNamespace,
},
Data: store.KeyValues(map[string][]byte{
"existing-key": []byte("new-value"),
}),
},
},
want: want{
changed: true,
},
},
"SecretHasExpectedOwner": {
reason: "Should correctly check the owner of the secret.",
args: args{
client: resource.ClientApplicator{
Applicator: resource.ApplyFn(func(ctx context.Context, obj client.Object, option ...resource.ApplyOption) error {
if diff := cmp.Diff(fakeConnectionSecret(withData(map[string][]byte{
"existing-key": []byte("new-value"),
})), obj.(*corev1.Secret)); diff != "" {
t.Errorf("r: -want, +got:\n%s", diff)
}
for _, fn := range option {
if err := fn(ctx, fakeConnectionSecret(withData(map[string][]byte{
"existing-key": []byte("old-value"),
}), withOwnerID(fakeOwnerID)), obj); err != nil {
return err
}
}
return nil
}),
},
secret: &store.Secret{
ScopedName: store.ScopedName{
Name: fakeSecretName,
Scope: fakeSecretNamespace,
},
Data: store.KeyValues(map[string][]byte{
"existing-key": []byte("new-value"),
}),
},
wo: []store.WriteOption{func(_ context.Context, current, _ *store.Secret) error {
if current.Metadata == nil || current.Metadata.GetOwnerUID() != fakeOwnerID {
return errors.Errorf("secret not owned by %s", fakeOwnerID)
}
return nil
}},
},
want: want{
changed: true,
},
},
"SecretUpdatedWithNewKey": {
reason: "Should update existing secret additively if a new key added",
args: args{
client: resource.ClientApplicator{
Applicator: resource.ApplyFn(func(ctx context.Context, obj client.Object, option ...resource.ApplyOption) error {
if diff := cmp.Diff(fakeConnectionSecret(withData(map[string][]byte{
"new-key": []byte("new-value"),
})), obj.(*corev1.Secret)); diff != "" {
t.Errorf("r: -want, +got:\n%s", diff)
}
for _, fn := range option {
if err := fn(ctx, fakeConnectionSecret(withData(map[string][]byte{
"existing-key": []byte("existing-value"),
})), obj); err != nil {
return err
}
}
return nil
}),
},
secret: &store.Secret{
ScopedName: store.ScopedName{
Name: fakeSecretName,
Scope: fakeSecretNamespace,
},
Data: store.KeyValues(map[string][]byte{
"new-key": []byte("new-value"),
}),
},
},
want: want{
changed: true,
},
},
"SecretCreatedWithData": {
reason: "Should create a secret with all key values with default type.",
args: args{
client: resource.ClientApplicator{
Applicator: resource.ApplyFn(func(ctx context.Context, obj client.Object, option ...resource.ApplyOption) error {
if diff := cmp.Diff(fakeConnectionSecret(withData(fakeKV())), obj.(*corev1.Secret)); diff != "" {
t.Errorf("r: -want, +got:\n%s", diff)
}
for _, fn := range option {
if err := fn(ctx, &corev1.Secret{}, obj); err != nil {
return err
}
}
return nil
}),
},
secret: &store.Secret{
ScopedName: store.ScopedName{
Name: fakeSecretName,
Scope: fakeSecretNamespace,
},
Data: store.KeyValues(fakeKV()),
},
},
want: want{
changed: true,
},
},
"SecretCreatedWithDataAndMetadata": {
reason: "Should create a secret with all key values and provided metadata data.",
args: args{
client: resource.ClientApplicator{
Applicator: resource.ApplyFn(func(ctx context.Context, obj client.Object, option ...resource.ApplyOption) error {
if diff := cmp.Diff(fakeConnectionSecret(
withData(fakeKV()),
withType(corev1.SecretTypeOpaque),
withLabels(fakeLabels()),
withAnnotations(fakeAnnotations())), obj.(*corev1.Secret)); diff != "" {
t.Errorf("r: -want, +got:\n%s", diff)
}
for _, fn := range option {
if err := fn(ctx, &corev1.Secret{}, obj); err != nil {
return err
}
}
return nil
}),
},
secret: &store.Secret{
ScopedName: store.ScopedName{
Name: fakeSecretName,
Scope: fakeSecretNamespace,
},
Metadata: &v1.ConnectionSecretMetadata{
Labels: map[string]string{
"environment": "unit-test",
"reason": "testing",
},
Annotations: map[string]string{
"some-annotation-key": "some-annotation-value",
},
Type: &secretTypeOpaque,
},
Data: store.KeyValues(fakeKV()),
},
},
want: want{
changed: true,
},
},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
ss := &SecretStore{
client: tc.args.client,
defaultNamespace: tc.args.defaultNamespace,
}
changed, err := ss.WriteKeyValues(context.Background(), tc.args.secret, tc.args.wo...)
if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" {
t.Errorf("\n%s\nss.WriteKeyValues(...): -want error, +got error:\n%s", tc.reason, diff)
}
if diff := cmp.Diff(tc.want.changed, changed); diff != "" {
t.Errorf("\n%s\nss.WriteKeyValues(...): -want changed, +got changed:\n%s", tc.reason, diff)
}
})
}
}
func TestSecretStoreDeleteKeyValues(t *testing.T) {
type args struct {
client resource.ClientApplicator
defaultNamespace string
secret *store.Secret
do []store.DeleteOption
}
type want struct {
err error
}
cases := map[string]struct {
reason string
args
want
}{
"CannotGetSecret": {
reason: "Should return a proper error when it fails to get secret.",
args: args{
client: resource.ClientApplicator{
Client: &test.MockClient{
MockGet: test.NewMockGetFn(errBoom),
},
},
secret: &store.Secret{
ScopedName: store.ScopedName{
Name: fakeSecretName,
Scope: fakeSecretNamespace,
},
},
},
want: want{
err: errors.Wrap(errBoom, errGetSecret),
},
},
"SecretUpdatedWithRemainingKeys": {
reason: "Should remove supplied keys from secret and update with remaining.",
args: args{
client: resource.ClientApplicator{
Client: &test.MockClient{
MockGet: test.NewMockGetFn(nil, func(obj client.Object) error {
*obj.(*corev1.Secret) = *fakeConnectionSecret(withData(fakeKV()))
return nil
}),
MockUpdate: func(_ context.Context, obj client.Object, _ ...client.UpdateOption) error {
if diff := cmp.Diff(fakeConnectionSecret(withData(map[string][]byte{"key3": []byte("value3")})), obj.(*corev1.Secret)); diff != "" {
t.Errorf("r: -want, +got:\n%s", diff)
}
return nil
},
},
},
secret: &store.Secret{
ScopedName: store.ScopedName{
Name: fakeSecretName,
Scope: fakeSecretNamespace,
},
Data: store.KeyValues(map[string][]byte{
"key1": []byte("value1"),
"key2": []byte("value2"),
}),
},
},
want: want{
err: nil,
},
},
"CannotDeleteSecret": {
reason: "Should return a proper error when it fails to delete secret.",
args: args{
client: resource.ClientApplicator{
Client: &test.MockClient{
MockGet: test.NewMockGetFn(nil, func(obj client.Object) error {
*obj.(*corev1.Secret) = *fakeConnectionSecret()
return nil
}),
MockDelete: test.NewMockDeleteFn(errBoom),
},
},
secret: &store.Secret{
ScopedName: store.ScopedName{
Name: fakeSecretName,
Scope: fakeSecretNamespace,
},
},
},
want: want{
err: errors.Wrap(errBoom, errDeleteSecret),
},
},
"SecretAlreadyDeleted": {
reason: "Should not return error if secret already deleted.",
args: args{
client: resource.ClientApplicator{
Client: &test.MockClient{
MockGet: test.NewMockGetFn(nil, func(_ client.Object) error {
return kerrors.NewNotFound(schema.GroupResource{}, "")
}),
},
},
secret: &store.Secret{
ScopedName: store.ScopedName{
Name: fakeSecretName,
Scope: fakeSecretNamespace,
},
},
},
want: want{
err: nil,
},
},
"FailedDeleteOption": {
reason: "Should return a proper error if provided delete option fails.",
args: args{
client: resource.ClientApplicator{
Client: &test.MockClient{
MockGet: test.NewMockGetFn(nil, func(obj client.Object) error {
*obj.(*corev1.Secret) = *fakeConnectionSecret(withData(fakeKV()))
return nil
}),
MockDelete: func(_ context.Context, _ client.Object, _ ...client.DeleteOption) error {
return nil
},
},
},
secret: &store.Secret{
ScopedName: store.ScopedName{
Name: fakeSecretName,
Scope: fakeSecretNamespace,
},
},
do: []store.DeleteOption{
func(_ context.Context, _ *store.Secret) error {
return errBoom
},
},
},
want: want{
err: errBoom,
},
},
"SecretDeletedNoKVSupplied": {
reason: "Should delete the whole secret if no kv supplied as parameter.",
args: args{
client: resource.ClientApplicator{
Client: &test.MockClient{
MockGet: test.NewMockGetFn(nil, func(obj client.Object) error {
*obj.(*corev1.Secret) = *fakeConnectionSecret(withData(fakeKV()))
return nil
}),
MockDelete: func(_ context.Context, _ client.Object, _ ...client.DeleteOption) error {
return nil
},
},
},
secret: &store.Secret{
ScopedName: store.ScopedName{
Name: fakeSecretName,
Scope: fakeSecretNamespace,
},
},
do: []store.DeleteOption{
func(_ context.Context, _ *store.Secret) error {
return nil
},
},
},
want: want{
err: nil,
},
},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
ss := &SecretStore{
client: tc.args.client,
defaultNamespace: tc.args.defaultNamespace,
}
err := ss.DeleteKeyValues(context.Background(), tc.args.secret, tc.args.do...)
if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" {
t.Errorf("\n%s\nss.DeleteKeyValues(...): -want error, +got error:\n%s", tc.reason, diff)
}
})
}
}
func TestNewSecretStore(t *testing.T) {
type args struct {
client resource.ClientApplicator
cfg v1.SecretStoreConfig
}
type want struct {
err error
}
cases := map[string]struct {
reason string
args
want
}{
"SuccessfulLocal": {
reason: "Should return no error after successfully building local Kubernetes secret store",
args: args{
client: resource.ClientApplicator{},
cfg: v1.SecretStoreConfig{
Type: &storeTypeKubernetes,
DefaultScope: "test-ns",
},
},
want: want{
err: nil,
},
},
"NoSecretWithRemoteKubeconfig": {
reason: "Should fail properly if configured kubeconfig secret does not exist",
args: args{
client: resource.ClientApplicator{
Client: &test.MockClient{
MockGet: test.NewMockGetFn(nil, func(_ client.Object) error {
return kerrors.NewNotFound(schema.GroupResource{}, "kube-conn")
}),
},
},
cfg: v1.SecretStoreConfig{
Type: &storeTypeKubernetes,
DefaultScope: "test-ns",
Kubernetes: &v1.KubernetesSecretStoreConfig{
Auth: v1.KubernetesAuthConfig{
Source: v1.CredentialsSourceSecret,
CommonCredentialSelectors: v1.CommonCredentialSelectors{
SecretRef: &v1.SecretKeySelector{
SecretReference: v1.SecretReference{
Name: "kube-conn",
Namespace: "test-ns",
},
Key: "kubeconfig",
},
},
},
},
},
},
want: want{
err: errors.Wrap(errors.Wrap(errors.Wrap(kerrors.NewNotFound(schema.GroupResource{}, "kube-conn"), "cannot get credentials secret"), errExtractKubernetesAuthCreds), errBuildClient),
},
},
"InvalidRestConfigForRemote": {
reason: "Should fetch the configured kubeconfig and fail if it is not valid",
args: args{
client: resource.ClientApplicator{
Client: &test.MockClient{
MockGet: test.NewMockGetFn(nil, func(obj client.Object) error {
*obj.(*corev1.Secret) = corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "kube-conn",
Namespace: "test-ns",
},
Data: map[string][]byte{
"kubeconfig": []byte(`
apiVersion: v1
kind: Config
malformed
`),
},
}
return nil
}),
},
},
cfg: v1.SecretStoreConfig{
Type: &storeTypeKubernetes,
DefaultScope: "test-ns",
Kubernetes: &v1.KubernetesSecretStoreConfig{
Auth: v1.KubernetesAuthConfig{
Source: v1.CredentialsSourceSecret,
CommonCredentialSelectors: v1.CommonCredentialSelectors{
SecretRef: &v1.SecretKeySelector{
SecretReference: v1.SecretReference{
Name: "kube-conn",
Namespace: "test-ns",
},
Key: "kubeconfig",
},
},
},
},
},
},
want: want{
err: errors.Wrap(errors.Wrap(errors.New("yaml: line 5: could not find expected ':'"), errBuildRestConfig), errBuildClient),
},
},
"InvalidKubeconfigForRemote": {
reason: "Should fetch the configured kubeconfig and fail if it is not valid",
args: args{
client: resource.ClientApplicator{
Client: &test.MockClient{
MockGet: test.NewMockGetFn(nil, func(obj client.Object) error {
*obj.(*corev1.Secret) = corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "kube-conn",
Namespace: "test-ns",
},
Data: map[string][]byte{
"kubeconfig": []byte(`
apiVersion: v1
clusters:
- cluster:
certificate-authority-data: TEST
server: https://127.0.0.1:64695
name: kind-kind
contexts:
- context:
cluster: kind-kind
namespace: crossplane-system
user: kind-kind
name: kind-kind
current-context: kind-kind
kind: Config
users:
- name: kind-kind
user: {}
`),
},
}
return nil
}),
},
},
cfg: v1.SecretStoreConfig{
Type: &storeTypeKubernetes,
DefaultScope: "test-ns",
Kubernetes: &v1.KubernetesSecretStoreConfig{
Auth: v1.KubernetesAuthConfig{
Source: v1.CredentialsSourceSecret,
CommonCredentialSelectors: v1.CommonCredentialSelectors{
SecretRef: &v1.SecretKeySelector{
SecretReference: v1.SecretReference{
Name: "kube-conn",
Namespace: "test-ns",
},
Key: "kubeconfig",
},
},
},
},
},
},
want: want{
err: errors.Wrap(errors.New("unable to load root certificates: unable to parse bytes as PEM block"), errBuildClient),
},
},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
_, err := NewSecretStore(context.Background(), tc.args.client, nil, tc.args.cfg)
if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" {
t.Errorf("\n%s\nNewSecretStore(...): -want error, +got error:\n%s", tc.reason, diff)
}
})
}
}
type secretOption func(*corev1.Secret)
func withType(t corev1.SecretType) secretOption {
return func(s *corev1.Secret) {
s.Type = t
}
}
func withData(d map[string][]byte) secretOption {
return func(s *corev1.Secret) {
s.Data = d
}
}
func withLabels(l map[string]string) secretOption {
return func(s *corev1.Secret) {
s.Labels = l
}
}
func withAnnotations(a map[string]string) secretOption {
return func(s *corev1.Secret) {
s.Annotations = a
}
}
func withOwnerID(id string) secretOption {
return func(s *corev1.Secret) {
s.SetOwnerReferences([]metav1.OwnerReference{
{
UID: types.UID(id),
},
})
}
}
func fakeConnectionSecret(opts ...secretOption) *corev1.Secret {
s := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: fakeSecretName,
Namespace: fakeSecretNamespace,
},
Type: resource.SecretTypeConnection,
}
for _, o := range opts {
o(s)
}
return s
}