diff --git a/pkg/bundle/inject/controller.go b/pkg/bundle/inject/controller.go new file mode 100644 index 000000000..bae9afb9d --- /dev/null +++ b/pkg/bundle/inject/controller.go @@ -0,0 +1,176 @@ +/* +Copyright 2021 The cert-manager 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 inject + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + v1 "k8s.io/client-go/applyconfigurations/core/v1" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + trustapi "github.com/cert-manager/trust-manager/pkg/apis/trust/v1alpha1" + "github.com/cert-manager/trust-manager/pkg/bundle/controller" + "github.com/cert-manager/trust-manager/pkg/bundle/internal/source" + "github.com/cert-manager/trust-manager/pkg/bundle/internal/ssa_client" +) + +const ( + // BundleInjectBundleNameLabelKey is the key of the label that will trigger the injection of bundle data into the resource. + // The label value should be the name of the bundle to inject data from. + BundleInjectBundleNameLabelKey = "inject.trust-manager.io/bundle-name" + // BundleInjectKeyLabelKey is the key for an optional label to specify the key to inject the bundle data into the resource. + // The bundle data will be injected into the 'ca-bundle.crt' key if this label is not found in resource. + BundleInjectKeyLabelKey = "inject.trust-manager.io/key" +) + +type Injector struct { + client.Client + bundleBuilder *source.BundleBuilder +} + +func (i *Injector) SetupWithManager(mgr ctrl.Manager, opts controller.Options) error { + i.bundleBuilder = &source.BundleBuilder{ + Reader: mgr.GetClient(), + Options: opts, + } + + return ctrl.NewControllerManagedBy(mgr). + Named("configmap-injector"). + For(&metav1.PartialObjectMetadata{TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "ConfigMap"}}, + builder.WithPredicates( + hasLabelPredicate(BundleInjectBundleNameLabelKey), + ), + ). + Complete(i) +} + +func (i *Injector) Reconcile(ctx context.Context, request reconcile.Request) (reconcile.Result, error) { + target := &metav1.PartialObjectMetadata{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "v1", + Kind: "ConfigMap", + }, + } + + if err := i.Get(ctx, request.NamespacedName, target); err != nil { + if apierrors.IsNotFound(err) { + return reconcile.Result{}, nil + } + return reconcile.Result{}, err + } + + bundleName := target.GetLabels()[BundleInjectBundleNameLabelKey] + if bundleName == "" { + return reconcile.Result{}, nil + } + + bundle := &trustapi.Bundle{} + if err := i.Get(ctx, types.NamespacedName{Name: bundleName}, bundle); err != nil { + return reconcile.Result{}, fmt.Errorf("failed to look up bundle %q: %w", bundleName, err) + } + + // TODO: Add support for additional formats + bundleData, err := i.bundleBuilder.BuildBundle(ctx, bundle.Spec.Sources, nil) + if err != nil { + return reconcile.Result{}, fmt.Errorf("failed to build bundle %q: %w", bundleName, err) + } + key := target.GetLabels()[BundleInjectKeyLabelKey] + if key == "" { + key = "ca-bundle.crt" + } + + applyConfig := v1.ConfigMap(request.Name, request.Namespace). + WithAnnotations(map[string]string{ + trustapi.BundleHashAnnotationKey: trustBundleHash([]byte(bundleData.Data)), + }). + WithData(map[string]string{key: bundleData.Data}) + + return reconcile.Result{}, patchConfigMap(ctx, i.Client, applyConfig) +} + +func trustBundleHash(data []byte) string { + hash := sha256.New() + _, _ = hash.Write(data) + hashValue := [32]byte{} + hash.Sum(hashValue[:0]) + dataHash := hex.EncodeToString(hashValue[:]) + return dataHash +} + +type Cleaner struct { + client.Client +} + +func (c *Cleaner) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + Named("configmap-injector-cleaner"). + For(&metav1.PartialObjectMetadata{TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "ConfigMap"}}, + builder.WithPredicates( + hasAnnotationPredicate(trustapi.BundleHashAnnotationKey), + predicate.Not(hasLabelPredicate(BundleInjectBundleNameLabelKey)), + ), + ). + Complete(c) +} + +func (c *Cleaner) Reconcile(ctx context.Context, request reconcile.Request) (reconcile.Result, error) { + applyConfig := v1.ConfigMap(request.Name, request.Namespace) + + return reconcile.Result{}, patchConfigMap(ctx, c.Client, applyConfig) +} + +func patchConfigMap(ctx context.Context, c client.Client, applyConfig *v1.ConfigMapApplyConfiguration) error { + obj := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: *applyConfig.Name, + Namespace: *applyConfig.Namespace, + }, + } + + encodedPatch, err := json.Marshal(applyConfig) + if err != nil { + return err + } + + return c.Patch(ctx, obj, ssa_client.ApplyPatch{Patch: encodedPatch}, ssa_client.FieldManager, client.ForceOwnership) +} + +func hasLabelPredicate(key string) predicate.Predicate { + return predicate.NewPredicateFuncs(func(obj client.Object) bool { + _, ok := obj.GetLabels()[key] + return ok + }) +} + +func hasAnnotationPredicate(key string) predicate.Predicate { + return predicate.NewPredicateFuncs(func(obj client.Object) bool { + _, ok := obj.GetAnnotations()[key] + return ok + }) +} diff --git a/test/integration/bundle/inject/controller_test.go b/test/integration/bundle/inject/controller_test.go new file mode 100644 index 000000000..0c3a9c679 --- /dev/null +++ b/test/integration/bundle/inject/controller_test.go @@ -0,0 +1,94 @@ +/* +Copyright 2021 The cert-manager 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 inject + +import ( + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/envtest/komega" + + "github.com/cert-manager/trust-manager/pkg/bundle/inject" + "github.com/cert-manager/trust-manager/test/dummy" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Injector", func() { + var namespace string + + BeforeEach(func() { + ns := &corev1.Namespace{} + ns.GenerateName = "inject-" + Expect(k8sClient.Create(ctx, ns)).To(Succeed()) + namespace = ns.Name + }) + + It("should inject bundle data when ConfigMap labeled", func() { + cm := &corev1.ConfigMap{} + cm.GenerateName = "cm-" + cm.Namespace = namespace + cm.Labels = map[string]string{ + inject.BundleInjectBundleNameLabelKey: bundleName, + "app": "my-app", + } + cm.Data = map[string]string{ + "tls.crt": "bar", + "tls.key": "baz", + } + Expect(k8sClient.Create(ctx, cm)).To(Succeed()) + + // Wait for ConfigMap to be processed by controller + Eventually(komega.Object(cm)).Should( + HaveField("Data", + HaveKeyWithValue("ca-bundle.crt", dummy.TestCertificate1), + ), + ) + Expect(cm.Labels).To(HaveKeyWithValue("app", "my-app")) + + By("changing key label on ConfigMap, it should switch key", func() { + Expect(komega.Update(cm, func() { + cm.Labels[inject.BundleInjectKeyLabelKey] = "ca.crt" + })()).To(Succeed()) + + // Wait for ConfigMap to be processed by controller + Eventually(komega.Object(cm)).Should( + HaveField("Data", SatisfyAll( + HaveKeyWithValue("ca.crt", dummy.TestCertificate1), + Not(HaveKey("ca-bundle.crt")), + )), + ) + }) + + By("removing label from ConfigMap, it should remove bundle data", func() { + Expect(komega.Update(cm, func() { + delete(cm.Labels, inject.BundleInjectBundleNameLabelKey) + })()).To(Succeed()) + + // Wait for ConfigMap to be processed by controller + Eventually(komega.Object(cm)).Should( + HaveField("Data", + Not(HaveKey("ca.crt")), + ), + ) + Expect(cm.Labels).To(HaveKeyWithValue("app", "my-app")) + Expect(cm.Data).To(Equal(map[string]string{ + "tls.crt": "bar", + "tls.key": "baz", + })) + }) + }) +}) diff --git a/test/integration/bundle/inject/suite_test.go b/test/integration/bundle/inject/suite_test.go new file mode 100644 index 000000000..75bd1c9ed --- /dev/null +++ b/test/integration/bundle/inject/suite_test.go @@ -0,0 +1,131 @@ +/* +Copyright 2021 The cert-manager 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 inject + +import ( + "context" + "path" + "testing" + + "k8s.io/client-go/rest" + "k8s.io/utils/ptr" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" + "sigs.k8s.io/controller-runtime/pkg/envtest/komega" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + "sigs.k8s.io/controller-runtime/pkg/metrics/server" + + trustapi "github.com/cert-manager/trust-manager/pkg/apis/trust/v1alpha1" + "github.com/cert-manager/trust-manager/pkg/bundle/controller" + "github.com/cert-manager/trust-manager/pkg/bundle/inject" + "github.com/cert-manager/trust-manager/test/dummy" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +const bundleName = "my-bundle" + +var ( + cfg *rest.Config + k8sClient client.Client + testEnv *envtest.Environment + ctx context.Context + cancel context.CancelFunc +) + +func TestAPIs(t *testing.T) { + ctx = t.Context() + RegisterFailHandler(Fail) + RunSpecs(t, "Controller Suite") +} + +var _ = BeforeSuite(func() { + logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) + + _, cancel = context.WithCancel(ctx) + + By("bootstrapping test environment") + testEnv = &envtest.Environment{ + UseExistingCluster: ptr.To(false), + CRDDirectoryPaths: []string{ + path.Join("..", "..", "..", "..", "deploy", "crds"), + }, + ErrorIfCRDPathMissing: true, + Scheme: trustapi.GlobalScheme, + } + + var err error + // cfg is defined in this file globally. + cfg, err = testEnv.Start() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg).NotTo(BeNil()) + + k8sClient, err = client.New(cfg, client.Options{Scheme: trustapi.GlobalScheme}) + Expect(err).NotTo(HaveOccurred()) + Expect(k8sClient).NotTo(BeNil()) + komega.SetClient(k8sClient) + + setupBundle() + + k8sManager, err := ctrl.NewManager(cfg, ctrl.Options{ + Client: client.Options{Cache: &client.CacheOptions{Unstructured: true}}, + Scheme: trustapi.GlobalScheme, + Metrics: server.Options{ + // Disable metrics server to avoid port conflict + BindAddress: "0", + }, + }) + Expect(err).NotTo(HaveOccurred()) + + injector := &inject.Injector{ + Client: k8sManager.GetClient(), + } + Expect(injector.SetupWithManager(k8sManager, controller.Options{})).To(Succeed()) + cleaner := &inject.Cleaner{ + Client: k8sManager.GetClient(), + } + Expect(cleaner.SetupWithManager(k8sManager)).To(Succeed()) + + go func() { + defer GinkgoRecover() + var ctrlCtx context.Context + ctrlCtx, cancel = context.WithCancel(ctrl.SetupSignalHandler()) + Expect(k8sManager.Start(ctrlCtx)).To(Succeed()) + }() +}) + +var _ = AfterSuite(func() { + cancel() + + By("tearing down the test environment") + err := testEnv.Stop() + Expect(err).NotTo(HaveOccurred()) +}) + +func setupBundle() { + bundle := &trustapi.Bundle{} + bundle.Name = bundleName + bundle.Spec.Sources = []trustapi.BundleSource{{ + InLine: ptr.To(dummy.TestCertificate1), + }} + + err := k8sClient.Create(ctx, bundle) + Expect(err).NotTo(HaveOccurred()) +}