diff --git a/deploy/charts/trust-manager/README.md b/deploy/charts/trust-manager/README.md index 3f213b72..1f485201 100644 --- a/deploy/charts/trust-manager/README.md +++ b/deploy/charts/trust-manager/README.md @@ -39,6 +39,7 @@ Kubernetes: `>= 1.22.0-0` | app.webhook.service | object | `{"type":"ClusterIP"}` | Type of Kubernetes Service used by the Webhook | | app.webhook.timeoutSeconds | int | `5` | Timeout of webhook HTTP request. | | crds.enabled | bool | `true` | Whether or not to install the crds. | +| authorizedSecrets[0] | string | `"ca-bundle"` | | | defaultPackageImage.pullPolicy | string | `"IfNotPresent"` | imagePullPolicy for the default package image | | defaultPackageImage.repository | string | `"quay.io/jetstack/cert-manager-package-debian"` | Repository for the default package image. This image enables the 'useDefaultCAs' source on Bundles. | | defaultPackageImage.tag | string | `"20210119.0"` | Tag for the default package image | diff --git a/deploy/charts/trust-manager/templates/clusterrole.yaml b/deploy/charts/trust-manager/templates/clusterrole.yaml index 8de40cc5..15803744 100644 --- a/deploy/charts/trust-manager/templates/clusterrole.yaml +++ b/deploy/charts/trust-manager/templates/clusterrole.yaml @@ -31,6 +31,17 @@ rules: - "configmaps" verbs: ["get", "list", "create", "update", "watch", "delete"] +- apiGroups: + - "" + resources: + - "secrets" + verbs: ["get", "list", "update", "watch", "delete"] + resourceNames: {{ .Values.authorizedSecrets | toYaml | nindent 2 }} +- apiGroups: + - "" + resources: + - "secrets" + verbs: ["create"] - apiGroups: - "" resources: diff --git a/deploy/charts/trust-manager/templates/trust.cert-manager.io_bundles.yaml b/deploy/charts/trust-manager/templates/trust.cert-manager.io_bundles.yaml index 94d2b347..b3fe21cb 100644 --- a/deploy/charts/trust-manager/templates/trust.cert-manager.io_bundles.yaml +++ b/deploy/charts/trust-manager/templates/trust.cert-manager.io_bundles.yaml @@ -115,6 +115,15 @@ spec: type: object additionalProperties: type: string + secret: + description: Secret is the target Secret in Namespaces that all Bundle source data will be synced to. + type: object + required: + - key + properties: + key: + description: Key is the key of the entry in the object's `data` field to be used. + type: string status: description: Status of the Bundle. This is set and managed automatically. type: object @@ -174,6 +183,15 @@ spec: type: object additionalProperties: type: string + secret: + description: Secret is the target Secret in Namespaces that all Bundle source data will be synced to. + type: object + required: + - key + properties: + key: + description: Key is the key of the entry in the object's `data` field to be used. + type: string served: true storage: true subresources: diff --git a/deploy/charts/trust-manager/values.yaml b/deploy/charts/trust-manager/values.yaml index 65c98a8f..776fa047 100644 --- a/deploy/charts/trust-manager/values.yaml +++ b/deploy/charts/trust-manager/values.yaml @@ -67,6 +67,8 @@ app: # -- If false, disables the default seccomp profile, which might be required to run on certain platforms seccompProfileEnabled: true +authorizedSecrets: ['ca-bundle'] + resources: {} # -- Kubernetes pod resource limits for trust. # limits: diff --git a/pkg/apis/trust/v1alpha1/types_bundle.go b/pkg/apis/trust/v1alpha1/types_bundle.go index 9ed239a0..59d96578 100644 --- a/pkg/apis/trust/v1alpha1/types_bundle.go +++ b/pkg/apis/trust/v1alpha1/types_bundle.go @@ -96,6 +96,10 @@ type BundleTarget struct { // data will be synced to. ConfigMap *KeySelector `json:"configMap,omitempty"` + // Secret is the target Secret in Namespaces that all Bundle source + // data will be synced to. + Secret *KeySelector `json:"secret,omitempty"` + // NamespaceSelector will, if set, only sync the target resource in // Namespaces which match the selector. // +optional diff --git a/pkg/bundle/bundle.go b/pkg/bundle/bundle.go index a43bea79..d359be82 100644 --- a/pkg/bundle/bundle.go +++ b/pkg/bundle/bundle.go @@ -202,7 +202,26 @@ func (b *bundle) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, return ctrl.Result{Requeue: true}, b.targetDirectClient.Status().Update(ctx, &bundle) } - if synced { + secretSynced := false + if bundle.Spec.Target.Secret != nil { + var syncErr error + secretSynced, syncErr = b.syncSecretTarget(ctx, log, &bundle, namespaceSelector, &namespace, []byte(resolvedBundle.data)) + if syncErr != nil { + log.Error(syncErr, "failed sync bundle to target namespace") + b.recorder.Eventf(&bundle, corev1.EventTypeWarning, "SyncTargetFailed", "Failed to sync secret target in Namespace %q: %s", namespace.Name, syncErr) + + b.setBundleCondition(&bundle, trustapi.BundleCondition{ + Type: trustapi.BundleConditionSynced, + Status: corev1.ConditionFalse, + Reason: "SyncTargetFailed", + Message: fmt.Sprintf("Failed to sync bundle target secret to namespace %q: %s", namespace.Name, syncErr), + }) + + return ctrl.Result{Requeue: true}, b.targetDirectClient.Status().Update(ctx, &bundle) + } + } + + if synced || secretSynced { // We need to update if any target is synced. needsUpdate = true } diff --git a/pkg/bundle/sync.go b/pkg/bundle/sync.go index 2cb17615..d09e469a 100644 --- a/pkg/bundle/sync.go +++ b/pkg/bundle/sync.go @@ -18,6 +18,7 @@ package bundle import ( "context" + "bytes" "errors" "fmt" "strings" @@ -210,7 +211,7 @@ func (b *bundle) syncTarget(ctx context.Context, log logr.Logger, } // Match, return do nothing - if cmdata, ok := configMap.Data[target.ConfigMap.Key]; !ok || cmdata != data { + if cmdata, ok2 := configMap.Data[target.ConfigMap.Key]; !ok2 || cmdata != data { if configMap.Data == nil { configMap.Data = make(map[string]string) } @@ -223,7 +224,7 @@ func (b *bundle) syncTarget(ctx context.Context, log logr.Logger, return false, nil } - if err := b.targetDirectClient.Update(ctx, &configMap); err != nil { + if err = b.targetDirectClient.Update(ctx, &configMap); err != nil { return true, fmt.Errorf("failed to update configmap %s/%s with bundle: %w", namespace, bundle.Name, err) } @@ -231,3 +232,92 @@ func (b *bundle) syncTarget(ctx context.Context, log logr.Logger, return true, nil } + +// syncSecretTarget syncs the given data to the target Secret in the given namespace. +// The name of the Secret is the same as the Bundle. +// Ensures the Secret is owned by the given Bundle, and the data is up to date. +// Returns true if the Secret has been created or was updated. +func (b *bundle) syncSecretTarget(ctx context.Context, log logr.Logger, + bundle *trustapi.Bundle, + namespaceSelector labels.Selector, + namespace *corev1.Namespace, + data []byte, +) (bool, error) { + target := bundle.Spec.Target + + if target.Secret == nil { + // Fail silently, since target.Secret is optional + return false, nil + } + + matchNamespace := namespaceSelector.Matches(labels.Set(namespace.Labels)) + + var secret corev1.Secret + err := b.targetDirectClient.Get(ctx, client.ObjectKey{Namespace: namespace.Name, Name: bundle.Name}, &secret) + + // If the Secret doesn't exist yet, create it. + if apierrors.IsNotFound(err) { + // If the namespace doesn't match selector we do nothing since we don't + // want to create it, and it also doesn't exist. + if !matchNamespace { + log.V(4).Info("ignoring namespace as it doesn't match selector", "labels", namespace.Labels) + return false, nil + } + + secret = corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: bundle.Name, + Namespace: namespace.Name, + OwnerReferences: []metav1.OwnerReference{*metav1.NewControllerRef(bundle, trustapi.SchemeGroupVersion.WithKind("Bundle"))}, + }, + Type: "Opaque", + Data: map[string][]byte{ + target.Secret.Key: data, + }, + } + + return true, b.targetDirectClient.Create(ctx, &secret) + } + + // Here, the secret exists, but the selector doesn't match the namespace. + if !matchNamespace { + // The ConfigMap is owned by this controller- delete it. + if metav1.IsControlledBy(&secret, bundle) { + log.V(2).Info("deleting bundle from Namespace since namespaceSelector does not match") + return true, b.targetDirectClient.Delete(ctx, &secret) + } + // The ConfigMap isn't owned by us, so we shouldn't delete it. Return that + // we did nothing. + b.recorder.Eventf(&secret, corev1.EventTypeWarning, "NotOwned", "Secret is not owned by trust.cert-manager.io so ignoring") + return false, nil + } + + var needsUpdate bool + // If ConfigMap is missing OwnerReference, add it back. + if !metav1.IsControlledBy(&secret, bundle) { + secret.OwnerReferences = append(secret.OwnerReferences, *metav1.NewControllerRef(bundle, trustapi.SchemeGroupVersion.WithKind("Bundle"))) + needsUpdate = true + } + + // Match, return do nothing + if cmdata, ok := secret.Data[target.Secret.Key]; !ok || bytes.Compare(cmdata, data) != 0 { + if secret.Data == nil { + secret.Data = make(map[string][]byte) + } + secret.Data[target.Secret.Key] = data + needsUpdate = true + } + + // Exit early if no update is needed + if !needsUpdate { + return false, nil + } + + if err := b.targetDirectClient.Update(ctx, &secret); err != nil { + return true, fmt.Errorf("failed to update secret %s/%s with bundle: %w", namespace, bundle.Name, err) + } + + log.V(2).Info("synced bundle to namespace") + + return true, nil +} diff --git a/pkg/bundle/sync_test.go b/pkg/bundle/sync_test.go index 57487092..fa37d83f 100644 --- a/pkg/bundle/sync_test.go +++ b/pkg/bundle/sync_test.go @@ -374,6 +374,344 @@ func Test_syncTarget(t *testing.T) { } } +func Test_syncSecretTarget(t *testing.T) { + const ( + bundleName = "test-bundle" + key = "key" + data = dummy.TestCertificate1 + ) + + labelEverything := func(*testing.T) labels.Selector { + return labels.Everything() + } + + tests := map[string]struct { + object runtime.Object + namespace corev1.Namespace + selector func(t *testing.T) labels.Selector + // Expect the configmap to exist at the end of the sync. + expExists bool + expEvent string + // Expect the owner reference of the configmap to point to the bundle. + expOwnerReference bool + expNeedsUpdate bool + }{ + "if object doesn't exist, expect update": { + object: nil, + namespace: corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "test-namespace"}}, + selector: labelEverything, + expExists: true, + expOwnerReference: true, + expNeedsUpdate: true, + }, + "if object exists but without data or owner, expect update": { + object: &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: bundleName, Namespace: "test-namespace"}}, + namespace: corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "test-namespace"}}, + selector: labelEverything, + expExists: true, + expOwnerReference: true, + expNeedsUpdate: true, + }, + "if object exists with data but no owner, expect update": { + object: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: bundleName, Namespace: "test-namespace"}, + Data: map[string][]byte{key: []byte(data)}, + }, + namespace: corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "test-namespace"}}, + selector: labelEverything, + expExists: true, + expOwnerReference: true, + expNeedsUpdate: true, + }, + "if object exists with owner but no data, expect update": { + object: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: bundleName, + Namespace: "test-namespace", + OwnerReferences: []metav1.OwnerReference{ + { + Kind: "Bundle", + APIVersion: "trust.cert-manager.io/v1alpha1", + Name: bundleName, + Controller: pointer.Bool(true), + BlockOwnerDeletion: pointer.Bool(true), + }, + }, + }, + }, + namespace: corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "test-namespace"}}, + selector: labelEverything, + expExists: true, + expOwnerReference: true, + expNeedsUpdate: true, + }, + "if object exists with owner but wrong data, expect update": { + object: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: bundleName, + Namespace: "test-namespace", + OwnerReferences: []metav1.OwnerReference{ + { + Kind: "Bundle", + APIVersion: "trust.cert-manager.io/v1alpha1", + Name: bundleName, + Controller: pointer.Bool(true), + BlockOwnerDeletion: pointer.Bool(true), + }, + }, + }, + Data: map[string][]byte{key: []byte("wrong data")}, + }, + namespace: corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "test-namespace"}}, + selector: labelEverything, + expExists: true, + expOwnerReference: true, + expNeedsUpdate: true, + }, + "if object exists with owner but wrong key, expect update": { + object: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: bundleName, + Namespace: "test-namespace", + OwnerReferences: []metav1.OwnerReference{ + { + Kind: "Bundle", + APIVersion: "trust.cert-manager.io/v1alpha1", + Name: bundleName, + Controller: pointer.Bool(true), + BlockOwnerDeletion: pointer.Bool(true), + }, + }, + }, + Data: map[string][]byte{"wrong key": []byte(data)}, + }, + namespace: corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "test-namespace"}}, + selector: labelEverything, + expExists: true, + expOwnerReference: true, + expNeedsUpdate: true, + }, + "if object exists with correct data, expect no update": { + object: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: bundleName, + Namespace: "test-namespace", + OwnerReferences: []metav1.OwnerReference{ + { + Kind: "Bundle", + APIVersion: "trust.cert-manager.io/v1alpha1", + Name: bundleName, + Controller: pointer.Bool(true), + BlockOwnerDeletion: pointer.Bool(true), + }, + }, + }, + Data: map[string][]byte{key: []byte(data)}, + }, + namespace: corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "test-namespace"}}, + selector: labelEverything, + expExists: true, + expOwnerReference: true, + expNeedsUpdate: false, + }, + "if object exists with correct data and some extra data and owner, expect no update": { + object: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: bundleName, + Namespace: "test-namespace", + OwnerReferences: []metav1.OwnerReference{ + { + Kind: "Bundle", + APIVersion: "trust.cert-manager.io/v1alpha1", + Name: bundleName, + Controller: pointer.Bool(true), + BlockOwnerDeletion: pointer.Bool(true), + }, + { + Kind: "Bundle", + APIVersion: "trust.cert-manager.io/v1alpha1", + Name: "another-bundle", + Controller: pointer.Bool(true), + BlockOwnerDeletion: pointer.Bool(true), + }, + }, + }, + Data: map[string][]byte{key: []byte(data), "another-key": []byte("another-data")}, + }, + namespace: corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "test-namespace"}}, + selector: labelEverything, + expExists: true, + expOwnerReference: true, + expNeedsUpdate: false, + }, + "if object doesn't exist and labels match, expect update": { + object: nil, + namespace: corev1.Namespace{ObjectMeta: metav1.ObjectMeta{ + Name: "test-namespace", + Labels: map[string]string{"foo": "bar"}, + }}, + selector: func(t *testing.T) labels.Selector { + req, err := labels.NewRequirement("foo", selection.Equals, []string{"bar"}) + assert.NoError(t, err) + return labels.NewSelector().Add(*req) + }, + expExists: true, + expOwnerReference: true, + expNeedsUpdate: true, + }, + "if object doesn't exist and labels don't match, don't expect update": { + object: nil, + namespace: corev1.Namespace{ObjectMeta: metav1.ObjectMeta{ + Name: "test-namespace", + Labels: map[string]string{"bar": "foo"}, + }}, + selector: func(t *testing.T) labels.Selector { + req, err := labels.NewRequirement("foo", selection.Equals, []string{"bar"}) + assert.NoError(t, err) + return labels.NewSelector().Add(*req) + }, + expExists: false, + expOwnerReference: true, + expNeedsUpdate: false, + }, + "if object exists with correct data and labels match, expect no update": { + object: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: bundleName, + Namespace: "test-namespace", + OwnerReferences: []metav1.OwnerReference{ + { + Kind: "Bundle", + APIVersion: "trust.cert-manager.io/v1alpha1", + Name: bundleName, + Controller: pointer.Bool(true), + BlockOwnerDeletion: pointer.Bool(true), + }, + }, + }, + Data: map[string][]byte{key: []byte(data)}, + }, + namespace: corev1.Namespace{ObjectMeta: metav1.ObjectMeta{ + Name: "test-namespace", + Labels: map[string]string{"foo": "bar"}, + }}, + selector: func(t *testing.T) labels.Selector { + req, err := labels.NewRequirement("foo", selection.Equals, []string{"bar"}) + assert.NoError(t, err) + return labels.NewSelector().Add(*req) + }, + expExists: true, + expOwnerReference: true, + expNeedsUpdate: false, + }, + "if object exists with correct data but labels don't match, expect deletion": { + object: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: bundleName, + Namespace: "test-namespace", + OwnerReferences: []metav1.OwnerReference{ + { + Kind: "Bundle", + APIVersion: "trust.cert-manager.io/v1alpha1", + Name: bundleName, + Controller: pointer.Bool(true), + BlockOwnerDeletion: pointer.Bool(true), + }, + }, + }, + Data: map[string][]byte{key: []byte(data)}, + }, + namespace: corev1.Namespace{ObjectMeta: metav1.ObjectMeta{ + Name: "test-namespace", + Labels: map[string]string{"bar": "foo"}, + }}, + selector: func(t *testing.T) labels.Selector { + req, err := labels.NewRequirement("foo", selection.Equals, []string{"bar"}) + assert.NoError(t, err) + return labels.NewSelector().Add(*req) + }, + expExists: false, + expOwnerReference: false, + expNeedsUpdate: true, + }, + "if object exists and labels don't match, but controller doesn't have ownership, expect no update": { + object: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: bundleName, + Namespace: "test-namespace", + }, + Data: map[string][]byte{key: []byte(data)}, + }, + namespace: corev1.Namespace{ObjectMeta: metav1.ObjectMeta{ + Name: "test-namespace", + Labels: map[string]string{"bar": "foo"}, + }}, + selector: func(t *testing.T) labels.Selector { + req, err := labels.NewRequirement("foo", selection.Equals, []string{"bar"}) + assert.NoError(t, err) + return labels.NewSelector().Add(*req) + }, + expExists: true, + expOwnerReference: false, + expNeedsUpdate: false, + expEvent: "Warning NotOwned Secret is not owned by trust.cert-manager.io so ignoring", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + clientBuilder := fakeclient.NewClientBuilder(). + WithScheme(trustapi.GlobalScheme) + if test.object != nil { + clientBuilder.WithRuntimeObjects(test.object) + } + fakeclient := clientBuilder.Build() + fakerecorder := record.NewFakeRecorder(1) + + b := &bundle{targetDirectClient: fakeclient, recorder: fakerecorder} + + needsUpdate, err := b.syncSecretTarget(context.TODO(), klogr.New(), &trustapi.Bundle{ + ObjectMeta: metav1.ObjectMeta{Name: bundleName}, + Spec: trustapi.BundleSpec{Target: trustapi.BundleTarget{ + ConfigMap: &trustapi.KeySelector{Key: key}, + Secret: &trustapi.KeySelector{Key: key}, + }}, + }, test.selector(t), &test.namespace, []byte(data)) + assert.NoError(t, err) + + assert.Equalf(t, test.expNeedsUpdate, needsUpdate, "unexpected needsUpdate, exp=%t got=%t", test.expNeedsUpdate, needsUpdate) + + var secret corev1.Secret + err = fakeclient.Get(context.TODO(), client.ObjectKey{Namespace: test.namespace.Name, Name: bundleName}, &secret) + assert.Equalf(t, test.expExists, !apierrors.IsNotFound(err), "unexpected is not found: %v", err) + + if test.expExists { + assert.Equalf(t, data, string(secret.Data[key]), "unexpected data on Secret: exp=%s:%s got=%v", key, data, secret.Data) + + expectedOwnerReference := metav1.OwnerReference{ + Kind: "Bundle", + APIVersion: "trust.cert-manager.io/v1alpha1", + Name: bundleName, + Controller: pointer.Bool(true), + BlockOwnerDeletion: pointer.Bool(true), + } + if test.expOwnerReference { + assert.Equalf(t, expectedOwnerReference, secret.OwnerReferences[0], "unexpected data on Secret: exp=%s:%s got=%v", key, data, secret.Data) + } else { + assert.NotContains(t, secret.OwnerReferences, expectedOwnerReference) + } + } + + var event string + select { + case event = <-fakerecorder.Events: + default: + } + assert.Equal(t, test.expEvent, event) + }) + } +} + func Test_buildSourceBundle(t *testing.T) { tests := map[string]struct { bundle *trustapi.Bundle