Repository: projectcapsule/capsule

Issue:
improve cordoning selection
<!--
Yay, it look you're enjoying Capsule and, first, thanks for that!

We're trying to build a community drive Open Source project, so don't
hesitate proposing your enhancement ideas: keep in mind, since we would like
to keep it as agnostic as possible, to motivate all your assumptions.

-->

# Describe the feature

When a tenant is cordoned we intercept anything with a validatingwebhook and block, if cordoned true. This is the Webhook entry

```
  name: cordoning.tenant.projectcapsule.dev
  namespaceSelector:
    matchExpressions:
    - key: capsule.clastix.io/tenant
      operator: Exists
  objectSelector: {}
  rules:
  - apiGroups:
    - '*'
    apiVersions:
    - '*'
    operations:
    - CREATE
    - UPDATE
    - DELETE
    resources:
    - '*'
```

To save resources, we should add an additional namespace selector and add a label to namespaces which are in a cordoned tenant, eg. `projectcapsule.dev/cordoned`. This way we can let the Admission preselect which resources really need to be passed to the cordoned webhook, Eg:


```
  name: cordoning.tenant.projectcapsule.dev
  namespaceSelector:
    matchExpressions:
    - key: capsule.clastix.io/tenant
      operator: Exists
    - key: projectcapsule.dev/cordoned
      operator: Exists
  objectSelector: {}
  rules:
  - apiGroups:
    - '*'
    apiVersions:
    - '*'
    operations:
    - CREATE
    - UPDATE
    - DELETE
    resources:
    - '*'
```

Tasks:
- Add expression to helm chart for cordoning webhook
- Add in Tenant controller, that namespaces in a cordoned tenant get this label. When uncordoned remove label
- Verify with E2E Tests




Discussion:

Solution patch:
diff --git a/.github/workflows/check-actions.yml b/.github/workflows/check-actions.yml
index c9e4d7e..b60f95a 100644
--- a/.github/workflows/check-actions.yml
+++ b/.github/workflows/check-actions.yml
@@ -17,7 +17,7 @@ jobs:
       - name: Checkout
         uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
       - name: Ensure SHA pinned actions
-        uses: zgosalvez/github-actions-ensure-sha-pinned-actions@4830be28ce81da52ec70d65c552a7403821d98d4 # v3.0.23
+        uses: zgosalvez/github-actions-ensure-sha-pinned-actions@2d6823da4039243036c86d76f503c84e2ded2517 # v3.0.24
         with:
           # slsa-github-generator requires using a semver tag for reusable workflows.
           # See: https://github.com/slsa-framework/slsa-github-generator#referencing-slsa-builders-and-generators
diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml
index 499eab5..6c252c1 100644
--- a/.github/workflows/coverage.yml
+++ b/.github/workflows/coverage.yml
@@ -56,7 +56,7 @@ jobs:
         with:
           args: '-no-fail -fmt sarif -out gosec.sarif ./...'
       - name: Upload SARIF file
-        uses: github/codeql-action/upload-sarif@97a2bfd2a3d26d458da69e548f7f859d6fca634d
+        uses: github/codeql-action/upload-sarif@5eb3ed6614230b1931d5c08df9e096e4ba524f21
         with:
           sarif_file: gosec.sarif
   unit_tests:
diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml
index 09f5331..0802880 100644
--- a/.github/workflows/docker-build.yml
+++ b/.github/workflows/docker-build.yml
@@ -40,6 +40,6 @@ jobs:
           # See: https://github.com/aquasecurity/trivy-action/issues/389#issuecomment-2385416577
           TRIVY_DB_REPOSITORY: 'public.ecr.aws/aquasecurity/trivy-db:2'
       - name: Upload Trivy scan results to GitHub Security tab
-        uses: github/codeql-action/upload-sarif@97a2bfd2a3d26d458da69e548f7f859d6fca634d
+        uses: github/codeql-action/upload-sarif@5eb3ed6614230b1931d5c08df9e096e4ba524f21
         with:
           sarif_file: 'trivy-results.sarif'
diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml
index 030dcaa..ffaee7b 100644
--- a/.github/workflows/scorecard.yml
+++ b/.github/workflows/scorecard.yml
@@ -37,6 +37,6 @@ jobs:
           path: results.sarif
           retention-days: 5
       - name: Upload to code-scanning
-        uses: github/codeql-action/upload-sarif@28deaeda66b76a05916b6923827895f2b14ab387 # v3.28.16
+        uses: github/codeql-action/upload-sarif@60168efe1c415ce0f5521ea06d5c2062adbeed1b # v3.28.17
         with:
           sarif_file: results.sarif
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 99064a2..79bc0ad 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -13,7 +13,7 @@ repos:
   - id: end-of-file-fixer
   - id: trailing-whitespace
 - repo: https://github.com/adrienverge/yamllint
-  rev: v1.37.0
+  rev: v1.37.1
   hooks:
   - id: yamllint
     args: [-c=.github/configs/lintconf.yaml]
diff --git a/Makefile b/Makefile
index a0d0b7b..be0277a 100644
--- a/Makefile
+++ b/Makefile
@@ -357,7 +357,7 @@ ko:
 	$(call go-install-tool,$(KO),github.com/$(KO_LOOKUP)@$(KO_VERSION))
 
 GOLANGCI_LINT          := $(LOCALBIN)/golangci-lint
