From 2db7582586f7eba2e5cf391c13268c896a2162ff Mon Sep 17 00:00:00 2001 From: joshvanl Date: Thu, 4 Mar 2021 16:27:33 +0000 Subject: [PATCH] Adds CertificateRequest approver controller. This controller will currently _always_ set the Approved condition to true on CertificateRequests Signed-off-by: joshvanl --- .../certificaterequests/approver/BUILD.bazel | 56 ++++ .../certificaterequests/approver/approver.go | 103 ++++++++ .../approver/approver_test.go | 240 ++++++++++++++++++ .../certificaterequests/approver/sync.go | 73 ++++++ 4 files changed, 472 insertions(+) create mode 100644 pkg/controller/certificaterequests/approver/BUILD.bazel create mode 100644 pkg/controller/certificaterequests/approver/approver.go create mode 100644 pkg/controller/certificaterequests/approver/approver_test.go create mode 100644 pkg/controller/certificaterequests/approver/sync.go diff --git a/pkg/controller/certificaterequests/approver/BUILD.bazel b/pkg/controller/certificaterequests/approver/BUILD.bazel new file mode 100644 index 000000000..e0dd4f641 --- /dev/null +++ b/pkg/controller/certificaterequests/approver/BUILD.bazel @@ -0,0 +1,56 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "go_default_library", + srcs = [ + "approver.go", + "sync.go", + ], + importpath = "github.com/jetstack/cert-manager/pkg/controller/certificaterequests/approver", + visibility = ["//visibility:public"], + deps = [ + "//pkg/api/util:go_default_library", + "//pkg/apis/certmanager/v1:go_default_library", + "//pkg/apis/meta/v1:go_default_library", + "//pkg/client/clientset/versioned:go_default_library", + "//pkg/client/listers/certmanager/v1:go_default_library", + "//pkg/controller:go_default_library", + "//pkg/logs:go_default_library", + "@com_github_go_logr_logr//:go_default_library", + "@io_k8s_api//core/v1:go_default_library", + "@io_k8s_apimachinery//pkg/api/errors:go_default_library", + "@io_k8s_apimachinery//pkg/apis/meta/v1:go_default_library", + "@io_k8s_client_go//tools/cache:go_default_library", + "@io_k8s_client_go//tools/record:go_default_library", + "@io_k8s_client_go//util/workqueue:go_default_library", + ], +) + +filegroup( + name = "package-srcs", + srcs = glob(["**"]), + tags = ["automanaged"], + visibility = ["//visibility:private"], +) + +filegroup( + name = "all-srcs", + srcs = [":package-srcs"], + tags = ["automanaged"], + visibility = ["//visibility:public"], +) + +go_test( + name = "go_default_test", + srcs = ["approver_test.go"], + embed = [":go_default_library"], + deps = [ + "//pkg/apis/certmanager/v1:go_default_library", + "//pkg/apis/meta/v1:go_default_library", + "//pkg/controller:go_default_library", + "//pkg/controller/test:go_default_library", + "@io_k8s_apimachinery//pkg/apis/meta/v1:go_default_library", + "@io_k8s_client_go//testing:go_default_library", + "@io_k8s_utils//clock/testing:go_default_library", + ], +) diff --git a/pkg/controller/certificaterequests/approver/approver.go b/pkg/controller/certificaterequests/approver/approver.go new file mode 100644 index 000000000..e7f8be7bd --- /dev/null +++ b/pkg/controller/certificaterequests/approver/approver.go @@ -0,0 +1,103 @@ +/* +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 approver + +import ( + "context" + + "github.com/go-logr/logr" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/client-go/tools/cache" + "k8s.io/client-go/tools/record" + "k8s.io/client-go/util/workqueue" + + cmclient "github.com/jetstack/cert-manager/pkg/client/clientset/versioned" + cmlisters "github.com/jetstack/cert-manager/pkg/client/listers/certmanager/v1" + controllerpkg "github.com/jetstack/cert-manager/pkg/controller" + logf "github.com/jetstack/cert-manager/pkg/logs" +) + +const ( + ControllerName = "certificaterequests-approver" +) + +// Controller is a CertificateRequest controller which manages the "Approved" +// condition. In the absence of any automated policy engine, this controller +// will _always_ set the "Approved" condition to True. All CertificateRequest +// signing controllers should wait until the "Approved" condition is set to +// True before processing. +type Controller struct { + // logger to be used by this controller + log logr.Logger + + certificateRequestLister cmlisters.CertificateRequestLister + cmClient cmclient.Interface + + recorder record.EventRecorder + + queue workqueue.RateLimitingInterface +} + +func init() { + // create certificate request approver controller + controllerpkg.Register(ControllerName, func(ctx *controllerpkg.Context) (controllerpkg.Interface, error) { + return controllerpkg.NewBuilder(ctx, ControllerName). + For(new(Controller)).Complete() + }) +} + +// Register registers and constructs the controller using the provided context. +// It returns the workqueue to be used to enqueue items, a list of +// InformerSynced functions that must be synced, or an error. +func (c *Controller) Register(ctx *controllerpkg.Context) (workqueue.RateLimitingInterface, []cache.InformerSynced, error) { + c.log = logf.FromContext(ctx.RootContext, ControllerName) + c.queue = workqueue.NewNamedRateLimitingQueue(controllerpkg.DefaultItemBasedRateLimiter(), ControllerName) + + certificateRequestInformer := ctx.SharedInformerFactory.Certmanager().V1().CertificateRequests() + mustSync := append([]cache.InformerSynced{certificateRequestInformer.Informer().HasSynced}) + certificateRequestInformer.Informer().AddEventHandler(&controllerpkg.QueuingEventHandler{Queue: c.queue}) + + c.certificateRequestLister = certificateRequestInformer.Lister() + c.cmClient = ctx.CMClient + c.recorder = ctx.Recorder + + c.log.V(logf.DebugLevel).Info("certificate request approver controller registered") + + return c.queue, mustSync, nil +} + +func (c *Controller) ProcessItem(ctx context.Context, key string) error { + log := logf.FromContext(ctx) + namespace, name, err := cache.SplitMetaNamespaceKey(key) + if err != nil { + log.Error(err, "invalid resource key") + return nil + } + + cr, err := c.certificateRequestLister.CertificateRequests(namespace).Get(name) + if apierrors.IsNotFound(err) { + log.Error(err, "certificate request in work queue no longer exists") + return nil + } + + if err != nil { + return err + } + + ctx = logf.NewContext(ctx, logf.WithResource(log, cr)) + return c.Sync(ctx, cr) +} diff --git a/pkg/controller/certificaterequests/approver/approver_test.go b/pkg/controller/certificaterequests/approver/approver_test.go new file mode 100644 index 000000000..6d200af51 --- /dev/null +++ b/pkg/controller/certificaterequests/approver/approver_test.go @@ -0,0 +1,240 @@ +/* +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 approver + +import ( + "context" + "testing" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + coretesting "k8s.io/client-go/testing" + fakeclock "k8s.io/utils/clock/testing" + + cmapi "github.com/jetstack/cert-manager/pkg/apis/certmanager/v1" + cmmeta "github.com/jetstack/cert-manager/pkg/apis/meta/v1" + controllerpkg "github.com/jetstack/cert-manager/pkg/controller" + testpkg "github.com/jetstack/cert-manager/pkg/controller/test" +) + +func TestProcessItem(t *testing.T) { + // now time is the current time at the start of the test (the clock is fixed) + now := time.Now() + metaNow := metav1.NewTime(now) + tests := map[string]struct { + // key that should be passed to ProcessItem. + // if not set, the 'namespace/name' of the 'CertificateRequest' field will be used. + // if neither is set, the key will be "" + key string + + // CertificateRequest to be synced for the test. + // if not set, the 'key' will be passed to ProcessItem instead. + request *cmapi.CertificateRequest + + // expectedEvent, if set, is an 'event string' that is expected to be fired. + expectedEvent string + + // expectedConditions is the expected set of conditions on the + // CertificateRequest resource if an Update is made. + // If nil, no update is expected. + // If empty, an update to the empty set/nil is expected. + expectedConditions []cmapi.CertificateRequestCondition + + // err is the expected error text returned by the controller, if any. + err string + }{ + "do nothing if an empty 'key' is used": {}, + "do nothing if an invalid 'key' is used": { + key: "abc/def/ghi", + }, + "do nothing if a key references a Certificate that does not exist": { + key: "namespace/name", + }, + "do nothing if CertificateRequest already has 'Approved' True condition": { + request: &cmapi.CertificateRequest{ + ObjectMeta: metav1.ObjectMeta{Namespace: "testns", Name: "test"}, + Status: cmapi.CertificateRequestStatus{ + Conditions: []cmapi.CertificateRequestCondition{ + { + Type: cmapi.CertificateRequestConditionApproved, + Status: cmmeta.ConditionTrue, + }, + }, + }, + }, + }, + "do nothing if CertificateRequest already has 'Denied' True condition": { + request: &cmapi.CertificateRequest{ + ObjectMeta: metav1.ObjectMeta{Namespace: "testns", Name: "test"}, + Status: cmapi.CertificateRequestStatus{ + Conditions: []cmapi.CertificateRequestCondition{ + { + Type: cmapi.CertificateRequestConditionApproved, + Status: cmmeta.ConditionFalse, + }, + }, + }, + }, + }, + "do nothing if CertificateRequest already has 'Ready' Failed condition": { + request: &cmapi.CertificateRequest{ + ObjectMeta: metav1.ObjectMeta{Namespace: "testns", Name: "test"}, + Status: cmapi.CertificateRequestStatus{ + Conditions: []cmapi.CertificateRequestCondition{ + { + Type: cmapi.CertificateRequestConditionReady, + Status: cmmeta.ConditionFalse, + Reason: cmapi.CertificateRequestReasonFailed, + }, + }, + }, + }, + }, + "do nothing if CertificateRequest already has 'Ready' Issued condition": { + request: &cmapi.CertificateRequest{ + ObjectMeta: metav1.ObjectMeta{Namespace: "testns", Name: "test"}, + Status: cmapi.CertificateRequestStatus{ + Conditions: []cmapi.CertificateRequestCondition{ + { + Type: cmapi.CertificateRequestConditionReady, + Status: cmmeta.ConditionTrue, + Reason: cmapi.CertificateRequestReasonIssued, + }, + }, + }, + }, + }, + "approve CertificateRequest if no condition": { + request: &cmapi.CertificateRequest{ + ObjectMeta: metav1.ObjectMeta{Namespace: "testns", Name: "test"}, + Status: cmapi.CertificateRequestStatus{ + Conditions: []cmapi.CertificateRequestCondition{}, + }, + }, + expectedConditions: []cmapi.CertificateRequestCondition{ + { + Type: cmapi.CertificateRequestConditionApproved, + Status: cmmeta.ConditionTrue, + Reason: cmapi.CertificateRequestReasonApproved, + Message: ApprovedMessage, + LastTransitionTime: &metaNow, + }, + }, + expectedEvent: "Normal Approved Certificate request has been approved by cert-manager.io", + }, + "approve CertificateRequest has 'Ready' Pending condition": { + request: &cmapi.CertificateRequest{ + ObjectMeta: metav1.ObjectMeta{Namespace: "testns", Name: "test"}, + Status: cmapi.CertificateRequestStatus{ + Conditions: []cmapi.CertificateRequestCondition{ + { + Type: cmapi.CertificateRequestConditionReady, + Status: cmmeta.ConditionFalse, + Reason: cmapi.CertificateRequestReasonPending, + }, + }, + }, + }, + expectedConditions: []cmapi.CertificateRequestCondition{ + { + Type: cmapi.CertificateRequestConditionReady, + Status: cmmeta.ConditionFalse, + Reason: cmapi.CertificateRequestReasonPending, + }, + { + Type: cmapi.CertificateRequestConditionApproved, + Status: cmmeta.ConditionTrue, + Reason: cmapi.CertificateRequestReasonApproved, + Message: ApprovedMessage, + LastTransitionTime: &metaNow, + }, + }, + expectedEvent: "Normal Approved Certificate request has been approved by cert-manager.io", + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + // Create and initialise a new unit test builder + builder := &testpkg.Builder{ + T: t, + Clock: fakeclock.NewFakeClock(now), + } + if test.request != nil { + builder.CertManagerObjects = append(builder.CertManagerObjects, test.request) + } + builder.Init() + + c := new(Controller) + _, _, err := c.Register(builder.Context) + if err != nil { + t.Fatal(err) + } + if test.expectedConditions != nil { + if test.request == nil { + t.Fatal("cannot expect an Update operation if test.request is nil") + } + expectedRequest := test.request.DeepCopy() + expectedRequest.Status.Conditions = test.expectedConditions + builder.ExpectedActions = append(builder.ExpectedActions, + testpkg.NewAction(coretesting.NewUpdateSubresourceAction( + cmapi.SchemeGroupVersion.WithResource("certificaterequests"), + "status", + test.request.Namespace, + expectedRequest, + )), + ) + } + if test.expectedEvent != "" { + builder.ExpectedEvents = []string{test.expectedEvent} + } + // Start the informers and begin processing updates + builder.Start() + defer builder.Stop() + + key := test.key + if key == "" && test.request != nil { + key, err = controllerpkg.KeyFunc(test.request) + if err != nil { + t.Fatal(err) + } + } + + // Call ProcessItem + err = c.ProcessItem(context.Background(), key) + switch { + case err != nil: + if test.err != err.Error() { + t.Errorf("error text did not match, got=%s, exp=%s", err.Error(), test.err) + } + default: + if test.err != "" { + t.Errorf("got no error but expected: %s", test.err) + } + } + + if err := builder.AllEventsCalled(); err != nil { + builder.T.Error(err) + } + if err := builder.AllActionsExecuted(); err != nil { + builder.T.Error(err) + } + if err := builder.AllReactorsCalled(); err != nil { + builder.T.Error(err) + } + }) + } +} diff --git a/pkg/controller/certificaterequests/approver/sync.go b/pkg/controller/certificaterequests/approver/sync.go new file mode 100644 index 000000000..69e83b7df --- /dev/null +++ b/pkg/controller/certificaterequests/approver/sync.go @@ -0,0 +1,73 @@ +/* +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 approver + +import ( + "context" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + apiutil "github.com/jetstack/cert-manager/pkg/api/util" + cmapi "github.com/jetstack/cert-manager/pkg/apis/certmanager/v1" + cmmeta "github.com/jetstack/cert-manager/pkg/apis/meta/v1" + logf "github.com/jetstack/cert-manager/pkg/logs" +) + +const ( + ApprovedMessage = "Certificate request has been approved by cert-manager.io" +) + +// Sync will set the "Approved" condition to True on synced +// CertificateRequests. If the "Denied", "Approved" or "Ready" condition alrady +// exists, exit early. +func (c *Controller) Sync(ctx context.Context, cr *cmapi.CertificateRequest) (err error) { + log := logf.FromContext(ctx, "approver") + + switch { + case + // If the CertificateRequest has already been approved, exit early. + apiutil.CertificateRequestHasApproved(cr), + + // If the CertificateRequest has already been denied, exit early. + apiutil.CertificateRequestHasDenied(cr), + + // If the CertificateRequest is "Issued" or "Failed", exit early. + apiutil.CertificateRequestReadyReason(cr) == cmapi.CertificateRequestReasonFailed, + apiutil.CertificateRequestReadyReason(cr) == cmapi.CertificateRequestReasonIssued: + return nil + } + + // Update the CertificateRequest approved condition to true. + apiutil.SetCertificateRequestCondition(cr, + cmapi.CertificateRequestConditionApproved, + cmmeta.ConditionTrue, + cmapi.CertificateRequestReasonApproved, + ApprovedMessage, + ) + + // Update CertificateRequest with + _, err = c.cmClient.CertmanagerV1().CertificateRequests(cr.Namespace).UpdateStatus(ctx, cr, metav1.UpdateOptions{}) + if err != nil { + return err + } + c.recorder.Event(cr, corev1.EventTypeNormal, cmapi.CertificateRequestReasonApproved, ApprovedMessage) + + log.V(logf.DebugLevel).Info("approved certificate request") + + return nil +}