270 lines
8.8 KiB
Go
270 lines
8.8 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 implements a secret store backed by Kubernetes Secrets.
|
|
package kubernetes
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
|
|
"github.com/google/go-cmp/cmp"
|
|
"github.com/google/go-cmp/cmp/cmpopts"
|
|
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"
|
|
"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, _ *tls.Config, 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, n store.ScopedName, s *store.Secret) error {
|
|
ks := &corev1.Secret{}
|
|
if err := ss.client.Get(ctx, types.NamespacedName{Name: n.Name, Namespace: ss.namespaceForSecret(n)}, ks); resource.IgnoreNotFound(err) != nil {
|
|
return errors.Wrap(err, errGetSecret)
|
|
}
|
|
|
|
s.Data = ks.Data
|
|
s.Metadata = &v1.ConnectionSecretMetadata{
|
|
Labels: ks.Labels,
|
|
Annotations: ks.Annotations,
|
|
Type: &ks.Type,
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// WriteKeyValues writes key value pairs to a given Kubernetes Secret.
|
|
func (ss *SecretStore) WriteKeyValues(ctx context.Context, s *store.Secret, wo ...store.WriteOption) (bool, error) {
|
|
ks := &corev1.Secret{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: s.Name,
|
|
Namespace: ss.namespaceForSecret(s.ScopedName),
|
|
},
|
|
Type: resource.SecretTypeConnection,
|
|
Data: s.Data,
|
|
}
|
|
|
|
if s.Metadata != nil {
|
|
ks.Labels = s.Metadata.Labels
|
|
|
|
ks.Annotations = s.Metadata.Annotations
|
|
if s.Metadata.Type != nil {
|
|
ks.Type = *s.Metadata.Type
|
|
}
|
|
}
|
|
|
|
ao := applyOptions(wo...)
|
|
ao = append(ao, resource.AllowUpdateIf(func(current, desired runtime.Object) bool {
|
|
// We consider the update to be a no-op and don't allow it if the
|
|
// current and existing secret data are identical.
|
|
return !cmp.Equal(current.(*corev1.Secret).Data, desired.(*corev1.Secret).Data, cmpopts.EquateEmpty()) //nolint:forcetypeassert // Will always be a secret.
|
|
}))
|
|
|
|
err := ss.client.Apply(ctx, ks, ao...)
|
|
if resource.IsNotAllowed(err) {
|
|
// The update was not allowed because it was a no-op.
|
|
return false, nil
|
|
}
|
|
|
|
if err != nil {
|
|
return false, errors.Wrap(err, errApplySecret)
|
|
}
|
|
|
|
return true, nil
|
|
}
|
|
|
|
// 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, s *store.Secret, do ...store.DeleteOption) 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 ConnectionSecretOwner
|
|
// 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.
|
|
ks := &corev1.Secret{}
|
|
|
|
err := ss.client.Get(ctx, types.NamespacedName{Name: s.Name, Namespace: ss.namespaceForSecret(s.ScopedName)}, ks)
|
|
if kerrors.IsNotFound(err) {
|
|
// Secret already deleted, nothing to do.
|
|
return nil
|
|
}
|
|
|
|
if err != nil {
|
|
return errors.Wrap(err, errGetSecret)
|
|
}
|
|
|
|
for _, o := range do {
|
|
if err = o(ctx, s); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Delete all supplied keys from secret data
|
|
for k := range s.Data {
|
|
delete(ks.Data, k)
|
|
}
|
|
|
|
if len(s.Data) == 0 || len(ks.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, ks), errDeleteSecret)
|
|
}
|
|
// If there are still keys left, update the secret with the remaining.
|
|
return errors.Wrapf(ss.client.Update(ctx, ks), errUpdateSecret)
|
|
}
|
|
|
|
func (ss *SecretStore) namespaceForSecret(n store.ScopedName) string {
|
|
if n.Scope == "" {
|
|
return ss.defaultNamespace
|
|
}
|
|
|
|
return n.Scope
|
|
}
|
|
|
|
func applyOptions(wo ...store.WriteOption) []resource.ApplyOption {
|
|
ao := make([]resource.ApplyOption, len(wo))
|
|
for i := range wo {
|
|
o := wo[i]
|
|
ao[i] = func(ctx context.Context, current, desired runtime.Object) error {
|
|
currentSecret := current.(*corev1.Secret) //nolint:forcetypeassert // Will always be a secret.
|
|
desiredSecret := desired.(*corev1.Secret) //nolint:forcetypeassert // Will always be a secret.
|
|
|
|
cs := &store.Secret{
|
|
ScopedName: store.ScopedName{
|
|
Name: currentSecret.Name,
|
|
Scope: currentSecret.Namespace,
|
|
},
|
|
Metadata: &v1.ConnectionSecretMetadata{
|
|
Labels: currentSecret.Labels,
|
|
Annotations: currentSecret.Annotations,
|
|
Type: ¤tSecret.Type,
|
|
},
|
|
Data: currentSecret.Data,
|
|
}
|
|
|
|
// NOTE(turkenh): With External Secret Stores, we are using a special label/tag with key
|
|
// "secret.crossplane.io/owner-uid" to track the owner of the connection secret. However, different from
|
|
// other Secret Store implementations, Kubernetes Store uses metadata.OwnerReferences for this purpose and
|
|
// we don't want it to appear in the labels of the secret additionally.
|
|
// Here we are adding the owner label to the internal representation of the current secret as part of
|
|
// converting store.WriteOption's to k8s resource.ApplyOption's, so that our generic store.WriteOptions
|
|
// checking secret owner could work as expected.
|
|
// Fixes: https://github.com/crossplane/crossplane/issues/3520
|
|
if len(currentSecret.GetOwnerReferences()) > 0 {
|
|
cs.Metadata.SetOwnerUID(currentSecret.GetOwnerReferences()[0].UID)
|
|
}
|
|
|
|
ds := &store.Secret{
|
|
ScopedName: store.ScopedName{
|
|
Name: desiredSecret.Name,
|
|
Scope: desiredSecret.Namespace,
|
|
},
|
|
Metadata: &v1.ConnectionSecretMetadata{
|
|
Labels: desiredSecret.Labels,
|
|
Annotations: desiredSecret.Annotations,
|
|
Type: &desiredSecret.Type,
|
|
},
|
|
Data: desiredSecret.Data,
|
|
}
|
|
|
|
if err := o(ctx, cs, ds); err != nil {
|
|
return err
|
|
}
|
|
|
|
desiredSecret.Data = ds.Data
|
|
desiredSecret.Labels = ds.Metadata.Labels
|
|
|
|
desiredSecret.Annotations = ds.Metadata.Annotations
|
|
if ds.Metadata.Type != nil {
|
|
desiredSecret.Type = *ds.Metadata.Type
|
|
}
|
|
|
|
return nil
|
|
}
|
|
}
|
|
|
|
return ao
|
|
}
|