-GOLANGCI_LINT_VERSION  := v2.1.5
+GOLANGCI_LINT_VERSION  := v2.1.6
 GOLANGCI_LINT_LOOKUP   := golangci/golangci-lint
 golangci-lint: ## Download golangci-lint locally if necessary.
 	@test -s $(GOLANGCI_LINT) && $(GOLANGCI_LINT) -h | grep -q $(GOLANGCI_LINT_VERSION) || \
@@ -371,7 +371,7 @@ apidocs-gen: ## Download crdoc locally if necessary.
 	$(call go-install-tool,$(APIDOCS_GEN),fybrik.io/crdoc@$(APIDOCS_GEN_VERSION))
 
 HARPOON         := $(LOCALBIN)/harpoon
-HARPOON_VERSION := v0.9.6
+HARPOON_VERSION := v0.10.1
 HARPOON_LOOKUP  := alegrey91/harpoon
 harpoon:
 	@mkdir $(LOCALBIN)
diff --git a/charts/capsule/README.md b/charts/capsule/README.md
index 5a9a2d3..0e478e5 100644
--- a/charts/capsule/README.md
+++ b/charts/capsule/README.md
@@ -197,6 +197,12 @@ Here the values you can override:
 | webhooks.hooks.cordoning.failurePolicy | string | `"Fail"` |  |
 | webhooks.hooks.cordoning.namespaceSelector.matchExpressions[0].key | string | `"capsule.clastix.io/tenant"` |  |
 | webhooks.hooks.cordoning.namespaceSelector.matchExpressions[0].operator | string | `"Exists"` |  |
+| webhooks.hooks.cordoning.namespaceSelector.matchExpressions[1].key | string | `"projectcapsule.dev/cordoned"` |  |
+| webhooks.hooks.cordoning.namespaceSelector.matchExpressions[1].operator | string | `"Exists"` |  |
+| webhooks.hooks.customresources.failurePolicy | string | `"Fail"` |  |
+| webhooks.hooks.customresources.namespaceSelector.matchExpressions[0].key | string | `"capsule.clastix.io/tenant"` |  |
+| webhooks.hooks.customresources.namespaceSelector.matchExpressions[0].operator | string | `"Exists"` |  |
+| webhooks.hooks.customresources.objectSelector | object | `{}` |  |
 | webhooks.hooks.defaults.ingress.failurePolicy | string | `"Fail"` |  |
 | webhooks.hooks.defaults.ingress.namespaceSelector.matchExpressions[0].key | string | `"capsule.clastix.io/tenant"` |  |
 | webhooks.hooks.defaults.ingress.namespaceSelector.matchExpressions[0].operator | string | `"Exists"` |  |
@@ -209,6 +215,10 @@ Here the values you can override:
 | webhooks.hooks.ingresses.failurePolicy | string | `"Fail"` |  |
 | webhooks.hooks.ingresses.namespaceSelector.matchExpressions[0].key | string | `"capsule.clastix.io/tenant"` |  |
 | webhooks.hooks.ingresses.namespaceSelector.matchExpressions[0].operator | string | `"Exists"` |  |
+| webhooks.hooks.namespace.mutation.failurePolicy | string | `"Fail"` |  |
+| webhooks.hooks.namespace.mutation.namespaceSelector | object | `{}` |  |
+| webhooks.hooks.namespace.mutation.objectSelector | object | `{}` |  |
+| webhooks.hooks.namespace.validation.failurePolicy | string | `"Fail"` |  |
 | webhooks.hooks.namespaceOwnerReference.failurePolicy | string | `"Fail"` |  |
 | webhooks.hooks.namespaces.failurePolicy | string | `"Fail"` |  |
 | webhooks.hooks.networkpolicies.failurePolicy | string | `"Fail"` |  |
diff --git a/charts/capsule/templates/mutatingwebhookconfiguration.yaml b/charts/capsule/templates/mutatingwebhookconfiguration.yaml
index ada65e9..2cc4297 100644
--- a/charts/capsule/templates/mutatingwebhookconfiguration.yaml
+++ b/charts/capsule/templates/mutatingwebhookconfiguration.yaml
@@ -81,28 +81,34 @@ webhooks:
   sideEffects: None
   timeoutSeconds: {{ $.Values.webhooks.mutatingWebhooksTimeoutSeconds }}
 {{- end }}
-{{- with .Values.webhooks.hooks.namespaceOwnerReference }}
+{{- with (mergeOverwrite .Values.webhooks.hooks.namespace.mutation .Values.webhooks.hooks.namespaceOwnerReference) }}
 - admissionReviewVersions:
     - v1
     - v1beta1
   clientConfig:
-    {{- include "capsule.webhooks.service" (dict "path" "/namespace-owner-reference" "ctx" $) | nindent 4 }}
+    {{- include "capsule.webhooks.service" (dict "path" "/namespace-patch" "ctx" $) | nindent 4 }}
   failurePolicy: {{ .failurePolicy }}
   matchPolicy: Equivalent
