Repository: operator-framework/operator-controller
Pull request #2076 (2025-07-07 08:47:56)

:sparkles: Add NamespaceSelector to generated webhook configs
# Description

Sets the NamespaceSelector in generated Validating and MutatingWebhookConfiguration resources. This ensures that webhooks for operators installed in single/own namespace mode are (at least for namespaced APIs) restricted to the target namespace of the installation.

The implementation diverges from that of OLMv0. In OLM v0 the namespace selector is derived from OperatorGroup labels OLM sets on the target namespaces. In OLMv1 we don't have OperatorGroups and there's probably no need to add any labels to target namespaces. So, we use the kubernetes provided "kubernetes.io/metadata.name" label. This may not work in older versions of kubernetes.

Note: the renderer itself supports rendering the bundle manifests for multi-namespace mode. OLMv1 just doesn't support MultiNamespace mode. Therefore, the namespace selector uses a match expression with the `In` operator.

## Reviewer Checklist

- [ ] API Go Documentation
- [ ] Tests: Unit Tests (and E2E Tests, if appropriate)
- [ ] Comprehensive Commit Messages
- [ ] Links to related GitHub Issue(s)


netlify[bot]: ### <span aria-hidden="true">👷</span> Deploy Preview for *olmv1* processing.


|  Name | Link |
|:-:|------------------------|
|<span aria-hidden="true">🔨</span> Latest commit | 6ec2d73943132e9533f3704f78ebd42f3a40aa81 |
|<span aria-hidden="true">🔍</span> Latest deploy log | https://app.netlify.com/projects/olmv1/deploys/686b89be5ed8490008b953df |
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/operator-framework/operator-controller/pull/2076?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=operator-framework) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 73.37%. Comparing base [(`7d4414b`)](https://app.codecov.io/gh/operator-framework/operator-controller/commit/7d4414b1f9188d1af01034010cd921e6d62f657d?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=operator-framework) to head [(`6ec2d73`)](https://app.codecov.io/gh/operator-framework/operator-controller/commit/6ec2d73943132e9533f3704f78ebd42f3a40aa81?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=operator-framework).

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #2076      +/-   ##
==========================================
+ Coverage   73.34%   73.37%   +0.03%     
==========================================
  Files          77       77              
  Lines        7056     7076      +20     
==========================================
+ Hits         5175     5192      +17     
- Misses       1541     1543       +2     
- Partials      340      341       +1     
```

| [Flag](https://app.codecov.io/gh/operator-framework/operator-controller/pull/2076/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=operator-framework) | Coverage Δ | |
|---|---|---|
| [e2e](https://app.codecov.io/gh/operator-framework/operator-controller/pull/2076/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=operator-framework) | `?` | |
| [experimental-e2e](https://app.codecov.io/gh/operator-framework/operator-controller/pull/2076/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=operator-framework) | `51.15% <5.00%> (-0.15%)` | :arrow_down: |
| [unit](https://app.codecov.io/gh/operator-framework/operator-controller/pull/2076/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=operator-framework) | `58.38% <100.00%> (+0.11%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=operator-framework#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/operator-framework/operator-controller/pull/2076?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=operator-framework).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=operator-framework).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>

Patch:
diff --git a/config/overlays/tilt-local-dev/catalogd/kustomization.yaml b/config/overlays/tilt-local-dev/catalogd/kustomization.yaml
index c1585f0..449011f 100644
--- a/config/overlays/tilt-local-dev/catalogd/kustomization.yaml
+++ b/config/overlays/tilt-local-dev/catalogd/kustomization.yaml
@@ -6,6 +6,7 @@ commonAnnotations:
   olm.operatorframework.io/feature-set: tilt
 resources:
 - ../../../base/catalogd
+- ../../../base/catalogd/crd/experimental
 - ../../../base/common
 components:
 - ../../../components/cert-manager/catalogd
diff --git a/config/overlays/tilt-local-dev/operator-controller/kustomization.yaml b/config/overlays/tilt-local-dev/operator-controller/kustomization.yaml
index 911459c..3697a73 100644
--- a/config/overlays/tilt-local-dev/operator-controller/kustomization.yaml
+++ b/config/overlays/tilt-local-dev/operator-controller/kustomization.yaml
@@ -6,6 +6,7 @@ commonAnnotations:
   olm.operatorframework.io/feature-set: tilt
 resources:
 - ../../../base/operator-controller
+- ../../../base/operator-controller/crd/experimental
 - ../../../base/common
 components:
 - ../../../components/cert-manager/operator-controller
diff --git a/docs/draft/howto/enable-webhook-support.md b/docs/draft/howto/enable-webhook-support.md
index b21290a..2ab856b 100644
--- a/docs/draft/howto/enable-webhook-support.md
+++ b/docs/draft/howto/enable-webhook-support.md
@@ -15,17 +15,13 @@ certificate provider. Currently, two certificate providers are supported: CertMa
 
 As CertManager is already installed with OLMv1, we suggest using `WebhookProviderCertManager`.
 
-### Update OLM to enable Feature
+### Run OLM v1with Experimental Features Enabled
 
-```terminal title=Enable WebhookProviderCertManager feature
-kubectl kustomize config/overlays/featuregate/webhook-provider-certmanager | kubectl apply -f -
+```terminal title=Enable Experimental Features in a New Kind Cluster
+make run-experimental
 ```
 
-Or,
-
-```terminal title=Enable WebhookProviderOpenshiftServiceCA feature
-kubectl kustomize config/overlays/featuregate/webhook-provider-openshift-serviceca | kubectl apply -f -
-```
+This will enable only the `WebhookProviderCertManager` feature-gate, which works with cert-manager.
 
 Then,
 
diff --git a/docs/draft/howto/use-synthetic-permissions.md b/docs/draft/howto/use-synthetic-permissions.md
index 15f9c2c..9820ad2 100644
--- a/docs/draft/howto/use-synthetic-permissions.md
+++ b/docs/draft/howto/use-synthetic-permissions.md
@@ -8,10 +8,10 @@ Synthetic user permissions enables fine-grained configuration of ClusterExtensio
 User can not only configure RBAC permissions governing the management across all ClusterExtensions, but also on a 
 case-by-case basis.
 
-### Update OLM to enable Feature
+### Run OLM v1with Experimental Features Enabled
 
-```terminal title=Enable SyntheticPermissions feature
-kubectl kustomize config/overlays/featuregate/synthetic-user-permissions | kubectl apply -f -
+```terminal title=Enable Experimental Features in a New Kind Cluster
+make run-experimental
 ```
 
 ```terminal title=Wait for rollout to complete
diff --git a/internal/operator-controller/rukpak/render/registryv1/generators/generators.go b/internal/operator-controller/rukpak/render/registryv1/generators/generators.go
index 7ae8de8..3fdbf94 100644
--- a/internal/operator-controller/rukpak/render/registryv1/generators/generators.go
+++ b/internal/operator-controller/rukpak/render/registryv1/generators/generators.go
@@ -13,6 +13,7 @@ import (
 	corev1 "k8s.io/api/core/v1"
 	rbacv1 "k8s.io/api/rbac/v1"
 	apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/util/intstr"
 	"k8s.io/apimachinery/pkg/util/sets"
 	"k8s.io/utils/ptr"
@@ -29,6 +30,8 @@ import (
 const (
 	tlsCrtPath = "tls.crt"
 	tlsKeyPath = "tls.key"
+
+	labelKubernetesNamespaceMetadataName = "kubernetes.io/metadata.name"
 )
 
 // volume mount name -> mount path
@@ -291,6 +294,7 @@ func BundleValidatingWebhookResourceGenerator(rv1 *bundle.RegistryV1, opts rende
 
 	//nolint:prealloc
 	var objs []client.Object
+
 	for _, wh := range rv1.CSV.Spec.WebhookDefinitions {
 		if wh.Type != v1alpha1.ValidatingAdmissionWebhook {
 			continue
@@ -318,6 +322,9 @@ func BundleValidatingWebhookResourceGenerator(rv1 *bundle.RegistryV1, opts rende
 							Port:      &wh.ContainerPort,
 						},
 					},
+					// It is safe to create a namespace selector even for cluster scoped CRs. A webhook
+					// is never skipped for cluster scoped CRs.
+					NamespaceSelector: getWebhookNamespaceSelector(opts.TargetNamespaces),
 				},
 			),
 		)
@@ -367,6 +374,9 @@ func BundleMutatingWebhookResourceGenerator(rv1 *bundle.RegistryV1, opts render.
 						},
 					},
 					ReinvocationPolicy: wh.ReinvocationPolicy,
+					// It is safe to create a namespace selector even for cluster scoped CRs. A webhook
+					// is never skipped for cluster scoped CRs.
+					NamespaceSelector: getWebhookNamespaceSelector(opts.TargetNamespaces),
 				},
 			),
 		)
@@ -535,3 +545,20 @@ func addCertVolumesToDeployment(dep *appsv1.Deployment, certSecretInfo render.Ce
 		)
 	}
 }
+
+// getWebhookNamespaceSelector returns a label selector that matches any namespace in targetNamespaces.
+// If targetNamespaces is empty, nil, or includes "" (signifying all namespaces) nil is returned.
+func getWebhookNamespaceSelector(targetNamespaces []string) *metav1.LabelSelector {
+	if len(targetNamespaces) > 0 && !slices.Contains(targetNamespaces, "") {
+		return &metav1.LabelSelector{
+			MatchExpressions: []metav1.LabelSelectorRequirement{
+				{
+					Key:      labelKubernetesNamespaceMetadataName,
+					Operator: metav1.LabelSelectorOpIn,
+					Values:   targetNamespaces,
+				},
+			},
+		}
+	}
+	return nil
+}


Test patch:
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 bf48b2a..9b0e58e 100644
--- a/internal/operator-controller/rukpak/render/registryv1/generators/generators_test.go
+++ b/internal/operator-controller/rukpak/render/registryv1/generators/generators_test.go
@@ -1507,7 +1507,7 @@ func Test_BundleValidatingWebhookResourceGenerator_Succeeds(t *testing.T) {
 			},
 			opts: render.Options{
 				InstallNamespace: "install-namespace",
-				TargetNamespaces: []string{"watch-namespace-one", "watch-namespace-two"},
+				TargetNamespaces: []string{""},
 			},
 			expectedResources: []client.Object{
 				&admissionregistrationv1.ValidatingWebhookConfiguration{
@@ -1554,6 +1554,7 @@ func Test_BundleValidatingWebhookResourceGenerator_Succeeds(t *testing.T) {
 									Port:      ptr.To(int32(443)),
 								},
 							},
+							// No NamespaceSelector is set targetNamespaces = []string{""} (AllNamespaces install mode)
 						},
 					},
 				},
@@ -1647,6 +1648,15 @@ func Test_BundleValidatingWebhookResourceGenerator_Succeeds(t *testing.T) {
 									Port:      ptr.To(int32(443)),
 								},
 							},
+							NamespaceSelector: &metav1.LabelSelector{
+								MatchExpressions: []metav1.LabelSelectorRequirement{
+									{
+										Key:      "kubernetes.io/metadata.name",
+										Operator: metav1.LabelSelectorOpIn,
+										Values:   []string{"watch-namespace-one", "watch-namespace-two"},
+									},
+								},
+							},
 						},
 					},
 				},
@@ -1694,6 +1704,15 @@ func Test_BundleValidatingWebhookResourceGenerator_Succeeds(t *testing.T) {
 									Port:      ptr.To(int32(443)),
 								},
 							},
+							NamespaceSelector: &metav1.LabelSelector{
+								MatchExpressions: []metav1.LabelSelectorRequirement{
+									{
+										Key:      "kubernetes.io/metadata.name",
+										Operator: metav1.LabelSelectorOpIn,
+										Values:   []string{"watch-namespace-one", "watch-namespace-two"},
+									},
+								},
+							},
 						},
 					},
 				},
@@ -1772,7 +1791,7 @@ func Test_BundleMutatingWebhookResourceGenerator_Succeeds(t *testing.T) {
 			},
 			opts: render.Options{
 				InstallNamespace: "install-namespace",
-				TargetNamespaces: []string{"watch-namespace-one", "watch-namespace-two"},
+				TargetNamespaces: []string{""},
 			},
 			expectedResources: []client.Object{
 				&admissionregistrationv1.MutatingWebhookConfiguration{
@@ -1820,6 +1839,7 @@ func Test_BundleMutatingWebhookResourceGenerator_Succeeds(t *testing.T) {
 									Port:      ptr.To(int32(443)),
 								},
 							},
+							// No NamespaceSelector is set targetNamespaces = []string{""} (AllNamespaces install mode)
 						},
 					},
 				},
@@ -1915,6 +1935,15 @@ func Test_BundleMutatingWebhookResourceGenerator_Succeeds(t *testing.T) {
 									Port:      ptr.To(int32(443)),
 								},
 							},
+							NamespaceSelector: &metav1.LabelSelector{
+								MatchExpressions: []metav1.LabelSelectorRequirement{
+									{
+										Key:      "kubernetes.io/metadata.name",
+										Operator: metav1.LabelSelectorOpIn,
+										Values:   []string{"watch-namespace-one", "watch-namespace-two"},
+									},
+								},
+							},
 						},
 					},
 				},
@@ -1962,6 +1991,15 @@ func Test_BundleMutatingWebhookResourceGenerator_Succeeds(t *testing.T) {
 									Port:      ptr.To(int32(443)),
 								},
 							},
+							NamespaceSelector: &metav1.LabelSelector{
+								MatchExpressions: []metav1.LabelSelectorRequirement{
+									{
+										Key:      "kubernetes.io/metadata.name",
+										Operator: metav1.LabelSelectorOpIn,
+										Values:   []string{"watch-namespace-one", "watch-namespace-two"},
+									},
+								},
+							},
 						},
 					},
 				},
diff --git a/test/e2e/cluster_extension_install_test.go b/test/e2e/cluster_extension_install_test.go
index 3129a5a..bc82512 100644
--- a/test/e2e/cluster_extension_install_test.go
+++ b/test/e2e/cluster_extension_install_test.go
@@ -317,7 +317,7 @@ func TestClusterExtensionInstallRegistry(t *testing.T) {
 		},
 		{
 			// NOTE: This test requires an extra configuration in /etc/containers/registries.conf, which is mounted
-			// for this e2e via the ./config/components/registries-conf kustomize component as part of the e2e overlay.
+			// for this e2e via the ./config/components/e2e/registries-conf kustomize component as part of the e2e component.
 			// The goal here is to prove that "mirrored-registry.operator-controller-e2e.svc.cluster.local:5000" is
 			// mapped to the "real" registry hostname ("docker-registry.operator-controller-e2e.svc.cluster.local:5000").
 			name:        "package requires mirror registry configuration in /etc/containers/registries.conf",