Merge pull request #321 from turkenh/ess-foundation

Add connection package for External Secret Store support
This commit is contained in:
Nic Cope 2022-03-02 09:08:05 -08:00 committed by GitHub
commit 3232ffa5ef
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 1978 additions and 12 deletions

View File

@ -57,16 +57,6 @@ cobertura:
grep -v zz_generated.deepcopy | \
$(GOCOVER_COBERTURA) > $(GO_TEST_OUTPUT)/cobertura-coverage.xml
# Ensure a PR is ready for review.
reviewable: generate lint
@go mod tidy
# Ensure branch is clean.
check-diff: reviewable
@$(INFO) checking that branch is clean
@git diff --quiet || $(FAIL)
@$(OK) branch is clean
# Update the submodules, such as the common build scripts.
submodules:
@git submodule sync

View File

@ -0,0 +1,182 @@
/*
Copyright 2019 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 v1
import (
corev1 "k8s.io/api/core/v1"
)
// PublishConnectionDetailsTo represents configuration of a connection secret.
type PublishConnectionDetailsTo struct {
// Name is the name of the connection secret.
Name string `json:"name"`
// Metadata is the metadata for connection secret.
// +optional
Metadata *ConnectionSecretMetadata `json:"metadata,omitempty"`
// SecretStoreConfigRef specifies which secret store config should be used
// for this ConnectionSecret.
// +optional
// +kubebuilder:default={"name": "default"}
SecretStoreConfigRef *Reference `json:"configRef,omitempty"`
}
// ConnectionSecretMetadata represents metadata of a connection secret.
type ConnectionSecretMetadata struct {
// Labels are the labels/tags to be added to connection secret.
// - For Kubernetes secrets, this will be used as "metadata.labels".
// - It is up to Secret Store implementation for others store types.
// +optional
Labels map[string]string `json:"labels,omitempty"`
// Annotations are the annotations to be added to connection secret.
// - For Kubernetes secrets, this will be used as "metadata.annotations".
// - It is up to Secret Store implementation for others store types.
// +optional
Annotations map[string]string `json:"annotations,omitempty"`
// Type is the SecretType for the connection secret.
// - Only valid for Kubernetes Secret Stores.
// +optional
Type *corev1.SecretType `json:"type,omitempty"`
}
// SecretStoreType represents a secret store type.
type SecretStoreType string
const (
// SecretStoreKubernetes indicates that secret store type is
// Kubernetes. In other words, connection secrets will be stored as K8s
// Secrets.
SecretStoreKubernetes SecretStoreType = "Kubernetes"
// SecretStoreVault indicates that secret store type is Vault.
SecretStoreVault SecretStoreType = "Vault"
)
// SecretStoreConfig represents configuration of a Secret Store.
type SecretStoreConfig struct {
// Type configures which secret store to be used. Only the configuration
// block for this store will be used and others will be ignored if provided.
// Default is Kubernetes.
// +optional
// +kubebuilder:default=Kubernetes
Type *SecretStoreType `json:"type,omitempty"`
// DefaultScope used for scoping secrets for "cluster-scoped" resources.
// If store type is "Kubernetes", this would mean the default namespace to
// store connection secrets for cluster scoped resources.
// In case of "Vault", this would be used as the default parent path.
// Typically, should be set as Crossplane installation namespace.
DefaultScope string `json:"defaultScope"`
// Kubernetes configures a Kubernetes secret store.
// If the "type" is "Kubernetes" but no config provided, in cluster config
// will be used.
// +optional
Kubernetes *KubernetesSecretStoreConfig `json:"kubernetes,omitempty"`
// Vault configures a Vault secret store.
// +optional
Vault *VaultSecretStoreConfig `json:"vault,omitempty"`
}
// KubernetesAuthConfig required to authenticate to a K8s API. It expects
// a "kubeconfig" file to be provided.
type KubernetesAuthConfig struct {
// Source of the credentials.
// +kubebuilder:validation:Enum=None;Secret;Environment;Filesystem
Source CredentialsSource `json:"source"`
// CommonCredentialSelectors provides common selectors for extracting
// credentials.
CommonCredentialSelectors `json:",inline"`
}
// KubernetesSecretStoreConfig represents the required configuration
// for a Kubernetes secret store.
type KubernetesSecretStoreConfig struct {
// Credentials used to connect to the Kubernetes API.
Auth KubernetesAuthConfig `json:"auth"`
// TODO(turkenh): Support additional identities like
// https://github.com/crossplane-contrib/provider-kubernetes/blob/4d722ef914e6964e80e190317daca9872ae98738/apis/v1alpha1/types.go#L34
}
// VaultAuthMethod represent a Vault authentication method.
// https://www.vaultproject.io/docs/auth
type VaultAuthMethod string
const (
// VaultAuthKubernetes indicates that "Kubernetes Auth" will be used to
// authenticate to Vault.
// https://www.vaultproject.io/docs/auth/kubernetes
VaultAuthKubernetes VaultAuthMethod = "Kubernetes"
// VaultAuthToken indicates that "Token Auth" will be used to
// authenticate to Vault.
// https://www.vaultproject.io/docs/auth/token
VaultAuthToken VaultAuthMethod = "Token"
)
// VaultAuthKubernetesConfig represents configuration for Vault Kubernetes Auth
// Method.
// https://www.vaultproject.io/docs/auth
type VaultAuthKubernetesConfig struct {
// MountPath configures path where the Kubernetes authentication backend is
// mounted in Vault.
MountPath string `json:"mountPath"`
// Role configures the Vault Role to assume.
Role string `json:"role"`
}
// VaultAuthConfig required to authenticate to a Vault API.
type VaultAuthConfig struct {
// Method configures which auth method will be used.
Method VaultAuthMethod `json:"method"`
// Kubernetes configures Kubernetes Auth for Vault.
// +optional
Kubernetes *VaultAuthKubernetesConfig `json:"kubernetes,omitempty"`
}
// VaultSecretStoreConfig represents the required configuration for a Vault
// secret store.
type VaultSecretStoreConfig struct {
// Server is the url of the Vault server, e.g. "https://vault.acme.org"
Server string `json:"server"`
// ParentPath is the path to be prepended to all secrets.
ParentPath string `json:"parentPath"`
// Version of the KV Secrets engine of Vault.
// https://www.vaultproject.io/docs/secrets/kv
// +optional
// +kubebuilder:default=v2
Version *string `json:"version,omitempty"`
// CABundle is base64 encoded string of Vaults CA certificate.
// +optional
CABundle *string `json:"caBundle,omitempty"`
// CABundleSecretRef is a reference to a K8s secret key with Vaults CA
// certificate.
// +optional
CABundleSecretRef *SecretKeySelector `json:"caBundleSecretRef,omitempty"`
// Auth configures an authentication method for Vault.
Auth VaultAuthConfig `json:"auth"`
}

View File

@ -93,6 +93,40 @@ func (in *ConditionedStatus) DeepCopy() *ConditionedStatus {
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ConnectionSecretMetadata) DeepCopyInto(out *ConnectionSecretMetadata) {
*out = *in
if in.Labels != nil {
in, out := &in.Labels, &out.Labels
*out = make(map[string]string, len(*in))
for key, val := range *in {
(*out)[key] = val
}
}
if in.Annotations != nil {
in, out := &in.Annotations, &out.Annotations
*out = make(map[string]string, len(*in))
for key, val := range *in {
(*out)[key] = val
}
}
if in.Type != nil {
in, out := &in.Type, &out.Type
*out = new(corev1.SecretType)
**out = **in
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ConnectionSecretMetadata.
func (in *ConnectionSecretMetadata) DeepCopy() *ConnectionSecretMetadata {
if in == nil {
return nil
}
out := new(ConnectionSecretMetadata)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *EnvSelector) DeepCopyInto(out *EnvSelector) {
*out = *in
@ -123,6 +157,38 @@ func (in *FsSelector) DeepCopy() *FsSelector {
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *KubernetesAuthConfig) DeepCopyInto(out *KubernetesAuthConfig) {
*out = *in
in.CommonCredentialSelectors.DeepCopyInto(&out.CommonCredentialSelectors)
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KubernetesAuthConfig.
func (in *KubernetesAuthConfig) DeepCopy() *KubernetesAuthConfig {
if in == nil {
return nil
}
out := new(KubernetesAuthConfig)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *KubernetesSecretStoreConfig) DeepCopyInto(out *KubernetesSecretStoreConfig) {
*out = *in
in.Auth.DeepCopyInto(&out.Auth)
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KubernetesSecretStoreConfig.
func (in *KubernetesSecretStoreConfig) DeepCopy() *KubernetesSecretStoreConfig {
if in == nil {
return nil
}
out := new(KubernetesSecretStoreConfig)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *LocalSecretReference) DeepCopyInto(out *LocalSecretReference) {
*out = *in
@ -196,6 +262,31 @@ func (in *ProviderConfigUsage) DeepCopy() *ProviderConfigUsage {
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *PublishConnectionDetailsTo) DeepCopyInto(out *PublishConnectionDetailsTo) {
*out = *in
if in.Metadata != nil {
in, out := &in.Metadata, &out.Metadata
*out = new(ConnectionSecretMetadata)
(*in).DeepCopyInto(*out)
}
if in.SecretStoreConfigRef != nil {
in, out := &in.SecretStoreConfigRef, &out.SecretStoreConfigRef
*out = new(Reference)
**out = **in
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PublishConnectionDetailsTo.
func (in *PublishConnectionDetailsTo) DeepCopy() *PublishConnectionDetailsTo {
if in == nil {
return nil
}
out := new(PublishConnectionDetailsTo)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *Reference) DeepCopyInto(out *Reference) {
*out = *in
@ -288,6 +379,36 @@ func (in *SecretReference) DeepCopy() *SecretReference {
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *SecretStoreConfig) DeepCopyInto(out *SecretStoreConfig) {
*out = *in
if in.Type != nil {
in, out := &in.Type, &out.Type
*out = new(SecretStoreType)
**out = **in
}
if in.Kubernetes != nil {
in, out := &in.Kubernetes, &out.Kubernetes
*out = new(KubernetesSecretStoreConfig)
(*in).DeepCopyInto(*out)
}
if in.Vault != nil {
in, out := &in.Vault, &out.Vault
*out = new(VaultSecretStoreConfig)
(*in).DeepCopyInto(*out)
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecretStoreConfig.
func (in *SecretStoreConfig) DeepCopy() *SecretStoreConfig {
if in == nil {
return nil
}
out := new(SecretStoreConfig)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *Selector) DeepCopyInto(out *Selector) {
*out = *in
@ -370,3 +491,69 @@ func (in *TypedReference) DeepCopy() *TypedReference {
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *VaultAuthConfig) DeepCopyInto(out *VaultAuthConfig) {
*out = *in
if in.Kubernetes != nil {
in, out := &in.Kubernetes, &out.Kubernetes
*out = new(VaultAuthKubernetesConfig)
**out = **in
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VaultAuthConfig.
func (in *VaultAuthConfig) DeepCopy() *VaultAuthConfig {
if in == nil {
return nil
}
out := new(VaultAuthConfig)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *VaultAuthKubernetesConfig) DeepCopyInto(out *VaultAuthKubernetesConfig) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VaultAuthKubernetesConfig.
func (in *VaultAuthKubernetesConfig) DeepCopy() *VaultAuthKubernetesConfig {
if in == nil {
return nil
}
out := new(VaultAuthKubernetesConfig)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *VaultSecretStoreConfig) DeepCopyInto(out *VaultSecretStoreConfig) {
*out = *in
if in.Version != nil {
in, out := &in.Version, &out.Version
*out = new(string)
**out = **in
}
if in.CABundle != nil {
in, out := &in.CABundle, &out.CABundle
*out = new(string)
**out = **in
}
if in.CABundleSecretRef != nil {
in, out := &in.CABundleSecretRef, &out.CABundleSecretRef
*out = new(SecretKeySelector)
**out = **in
}
in.Auth.DeepCopyInto(&out.Auth)
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VaultSecretStoreConfig.
func (in *VaultSecretStoreConfig) DeepCopy() *VaultSecretStoreConfig {
if in == nil {
return nil
}
out := new(VaultSecretStoreConfig)
in.DeepCopyInto(out)
return out
}

View File

@ -0,0 +1,115 @@
/*
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 fake
import (
"context"
"encoding/json"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
v1 "github.com/crossplane/crossplane-runtime/apis/common/v1"
"github.com/crossplane/crossplane-runtime/pkg/connection/store"
)
// MockSecretOwner is a mock object that satisfies ConnectionSecretOwner
// interface.
type MockSecretOwner struct {
runtime.Object
metav1.ObjectMeta
To *v1.PublishConnectionDetailsTo
}
// GetPublishConnectionDetailsTo returns the publish connection details to reference.
func (m *MockSecretOwner) GetPublishConnectionDetailsTo() *v1.PublishConnectionDetailsTo {
return m.To
}
// SetPublishConnectionDetailsTo sets the publish connection details to reference.
func (m *MockSecretOwner) SetPublishConnectionDetailsTo(t *v1.PublishConnectionDetailsTo) {
m.To = t
}
// GetObjectKind returns schema.ObjectKind.
func (m *MockSecretOwner) GetObjectKind() schema.ObjectKind {
return schema.EmptyObjectKind
}
// DeepCopyObject returns a copy of the object as runtime.Object
func (m *MockSecretOwner) DeepCopyObject() runtime.Object {
out := &MockSecretOwner{}
j, err := json.Marshal(m)
if err != nil {
panic(err)
}
_ = json.Unmarshal(j, out)
return out
}
// SecretStore is a fake SecretStore
type SecretStore struct {
ReadKeyValuesFn func(ctx context.Context, i store.Secret) (store.KeyValues, error)
WriteKeyValuesFn func(ctx context.Context, i store.Secret, kv store.KeyValues) error
DeleteKeyValuesFn func(ctx context.Context, i store.Secret, kv store.KeyValues) error
}
// ReadKeyValues reads key values.
func (ss *SecretStore) ReadKeyValues(ctx context.Context, i store.Secret) (store.KeyValues, error) {
return ss.ReadKeyValuesFn(ctx, i)
}
// WriteKeyValues writes key values.
func (ss *SecretStore) WriteKeyValues(ctx context.Context, i store.Secret, kv store.KeyValues) error {
return ss.WriteKeyValuesFn(ctx, i, kv)
}
// DeleteKeyValues deletes key values.
func (ss *SecretStore) DeleteKeyValues(ctx context.Context, i store.Secret, kv store.KeyValues) error {
return ss.DeleteKeyValuesFn(ctx, i, kv)
}
// StoreConfig is a mock implementation of the StoreConfig interface.
type StoreConfig struct {
metav1.ObjectMeta
Config v1.SecretStoreConfig
v1.ConditionedStatus
}
// GetStoreConfig returns SecretStoreConfig
func (s *StoreConfig) GetStoreConfig() v1.SecretStoreConfig {
return s.Config
}
// GetObjectKind returns schema.ObjectKind.
func (s *StoreConfig) GetObjectKind() schema.ObjectKind {
return schema.EmptyObjectKind
}
// DeepCopyObject returns a copy of the object as runtime.Object
func (s *StoreConfig) DeepCopyObject() runtime.Object {
out := &StoreConfig{}
j, err := json.Marshal(s)
if err != nil {
panic(err)
}
_ = json.Unmarshal(j, out)
return out
}

View File

@ -0,0 +1,53 @@
/*
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 connection
import (
"context"
v1 "github.com/crossplane/crossplane-runtime/apis/common/v1"
"github.com/crossplane/crossplane-runtime/pkg/connection/store"
"github.com/crossplane/crossplane-runtime/pkg/resource"
)
// A DetailsPublisherTo may write a connection details secret to a secret store
type DetailsPublisherTo interface {
SetPublishConnectionDetailsTo(r *v1.PublishConnectionDetailsTo)
GetPublishConnectionDetailsTo() *v1.PublishConnectionDetailsTo
}
// A SecretOwner is a Kubernetes object that owns a connection secret.
type SecretOwner interface {
resource.Object
DetailsPublisherTo
}
// A StoreConfig configures a connection store.
type StoreConfig interface {
resource.Object
resource.Conditioned
GetStoreConfig() v1.SecretStoreConfig
}
// A Store stores sensitive key values in Secret.
type Store interface {
ReadKeyValues(ctx context.Context, i store.Secret) (store.KeyValues, error)
WriteKeyValues(ctx context.Context, i store.Secret, kv store.KeyValues) error
DeleteKeyValues(ctx context.Context, i store.Secret, kv store.KeyValues) error
}

160
pkg/connection/manager.go Normal file
View File

@ -0,0 +1,160 @@
/*
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 connection
import (
"context"
"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/logging"
"github.com/crossplane/crossplane-runtime/pkg/reconciler/managed"
"github.com/crossplane/crossplane-runtime/pkg/resource"
)
// Error strings.
const (
errConnectStore = "cannot connect to secret store"
errWriteStore = "cannot write to secret store"
errDeleteFromStore = "cannot delete from secret store"
errGetStoreConfig = "cannot get store config"
)
// StoreBuilderFn is a function that builds and returns a Store with a given
// store config.
type StoreBuilderFn func(ctx context.Context, local client.Client, cfg v1.SecretStoreConfig) (Store, error)
// A DetailsManagerOption configures a DetailsManager.
type DetailsManagerOption func(*DetailsManager)
// WithLogger specifies how the DetailsManager should log messages.
func WithLogger(l logging.Logger) DetailsManagerOption {
return func(m *DetailsManager) {
m.log = l
}
}
// WithStoreBuilder configures the StoreBuilder to use.
func WithStoreBuilder(sb StoreBuilderFn) DetailsManagerOption {
return func(m *DetailsManager) {
m.storeBuilder = sb
}
}
// DetailsManager is a connection details manager that satisfies the required
// interfaces to work with connection details by managing interaction with
// different store implementations.
type DetailsManager struct {
client client.Client
newConfig func() StoreConfig
storeBuilder StoreBuilderFn
log logging.Logger
}
// NewDetailsManager returns a new connection DetailsManager.
func NewDetailsManager(c client.Client, of schema.GroupVersionKind, o ...DetailsManagerOption) *DetailsManager {
nc := func() StoreConfig {
return resource.MustCreateObject(of, c.Scheme()).(StoreConfig)
}
// Panic early if we've been asked to reconcile a resource kind that has not
// been registered with our controller manager's scheme.
_ = nc()
m := &DetailsManager{
client: c,
newConfig: nc,
storeBuilder: RuntimeStoreBuilder,
log: logging.NewNopLogger(),
}
for _, mo := range o {
mo(m)
}
return m
}
// PublishConnection publishes the supplied ConnectionDetails to a secret on
// the configured connection Store.
// TODO(turkenh): Refactor this method once the `managed.ConnectionPublisher`
// interface methods refactored per new types: SecretOwner and KeyValues
func (m *DetailsManager) PublishConnection(ctx context.Context, mg resource.Managed, c managed.ConnectionDetails) error {
return m.publishConnection(ctx, mg.(SecretOwner), store.KeyValues(c))
}
// UnpublishConnection deletes connection details secret from the configured
// connection Store.
// TODO(turkenh): Refactor this method once the `managed.ConnectionPublisher`
// interface methods refactored per new types: SecretOwner and KeyValues
func (m *DetailsManager) UnpublishConnection(ctx context.Context, mg resource.Managed, c managed.ConnectionDetails) error {
return m.unpublishConnection(ctx, mg.(SecretOwner), store.KeyValues(c))
}
func (m *DetailsManager) connectStore(ctx context.Context, p *v1.PublishConnectionDetailsTo) (Store, error) {
sc := m.newConfig()
if err := m.client.Get(ctx, types.NamespacedName{Name: p.SecretStoreConfigRef.Name}, sc); err != nil {
return nil, errors.Wrap(err, errGetStoreConfig)
}
return m.storeBuilder(ctx, m.client, sc.GetStoreConfig())
}
func (m *DetailsManager) publishConnection(ctx context.Context, so SecretOwner, kv store.KeyValues) error {
// This resource does not want to expose a connection secret.
p := so.GetPublishConnectionDetailsTo()
if p == nil {
return nil
}
ss, err := m.connectStore(ctx, p)
if err != nil {
return errors.Wrap(err, errConnectStore)
}
return errors.Wrap(ss.WriteKeyValues(ctx, store.Secret{
Name: p.Name,
Scope: so.GetNamespace(),
Metadata: p.Metadata,
}, kv), errWriteStore)
}
func (m *DetailsManager) unpublishConnection(ctx context.Context, so SecretOwner, kv store.KeyValues) error {
// This resource didn't expose a connection secret.
p := so.GetPublishConnectionDetailsTo()
if p == nil {
return nil
}
ss, err := m.connectStore(ctx, p)
if err != nil {
return errors.Wrap(err, errConnectStore)
}
return errors.Wrap(ss.DeleteKeyValues(ctx, store.Secret{
Name: p.Name,
Scope: so.GetNamespace(),
Metadata: p.Metadata,
}, kv), errDeleteFromStore)
}

View File

@ -0,0 +1,365 @@
/*
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 connection
import (
"context"
"testing"
"github.com/google/go-cmp/cmp"
kerrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"sigs.k8s.io/controller-runtime/pkg/client"
v1 "github.com/crossplane/crossplane-runtime/apis/common/v1"
"github.com/crossplane/crossplane-runtime/pkg/connection/fake"
"github.com/crossplane/crossplane-runtime/pkg/connection/store"
"github.com/crossplane/crossplane-runtime/pkg/errors"
resourcefake "github.com/crossplane/crossplane-runtime/pkg/resource/fake"
"github.com/crossplane/crossplane-runtime/pkg/test"
)
const (
SecretStoreFake v1.SecretStoreType = "Fake"
fakeConfig = "fake"
)
const (
errBuildStore = "cannot build store"
)
var (
fakeStore = SecretStoreFake
)
func TestManagerConnectStore(t *testing.T) {
type args struct {
c client.Client
sb StoreBuilderFn
p *v1.PublishConnectionDetailsTo
}
type want struct {
err error
}
cases := map[string]struct {
reason string
args
want
}{
"ConfigNotFound": {
reason: "We should return a proper error if referenced StoreConfig does not exist.",
args: args{
c: &test.MockClient{
MockGet: func(_ context.Context, key client.ObjectKey, obj client.Object) error {
return kerrors.NewNotFound(schema.GroupResource{}, key.Name)
},
MockScheme: test.NewMockSchemeFn(resourcefake.SchemeWith(&fake.StoreConfig{})),
},
sb: fakeStoreBuilderFn(fake.SecretStore{}),
p: &v1.PublishConnectionDetailsTo{
SecretStoreConfigRef: &v1.Reference{
Name: fakeConfig,
},
},
},
want: want{
err: errors.Wrapf(kerrors.NewNotFound(schema.GroupResource{}, fakeConfig), errGetStoreConfig),
},
},
"BuildStoreError": {
reason: "We should return any error encountered while building the Store.",
args: args{
c: &test.MockClient{
MockGet: func(_ context.Context, key client.ObjectKey, obj client.Object) error {
*obj.(*fake.StoreConfig) = fake.StoreConfig{}
return nil
},
MockScheme: test.NewMockSchemeFn(resourcefake.SchemeWith(&fake.StoreConfig{})),
},
sb: func(ctx context.Context, local client.Client, cfg v1.SecretStoreConfig) (Store, error) {
return nil, errors.New(errBuildStore)
},
p: &v1.PublishConnectionDetailsTo{
SecretStoreConfigRef: &v1.Reference{
Name: fakeConfig,
},
},
},
want: want{
err: errors.New(errBuildStore),
},
},
"SuccessfulConnect": {
reason: "We should not return an error when connected successfully.",
args: args{
c: &test.MockClient{
MockGet: func(_ context.Context, key client.ObjectKey, obj client.Object) error {
*obj.(*fake.StoreConfig) = fake.StoreConfig{
ObjectMeta: metav1.ObjectMeta{
Name: fakeConfig,
},
Config: v1.SecretStoreConfig{
Type: &fakeStore,
},
}
return nil
},
MockScheme: test.NewMockSchemeFn(resourcefake.SchemeWith(&fake.StoreConfig{})),
},
sb: fakeStoreBuilderFn(fake.SecretStore{}),
p: &v1.PublishConnectionDetailsTo{
SecretStoreConfigRef: &v1.Reference{
Name: fakeConfig,
},
},
},
want: want{
err: nil,
},
},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
m := NewDetailsManager(tc.args.c, resourcefake.GVK(&fake.StoreConfig{}), WithStoreBuilder(tc.args.sb))
_, err := m.connectStore(context.Background(), tc.args.p)
if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" {
t.Errorf("\nReason: %s\nm.connectStore(...): -want error, +got error:\n%s", tc.reason, diff)
}
})
}
}
func TestManagerPublishConnection(t *testing.T) {
type args struct {
c client.Client
sb StoreBuilderFn
kv store.KeyValues
so SecretOwner
}
type want struct {
err error
}
cases := map[string]struct {
reason string
args
want
}{
"NoConnectionDetails": {
reason: "We should return no error if resource does not want to expose a connection secret.",
args: args{
c: &test.MockClient{
MockScheme: test.NewMockSchemeFn(resourcefake.SchemeWith(&fake.StoreConfig{})),
},
so: &fake.MockSecretOwner{To: nil},
},
want: want{
err: nil,
},
},
"CannotConnect": {
reason: "We should return any error encountered while connecting to Store.",
args: args{
c: &test.MockClient{
MockGet: func(_ context.Context, key client.ObjectKey, obj client.Object) error {
return kerrors.NewNotFound(schema.GroupResource{}, key.Name)
},
MockScheme: test.NewMockSchemeFn(resourcefake.SchemeWith(&fake.StoreConfig{})),
},
sb: fakeStoreBuilderFn(fake.SecretStore{
WriteKeyValuesFn: func(ctx context.Context, i store.Secret, kv store.KeyValues) error {
return nil
},
}),
so: &fake.MockSecretOwner{
To: &v1.PublishConnectionDetailsTo{
SecretStoreConfigRef: &v1.Reference{
Name: "non-existing",
},
},
},
},
want: want{
err: errors.Wrap(errors.Wrapf(kerrors.NewNotFound(schema.GroupResource{}, "non-existing"), errGetStoreConfig), errConnectStore),
},
},
"SuccessfulPublish": {
reason: "We should return no error when published successfully.",
args: args{
c: &test.MockClient{
MockGet: func(_ context.Context, key client.ObjectKey, obj client.Object) error {
*obj.(*fake.StoreConfig) = fake.StoreConfig{
ObjectMeta: metav1.ObjectMeta{
Name: fakeConfig,
},
Config: v1.SecretStoreConfig{
Type: &fakeStore,
},
}
return nil
},
MockScheme: test.NewMockSchemeFn(resourcefake.SchemeWith(&fake.StoreConfig{})),
},
sb: fakeStoreBuilderFn(fake.SecretStore{
WriteKeyValuesFn: func(ctx context.Context, i store.Secret, kv store.KeyValues) error {
return nil
},
}),
so: &fake.MockSecretOwner{
To: &v1.PublishConnectionDetailsTo{
SecretStoreConfigRef: &v1.Reference{
Name: "fake",
},
},
},
},
want: want{
err: nil,
},
},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
m := NewDetailsManager(tc.args.c, resourcefake.GVK(&fake.StoreConfig{}), WithStoreBuilder(tc.args.sb))
err := m.publishConnection(context.Background(), tc.args.so, tc.args.kv)
if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" {
t.Errorf("\nReason: %s\nm.publishConnection(...): -want error, +got error:\n%s", tc.reason, diff)
}
})
}
}
func TestManagerUnpublishConnection(t *testing.T) {
type args struct {
c client.Client
sb StoreBuilderFn
kv store.KeyValues
so SecretOwner
}
type want struct {
err error
}
cases := map[string]struct {
reason string
args
want
}{
"NoConnectionDetails": {
reason: "We should return no error if resource does not want to expose a connection secret.",
args: args{
c: &test.MockClient{
MockScheme: test.NewMockSchemeFn(resourcefake.SchemeWith(&fake.StoreConfig{})),
},
so: &fake.MockSecretOwner{To: nil},
},
want: want{
err: nil,
},
},
"CannotConnect": {
reason: "We should return any error encountered while connecting to Store.",
args: args{
c: &test.MockClient{
MockGet: func(_ context.Context, key client.ObjectKey, obj client.Object) error {
return kerrors.NewNotFound(schema.GroupResource{}, key.Name)
},
MockScheme: test.NewMockSchemeFn(resourcefake.SchemeWith(&fake.StoreConfig{})),
},
sb: fakeStoreBuilderFn(fake.SecretStore{
WriteKeyValuesFn: func(ctx context.Context, i store.Secret, kv store.KeyValues) error {
return nil
},
}),
so: &fake.MockSecretOwner{
To: &v1.PublishConnectionDetailsTo{
SecretStoreConfigRef: &v1.Reference{
Name: "non-existing",
},
},
},
},
want: want{
err: errors.Wrap(errors.Wrapf(kerrors.NewNotFound(schema.GroupResource{}, "non-existing"), errGetStoreConfig), errConnectStore),
},
},
"SuccessfulUnpublish": {
reason: "We should return no error when unpublished successfully.",
args: args{
c: &test.MockClient{
MockGet: func(_ context.Context, key client.ObjectKey, obj client.Object) error {
*obj.(*fake.StoreConfig) = fake.StoreConfig{
ObjectMeta: metav1.ObjectMeta{
Name: fakeConfig,
},
Config: v1.SecretStoreConfig{
Type: &fakeStore,
},
}
return nil
},
MockScheme: test.NewMockSchemeFn(resourcefake.SchemeWith(&fake.StoreConfig{})),
},
sb: fakeStoreBuilderFn(fake.SecretStore{
DeleteKeyValuesFn: func(ctx context.Context, i store.Secret, kv store.KeyValues) error {
return nil
},
}),
so: &fake.MockSecretOwner{
To: &v1.PublishConnectionDetailsTo{
SecretStoreConfigRef: &v1.Reference{
Name: "fake",
},
},
},
},
want: want{
err: nil,
},
},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
m := NewDetailsManager(tc.args.c, resourcefake.GVK(&fake.StoreConfig{}), WithStoreBuilder(tc.args.sb))
err := m.unpublishConnection(context.Background(), tc.args.so, tc.args.kv)
if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" {
t.Errorf("\nReason: %s\nm.unpublishConnection(...): -want error, +got error:\n%s", tc.reason, diff)
}
})
}
}
func fakeStoreBuilderFn(ss fake.SecretStore) StoreBuilderFn {
return func(_ context.Context, _ client.Client, cfg v1.SecretStoreConfig) (Store, error) {
if *cfg.Type == fakeStore {
return &ss, nil
}
return nil, errors.Errorf(errFmtUnknownSecretStore, *cfg.Type)
}
}

View File

@ -0,0 +1,157 @@
/*
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"
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/types"
"k8s.io/client-go/tools/clientcmd"
"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"
)
// Error strings.
const (
errGetSecret = "cannot get secret"
errDeleteSecret = "cannot delete secret"
errUpdateSecret = "cannot update secret"
errApplySecret = "cannot apply secret"
errExtractKubernetesAuthCreds = "cannot extract kubernetes auth credentials"
errBuildRestConfig = "cannot build rest config kubeconfig"
errBuildClient = "cannot build Kubernetes client"
)
// SecretStore is a Kubernetes Secret Store.
type SecretStore struct {
client resource.ClientApplicator
defaultNamespace string
}
// NewSecretStore returns a new Kubernetes SecretStore.
func NewSecretStore(ctx context.Context, local client.Client, cfg v1.SecretStoreConfig) (*SecretStore, error) {
kube, err := buildClient(ctx, local, cfg)
if err != nil {
return nil, errors.Wrap(err, errBuildClient)
}
return &SecretStore{
client: resource.ClientApplicator{
Client: kube,
Applicator: resource.NewApplicatorWithRetry(resource.NewAPIPatchingApplicator(kube), resource.IsAPIErrorWrapped, nil),
},
defaultNamespace: cfg.DefaultScope,
}, nil
}
func buildClient(ctx context.Context, local client.Client, cfg v1.SecretStoreConfig) (client.Client, error) {
if cfg.Kubernetes == nil {
// No KubernetesSecretStoreConfig provided, local API Server will be
// used as Secret Store.
return local, nil
}
// Configure client for an external API server with a given Kubeconfig.
kfg, err := resource.CommonCredentialExtractor(ctx, cfg.Kubernetes.Auth.Source, local, cfg.Kubernetes.Auth.CommonCredentialSelectors)
if err != nil {
return nil, errors.Wrap(err, errExtractKubernetesAuthCreds)
}
config, err := clientcmd.RESTConfigFromKubeConfig(kfg)
if err != nil {
return nil, errors.Wrap(err, errBuildRestConfig)
}
return client.New(config, client.Options{})
}
// ReadKeyValues reads and returns key value pairs for a given Kubernetes Secret.
func (ss *SecretStore) ReadKeyValues(ctx context.Context, i store.Secret) (store.KeyValues, error) {
s := &corev1.Secret{}
return s.Data, errors.Wrap(ss.client.Get(ctx, types.NamespacedName{Name: i.Name, Namespace: ss.namespaceForSecret(i)}, s), errGetSecret)
}
// WriteKeyValues writes key value pairs to a given Kubernetes Secret.
func (ss *SecretStore) WriteKeyValues(ctx context.Context, i store.Secret, kv store.KeyValues) error {
s := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: i.Name,
Namespace: ss.namespaceForSecret(i),
},
Type: resource.SecretTypeConnection,
Data: kv,
}
if i.Metadata != nil {
s.Labels = i.Metadata.Labels
s.Annotations = i.Metadata.Annotations
if i.Metadata.Type != nil {
s.Type = *i.Metadata.Type
}
}
return errors.Wrap(ss.client.Apply(ctx, s), errApplySecret)
}
// DeleteKeyValues delete key value pairs from a given Kubernetes Secret.
// If no kv specified, the whole secret instance is deleted.
// If kv specified, those would be deleted and secret instance will be deleted
// only if there is no data left.
func (ss *SecretStore) DeleteKeyValues(ctx context.Context, i store.Secret, kv store.KeyValues) error {
// NOTE(turkenh): DeleteKeyValues method wouldn't need to do anything if we
// have used owner references similar to existing implementation. However,
// this wouldn't work if the K8s API is not the same as where SecretOwner
// object lives, i.e. a remote cluster.
// Considering there is not much additional value with deletion via garbage
// collection in this specific case other than one less API call during
// deletion, I opted for unifying both instead of adding conditional logic
// like add owner references if not remote and not call delete etc.
s := &corev1.Secret{}
err := ss.client.Get(ctx, types.NamespacedName{Name: i.Name, Namespace: ss.namespaceForSecret(i)}, s)
if kerrors.IsNotFound(err) {
// Secret already deleted, nothing to do.
return nil
}
if err != nil {
return errors.Wrap(err, errGetSecret)
}
// Delete all supplied keys from secret data
for k := range kv {
delete(s.Data, k)
}
if len(kv) == 0 || len(s.Data) == 0 {
// Secret is deleted only if:
// - No kv to delete specified as input
// - No data left in the secret
return errors.Wrapf(ss.client.Delete(ctx, s), errDeleteSecret)
}
// If there are still keys left, update the secret with the remaining.
return errors.Wrapf(ss.client.Update(ctx, s), errUpdateSecret)
}
func (ss *SecretStore) namespaceForSecret(i store.Secret) string {
if i.Scope == "" {
return ss.defaultNamespace
}
return i.Scope
}

View File

@ -0,0 +1,667 @@
/*
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"
"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"
fakeKV = map[string][]byte{
"key1": []byte("value1"),
"key2": []byte("value2"),
"key3": []byte("value3"),
}
fakeLabels = map[string]string{
"environment": "unit-test",
"reason": "testing",
}
fakeAnnotations = map[string]string{
"some-annotation-key": "some-annotation-value",
}
storeTypeKubernetes = v1.SecretStoreKubernetes
)
func TestSecretStoreReadKeyValues(t *testing.T) {
type args struct {
client resource.ClientApplicator
secret store.Secret
}
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),
},
},
secret: store.Secret{
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: test.NewMockGetFn(nil, func(obj client.Object) error {
*obj.(*corev1.Secret) = corev1.Secret{
Data: fakeKV,
}
return nil
}),
},
},
secret: store.Secret{
Name: "fake",
},
},
want: want{
result: store.KeyValues(fakeKV),
},
},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
ss := &SecretStore{
client: tc.args.client,
}
got, err := ss.ReadKeyValues(context.Background(), tc.args.secret)
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, got); 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
kv store.KeyValues
}
type want struct {
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(ctx context.Context, obj client.Object, option ...resource.ApplyOption) error {
return errBoom
}),
},
secret: store.Secret{
Name: fakeSecretName,
Scope: fakeSecretNamespace,
},
kv: store.KeyValues(fakeKV),
},
want: want{
err: errors.Wrap(errBoom, errApplySecret),
},
},
"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 {
if diff := cmp.Diff(fakeConnectionSecret(withData(fakeKV)), obj.(*corev1.Secret)); diff != "" {
t.Errorf("r: -want, +got:\n%s", diff)
}
return nil
}),
},
secret: store.Secret{
Name: fakeSecretName,
Scope: fakeSecretNamespace,
},
kv: store.KeyValues(fakeKV),
},
want: want{
err: nil,
},
},
"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)
}
return nil
}),
},
secret: store.Secret{
Name: fakeSecretName,
Scope: fakeSecretNamespace,
},
kv: store.KeyValues(map[string][]byte{
"existing-key": []byte("new-value"),
}),
},
want: want{
err: nil,
},
},
"SecretPatchedWithNewKey": {
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)
}
return nil
}),
},
secret: store.Secret{
Name: fakeSecretName,
Scope: fakeSecretNamespace,
},
kv: store.KeyValues(map[string][]byte{
"new-key": []byte("new-value"),
}),
},
want: want{
err: nil,
},
},
"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)
}
return nil
}),
},
secret: store.Secret{
Name: fakeSecretName,
Scope: fakeSecretNamespace,
},
kv: store.KeyValues(fakeKV),
},
want: want{
err: nil,
},
},
"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)
}
return nil
}),
},
secret: store.Secret{
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,
},
},
kv: store.KeyValues(fakeKV),
},
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.WriteKeyValues(context.Background(), tc.args.secret, tc.args.kv)
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)
}
})
}
}
func TestSecretStoreDeleteKeyValues(t *testing.T) {
type args struct {
client resource.ClientApplicator
defaultNamespace string
secret store.Secret
kv store.KeyValues
}
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{
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(ctx context.Context, obj client.Object, opts ...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{
Name: fakeSecretName,
Scope: fakeSecretNamespace,
},
kv: 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{
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(obj client.Object) error {
return kerrors.NewNotFound(schema.GroupResource{}, "")
}),
},
},
secret: store.Secret{
Name: fakeSecretName,
Scope: fakeSecretNamespace,
},
},
want: want{
err: nil,
},
},
"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(ctx context.Context, obj client.Object, opts ...client.DeleteOption) error {
return nil
},
},
},
secret: store.Secret{
Name: fakeSecretName,
Scope: fakeSecretNamespace,
},
},
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.kv)
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(obj 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, 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 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
}

View File

@ -0,0 +1,31 @@
/*
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 store
import (
v1 "github.com/crossplane/crossplane-runtime/apis/common/v1"
)
// KeyValues is a map with sensitive values.
type KeyValues map[string][]byte
// A Secret is an entity representing a set of sensitive Key Values.
type Secret struct {
Name string
Scope string
Metadata *v1.ConnectionSecretMetadata
}

45
pkg/connection/stores.go Normal file
View File

@ -0,0 +1,45 @@
/*
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 connection
import (
"context"
"sigs.k8s.io/controller-runtime/pkg/client"
v1 "github.com/crossplane/crossplane-runtime/apis/common/v1"
"github.com/crossplane/crossplane-runtime/pkg/connection/store/kubernetes"
"github.com/crossplane/crossplane-runtime/pkg/errors"
)
const (
errFmtUnknownSecretStore = "unknown secret store type: %q"
)
// RuntimeStoreBuilder builds and returns a Store for any supported Store type
// in a given config.
//
// All in-tree connection Store implementations needs to be registered here.
func RuntimeStoreBuilder(ctx context.Context, local client.Client, cfg v1.SecretStoreConfig) (Store, error) {
switch *cfg.Type {
case v1.SecretStoreKubernetes:
return kubernetes.NewSecretStore(ctx, local, cfg)
case v1.SecretStoreVault:
return nil, errors.New("Vault is not supported as a secret store yet")
}
return nil, errors.Errorf(errFmtUnknownSecretStore, *cfg.Type)
}

View File

@ -53,6 +53,9 @@ type MockStatusUpdateFn func(ctx context.Context, obj client.Object, opts ...cli
// A MockStatusPatchFn is used to mock client.Client's StatusUpdate implementation.
type MockStatusPatchFn func(ctx context.Context, obj client.Object, patch client.Patch, opts ...client.PatchOption) error
// A MockSchemeFn is used to mock client.Client's Scheme implementation.
type MockSchemeFn func() *runtime.Scheme
// An ObjectFn operates on the supplied Object. You might use an ObjectFn to
// test or update the contents of an Object.
type ObjectFn func(obj client.Object) error
@ -169,6 +172,13 @@ func NewMockStatusPatchFn(err error, ofn ...ObjectFn) MockStatusPatchFn {
}
}
// NewMockSchemeFn returns a MockSchemeFn that returns the scheme
func NewMockSchemeFn(scheme *runtime.Scheme) MockSchemeFn {
return func() *runtime.Scheme {
return scheme
}
}
// MockClient implements controller-runtime's Client interface, allowing each
// method to be overridden for testing. The controller-runtime provides a fake
// client, but it is has surprising side effects (e.g. silently calling
@ -183,6 +193,8 @@ type MockClient struct {
MockPatch MockPatchFn
MockStatusUpdate MockStatusUpdateFn
MockStatusPatch MockStatusPatchFn
MockScheme MockSchemeFn
}
// NewMockClient returns a MockClient that does nothing when its methods are
@ -198,6 +210,8 @@ func NewMockClient() *MockClient {
MockPatch: NewMockPatchFn(nil),
MockStatusUpdate: NewMockStatusUpdateFn(nil),
MockStatusPatch: NewMockStatusPatchFn(nil),
MockScheme: NewMockSchemeFn(nil),
}
}
@ -249,9 +263,9 @@ func (c *MockClient) RESTMapper() meta.RESTMapper {
return nil
}
// Scheme returns the current runtime scheme.
// Scheme calls MockClient's MockScheme function
func (c *MockClient) Scheme() *runtime.Scheme {
return nil
return c.MockScheme()
}
// MockStatusWriter provides mock functionality for status sub-resource