-  name: owner.namespace.projectcapsule.dev
-  namespaceSelector: {}
-  objectSelector: {}
+  name: namespace-patching.tenants.projectcapsule.dev
+  {{- with .namespaceSelector }}
+  namespaceSelector:
+    {{- toYaml . |  nindent 4 }}
+  {{- end }}
+  {{- with .objectSelector }}
+  objectSelector:
+    {{- toYaml . |  nindent 4 }}
+  {{- end }}
   reinvocationPolicy: Never
   rules:
     - apiGroups:
-      - ""
+        - ""
       apiVersions:
-      - v1
+        - v1
       operations:
-      - CREATE
-      - UPDATE
+        - CREATE
+        - UPDATE
       resources:
-      - namespaces
+        - namespaces
       scope: '*'
   sideEffects: NoneOnDryRun
   timeoutSeconds: {{ $.Values.webhooks.mutatingWebhooksTimeoutSeconds }}
diff --git a/charts/capsule/templates/validatingwebhookconfiguration.yaml b/charts/capsule/templates/validatingwebhookconfiguration.yaml
index 59e71e2..2dba323 100644
--- a/charts/capsule/templates/validatingwebhookconfiguration.yaml
+++ b/charts/capsule/templates/validatingwebhookconfiguration.yaml
@@ -274,4 +274,36 @@ webhooks:
   sideEffects: None
   timeoutSeconds: {{ $.Values.webhooks.validatingWebhooksTimeoutSeconds }}
 {{- end }}
+{{- with .Values.webhooks.hooks.customresources }}
+- admissionReviewVersions:
+    - v1
+    - v1beta1
+  clientConfig:
+    {{- include "capsule.webhooks.service" (dict "path" "/customresources" "ctx" $) | nindent 4 }}
+  failurePolicy: {{ .failurePolicy }}
+  matchPolicy: Equivalent
+  name: customresources.tenant.projectcapsule.dev
+  {{- with .namespaceSelector }}
+  namespaceSelector:
+    {{- toYaml . |  nindent 4 }}
+  {{- end }}
+  {{- with .objectSelector }}
+  objectSelector:
+    {{- toYaml . |  nindent 4 }}
+  {{- end }}
+  rules:
+    - apiGroups:
+        - '*'
+      apiVersions:
+        - '*'
+      operations:
+        - CREATE
+        - UPDATE
+        - DELETE
+      resources:
+        - '*'
+      scope: Namespaced
+  sideEffects: None
+  timeoutSeconds: {{ $.Values.webhooks.validatingWebhooksTimeoutSeconds }}
 {{- end }}
+{{- end }}
\ No newline at end of file
diff --git a/charts/capsule/values.schema.json b/charts/capsule/values.schema.json
index 252236b..ed42dda 100644
--- a/charts/capsule/values.schema.json
+++ b/charts/capsule/values.schema.json
@@ -512,6 +512,37 @@
                             },
                             "type": "object"
                         },
