From 9f7533867da23c367ac2c172d03ecd7859b2e6bf Mon Sep 17 00:00:00 2001 From: Moritz Johner Date: Sat, 12 Oct 2024 20:41:10 +0200 Subject: [PATCH] feat: push secret metadata (#3600) Signed-off-by: Moritz Johner --- docs/provider/kubernetes.md | 68 +++++ pkg/provider/kubernetes/client.go | 192 +++++++------- pkg/provider/kubernetes/client_test.go | 336 ++++++++++++++++++++++++- pkg/provider/kubernetes/metadata.go | 148 +++++++++++ 4 files changed, 640 insertions(+), 104 deletions(-) create mode 100644 pkg/provider/kubernetes/metadata.go diff --git a/docs/provider/kubernetes.md b/docs/provider/kubernetes.md index 40d556659..9f5ebec00 100644 --- a/docs/provider/kubernetes.md +++ b/docs/provider/kubernetes.md @@ -298,6 +298,74 @@ rules: - create ``` +#### PushSecret Metadata + +The Kubernetes provider is able to manage both `metadata.labels` and `metadata.annotations` of the secret on the target cluster. + +Users have different preferences on what metadata should be pushed. ESO by default pushes both labels and annotations to the target secret and merges them with the existing metadata. + +You can specify the metadata in the `spec.template.metadata` section if you want to decouple it from the existing secret. + +```yaml +{% raw %} +apiVersion: external-secrets.io/v1alpha1 +kind: PushSecret +metadata: + name: example +spec: + # ... + template: + metadata: + labels: + app.kubernetes.io/part-of: argocd + data: + mysql_connection_string: "mysql://{{ .hostname }}:3306/{{ .database }}" + data: + - match: + secretKey: mysql_connection_string + remoteRef: + remoteKey: backend_secrets + property: mysql_connection_string +{% endraw %} +``` + +Further, you can leverage the `.data[].metadata` section to fine-tine the behaviour of the metadata merge strategy. The metadata section is a versioned custom-resource _alike_ structure, the behaviour is detailed below. + +```yaml +apiVersion: external-secrets.io/v1alpha1 +kind: PushSecret +metadata: + name: example +spec: + # ... + data: + - match: + secretKey: example-1 + remoteRef: + remoteKey: example-remote-secret + property: url + + metadata: + apiVersion: kubernetes.external-secrets.io/v1alpha1 + kind: PushSecretMetadata + spec: + sourceMergePolicy: Merge # or Replace + targetMergePolicy: Merge # or Replace / Ignore + labels: + color: red + annotations: + yes: please + +``` + + +| Field | Type | Description | +| ----------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| sourceMergePolicy | string: `Merge`, `Replace` | The sourceMergePolicy defines how the metadata of the source secret is merged. `Merge` will merge the metadata of the source secret with the metadata defined in `.data[].metadata`. With `Replace`, the metadata in `.data[].metadata` replaces the source metadata. | +| targetMergePolicy | string: `Merge`, `Replace`, `Ignore` | The targetMergePolicy defines how ESO merges the metadata produced by the sourceMergePolicy with the target secret. With `Merge`, the source metadata is merged with the existing metadata from the target secret. `Replace` will replace the target metadata with the metadata defined in the source. `Ignore` leaves the target metadata as is. | +| labels | `map[string]string` | The labels. | +| annotations | `map[string]string` | The annotations. | + #### Implementation Considerations When utilizing the PushSecret feature and configuring the permissions for the SecretStore, consider the following: diff --git a/pkg/provider/kubernetes/client.go b/pkg/provider/kubernetes/client.go index 957a8671d..70aa59465 100644 --- a/pkg/provider/kubernetes/client.go +++ b/pkg/provider/kubernetes/client.go @@ -15,17 +15,16 @@ limitations under the License. package kubernetes import ( - "bytes" "context" "encoding/base64" "encoding/json" "errors" "fmt" - "reflect" "strings" "github.com/tidwall/gjson" v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/equality" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" @@ -109,50 +108,107 @@ func (c *Client) PushSecret(ctx context.Context, secret *v1.Secret, data esv1bet if data.GetProperty() == "" && data.GetSecretKey() != "" { return errors.New("requires property in RemoteRef to push secret value if secret key is defined") } - - extSecret, getErr := c.userSecretClient.Get(ctx, data.GetRemoteKey(), metav1.GetOptions{}) - metrics.ObserveAPICall(constants.ProviderKubernetes, constants.CallKubernetesGetSecret, getErr) - if getErr != nil { - // create if it not exists - if apierrors.IsNotFound(getErr) { - typ := v1.SecretTypeOpaque - if secret.Type != "" { - typ = secret.Type - } - - return c.createSecret(ctx, secret, typ, data) - } - return getErr + remoteSecret := &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: c.store.RemoteNamespace, + Name: data.GetRemoteKey(), + }, } - // the whole secret was pushed to the provider - if data.GetSecretKey() == "" { - if data.GetProperty() != "" { - value, err := c.marshalData(secret) - if err != nil { - return err - } + return c.createOrUpdate(ctx, remoteSecret, func() error { + return c.mergePushSecretData(data, remoteSecret, secret) + }) +} - if v, ok := extSecret.Data[data.GetProperty()]; ok && bytes.Equal(v, value) { - return nil - } +func (c *Client) mergePushSecretData(remoteRef esv1beta1.PushSecretData, remoteSecret, localSecret *v1.Secret) error { + // apply secret type + secretType := v1.SecretTypeOpaque + if localSecret.Type != "" { + secretType = localSecret.Type + } + remoteSecret.Type = secretType - return c.updateProperty(ctx, extSecret, data, value) - } - - if reflect.DeepEqual(extSecret.Data, secret.Data) { - return nil - } - - return c.updateMap(ctx, extSecret, secret.Data) + // merge secret data with existing secret data + if remoteSecret.Data == nil { + remoteSecret.Data = make(map[string][]byte) } - // only a single property was pushed - if v, ok := extSecret.Data[data.GetProperty()]; ok && bytes.Equal(v, secret.Data[data.GetSecretKey()]) { + pushMeta, err := parseMetadataParameters(remoteRef.GetMetadata()) + if err != nil { + return fmt.Errorf("unable to parse metadata parameters: %w", err) + } + + // merge metadata based on the policy + var targetLabels, targetAnnotations map[string]string + sourceLabels, sourceAnnotations, err := mergeSourceMetadata(localSecret, pushMeta) + if err != nil { + return fmt.Errorf("failed to merge source metadata: %w", err) + } + targetLabels, targetAnnotations, err = mergeTargetMetadata(remoteSecret, pushMeta, sourceLabels, sourceAnnotations) + if err != nil { + return fmt.Errorf("failed to merge target metadata: %w", err) + } + remoteSecret.ObjectMeta.Labels = targetLabels + remoteSecret.ObjectMeta.Annotations = targetAnnotations + + // case 1: push the whole secret + if remoteRef.GetProperty() == "" { + for k, v := range localSecret.Data { + remoteSecret.Data[k] = v + } return nil } - return c.updateProperty(ctx, extSecret, data, secret.Data[data.GetSecretKey()]) + // cases 2a + 2b: push into a property. + // if secret key is empty, we will marshal the whole secret and put it into + // the property defined in the remoteRef. + if remoteRef.GetSecretKey() == "" { + value, err := c.marshalData(localSecret) + if err != nil { + return err + } + remoteSecret.Data[remoteRef.GetProperty()] = value + } else { + // if secret key is defined, we will push that key from the local secret + remoteSecret.Data[remoteRef.GetProperty()] = localSecret.Data[remoteRef.GetSecretKey()] + } + return nil +} + +func (c *Client) createOrUpdate(ctx context.Context, targetSecret *v1.Secret, f func() error) error { + target, err := c.userSecretClient.Get(ctx, targetSecret.Name, metav1.GetOptions{}) + metrics.ObserveAPICall(constants.ProviderKubernetes, constants.CallKubernetesGetSecret, err) + if err != nil { + if !apierrors.IsNotFound(err) { + return err + } + if err := f(); err != nil { + return err + } + _, err := c.userSecretClient.Create(ctx, targetSecret, metav1.CreateOptions{}) + metrics.ObserveAPICall(constants.ProviderKubernetes, constants.CallKubernetesCreateSecret, err) + if err != nil { + return err + } + return nil + } + + *targetSecret = *target + existing := targetSecret.DeepCopyObject() + if err := f(); err != nil { + return err + } + + if equality.Semantic.DeepEqual(existing, targetSecret) { + return nil + } + + _, err = c.userSecretClient.Update(ctx, targetSecret, metav1.UpdateOptions{}) + metrics.ObserveAPICall(constants.ProviderKubernetes, constants.CallKubernetesUpdateSecret, err) + if err != nil { + return err + } + return nil } func (c *Client) marshalData(secret *v1.Secret) ([]byte, error) { @@ -337,41 +393,6 @@ func convertMap(in map[string][]byte) map[string]string { return out } -func (c *Client) createSecret(ctx context.Context, secret *v1.Secret, typed v1.SecretType, remoteRef esv1beta1.PushSecretData) error { - data := make(map[string][]byte) - - if remoteRef.GetProperty() != "" { - // set a specific remote key - if remoteRef.GetSecretKey() == "" { - value, err := c.marshalData(secret) - if err != nil { - return err - } - - data[remoteRef.GetProperty()] = value - } else { - // push a specific secret key into a specific remote property - data[remoteRef.GetProperty()] = secret.Data[remoteRef.GetSecretKey()] - } - } else { - // push the whole secret as is using each key of the secret as a property in the created secret - data = secret.Data - } - - s := v1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: remoteRef.GetRemoteKey(), - Namespace: c.store.RemoteNamespace, - }, - Data: data, - Type: typed, - } - - _, err := c.userSecretClient.Create(ctx, &s, metav1.CreateOptions{}) - metrics.ObserveAPICall(constants.ProviderKubernetes, constants.CallKubernetesCreateSecret, err) - return err -} - // fullDelete removes remote secret completely. func (c *Client) fullDelete(ctx context.Context, secretName string) error { err := c.userSecretClient.Delete(ctx, secretName, metav1.DeleteOptions{}) @@ -392,33 +413,6 @@ func (c *Client) removeProperty(ctx context.Context, extSecret *v1.Secret, remot return err } -func (c *Client) updateMap(ctx context.Context, extSecret *v1.Secret, values map[string][]byte) error { - // update the existing map with values from the pushed secret but keep existing values in tack. - for k, v := range values { - extSecret.Data[k] = v - } - - return c.updateSecret(ctx, extSecret) -} - -func (c *Client) updateProperty(ctx context.Context, extSecret *v1.Secret, remoteRef esv1beta1.PushSecretRemoteRef, value []byte) error { - if extSecret.Data == nil { - extSecret.Data = make(map[string][]byte) - } - - // otherwise update remote secret - extSecret.Data[remoteRef.GetProperty()] = value - - return c.updateSecret(ctx, extSecret) -} - -func (c *Client) updateSecret(ctx context.Context, extSecret *v1.Secret) error { - _, err := c.userSecretClient.Update(ctx, extSecret, metav1.UpdateOptions{}) - metrics.ObserveAPICall(constants.ProviderKubernetes, constants.CallKubernetesUpdateSecret, err) - - return err -} - func getSecret(secret *v1.Secret, ref esv1beta1.ExternalSecretDataRemoteRef) ([]byte, error) { if ref.MetadataPolicy == esv1beta1.ExternalSecretMetadataPolicyFetch { s, found, err := getFromSecretMetadata(secret, ref) diff --git a/pkg/provider/kubernetes/client_test.go b/pkg/provider/kubernetes/client_test.go index 744ef9686..b55ee2d52 100644 --- a/pkg/provider/kubernetes/client_test.go +++ b/pkg/provider/kubernetes/client_test.go @@ -24,6 +24,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/stretchr/testify/assert" v1 "k8s.io/api/core/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" @@ -86,8 +87,9 @@ func (fk *fakeClient) Delete(_ context.Context, name string, _ metav1.DeleteOpti func (fk *fakeClient) Create(_ context.Context, secret *v1.Secret, _ metav1.CreateOptions) (*v1.Secret, error) { s := &v1.Secret{ - Data: secret.Data, - Type: secret.Type, + Data: secret.Data, + ObjectMeta: secret.ObjectMeta, + Type: secret.Type, } fk.secretMap[secret.Name] = s return s, nil @@ -98,6 +100,7 @@ func (fk *fakeClient) Update(_ context.Context, secret *v1.Secret, _ metav1.Upda if !ok { return nil, errors.New("error while updating secret") } + s.ObjectMeta = secret.ObjectMeta s.Data = secret.Data return s, nil } @@ -705,6 +708,9 @@ func TestDeleteSecret(t *testing.T) { wantErr: false, wantSecretMap: map[string]*v1.Secret{ "mysec": { + ObjectMeta: metav1.ObjectMeta{ + Name: "mysec", + }, Data: map[string][]byte{ "secret": []byte(`bar`), }, @@ -797,6 +803,11 @@ func TestPushSecret(t *testing.T) { }, wantSecretMap: map[string]*v1.Secret{ "mysec": { + ObjectMeta: metav1.ObjectMeta{ + Name: "mysec", + Labels: map[string]string{}, + Annotations: map[string]string{}, + }, Data: map[string][]byte{ "token": []byte(`foo`), "token2": []byte(`foo`), @@ -827,6 +838,11 @@ func TestPushSecret(t *testing.T) { }, wantSecretMap: map[string]*v1.Secret{ "mysec": { + ObjectMeta: metav1.ObjectMeta{ + Name: "mysec", + Labels: map[string]string{}, + Annotations: map[string]string{}, + }, Data: map[string][]byte{ "token": []byte(`{"foo":"bar"}`), }, @@ -856,6 +872,11 @@ func TestPushSecret(t *testing.T) { }, wantSecretMap: map[string]*v1.Secret{ "mysec": { + ObjectMeta: metav1.ObjectMeta{ + Name: "mysec", + Labels: map[string]string{}, + Annotations: map[string]string{}, + }, Data: map[string][]byte{ "token": []byte(`foo`), "token2": []byte(`{"foo":"bar"}`), @@ -883,6 +904,11 @@ func TestPushSecret(t *testing.T) { }, wantSecretMap: map[string]*v1.Secret{ "mysec": { + ObjectMeta: metav1.ObjectMeta{ + Name: "mysec", + Labels: map[string]string{}, + Annotations: map[string]string{}, + }, Data: map[string][]byte{ "marshaled": []byte(`{"token":"foo","token2":"2"}`), }, @@ -915,6 +941,11 @@ func TestPushSecret(t *testing.T) { wantErr: false, wantSecretMap: map[string]*v1.Secret{ "mysec": { + ObjectMeta: metav1.ObjectMeta{ + Name: "mysec", + Labels: map[string]string{}, + Annotations: map[string]string{}, + }, Data: map[string][]byte{ "token": []byte(`foo`), "secret": []byte(`bar`), @@ -947,6 +978,11 @@ func TestPushSecret(t *testing.T) { wantErr: false, wantSecretMap: map[string]*v1.Secret{ "mysec": { + ObjectMeta: metav1.ObjectMeta{ + Name: "mysec", + Labels: map[string]string{}, + Annotations: map[string]string{}, + }, Data: map[string][]byte{ "token": []byte(`bar`), }, @@ -954,7 +990,216 @@ func TestPushSecret(t *testing.T) { }, }, { - name: "create new secret", + name: "replace existing property in existing secret with targetMergePolicy set to Ignore", + fields: fields{ + Client: &fakeClient{ + t: t, + secretMap: map[string]*v1.Secret{ + "mysec": { + Data: map[string][]byte{ + "token": []byte(`foo`), + }, + }, + }, + }, + }, + secret: &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "mysec", + // these should be ignored as the targetMergePolicy is set to Ignore + Labels: map[string]string{"dev": "seb"}, + Annotations: map[string]string{"date": "today"}, + }, + Data: map[string][]byte{secretKey: []byte("bar")}, + }, + data: testingfake.PushSecretData{ + SecretKey: secretKey, + RemoteKey: "mysec", + Property: "token", + Metadata: &apiextensionsv1.JSON{ + Raw: []byte(`{"apiVersion":"kubernetes.external-secrets.io/v1alpha1", "kind": "PushSecretMetadata", spec: {"targetMergePolicy": "Ignore"}}`), + }, + }, + wantErr: false, + wantSecretMap: map[string]*v1.Secret{ + "mysec": { + ObjectMeta: metav1.ObjectMeta{ + Name: "mysec", + Labels: map[string]string{}, + Annotations: map[string]string{}, + }, + Data: map[string][]byte{ + "token": []byte(`bar`), + }, + }, + }, + }, + { + name: "replace existing property in existing secret with targetMergePolicy set to Replace", + fields: fields{ + Client: &fakeClient{ + t: t, + secretMap: map[string]*v1.Secret{ + "mysec": { + ObjectMeta: metav1.ObjectMeta{ + Name: "mysec", + Labels: map[string]string{ + "already": "existing", + }, + Annotations: map[string]string{ + "already": "existing", + }, + }, + Data: map[string][]byte{ + "token": []byte(`foo`), + }, + }, + }, + }, + }, + secret: &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "mysec", + // these should replace existing metadata as the targetMergePolicy is set to Replace + Labels: map[string]string{"dev": "seb"}, + Annotations: map[string]string{"date": "today"}, + }, + Data: map[string][]byte{secretKey: []byte("bar")}, + }, + data: testingfake.PushSecretData{ + SecretKey: secretKey, + RemoteKey: "mysec", + Property: "token", + Metadata: &apiextensionsv1.JSON{ + Raw: []byte(`{"apiVersion":"kubernetes.external-secrets.io/v1alpha1", "kind": "PushSecretMetadata", spec: {"targetMergePolicy": "Replace"}}`), + }, + }, + wantErr: false, + wantSecretMap: map[string]*v1.Secret{ + "mysec": { + ObjectMeta: metav1.ObjectMeta{ + Name: "mysec", + Labels: map[string]string{ + "dev": "seb", + }, + Annotations: map[string]string{ + "date": "today", + }, + }, + Data: map[string][]byte{ + "token": []byte(`bar`), + }, + }, + }, + }, + { + name: "create new secret, merging existing metadata", + fields: fields{ + Client: &fakeClient{ + t: t, + secretMap: map[string]*v1.Secret{ + "yoursec": { + Data: map[string][]byte{ + "token": []byte(`foo`), + }, + }, + }, + }, + }, + secret: &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + "this-annotation": "should be present on the targey secret", + }, + }, + Data: map[string][]byte{secretKey: []byte("bar")}, + }, + data: testingfake.PushSecretData{ + SecretKey: secretKey, + RemoteKey: "mysec", + Property: "secret", + Metadata: &apiextensionsv1.JSON{ + Raw: []byte(`{"apiVersion":"kubernetes.external-secrets.io/v1alpha1", "kind": "PushSecretMetadata", spec: {"annotations": {"date": "today"}, "labels": {"dev": "seb"}}}`), + }, + }, + wantErr: false, + wantSecretMap: map[string]*v1.Secret{ + "yoursec": { + Data: map[string][]byte{ + "token": []byte(`foo`), + }, + }, + "mysec": { + ObjectMeta: metav1.ObjectMeta{ + Name: "mysec", + Annotations: map[string]string{ + "date": "today", + "this-annotation": "should be present on the targey secret", + }, + Labels: map[string]string{"dev": "seb"}, + }, + Data: map[string][]byte{ + "secret": []byte(`bar`), + }, + Type: v1.SecretTypeOpaque, + }, + }, + }, + { + name: "create new secret with metadata from secret metadata and remoteRef.metadata", + fields: fields{ + Client: &fakeClient{ + t: t, + secretMap: map[string]*v1.Secret{ + "yoursec": { + Data: map[string][]byte{ + "token": []byte(`foo`), + }, + }, + }, + }, + }, + secret: &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{"date": "today"}, + Labels: map[string]string{"dev": "seb"}, + }, + Data: map[string][]byte{secretKey: []byte("bar")}, + }, + data: testingfake.PushSecretData{ + SecretKey: secretKey, + RemoteKey: "mysec", + Property: "secret", + Metadata: &apiextensionsv1.JSON{ + Raw: []byte(`{"apiVersion":"kubernetes.external-secrets.io/v1alpha1", "kind": "PushSecretMetadata", spec: { "sourceMergePolicy": "Replace", "annotations": {"another-field": "from-remote-ref"}, "labels": {"other-label": "from-remote-ref"}}}`), + }, + }, + wantErr: false, + wantSecretMap: map[string]*v1.Secret{ + "yoursec": { + Data: map[string][]byte{ + "token": []byte(`foo`), + }, + }, + "mysec": { + ObjectMeta: metav1.ObjectMeta{ + Name: "mysec", + Annotations: map[string]string{ + "another-field": "from-remote-ref", + }, + Labels: map[string]string{ + "other-label": "from-remote-ref", + }, + }, + Data: map[string][]byte{ + "secret": []byte(`bar`), + }, + Type: v1.SecretTypeOpaque, + }, + }, + }, + { + name: "invalid secret metadata structure results in error", fields: fields{ Client: &fakeClient{ t: t, @@ -974,6 +1219,75 @@ func TestPushSecret(t *testing.T) { SecretKey: secretKey, RemoteKey: "mysec", Property: "secret", + Metadata: &apiextensionsv1.JSON{ + Raw: []byte(`{}`), + }, + }, + wantErr: true, + wantSecretMap: map[string]*v1.Secret{ + "yoursec": { + Data: map[string][]byte{ + "token": []byte(`foo`), + }, + }, + }, + }, + { + name: "non-json secret metadata results in error", + fields: fields{ + Client: &fakeClient{ + t: t, + secretMap: map[string]*v1.Secret{ + "yoursec": { + Data: map[string][]byte{ + "token": []byte(`foo`), + }, + }, + }, + }, + }, + secret: &v1.Secret{ + Data: map[string][]byte{secretKey: []byte("bar")}, + }, + data: testingfake.PushSecretData{ + SecretKey: secretKey, + RemoteKey: "mysec", + Property: "secret", + Metadata: &apiextensionsv1.JSON{ + Raw: []byte(`--- not json ---`), + }, + }, + wantErr: true, + wantSecretMap: map[string]*v1.Secret{ + "yoursec": { + Data: map[string][]byte{ + "token": []byte(`foo`), + }, + }, + }, + }, + { + name: "create new secret with whole secret", + fields: fields{ + Client: &fakeClient{ + t: t, + secretMap: map[string]*v1.Secret{ + "yoursec": { + Data: map[string][]byte{ + "token": []byte(`foo`), + }, + }, + }, + }, + }, + secret: &v1.Secret{ + Data: map[string][]byte{ + "foo": []byte("bar"), + "baz": []byte("bang"), + }, + }, + data: testingfake.PushSecretData{ + RemoteKey: "mysec", }, wantErr: false, wantSecretMap: map[string]*v1.Secret{ @@ -983,8 +1297,14 @@ func TestPushSecret(t *testing.T) { }, }, "mysec": { + ObjectMeta: metav1.ObjectMeta{ + Name: "mysec", + Labels: map[string]string{}, + Annotations: map[string]string{}, + }, Data: map[string][]byte{ - "secret": []byte(`bar`), + "foo": []byte("bar"), + "baz": []byte("bang"), }, Type: v1.SecretTypeOpaque, }, @@ -1021,13 +1341,19 @@ func TestPushSecret(t *testing.T) { }, }, "mysec": { + ObjectMeta: metav1.ObjectMeta{ + Name: "mysec", + Labels: map[string]string{}, + Annotations: map[string]string{}, + }, Data: map[string][]byte{ "config.json": []byte(`{"auths": {"myregistry.localhost": {"username": "{{ .username }}", "password": "{{ .password }}"}}}`), }, Type: v1.SecretTypeDockerConfigJson, }, }, - }} + }, + } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { p := &Client{ diff --git a/pkg/provider/kubernetes/metadata.go b/pkg/provider/kubernetes/metadata.go new file mode 100644 index 000000000..29d5abe4b --- /dev/null +++ b/pkg/provider/kubernetes/metadata.go @@ -0,0 +1,148 @@ +/* +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 ( + "fmt" + + v1 "k8s.io/api/core/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/yaml" +) + +const ( + metadataAPIVersion = "kubernetes.external-secrets.io/v1alpha1" + metadataKind = "PushSecretMetadata" +) + +type PushSecretMetadata struct { + metav1.TypeMeta + Spec PushSecretMetadataSpec `json:"spec,omitempty"` +} +type PushSecretMetadataSpec struct { + TargetMergePolicy targetMergePolicy `json:"targetMergePolicy,omitempty"` + SourceMergePolicy sourceMergePolicy `json:"sourceMergePolicy,omitempty"` + + Labels map[string]string `json:"labels,omitempty"` + Annotations map[string]string `json:"annotations,omitempty"` +} + +type targetMergePolicy string + +const ( + targetMergePolicyMerge targetMergePolicy = "Merge" + targetMergePolicyReplace targetMergePolicy = "Replace" + targetMergePolicyIgnore targetMergePolicy = "Ignore" +) + +type sourceMergePolicy string + +const ( + sourceMergePolicyMerge sourceMergePolicy = "Merge" + sourceMergePolicyReplace sourceMergePolicy = "Replace" +) + +func parseMetadataParameters(data *apiextensionsv1.JSON) (*PushSecretMetadata, error) { + if data == nil { + return nil, nil + } + var metadata PushSecretMetadata + err := yaml.Unmarshal(data.Raw, &metadata, yaml.DisallowUnknownFields) + if err != nil { + return nil, fmt.Errorf("failed to parse %s %s: %w", metadataAPIVersion, metadataKind, err) + } + + if metadata.APIVersion != metadataAPIVersion { + return nil, fmt.Errorf("unexpected apiVersion %q, expected %q", metadata.APIVersion, metadataAPIVersion) + } + + if metadata.Kind != metadataKind { + return nil, fmt.Errorf("unexpected kind %q, expected %q", metadata.Kind, metadataKind) + } + + return &metadata, nil +} + +// Takes the local secret metadata and merges it with the push metadata. +// The push metadata takes precedence. +// Depending on the policy, we either merge or overwrite the metadata from the local secret. +func mergeSourceMetadata(localSecret *v1.Secret, pushMeta *PushSecretMetadata) (map[string]string, map[string]string, error) { + labels := localSecret.ObjectMeta.Labels + annotations := localSecret.ObjectMeta.Annotations + if pushMeta == nil { + return labels, annotations, nil + } + if labels == nil { + labels = make(map[string]string) + } + if annotations == nil { + annotations = make(map[string]string) + } + + switch pushMeta.Spec.SourceMergePolicy { + case "", sourceMergePolicyMerge: + for k, v := range pushMeta.Spec.Labels { + labels[k] = v + } + for k, v := range pushMeta.Spec.Annotations { + annotations[k] = v + } + case sourceMergePolicyReplace: + labels = pushMeta.Spec.Labels + annotations = pushMeta.Spec.Annotations + default: + return nil, nil, fmt.Errorf("unexpected source merge policy %q", pushMeta.Spec.SourceMergePolicy) + } + return labels, annotations, nil +} + +// Takes the remote secret metadata and merges it with the source metadata. +// The source metadata may replace the existing labels/annotations +// or merge into it depending on policy. +func mergeTargetMetadata(remoteSecret *v1.Secret, pushMeta *PushSecretMetadata, sourceLabels, sourceAnnotations map[string]string) (map[string]string, map[string]string, error) { + labels := remoteSecret.ObjectMeta.Labels + annotations := remoteSecret.ObjectMeta.Annotations + if labels == nil { + labels = make(map[string]string) + } + if annotations == nil { + annotations = make(map[string]string) + } + var targetMergePolicy targetMergePolicy + if pushMeta != nil { + targetMergePolicy = pushMeta.Spec.TargetMergePolicy + } + + switch targetMergePolicy { + case "", targetMergePolicyMerge: + for k, v := range sourceLabels { + labels[k] = v + } + for k, v := range sourceAnnotations { + annotations[k] = v + } + case targetMergePolicyReplace: + labels = sourceLabels + annotations = sourceAnnotations + case targetMergePolicyIgnore: + // leave the target metadata as is + // this is useful when we only want to push data + // and the user does not want to touch the metadata + default: + return nil, nil, fmt.Errorf("unexpected target merge policy %q", targetMergePolicy) + } + return labels, annotations, nil +}