Repository: operator-framework/operator-controller
Pull request #1996 (2025-05-28 08:38:43)

:seedling: Add feature-gate kustomize files, docs, and demo for webhook support feature
# Description

Adds docs and demo for webhook support feature

## Reviewer Checklist

- [ ] API Go Documentation
- [ ] Tests: Unit Tests (and E2E Tests, if appropriate)
- [ ] Comprehensive Commit Messages
- [ ] Links to related GitHub Issue(s)


openshift-ci[bot]: [APPROVALNOTIFIER] This PR is **NOT APPROVED**

This pull-request has been approved by:
**Once this PR has been reviewed and has the lgtm label**, please assign [tmshort](https://github.com/tmshort) for approval. For more information see [the Code Review Process](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process).

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=operator-framework%2Foperator-controller).

<details open>
Needs approval from an approver in each of these files:

- **[OWNERS](https://github.com/operator-framework/operator-controller/blob/main/OWNERS)**

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":["tmshort"]} -->

Patch:
diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml
new file mode 100644
index 0000000..a20ee47
--- /dev/null
+++ b/.github/workflows/stale.yml
@@ -0,0 +1,55 @@
+# This workflow automatically marks issues and pull requests as stale after 90 days of inactivity
+# and closes them after an additional 30 days if no further activity occurs.
+#
+# Key behavior:
+# - After 90 days of no activity:
+#     - Open issues and pull requests are labeled with "lifecycle/stale"
+#     - A comment is posted to notify contributors about the inactivity
+#
+# - After 30 additional days (i.e., 120 days total):
+#     - If still inactive and still labeled "lifecycle/stale", the issue or PR is closed
+#     - A closing comment is posted to explain why it was closed
+#
+# - Activity such as a comment, commit, or label removal during the stale period
+#   will remove the "lifecycle/stale" label and reset the clock
+#
+# - Items with any of the following labels will never be marked stale or closed:
+#     - security
+#     - planned
+#     - priority/critical
+#     - lifecycle/frozen
+#     - verified
+#
+# This workflow uses: https://github.com/actions/stale
+name: "Close stale issues and PRs"
+on:
+  schedule:
+    - cron: "0 1 * * *"   # Runs daily at 01:00 UTC
+
+jobs:
+  stale:
+    runs-on: ubuntu-latest
+    permissions:
+      # allow labeling, commenting, closing issues and PRs
+      issues: write
+      pull-requests: write     
+    steps:
+      - uses: actions/stale@v9
+        with:
+          repo-token: ${{ secrets.GITHUB_TOKEN }}
+          days-before-stale: 90
+          days-before-close: 30
+          stale-issue-label: "lifecycle/stale"
+          stale-pr-label: "lifecycle/stale"
+          stale-issue-message: >
+            Issues go stale after 90 days of inactivity. If there is no further
+            activity, the issue will be closed in another 30 days.
+          stale-pr-message: >
+            PRs go stale after 90 days of inactivity. If there is no further
+            activity, the PR will be closed in another 30 days.
+          close-issue-message: "This issue has been closed due to inactivity."
+          close-pr-message: "This pull request has been closed due to inactivity."
+          exempt-issue-labels: "security,planned,priority/critical,lifecycle/frozen,verified"
+          exempt-pr-labels: "security,planned,priority/critical,lifecycle/frozen,verified"
+          operations-per-run: 30
+
diff --git a/config/overlays/featuregate/synthetic-user-permissions/kustomization.yaml b/config/overlays/featuregate/synthetic-user-permissions/kustomization.yaml
index 01e3a6d..e5e8b33 100644
--- a/config/overlays/featuregate/synthetic-user-permissions/kustomization.yaml
+++ b/config/overlays/featuregate/synthetic-user-permissions/kustomization.yaml
@@ -1,4 +1,4 @@
-# kustomization file for secure OLMv1
+# kustomization file for OLMv1 support for synthetic auth
 # DO NOT ADD A NAMESPACE HERE
 apiVersion: kustomize.config.k8s.io/v1beta1
 kind: Kustomization
diff --git a/config/overlays/featuregate/webhook-provider-certmanager/kustomization.yaml b/config/overlays/featuregate/webhook-provider-certmanager/kustomization.yaml
new file mode 100644
index 0000000..3898bbc
--- /dev/null
+++ b/config/overlays/featuregate/webhook-provider-certmanager/kustomization.yaml
@@ -0,0 +1,15 @@
+# kustomization file for cert-manager backed OLMv1 support for installation of bundles with webhooks
+# DO NOT ADD A NAMESPACE HERE
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+resources:
+  - ../../../base/operator-controller
+  - ../../../base/common
+components:
+  - ../../../components/tls/operator-controller
+
+patches:
+ - target:
+      kind: Deployment
+      name: operator-controller-controller-manager
+   path: patches/enable-featuregate.yaml
diff --git a/config/overlays/featuregate/webhook-provider-certmanager/patches/enable-featuregate.yaml b/config/overlays/featuregate/webhook-provider-certmanager/patches/enable-featuregate.yaml
new file mode 100644
index 0000000..ba47fa3
--- /dev/null
+++ b/config/overlays/featuregate/webhook-provider-certmanager/patches/enable-featuregate.yaml
@@ -0,0 +1,4 @@
+# enable cert-manager backed webhook support feature gate
+- op: add
+  path: /spec/template/spec/containers/0/args/-
+  value: "--feature-gates=WebhookProviderCertManager=true"
diff --git a/config/overlays/featuregate/webhook-provider-openshift-serviceca/kustomization.yaml b/config/overlays/featuregate/webhook-provider-openshift-serviceca/kustomization.yaml
new file mode 100644
index 0000000..de31bef
--- /dev/null
+++ b/config/overlays/featuregate/webhook-provider-openshift-serviceca/kustomization.yaml
@@ -0,0 +1,15 @@
+# kustomization file for openshift-serviceca backed OLMv1 support for installation of bundles with webhooks
+# DO NOT ADD A NAMESPACE HERE
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+resources:
+  - ../../../base/operator-controller
+  - ../../../base/common
+components:
+  - ../../../components/tls/operator-controller
+
+patches:
+ - target:
+      kind: Deployment
+      name: operator-controller-controller-manager
+   path: patches/enable-featuregate.yaml
diff --git a/config/overlays/featuregate/webhook-provider-openshift-serviceca/patches/enable-featuregate.yaml b/config/overlays/featuregate/webhook-provider-openshift-serviceca/patches/enable-featuregate.yaml
new file mode 100644
index 0000000..e1fa435
--- /dev/null
+++ b/config/overlays/featuregate/webhook-provider-openshift-serviceca/patches/enable-featuregate.yaml
@@ -0,0 +1,4 @@
+# enable openshift-serviceca backed webhook support feature gate
+- op: add
+  path: /spec/template/spec/containers/0/args/-
+  value: "--feature-gates=WebhookProviderOpenshiftServiceCA=true"
diff --git a/docs/draft/howto/enable-webhook-support.md b/docs/draft/howto/enable-webhook-support.md
new file mode 100644
index 0000000..b21290a
--- /dev/null
+++ b/docs/draft/howto/enable-webhook-support.md
@@ -0,0 +1,62 @@
+## Installation of Bundles containing Webhooks
+
+!!! note
+This feature is still in *alpha*. Either the `WebhookProviderCertManager`, or the `WebhookProviderOpenshiftServiceCA`, feature-gate 
+must be enabled to make use of it. See the instructions below on how to enable the feature-gate.
+
+OLMv1 currently does not support the installation of bundles containing webhooks. The webhook support feature enables this capability.
+Webhooks, or more concretely Admission Webhooks, are part of Kuberntes' [Dynamic Admission Control](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/)
+feature. Webhooks run as services called by the kube-apiservice in due course of processing a resource related request. They can be used to validate resources, ensure reasonable default values,
+are set, or aid in the migration to new CustomResourceDefinition schema. The communication with the webhook service is secured by TLS. In OLMv1, the TLS certificate is managed by a 
+certificate provider. Currently, two certificate providers are supported: CertManager and Openshift-ServiceCA. The certificate provider to use given by the feature-gate:
+
+- `WebhookProviderCertManager` for [CertManager](https://cert-manager.io/)
+- `WebhookProviderOpenshiftServiceCA` for [Openshift-ServiceCA](https://github.com/openshift/service-ca-operator)
+
+As CertManager is already installed with OLMv1, we suggest using `WebhookProviderCertManager`.
+
+### Update OLM to enable Feature
+
+```terminal title=Enable WebhookProviderCertManager feature
+kubectl kustomize config/overlays/featuregate/webhook-provider-certmanager | kubectl apply -f -
+```
+
+Or,
+
+```terminal title=Enable WebhookProviderOpenshiftServiceCA feature
+kubectl kustomize config/overlays/featuregate/webhook-provider-openshift-serviceca | kubectl apply -f -
+```
+
+Then,
+
+```terminal title=Wait for rollout to complete
+kubectl rollout status -n olmv1-system deployment/operator-controller-controller-manager 
+```
+
+### Notes on the generated certificate
+
+#### CertManager
+
+The generated certificate maintains a high-level of parity with the certificate generated by OLMv0:
+- Self-signed
+- Two validity period, rotating 24h before expiry
+- Valid for the webhook service's DNSNames:
+  - <service-name>.<namespace>
+  - <service-name>.<namespace>.svc
+  - <service-name>.<namespace>.svc.cluster.local
+
+#### Openshift-ServiceCA
+
+Generation and rotation are completely governed by [Openshift-ServiceCA](https://github.com/openshift/service-ca-operator)
+
+### How does it work?
+
+There's no change in the installation flow. Just install a bundle containing webhooks as you would any other.
+
+### Demo
+
+!!! note
+As there is no difference in usage or experience between the CertManager and Openshift-ServiceCA variants, only
+the cert-manager variant is demoed.
+
+[![asciicast](https://asciinema.org/a/GyjsB129GkUadeuxFhNuG4FcS.svg)](https://asciinema.org/a/GyjsB129GkUadeuxFhNuG4FcS)
diff --git a/docs/draft/howto/rbac-permissions-checking.md b/docs/draft/howto/rbac-permissions-checking.md
new file mode 100644
index 0000000..04c13bf
--- /dev/null
+++ b/docs/draft/howto/rbac-permissions-checking.md
@@ -0,0 +1,54 @@
+# How To Get Your Cluster Extension RBAC Right — Working with the Preflight Permissions Check
+
+Cluster Extensions in Operator Lifecycle Manager (OLM) v1 are installed and managed via a **service account** that you (the cluster admin) provide. Unlike OLM v0, OLM v1 itself doesn’t have cluster-admin privileges to grant Operators the access they need – **you** must ensure the service account has all necessary Role-Based Access Control (RBAC) permissions. If the service account is missing permissions, the extension’s installation will fail or hang. To address this, the **operator-controller** now performs a **preflight permissions check** before installing an extension. This check identifies any missing RBAC permissions up front and surfaces them to you so that you can fix the issues.
+
+## Understanding the Preflight Permissions Check
+
+When you create a `ClusterExtension` Custom Resource (CR) to install an Operator extension, the operator-controller will do a dry-run of the installation and verify that the specified service account can perform all the actions required by that extension. This includes creating all the Kubernetes objects in the bundle (Deployments, Services, CRDs, etc.), as well as creating any RBAC roles or bindings that the extension’s bundle defines.
+
+If any required permission is missing, the preflight check will **fail fast** *before* attempting the real installation. Instead of proceeding, the operator-controller records which permissions are missing. You’ll find this information in two places:
+
+- **ClusterExtension Status Conditions:** The `ClusterExtension` CR will have a condition (such as **Progressing** or **Installing**) with a message describing the missing permissions. The condition’s reason may be set to “Retrying” (meaning the controller will periodically retry the install) and the message will start with “pre-authorization failed: …”.
+- **Operator-Controller Logs:** The same message is also logged by the operator-controller pod. If you have access to the operator-controller’s logs (in namespace `olm-controller` on OpenShift), you can see the detailed RBAC errors there as well.
+
+### Interpreting the Preflight Check Output
+
+The preflight check’s output enumerates the **RBAC rules** that the service account is missing. Each missing permission is listed in a structured format. For example, a message might say:
+
+```
+service account requires the following permissions to manage cluster extension:
+ Namespace:"" APIGroups:[] Resources:[services] Verbs:[list,watch]
+ Namespace:"pipelines" APIGroups:[] Resources:[secrets] Verbs:[get]
+```
+
+Let’s break down how to read this output:
+
+- **`Namespace:""`** – An empty namespace in quotes means the permission is needed at the **cluster scope** (not limited to a single namespace). In the example above, `Namespace:""` for Services indicates the service account needs the ability to list/watch Services cluster-wide.
+- **`APIGroups:[]`** – An empty API group (`[]`) means the **core API group** (no group). For instance, core resources like Services, Secrets, ConfigMaps have `APIGroups:[]`. If the resource is part of a named API group (e.g. `apps`, `apiextensions.k8s.io`), that group would be listed here.
+- **`Resources:[...]`** – The resource type that’s missing permissions. e.g. `services`, `secrets`, `customresourcedefinitions`.
+- **`Verbs:[...]`** – The specific actions (verbs) that the service account is not allowed to do for that resource. Multiple verbs listed together means none of those verbs are permitted (and are all required).
+
+A few special cases to note:
+
+- **Privilege Escalation Cases:** If the extension’s bundle includes the creation of a Role or ClusterRole, the service account needs to have at least the permissions it is trying to grant. If not, the preflight check will report those verbs as missing to prevent privilege escalation.
+- **Missing Role References (Resolution Errors):** If an Operator’s bundle references an existing ClusterRole or Role that is not found, the preflight check will report an “authorization evaluation error” listing the missing role.
+
+## Resolving Common RBAC Permission Errors
+
+Once you understand what each missing permission is, the fix is usually straightforward: **grant the service account those permissions**. Here are common scenarios and how to address them:
+
+- **Missing resource permissions (verbs):** Update or create a (Cluster)Role and RoleBinding/ClusterRoleBinding to grant the missing verbs on the resources in the specified namespaces or at cluster scope.
+- **Privilege escalation missing permissions:** Treat these missing verbs as required for the installer as well, granting the service account those rights so it can create the roles it needs.
+- **Missing roles/clusterroles:** Ensure any referenced roles exist by creating them or adjusting the extension’s expectations.
+
+## Demo Scenario (OpenShift)
+
+Below is an example demo you can run on OpenShift to see the preflight check in action:
+
+1. **Create a minimal ServiceAccount and ClusterRole** that lacks key permissions (e.g., missing list/watch on Services and create on CRDs).
+2. **Apply a ClusterExtension** pointing to the Pipelines Operator package, specifying the above service account.
+3. **Describe the ClusterExtension** (`oc describe clusterextension pipelines-operator`) to see the preflight “pre-authorization failed” errors listing missing permissions.
+4. **Update the ClusterRole** to include the missing verbs.
+5. **Reapply the role** and observe the ClusterExtension status clear and the operator proceed with installation.
+
+By following this guide and using the preflight output, you can iteratively grant exactly the permissions needed—no more, no less—ensuring your cluster extensions install reliably and securely.
diff --git a/go.mod b/go.mod
index 7190953..91e568d 100644
--- a/go.mod
+++ b/go.mod
@@ -10,7 +10,7 @@ require (
 	github.com/containerd/containerd v1.7.27
 	github.com/containers/image/v5 v5.35.0
 	github.com/fsnotify/fsnotify v1.9.0
-	github.com/go-logr/logr v1.4.2
+	github.com/go-logr/logr v1.4.3
 	github.com/google/go-cmp v0.7.0
 	github.com/google/go-containerregistry v0.20.3
 	github.com/gorilla/handlers v1.5.2
diff --git a/go.sum b/go.sum
index 812840e..a2f1f19 100644
--- a/go.sum
+++ b/go.sum
@@ -170,8 +170,8 @@ github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2
 github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
 github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
 github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
-github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
-github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
+github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
 github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
 github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
 github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ=
diff --git a/hack/demo/resources/webhook-provider-certmanager/webhook-operator-catalog.yaml b/hack/demo/resources/webhook-provider-certmanager/webhook-operator-catalog.yaml
new file mode 100644
index 0000000..ff325c0
--- /dev/null
+++ b/hack/demo/resources/webhook-provider-certmanager/webhook-operator-catalog.yaml
@@ -0,0 +1,9 @@
+apiVersion: olm.operatorframework.io/v1
+kind: ClusterCatalog
+metadata:
+  name: webhook-operator-catalog
+spec:
+  source:
+    type: Image
+    image:
+      ref: quay.io/operator-framework/webhook-operator-index:0.0.3
diff --git a/hack/demo/resources/webhook-provider-certmanager/webhook-operator-extension.yaml b/hack/demo/resources/webhook-provider-certmanager/webhook-operator-extension.yaml
new file mode 100644
index 0000000..19b7ece
--- /dev/null
+++ b/hack/demo/resources/webhook-provider-certmanager/webhook-operator-extension.yaml
@@ -0,0 +1,15 @@
+apiVersion: olm.operatorframework.io/v1
+kind: ClusterExtension
+metadata:
+  name: webhook-operator
+spec:
+  namespace: webhook-operator
+  serviceAccount:
+    name: webhook-operator-installer
+  source:
+    catalog:
+      packageName: webhook-operator
+      version: 0.0.1
+      selector: {}
+      upgradeConstraintPolicy: CatalogProvided
+    sourceType: Catalog
diff --git a/hack/demo/webhook-provider-certmanager-demo.sh b/hack/demo/webhook-provider-certmanager-demo.sh
new file mode 100755
index 0000000..ba723ca
--- /dev/null
+++ b/hack/demo/webhook-provider-certmanager-demo.sh
@@ -0,0 +1,60 @@
+#!/usr/bin/env bash
+
+#
+# Welcome to the webhook support with CertManager demo
+#
+trap "trap - SIGTERM && kill -- -$$" SIGINT SIGTERM EXIT
+
+# enable 'WebhookProviderCertManager' feature
+kubectl kustomize config/overlays/featuregate/webhook-provider-certmanager | kubectl apply -f -
+
+# wait for operator-controller to become available
+kubectl rollout status -n olmv1-system deployment/operator-controller-controller-manager
+
+# create webhook-operator catalog
+cat ${DEMO_RESOURCE_DIR}/webhook-provider-certmanager/webhook-operator-catalog.yaml
+kubectl apply -f ${DEMO_RESOURCE_DIR}/webhook-provider-certmanager/webhook-operator-catalog.yaml
+
+# wait for catalog to be serving
+kubectl wait --for=condition=Serving clustercatalog/webhook-operator-catalog --timeout="60s"
+
+# create install namespace
+kubectl create ns webhook-operator
+
+# create installer service account
+kubectl create serviceaccount -n webhook-operator webhook-operator-installer
+
+# give installer service account admin privileges
+kubectl create clusterrolebinding webhook-operator-installer-crb --clusterrole=cluster-admin --serviceaccount=webhook-operator:webhook-operator-installer
+
+# install webhook operator clusterextension
+cat ${DEMO_RESOURCE_DIR}/webhook-provider-certmanager/webhook-operator-extension.yaml
+
+# apply cluster extension
+kubectl apply -f ${DEMO_RESOURCE_DIR}/webhook-provider-certmanager/webhook-operator-extension.yaml
+
+# wait for cluster extension installation to succeed
+kubectl wait --for=condition=Installed clusterextension/webhook-operator --timeout="60s"
+
+# wait for webhook-operator deployment to become available and back the webhook service
+kubectl wait --for=condition=Available -n webhook-operator deployments/webhook-operator-webhook
+
+# demonstrate working validating webhook
+cat ${DEMO_RESOURCE_DIR}/webhook-provider-certmanager/validating-webhook-test.yaml
+
+# resource creation should be rejected by the validating webhook due to bad attribute value
+kubectl apply -f ${DEMO_RESOURCE_DIR}/webhook-provider-certmanager/validating-webhook-test.yaml
+
+# demonstrate working mutating webhook
+cat ${DEMO_RESOURCE_DIR}/webhook-provider-certmanager/mutating-webhook-test.yaml
+
+# apply resource
+kubectl apply -f ${DEMO_RESOURCE_DIR}/webhook-provider-certmanager/mutating-webhook-test.yaml
+
+# get webhooktest resource in v1 schema - resource should have new .spec.mutate attribute
+kubectl get webhooktest.v1.webhook.operators.coreos.io -n webhook-operator mutating-webhook-test -o yaml
+
+# demonstrate working conversion webhook by getting webhook test resource in v2 schema - the .spec attributes should now be under the .spec.conversion stanza
+kubectl get webhooktest.v2.webhook.operators.coreos.io -n webhook-operator mutating-webhook-test -o yaml
+
+# this concludes the webhook support demo - Thank you!
diff --git a/internal/operator-controller/rukpak/render/certprovider.go b/internal/operator-controller/rukpak/render/certprovider.go
index f3920a4..80c9e67 100644
--- a/internal/operator-controller/rukpak/render/certprovider.go
+++ b/internal/operator-controller/rukpak/render/certprovider.go
@@ -28,10 +28,10 @@ type CertSecretInfo struct {
 // CertificateProvisionerConfig contains the necessary information for a CertificateProvider
 // to correctly generate and modify object for certificate injection and automation
 type CertificateProvisionerConfig struct {
-	WebhookServiceName string
-	CertName           string
-	Namespace          string
-	CertProvider       CertificateProvider
+	ServiceName  string
+	CertName     string
+	Namespace    string
+	CertProvider CertificateProvider
 }
 
 // CertificateProvisioner uses a CertificateProvider to modify and generate objects based on its
@@ -70,9 +70,9 @@ func CertProvisionerFor(deploymentName string, opts Options) CertificateProvisio
 	certName := util.ObjectNameForBaseAndSuffix(webhookServiceName, "cert")
 
 	return CertificateProvisioner{
-		CertProvider:       opts.CertificateProvider,
-		WebhookServiceName: webhookServiceName,
-		Namespace:          opts.InstallNamespace,
-		CertName:           certName,
+		CertProvider: opts.CertificateProvider,
+		ServiceName:  webhookServiceName,
+		Namespace:    opts.InstallNamespace,
+		CertName:     certName,
 	}
 }
diff --git a/internal/operator-controller/rukpak/render/certproviders/certmanager.go b/internal/operator-controller/rukpak/render/certproviders/certmanager.go
index f9dada3..0cde052 100644
--- a/internal/operator-controller/rukpak/render/certproviders/certmanager.go
+++ b/internal/operator-controller/rukpak/render/certproviders/certmanager.go
@@ -20,6 +20,7 @@ import (
 const (
 	certManagerInjectCAAnnotation = "cert-manager.io/inject-ca-from"
 	olmv0RotationPeriod           = 730 * 24 * time.Hour // 2 year rotation
+	olmv0RenewBefore              = 24 * time.Hour       // renew certificate within 24h of expiry
 )
 
 var _ render.CertificateProvider = (*CertManagerCertificateProvider)(nil)
@@ -55,6 +56,7 @@ func (p CertManagerCertificateProvider) AdditionalObjects(cfg render.Certificate
 	// OLMv0 parity:
 	// - self-signed issuer
 	// - 2 year rotation period
+	// - renew 24h before expiry
 	// - CN: argocd-operator-controller-manager-service.argocd (<deploymentName>-service.<namespace>)
 	// - CA: false
 	// - DNS:argocd-operator-controller-manager-service.argocd, DNS:argocd-operator-controller-manager-service.argocd.svc, DNS:argocd-operator-controller-manager-service.argocd.svc.cluster.local
@@ -151,13 +153,13 @@ func (p CertManagerCertificateProvider) AdditionalObjects(cfg render.Certificate
 		},
 		Spec: certmanagerv1.CertificateSpec{
 			SecretName: cfg.CertName,
-			CommonName: fmt.Sprintf("%s.%s", cfg.WebhookServiceName, cfg.Namespace),
+			CommonName: fmt.Sprintf("%s.%s", cfg.ServiceName, cfg.Namespace),
 			Usages:     []certmanagerv1.KeyUsage{certmanagerv1.UsageServerAuth},
 			IsCA:       false,
 			DNSNames: []string{
-				fmt.Sprintf("%s.%s", cfg.WebhookServiceName, cfg.Namespace),
-				fmt.Sprintf("%s.%s.svc", cfg.WebhookServiceName, cfg.Namespace),
-				fmt.Sprintf("%s.%s.svc.cluster.local", cfg.WebhookServiceName, cfg.Namespace),
+				fmt.Sprintf("%s.%s", cfg.ServiceName, cfg.Namespace),
+				fmt.Sprintf("%s.%s.svc", cfg.ServiceName, cfg.Namespace),
+				fmt.Sprintf("%s.%s.svc.cluster.local", cfg.ServiceName, cfg.Namespace),
 			},
 			IssuerRef: certmanagermetav1.ObjectReference{
 				Name: issuer.GetName(),
@@ -165,6 +167,9 @@ func (p CertManagerCertificateProvider) AdditionalObjects(cfg render.Certificate
 			Duration: &metav1.Duration{
 				Duration: olmv0RotationPeriod,
 			},
+			RenewBefore: &metav1.Duration{
+				Duration: olmv0RenewBefore,
+			},
 		},
 	}
 	certObj, err := util.ToUnstructured(certificate)
diff --git a/internal/operator-controller/rukpak/render/registryv1/generators/generators.go b/internal/operator-controller/rukpak/render/registryv1/generators/generators.go
index cf17142..bdeb85b 100644
--- a/internal/operator-controller/rukpak/render/registryv1/generators/generators.go
+++ b/internal/operator-controller/rukpak/render/registryv1/generators/generators.go
@@ -241,7 +241,7 @@ func BundleCRDGenerator(rv1 *bundle.RegistryV1, opts render.Options) ([]client.O
 					ClientConfig: &apiextensionsv1.WebhookClientConfig{
 						Service: &apiextensionsv1.ServiceReference{
 							Namespace: opts.InstallNamespace,
-							Name:      certProvisioner.WebhookServiceName,
+							Name:      certProvisioner.ServiceName,
 							Path:      &conversionWebhookPath,
 							Port:      &cw.ContainerPort,
 						},
@@ -314,7 +314,7 @@ func BundleValidatingWebhookResourceGenerator(rv1 *bundle.RegistryV1, opts rende
 					ClientConfig: admissionregistrationv1.WebhookClientConfig{
 						Service: &admissionregistrationv1.ServiceReference{
 							Namespace: opts.InstallNamespace,
-							Name:      certProvisioner.WebhookServiceName,
+							Name:      certProvisioner.ServiceName,
 							Path:      wh.WebhookPath,
 							Port:      &wh.ContainerPort,
 						},
@@ -362,7 +362,7 @@ func BundleMutatingWebhookResourceGenerator(rv1 *bundle.RegistryV1, opts render.
 					ClientConfig: admissionregistrationv1.WebhookClientConfig{
 						Service: &admissionregistrationv1.ServiceReference{
 							Namespace: opts.InstallNamespace,
-							Name:      certProvisioner.WebhookServiceName,
+							Name:      certProvisioner.ServiceName,
 							Path:      wh.WebhookPath,
 							Port:      &wh.ContainerPort,
 						},
@@ -379,10 +379,10 @@ func BundleMutatingWebhookResourceGenerator(rv1 *bundle.RegistryV1, opts render.
 	return objs, nil
 }
 
-// BundleWebhookServiceResourceGenerator generates Service resources based that support the webhooks defined in
-// the bundle's cluster service version spec. The resource is modified by the CertificateProvider in opts
+// BundleDeploymentServiceResourceGenerator generates Service resources that support, e.g. the webhooks,
+// defined in the bundle's cluster service version spec. The resource is modified by the CertificateProvider in opts
 // to add any annotations or modifications necessary for certificate injection.
-func BundleWebhookServiceResourceGenerator(rv1 *bundle.RegistryV1, opts render.Options) ([]client.Object, error) {
+func BundleDeploymentServiceResourceGenerator(rv1 *bundle.RegistryV1, opts render.Options) ([]client.Object, error) {
 	if rv1 == nil {
 		return nil, fmt.Errorf("bundle cannot be nil")
 	}
@@ -415,7 +415,7 @@ func BundleWebhookServiceResourceGenerator(rv1 *bundle.RegistryV1, opts render.O
 
 		certProvisioner := render.CertProvisionerFor(deploymentSpec.Name, opts)
 		serviceResource := CreateServiceResource(
-			certProvisioner.WebhookServiceName,
+			certProvisioner.ServiceName,
 			opts.InstallNamespace,
 			WithServiceSpec(
 				corev1.ServiceSpec{
diff --git a/internal/operator-controller/rukpak/render/registryv1/registryv1.go b/internal/operator-controller/rukpak/render/registryv1/registryv1.go
index 61f0e6e..6621a6c 100644
--- a/internal/operator-controller/rukpak/render/registryv1/registryv1.go
+++ b/internal/operator-controller/rukpak/render/registryv1/registryv1.go
@@ -43,6 +43,6 @@ var ResourceGenerators = []render.ResourceGenerator{
 	generators.BundleCSVDeploymentGenerator,
 	generators.BundleValidatingWebhookResourceGenerator,
 	generators.BundleMutatingWebhookResourceGenerator,
-	generators.BundleWebhookServiceResourceGenerator,
+	generators.BundleDeploymentServiceResourceGenerator,
 	generators.CertProviderResourceGenerator,
 }


Test patch:
diff --git a/hack/demo/resources/webhook-provider-certmanager/mutating-webhook-test.yaml b/hack/demo/resources/webhook-provider-certmanager/mutating-webhook-test.yaml
new file mode 100644
index 0000000..5719402
--- /dev/null
+++ b/hack/demo/resources/webhook-provider-certmanager/mutating-webhook-test.yaml
@@ -0,0 +1,7 @@
+apiVersion: webhook.operators.coreos.io/v1
+kind: webhooktest
+metadata:
+  namespace: webhook-operator
+  name: mutating-webhook-test
+spec:
+  valid: true
diff --git a/hack/demo/resources/webhook-provider-certmanager/validating-webhook-test.yaml b/hack/demo/resources/webhook-provider-certmanager/validating-webhook-test.yaml
new file mode 100644
index 0000000..227ab84
--- /dev/null
+++ b/hack/demo/resources/webhook-provider-certmanager/validating-webhook-test.yaml
@@ -0,0 +1,7 @@
+apiVersion: webhook.operators.coreos.io/v1
+kind: webhooktest
+metadata:
+  namespace: webhook-operator
+  name: validating-webhook-test
+spec:
+  valid: false
diff --git a/internal/operator-controller/rukpak/render/certprovider_test.go b/internal/operator-controller/rukpak/render/certprovider_test.go
index 3005cfd..a245a41 100644
--- a/internal/operator-controller/rukpak/render/certprovider_test.go
+++ b/internal/operator-controller/rukpak/render/certprovider_test.go
@@ -16,10 +16,10 @@ import (
 
 func Test_CertificateProvisioner_WithoutCertProvider(t *testing.T) {
 	provisioner := &render.CertificateProvisioner{
-		WebhookServiceName: "webhook",
-		CertName:           "cert",
-		Namespace:          "namespace",
-		CertProvider:       nil,
+		ServiceName:  "webhook",
+		CertName:     "cert",
+		Namespace:    "namespace",
+		CertProvider: nil,
 	}
 
 	require.NoError(t, provisioner.InjectCABundle(&corev1.Secret{}))
@@ -50,10 +50,10 @@ func Test_CertificateProvisioner_WithCertProvider(t *testing.T) {
 		},
 	}
 	provisioner := &render.CertificateProvisioner{
-		WebhookServiceName: "webhook",
-		CertName:           "cert",
-		Namespace:          "namespace",
-		CertProvider:       fakeProvider,
+		ServiceName:  "webhook",
+		CertName:     "cert",
+		Namespace:    "namespace",
+		CertProvider: fakeProvider,
 	}
 
 	svc := &corev1.Service{}
@@ -83,10 +83,10 @@ func Test_CertificateProvisioner_Errors(t *testing.T) {
 		},
 	}
 	provisioner := &render.CertificateProvisioner{
-		WebhookServiceName: "webhook",
-		CertName:           "cert",
-		Namespace:          "namespace",
-		CertProvider:       fakeProvider,
+		ServiceName:  "webhook",
+		CertName:     "cert",
+		Namespace:    "namespace",
+		CertProvider: fakeProvider,
 	}
 
 	err := provisioner.InjectCABundle(&corev1.Service{})
@@ -107,7 +107,7 @@ func Test_CertProvisionerFor(t *testing.T) {
 	})
 
 	require.Equal(t, prov.CertProvider, fakeProvider)
-	require.Equal(t, "my-deployment-thing-service", prov.WebhookServiceName)
+	require.Equal(t, "my-deployment-thing-service", prov.ServiceName)
 	require.Equal(t, "my-deployment-thing-service-cert", prov.CertName)
 	require.Equal(t, "my-namespace", prov.Namespace)
 }
@@ -115,8 +115,8 @@ func Test_CertProvisionerFor(t *testing.T) {
 func Test_CertProvisionerFor_ExtraLargeName_MoreThan63Chars(t *testing.T) {
 	prov := render.CertProvisionerFor("my.object.thing.has.a.really.really.really.really.really.long.name", render.Options{})
 
-	require.Len(t, prov.WebhookServiceName, 63)
+	require.Len(t, prov.ServiceName, 63)
 	require.Len(t, prov.CertName, 63)
-	require.Equal(t, "my-object-thing-has-a-really-really-really-really-reall-service", prov.WebhookServiceName)
+	require.Equal(t, "my-object-thing-has-a-really-really-really-really-reall-service", prov.ServiceName)
 	require.Equal(t, "my-object-thing-has-a-really-really-really-really-reall-se-cert", prov.CertName)
 }
diff --git a/internal/operator-controller/rukpak/render/certproviders/certmanager_test.go b/internal/operator-controller/rukpak/render/certproviders/certmanager_test.go
index b5da581..2acb40d 100644
--- a/internal/operator-controller/rukpak/render/certproviders/certmanager_test.go
+++ b/internal/operator-controller/rukpak/render/certproviders/certmanager_test.go
@@ -30,9 +30,9 @@ func Test_CertManagerProvider_InjectCABundle(t *testing.T) {
 			name: "injects certificate annotation in validating webhook configuration",
 			obj:  &admissionregistrationv1.ValidatingWebhookConfiguration{},
 			cfg: render.CertificateProvisionerConfig{
-				WebhookServiceName: "webhook-service",
-				Namespace:          "namespace",
-				CertName:           "cert-name",
+				ServiceName: "webhook-service",
+				Namespace:   "namespace",
+				CertName:    "cert-name",
 			},
 			expectedObj: &admissionregistrationv1.ValidatingWebhookConfiguration{
 				ObjectMeta: metav1.ObjectMeta{
@@ -46,9 +46,9 @@ func Test_CertManagerProvider_InjectCABundle(t *testing.T) {
 			name: "injects certificate annotation in mutating webhook configuration",
 			obj:  &admissionregistrationv1.MutatingWebhookConfiguration{},
 			cfg: render.CertificateProvisionerConfig{
-				WebhookServiceName: "webhook-service",
-				Namespace:          "namespace",
-				CertName:           "cert-name",
+				ServiceName: "webhook-service",
+				Namespace:   "namespace",
+				CertName:    "cert-name",
 			},
 			expectedObj: &admissionregistrationv1.MutatingWebhookConfiguration{
 				ObjectMeta: metav1.ObjectMeta{
@@ -62,9 +62,9 @@ func Test_CertManagerProvider_InjectCABundle(t *testing.T) {
 			name: "injects certificate annotation in custom resource definition",
 			obj:  &apiextensionsv1.CustomResourceDefinition{},
 			cfg: render.CertificateProvisionerConfig{
-				WebhookServiceName: "webhook-service",
-				Namespace:          "namespace",
-				CertName:           "cert-name",
+				ServiceName: "webhook-service",
+				Namespace:   "namespace",
+				CertName:    "cert-name",
 			},
 			expectedObj: &apiextensionsv1.CustomResourceDefinition{
 				ObjectMeta: metav1.ObjectMeta{
@@ -78,9 +78,9 @@ func Test_CertManagerProvider_InjectCABundle(t *testing.T) {
 			name: "ignores other objects",
 			obj:  &corev1.Service{},
 			cfg: render.CertificateProvisionerConfig{
-				WebhookServiceName: "webhook-service",
-				Namespace:          "namespace",
-				CertName:           "cert-name",
+				ServiceName: "webhook-service",
+				Namespace:   "namespace",
+				CertName:    "cert-name",
 			},
 			expectedObj: &corev1.Service{},
 		},
@@ -96,9 +96,9 @@ func Test_CertManagerProvider_InjectCABundle(t *testing.T) {
 func Test_CertManagerProvider_AdditionalObjects(t *testing.T) {
 	certProvier := certproviders.CertManagerCertificateProvider{}
 	objs, err := certProvier.AdditionalObjects(render.CertificateProvisionerConfig{
-		WebhookServiceName: "webhook-service",
-		Namespace:          "namespace",
-		CertName:           "cert-name",
+		ServiceName: "webhook-service",
+		Namespace:   "namespace",
+		CertName:    "cert-name",
 	})
 	require.NoError(t, err)
 	require.Equal(t, []unstructured.Unstructured{
@@ -143,6 +143,10 @@ func Test_CertManagerProvider_AdditionalObjects(t *testing.T) {
 					// OLMv0 has a 2 year certificate rotation period
 					Duration: 730 * 24 * time.Hour,
 				},
+				RenewBefore: &metav1.Duration{
+					// OLMv0 reviews 24h before expiry
+					Duration: 24 * time.Hour,
+				},
 			},
 		}),
 	}, objs)
@@ -151,9 +155,9 @@ func Test_CertManagerProvider_AdditionalObjects(t *testing.T) {
 func Test_CertManagerProvider_GetCertSecretInfo(t *testing.T) {
 	certProvier := certproviders.CertManagerCertificateProvider{}
 	certInfo := certProvier.GetCertSecretInfo(render.CertificateProvisionerConfig{
-		WebhookServiceName: "webhook-service",
-		Namespace:          "namespace",
-		CertName:           "cert-name",
+		ServiceName: "webhook-service",
+		Namespace:   "namespace",
+		CertName:    "cert-name",
 	})
 	require.Equal(t, render.CertSecretInfo{
 		SecretName:     "cert-name",
diff --git a/internal/operator-controller/rukpak/render/certproviders/openshift_serviceca_test.go b/internal/operator-controller/rukpak/render/certproviders/openshift_serviceca_test.go
index 24e8ecc..c4ca3e5 100644
--- a/internal/operator-controller/rukpak/render/certproviders/openshift_serviceca_test.go
+++ b/internal/operator-controller/rukpak/render/certproviders/openshift_serviceca_test.go
@@ -25,9 +25,9 @@ func Test_OpenshiftServiceCAProvider_InjectCABundle(t *testing.T) {
 			name: "injects inject-cabundle annotation in validating webhook configuration",
 			obj:  &admissionregistrationv1.ValidatingWebhookConfiguration{},
 			cfg: render.CertificateProvisionerConfig{
-				WebhookServiceName: "webhook-service",
-				Namespace:          "namespace",
-				CertName:           "cert-name",
+				ServiceName: "webhook-service",
+				Namespace:   "namespace",
+				CertName:    "cert-name",
 			},
 			expectedObj: &admissionregistrationv1.ValidatingWebhookConfiguration{
 				ObjectMeta: metav1.ObjectMeta{
@@ -41,9 +41,9 @@ func Test_OpenshiftServiceCAProvider_InjectCABundle(t *testing.T) {
 			name: "injects inject-cabundle annotation in mutating webhook configuration",
 			obj:  &admissionregistrationv1.MutatingWebhookConfiguration{},
 			cfg: render.CertificateProvisionerConfig{
-				WebhookServiceName: "webhook-service",
-				Namespace:          "namespace",
-				CertName:           "cert-name",
+				ServiceName: "webhook-service",
+				Namespace:   "namespace",
+				CertName:    "cert-name",
 			},
 			expectedObj: &admissionregistrationv1.MutatingWebhookConfiguration{
 				ObjectMeta: metav1.ObjectMeta{
@@ -57,9 +57,9 @@ func Test_OpenshiftServiceCAProvider_InjectCABundle(t *testing.T) {
 			name: "injects inject-cabundle annotation in custom resource definition",
 			obj:  &apiextensionsv1.CustomResourceDefinition{},
 			cfg: render.CertificateProvisionerConfig{
-				WebhookServiceName: "webhook-service",
-				Namespace:          "namespace",
-				CertName:           "cert-name",
+				ServiceName: "webhook-service",
+				Namespace:   "namespace",
+				CertName:    "cert-name",
 			},
 			expectedObj: &apiextensionsv1.CustomResourceDefinition{
 				ObjectMeta: metav1.ObjectMeta{
@@ -73,9 +73,9 @@ func Test_OpenshiftServiceCAProvider_InjectCABundle(t *testing.T) {
 			name: "injects serving-cert-secret-name annotation in service resource referencing the certificate name",
 			obj:  &corev1.Service{},
 			cfg: render.CertificateProvisionerConfig{
-				WebhookServiceName: "webhook-service",
-				Namespace:          "namespace",
-				CertName:           "cert-name",
+				ServiceName: "webhook-service",
+				Namespace:   "namespace",
+				CertName:    "cert-name",
 			},
 			expectedObj: &corev1.Service{
 				ObjectMeta: metav1.ObjectMeta{
@@ -89,9 +89,9 @@ func Test_OpenshiftServiceCAProvider_InjectCABundle(t *testing.T) {
 			name: "ignores other objects",
 			obj:  &corev1.Secret{},
 			cfg: render.CertificateProvisionerConfig{
-				WebhookServiceName: "webhook-service",
-				Namespace:          "namespace",
-				CertName:           "cert-name",
+				ServiceName: "webhook-service",
+				Namespace:   "namespace",
+				CertName:    "cert-name",
 			},
 			expectedObj: &corev1.Secret{},
 		},
@@ -107,9 +107,9 @@ func Test_OpenshiftServiceCAProvider_InjectCABundle(t *testing.T) {
 func Test_OpenshiftServiceCAProvider_AdditionalObjects(t *testing.T) {
 	certProvider := certproviders.OpenshiftServiceCaCertificateProvider{}
 	objs, err := certProvider.AdditionalObjects(render.CertificateProvisionerConfig{
-		WebhookServiceName: "webhook-service",
-		Namespace:          "namespace",
-		CertName:           "cert-name",
+		ServiceName: "webhook-service",
+		Namespace:   "namespace",
+		CertName:    "cert-name",
 	})
 	require.NoError(t, err)
 	require.Nil(t, objs)
@@ -118,9 +118,9 @@ func Test_OpenshiftServiceCAProvider_AdditionalObjects(t *testing.T) {
 func Test_OpenshiftServiceCAProvider_GetCertSecretInfo(t *testing.T) {
 	certProvider := certproviders.OpenshiftServiceCaCertificateProvider{}
 	certInfo := certProvider.GetCertSecretInfo(render.CertificateProvisionerConfig{
-		WebhookServiceName: "webhook-service",
-		Namespace:          "namespace",
-		CertName:           "cert-name",
+		ServiceName: "webhook-service",
+		Namespace:   "namespace",
+		CertName:    "cert-name",
 	})
 	require.Equal(t, render.CertSecretInfo{
 		SecretName:     "cert-name",
diff --git a/internal/operator-controller/rukpak/render/registryv1/generators/generators_test.go b/internal/operator-controller/rukpak/render/registryv1/generators/generators_test.go
index 91d02de..f2e542d 100644
--- a/internal/operator-controller/rukpak/render/registryv1/generators/generators_test.go
+++ b/internal/operator-controller/rukpak/render/registryv1/generators/generators_test.go
@@ -2000,7 +2000,7 @@ func Test_BundleMutatingWebhookResourceGenerator_FailsOnNil(t *testing.T) {
 	require.Contains(t, err.Error(), "bundle cannot be nil")
 }
 
-func Test_BundleWebhookServiceResourceGenerator_Succeeds(t *testing.T) {
+func Test_BundleDeploymentServiceResourceGenerator_Succeeds(t *testing.T) {
 	fakeProvider := FakeCertProvider{
 		InjectCABundleFn: func(obj client.Object, cfg render.CertificateProvisionerConfig) error {
 			obj.SetAnnotations(map[string]string{
@@ -2414,14 +2414,14 @@ func Test_BundleWebhookServiceResourceGenerator_Succeeds(t *testing.T) {
 		},
 	} {
 		t.Run(tc.name, func(t *testing.T) {
-			objs, err := generators.BundleWebhookServiceResourceGenerator(tc.bundle, tc.opts)
+			objs, err := generators.BundleDeploymentServiceResourceGenerator(tc.bundle, tc.opts)
 			require.NoError(t, err)
 			require.Equal(t, tc.expectedResources, objs)
 		})
 	}
 }
 
-func Test_BundleWebhookServiceResourceGenerator_FailsOnNil(t *testing.T) {
+func Test_BundleDeploymentServiceResourceGenerator_FailsOnNil(t *testing.T) {
 	objs, err := generators.BundleMutatingWebhookResourceGenerator(nil, render.Options{})
 	require.Nil(t, objs)
 	require.Error(t, err)
diff --git a/internal/operator-controller/rukpak/render/registryv1/registryv1_test.go b/internal/operator-controller/rukpak/render/registryv1/registryv1_test.go
index ed1b329..63dfc3a 100644
--- a/internal/operator-controller/rukpak/render/registryv1/registryv1_test.go
+++ b/internal/operator-controller/rukpak/render/registryv1/registryv1_test.go
@@ -50,7 +50,7 @@ func Test_ResourceGeneratorsHasAllGenerators(t *testing.T) {
 		generators.BundleCSVDeploymentGenerator,
 		generators.BundleValidatingWebhookResourceGenerator,
 		generators.BundleMutatingWebhookResourceGenerator,
-		generators.BundleWebhookServiceResourceGenerator,
+		generators.BundleDeploymentServiceResourceGenerator,
 		generators.CertProviderResourceGenerator,
 	}
 	actualGenerators := registryv1.ResourceGenerators