+                        "customresources": {
+                            "properties": {
+                                "failurePolicy": {
+                                    "type": "string"
+                                },
+                                "namespaceSelector": {
+                                    "properties": {
+                                        "matchExpressions": {
+                                            "items": {
+                                                "properties": {
+                                                    "key": {
+                                                        "type": "string"
+                                                    },
+                                                    "operator": {
+                                                        "type": "string"
+                                                    }
+                                                },
+                                                "type": "object"
+                                            },
+                                            "type": "array"
+                                        }
+                                    },
+                                    "type": "object"
+                                },
+                                "objectSelector": {
+                                    "properties": {},
+                                    "type": "object"
+                                }
+                            },
+                            "type": "object"
+                        },
                         "defaults": {
                             "properties": {
                                 "ingress": {
@@ -625,6 +656,35 @@
                             },
                             "type": "object"
                         },
+                        "namespace": {
+                            "properties": {
+                                "mutation": {
+                                    "properties": {
+                                        "failurePolicy": {
+                                            "type": "string"
+                                        },
+                                        "namespaceSelector": {
+                                            "properties": {},
+                                            "type": "object"
+                                        },
+                                        "objectSelector": {
+                                            "properties": {},
+                                            "type": "object"
+                                        }
+                                    },
+                                    "type": "object"
+                                },
+                                "validation": {
+                                    "properties": {
+                                        "failurePolicy": {
+                                            "type": "string"
+                                        }
+                                    },
+                                    "type": "object"
+                                }
+                            },
+                            "type": "object"
+                        },
                         "namespaceOwnerReference": {
                             "properties": {
                                 "failurePolicy": {
diff --git a/charts/capsule/values.yaml b/charts/capsule/values.yaml
index c1d70ea..c180f71 100644
--- a/charts/capsule/values.yaml
+++ b/charts/capsule/values.yaml
@@ -267,12 +267,28 @@ webhooks:
   hooks:
     namespaceOwnerReference:
       failurePolicy: Fail
+    customresources:
+      failurePolicy: Fail
+      namespaceSelector:
+        matchExpressions:
+          - key: capsule.clastix.io/tenant
+            operator: Exists
+      objectSelector: {}
+    namespace:
+      validation:
+        failurePolicy: Fail
+      mutation:
+        failurePolicy: Fail
+        namespaceSelector: {}
+        objectSelector: {}
     cordoning:
       failurePolicy: Fail
       namespaceSelector:
         matchExpressions:
           - key: capsule.clastix.io/tenant
             operator: Exists
+          - key: projectcapsule.dev/cordoned
+            operator: Exists
     ingresses:
       failurePolicy: Fail
       namespaceSelector:
diff --git a/controllers/tenant/namespaces.go b/controllers/tenant/namespaces.go
index 64843a3..cee3112 100644
--- a/controllers/tenant/namespaces.go
+++ b/controllers/tenant/namespaces.go
@@ -73,6 +73,12 @@ func (r *Manager) syncNamespaceMetadata(ctx context.Context, namespace string, t
 				}
 			}
 
+			if tnt.Spec.Cordoned {
+				ns.Labels[utils.CordonedLabel] = "true"
+			} else {
+				delete(ns.Labels, utils.CordonedLabel)
+			}
+
 			if tnt.Spec.NodeSelector != nil {
 				annotations = utils.BuildNodeSelector(tnt, annotations)
 			}
diff --git a/go.mod b/go.mod
index f20c186..0dc2bd6 100644
--- a/go.mod
+++ b/go.mod
@@ -20,8 +20,8 @@ require (
 	k8s.io/apiextensions-apiserver v0.33.0
 	k8s.io/apimachinery v0.33.0
 	k8s.io/client-go v0.33.0
-	k8s.io/utils v0.0.0-20250321185631-1f6e0b77f77e
-	sigs.k8s.io/cluster-api v1.10.0
+	k8s.io/utils v0.0.0-20250502105355-0f33e8f1c979
+	sigs.k8s.io/cluster-api v1.10.1
 	sigs.k8s.io/controller-runtime v0.20.4
 )
 
diff --git a/go.sum b/go.sum
index 8375090..539f1d9 100644
--- a/go.sum
+++ b/go.sum
@@ -453,6 +453,8 @@ k8s.io/utils v0.0.0-20241210054802-24370beab758 h1:sdbE21q2nlQtFh65saZY+rRM6x6aJ
 k8s.io/utils v0.0.0-20241210054802-24370beab758/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
 k8s.io/utils v0.0.0-20250321185631-1f6e0b77f77e h1:KqK5c/ghOm8xkHYhlodbp6i6+r+ChV2vuAuVRdFbLro=
 k8s.io/utils v0.0.0-20250321185631-1f6e0b77f77e/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
+k8s.io/utils v0.0.0-20250502105355-0f33e8f1c979 h1:jgJW5IePPXLGB8e/1wvd0Ich9QE97RvvF3a8J3fP/Lg=
+k8s.io/utils v0.0.0-20250502105355-0f33e8f1c979/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
 sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.30.3 h1:2770sDpzrjjsAtVhSeUFseziht227YAWYHLGNM8QPwY=
 sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.30.3/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw=
 sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 h1:jpcvIRr3GLoUoEKRkHKSmGjxb6lWwrBlJsXc+eUYQHM=
@@ -461,6 +463,8 @@ sigs.k8s.io/cluster-api v1.8.4 h1:jBKQH1H/HUdUFk8T6qDzIxZJfWw1F5ZP0ZpYQJDmTHs=
 sigs.k8s.io/cluster-api v1.8.4/go.mod h1:pXv5LqLxuIbhGIXykyNKiJh+KrLweSBajVHHitPLyoY=
 sigs.k8s.io/cluster-api v1.10.0 h1:Jcmr8aEDI4/MCqNmvjj9YQlW7r4qTUpTnL6T9UUrVuQ=
 sigs.k8s.io/cluster-api v1.10.0/go.mod h1:aiPMrNPoaJc/GuJ4TCpWX8bVe11+iCJ4HI0f3c9QiJg=
+sigs.k8s.io/cluster-api v1.10.1 h1:5vsLNgQ4SkPudJ1USK532B0SIdJxRsCNKt2DZtBf+ww=
+sigs.k8s.io/cluster-api v1.10.1/go.mod h1:aiPMrNPoaJc/GuJ4TCpWX8bVe11+iCJ4HI0f3c9QiJg=
 sigs.k8s.io/controller-runtime v0.19.4 h1:SUmheabttt0nx8uJtoII4oIP27BVVvAKFvdvGFwV/Qo=
 sigs.k8s.io/controller-runtime v0.19.4/go.mod h1:iRmWllt8IlaLjvTTDLhRBXIEtkCK6hwVBJJsYS9Ajf4=
 sigs.k8s.io/controller-runtime v0.20.4 h1:X3c+Odnxz+iPTRobG4tp092+CvBU9UK0t/bRf+n0DGU=
diff --git a/main.go b/main.go
index 1fbec26..69f1c48 100644
--- a/main.go
+++ b/main.go
@@ -43,10 +43,10 @@ import (
 	"github.com/projectcapsule/capsule/pkg/webhook"
 	"github.com/projectcapsule/capsule/pkg/webhook/defaults"
 	"github.com/projectcapsule/capsule/pkg/webhook/ingress"
-	namespacewebhook "github.com/projectcapsule/capsule/pkg/webhook/namespace"
+	namespacemutation "github.com/projectcapsule/capsule/pkg/webhook/namespace/mutation"
+	namespacevalidation "github.com/projectcapsule/capsule/pkg/webhook/namespace/validation"
 	"github.com/projectcapsule/capsule/pkg/webhook/networkpolicy"
 	"github.com/projectcapsule/capsule/pkg/webhook/node"
-	"github.com/projectcapsule/capsule/pkg/webhook/ownerreference"
 	"github.com/projectcapsule/capsule/pkg/webhook/pod"
 	"github.com/projectcapsule/capsule/pkg/webhook/pvc"
 	"github.com/projectcapsule/capsule/pkg/webhook/route"
@@ -220,16 +220,17 @@ func main() {
 	webhooksList := append(
 		make([]webhook.Webhook, 0),
 		route.Pod(pod.ImagePullPolicy(), pod.ContainerRegistry(), pod.PriorityClass(), pod.RuntimeClass()),
-		route.Namespace(utils.InCapsuleGroups(cfg, namespacewebhook.PatchHandler(), namespacewebhook.QuotaHandler(), namespacewebhook.FreezeHandler(cfg), namespacewebhook.PrefixHandler(cfg), namespacewebhook.UserMetadataHandler())),
+		route.Namespace(utils.InCapsuleGroups(cfg, namespacevalidation.PatchHandler(), namespacevalidation.QuotaHandler(), namespacevalidation.FreezeHandler(cfg), namespacevalidation.PrefixHandler(cfg), namespacevalidation.UserMetadataHandler())),
 		route.Ingress(ingress.Class(cfg, kubeVersion), ingress.Hostnames(cfg), ingress.Collision(cfg), ingress.Wildcard()),
 		route.PVC(pvc.Validating(), pvc.PersistentVolumeReuse()),
 		route.Service(service.Handler()),
 		route.TenantResourceObjects(utils.InCapsuleGroups(cfg, tntresource.WriteOpsHandler())),
 		route.NetworkPolicy(utils.InCapsuleGroups(cfg, networkpolicy.Handler())),
 		route.Tenant(tenant.NameHandler(), tenant.RoleBindingRegexHandler(), tenant.IngressClassRegexHandler(), tenant.StorageClassRegexHandler(), tenant.ContainerRegistryRegexHandler(), tenant.HostnameRegexHandler(), tenant.FreezedEmitter(), tenant.ServiceAccountNameHandler(), tenant.ForbiddenAnnotationsRegexHandler(), tenant.ProtectedHandler(), tenant.MetaHandler()),
-		route.OwnerReference(utils.InCapsuleGroups(cfg, ownerreference.Handler(cfg))),
-		route.Cordoning(tenant.CordoningHandler(cfg), tenant.ResourceCounterHandler(manager.GetClient())),
+		route.Cordoning(tenant.CordoningHandler(cfg)),
 		route.Node(utils.InCapsuleGroups(cfg, node.UserMetadataHandler(cfg, kubeVersion))),
+		route.NamespacePatch(utils.InCapsuleGroups(cfg, namespacemutation.CordoningLabelHandler(cfg), namespacemutation.OwnerReferenceHandler(cfg))),
+		route.CustomResources(tenant.ResourceCounterHandler(manager.GetClient())),
 		route.Defaults(defaults.Handler(cfg, kubeVersion)),
 	)
 
diff --git a/pkg/utils/tenant_labels.go b/pkg/utils/tenant_labels.go
index b236c5f..c1a10ad 100644
--- a/pkg/utils/tenant_labels.go
+++ b/pkg/utils/tenant_labels.go
@@ -15,6 +15,10 @@ import (
 	"github.com/projectcapsule/capsule/api/v1beta2"
 )
 
+const (
+	CordonedLabel = "projectcapsule.dev/cordoned"
+)
+
 func GetTypeLabel(t runtime.Object) (label string, err error) {
 	switch v := t.(type) {
 	case *v1beta1.Tenant, *v1beta2.Tenant:
diff --git a/pkg/webhook/namespace/mutation/cordoning.go b/pkg/webhook/namespace/mutation/cordoning.go
new file mode 100644
index 0000000..465067d
--- /dev/null
+++ b/pkg/webhook/namespace/mutation/cordoning.go
@@ -0,0 +1,92 @@
+// Copyright 2020-2023 Project Capsule Authors.
+// SPDX-License-Identifier: Apache-2.0
+
+package mutation
+
+import (
+	"context"
+	"encoding/json"
+	corev1 "k8s.io/api/core/v1"
+	"k8s.io/apimachinery/pkg/types"
+	"k8s.io/client-go/tools/record"
+	"net/http"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
+
+	capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
+	"github.com/projectcapsule/capsule/pkg/configuration"
+	capsuleutils "github.com/projectcapsule/capsule/pkg/utils"
+	capsulewebhook "github.com/projectcapsule/capsule/pkg/webhook"
+	"github.com/projectcapsule/capsule/pkg/webhook/utils"
+)
+
+type cordoningLabelHandler struct {
+	cfg configuration.Configuration
+}
+
+func CordoningLabelHandler(cfg configuration.Configuration) capsulewebhook.Handler {
+	return &cordoningLabelHandler{
+		cfg: cfg,
+	}
+}
+
+func (h *cordoningLabelHandler) OnCreate(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
+	return func(context.Context, admission.Request) *admission.Response {
+		return nil
+	}
+}
+
+func (h *cordoningLabelHandler) OnDelete(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
+	return func(context.Context, admission.Request) *admission.Response {
+		return nil
+	}
+}
+
+func (h *cordoningLabelHandler) OnUpdate(c client.Client, decoder admission.Decoder, _ record.EventRecorder) capsulewebhook.Func {
+	return func(ctx context.Context, req admission.Request) *admission.Response {
+		ns := &corev1.Namespace{}
+		if err := decoder.Decode(req, ns); err != nil {
+			return utils.ErroredResponse(err)
+		}
+		response := h.syncNamespaceCordonLabel(ctx, c, req, ns)
+		return response
+	}
+}
+
+func (h *cordoningLabelHandler) syncNamespaceCordonLabel(ctx context.Context, c client.Client, req admission.Request, ns *corev1.Namespace) *admission.Response {
+	tnt := &capsulev1beta2.Tenant{}
+	ln, err := capsuleutils.GetTypeLabel(tnt)
+	if err != nil {
+		response := admission.Errored(http.StatusInternalServerError, err)
+
+		return &response
+	}
+
+	if label, ok := ns.Labels[ln]; ok {
+		if err = c.Get(ctx, types.NamespacedName{Name: label}, tnt); err != nil {
+			admission.Errored(http.StatusInternalServerError, err)
+		}
+	}
+
+	if !tnt.Spec.Cordoned {
+		return nil
+	}
+
+	labels := ns.GetLabels()
+	if _, ok := labels[capsuleutils.CordonedLabel]; ok {
+		return nil
+	}
+
+	ns.Labels[capsuleutils.CordonedLabel] = "true"
+
+	marshaled, err := json.Marshal(ns)
+	if err != nil {
+		response := admission.Errored(http.StatusInternalServerError, err)
+
+		return &response
+	}
+
+	response := admission.PatchResponseFromRaw(req.Object.Raw, marshaled)
+
+	return &response
+}
diff --git a/pkg/webhook/ownerreference/patching.go b/pkg/webhook/namespace/mutation/ownerreference.go
similarity index 85%
rename from pkg/webhook/ownerreference/patching.go
rename to pkg/webhook/namespace/mutation/ownerreference.go
index a120095..fa41b8e 100644
--- a/pkg/webhook/ownerreference/patching.go
+++ b/pkg/webhook/namespace/mutation/ownerreference.go
@@ -1,7 +1,7 @@
 // Copyright 2020-2023 Project Capsule Authors.
 // SPDX-License-Identifier: Apache-2.0
 
-package ownerreference
+package mutation
 
 import (
 	"context"
@@ -28,29 +28,29 @@ import (
 	"github.com/projectcapsule/capsule/pkg/webhook/utils"
 )
 
-type handler struct {
+type ownerReferenceHandler struct {
 	cfg configuration.Configuration
 }
 
-func Handler(cfg configuration.Configuration) capsulewebhook.Handler {
-	return &handler{
+func OwnerReferenceHandler(cfg configuration.Configuration) capsulewebhook.Handler {
+	return &ownerReferenceHandler{
 		cfg: cfg,
 	}
 }
 
-func (h *handler) OnCreate(client client.Client, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
+func (h *ownerReferenceHandler) OnCreate(client client.Client, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
 	return func(ctx context.Context, req admission.Request) *admission.Response {
 		return h.setOwnerRef(ctx, req, client, decoder, recorder)
 	}
 }
 
-func (h *handler) OnDelete(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
+func (h *ownerReferenceHandler) OnDelete(client.Client, admission.Decoder, record.EventRecorder) capsulewebhook.Func {
 	return func(context.Context, admission.Request) *admission.Response {
 		return nil
 	}
 }
 
-func (h *handler) OnUpdate(c client.Client, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
+func (h *ownerReferenceHandler) OnUpdate(c client.Client, decoder admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
 	return func(ctx context.Context, req admission.Request) *admission.Response {
 		oldNs := &corev1.Namespace{}
 		if err := decoder.DecodeRaw(req.OldObject, oldNs); err != nil {
@@ -113,7 +113,7 @@ func (h *handler) OnUpdate(c client.Client, decoder admission.Decoder, recorder
 	}
 }
 
-func (h *handler) namespaceIsOwned(ns *corev1.Namespace, tenantList *capsulev1beta2.TenantList, req admission.Request) bool {
+func (h *ownerReferenceHandler) namespaceIsOwned(ns *corev1.Namespace, tenantList *capsulev1beta2.TenantList, req admission.Request) bool {
 	for _, tenant := range tenantList.Items {
 		for _, ownerRef := range ns.OwnerReferences {
 			if !capsuleutils.IsTenantOwnerReference(ownerRef) {
@@ -129,7 +129,7 @@ func (h *handler) namespaceIsOwned(ns *corev1.Namespace, tenantList *capsulev1be
 	return false
 }
 
-func (h *handler) setOwnerRef(ctx context.Context, req admission.Request, client client.Client, decoder admission.Decoder, recorder record.EventRecorder) *admission.Response {
+func (h *ownerReferenceHandler) setOwnerRef(ctx context.Context, req admission.Request, client client.Client, decoder admission.Decoder, recorder record.EventRecorder) *admission.Response {
 	ns := &corev1.Namespace{}
 	if err := decoder.Decode(req, ns); err != nil {
 		response := admission.Errored(http.StatusBadRequest, err)
@@ -255,7 +255,7 @@ func (h *handler) setOwnerRef(ctx context.Context, req admission.Request, client
 	return &response
 }
 
-func (h *handler) patchResponseForOwnerRef(tenant *capsulev1beta2.Tenant, ns *corev1.Namespace, recorder record.EventRecorder) admission.Response {
+func (h *ownerReferenceHandler) patchResponseForOwnerRef(tenant *capsulev1beta2.Tenant, ns *corev1.Namespace, recorder record.EventRecorder) admission.Response {
 	scheme := runtime.NewScheme()
 	_ = capsulev1beta2.AddToScheme(scheme)
 	_ = corev1.AddToScheme(scheme)
@@ -281,7 +281,7 @@ func (h *handler) patchResponseForOwnerRef(tenant *capsulev1beta2.Tenant, ns *co
 	return admission.PatchResponseFromRaw(o, c)
 }
 
-func (h *handler) listTenantsForOwnerKind(ctx context.Context, ownerKind string, ownerName string, clt client.Client) (*capsulev1beta2.TenantList, error) {
+func (h *ownerReferenceHandler) listTenantsForOwnerKind(ctx context.Context, ownerKind string, ownerName string, clt client.Client) (*capsulev1beta2.TenantList, error) {
 	tntList := &capsulev1beta2.TenantList{}
 	fields := client.MatchingFields{
 		".spec.owner.ownerkind": fmt.Sprintf("%s:%s", ownerKind, ownerName),
@@ -291,7 +291,7 @@ func (h *handler) listTenantsForOwnerKind(ctx context.Context, ownerKind string,
 	return tntList, err
 }
 
-func (h *handler) validateNamespacePrefix(ns *corev1.Namespace, tenant *capsulev1beta2.Tenant) *admission.Response {
+func (h *ownerReferenceHandler) validateNamespacePrefix(ns *corev1.Namespace, tenant *capsulev1beta2.Tenant) *admission.Response {
 	// Check if ForceTenantPrefix is true
 	if tenant.Spec.ForceTenantPrefix != nil && *tenant.Spec.ForceTenantPrefix {
 		if !strings.HasPrefix(ns.GetName(), fmt.Sprintf("%s-", tenant.GetName())) {
diff --git a/pkg/webhook/namespace/errors.go b/pkg/webhook/namespace/validation/errors.go
similarity index 95%
rename from pkg/webhook/namespace/errors.go
rename to pkg/webhook/namespace/validation/errors.go
index cfaac49..1377f49 100644
--- a/pkg/webhook/namespace/errors.go
+++ b/pkg/webhook/namespace/validation/errors.go
@@ -1,7 +1,7 @@
 // Copyright 2020-2023 Project Capsule Authors.
 // SPDX-License-Identifier: Apache-2.0
 
-package namespace
+package validation
 
 type namespaceQuotaExceededError struct{}
 
diff --git a/pkg/webhook/namespace/freezed.go b/pkg/webhook/namespace/validation/freezed.go
similarity index 99%
rename from pkg/webhook/namespace/freezed.go
rename to pkg/webhook/namespace/validation/freezed.go
index 2eb0561..f01b6ab 100644
--- a/pkg/webhook/namespace/freezed.go
+++ b/pkg/webhook/namespace/validation/freezed.go
@@ -1,7 +1,7 @@
 // Copyright 2020-2023 Project Capsule Authors.
 // SPDX-License-Identifier: Apache-2.0
 
-package namespace
+package validation
 
 import (
 	"context"
diff --git a/pkg/webhook/namespace/patch.go b/pkg/webhook/namespace/validation/patch.go
similarity index 99%
rename from pkg/webhook/namespace/patch.go
rename to pkg/webhook/namespace/validation/patch.go
index 8ce3841..e071896 100644
--- a/pkg/webhook/namespace/patch.go
+++ b/pkg/webhook/namespace/validation/patch.go
@@ -1,7 +1,7 @@
 // Copyright 2020-2023 Project Capsule Authors.
 // SPDX-License-Identifier: Apache-2.0
 
-package namespace
+package validation
 
 import (
 	"context"
diff --git a/pkg/webhook/namespace/prefix.go b/pkg/webhook/namespace/validation/prefix.go
similarity index 99%
rename from pkg/webhook/namespace/prefix.go
rename to pkg/webhook/namespace/validation/prefix.go
index 31f9a3a..a1371a9 100644
--- a/pkg/webhook/namespace/prefix.go
+++ b/pkg/webhook/namespace/validation/prefix.go
@@ -1,7 +1,7 @@
 // Copyright 2020-2023 Project Capsule Authors.
 // SPDX-License-Identifier: Apache-2.0
 
-package namespace
+package validation
 
 import (
 	"context"
diff --git a/pkg/webhook/namespace/quota.go b/pkg/webhook/namespace/validation/quota.go
similarity index 99%
rename from pkg/webhook/namespace/quota.go
rename to pkg/webhook/namespace/validation/quota.go
index 9d2443e..ff2df19 100644
--- a/pkg/webhook/namespace/quota.go
+++ b/pkg/webhook/namespace/validation/quota.go
@@ -1,7 +1,7 @@
 // Copyright 2020-2023 Project Capsule Authors.
 // SPDX-License-Identifier: Apache-2.0
 
-package namespace
+package validation
 
 import (
 	"context"
diff --git a/pkg/webhook/namespace/user_metadata.go b/pkg/webhook/namespace/validation/user_metadata.go
similarity index 99%
rename from pkg/webhook/namespace/user_metadata.go
rename to pkg/webhook/namespace/validation/user_metadata.go
index bbc9708..e3d0c8f 100644
--- a/pkg/webhook/namespace/user_metadata.go
+++ b/pkg/webhook/namespace/validation/user_metadata.go
@@ -1,7 +1,7 @@
 // Copyright 2020-2023 Project Capsule Authors.
 // SPDX-License-Identifier: Apache-2.0
 
-package namespace
+package validation
 
 import (
 	"context"
diff --git a/pkg/webhook/route/customresources.go b/pkg/webhook/route/customresources.go
new file mode 100644
index 0000000..6efac49
--- /dev/null
+++ b/pkg/webhook/route/customresources.go
@@ -0,0 +1,24 @@
+// Copyright 2020-2023 Project Capsule Authors.
+// SPDX-License-Identifier: Apache-2.0
+
+package route
+
+import (
+	capsulewebhook "github.com/projectcapsule/capsule/pkg/webhook"
+)
+
+type customResourcesHandler struct {
+	handlers []capsulewebhook.Handler
+}
+
+func CustomResources(handlers ...capsulewebhook.Handler) capsulewebhook.Webhook {
+	return &customResourcesHandler{handlers: handlers}
+}
+
+func (w *customResourcesHandler) GetHandlers() []capsulewebhook.Handler {
+	return w.handlers
+}
+
+func (w *customResourcesHandler) GetPath() string {
+	return "/customresources"
+}
diff --git a/pkg/webhook/route/namespaces.go b/pkg/webhook/route/namespaces.go
index 0b7b0be..673a76a 100644
--- a/pkg/webhook/route/namespaces.go
+++ b/pkg/webhook/route/namespaces.go
@@ -22,3 +22,19 @@ func (w *namespace) GetHandlers() []capsulewebhook.Handler {
 func (w *namespace) GetPath() string {
 	return "/namespaces"
 }
+
+type namespacePatch struct {
+	handlers []capsulewebhook.Handler
+}
+
+func NamespacePatch(handlers ...capsulewebhook.Handler) capsulewebhook.Webhook {
+	return &namespacePatch{handlers: handlers}
+}
+
+func (w *namespacePatch) GetHandlers() []capsulewebhook.Handler {
+	return w.handlers
+}
+
+func (w *namespacePatch) GetPath() string {
+	return "/namespace-patch"
+}



Test patch:
diff --git a/e2e/tenant_cordoning_test.go b/e2e/tenant_cordoning_test.go
index 7553ee7..b7cc2c5 100644
--- a/e2e/tenant_cordoning_test.go
+++ b/e2e/tenant_cordoning_test.go
@@ -5,6 +5,7 @@ package e2e
 
 import (
 	"context"
+	"github.com/projectcapsule/capsule/pkg/utils"
 	"time"
 
 	. "github.com/onsi/ginkgo/v2"
@@ -40,7 +41,6 @@ var _ = Describe("cordoning a Tenant", func() {
 	JustAfterEach(func() {
 		Expect(k8sClient.Delete(context.TODO(), tnt)).Should(Succeed())
 	})
-
 	It("should block or allow operations", func() {
 		cs := ownerClient(tnt.Spec.Owners[0])
 
@@ -59,7 +59,6 @@ var _ = Describe("cordoning a Tenant", func() {
 				},
 			},
 		}
-
 		By("creating a Namespace", func() {
 			NamespaceCreation(ns, tnt.Spec.Owners[0], defaultTimeoutInterval).Should(Succeed())
 
@@ -70,6 +69,19 @@ var _ = Describe("cordoning a Tenant", func() {
 			}).Should(Succeed())
 		})
 
+		By("should contain the cordoned Capsule label", func() {
+			Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: tnt.Name}, tnt)).Should(Succeed())
+
+			tnt.Spec.Cordoned = true
+
+			Expect(k8sClient.Update(context.TODO(), tnt)).Should(Succeed())
+
+			Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: ns.GetName()}, ns)).Should(Succeed())
+
+			Expect(ns.Labels).To(HaveKey(utils.CordonedLabel))
+
+		})
+
 		By("cordoning the Tenant deletion must be blocked", func() {
 			Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: tnt.Name}, tnt)).Should(Succeed())
 
@@ -93,5 +105,18 @@ var _ = Describe("cordoning a Tenant", func() {
 
 			Expect(cs.CoreV1().Pods(ns.Name).Delete(context.Background(), pod.Name, metav1.DeleteOptions{})).Should(Succeed())
 		})
+
+		By("should not contain the cordoned Capsule label", func() {
+			Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: tnt.Name}, tnt)).Should(Succeed())
+
+			tnt.Spec.Cordoned = false
+
+			Expect(k8sClient.Update(context.TODO(), tnt)).Should(Succeed())
+
+			Expect(k8sClient.Get(context.TODO(), types.NamespacedName{Name: ns.GetName()}, ns)).Should(Succeed())
+
+			Expect(ns.Labels).ToNot(HaveKey(utils.CordonedLabel))
+
+		})
 	})
 })