Repository: elastisys/compliantkubernetes-apps
Pull request #2459: Add opa policy to restrict PDBs, always allow at least 1 disruption
Author: login: viktor-f
Labels: name: app/opa-gatekeeper
description: Open Policy Agent Gatekeeper
Created: 2025-03-11T10:53:40Z | Merged: 2025-03-25T10:42:37Z

<!-- Choose your PR title carefully as it will be used as the entry in the changelog! -->

<!-- markdownlint-disable MD041 -->
> [!warning]
> **This is a public repository, ensure not to disclose:**
>
> - [x] personal data beyond what is necessary for interacting with this pull request, nor
> - [x] business confidential information, such as customer names.

### What kind of PR is this?

**Required**: Mark one of the following that is applicable:

- [x] kind/feature       <!-- This PR adds a new feature -->
- [ ] kind/improvement   <!-- This PR changes an existing feature -->
- [ ] kind/deprecation   <!-- This PR removes an existing feature -->
- [ ] kind/documentation <!-- This PR contains documentation -->
- [ ] kind/clean-up      <!-- This PR cleans up technical debt -->
- [ ] kind/bug           <!-- This PR fixes a bug -->
- [ ] kind/other         <!-- This PR does something else -->

_Optional_: Mark one or more of the following that are applicable:

> [!important]
> Breaking changes should be marked `kind/admin-change` or `kind/dev-change` depending on type
> Critical security fixes should be marked with `kind/security`

- [x] kind/admin-change   <!-- This PR introduces an admin facing change, add "Platform Administrator notice" section -->
- [x] kind/dev-change     <!-- This PR introduces a dev facing change, add "Application Developer notice" section -->
- [ ] kind/security       <!-- This PR introduces a critical security fix, add "Security notice" section -->
- [ ] [kind/adr](set-me\) <!-- This PR implements an ADR, add the link -->

<!-- Uncomment the additional sections that applies. -->

<!-- Additional information to be added in the release notes
### Release notes
...
-->


### Platform Administrator notice

A new gatekeeper policy has been added that will deny any PodDisruptionBudget and connected Pod controller if the PodDisruptionBudget does not allow at least 1 Pod disruption. Note that this will apply in both sc and wc, it will also apply to namespaces even if they have the label owner=operator.

### Application Developer notice

A new gatekeeper policy has been added that will deny any PodDisruptionBudget and connected Pod controller if the PodDisruptionBudget does not allow at least 1 Pod disruption.

<!-- Add additional information with kind/security
### Security notice
...
-->

### What does this PR do / why do we need this PR?

This adds a new gatekeeper policy that will deny any PodDisruptionBudget and connected Pod controller if the PodDisruptionBudget does not allow at least 1 Pod disruption. Note that this will apply in both sc and wc, it will also apply to namespaces even if they have the label owner=operator.

Pod controllers only includes: Deployment, ReplicaSet, StatefulSet, ReplicaController.

The general logic for this is:
```
If creating or modifying PDB
  If maxUnavailable == 0 or "0%"
    deny request
  If minAvailable
    If any matching pod controller
      If minAvailable is >= number of replicas
        deny request
If creating or modifying pod controller
  If any matching PDB
    Use the logic above under "IF PDB"
```
In sentences this means that we want to stop either PDBs or pod controllers if they together do not allow for any disruption. If the PDB is using maxUnavailable, then this happens if it is set to 0 or 0%, regardless of the replicas in the pod controller. If the PDB is using minAvailable, then this happens if it is equal or higher than the replicas in the pod controller. The policy includes logic to stop requests that would create or edit both PDBs or pod controllers.

There is also an extra check to allow replicasets to violate this policy if they are controlled by a deployment. This is because any matching PDB will look at all of the pods for the deployment, not any single replicaset.

The logic has been based on an [upstream policy](https://open-policy-agent.github.io/gatekeeper-library/website/validation/poddisruptionbudget/), but it has almost entirely been reworked. The logic of matching selectors has been based on the logic in our [networkpolicy gatekeeper policy](https://github.com/elastisys/compliantkubernetes-apps/blob/main/helmfile.d/charts/gatekeeper/templates/policies/network-policies.rego).

I was planning on adding more checks to this. But this took longer to implement than planned, so I'm now planning on turning the rest into a separate task. The things that were not implemented are:
- Denying pods without pod controllers that are matching a PDB. This is not a pattern we want to allow.
- Denying PDBs and matching daemonsets/jobs/cronjobs if the PDB is matching a daemonsets/jobs/cronjobs. This is not a pattern we want to allow.
- Denying PDBs and pods/podcontrollers if the PDB is matching multiple pod controllers or pods without controllers. This is not something we want to allow because "The eviction API will disallow eviction of any pod covered by multiple PDBs, so most users will want to avoid overlapping selectors." ([ref](https://kubernetes.io/docs/tasks/run-application/configure-pdb/#arbitrary-controllers-and-selectors))

This PR adds more resources that will be synced (cached) by gatekeeper. This is needed so that we can compare PDBs to pod controllers (otherwise you just have access to the object that is being validated). 
But adding resources here will increase the resource usage (primarily memory) for gatekeeper. Syncing PDBs will likely not increase the usage significantly, since there are relatively few PDBs in a cluster. However pod controllers are a lot more common and will noticeably increase the memory usage, but as I show below I think that the usage has not increased so much more that it should prevent us from adding this feature. I did some testing to see how much the resource usage increased. Note that some of the testing included pods, because that would be needed for one of the extra features mentioned above that I was planning on adding but am now skipping.

> Number of resources in the cluster:
> Pods: 240
> Deployments: 55
> StatefulSets: 10
> ReplicaSets: 73
> ReplicationController: 0
> 
> Note: many small pod manifests in this test, actual usage with "real pods" is likely higher (tested in scenario 4 and 5 below)
> 
> Without pods or controllers:
>   Gatekeeper controllers 140 MB
>   Gatekeeper audit 160 MB, 215 MB spikes
> With controllers:
>   Gatekeeper controllers 160 MB
>   Gatekeeper audit 175 MB idle, 250 MB spikes
> With pods and controllers:
>   Gatekeeper controllers 180 MB
>   Gatekeeper audit 200 MB idle, 300 MB spikes
> With pods and controllers and extra annotations (to get larger pod manifests):
>   Gatekeeper controllers 190MB
>   Gatekeeper audit 210 MB idle, 320 MB spikes
> With pods and controllers and extra annotations on a larger cluster (1000 pods, 140 deployments, 160 replicasets)
>   Gatekeeper controllers 290MB idle, 370 MB spikes
>   Gatekeeper audit 310 MB idle, 520 MB spikes

<!-- Add all issues that are fixed by this PR, use "Part of" instead of "Fixes" if you want to keep issues open. -->
- Part of elastisys/compliantkubernetes-apps#2318 

Even though this is part of an autoscaling task, this policy is good to have for any type of cluster. Since PDBs can disrupt any type of scenario where we want to drain nodes or similar.

#### Information to reviewers

As of creating the PR I have not yet added tests or public documentation. I will work on that next, but the code and config should be ok to review for now.

This PR also included some whitespace fixes that I stumbled upon. I hope it is ok that is is fixed in this PR. Otherwise let me know and I will move that to a separate PR.

#### Checklist

<!-- This section is not added to the changelog or release notes, it is to help you as a contributor and reviewers. -->

- [x] Proper commit message prefix on all commits
    <!-- Example of commit message prefixes:
    - all: changes to multiple areas
    - apps: changes to applications running in all clusters
    - apps sc: changes to applications running in service clusters
    - apps wc: changes to applications running in workload clusters
    - bin: changes to management binaries
    - config: changes to configuration
    - docs: changes to documentation
    - release: release related
    - scripts: changes to scripts
    - tests: changes to tests
    --->
- Change checks:
    - [ ] The change is transparent
    - [ ] The change is disruptive
    - [ ] The change requires no migration steps
    - [ ] The change requires migration steps
    - [ ] The change updates CRDs
    - [ ] The change updates the config _and_ the schema
- Documentation checks:
    - [ ] The [public documentation](https://github.com/elastisys/welkin) required no updates
    - [ ] The [public documentation](https://github.com/elastisys/welkin) required an update - [link to change](set-me\)
- Metrics checks:
    - [ ] The metrics are still exposed and present in Grafana after the change
    - [ ] The metrics names didn't change (Grafana dashboards and Prometheus alerts required no updates)
    - [ ] The metrics names did change (Grafana dashboards and Prometheus alerts required an update)
- Logs checks:
    - [ ] The logs do not show any errors after the change
- PodSecurityPolicy checks:
    - [ ] Any changed Pod is covered by Kubernetes Pod Security Standards
    - [ ] Any changed Pod is covered by Gatekeeper Pod Security Policies
    - [ ] The change does not cause any Pods to be blocked by Pod Security Standards or Policies
- NetworkPolicy checks:
    - [ ] Any changed Pod is covered by Network Policies
    - [ ] The change does not cause any dropped packets in the NetworkPolicy Dashboard
- Audit checks:
    - [ ] The change does not cause any unnecessary Kubernetes audit events
    - [ ] The change requires changes to Kubernetes audit policy
- Falco checks:
    - [ ] The change does not cause any alerts to be generated by Falco
- Bug checks:
    - [ ] The bug fix is covered by regression tests



Diff:
diff --git a/config/common-config.yaml b/config/common-config.yaml
index c90dd0a914..f07120c1f0 100644
--- a/config/common-config.yaml
+++ b/config/common-config.yaml
@@ -930,11 +930,17 @@ opa:
   rejectLocalStorageEmptyDir:
     enabled: false
     enforcement: warn
+
   ## Enable rule that warns about pods without a controller, which can prevent cluster autoscaler from evicting when scaling down
   rejectPodWithoutController:
     enabled: false
     enforcement: warn
 
+  ## Enable rule that denies PDBs and connected pod controllers if PDB does not allow for at least 1 pod disruption
+  restrictPodDisruptionBudgets:
+    enabled: true
+    enforcement: deny
+
 trivy:
   enabled: true
   # comma separated list of namespaces (or glob patterns) to be excluded from scanning
diff --git a/config/schemas/config.yaml b/config/schemas/config.yaml
index 318002f62e..ddab27ac7a 100644
--- a/config/schemas/config.yaml
+++ b/config/schemas/config.yaml
@@ -3676,6 +3676,32 @@ properties:
               deny: Deny actions violating the constraint.
               warn: Warn actions violating the constraint.
               dryrun: Dryrun actions violating the constraint.
+      restrictPodDisruptionBudgets:
+        title: Safeguard Restrict PodDisruptionBudget
+        description: |-
+          Configure constraint to reject PodDisruptionBudgets and connected Pod controllers if the PDB does not allow for at least 1 pod disruption.
+
+          > [!note]
+          > See [the dev docs](https://elastisys.io/welkin/user-guide/safeguards/enforce-restricted-pod-disruption-budgets/) for context.
+        type: object
+        additionalProperties: false
+        properties:
+          enabled:
+            title: Safeguard Restrict PodDisruptionBudget Enabled
+            type: boolean
+            default: true
+          enforcement:
+            title: Safeguard Restrict PodDisruptionBudget Enforcement
+            type: string
+            default: deny
+            enum:
+              - deny
+              - warn
+              - dryrun
+            meta:enum:
+              deny: Deny actions violating the constraint.
+              warn: Warn actions violating the constraint.
+              dryrun: Dryrun actions violating the constraint.
       networkPolicies:
         title: Safeguard Network Policies
         description: |-
diff --git a/helmfile.d/charts/gatekeeper/constraints/templates/restrict-pod-disruption-budgets/constraint.yaml b/helmfile.d/charts/gatekeeper/constraints/templates/restrict-pod-disruption-budgets/constraint.yaml
new file mode 100644
index 0000000000..966ae20998
--- /dev/null
+++ b/helmfile.d/charts/gatekeeper/constraints/templates/restrict-pod-disruption-budgets/constraint.yaml
@@ -0,0 +1,18 @@
+{{- if .Values.restrictPodDisruptionBudgets.enable -}}
+apiVersion: constraints.gatekeeper.sh/v1alpha1
+kind: K8sRestrictPodDisruptionBudgets
+metadata:
+  name: elastisys-restrict-pod-disruption-budgets
+spec:
+  enforcementAction: {{ .Values.restrictPodDisruptionBudgets.enforcementAction }}
+  match:
+    kinds:
+      - apiGroups: [""]
+        kinds: ["ReplicationController"]
+      - apiGroups: ["apps"]
+        kinds: ["Deployment", "StatefulSet", "ReplicaSet"]
+      - apiGroups: ["policy"]
+        kinds: ["PodDisruptionBudget"]
+    excludedNamespaces: ["kube-system", "kube-public", "kube-node-lease", "calico-system"]
+    # Note that this does not exclude all namespaces with label owner=operator
+{{- end }}
diff --git a/helmfile.d/charts/gatekeeper/constraints/values.yaml b/helmfile.d/charts/gatekeeper/constraints/values.yaml
index 8dbb7d4ce3..7ef0711099 100644
--- a/helmfile.d/charts/gatekeeper/constraints/values.yaml
+++ b/helmfile.d/charts/gatekeeper/constraints/values.yaml
@@ -31,6 +31,9 @@ rejectLocalStorageEmptyDir:
 rejectPodWithoutController:
     enable: false
     enforcementAction: warn
+restrictPodDisruptionBudgets:
+    enable: true
+    enforcementAction: deny
 
 imageRegistryURL: registry.example.com
 
diff --git a/helmfile.d/charts/gatekeeper/templates/policies/reject-pod-without-controller.rego b/helmfile.d/charts/gatekeeper/templates/policies/reject-pod-without-controller.rego
index 7b77a1cc30..e00ead78ff 100644
--- a/helmfile.d/charts/gatekeeper/templates/policies/reject-pod-without-controller.rego
+++ b/helmfile.d/charts/gatekeeper/templates/policies/reject-pod-without-controller.rego
@@ -5,7 +5,7 @@ metadata := input.review.object.metadata
 # violation if pod has no ownerReferences.
 violation[{"msg": msg}] {
     input.review.object.kind == "Pod"
-	missing(metadata, "ownerReferences")
+    missing(metadata, "ownerReferences")
     not check_annotation
     msg := sprintf("The Pod <%v> does not have any ownerReferences. This can prevent autoscaler from scaling down a node where this is running. Read more about this and possible solutions at https://elastisys.io/welkin/user-guide/safeguards/enforce-no-pod-without-controller",[metadata.name])
 }
@@ -13,7 +13,7 @@ violation[{"msg": msg}] {
 # violation if ownerReferences is empty.
 violation[{"msg": msg}] {
     input.review.object.kind == "Pod"
-	metadata.ownerReferences == []
+    metadata.ownerReferences == []
     not check_annotation
     msg := sprintf("The Pod <%v> does not have any ownerReferences. This can prevent autoscaler from scaling down a node where this is running. Read more about this and possible solutions at https://elastisys.io/welkin/user-guide/safeguards/enforce-no-pod-without-controller",[metadata.name])
 }
@@ -24,5 +24,5 @@ missing(obj, field) {
 }
 
 check_annotation {
-	metadata.annotations[input.parameters.annotation] == "true"
+    metadata.annotations[input.parameters.annotation] == "true"
 }
diff --git a/helmfile.d/charts/gatekeeper/templates/policies/restrict-pod-disruption-budgets.rego b/helmfile.d/charts/gatekeeper/templates/policies/restrict-pod-disruption-budgets.rego
new file mode 100644
index 0000000000..33835cec2f
--- /dev/null
+++ b/helmfile.d/charts/gatekeeper/templates/policies/restrict-pod-disruption-budgets.rego
@@ -0,0 +1,180 @@
+package k8srestrictpoddisruptionbudgets
+
+# Reject PDB if maxUnavailable does not allow at least 1 pod disruption
+violation[{"msg": msg}] {
+    input.review.object.kind == "PodDisruptionBudget"
+    pdb := input.review.object
+
+    pdb.spec.maxUnavailable
+
+    not_valid_pdb_max_unavailable(pdb)
+    msg := sprintf(
+    "PodDisruptionBudget rejected: PodDisruptionBudget <%v> has maxUnavailable of %v, only positive integers or percentages are allowed for maxUnavailable. Read more about this and possible solutions at https://elastisys.io/welkin/user-guide/safeguards/enforce-restricted-pod-disruption-budgets/",
+    [pdb.metadata.name, pdb.spec.maxUnavailable],
+    )
+}
+
+# Reject PDB if minAvailable does not allow at least 1 pod disruption
+violation[{"msg": msg}] {
+    input.review.object.kind == "PodDisruptionBudget"
+    pdb := input.review.object
+
+    pdb.spec.minAvailable
+
+    pod_controller_group_kind := pod_controller_groups_kinds[_]
+    objs := [controllers | controllers := data.inventory.namespace[pdb.metadata.namespace][pod_controller_group_kind.group][pod_controller_group_kind.kind]]
+    obj := objs[_][_]
+
+    not mismatched_selector(pdb, obj)
+
+    not_valid_pdb_min_available(obj, pdb)
+    not replica_set_under_deployment(obj)
+
+    msg := sprintf(
+    "PodDisruptionBudget rejected: %v <%v> has %v replica(s) but PodDisruptionBudget <%v> has minAvailable of %v, minAvailable should always be lower than replica(s), and not used when replica(s) is set to 1. Read more about this and possible solutions at https://elastisys.io/welkin/user-guide/safeguards/enforce-restricted-pod-disruption-budgets/",
+    [obj.kind, obj.metadata.name, obj.spec.replicas, pdb.metadata.name, pdb.spec.minAvailable],
+    )
+}
+
+# Reject pod controller if connected PDBs maxUnavailable does not allow at least 1 pod disruption
+violation[{"msg": msg}] {
+    input.review.object.kind == pod_controller_groups_kinds[_].kind
+    obj := input.review.object
+    not replica_set_under_deployment(obj)
+
+    pdb := data.inventory.namespace[obj.metadata.namespace]["policy/v1"].PodDisruptionBudget[_]
+
+    pdb.spec.maxUnavailable
+
+    not mismatched_selector(pdb, obj)
+
+    not_valid_pdb_max_unavailable(pdb)
+    msg := sprintf(
+    "%v rejected: %v <%v> has been selected by PodDisruptionBudget <%v> but has maxUnavailable of %v, only positive integers or percentages are allowed for maxUnavailable. Read more about this and possible solutions at https://elastisys.io/welkin/user-guide/safeguards/enforce-restricted-pod-disruption-budgets/",
+    [obj.kind, obj.kind, obj.metadata.name, pdb.metadata.name, pdb.spec.maxUnavailable],
+    )
+}
+
+# Reject pod controller if connected PDBs minAvailable does not allow at least 1 pod disruption
+violation[{"msg": msg}] {
+    input.review.object.kind == pod_controller_groups_kinds[_].kind
+    obj := input.review.object
+    not replica_set_under_deployment(obj)
+
+    pdb := data.inventory.namespace[obj.metadata.namespace]["policy/v1"].PodDisruptionBudget[_]
+
+    pdb.spec.minAvailable
+
+    not mismatched_selector(pdb, obj)
+
+    not_valid_pdb_min_available(obj, pdb)
+    msg := sprintf(
+    "%v rejected: %v <%v> has %v replica(s) but PodDisruptionBudget <%v> has minAvailable of %v, minAvailable should always be lower than replica(s), and not used when replica(s) is set to 1. Read more about this and possible solutions at https://elastisys.io/welkin/user-guide/safeguards/enforce-restricted-pod-disruption-budgets/",
+    [obj.kind, obj.kind, obj.metadata.name, obj.spec.replicas, pdb.metadata.name, pdb.spec.minAvailable],
+    )
+}
+
+# The type of pod controller to validate
+pod_controller_groups_kinds := [
+    {"group": "apps/v1", "kind": "Deployment"},
+    {"group": "apps/v1", "kind": "StatefulSet"},
+    {"group": "apps/v1", "kind": "ReplicaSet"},
+    {"group": "v1", "kind": "ReplicationController"}
+]
+
+# Do not reject replicasets that are controlled by deployment, instead reject the deployment
+replica_set_under_deployment(obj) {
+    obj.kind == "ReplicaSet"
+    count([i | obj.metadata.ownerReferences[i].kind == "Deployment"]) > 0
+}
+
+# Check minAvailable if it is integer
+not_valid_pdb_min_available(obj, pdb) {
+    not regex.match("^[0-9]+%$", pdb.spec.minAvailable)
+    obj.spec.replicas <= pdb.spec.minAvailable
+}
+
+# Check minAvailable if it is percentage
+not_valid_pdb_min_available(obj, pdb) {
+    replicas := obj.spec.replicas
+    regex.match("^[0-9]+%$", pdb.spec.minAvailable)
+    percentage_num := to_number(replace(pdb.spec.minAvailable, "%", ""))
+    min_available := ceil((percentage_num/100)*replicas)
+
+    replicas <= min_available
+}
+
+not_valid_pdb_max_unavailable(pdb) {
+    pdb.spec.maxUnavailable == 0
+}
+
+not_valid_pdb_max_unavailable(pdb) {
+    pdb.spec.maxUnavailable == "0%"
+}
+
+# Check one podDisruptionBudget and pod(controller), returns true if it does not match
+mismatched_selector(pdb, obj) = res {
+    r1 := matchLabelsMissingKeys(pdb, obj)
+    r2 := any(matchLabelsValues(pdb, obj))
+    r3 := match_expressions_exists(pdb, obj)
+    r4 := match_expressions_does_not_exist(pdb, obj)
+    r5 := any(match_expressions_in(pdb, obj))
+    r6 := any(match_expressions_not_in(pdb, obj))
+    # Return true if any part of the podDisruptionBudget and pod(controller) does not match
+    res := any({r1, r2, r3, r4, r5, r6})
+}
+
+matchLabelsMissingKeys(pdb, obj) = res {
+    res3 := {key | pdb.spec.selector.matchLabels[key]}
+    res4 := {key | get_labels(obj)[key]}
+    res := count(res3 - res4) != 0
+}
+
+matchLabelsValues(pdb, obj) = res {
+    res := [x |
+        get_labels(obj)[key1] != pdb.spec.selector.matchLabels[key3];
+        x := key1 == key3]
+}
+
+match_expressions_exists(pdb, obj) = res {
+    keys := { key |
+        pdb.spec.selector.matchExpressions[i].operator == "Exists"
+        key := pdb.spec.selector.matchExpressions[i].key}
+    inputKeys := {key | get_labels(obj)[key]}
+    res := count(keys - inputKeys) != 0
+}
+
+match_expressions_does_not_exist(pdb, obj) = res {
+    keys := { key |
+        pdb.spec.selector.matchExpressions[i].operator == "DoesNotExist"
+        key := pdb.spec.selector.matchExpressions[i].key}
+    inputKeys := {key | get_labels(obj)[key]}
+    res := count(keys & inputKeys) != 0
+}
+
+match_expressions_in(pdb, obj) = res {
+    res := [ x |
+        pdb.spec.selector.matchExpressions[i].operator == "In"
+        key := pdb.spec.selector.matchExpressions[i].key
+        x := false == any([y | y := get_labels(obj)[key] == pdb.spec.selector.matchExpressions[i].values[_]])]
+}
+
+match_expressions_not_in(pdb, obj) = res {
+    res := [ x |
+        pdb.spec.selector.matchExpressions[i].operator == "NotIn"
+        key := pdb.spec.selector.matchExpressions[i].key
+        x := any([y | y := get_labels(obj)[key] == pdb.spec.selector.matchExpressions[i].values[_]])]
+}
+
+# Get labels for resources that use pod templates.
+get_labels(obj) = res {
+    kinds := [
+        "Deployment",
+        "StatefulSet",
+        "ReplicaSet",
+        "ReplicationController"
+    ]
+    obj.kind == kinds[_]
+
+    res := obj.spec.template.metadata.labels
+}
diff --git a/helmfile.d/charts/gatekeeper/templates/policies/tests/reject-pod-without-controller.rego b/helmfile.d/charts/gatekeeper/templates/policies/tests/reject-pod-without-controller.rego
index 6d95e6f9cb..ca2719416d 100644
--- a/helmfile.d/charts/gatekeeper/templates/policies/tests/reject-pod-without-controller.rego
+++ b/helmfile.d/charts/gatekeeper/templates/policies/tests/reject-pod-without-controller.rego
@@ -7,17 +7,17 @@ import data.k8srejectpodwithoutcontroller
 #
 
 generate_pod(annotation, metadata) = obj {
-	obj := {
-		"parameters": {
-			"annotation": annotation
-		},
-		"review": {
-			"object": {
-				"kind": "Pod",
-				"metadata": metadata
-			}
-		}
-	}
+    obj := {
+        "parameters": {
+            "annotation": annotation
+        },
+        "review": {
+            "object": {
+                "kind": "Pod",
+                "metadata": metadata
+            }
+        }
+    }
 }
 
 #
@@ -27,73 +27,73 @@ generate_pod(annotation, metadata) = obj {
 test_with_owner_reference {
     count(k8srejectpodwithoutcontroller.violation) == 0 with input as generate_pod(
         "cluster-autoscaler.kubernetes.io/safe-to-evict",
-		{
-			"name": "pod-name",
-			"ownerReferences": [
-				{
-					"apiVersion": "apps/v1",
-					"blockOwnerDeletion": "true",
-					"controller": "true",
-					"kind": "ReplicaSet",
-					"name": "replicaset-name",
-					"uid": "uid"
-				}
-			]
-		}
+        {
+            "name": "pod-name",
+            "ownerReferences": [
+                {
+                    "apiVersion": "apps/v1",
+                    "blockOwnerDeletion": "true",
+                    "controller": "true",
+                    "kind": "ReplicaSet",
+                    "name": "replicaset-name",
+                    "uid": "uid"
+                }
+            ]
+        }
     )
 }
 
 test_without_owner_reference {
     count(k8srejectpodwithoutcontroller.violation) == 1 with input as generate_pod(
         "cluster-autoscaler.kubernetes.io/safe-to-evict",
-		{
-			"name": "pod-name"
-		}
+        {
+            "name": "pod-name"
+        }
     )
 }
 
 test_with_empty_owner_reference {
     count(k8srejectpodwithoutcontroller.violation) == 1 with input as generate_pod(
         "cluster-autoscaler.kubernetes.io/safe-to-evict",
-		{
-			"name": "pod-name",
-			"ownerReferences": []
-		}
+        {
+            "name": "pod-name",
+            "ownerReferences": []
+        }
     )
 }
 
 test_with_annotation {
     count(k8srejectpodwithoutcontroller.violation) == 0 with input as generate_pod(
         "cluster-autoscaler.kubernetes.io/safe-to-evict",
-		{
-			"name": "pod-name",
-			"annotations": {
-				"cluster-autoscaler.kubernetes.io/safe-to-evict": "true"
-			}
-		}
+        {
+            "name": "pod-name",
+            "annotations": {
+                "cluster-autoscaler.kubernetes.io/safe-to-evict": "true"
+            }
+        }
     )
 }
 
 test_with_wrong_annotation_key {
     count(k8srejectpodwithoutcontroller.violation) == 1 with input as generate_pod(
         "cluster-autoscaler.kubernetes.io/safe-to-evict",
-		{
-			"name": "pod-name",
-			"annotations": {
-				"wrong-annotation": "true"
-			}
-		}
+        {
+            "name": "pod-name",
+            "annotations": {
+                "wrong-annotation": "true"
+            }
+        }
     )
 }
 
 test_with_wrong_annotation_value {
     count(k8srejectpodwithoutcontroller.violation) == 1 with input as generate_pod(
         "cluster-autoscaler.kubernetes.io/safe-to-evict",
-		{
-			"name": "pod-name",
-			"annotations": {
-				"cluster-autoscaler.kubernetes.io/safe-to-evict": "wrong"
-			}
-		}
+        {
+            "name": "pod-name",
+            "annotations": {
+                "cluster-autoscaler.kubernetes.io/safe-to-evict": "wrong"
+            }
+        }
     )
 }
diff --git a/helmfile.d/charts/gatekeeper/templates/policies/tests/restrict-pod-disruption-budgets.rego b/helmfile.d/charts/gatekeeper/templates/policies/tests/restrict-pod-disruption-budgets.rego
new file mode 100644
index 0000000000..3cf787c2fb
--- /dev/null
+++ b/helmfile.d/charts/gatekeeper/templates/policies/tests/restrict-pod-disruption-budgets.rego
@@ -0,0 +1,748 @@
+package test.k8srestrictpoddisruptionbudgets
+
+import data.k8srestrictpoddisruptionbudgets
+
+#
+# Help functions
+#
+
+generate_pdb(type, limit, namespace, selector) = obj {
+    obj := {
+        "kind": "PodDisruptionBudget",
+        "metadata": {
+            "name": "pdb-name",
+            "namespace": namespace
+        },
+        "spec": {
+            type: limit,
+            "selector": selector
+        }
+    }
+}
+
+generate_pod_controller(kind, replicas, namespace, labels) = obj {
+    allowed_kinds := [
+        "Deployment",
+        "StatefulSet",
+        "ReplicaSet",
+        "ReplicationController"
+    ]
+    kind == allowed_kinds[_]
+    obj := {
+        "kind": kind,
+        "metadata": {
+            "namespace": namespace,
+            "name": "controller-name"
+        },
+        "spec": {
+            "replicas": replicas,
+            "template": {
+                "metadata": {
+                    "labels": labels
+                }
+            }
+        }
+    }
+}
+
+generate_replicaset_under_deployment(replicas, namespace, labels) = obj {
+    obj := {
+        "kind": "ReplicaSet",
+        "metadata": {
+            "namespace": namespace,
+            "name": "controller-name",
+            "ownerReferences": [
+                {
+                    "kind": "Deployment"
+                }
+            ]
+        },
+        "spec": {
+            "replicas": replicas,
+            "template": {
+                "metadata": {
+                    "labels": labels
+                }
+            }
+        }
+    }
+}
+
+input_wrap(obj) = input {
+    input := {"review": {"object": obj}}
+}
+
+pod_controller_groups_kinds := [
+    {"group": "apps/v1", "kind": "Deployment"},
+    {"group": "apps/v1", "kind": "StatefulSet"},
+    {"group": "apps/v1", "kind": "ReplicaSet"},
+    {"group": "v1", "kind": "ReplicationController"}
+]
+
+#
+# Tests
+#
+
+# --- Test PDB as review object ---
+
+test_ok_pdb_no_pod_maxUnavailable_int {
+    count(k8srestrictpoddisruptionbudgets.violation) == 0 with input as input_wrap(generate_pdb(
+        "maxUnavailable",
+        1,
+        "default",
+        {"matchLabels": {"key": "val"}}
+    ))
+}
+
+test_ok_pdb_no_pod_maxUnavailable_percent {
+    count(k8srestrictpoddisruptionbudgets.violation) == 0 with input as input_wrap(generate_pdb(
+        "maxUnavailable",
+        "10%",
+        "default",
+        {"matchLabels": {"key": "val"}}
+    ))
+}
+
+
+test_bad_pdb_no_pod_maxUnavailable_int {
+    count(k8srestrictpoddisruptionbudgets.violation) == 1 with input as input_wrap(generate_pdb(
+        "maxUnavailable",
+        0,
+        "default",
+        {"matchLabels": {"key": "val"}}
+    ))
+}
+
+test_bad_pdb_no_pod_maxUnavailable_percent {
+    count(k8srestrictpoddisruptionbudgets.violation) == 1 with input as input_wrap(generate_pdb(
+        "maxUnavailable",
+        "0%",
+        "default",
+        {"matchLabels": {"key": "val"}}
+    ))
+}
+
+test_ok_pdb_no_pod_minAvailable_int {
+    count(k8srestrictpoddisruptionbudgets.violation) == 0 with input as input_wrap(generate_pdb(
+        "minAvailable",
+        0,
+        "default",
+        {"matchLabels": {"key": "val"}}
+    ))
+}
+
+test_ok_pdb_no_pod_minAvailable_percent {
+    count(k8srestrictpoddisruptionbudgets.violation) == 0 with input as input_wrap(generate_pdb(
+        "minAvailable",
+        "0%",
+        "default",
+        {"matchLabels": {"key": "val"}}
+    ))
+}
+
+test_ok_pdb_with_pod_minAvailable_int {
+    namespace := "default"
+    selector := {"matchLabels": {"key": "val"}}
+    labels := {"key": "val"}
+    inventories := [inventory | inventory := {"namespace": {namespace: {pod_controller_groups_kinds[i].group: {pod_controller_groups_kinds[i].kind: [generate_pod_controller(
+        pod_controller_groups_kinds[i].kind,
+        3,
+        namespace,
+        labels
+    )]}}}}]
+
+    res = [test | test = count(k8srestrictpoddisruptionbudgets.violation) == 0
+        with input as input_wrap(generate_pdb(
+            "minAvailable",
+            1,
+            namespace,
+            selector
+        )) with data.inventory as inventories[_]
+    ]
+    all(res)
+    count(res) == 4
+}
+
+test_ok_pdb_with_pod_minAvailable_percent {
+    namespace := "default"
+    selector := {"matchLabels": {"key": "val"}}
+    labels := {"key": "val"}
+    inventories := [inventory | inventory := {"namespace": {namespace: {pod_controller_groups_kinds[i].group: {pod_controller_groups_kinds[i].kind: [generate_pod_controller(
+        pod_controller_groups_kinds[i].kind,
+        3,
+        namespace,
+        labels
+    )]}}}}]
+
+    res = [test | test = count(k8srestrictpoddisruptionbudgets.violation) == 0
+        with input as input_wrap(generate_pdb(
+            "minAvailable",
+            "10%",
+            namespace,
+            selector
+        )) with data.inventory as inventories[_]
+    ]
+    all(res)
+    count(res) == 4
+}
+
+test_bad_pdb_with_pod_minAvailable_int {
+    namespace := "default"
+    selector := {"matchLabels": {"key": "val"}}
+    labels := {"key": "val"}
+    inventories := [inventory | inventory := {"namespace": {namespace: {pod_controller_groups_kinds[i].group: {pod_controller_groups_kinds[i].kind: [generate_pod_controller(
+        pod_controller_groups_kinds[i].kind,
+        3,
+        namespace,
+        labels
+    )]}}}}]
+
+    res = [test | test = count(k8srestrictpoddisruptionbudgets.violation) == 1
+        with input as input_wrap(generate_pdb(
+            "minAvailable",
+            3,
+            namespace,
+            selector
+        )) with data.inventory as inventories[_]
+    ]
+    all(res)
+    count(res) == 4
+}
+
+test_bad_pdb_with_pod_minAvailable_percent {
+    namespace := "default"
+    selector := {"matchLabels": {"key": "val"}}
+    labels := {"key": "val"}
+    inventories := [inventory | inventory := {"namespace": {namespace: {pod_controller_groups_kinds[i].group: {pod_controller_groups_kinds[i].kind: [generate_pod_controller(
+        pod_controller_groups_kinds[i].kind,
+        3,
+        namespace,
+        labels
+    )]}}}}]
+
+    res = [test | test = count(k8srestrictpoddisruptionbudgets.violation) == 1
+        with input as input_wrap(generate_pdb(
+            "minAvailable",
+            "90%",
+            namespace,
+            selector
+        )) with data.inventory as inventories[_]
+    ]
+    all(res)
+    count(res) == 4
+}
+
+# --- test pod controller as review object ---
+
+test_ok_pod_controller_maxUnavailable_int {
+    namespace := "default"
+    selector := {"matchLabels": {"key": "val"}}
+    labels := {"key": "val"}
+    pod_controllers := [pod_controller | pod_controller = input_wrap(generate_pod_controller(
+        pod_controller_groups_kinds[_].kind,
+        3,
+        namespace,
+        labels
+    ))]
+
+    res = [test | test = count(k8srestrictpoddisruptionbudgets.violation) == 0
+        with input as pod_controllers[_]
+        with data.inventory as {"namespace": {namespace: {"policy/v1": {"PodDisruptionBudget": [generate_pdb(
+            "maxUnavailable",
+            1,
+            namespace,
+            selector
+        )]}}}}
+    ]
+    all(res)
+    count(res) == 4
+}
+
+test_ok_pod_controller_maxUnavailable_percent {
+    namespace := "default"
+    selector := {"matchLabels": {"key": "val"}}
+    labels := {"key": "val"}
+    pod_controllers := [pod_controller | pod_controller = input_wrap(generate_pod_controller(
+        pod_controller_groups_kinds[_].kind,
+        3,
+        namespace,
+        labels
+    ))]
+
+    res = [test | test = count(k8srestrictpoddisruptionbudgets.violation) == 0
+        with input as pod_controllers[_]
+        with data.inventory as {"namespace": {namespace: {"policy/v1": {"PodDisruptionBudget": [generate_pdb(
+            "maxUnavailable",
+            "20%",
+            namespace,
+            selector
+        )]}}}}
+    ]
+    all(res)
+    count(res) == 4
+}
+
+test_bad_pod_controller_maxUnavailable_int {
+    namespace := "default"
+    selector := {"matchLabels": {"key": "val"}}
+    labels := {"key": "val"}
+    pod_controllers := [pod_controller | pod_controller = input_wrap(generate_pod_controller(
+        pod_controller_groups_kinds[_].kind,
+        3,
+        namespace,
+        labels
+    ))]
+
+    res = [test | test = count(k8srestrictpoddisruptionbudgets.violation) == 1
+        with input as pod_controllers[_]
+        with data.inventory as {"namespace": {namespace: {"policy/v1": {"PodDisruptionBudget": [generate_pdb(
+            "maxUnavailable",
+            0,
+            namespace,
+            selector
+        )]}}}}
+    ]
+    all(res)
+    count(res) == 4
+}
+
+test_bad_pod_controller_maxUnavailable_percent {
+    namespace := "default"
+    selector := {"matchLabels": {"key": "val"}}
+    labels := {"key": "val"}
+    pod_controllers := [pod_controller | pod_controller = input_wrap(generate_pod_controller(
+        pod_controller_groups_kinds[_].kind,
+        3,
+        namespace,
+        labels
+    ))]
+
+    res = [test | test = count(k8srestrictpoddisruptionbudgets.violation) == 1
+        with input as pod_controllers[_]
+        with data.inventory as {"namespace": {namespace: {"policy/v1": {"PodDisruptionBudget": [generate_pdb(
+            "maxUnavailable",
+            "0%",
+            namespace,
+            selector
+        )]}}}}
+    ]
+    all(res)
+    count(res) == 4
+}
+
+test_ok_pod_controller_minAvailable_int {
+    namespace := "default"
+    selector := {"matchLabels": {"key": "val"}}
+    labels := {"key": "val"}
+    pod_controllers := [pod_controller | pod_controller = input_wrap(generate_pod_controller(
+        pod_controller_groups_kinds[_].kind,
+        3,
+        namespace,
+        labels
+    ))]
+
+    res = [test | test = count(k8srestrictpoddisruptionbudgets.violation) == 0
+        with input as pod_controllers[_]
+        with data.inventory as {"namespace": {namespace: {"policy/v1": {"PodDisruptionBudget": [generate_pdb(
+            "minAvailable",
+            1,
+            namespace,
+            selector
+        )]}}}}
+    ]
+    all(res)
+    count(res) == 4
+}
+
+test_ok_pod_controller_minAvailable_percent {
+    namespace := "default"
+    selector := {"matchLabels": {"key": "val"}}
+    labels := {"key": "val"}
+    pod_controllers := [pod_controller | pod_controller = input_wrap(generate_pod_controller(
+        pod_controller_groups_kinds[_].kind,
+        3,
+        namespace,
+        labels
+    ))]
+
+    res = [test | test = count(k8srestrictpoddisruptionbudgets.violation) == 0
+        with input as pod_controllers[_]
+        with data.inventory as {"namespace": {namespace: {"policy/v1": {"PodDisruptionBudget": [generate_pdb(
+            "minAvailable",
+            "20%",
+            namespace,
+            selector
+        )]}}}}
+    ]
+    all(res)
+    count(res) == 4
+}
+
+test_bad_pod_controller_minAvailable_int {
+    namespace := "default"
+    selector := {"matchLabels": {"key": "val"}}
+    labels := {"key": "val"}
+    pod_controllers := [pod_controller | pod_controller = input_wrap(generate_pod_controller(
+        pod_controller_groups_kinds[_].kind,
+        3,
+        namespace,
+        labels
+    ))]
+
+    res = [test | test = count(k8srestrictpoddisruptionbudgets.violation) == 1
+        with input as pod_controllers[_]
+        with data.inventory as {"namespace": {namespace: {"policy/v1": {"PodDisruptionBudget": [generate_pdb(
+            "minAvailable",
+            5,
+            namespace,
+            selector
+        )]}}}}
+    ]
+    all(res)
+    count(res) == 4
+}
+
+test_bad_pod_controller_minAvailable_percent {
+    namespace := "default"
+    selector := {"matchLabels": {"key": "val"}}
+    labels := {"key": "val"}
+    pod_controllers := [pod_controller | pod_controller = input_wrap(generate_pod_controller(
+        pod_controller_groups_kinds[_].kind,
+        3,
+        namespace,
+        labels
+    ))]
+
+    res = [test | test = count(k8srestrictpoddisruptionbudgets.violation) == 1
+        with input as pod_controllers[_]
+        with data.inventory as {"namespace": {namespace: {"policy/v1": {"PodDisruptionBudget": [generate_pdb(
+            "minAvailable",
+            "90%",
+            namespace,
+            selector
+        )]}}}}
+    ]
+    all(res)
+    count(res) == 4
+}
+
+# --- Test replicaset under deployment as review object ---
+
+test_bad_replicaset_under_deployment_maxUnavailable_int {
+    namespace := "default"
+    selector := {"matchLabels": {"key": "val"}}
+    labels := {"key": "val"}
+
+    # The replicaset and PDB does not allow for disruption, but this should not violate since replicaset is under control of a deployment
+    count(k8srestrictpoddisruptionbudgets.violation) == 0
+        with input as input_wrap(generate_replicaset_under_deployment(
+            3,
+            namespace,
+            labels
+        ))
+        with data.inventory as {"namespace": {namespace: {"policy/v1": {"PodDisruptionBudget": [generate_pdb(
+            "maxUnavailable",
+            0,
+            namespace,
+            selector
+    )]}}}}
+}
+
+test_bad_replicaset_under_deployment_minAvailable_int {
+    namespace := "default"
+    selector := {"matchLabels": {"key": "val"}}
+    labels := {"key": "val"}
+
+    # The replicaset and PDB does not allow for disruption, but this should not violate since replicaset is under control of a deployment
+    count(k8srestrictpoddisruptionbudgets.violation) == 0
+        with input as input_wrap(generate_replicaset_under_deployment(
+            3,
+            namespace,
+            labels
+        ))
+        with data.inventory as {"namespace": {namespace: {"policy/v1": {"PodDisruptionBudget": [generate_pdb(
+            "minAvailable",
+            3,
+            namespace,
+            selector
+    )]}}}}
+}
+
+# --- Test different selectors ---
+
+test_matching_match_labels {
+    namespace := "default"
+    selector := {"matchLabels": {"key": "val"}}
+    labels := {"key": "val"}
+
+    count(k8srestrictpoddisruptionbudgets.violation) == 1
+        with input as input_wrap(generate_pod_controller(
+            "Deployment",
+            3,
+            namespace,
+            labels
+        ))
+        with data.inventory as {"namespace": {namespace: {"policy/v1": {"PodDisruptionBudget": [generate_pdb(
+            "minAvailable",
+            3,
+            namespace,
+            selector
+    )]}}}}
+}
+
+test_matching_match_labels {
+    namespace := "default"
+    selector := {"matchLabels": {"key": "val", "key2": "val2"}}
+    labels := {"key": "val", "key2": "val2"}
+
+    count(k8srestrictpoddisruptionbudgets.violation) == 1
+        with input as input_wrap(generate_pod_controller(
+            "Deployment",
+            3,
+            namespace,
+            labels
+        ))
+        with data.inventory as {"namespace": {namespace: {"policy/v1": {"PodDisruptionBudget": [generate_pdb(
+            "minAvailable",
+            3,
+            namespace,
+            selector
+    )]}}}}
+}
+
+test_mismatching_match_labels_wrong_key {
+    namespace := "default"
+    selector := {"matchLabels": {"key": "val", "wrong_key": "val2"}}
+    labels := {"key": "val", "key2": "val2"}
+
+    count(k8srestrictpoddisruptionbudgets.violation) == 0
+        with input as input_wrap(generate_pod_controller(
+            "Deployment",
+            3,
+            namespace,
+            labels
+        ))
+        with data.inventory as {"namespace": {namespace: {"policy/v1": {"PodDisruptionBudget": [generate_pdb(
+            "minAvailable",
+            3,
+            namespace,
+            selector
+    )]}}}}
+}
+
+test_mismatching_match_labels_wrong_val {
+    namespace := "default"
+    selector := {"matchLabels": {"key": "val", "key2": "wrong_val"}}
+    labels := {"key": "val", "key2": "val2"}
+
+    count(k8srestrictpoddisruptionbudgets.violation) == 0
+        with input as input_wrap(generate_pod_controller(
+            "Deployment",
+            3,
+            namespace,
+            labels
+        ))
+        with data.inventory as {"namespace": {namespace: {"policy/v1": {"PodDisruptionBudget": [generate_pdb(
+            "minAvailable",
+            3,
+            namespace,
+            selector
+    )]}}}}
+}
+
+test_matching_match_expression_exists {
+    namespace := "default"
+    selector := {"matchExpression": [{"key": "key", "operator": "Exists"}]}
+    labels := {"key": "val", "key2": "val2"}
+
+    count(k8srestrictpoddisruptionbudgets.violation) == 1
+        with input as input_wrap(generate_pod_controller(
+            "Deployment",
+            3,
+            namespace,
+            labels
+        ))
+        with data.inventory as {"namespace": {namespace: {"policy/v1": {"PodDisruptionBudget": [generate_pdb(
+            "minAvailable",
+            3,
+            namespace,
+            selector
+    )]}}}}
+}
+
+test_mismatching_match_expression_exists {
+    namespace := "default"
+    selector := {"matchExpressions": [{"key": "wrong_key", "operator": "Exists"}]}
+    labels := {"key": "val", "key2": "val2"}
+
+    count(k8srestrictpoddisruptionbudgets.violation) == 0
+        with input as input_wrap(generate_pod_controller(
+            "Deployment",
+            3,
+            namespace,
+            labels
+        ))
+        with data.inventory as {"namespace": {namespace: {"policy/v1": {"PodDisruptionBudget": [generate_pdb(
+            "minAvailable",
+            3,
+            namespace,
+            selector
+    )]}}}}
+}
+
+test_matching_match_expression_does_not_exist {
+    namespace := "default"
+    selector := {"matchExpression": [{"key": "other_key", "operator": "DoesNotExist"}]}
+    labels := {"key": "val", "key2": "val2"}
+
+    count(k8srestrictpoddisruptionbudgets.violation) == 1
+        with input as input_wrap(generate_pod_controller(
+            "Deployment",
+            3,
+            namespace,
+            labels
+        ))
+        with data.inventory as {"namespace": {namespace: {"policy/v1": {"PodDisruptionBudget": [generate_pdb(
+            "minAvailable",
+            3,
+            namespace,
+            selector
+    )]}}}}
+}
+
+test_mismatching_match_expression_does_not_exist {
+    namespace := "default"
+    selector := {"matchExpressions": [{"key": "key", "operator": "DoesNotExist"}]}
+    labels := {"key": "val", "key2": "val2"}
+
+    count(k8srestrictpoddisruptionbudgets.violation) == 0
+        with input as input_wrap(generate_pod_controller(
+            "Deployment",
+            3,
+            namespace,
+            labels
+        ))
+        with data.inventory as {"namespace": {namespace: {"policy/v1": {"PodDisruptionBudget": [generate_pdb(
+            "minAvailable",
+            3,
+            namespace,
+            selector
+    )]}}}}
+}
+
+test_matching_match_expression_in {
+    namespace := "default"
+    selector := {"matchExpressions": [{"key": "key", "operator": "In", "values": ["val"]}]}
+    labels := {"key": "val", "key2": "val2"}
+    count(k8srestrictpoddisruptionbudgets.violation) == 1
+        with input as input_wrap(generate_pod_controller(
+            "Deployment",
+            3,
+            namespace,
+            labels
+        ))
+        with data.inventory as {"namespace": {namespace: {"policy/v1": {"PodDisruptionBudget": [generate_pdb(
+            "minAvailable",
+            3,
+            namespace,
+            selector
+    )]}}}}
+}
+
+test_mismatching_match_expression_in_wrong_key {
+    namespace := "default"
+    selector := {"matchExpressions": [{"key": "wrong_key", "operator": "In", "values": ["val"]}]}
+    labels := {"key": "val", "key2": "val2"}
+
+    count(k8srestrictpoddisruptionbudgets.violation) == 0
+        with input as input_wrap(generate_pod_controller(
+            "Deployment",
+            3,
+            namespace,
+            labels
+        ))
+        with data.inventory as {"namespace": {namespace: {"policy/v1": {"PodDisruptionBudget": [generate_pdb(
+            "minAvailable",
+            3,
+            namespace,
+            selector
+    )]}}}}
+}
+
+test_mismatching_match_expression_in_wrong_val {
+    namespace := "default"
+    selector := {"matchExpressions": [{"key": "wrong_key", "operator": "In", "values": ["wrong_val"]}]}
+    labels := {"key": "val", "key2": "val2"}
+
+    count(k8srestrictpoddisruptionbudgets.violation) == 0
+        with input as input_wrap(generate_pod_controller(
+            "Deployment",
+            3,
+            namespace,
+            labels
+        ))
+        with data.inventory as {"namespace": {namespace: {"policy/v1": {"PodDisruptionBudget": [generate_pdb(
+            "minAvailable",
+            3,
+            namespace,
+            selector
+    )]}}}}
+}
+
+test_matching_match_expression_not_in_other_key {
+    namespace := "default"
+    selector := {"matchExpressions": [{"key": "other_key", "operator": "NotIn", "values": ["val"]}]}
+    labels := {"key": "val", "key2": "val2"}
+    count(k8srestrictpoddisruptionbudgets.violation) == 1
+        with input as input_wrap(generate_pod_controller(
+            "Deployment",
+            3,
+            namespace,
+            labels
+        ))
+        with data.inventory as {"namespace": {namespace: {"policy/v1": {"PodDisruptionBudget": [generate_pdb(
+            "minAvailable",
+            3,
+            namespace,
+            selector
+    )]}}}}
+}
+
+test_matching_match_expression_not_in_other_val {
+    namespace := "default"
+    selector := {"matchExpressions": [{"key": "key", "operator": "NotIn", "values": ["other_val"]}]}
+    labels := {"key": "val", "key2": "val2"}
+    count(k8srestrictpoddisruptionbudgets.violation) == 1
+        with input as input_wrap(generate_pod_controller(
+            "Deployment",
+            3,
+            namespace,
+            labels
+        ))
+        with data.inventory as {"namespace": {namespace: {"policy/v1": {"PodDisruptionBudget": [generate_pdb(
+            "minAvailable",
+            3,
+            namespace,
+            selector
+    )]}}}}
+}
+
+test_mismatching_match_expression_not_in {
+    namespace := "default"
+    selector := {"matchExpressions": [{"key": "key", "operator": "NotIn", "values": ["val"]}]}
+    labels := {"key": "val", "key2": "val2"}
+
+    count(k8srestrictpoddisruptionbudgets.violation) == 0
+        with input as input_wrap(generate_pod_controller(
+            "Deployment",
+            3,
+            namespace,
+            labels
+        ))
+        with data.inventory as {"namespace": {namespace: {"policy/v1": {"PodDisruptionBudget": [generate_pdb(
+            "minAvailable",
+            3,
+            namespace,
+            selector
+    )]}}}}
+}
diff --git a/helmfile.d/charts/gatekeeper/templates/templates/config.yaml b/helmfile.d/charts/gatekeeper/templates/templates/config.yaml
index fc4cf498c4..dd27893d61 100644
--- a/helmfile.d/charts/gatekeeper/templates/templates/config.yaml
+++ b/helmfile.d/charts/gatekeeper/templates/templates/config.yaml
@@ -9,3 +9,18 @@ spec:
       - group: "networking.k8s.io"
         version: "v1"
         kind: "NetworkPolicy"
+      - group: "apps"
+        version: "v1"
+        kind: "Deployment"
+      - group: "apps"
+        version: "v1"
+        kind: "ReplicaSet"
+      - group: "apps"
+        version: "v1"
+        kind: "StatefulSet"
+      - group: ""
+        version: "v1"
+        kind: "ReplicationController"
+      - group: "policy"
+        version: "v1"
+        kind: "PodDisruptionBudget"
diff --git a/helmfile.d/charts/gatekeeper/templates/templates/restrict-pod-disruption-budgets/template.yaml b/helmfile.d/charts/gatekeeper/templates/templates/restrict-pod-disruption-budgets/template.yaml
new file mode 100644
index 0000000000..57c7c6d470
--- /dev/null
+++ b/helmfile.d/charts/gatekeeper/templates/templates/restrict-pod-disruption-budgets/template.yaml
@@ -0,0 +1,13 @@
+apiVersion: templates.gatekeeper.sh/v1
+kind: ConstraintTemplate
+metadata:
+  name: k8srestrictpoddisruptionbudgets
+spec:
+  crd:
+    spec:
+      names:
+        kind: K8sRestrictPodDisruptionBudgets
+  targets:
+    - target: admission.k8s.gatekeeper.sh
+      rego: |
+{{ .Files.Get "policies/restrict-pod-disruption-budgets.rego"  | indent 8 }}
diff --git a/helmfile.d/values/gatekeeper/constraints.yaml.gotmpl b/helmfile.d/values/gatekeeper/constraints.yaml.gotmpl
index cfbc6bb757..f8b20061a7 100644
--- a/helmfile.d/values/gatekeeper/constraints.yaml.gotmpl
+++ b/helmfile.d/values/gatekeeper/constraints.yaml.gotmpl
@@ -22,6 +22,9 @@ rejectLocalStorageEmptyDir:
 rejectPodWithoutController:
     enable: {{ .Values.opa.rejectPodWithoutController.enabled }}
     enforcementAction: {{ .Values.opa.rejectPodWithoutController.enforcement }}
+restrictPodDisruptionBudgets:
+    enable: {{ .Values.opa.restrictPodDisruptionBudgets.enabled }}
+    enforcementAction: {{ .Values.opa.restrictPodDisruptionBudgets.enforcement }}
 {{- if eq .Environment.Name "workload_cluster" }}
 allowUserCRDs:
     enable: {{ .Values.gatekeeper.allowUserCRDs.enabled }}
diff --git a/helmfile.d/values/gatekeeper/gatekeeper.yaml.gotmpl b/helmfile.d/values/gatekeeper/gatekeeper.yaml.gotmpl
index 852eb0fe17..67d534917c 100644
--- a/helmfile.d/values/gatekeeper/gatekeeper.yaml.gotmpl
+++ b/helmfile.d/values/gatekeeper/gatekeeper.yaml.gotmpl
@@ -48,6 +48,7 @@ validatingWebhookCustomRules:
       - services/proxy
       - nodes/proxy
       - hierarchyconfiguration
+      - poddisruptionbudgets
   - apiGroups:
       - '*'
     apiVersions:


Reviews:
user: login: Xartos
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-12T12:40:27Z
user: login: viktor-f
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T14:50:38Z
user: login: viktor-f
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T14:54:17Z
user: login: Xartos
body: Super nice! This should definitely fix some issues we have in some clusters
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T13:57:39Z
user: login: davidumea
body: > This PR also included some whitespace fixes that I stumbled upon. I hope it is ok that is is fixed in this PR. Otherwise let me know and I will move that to a separate PR.

I think this is fine, thanks for the cleanup!

> I have now updated the PR description with some more details. Please let me know if this explains it well or if I should clarify further.

Thanks it's crystal clear now!

I plan on continuing my review tomorrow
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T15:59:58Z
user: login: viktor-f
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T16:05:38Z
user: login: viktor-f
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T16:07:30Z
user: login: davidumea
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T10:47:51Z
user: login: viktor-f
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T12:36:00Z
user: login: viktor-f
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T12:39:44Z
user: login: davidumea
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T13:29:40Z
user: login: davidumea
body: I think the code looks good. Really nice work :slightly_smiling_face: 

I assume the plan is to add public docs and update the links here before you want to merge this?
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T14:30:00Z
user: login: davidumea
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-24T08:27:08Z
user: login: viktor-f
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-24T09:48:11Z
user: login: davidumea
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-24T12:27:02Z
user: login: viktor-f
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T07:35:41Z
user: login: davidumea
body: Really nice start on this :raised_hands: 
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T08:19:22Z
user: login: viktor-f
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T10:21:36Z

Review comments:
diff_hunk: @@ -0,0 +1,22 @@
+{{- if .Values.restrictPodDisruptionBudgets.enable -}}
+apiVersion: constraints.gatekeeper.sh/v1alpha1
+kind: K8sRestrictPodDisruptionBudgets
+metadata:
+  name: elastisys-restrict-pod-disruption-budgets
+spec:
+  enforcementAction: {{ .Values.restrictPodDisruptionBudgets.enforcementAction }}
+  match:
+    kinds:
+      - apiGroups: [""]
+        kinds: ["Pod", "ReplicationController"]
+      - apiGroups: ["apps"]
+        kinds: ["Deployment", "StatefulSet", "DaemonSet", "ReplicaSet"]
+      - apiGroups: ["extensions"]
+        kinds: ["Deployment", "StatefulSet", "DaemonSet", "ReplicaSet"]
+      - apiGroups: ["batch"]
+        kinds: ["Job", "CronJob"]
+      - apiGroups: ["policy"]
+        kinds: ["PodDisruptionBudget"]
+    excludedNamespaces: ["kube-system", "kube-public", "kube-node-lease", "calico-system"]
path: helmfile.d/charts/gatekeeper/constraints/templates/restrict-pod-disruption-budgets/constraint.yaml
user: login: Xartos
body: Question: Would it make sense to add a label to namespaces that are critical to us? Like a label `class=critical` or something. So that we can skip more if we need to. I'm not sure if like `openstack-system` or `capi-system` or `gpu-operator` namespaces that doesn't exist everywhere would benefit from this.

Alternatively make it possible to add more namespaces here so we don't need a new release to change
created_at: 2025-03-12T12:26:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,174 @@
+package k8srestrictpoddisruptionbudgets
+
+# Reject PDB if maxUnavailable does not allow at least 1 pod disruption
+violation[{"msg": msg}] {
+    input.review.kind.kind == "PodDisruptionBudget"
+    pdb := input.review.object
+
+	pdb.spec.maxUnavailable
path: helmfile.d/charts/gatekeeper/templates/policies/restrict-pod-disruption-budgets.rego
user: login: Xartos
body: NIT: Spaces instead of tabs. This is in more places in this file
```suggestion
    pdb.spec.maxUnavailable
```
created_at: 2025-03-12T12:28:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,174 @@
+package k8srestrictpoddisruptionbudgets
+
+# Reject PDB if maxUnavailable does not allow at least 1 pod disruption
+violation[{"msg": msg}] {
+    input.review.kind.kind == "PodDisruptionBudget"
+    pdb := input.review.object
+
+	pdb.spec.maxUnavailable
+
+    not_valid_pdb_max_unavailable(pdb)
+    # TODO update messages with link to public docs
+	msg := sprintf(
+    "PodDisruptionBudget rejected: PodDisruptionBudget <%v> has maxUnavailable of %v, only positive integers or percentages are allowed for maxUnavailable",
+    [pdb.metadata.name, pdb.spec.maxUnavailable],
+    )
+}
+
+# Reject PDB if minAvailable does not allow at least 1 pod disruption
+violation[{"msg": msg}] {
+    input.review.kind.kind == "PodDisruptionBudget"
+    pdb := input.review.object
+
+	pdb.spec.minAvailable
+
+	objs := [controllers | controllers := data.inventory.namespace[pdb.metadata.namespace]["apps/v1"][_]]
+	obj := objs[_][_]
+
+	not mismatched_selector(pdb, obj)
+
+    not_valid_pdb_min_available(obj, pdb)
+	not replica_set_under_deployment(obj)
+
+	keys := [key | data.inventory.namespace[pdb.metadata.namespace]["apps/v1"][key]]
+    msg := sprintf(
+    "PodDisruptionBudget rejected: %v <%v> has %v replica(s) but PodDisruptionBudget <%v> has minAvailable of %v, minAvailable should always be lower than replica(s), and not used when replica(s) is set to 1.",
+    [obj.kind, obj.metadata.name, obj.spec.replicas, pdb.metadata.name, pdb.spec.minAvailable],
+    )
+}
+
+# Reject pod controller if connected PDBs maxUnavailable does not allow at least 1 pod disruption
+violation[{"msg": msg}] {
+	input.review.kind.kind == podControllerKinds[_]
+    obj := input.review.object
+	not replica_set_under_deployment(obj)
+
+    pdb := data.inventory.namespace[obj.metadata.namespace]["policy/v1"].PodDisruptionBudget[_]
+
+	pdb.spec.maxUnavailable
+
+	not mismatched_selector(pdb, obj)
+
+    not_valid_pdb_max_unavailable(pdb)
+    msg := sprintf(
+    "%v rejected: %v <%v> has been selected by PodDisruptionBudget <%v> but has maxUnavailable of %v, only positive integers or percentages are allowed for maxUnavailable",
+    [obj.kind, obj.kind, obj.metadata.name, pdb.metadata.name, pdb.spec.maxUnavailable],
+    )
+}
+
+# Reject pod controller if connected PDBs minAvailable does not allow at least 1 pod disruption
+violation[{"msg": msg}] {
+    input.review.kind.kind == podControllerKinds[_]
+    obj := input.review.object
+	not replica_set_under_deployment(obj)
+
+    pdb := data.inventory.namespace[obj.metadata.namespace]["policy/v1"].PodDisruptionBudget[_]
+
+	pdb.spec.minAvailable
+
+	not mismatched_selector(pdb, obj)
+
+    not_valid_pdb_min_available(obj, pdb)
+    msg := sprintf(
+    "%v rejected: %v <%v> has %v replica(s) but PodDisruptionBudget <%v> has minAvailable of %v, minAvailable should always be lower than replica(s), and not used when replica(s) is set to 1.",
+    [obj.kind, obj.kind, obj.metadata.name, obj.spec.replicas, pdb.metadata.name, pdb.spec.minAvailable],
+    )
+}
+
+# The type of pod controller to validate
+podControllerKinds := [
+	"Deployment",
+	"StatefulSet",
+	"ReplicaSet",
+	"ReplicationController"
+]
+
+# Do not reject replicasets that are controlled by deployment, instead reject the deploymentd
+replica_set_under_deployment(obj) {
+	obj.kind == "ReplicaSet"
+    count([i | obj.metadata.ownerReferences[i].kind == "Deployment"]) > 0
+}
+
+# Check minAvailable if it is integer
+not_valid_pdb_min_available(obj, pdb) {
+	not regex.match("^[0-9]+%$", pdb.spec.minAvailable)
+    obj.spec.replicas <= pdb.spec.minAvailable
+}
+
+# Check minAvailable if it is percentage
+not_valid_pdb_min_available(obj, pdb) {
+	replicas := obj.spec.replicas
+	regex.match("^[0-9]+%$", pdb.spec.minAvailable)
+    percentage_num := to_number(replace(pdb.spec.minAvailable, "%", ""))
+	min_available := ceil((percentage_num/100)*replicas)
+
+	replicas <= min_available
+}
+
+not_valid_pdb_max_unavailable(pdb) {
+    pdb.spec.maxUnavailable == 0
+}
+
+not_valid_pdb_max_unavailable(pdb) {
+    pdb.spec.maxUnavailable == "0%"
path: helmfile.d/charts/gatekeeper/templates/policies/restrict-pod-disruption-budgets.rego
user: login: Xartos
body: Question: I assume that you've verified that it uses `ceil` here as well? Same as the `not_valid_pdb_min_available`
created_at: 2025-03-12T12:38:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,22 @@
+{{- if .Values.restrictPodDisruptionBudgets.enable -}}
+apiVersion: constraints.gatekeeper.sh/v1alpha1
+kind: K8sRestrictPodDisruptionBudgets
+metadata:
+  name: elastisys-restrict-pod-disruption-budgets
+spec:
+  enforcementAction: {{ .Values.restrictPodDisruptionBudgets.enforcementAction }}
+  match:
+    kinds:
+      - apiGroups: [""]
+        kinds: ["Pod", "ReplicationController"]
+      - apiGroups: ["apps"]
+        kinds: ["Deployment", "StatefulSet", "DaemonSet", "ReplicaSet"]
+      - apiGroups: ["extensions"]
+        kinds: ["Deployment", "StatefulSet", "DaemonSet", "ReplicaSet"]
+      - apiGroups: ["batch"]
+        kinds: ["Job", "CronJob"]
+      - apiGroups: ["policy"]
+        kinds: ["PodDisruptionBudget"]
+    excludedNamespaces: ["kube-system", "kube-public", "kube-node-lease", "calico-system"]
path: helmfile.d/charts/gatekeeper/constraints/templates/restrict-pod-disruption-budgets/constraint.yaml
user: login: viktor-f
body: Yes, this makes sense. I will write another task for this, since it is a bit out of scope for this PR.
created_at: 2025-03-18T14:50:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,174 @@
+package k8srestrictpoddisruptionbudgets
+
+# Reject PDB if maxUnavailable does not allow at least 1 pod disruption
+violation[{"msg": msg}] {
+    input.review.kind.kind == "PodDisruptionBudget"
+    pdb := input.review.object
+
+	pdb.spec.maxUnavailable
+
+    not_valid_pdb_max_unavailable(pdb)
+    # TODO update messages with link to public docs
+	msg := sprintf(
+    "PodDisruptionBudget rejected: PodDisruptionBudget <%v> has maxUnavailable of %v, only positive integers or percentages are allowed for maxUnavailable",
+    [pdb.metadata.name, pdb.spec.maxUnavailable],
+    )
+}
+
+# Reject PDB if minAvailable does not allow at least 1 pod disruption
+violation[{"msg": msg}] {
+    input.review.kind.kind == "PodDisruptionBudget"
+    pdb := input.review.object
+
+	pdb.spec.minAvailable
+
+	objs := [controllers | controllers := data.inventory.namespace[pdb.metadata.namespace]["apps/v1"][_]]
+	obj := objs[_][_]
+
+	not mismatched_selector(pdb, obj)
+
+    not_valid_pdb_min_available(obj, pdb)
+	not replica_set_under_deployment(obj)
+
+	keys := [key | data.inventory.namespace[pdb.metadata.namespace]["apps/v1"][key]]
+    msg := sprintf(
+    "PodDisruptionBudget rejected: %v <%v> has %v replica(s) but PodDisruptionBudget <%v> has minAvailable of %v, minAvailable should always be lower than replica(s), and not used when replica(s) is set to 1.",
+    [obj.kind, obj.metadata.name, obj.spec.replicas, pdb.metadata.name, pdb.spec.minAvailable],
+    )
+}
+
+# Reject pod controller if connected PDBs maxUnavailable does not allow at least 1 pod disruption
+violation[{"msg": msg}] {
+	input.review.kind.kind == podControllerKinds[_]
+    obj := input.review.object
+	not replica_set_under_deployment(obj)
+
+    pdb := data.inventory.namespace[obj.metadata.namespace]["policy/v1"].PodDisruptionBudget[_]
+
+	pdb.spec.maxUnavailable
+
+	not mismatched_selector(pdb, obj)
+
+    not_valid_pdb_max_unavailable(pdb)
+    msg := sprintf(
+    "%v rejected: %v <%v> has been selected by PodDisruptionBudget <%v> but has maxUnavailable of %v, only positive integers or percentages are allowed for maxUnavailable",
+    [obj.kind, obj.kind, obj.metadata.name, pdb.metadata.name, pdb.spec.maxUnavailable],
+    )
+}
+
+# Reject pod controller if connected PDBs minAvailable does not allow at least 1 pod disruption
+violation[{"msg": msg}] {
+    input.review.kind.kind == podControllerKinds[_]
+    obj := input.review.object
+	not replica_set_under_deployment(obj)
+
+    pdb := data.inventory.namespace[obj.metadata.namespace]["policy/v1"].PodDisruptionBudget[_]
+
+	pdb.spec.minAvailable
+
+	not mismatched_selector(pdb, obj)
+
+    not_valid_pdb_min_available(obj, pdb)
+    msg := sprintf(
+    "%v rejected: %v <%v> has %v replica(s) but PodDisruptionBudget <%v> has minAvailable of %v, minAvailable should always be lower than replica(s), and not used when replica(s) is set to 1.",
+    [obj.kind, obj.kind, obj.metadata.name, obj.spec.replicas, pdb.metadata.name, pdb.spec.minAvailable],
+    )
+}
+
+# The type of pod controller to validate
+podControllerKinds := [
+	"Deployment",
+	"StatefulSet",
+	"ReplicaSet",
+	"ReplicationController"
+]
+
+# Do not reject replicasets that are controlled by deployment, instead reject the deploymentd
+replica_set_under_deployment(obj) {
+	obj.kind == "ReplicaSet"
+    count([i | obj.metadata.ownerReferences[i].kind == "Deployment"]) > 0
+}
+
+# Check minAvailable if it is integer
+not_valid_pdb_min_available(obj, pdb) {
+	not regex.match("^[0-9]+%$", pdb.spec.minAvailable)
+    obj.spec.replicas <= pdb.spec.minAvailable
+}
+
+# Check minAvailable if it is percentage
+not_valid_pdb_min_available(obj, pdb) {
+	replicas := obj.spec.replicas
+	regex.match("^[0-9]+%$", pdb.spec.minAvailable)
+    percentage_num := to_number(replace(pdb.spec.minAvailable, "%", ""))
+	min_available := ceil((percentage_num/100)*replicas)
+
+	replicas <= min_available
+}
+
+not_valid_pdb_max_unavailable(pdb) {
+    pdb.spec.maxUnavailable == 0
+}
+
+not_valid_pdb_max_unavailable(pdb) {
+    pdb.spec.maxUnavailable == "0%"
path: helmfile.d/charts/gatekeeper/templates/policies/restrict-pod-disruption-budgets.rego
user: login: viktor-f
body: Yes, that is at least what the [upstream documentation](https://kubernetes.io/docs/tasks/run-application/configure-pdb) says.


> When you specify the value as a percentage, it may not map to an exact number of Pods. For example, if you have 7 Pods and you set minAvailable to "50%", it's not immediately obvious whether that means 3 Pods or 4 Pods must be available. Kubernetes rounds up to the nearest integer, so in this case, 4 Pods must be available. When you specify the value maxUnavailable as a percentage, Kubernetes rounds up the number of Pods that may be disrupted. Thereby a disruption can exceed your defined maxUnavailable percentage. You can examine the [code](https://github.com/kubernetes/kubernetes/blob/23be9587a0f8677eb8091464098881df939c44a9/pkg/controller/disruption/disruption.go#L539) that controls this behavior.

> If you set maxUnavailable to 0% or 0, or you set minAvailable to 100% or the number of replicas, you are requiring zero voluntary evictions.
created_at: 2025-03-18T14:54:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,22 @@
+{{- if .Values.restrictPodDisruptionBudgets.enable -}}
+apiVersion: constraints.gatekeeper.sh/v1alpha1
+kind: K8sRestrictPodDisruptionBudgets
+metadata:
+  name: elastisys-restrict-pod-disruption-budgets
+spec:
+  enforcementAction: {{ .Values.restrictPodDisruptionBudgets.enforcementAction }}
+  match:
+    kinds:
+      - apiGroups: [""]
+        kinds: ["Pod", "ReplicationController"]
+      - apiGroups: ["apps"]
+        kinds: ["Deployment", "StatefulSet", "DaemonSet", "ReplicaSet"]
+      - apiGroups: ["extensions"]
+        kinds: ["Deployment", "StatefulSet", "DaemonSet", "ReplicaSet"]
+      - apiGroups: ["batch"]
+        kinds: ["Job", "CronJob"]
path: helmfile.d/charts/gatekeeper/constraints/templates/restrict-pod-disruption-budgets/constraint.yaml
user: login: davidumea
body: These do nothing at the moment right? Same with daemonset
created_at: 2025-03-20T15:57:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -9,3 +9,18 @@ spec:
       - group: "networking.k8s.io"
         version: "v1"
         kind: "NetworkPolicy"
+      - group: "apps"
+        version: "v1"
+        kind: "Deployment"
+      - group: "apps"
+        version: "v1"
+        kind: "ReplicaSet"
+      - group: "apps"
+        version: "v1"
+        kind: "StatefulSet"
+      - group: ""
+        version: "v1"
+        kind: "ReplicationController"
+      - group: "policy"
+        version: "v1"
+        kind: "PodDisruptionBudget"
path: helmfile.d/charts/gatekeeper/templates/templates/config.yaml
user: login: davidumea
body: If you want to keep the kinds mentioned in my previous comment for future work, then please add them here as well :slightly_smiling_face: might save a future headache.
created_at: 2025-03-20T15:58:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,22 @@
+{{- if .Values.restrictPodDisruptionBudgets.enable -}}
+apiVersion: constraints.gatekeeper.sh/v1alpha1
+kind: K8sRestrictPodDisruptionBudgets
+metadata:
+  name: elastisys-restrict-pod-disruption-budgets
+spec:
+  enforcementAction: {{ .Values.restrictPodDisruptionBudgets.enforcementAction }}
+  match:
+    kinds:
+      - apiGroups: [""]
+        kinds: ["Pod", "ReplicationController"]
+      - apiGroups: ["apps"]
+        kinds: ["Deployment", "StatefulSet", "DaemonSet", "ReplicaSet"]
+      - apiGroups: ["extensions"]
+        kinds: ["Deployment", "StatefulSet", "DaemonSet", "ReplicaSet"]
+      - apiGroups: ["batch"]
+        kinds: ["Job", "CronJob"]
path: helmfile.d/charts/gatekeeper/constraints/templates/restrict-pod-disruption-budgets/constraint.yaml
user: login: viktor-f
body: Yeah, this was just something I forgot to remove when I reduced the scope of the task.
Removed now.
created_at: 2025-03-20T16:05:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -9,3 +9,18 @@ spec:
       - group: "networking.k8s.io"
         version: "v1"
         kind: "NetworkPolicy"
+      - group: "apps"
+        version: "v1"
+        kind: "Deployment"
+      - group: "apps"
+        version: "v1"
+        kind: "ReplicaSet"
+      - group: "apps"
+        version: "v1"
+        kind: "StatefulSet"
+      - group: ""
+        version: "v1"
+        kind: "ReplicationController"
+      - group: "policy"
+        version: "v1"
+        kind: "PodDisruptionBudget"
path: helmfile.d/charts/gatekeeper/templates/templates/config.yaml
user: login: viktor-f
body: I removed the kinds from the constraint.
As extra info: it would actually not be good to add the extra kinds here before they are needed, because that would cause gatekeeper to cache those kinds and increase the resource of the gatekeeper pods.
created_at: 2025-03-20T16:07:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,742 @@
+package test.k8srestrictpoddisruptionbudgets
+
+import data.k8srestrictpoddisruptionbudgets
+
+#
+# Help functions
+#
+
+generate_pdb(type, limit, namespace, selector) = obj {
+    obj := {
+        "kind": "PodDisruptionBudget",
+        "metadata": {
+            "name": "pdb-name",
+            "namespace": namespace
+        },
+        "spec": {
+            type: limit,
+            "selector": selector
+        }
+    }
+}
+
+generate_pod_controller(kind, replicas, namespace, labels) = obj {
+    allowed_kinds := [
+        "Deployment",
+        "StatefulSet",
+        "ReplicaSet",
+        "ReplicationController"
+    ]
+    kind == allowed_kinds[_]
+    obj := {
+        "kind": kind,
+        "metadata": {
+            "namespace": namespace,
+            "name": "controller-name"
+        },
+        "spec": {
+            "replicas": replicas,
+            "template": {
+                "metadata": {
+                    "labels": labels
+                }
+            }
+        }
+    }
+}
+
+generate_replicaset_under_deployment(replicas, namespace, labels) = obj {
+    obj := {
+        "kind": "ReplicaSet",
+        "metadata": {
+            "namespace": namespace,
+            "name": "controller-name",
+            "ownerReferences": [
+                {
+                    "kind": "Deployment"
+                }
+            ]
+        },
+        "spec": {
+            "replicas": replicas,
+            "template": {
+                "metadata": {
+                    "labels": labels
+                }
+            }
+        }
+    }
+}
+
+input_wrap(obj) = input {
+    input := {"review": {"object": obj}}
+}
path: helmfile.d/charts/gatekeeper/templates/policies/tests/restrict-pod-disruption-budgets.rego
user: login: davidumea
body: Nice detail :slightly_smiling_face: 
created_at: 2025-03-21T10:15:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,181 @@
+package k8srestrictpoddisruptionbudgets
+
+# Reject PDB if maxUnavailable does not allow at least 1 pod disruption
+violation[{"msg": msg}] {
+    input.review.object.kind == "PodDisruptionBudget"
+    pdb := input.review.object
+
+    pdb.spec.maxUnavailable
+
+    not_valid_pdb_max_unavailable(pdb)
+    # TODO update messages with link to public docs
+    msg := sprintf(
+    "PodDisruptionBudget rejected: PodDisruptionBudget <%v> has maxUnavailable of %v, only positive integers or percentages are allowed for maxUnavailable",
+    [pdb.metadata.name, pdb.spec.maxUnavailable],
+    )
+}
+
+# Reject PDB if minAvailable does not allow at least 1 pod disruption
+violation[{"msg": msg}] {
+    input.review.object.kind == "PodDisruptionBudget"
+    pdb := input.review.object
+
+    pdb.spec.minAvailable
+
+    pod_controller_group_kind := pod_controller_groups_kinds[_]
+    objs := [controllers | controllers := data.inventory.namespace[pdb.metadata.namespace][pod_controller_group_kind.group][pod_controller_group_kind.kind]]
+    obj := objs[_][_]
+
+    not mismatched_selector(pdb, obj)
+
+    not_valid_pdb_min_available(obj, pdb)
+    not replica_set_under_deployment(obj)
+
+    msg := sprintf(
+    "PodDisruptionBudget rejected: %v <%v> has %v replica(s) but PodDisruptionBudget <%v> has minAvailable of %v, minAvailable should always be lower than replica(s), and not used when replica(s) is set to 1.",
+    [obj.kind, obj.metadata.name, obj.spec.replicas, pdb.metadata.name, pdb.spec.minAvailable],
+    )
+}
+
+# Reject pod controller if connected PDBs maxUnavailable does not allow at least 1 pod disruption
+violation[{"msg": msg}] {
+    input.review.object.kind == pod_controller_groups_kinds[_].kind
+    obj := input.review.object
+    not replica_set_under_deployment(obj)
+
+    pdb := data.inventory.namespace[obj.metadata.namespace]["policy/v1"].PodDisruptionBudget[_]
+
+    pdb.spec.maxUnavailable
+
+    not mismatched_selector(pdb, obj)
+
+    not_valid_pdb_max_unavailable(pdb)
+    msg := sprintf(
+    "%v rejected: %v <%v> has been selected by PodDisruptionBudget <%v> but has maxUnavailable of %v, only positive integers or percentages are allowed for maxUnavailable",
+    [obj.kind, obj.kind, obj.metadata.name, pdb.metadata.name, pdb.spec.maxUnavailable],
+    )
+}
+
+# Reject pod controller if connected PDBs minAvailable does not allow at least 1 pod disruption
+violation[{"msg": msg}] {
+    input.review.object.kind == pod_controller_groups_kinds[_].kind
+    obj := input.review.object
+    not replica_set_under_deployment(obj)
+
+    pdb := data.inventory.namespace[obj.metadata.namespace]["policy/v1"].PodDisruptionBudget[_]
+
+    pdb.spec.minAvailable
+
+    not mismatched_selector(pdb, obj)
+
+    not_valid_pdb_min_available(obj, pdb)
+    msg := sprintf(
+    "%v rejected: %v <%v> has %v replica(s) but PodDisruptionBudget <%v> has minAvailable of %v, minAvailable should always be lower than replica(s), and not used when replica(s) is set to 1.",
+    [obj.kind, obj.kind, obj.metadata.name, obj.spec.replicas, pdb.metadata.name, pdb.spec.minAvailable],
+    )
+}
+
+# The type of pod controller to validate
+pod_controller_groups_kinds := [
+    {"group": "apps/v1", "kind": "Deployment"},
+    {"group": "apps/v1", "kind": "StatefulSet"},
+    {"group": "apps/v1", "kind": "ReplicaSet"},
+    {"group": "v1", "kind": "ReplicationController"}
+]
path: helmfile.d/charts/gatekeeper/templates/policies/restrict-pod-disruption-budgets.rego
user: login: davidumea
body: You only use the group here in 1 place (below), might want to split these into separate functions, one for group and one for kind.

https://github.com/elastisys/compliantkubernetes-apps/blob/8d6c09e4021c9233498844e31b1d633cbd1e4e13/helmfile.d/charts/gatekeeper/templates/policies/restrict-pod-disruption-budgets.rego#L26

Or do they need to be together to make sure it's the same object? If I understand it correctly it should take one object at a time so even if the information is fetched from two functions that shouldn't be a problem.
created_at: 2025-03-21T10:27:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,181 @@
+package k8srestrictpoddisruptionbudgets
+
+# Reject PDB if maxUnavailable does not allow at least 1 pod disruption
+violation[{"msg": msg}] {
+    input.review.object.kind == "PodDisruptionBudget"
+    pdb := input.review.object
+
+    pdb.spec.maxUnavailable
+
+    not_valid_pdb_max_unavailable(pdb)
+    # TODO update messages with link to public docs
+    msg := sprintf(
+    "PodDisruptionBudget rejected: PodDisruptionBudget <%v> has maxUnavailable of %v, only positive integers or percentages are allowed for maxUnavailable",
+    [pdb.metadata.name, pdb.spec.maxUnavailable],
+    )
+}
+
+# Reject PDB if minAvailable does not allow at least 1 pod disruption
+violation[{"msg": msg}] {
+    input.review.object.kind == "PodDisruptionBudget"
+    pdb := input.review.object
+
+    pdb.spec.minAvailable
+
+    pod_controller_group_kind := pod_controller_groups_kinds[_]
+    objs := [controllers | controllers := data.inventory.namespace[pdb.metadata.namespace][pod_controller_group_kind.group][pod_controller_group_kind.kind]]
+    obj := objs[_][_]
+
+    not mismatched_selector(pdb, obj)
+
+    not_valid_pdb_min_available(obj, pdb)
+    not replica_set_under_deployment(obj)
+
+    msg := sprintf(
+    "PodDisruptionBudget rejected: %v <%v> has %v replica(s) but PodDisruptionBudget <%v> has minAvailable of %v, minAvailable should always be lower than replica(s), and not used when replica(s) is set to 1.",
+    [obj.kind, obj.metadata.name, obj.spec.replicas, pdb.metadata.name, pdb.spec.minAvailable],
+    )
+}
+
+# Reject pod controller if connected PDBs maxUnavailable does not allow at least 1 pod disruption
+violation[{"msg": msg}] {
+    input.review.object.kind == pod_controller_groups_kinds[_].kind
+    obj := input.review.object
+    not replica_set_under_deployment(obj)
+
+    pdb := data.inventory.namespace[obj.metadata.namespace]["policy/v1"].PodDisruptionBudget[_]
+
+    pdb.spec.maxUnavailable
+
+    not mismatched_selector(pdb, obj)
+
+    not_valid_pdb_max_unavailable(pdb)
+    msg := sprintf(
+    "%v rejected: %v <%v> has been selected by PodDisruptionBudget <%v> but has maxUnavailable of %v, only positive integers or percentages are allowed for maxUnavailable",
+    [obj.kind, obj.kind, obj.metadata.name, pdb.metadata.name, pdb.spec.maxUnavailable],
+    )
+}
+
+# Reject pod controller if connected PDBs minAvailable does not allow at least 1 pod disruption
+violation[{"msg": msg}] {
+    input.review.object.kind == pod_controller_groups_kinds[_].kind
+    obj := input.review.object
+    not replica_set_under_deployment(obj)
+
+    pdb := data.inventory.namespace[obj.metadata.namespace]["policy/v1"].PodDisruptionBudget[_]
+
+    pdb.spec.minAvailable
+
+    not mismatched_selector(pdb, obj)
+
+    not_valid_pdb_min_available(obj, pdb)
+    msg := sprintf(
+    "%v rejected: %v <%v> has %v replica(s) but PodDisruptionBudget <%v> has minAvailable of %v, minAvailable should always be lower than replica(s), and not used when replica(s) is set to 1.",
+    [obj.kind, obj.kind, obj.metadata.name, obj.spec.replicas, pdb.metadata.name, pdb.spec.minAvailable],
+    )
+}
+
+# The type of pod controller to validate
+pod_controller_groups_kinds := [
+    {"group": "apps/v1", "kind": "Deployment"},
+    {"group": "apps/v1", "kind": "StatefulSet"},
+    {"group": "apps/v1", "kind": "ReplicaSet"},
+    {"group": "v1", "kind": "ReplicationController"}
+]
+
+# Do not reject replicasets that are controlled by deployment, instead reject the deploymentd
path: helmfile.d/charts/gatekeeper/templates/policies/restrict-pod-disruption-budgets.rego
user: login: davidumea
body: ```suggestion
# Do not reject replicasets that are controlled by deployment, instead reject the deployment
```
created_at: 2025-03-21T10:28:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,742 @@
+package test.k8srestrictpoddisruptionbudgets
+
+import data.k8srestrictpoddisruptionbudgets
+
+#
+# Help functions
+#
+
+generate_pdb(type, limit, namespace, selector) = obj {
+    obj := {
+        "kind": "PodDisruptionBudget",
+        "metadata": {
+            "name": "pdb-name",
+            "namespace": namespace
+        },
+        "spec": {
+            type: limit,
+            "selector": selector
+        }
+    }
+}
+
+generate_pod_controller(kind, replicas, namespace, labels) = obj {
+    allowed_kinds := [
+        "Deployment",
+        "StatefulSet",
+        "ReplicaSet",
+        "ReplicationController"
+    ]
+    kind == allowed_kinds[_]
+    obj := {
+        "kind": kind,
+        "metadata": {
+            "namespace": namespace,
+            "name": "controller-name"
+        },
+        "spec": {
+            "replicas": replicas,
+            "template": {
+                "metadata": {
+                    "labels": labels
+                }
+            }
+        }
+    }
+}
+
+generate_replicaset_under_deployment(replicas, namespace, labels) = obj {
+    obj := {
+        "kind": "ReplicaSet",
+        "metadata": {
+            "namespace": namespace,
+            "name": "controller-name",
+            "ownerReferences": [
+                {
+                    "kind": "Deployment"
+                }
+            ]
+        },
+        "spec": {
+            "replicas": replicas,
+            "template": {
+                "metadata": {
+                    "labels": labels
+                }
+            }
+        }
+    }
+}
+
+input_wrap(obj) = input {
+    input := {"review": {"object": obj}}
+}
+
+pod_controller_groups_kinds := [
+    {"group": "apps/v1", "kind": "Deployment"},
+    {"group": "apps/v1", "kind": "StatefulSet"},
+    {"group": "apps/v1", "kind": "ReplicaSet"},
+    {"group": "v1", "kind": "ReplicationController"}
+]
+
+#
+# Tests
+#
+
+# --- Test PDB as review object ---
+
+test_ok_pdb_no_pod_maxUnavailable_int {
+    count(k8srestrictpoddisruptionbudgets.violation) == 0 with input as input_wrap(generate_pdb(
+        "maxUnavailable",
+        1,
+        "default",
+        {"matchLabels": {"key": "val"}}
+    ))
+}
+
+test_ok_pdb_no_pod_maxUnavailable_percent {
+    count(k8srestrictpoddisruptionbudgets.violation) == 0 with input as input_wrap(generate_pdb(
+        "maxUnavailable",
+        "10%",
+        "default",
+        {"matchLabels": {"key": "val"}}
+    ))
+}
+
+
+test_bad_pdb_no_pod_maxUnavailable_int {
+    count(k8srestrictpoddisruptionbudgets.violation) == 1 with input as input_wrap(generate_pdb(
+        "maxUnavailable",
+        0,
+        "default",
+        {"matchLabels": {"key": "val"}}
+    ))
+}
+
+test_bad_pdb_no_pod_maxUnavailable_percent {
+    count(k8srestrictpoddisruptionbudgets.violation) == 1 with input as input_wrap(generate_pdb(
+        "maxUnavailable",
+        "0%",
+        "default",
+        {"matchLabels": {"key": "val"}}
+    ))
+}
+
+test_ok_pdb_no_pod_minAvailable_int {
+    count(k8srestrictpoddisruptionbudgets.violation) == 0 with input as input_wrap(generate_pdb(
+        "minAvailable",
+        0,
+        "default",
+        {"matchLabels": {"key": "val"}}
+    ))
+}
+
+test_ok_pdb_no_pod_minAvailable_percent {
+    count(k8srestrictpoddisruptionbudgets.violation) == 0 with input as input_wrap(generate_pdb(
+        "minAvailable",
+        "0%",
+        "default",
+        {"matchLabels": {"key": "val"}}
+    ))
+}
+
+test_ok_pdb_with_pod_minAvailable_int {
+    namespace := "default"
+    selector := {"matchLabels": {"key": "val"}}
+    labels := {"key": "val"}
+    inventories := [inventory | inventory := {"namespace": {namespace: {pod_controller_groups_kinds[i].group: {pod_controller_groups_kinds[i].kind: [generate_pod_controller(
+        pod_controller_groups_kinds[i].kind,
+        3,
+        namespace,
+        labels
+    )]}}}}]
+
+    res = [test | test = count(k8srestrictpoddisruptionbudgets.violation) == 0
+        with input as input_wrap(generate_pdb(
+            "minAvailable",
+            1,
+            namespace,
+            selector
+        )) with data.inventory as inventories[_]
+    ]
+    # TODO consider adding a count so that it is certain how many test cases that are run
+    all(res)
+}
+
+test_ok_pdb_with_pod_minAvailable_percent {
+    namespace := "default"
+    selector := {"matchLabels": {"key": "val"}}
+    labels := {"key": "val"}
+    inventories := [inventory | inventory := {"namespace": {namespace: {pod_controller_groups_kinds[i].group: {pod_controller_groups_kinds[i].kind: [generate_pod_controller(
+        pod_controller_groups_kinds[i].kind,
+        3,
+        namespace,
+        labels
+    )]}}}}]
+
+    res = [test | test = count(k8srestrictpoddisruptionbudgets.violation) == 0
+        with input as input_wrap(generate_pdb(
+            "minAvailable",
+            "10%",
+            namespace,
+            selector
+        )) with data.inventory as inventories[_]
+    ]
+    all(res)
+}
+
+test_bad_pdb_with_pod_minAvailable_int {
+    namespace := "default"
+    selector := {"matchLabels": {"key": "val"}}
+    labels := {"key": "val"}
+    inventories := [inventory | inventory := {"namespace": {namespace: {pod_controller_groups_kinds[i].group: {pod_controller_groups_kinds[i].kind: [generate_pod_controller(
+        pod_controller_groups_kinds[i].kind,
+        3,
+        namespace,
+        labels
+    )]}}}}]
+
+    res = [test | test = count(k8srestrictpoddisruptionbudgets.violation) == 1
+        with input as input_wrap(generate_pdb(
+            "minAvailable",
+            3,
+            namespace,
+            selector
+        )) with data.inventory as inventories[_]
+    ]
+    all(res)
+}
+
+test_bad_pdb_with_pod_minAvailable_percent {
+    namespace := "default"
+    selector := {"matchLabels": {"key": "val"}}
+    labels := {"key": "val"}
+    inventories := [inventory | inventory := {"namespace": {namespace: {pod_controller_groups_kinds[i].group: {pod_controller_groups_kinds[i].kind: [generate_pod_controller(
+        pod_controller_groups_kinds[i].kind,
+        3,
+        namespace,
+        labels
+    )]}}}}]
+
+    res = [test | test = count(k8srestrictpoddisruptionbudgets.violation) == 1
+        with input as input_wrap(generate_pdb(
+            "minAvailable",
+            "90%",
+            namespace,
+            selector
+        )) with data.inventory as inventories[_]
+    ]
+    all(res)
+}
+
+# --- test pod controller as review object ---
+
+test_ok_pod_controller_maxUnavailable_int {
+    namespace := "default"
+    selector := {"matchLabels": {"key": "val"}}
+    labels := {"key": "val"}
+    pod_controllers := [pod_controller | pod_controller = input_wrap(generate_pod_controller(
+        pod_controller_groups_kinds[_].kind,
+        3,
+        namespace,
+        labels
+    ))]
+
+    res = [test | test = count(k8srestrictpoddisruptionbudgets.violation) == 0
+        with input as pod_controllers[_]
+        with data.inventory as {"namespace": {namespace: {"policy/v1": {"PodDisruptionBudget": [generate_pdb(
+            "maxUnavailable",
+            1,
+            namespace,
+            selector
+        )]}}}}
+    ]
+    all(res)
+}
+
+test_ok_pod_controller_maxUnavailable_percent {
+    namespace := "default"
+    selector := {"matchLabels": {"key": "val"}}
+    labels := {"key": "val"}
+    pod_controllers := [pod_controller | pod_controller = input_wrap(generate_pod_controller(
+        pod_controller_groups_kinds[_].kind,
+        3,
+        namespace,
+        labels
+    ))]
+
+    res = [test | test = count(k8srestrictpoddisruptionbudgets.violation) == 0
+        with input as pod_controllers[_]
+        with data.inventory as {"namespace": {namespace: {"policy/v1": {"PodDisruptionBudget": [generate_pdb(
+            "maxUnavailable",
+            "20%",
+            namespace,
+            selector
+        )]}}}}
+    ]
+    all(res)
+}
+
+test_bad_pod_controller_maxUnavailable_int {
+    namespace := "default"
+    selector := {"matchLabels": {"key": "val"}}
+    labels := {"key": "val"}
+    pod_controllers := [pod_controller | pod_controller = input_wrap(generate_pod_controller(
+        pod_controller_groups_kinds[_].kind,
+        3,
+        namespace,
+        labels
+    ))]
+
+    res = [test | test = count(k8srestrictpoddisruptionbudgets.violation) == 1
+        with input as pod_controllers[_]
+        with data.inventory as {"namespace": {namespace: {"policy/v1": {"PodDisruptionBudget": [generate_pdb(
+            "maxUnavailable",
+            0,
+            namespace,
+            selector
+        )]}}}}
+    ]
+    all(res)
+}
+
+test_bad_pod_controller_maxUnavailable_percent {
+    namespace := "default"
+    selector := {"matchLabels": {"key": "val"}}
+    labels := {"key": "val"}
+    pod_controllers := [pod_controller | pod_controller = input_wrap(generate_pod_controller(
+        pod_controller_groups_kinds[_].kind,
+        3,
+        namespace,
+        labels
+    ))]
+
+    res = [test | test = count(k8srestrictpoddisruptionbudgets.violation) == 1
+        with input as pod_controllers[_]
+        with data.inventory as {"namespace": {namespace: {"policy/v1": {"PodDisruptionBudget": [generate_pdb(
+            "maxUnavailable",
+            "0%",
+            namespace,
+            selector
+        )]}}}}
+    ]
+    all(res)
+}
+
+test_ok_pod_controller_minAvailable_int {
+    namespace := "default"
+    selector := {"matchLabels": {"key": "val"}}
+    labels := {"key": "val"}
+    pod_controllers := [pod_controller | pod_controller = input_wrap(generate_pod_controller(
+        pod_controller_groups_kinds[_].kind,
+        3,
+        namespace,
+        labels
+    ))]
+
+    res = [test | test = count(k8srestrictpoddisruptionbudgets.violation) == 0
+        with input as pod_controllers[_]
+        with data.inventory as {"namespace": {namespace: {"policy/v1": {"PodDisruptionBudget": [generate_pdb(
+            "minAvailable",
+            1,
+            namespace,
+            selector
+        )]}}}}
+    ]
+    all(res)
+}
+
+test_ok_pod_controller_minAvailable_percent {
+    namespace := "default"
+    selector := {"matchLabels": {"key": "val"}}
+    labels := {"key": "val"}
+    pod_controllers := [pod_controller | pod_controller = input_wrap(generate_pod_controller(
+        pod_controller_groups_kinds[_].kind,
+        3,
+        namespace,
+        labels
+    ))]
+
+    res = [test | test = count(k8srestrictpoddisruptionbudgets.violation) == 0
+        with input as pod_controllers[_]
+        with data.inventory as {"namespace": {namespace: {"policy/v1": {"PodDisruptionBudget": [generate_pdb(
+            "minAvailable",
+            "20%",
+            namespace,
+            selector
+        )]}}}}
+    ]
+    all(res)
+}
+
+test_bad_pod_controller_minAvailable_int {
+    namespace := "default"
+    selector := {"matchLabels": {"key": "val"}}
+    labels := {"key": "val"}
+    pod_controllers := [pod_controller | pod_controller = input_wrap(generate_pod_controller(
+        pod_controller_groups_kinds[_].kind,
+        3,
+        namespace,
+        labels
+    ))]
+
+    res = [test | test = count(k8srestrictpoddisruptionbudgets.violation) == 1
+        with input as pod_controllers[_]
+        with data.inventory as {"namespace": {namespace: {"policy/v1": {"PodDisruptionBudget": [generate_pdb(
+            "minAvailable",
+            5,
+            namespace,
+            selector
+        )]}}}}
+    ]
+    all(res)
+}
+
+test_bad_pod_controller_minAvailable_percent {
+    namespace := "default"
+    selector := {"matchLabels": {"key": "val"}}
+    labels := {"key": "val"}
+    pod_controllers := [pod_controller | pod_controller = input_wrap(generate_pod_controller(
+        pod_controller_groups_kinds[_].kind,
+        3,
+        namespace,
+        labels
+    ))]
+
+    res = [test | test = count(k8srestrictpoddisruptionbudgets.violation) == 1
+        with input as pod_controllers[_]
+        with data.inventory as {"namespace": {namespace: {"policy/v1": {"PodDisruptionBudget": [generate_pdb(
+            "minAvailable",
+            "90%",
+            namespace,
+            selector
+        )]}}}}
+    ]
+    all(res)
+}
+
+# --- Test replicaset under deployment as review object ---
+
+test_bad_replicaset_under_deployment_maxUnavailable_int {
+    namespace := "default"
+    selector := {"matchLabels": {"key": "val"}}
+    labels := {"key": "val"}
+
+    # The replicaset and PDB does not allow for disruption, but this should not violate since replicaset is under control of a deployment
+    res = [test | test = count(k8srestrictpoddisruptionbudgets.violation) == 0
+        with input as input_wrap(generate_replicaset_under_deployment(
+            3,
+            namespace,
+            labels
+        ))
+        with data.inventory as {"namespace": {namespace: {"policy/v1": {"PodDisruptionBudget": [generate_pdb(
+            "maxUnavailable",
+            0,
+            namespace,
+            selector
+        )]}}}}
+    ]
+    all(res)
+}
+
+test_bad_replicaset_under_deployment_minAvailable_int {
+    namespace := "default"
+    selector := {"matchLabels": {"key": "val"}}
+    labels := {"key": "val"}
+
+    # The replicaset and PDB does not allow for disruption, but this should not violate since replicaset is under control of a deployment
+    res = [test | test = count(k8srestrictpoddisruptionbudgets.violation) == 0
+        with input as input_wrap(generate_replicaset_under_deployment(
+            3,
+            namespace,
+            labels
+        ))
+        with data.inventory as {"namespace": {namespace: {"policy/v1": {"PodDisruptionBudget": [generate_pdb(
+            "minAvailable",
+            3,
+            namespace,
+            selector
+        )]}}}}
+    ]
+    all(res)
+}
+
+# --- Test different selectors ---
+
+test_matching_match_labels {
+	namespace := "default"
+    selector := {"matchLabels": {"key": "val"}}
+    labels := {"key": "val"}
+
+    count(k8srestrictpoddisruptionbudgets.violation) == 1
+        with input as input_wrap(generate_pod_controller(
+            "Deployment",
+			3,
path: helmfile.d/charts/gatekeeper/templates/policies/tests/restrict-pod-disruption-budgets.rego
user: login: davidumea
body: Indentation is a bit off here, right?

Same with the replica integer for most tests below
created_at: 2025-03-21T10:41:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,181 @@
+package k8srestrictpoddisruptionbudgets
+
+# Reject PDB if maxUnavailable does not allow at least 1 pod disruption
+violation[{"msg": msg}] {
+    input.review.object.kind == "PodDisruptionBudget"
+    pdb := input.review.object
+
+    pdb.spec.maxUnavailable
+
+    not_valid_pdb_max_unavailable(pdb)
+    # TODO update messages with link to public docs
+    msg := sprintf(
+    "PodDisruptionBudget rejected: PodDisruptionBudget <%v> has maxUnavailable of %v, only positive integers or percentages are allowed for maxUnavailable",
+    [pdb.metadata.name, pdb.spec.maxUnavailable],
+    )
+}
+
+# Reject PDB if minAvailable does not allow at least 1 pod disruption
+violation[{"msg": msg}] {
+    input.review.object.kind == "PodDisruptionBudget"
+    pdb := input.review.object
+
+    pdb.spec.minAvailable
+
+    pod_controller_group_kind := pod_controller_groups_kinds[_]
+    objs := [controllers | controllers := data.inventory.namespace[pdb.metadata.namespace][pod_controller_group_kind.group][pod_controller_group_kind.kind]]
+    obj := objs[_][_]
+
+    not mismatched_selector(pdb, obj)
+
+    not_valid_pdb_min_available(obj, pdb)
+    not replica_set_under_deployment(obj)
+
+    msg := sprintf(
+    "PodDisruptionBudget rejected: %v <%v> has %v replica(s) but PodDisruptionBudget <%v> has minAvailable of %v, minAvailable should always be lower than replica(s), and not used when replica(s) is set to 1.",
+    [obj.kind, obj.metadata.name, obj.spec.replicas, pdb.metadata.name, pdb.spec.minAvailable],
+    )
+}
+
+# Reject pod controller if connected PDBs maxUnavailable does not allow at least 1 pod disruption
+violation[{"msg": msg}] {
+    input.review.object.kind == pod_controller_groups_kinds[_].kind
+    obj := input.review.object
+    not replica_set_under_deployment(obj)
+
+    pdb := data.inventory.namespace[obj.metadata.namespace]["policy/v1"].PodDisruptionBudget[_]
+
+    pdb.spec.maxUnavailable
+
+    not mismatched_selector(pdb, obj)
+
+    not_valid_pdb_max_unavailable(pdb)
+    msg := sprintf(
+    "%v rejected: %v <%v> has been selected by PodDisruptionBudget <%v> but has maxUnavailable of %v, only positive integers or percentages are allowed for maxUnavailable",
+    [obj.kind, obj.kind, obj.metadata.name, pdb.metadata.name, pdb.spec.maxUnavailable],
+    )
+}
+
+# Reject pod controller if connected PDBs minAvailable does not allow at least 1 pod disruption
+violation[{"msg": msg}] {
+    input.review.object.kind == pod_controller_groups_kinds[_].kind
+    obj := input.review.object
+    not replica_set_under_deployment(obj)
+
+    pdb := data.inventory.namespace[obj.metadata.namespace]["policy/v1"].PodDisruptionBudget[_]
+
+    pdb.spec.minAvailable
+
+    not mismatched_selector(pdb, obj)
+
+    not_valid_pdb_min_available(obj, pdb)
+    msg := sprintf(
+    "%v rejected: %v <%v> has %v replica(s) but PodDisruptionBudget <%v> has minAvailable of %v, minAvailable should always be lower than replica(s), and not used when replica(s) is set to 1.",
+    [obj.kind, obj.kind, obj.metadata.name, obj.spec.replicas, pdb.metadata.name, pdb.spec.minAvailable],
+    )
+}
+
+# The type of pod controller to validate
+pod_controller_groups_kinds := [
+    {"group": "apps/v1", "kind": "Deployment"},
+    {"group": "apps/v1", "kind": "StatefulSet"},
+    {"group": "apps/v1", "kind": "ReplicaSet"},
+    {"group": "v1", "kind": "ReplicationController"}
+]
path: helmfile.d/charts/gatekeeper/templates/policies/restrict-pod-disruption-budgets.rego
user: login: viktor-f
body: The idea was to ensure that it just uses these pairs of group and kind. This was the easiest way that I could think of to do this, but there are probably other ways.
I could also just have two separate lists of groups and kinds and then let it go through all combinations. I assume that is slightly less efficient, but probably not significantly.

IMO the current version feels nice to read and understand which groups and kinds belong to each other. But I think we should use the version that most people find easiest to read and understand. So I'm ok with changing it if that is what you and others want.
created_at: 2025-03-21T12:36:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,742 @@
+package test.k8srestrictpoddisruptionbudgets
+
+import data.k8srestrictpoddisruptionbudgets
+
+#
+# Help functions
+#
+
+generate_pdb(type, limit, namespace, selector) = obj {
+    obj := {
+        "kind": "PodDisruptionBudget",
+        "metadata": {
+            "name": "pdb-name",
+            "namespace": namespace
+        },
+        "spec": {
+            type: limit,
+            "selector": selector
+        }
+    }
+}
+
+generate_pod_controller(kind, replicas, namespace, labels) = obj {
+    allowed_kinds := [
+        "Deployment",
+        "StatefulSet",
+        "ReplicaSet",
+        "ReplicationController"
+    ]
+    kind == allowed_kinds[_]
+    obj := {
+        "kind": kind,
+        "metadata": {
+            "namespace": namespace,
+            "name": "controller-name"
+        },
+        "spec": {
+            "replicas": replicas,
+            "template": {
+                "metadata": {
+                    "labels": labels
+                }
+            }
+        }
+    }
+}
+
+generate_replicaset_under_deployment(replicas, namespace, labels) = obj {
+    obj := {
+        "kind": "ReplicaSet",
+        "metadata": {
+            "namespace": namespace,
+            "name": "controller-name",
+            "ownerReferences": [
+                {
+                    "kind": "Deployment"
+                }
+            ]
+        },
+        "spec": {
+            "replicas": replicas,
+            "template": {
+                "metadata": {
+                    "labels": labels
+                }
+            }
+        }
+    }
+}
+
+input_wrap(obj) = input {
+    input := {"review": {"object": obj}}
+}
+
+pod_controller_groups_kinds := [
+    {"group": "apps/v1", "kind": "Deployment"},
+    {"group": "apps/v1", "kind": "StatefulSet"},
+    {"group": "apps/v1", "kind": "ReplicaSet"},
+    {"group": "v1", "kind": "ReplicationController"}
+]
+
+#
+# Tests
+#
+
+# --- Test PDB as review object ---
+
+test_ok_pdb_no_pod_maxUnavailable_int {
+    count(k8srestrictpoddisruptionbudgets.violation) == 0 with input as input_wrap(generate_pdb(
+        "maxUnavailable",
+        1,
+        "default",
+        {"matchLabels": {"key": "val"}}
+    ))
+}
+
+test_ok_pdb_no_pod_maxUnavailable_percent {
+    count(k8srestrictpoddisruptionbudgets.violation) == 0 with input as input_wrap(generate_pdb(
+        "maxUnavailable",
+        "10%",
+        "default",
+        {"matchLabels": {"key": "val"}}
+    ))
+}
+
+
+test_bad_pdb_no_pod_maxUnavailable_int {
+    count(k8srestrictpoddisruptionbudgets.violation) == 1 with input as input_wrap(generate_pdb(
+        "maxUnavailable",
+        0,
+        "default",
+        {"matchLabels": {"key": "val"}}
+    ))
+}
+
+test_bad_pdb_no_pod_maxUnavailable_percent {
+    count(k8srestrictpoddisruptionbudgets.violation) == 1 with input as input_wrap(generate_pdb(
+        "maxUnavailable",
+        "0%",
+        "default",
+        {"matchLabels": {"key": "val"}}
+    ))
+}
+
+test_ok_pdb_no_pod_minAvailable_int {
+    count(k8srestrictpoddisruptionbudgets.violation) == 0 with input as input_wrap(generate_pdb(
+        "minAvailable",
+        0,
+        "default",
+        {"matchLabels": {"key": "val"}}
+    ))
+}
+
+test_ok_pdb_no_pod_minAvailable_percent {
+    count(k8srestrictpoddisruptionbudgets.violation) == 0 with input as input_wrap(generate_pdb(
+        "minAvailable",
+        "0%",
+        "default",
+        {"matchLabels": {"key": "val"}}
+    ))
+}
+
+test_ok_pdb_with_pod_minAvailable_int {
+    namespace := "default"
+    selector := {"matchLabels": {"key": "val"}}
+    labels := {"key": "val"}
+    inventories := [inventory | inventory := {"namespace": {namespace: {pod_controller_groups_kinds[i].group: {pod_controller_groups_kinds[i].kind: [generate_pod_controller(
+        pod_controller_groups_kinds[i].kind,
+        3,
+        namespace,
+        labels
+    )]}}}}]
+
+    res = [test | test = count(k8srestrictpoddisruptionbudgets.violation) == 0
+        with input as input_wrap(generate_pdb(
+            "minAvailable",
+            1,
+            namespace,
+            selector
+        )) with data.inventory as inventories[_]
+    ]
+    # TODO consider adding a count so that it is certain how many test cases that are run
+    all(res)
+}
+
+test_ok_pdb_with_pod_minAvailable_percent {
+    namespace := "default"
+    selector := {"matchLabels": {"key": "val"}}
+    labels := {"key": "val"}
+    inventories := [inventory | inventory := {"namespace": {namespace: {pod_controller_groups_kinds[i].group: {pod_controller_groups_kinds[i].kind: [generate_pod_controller(
+        pod_controller_groups_kinds[i].kind,
+        3,
+        namespace,
+        labels
+    )]}}}}]
+
+    res = [test | test = count(k8srestrictpoddisruptionbudgets.violation) == 0
+        with input as input_wrap(generate_pdb(
+            "minAvailable",
+            "10%",
+            namespace,
+            selector
+        )) with data.inventory as inventories[_]
+    ]
+    all(res)
+}
+
+test_bad_pdb_with_pod_minAvailable_int {
+    namespace := "default"
+    selector := {"matchLabels": {"key": "val"}}
+    labels := {"key": "val"}
+    inventories := [inventory | inventory := {"namespace": {namespace: {pod_controller_groups_kinds[i].group: {pod_controller_groups_kinds[i].kind: [generate_pod_controller(
+        pod_controller_groups_kinds[i].kind,
+        3,
+        namespace,
+        labels
+    )]}}}}]
+
+    res = [test | test = count(k8srestrictpoddisruptionbudgets.violation) == 1
+        with input as input_wrap(generate_pdb(
+            "minAvailable",
+            3,
+            namespace,
+            selector
+        )) with data.inventory as inventories[_]
+    ]
+    all(res)
+}
+
+test_bad_pdb_with_pod_minAvailable_percent {
+    namespace := "default"
+    selector := {"matchLabels": {"key": "val"}}
+    labels := {"key": "val"}
+    inventories := [inventory | inventory := {"namespace": {namespace: {pod_controller_groups_kinds[i].group: {pod_controller_groups_kinds[i].kind: [generate_pod_controller(
+        pod_controller_groups_kinds[i].kind,
+        3,
+        namespace,
+        labels
+    )]}}}}]
+
+    res = [test | test = count(k8srestrictpoddisruptionbudgets.violation) == 1
+        with input as input_wrap(generate_pdb(
+            "minAvailable",
+            "90%",
+            namespace,
+            selector
+        )) with data.inventory as inventories[_]
+    ]
+    all(res)
+}
+
+# --- test pod controller as review object ---
+
+test_ok_pod_controller_maxUnavailable_int {
+    namespace := "default"
+    selector := {"matchLabels": {"key": "val"}}
+    labels := {"key": "val"}
+    pod_controllers := [pod_controller | pod_controller = input_wrap(generate_pod_controller(
+        pod_controller_groups_kinds[_].kind,
+        3,
+        namespace,
+        labels
+    ))]
+
+    res = [test | test = count(k8srestrictpoddisruptionbudgets.violation) == 0
+        with input as pod_controllers[_]
+        with data.inventory as {"namespace": {namespace: {"policy/v1": {"PodDisruptionBudget": [generate_pdb(
+            "maxUnavailable",
+            1,
+            namespace,
+            selector
+        )]}}}}
+    ]
+    all(res)
+}
+
+test_ok_pod_controller_maxUnavailable_percent {
+    namespace := "default"
+    selector := {"matchLabels": {"key": "val"}}
+    labels := {"key": "val"}
+    pod_controllers := [pod_controller | pod_controller = input_wrap(generate_pod_controller(
+        pod_controller_groups_kinds[_].kind,
+        3,
+        namespace,
+        labels
+    ))]
+
+    res = [test | test = count(k8srestrictpoddisruptionbudgets.violation) == 0
+        with input as pod_controllers[_]
+        with data.inventory as {"namespace": {namespace: {"policy/v1": {"PodDisruptionBudget": [generate_pdb(
+            "maxUnavailable",
+            "20%",
+            namespace,
+            selector
+        )]}}}}
+    ]
+    all(res)
+}
+
+test_bad_pod_controller_maxUnavailable_int {
+    namespace := "default"
+    selector := {"matchLabels": {"key": "val"}}
+    labels := {"key": "val"}
+    pod_controllers := [pod_controller | pod_controller = input_wrap(generate_pod_controller(
+        pod_controller_groups_kinds[_].kind,
+        3,
+        namespace,
+        labels
+    ))]
+
+    res = [test | test = count(k8srestrictpoddisruptionbudgets.violation) == 1
+        with input as pod_controllers[_]
+        with data.inventory as {"namespace": {namespace: {"policy/v1": {"PodDisruptionBudget": [generate_pdb(
+            "maxUnavailable",
+            0,
+            namespace,
+            selector
+        )]}}}}
+    ]
+    all(res)
+}
+
+test_bad_pod_controller_maxUnavailable_percent {
+    namespace := "default"
+    selector := {"matchLabels": {"key": "val"}}
+    labels := {"key": "val"}
+    pod_controllers := [pod_controller | pod_controller = input_wrap(generate_pod_controller(
+        pod_controller_groups_kinds[_].kind,
+        3,
+        namespace,
+        labels
+    ))]
+
+    res = [test | test = count(k8srestrictpoddisruptionbudgets.violation) == 1
+        with input as pod_controllers[_]
+        with data.inventory as {"namespace": {namespace: {"policy/v1": {"PodDisruptionBudget": [generate_pdb(
+            "maxUnavailable",
+            "0%",
+            namespace,
+            selector
+        )]}}}}
+    ]
+    all(res)
+}
+
+test_ok_pod_controller_minAvailable_int {
+    namespace := "default"
+    selector := {"matchLabels": {"key": "val"}}
+    labels := {"key": "val"}
+    pod_controllers := [pod_controller | pod_controller = input_wrap(generate_pod_controller(
+        pod_controller_groups_kinds[_].kind,
+        3,
+        namespace,
+        labels
+    ))]
+
+    res = [test | test = count(k8srestrictpoddisruptionbudgets.violation) == 0
+        with input as pod_controllers[_]
+        with data.inventory as {"namespace": {namespace: {"policy/v1": {"PodDisruptionBudget": [generate_pdb(
+            "minAvailable",
+            1,
+            namespace,
+            selector
+        )]}}}}
+    ]
+    all(res)
+}
+
+test_ok_pod_controller_minAvailable_percent {
+    namespace := "default"
+    selector := {"matchLabels": {"key": "val"}}
+    labels := {"key": "val"}
+    pod_controllers := [pod_controller | pod_controller = input_wrap(generate_pod_controller(
+        pod_controller_groups_kinds[_].kind,
+        3,
+        namespace,
+        labels
+    ))]
+
+    res = [test | test = count(k8srestrictpoddisruptionbudgets.violation) == 0
+        with input as pod_controllers[_]
+        with data.inventory as {"namespace": {namespace: {"policy/v1": {"PodDisruptionBudget": [generate_pdb(
+            "minAvailable",
+            "20%",
+            namespace,
+            selector
+        )]}}}}
+    ]
+    all(res)
+}
+
+test_bad_pod_controller_minAvailable_int {
+    namespace := "default"
+    selector := {"matchLabels": {"key": "val"}}
+    labels := {"key": "val"}
+    pod_controllers := [pod_controller | pod_controller = input_wrap(generate_pod_controller(
+        pod_controller_groups_kinds[_].kind,
+        3,
+        namespace,
+        labels
+    ))]
+
+    res = [test | test = count(k8srestrictpoddisruptionbudgets.violation) == 1
+        with input as pod_controllers[_]
+        with data.inventory as {"namespace": {namespace: {"policy/v1": {"PodDisruptionBudget": [generate_pdb(
+            "minAvailable",
+            5,
+            namespace,
+            selector
+        )]}}}}
+    ]
+    all(res)
+}
+
+test_bad_pod_controller_minAvailable_percent {
+    namespace := "default"
+    selector := {"matchLabels": {"key": "val"}}
+    labels := {"key": "val"}
+    pod_controllers := [pod_controller | pod_controller = input_wrap(generate_pod_controller(
+        pod_controller_groups_kinds[_].kind,
+        3,
+        namespace,
+        labels
+    ))]
+
+    res = [test | test = count(k8srestrictpoddisruptionbudgets.violation) == 1
+        with input as pod_controllers[_]
+        with data.inventory as {"namespace": {namespace: {"policy/v1": {"PodDisruptionBudget": [generate_pdb(
+            "minAvailable",
+            "90%",
+            namespace,
+            selector
+        )]}}}}
+    ]
+    all(res)
+}
+
+# --- Test replicaset under deployment as review object ---
+
+test_bad_replicaset_under_deployment_maxUnavailable_int {
+    namespace := "default"
+    selector := {"matchLabels": {"key": "val"}}
+    labels := {"key": "val"}
+
+    # The replicaset and PDB does not allow for disruption, but this should not violate since replicaset is under control of a deployment
+    res = [test | test = count(k8srestrictpoddisruptionbudgets.violation) == 0
+        with input as input_wrap(generate_replicaset_under_deployment(
+            3,
+            namespace,
+            labels
+        ))
+        with data.inventory as {"namespace": {namespace: {"policy/v1": {"PodDisruptionBudget": [generate_pdb(
+            "maxUnavailable",
+            0,
+            namespace,
+            selector
+        )]}}}}
+    ]
+    all(res)
+}
+
+test_bad_replicaset_under_deployment_minAvailable_int {
+    namespace := "default"
+    selector := {"matchLabels": {"key": "val"}}
+    labels := {"key": "val"}
+
+    # The replicaset and PDB does not allow for disruption, but this should not violate since replicaset is under control of a deployment
+    res = [test | test = count(k8srestrictpoddisruptionbudgets.violation) == 0
+        with input as input_wrap(generate_replicaset_under_deployment(
+            3,
+            namespace,
+            labels
+        ))
+        with data.inventory as {"namespace": {namespace: {"policy/v1": {"PodDisruptionBudget": [generate_pdb(
+            "minAvailable",
+            3,
+            namespace,
+            selector
+        )]}}}}
+    ]
+    all(res)
+}
+
+# --- Test different selectors ---
+
+test_matching_match_labels {
+	namespace := "default"
+    selector := {"matchLabels": {"key": "val"}}
+    labels := {"key": "val"}
+
+    count(k8srestrictpoddisruptionbudgets.violation) == 1
+        with input as input_wrap(generate_pod_controller(
+            "Deployment",
+			3,
path: helmfile.d/charts/gatekeeper/templates/policies/tests/restrict-pod-disruption-budgets.rego
user: login: viktor-f
body: Thanks, the issues was that I had tabs instead of spaces here.

For some reason my vscode sometimes wants to use tabs instead of spaces for the rego files. I have tried to fix it, but not quite managed yet.
created_at: 2025-03-21T12:39:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,181 @@
+package k8srestrictpoddisruptionbudgets
+
+# Reject PDB if maxUnavailable does not allow at least 1 pod disruption
+violation[{"msg": msg}] {
+    input.review.object.kind == "PodDisruptionBudget"
+    pdb := input.review.object
+
+    pdb.spec.maxUnavailable
+
+    not_valid_pdb_max_unavailable(pdb)
+    # TODO update messages with link to public docs
+    msg := sprintf(
+    "PodDisruptionBudget rejected: PodDisruptionBudget <%v> has maxUnavailable of %v, only positive integers or percentages are allowed for maxUnavailable",
+    [pdb.metadata.name, pdb.spec.maxUnavailable],
+    )
+}
+
+# Reject PDB if minAvailable does not allow at least 1 pod disruption
+violation[{"msg": msg}] {
+    input.review.object.kind == "PodDisruptionBudget"
+    pdb := input.review.object
+
+    pdb.spec.minAvailable
+
+    pod_controller_group_kind := pod_controller_groups_kinds[_]
+    objs := [controllers | controllers := data.inventory.namespace[pdb.metadata.namespace][pod_controller_group_kind.group][pod_controller_group_kind.kind]]
+    obj := objs[_][_]
+
+    not mismatched_selector(pdb, obj)
+
+    not_valid_pdb_min_available(obj, pdb)
+    not replica_set_under_deployment(obj)
+
+    msg := sprintf(
+    "PodDisruptionBudget rejected: %v <%v> has %v replica(s) but PodDisruptionBudget <%v> has minAvailable of %v, minAvailable should always be lower than replica(s), and not used when replica(s) is set to 1.",
+    [obj.kind, obj.metadata.name, obj.spec.replicas, pdb.metadata.name, pdb.spec.minAvailable],
+    )
+}
+
+# Reject pod controller if connected PDBs maxUnavailable does not allow at least 1 pod disruption
+violation[{"msg": msg}] {
+    input.review.object.kind == pod_controller_groups_kinds[_].kind
+    obj := input.review.object
+    not replica_set_under_deployment(obj)
+
+    pdb := data.inventory.namespace[obj.metadata.namespace]["policy/v1"].PodDisruptionBudget[_]
+
+    pdb.spec.maxUnavailable
+
+    not mismatched_selector(pdb, obj)
+
+    not_valid_pdb_max_unavailable(pdb)
+    msg := sprintf(
+    "%v rejected: %v <%v> has been selected by PodDisruptionBudget <%v> but has maxUnavailable of %v, only positive integers or percentages are allowed for maxUnavailable",
+    [obj.kind, obj.kind, obj.metadata.name, pdb.metadata.name, pdb.spec.maxUnavailable],
+    )
+}
+
+# Reject pod controller if connected PDBs minAvailable does not allow at least 1 pod disruption
+violation[{"msg": msg}] {
+    input.review.object.kind == pod_controller_groups_kinds[_].kind
+    obj := input.review.object
+    not replica_set_under_deployment(obj)
+
+    pdb := data.inventory.namespace[obj.metadata.namespace]["policy/v1"].PodDisruptionBudget[_]
+
+    pdb.spec.minAvailable
+
+    not mismatched_selector(pdb, obj)
+
+    not_valid_pdb_min_available(obj, pdb)
+    msg := sprintf(
+    "%v rejected: %v <%v> has %v replica(s) but PodDisruptionBudget <%v> has minAvailable of %v, minAvailable should always be lower than replica(s), and not used when replica(s) is set to 1.",
+    [obj.kind, obj.kind, obj.metadata.name, obj.spec.replicas, pdb.metadata.name, pdb.spec.minAvailable],
+    )
+}
+
+# The type of pod controller to validate
+pod_controller_groups_kinds := [
+    {"group": "apps/v1", "kind": "Deployment"},
+    {"group": "apps/v1", "kind": "StatefulSet"},
+    {"group": "apps/v1", "kind": "ReplicaSet"},
+    {"group": "v1", "kind": "ReplicationController"}
+]
path: helmfile.d/charts/gatekeeper/templates/policies/restrict-pod-disruption-budgets.rego
user: login: davidumea
body: Okay, I would be fine with leaving it but I don't think it reads super well when referencing this `pod_controller_group_kind.group`
created_at: 2025-03-21T13:29:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,181 @@
+package k8srestrictpoddisruptionbudgets
+
+# Reject PDB if maxUnavailable does not allow at least 1 pod disruption
+violation[{"msg": msg}] {
+    input.review.object.kind == "PodDisruptionBudget"
+    pdb := input.review.object
+
+    pdb.spec.maxUnavailable
+
+    not_valid_pdb_max_unavailable(pdb)
+    # TODO update messages with link to public docs
path: helmfile.d/charts/gatekeeper/templates/policies/restrict-pod-disruption-budgets.rego
user: login: davidumea
body: ```suggestion
```
created_at: 2025-03-24T08:25:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,742 @@
+package test.k8srestrictpoddisruptionbudgets
+
+import data.k8srestrictpoddisruptionbudgets
+
+#
+# Help functions
+#
+
+generate_pdb(type, limit, namespace, selector) = obj {
+    obj := {
+        "kind": "PodDisruptionBudget",
+        "metadata": {
+            "name": "pdb-name",
+            "namespace": namespace
+        },
+        "spec": {
+            type: limit,
+            "selector": selector
+        }
+    }
+}
+
+generate_pod_controller(kind, replicas, namespace, labels) = obj {
+    allowed_kinds := [
+        "Deployment",
+        "StatefulSet",
+        "ReplicaSet",
+        "ReplicationController"
+    ]
+    kind == allowed_kinds[_]
+    obj := {
+        "kind": kind,
+        "metadata": {
+            "namespace": namespace,
+            "name": "controller-name"
+        },
+        "spec": {
+            "replicas": replicas,
+            "template": {
+                "metadata": {
+                    "labels": labels
+                }
+            }
+        }
+    }
+}
+
+generate_replicaset_under_deployment(replicas, namespace, labels) = obj {
+    obj := {
+        "kind": "ReplicaSet",
+        "metadata": {
+            "namespace": namespace,
+            "name": "controller-name",
+            "ownerReferences": [
+                {
+                    "kind": "Deployment"
+                }
+            ]
+        },
+        "spec": {
+            "replicas": replicas,
+            "template": {
+                "metadata": {
+                    "labels": labels
+                }
+            }
+        }
+    }
+}
+
+input_wrap(obj) = input {
+    input := {"review": {"object": obj}}
+}
+
+pod_controller_groups_kinds := [
+    {"group": "apps/v1", "kind": "Deployment"},
+    {"group": "apps/v1", "kind": "StatefulSet"},
+    {"group": "apps/v1", "kind": "ReplicaSet"},
+    {"group": "v1", "kind": "ReplicationController"}
+]
+
+#
+# Tests
+#
+
+# --- Test PDB as review object ---
+
+test_ok_pdb_no_pod_maxUnavailable_int {
+    count(k8srestrictpoddisruptionbudgets.violation) == 0 with input as input_wrap(generate_pdb(
+        "maxUnavailable",
+        1,
+        "default",
+        {"matchLabels": {"key": "val"}}
+    ))
+}
+
+test_ok_pdb_no_pod_maxUnavailable_percent {
+    count(k8srestrictpoddisruptionbudgets.violation) == 0 with input as input_wrap(generate_pdb(
+        "maxUnavailable",
+        "10%",
+        "default",
+        {"matchLabels": {"key": "val"}}
+    ))
+}
+
+
+test_bad_pdb_no_pod_maxUnavailable_int {
+    count(k8srestrictpoddisruptionbudgets.violation) == 1 with input as input_wrap(generate_pdb(
+        "maxUnavailable",
+        0,
+        "default",
+        {"matchLabels": {"key": "val"}}
+    ))
+}
+
+test_bad_pdb_no_pod_maxUnavailable_percent {
+    count(k8srestrictpoddisruptionbudgets.violation) == 1 with input as input_wrap(generate_pdb(
+        "maxUnavailable",
+        "0%",
+        "default",
+        {"matchLabels": {"key": "val"}}
+    ))
+}
+
+test_ok_pdb_no_pod_minAvailable_int {
+    count(k8srestrictpoddisruptionbudgets.violation) == 0 with input as input_wrap(generate_pdb(
+        "minAvailable",
+        0,
+        "default",
+        {"matchLabels": {"key": "val"}}
+    ))
+}
+
+test_ok_pdb_no_pod_minAvailable_percent {
+    count(k8srestrictpoddisruptionbudgets.violation) == 0 with input as input_wrap(generate_pdb(
+        "minAvailable",
+        "0%",
+        "default",
+        {"matchLabels": {"key": "val"}}
+    ))
+}
+
+test_ok_pdb_with_pod_minAvailable_int {
+    namespace := "default"
+    selector := {"matchLabels": {"key": "val"}}
+    labels := {"key": "val"}
+    inventories := [inventory | inventory := {"namespace": {namespace: {pod_controller_groups_kinds[i].group: {pod_controller_groups_kinds[i].kind: [generate_pod_controller(
+        pod_controller_groups_kinds[i].kind,
+        3,
+        namespace,
+        labels
+    )]}}}}]
+
+    res = [test | test = count(k8srestrictpoddisruptionbudgets.violation) == 0
+        with input as input_wrap(generate_pdb(
+            "minAvailable",
+            1,
+            namespace,
+            selector
+        )) with data.inventory as inventories[_]
+    ]
+    # TODO consider adding a count so that it is certain how many test cases that are run
path: helmfile.d/charts/gatekeeper/templates/policies/tests/restrict-pod-disruption-budgets.rego
user: login: davidumea
body: Was this TODO for future improvements or was it something you wrote for yourself here?
created_at: 2025-03-24T08:26:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,742 @@
+package test.k8srestrictpoddisruptionbudgets
+
+import data.k8srestrictpoddisruptionbudgets
+
+#
+# Help functions
+#
+
+generate_pdb(type, limit, namespace, selector) = obj {
+    obj := {
+        "kind": "PodDisruptionBudget",
+        "metadata": {
+            "name": "pdb-name",
+            "namespace": namespace
+        },
+        "spec": {
+            type: limit,
+            "selector": selector
+        }
+    }
+}
+
+generate_pod_controller(kind, replicas, namespace, labels) = obj {
+    allowed_kinds := [
+        "Deployment",
+        "StatefulSet",
+        "ReplicaSet",
+        "ReplicationController"
+    ]
+    kind == allowed_kinds[_]
+    obj := {
+        "kind": kind,
+        "metadata": {
+            "namespace": namespace,
+            "name": "controller-name"
+        },
+        "spec": {
+            "replicas": replicas,
+            "template": {
+                "metadata": {
+                    "labels": labels
+                }
+            }
+        }
+    }
+}
+
+generate_replicaset_under_deployment(replicas, namespace, labels) = obj {
+    obj := {
+        "kind": "ReplicaSet",
+        "metadata": {
+            "namespace": namespace,
+            "name": "controller-name",
+            "ownerReferences": [
+                {
+                    "kind": "Deployment"
+                }
+            ]
+        },
+        "spec": {
+            "replicas": replicas,
+            "template": {
+                "metadata": {
+                    "labels": labels
+                }
+            }
+        }
+    }
+}
+
+input_wrap(obj) = input {
+    input := {"review": {"object": obj}}
+}
+
+pod_controller_groups_kinds := [
+    {"group": "apps/v1", "kind": "Deployment"},
+    {"group": "apps/v1", "kind": "StatefulSet"},
+    {"group": "apps/v1", "kind": "ReplicaSet"},
+    {"group": "v1", "kind": "ReplicationController"}
+]
+
+#
+# Tests
+#
+
+# --- Test PDB as review object ---
+
+test_ok_pdb_no_pod_maxUnavailable_int {
+    count(k8srestrictpoddisruptionbudgets.violation) == 0 with input as input_wrap(generate_pdb(
+        "maxUnavailable",
+        1,
+        "default",
+        {"matchLabels": {"key": "val"}}
+    ))
+}
+
+test_ok_pdb_no_pod_maxUnavailable_percent {
+    count(k8srestrictpoddisruptionbudgets.violation) == 0 with input as input_wrap(generate_pdb(
+        "maxUnavailable",
+        "10%",
+        "default",
+        {"matchLabels": {"key": "val"}}
+    ))
+}
+
+
+test_bad_pdb_no_pod_maxUnavailable_int {
+    count(k8srestrictpoddisruptionbudgets.violation) == 1 with input as input_wrap(generate_pdb(
+        "maxUnavailable",
+        0,
+        "default",
+        {"matchLabels": {"key": "val"}}
+    ))
+}
+
+test_bad_pdb_no_pod_maxUnavailable_percent {
+    count(k8srestrictpoddisruptionbudgets.violation) == 1 with input as input_wrap(generate_pdb(
+        "maxUnavailable",
+        "0%",
+        "default",
+        {"matchLabels": {"key": "val"}}
+    ))
+}
+
+test_ok_pdb_no_pod_minAvailable_int {
+    count(k8srestrictpoddisruptionbudgets.violation) == 0 with input as input_wrap(generate_pdb(
+        "minAvailable",
+        0,
+        "default",
+        {"matchLabels": {"key": "val"}}
+    ))
+}
+
+test_ok_pdb_no_pod_minAvailable_percent {
+    count(k8srestrictpoddisruptionbudgets.violation) == 0 with input as input_wrap(generate_pdb(
+        "minAvailable",
+        "0%",
+        "default",
+        {"matchLabels": {"key": "val"}}
+    ))
+}
+
+test_ok_pdb_with_pod_minAvailable_int {
+    namespace := "default"
+    selector := {"matchLabels": {"key": "val"}}
+    labels := {"key": "val"}
+    inventories := [inventory | inventory := {"namespace": {namespace: {pod_controller_groups_kinds[i].group: {pod_controller_groups_kinds[i].kind: [generate_pod_controller(
+        pod_controller_groups_kinds[i].kind,
+        3,
+        namespace,
+        labels
+    )]}}}}]
+
+    res = [test | test = count(k8srestrictpoddisruptionbudgets.violation) == 0
+        with input as input_wrap(generate_pdb(
+            "minAvailable",
+            1,
+            namespace,
+            selector
+        )) with data.inventory as inventories[_]
+    ]
+    # TODO consider adding a count so that it is certain how many test cases that are run
+    all(res)
+}
+
+test_ok_pdb_with_pod_minAvailable_percent {
+    namespace := "default"
+    selector := {"matchLabels": {"key": "val"}}
+    labels := {"key": "val"}
+    inventories := [inventory | inventory := {"namespace": {namespace: {pod_controller_groups_kinds[i].group: {pod_controller_groups_kinds[i].kind: [generate_pod_controller(
+        pod_controller_groups_kinds[i].kind,
+        3,
+        namespace,
+        labels
+    )]}}}}]
+
+    res = [test | test = count(k8srestrictpoddisruptionbudgets.violation) == 0
+        with input as input_wrap(generate_pdb(
+            "minAvailable",
+            "10%",
+            namespace,
+            selector
+        )) with data.inventory as inventories[_]
+    ]
+    all(res)
+}
+
+test_bad_pdb_with_pod_minAvailable_int {
+    namespace := "default"
+    selector := {"matchLabels": {"key": "val"}}
+    labels := {"key": "val"}
+    inventories := [inventory | inventory := {"namespace": {namespace: {pod_controller_groups_kinds[i].group: {pod_controller_groups_kinds[i].kind: [generate_pod_controller(
+        pod_controller_groups_kinds[i].kind,
+        3,
+        namespace,
+        labels
+    )]}}}}]
+
+    res = [test | test = count(k8srestrictpoddisruptionbudgets.violation) == 1
+        with input as input_wrap(generate_pdb(
+            "minAvailable",
+            3,
+            namespace,
+            selector
+        )) with data.inventory as inventories[_]
+    ]
+    all(res)
+}
+
+test_bad_pdb_with_pod_minAvailable_percent {
+    namespace := "default"
+    selector := {"matchLabels": {"key": "val"}}
+    labels := {"key": "val"}
+    inventories := [inventory | inventory := {"namespace": {namespace: {pod_controller_groups_kinds[i].group: {pod_controller_groups_kinds[i].kind: [generate_pod_controller(
+        pod_controller_groups_kinds[i].kind,
+        3,
+        namespace,
+        labels
+    )]}}}}]
+
+    res = [test | test = count(k8srestrictpoddisruptionbudgets.violation) == 1
+        with input as input_wrap(generate_pdb(
+            "minAvailable",
+            "90%",
+            namespace,
+            selector
+        )) with data.inventory as inventories[_]
+    ]
+    all(res)
+}
+
+# --- test pod controller as review object ---
+
+test_ok_pod_controller_maxUnavailable_int {
+    namespace := "default"
+    selector := {"matchLabels": {"key": "val"}}
+    labels := {"key": "val"}
+    pod_controllers := [pod_controller | pod_controller = input_wrap(generate_pod_controller(
+        pod_controller_groups_kinds[_].kind,
+        3,
+        namespace,
+        labels
+    ))]
+
+    res = [test | test = count(k8srestrictpoddisruptionbudgets.violation) == 0
+        with input as pod_controllers[_]
+        with data.inventory as {"namespace": {namespace: {"policy/v1": {"PodDisruptionBudget": [generate_pdb(
+            "maxUnavailable",
+            1,
+            namespace,
+            selector
+        )]}}}}
+    ]
+    all(res)
+}
+
+test_ok_pod_controller_maxUnavailable_percent {
+    namespace := "default"
+    selector := {"matchLabels": {"key": "val"}}
+    labels := {"key": "val"}
+    pod_controllers := [pod_controller | pod_controller = input_wrap(generate_pod_controller(
+        pod_controller_groups_kinds[_].kind,
+        3,
+        namespace,
+        labels
+    ))]
+
+    res = [test | test = count(k8srestrictpoddisruptionbudgets.violation) == 0
+        with input as pod_controllers[_]
+        with data.inventory as {"namespace": {namespace: {"policy/v1": {"PodDisruptionBudget": [generate_pdb(
+            "maxUnavailable",
+            "20%",
+            namespace,
+            selector
+        )]}}}}
+    ]
+    all(res)
+}
+
+test_bad_pod_controller_maxUnavailable_int {
+    namespace := "default"
+    selector := {"matchLabels": {"key": "val"}}
+    labels := {"key": "val"}
+    pod_controllers := [pod_controller | pod_controller = input_wrap(generate_pod_controller(
+        pod_controller_groups_kinds[_].kind,
+        3,
+        namespace,
+        labels
+    ))]
+
+    res = [test | test = count(k8srestrictpoddisruptionbudgets.violation) == 1
+        with input as pod_controllers[_]
+        with data.inventory as {"namespace": {namespace: {"policy/v1": {"PodDisruptionBudget": [generate_pdb(
+            "maxUnavailable",
+            0,
+            namespace,
+            selector
+        )]}}}}
+    ]
+    all(res)
+}
+
+test_bad_pod_controller_maxUnavailable_percent {
+    namespace := "default"
+    selector := {"matchLabels": {"key": "val"}}
+    labels := {"key": "val"}
+    pod_controllers := [pod_controller | pod_controller = input_wrap(generate_pod_controller(
+        pod_controller_groups_kinds[_].kind,
+        3,
+        namespace,
+        labels
+    ))]
+
+    res = [test | test = count(k8srestrictpoddisruptionbudgets.violation) == 1
+        with input as pod_controllers[_]
+        with data.inventory as {"namespace": {namespace: {"policy/v1": {"PodDisruptionBudget": [generate_pdb(
+            "maxUnavailable",
+            "0%",
+            namespace,
+            selector
+        )]}}}}
+    ]
+    all(res)
+}
+
+test_ok_pod_controller_minAvailable_int {
+    namespace := "default"
+    selector := {"matchLabels": {"key": "val"}}
+    labels := {"key": "val"}
+    pod_controllers := [pod_controller | pod_controller = input_wrap(generate_pod_controller(
+        pod_controller_groups_kinds[_].kind,
+        3,
+        namespace,
+        labels
+    ))]
+
+    res = [test | test = count(k8srestrictpoddisruptionbudgets.violation) == 0
+        with input as pod_controllers[_]
+        with data.inventory as {"namespace": {namespace: {"policy/v1": {"PodDisruptionBudget": [generate_pdb(
+            "minAvailable",
+            1,
+            namespace,
+            selector
+        )]}}}}
+    ]
+    all(res)
+}
+
+test_ok_pod_controller_minAvailable_percent {
+    namespace := "default"
+    selector := {"matchLabels": {"key": "val"}}
+    labels := {"key": "val"}
+    pod_controllers := [pod_controller | pod_controller = input_wrap(generate_pod_controller(
+        pod_controller_groups_kinds[_].kind,
+        3,
+        namespace,
+        labels
+    ))]
+
+    res = [test | test = count(k8srestrictpoddisruptionbudgets.violation) == 0
+        with input as pod_controllers[_]
+        with data.inventory as {"namespace": {namespace: {"policy/v1": {"PodDisruptionBudget": [generate_pdb(
+            "minAvailable",
+            "20%",
+            namespace,
+            selector
+        )]}}}}
+    ]
+    all(res)
+}
+
+test_bad_pod_controller_minAvailable_int {
+    namespace := "default"
+    selector := {"matchLabels": {"key": "val"}}
+    labels := {"key": "val"}
+    pod_controllers := [pod_controller | pod_controller = input_wrap(generate_pod_controller(
+        pod_controller_groups_kinds[_].kind,
+        3,
+        namespace,
+        labels
+    ))]
+
+    res = [test | test = count(k8srestrictpoddisruptionbudgets.violation) == 1
+        with input as pod_controllers[_]
+        with data.inventory as {"namespace": {namespace: {"policy/v1": {"PodDisruptionBudget": [generate_pdb(
+            "minAvailable",
+            5,
+            namespace,
+            selector
+        )]}}}}
+    ]
+    all(res)
+}
+
+test_bad_pod_controller_minAvailable_percent {
+    namespace := "default"
+    selector := {"matchLabels": {"key": "val"}}
+    labels := {"key": "val"}
+    pod_controllers := [pod_controller | pod_controller = input_wrap(generate_pod_controller(
+        pod_controller_groups_kinds[_].kind,
+        3,
+        namespace,
+        labels
+    ))]
+
+    res = [test | test = count(k8srestrictpoddisruptionbudgets.violation) == 1
+        with input as pod_controllers[_]
+        with data.inventory as {"namespace": {namespace: {"policy/v1": {"PodDisruptionBudget": [generate_pdb(
+            "minAvailable",
+            "90%",
+            namespace,
+            selector
+        )]}}}}
+    ]
+    all(res)
+}
+
+# --- Test replicaset under deployment as review object ---
+
+test_bad_replicaset_under_deployment_maxUnavailable_int {
+    namespace := "default"
+    selector := {"matchLabels": {"key": "val"}}
+    labels := {"key": "val"}
+
+    # The replicaset and PDB does not allow for disruption, but this should not violate since replicaset is under control of a deployment
+    res = [test | test = count(k8srestrictpoddisruptionbudgets.violation) == 0
+        with input as input_wrap(generate_replicaset_under_deployment(
+            3,
+            namespace,
+            labels
+        ))
+        with data.inventory as {"namespace": {namespace: {"policy/v1": {"PodDisruptionBudget": [generate_pdb(
+            "maxUnavailable",
+            0,
+            namespace,
+            selector
+        )]}}}}
+    ]
+    all(res)
+}
+
+test_bad_replicaset_under_deployment_minAvailable_int {
+    namespace := "default"
+    selector := {"matchLabels": {"key": "val"}}
+    labels := {"key": "val"}
+
+    # The replicaset and PDB does not allow for disruption, but this should not violate since replicaset is under control of a deployment
+    res = [test | test = count(k8srestrictpoddisruptionbudgets.violation) == 0
+        with input as input_wrap(generate_replicaset_under_deployment(
+            3,
+            namespace,
+            labels
+        ))
+        with data.inventory as {"namespace": {namespace: {"policy/v1": {"PodDisruptionBudget": [generate_pdb(
+            "minAvailable",
+            3,
+            namespace,
+            selector
+        )]}}}}
+    ]
+    all(res)
+}
+
+# --- Test different selectors ---
+
+test_matching_match_labels {
+    namespace := "default"
+    selector := {"matchLabels": {"key": "val"}}
+    labels := {"key": "val"}
+
+    count(k8srestrictpoddisruptionbudgets.violation) == 1
+        with input as input_wrap(generate_pod_controller(
+            "Deployment",
+            3,
+            namespace,
+            labels
+        ))
+        with data.inventory as {"namespace": {namespace: {"policy/v1": {"PodDisruptionBudget": [generate_pdb(
+            "minAvailable",
+            3,
+            namespace,
+            selector
+    )]}}}}
+}
+
+test_matching_match_labels {
+    namespace := "default"
+    selector := {"matchLabels": {"key": "val", "key2": "val2"}}
+    labels := {"key": "val", "key2": "val2"}
+
+    count(k8srestrictpoddisruptionbudgets.violation) == 1
+        with input as input_wrap(generate_pod_controller(
+            "Deployment",
+            3,
+            namespace,
+            labels
+        ))
+        with data.inventory as {"namespace": {namespace: {"policy/v1": {"PodDisruptionBudget": [generate_pdb(
+            "minAvailable",
+            3,
+            namespace,
+            selector
+    )]}}}}
+}
+
+test_mismatching_match_labels_wrong_key {
+    namespace := "default"
+    selector := {"matchLabels": {"key": "val", "wrong_key": "val2"}}
+    labels := {"key": "val", "key2": "val2"}
+
+    count(k8srestrictpoddisruptionbudgets.violation) == 0
+        with input as input_wrap(generate_pod_controller(
+            "Deployment",
+            3,
+            namespace,
+            labels
+        ))
+        with data.inventory as {"namespace": {namespace: {"policy/v1": {"PodDisruptionBudget": [generate_pdb(
+            "minAvailable",
+            3,
+            namespace,
+            selector
+    )]}}}}
+}
+
+test_mismatching_match_labels_wrong_val {
+    namespace := "default"
+    selector := {"matchLabels": {"key": "val", "key2": "wrong_val"}}
+    labels := {"key": "val", "key2": "val2"}
+
+    count(k8srestrictpoddisruptionbudgets.violation) == 0
+        with input as input_wrap(generate_pod_controller(
+            "Deployment",
+            3,
+            namespace,
+            labels
+        ))
+        with data.inventory as {"namespace": {namespace: {"policy/v1": {"PodDisruptionBudget": [generate_pdb(
+            "minAvailable",
+            3,
+            namespace,
+            selector
+    )]}}}}
+}
+
+test_matching_match_expression_exists {
+    namespace := "default"
+    selector := {"matchExpression": [{"key": "key", "operator": "Exists"}]}
+    labels := {"key": "val", "key2": "val2"}
+
+    count(k8srestrictpoddisruptionbudgets.violation) == 1
+        with input as input_wrap(generate_pod_controller(
+            "Deployment",
+            3,
+            namespace,
+            labels
+        ))
+        with data.inventory as {"namespace": {namespace: {"policy/v1": {"PodDisruptionBudget": [generate_pdb(
+            "minAvailable",
+            3,
+            namespace,
+            selector
+    )]}}}}
+}
+
+test_mismatching_match_expression_exists {
+    namespace := "default"
+    selector := {"matchExpressions": [{"key": "wrong_key", "operator": "Exists"}]}
+    labels := {"key": "val", "key2": "val2"}
+
+    count(k8srestrictpoddisruptionbudgets.violation) == 0
+        with input as input_wrap(generate_pod_controller(
+            "Deployment",
+            3,
+            namespace,
+            labels
+        ))
+        with data.inventory as {"namespace": {namespace: {"policy/v1": {"PodDisruptionBudget": [generate_pdb(
+            "minAvailable",
+            3,
+            namespace,
+            selector
+    )]}}}}
+}
+
+test_matching_match_expression_does_not_exist {
+    namespace := "default"
+    selector := {"matchExpression": [{"key": "other_key", "operator": "DoesNotExist"}]}
+    labels := {"key": "val", "key2": "val2"}
+
+    count(k8srestrictpoddisruptionbudgets.violation) == 1
+        with input as input_wrap(generate_pod_controller(
+            "Deployment",
+            3,
+            namespace,
+            labels
+        ))
+        with data.inventory as {"namespace": {namespace: {"policy/v1": {"PodDisruptionBudget": [generate_pdb(
+            "minAvailable",
+            3,
+            namespace,
+            selector
+    )]}}}}
+}
+
+test_mismatching_match_expression_does_not_exist {
+    namespace := "default"
+    selector := {"matchExpressions": [{"key": "key", "operator": "DoesNotExist"}]}
+    labels := {"key": "val", "key2": "val2"}
+
+    count(k8srestrictpoddisruptionbudgets.violation) == 0
+        with input as input_wrap(generate_pod_controller(
+            "Deployment",
+            3,
+            namespace,
+            labels
+        ))
+        with data.inventory as {"namespace": {namespace: {"policy/v1": {"PodDisruptionBudget": [generate_pdb(
+            "minAvailable",
+            3,
+            namespace,
+            selector
+    )]}}}}
+}
+
+test_matching_match_expression_in {
+    namespace := "default"
+    selector := {"matchExpressions": [{"key": "key", "operator": "In", "values": ["val"]}]}
+    labels := {"key": "val", "key2": "val2"}
+    count(k8srestrictpoddisruptionbudgets.violation) == 1
+        with input as input_wrap(generate_pod_controller(
+            "Deployment",
+            3,
+            namespace,
+            labels
+        ))
+        with data.inventory as {"namespace": {namespace: {"policy/v1": {"PodDisruptionBudget": [generate_pdb(
+            "minAvailable",
+            3,
+            namespace,
+            selector
+    )]}}}}
+}
+
+test_mismatching_match_expression_in_wrong_key {
+    namespace := "default"
+    selector := {"matchExpressions": [{"key": "wrong_key", "operator": "In", "values": ["val"]}]}
+    labels := {"key": "val", "key2": "val2"}
+
+    count(k8srestrictpoddisruptionbudgets.violation) == 0
+        with input as input_wrap(generate_pod_controller(
+            "Deployment",
+            3,
+            namespace,
+            labels
+        ))
+        with data.inventory as {"namespace": {namespace: {"policy/v1": {"PodDisruptionBudget": [generate_pdb(
+            "minAvailable",
+            3,
+            namespace,
+            selector
+    )]}}}}
+}
+
+test_mismatching_match_expression_in_wrong_val {
+    namespace := "default"
+    selector := {"matchExpressions": [{"key": "wrong_key", "operator": "In", "values": ["wrong_val"]}]}
+    labels := {"key": "val", "key2": "val2"}
+
+    count(k8srestrictpoddisruptionbudgets.violation) == 0
+        with input as input_wrap(generate_pod_controller(
+            "Deployment",
+            3,
+            namespace,
+            labels
+        ))
+        with data.inventory as {"namespace": {namespace: {"policy/v1": {"PodDisruptionBudget": [generate_pdb(
+            "minAvailable",
+            3,
+            namespace,
+            selector
+    )]}}}}
+}
+
+test_matching_match_expression_not_in_other_key {
+    namespace := "default"
+    selector := {"matchExpressions": [{"key": "other_key", "operator": "NotIn", "values": ["val"]}]}
+    labels := {"key": "val", "key2": "val2"}
+    count(k8srestrictpoddisruptionbudgets.violation) == 1
+        with input as input_wrap(generate_pod_controller(
+            "Deployment",
+            3,
+            namespace,
+            labels
+        ))
+        with data.inventory as {"namespace": {namespace: {"policy/v1": {"PodDisruptionBudget": [generate_pdb(
+            "minAvailable",
+            3,
+            namespace,
+            selector
+    )]}}}}
+}
+
+
path: helmfile.d/charts/gatekeeper/templates/policies/tests/restrict-pod-disruption-budgets.rego
user: login: davidumea
body: nit
```suggestion
```
created_at: 2025-03-24T08:26:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,742 @@
+package test.k8srestrictpoddisruptionbudgets
+
+import data.k8srestrictpoddisruptionbudgets
+
+#
+# Help functions
+#
+
+generate_pdb(type, limit, namespace, selector) = obj {
+    obj := {
+        "kind": "PodDisruptionBudget",
+        "metadata": {
+            "name": "pdb-name",
+            "namespace": namespace
+        },
+        "spec": {
+            type: limit,
+            "selector": selector
+        }
+    }
+}
+
+generate_pod_controller(kind, replicas, namespace, labels) = obj {
+    allowed_kinds := [
+        "Deployment",
+        "StatefulSet",
+        "ReplicaSet",
+        "ReplicationController"
+    ]
+    kind == allowed_kinds[_]
+    obj := {
+        "kind": kind,
+        "metadata": {
+            "namespace": namespace,
+            "name": "controller-name"
+        },
+        "spec": {
+            "replicas": replicas,
+            "template": {
+                "metadata": {
+                    "labels": labels
+                }
+            }
+        }
+    }
+}
+
+generate_replicaset_under_deployment(replicas, namespace, labels) = obj {
+    obj := {
+        "kind": "ReplicaSet",
+        "metadata": {
+            "namespace": namespace,
+            "name": "controller-name",
+            "ownerReferences": [
+                {
+                    "kind": "Deployment"
+                }
+            ]
+        },
+        "spec": {
+            "replicas": replicas,
+            "template": {
+                "metadata": {
+                    "labels": labels
+                }
+            }
+        }
+    }
+}
+
+input_wrap(obj) = input {
+    input := {"review": {"object": obj}}
+}
+
+pod_controller_groups_kinds := [
+    {"group": "apps/v1", "kind": "Deployment"},
+    {"group": "apps/v1", "kind": "StatefulSet"},
+    {"group": "apps/v1", "kind": "ReplicaSet"},
+    {"group": "v1", "kind": "ReplicationController"}
+]
+
+#
+# Tests
+#
+
+# --- Test PDB as review object ---
+
+test_ok_pdb_no_pod_maxUnavailable_int {
+    count(k8srestrictpoddisruptionbudgets.violation) == 0 with input as input_wrap(generate_pdb(
+        "maxUnavailable",
+        1,
+        "default",
+        {"matchLabels": {"key": "val"}}
+    ))
+}
+
+test_ok_pdb_no_pod_maxUnavailable_percent {
+    count(k8srestrictpoddisruptionbudgets.violation) == 0 with input as input_wrap(generate_pdb(
+        "maxUnavailable",
+        "10%",
+        "default",
+        {"matchLabels": {"key": "val"}}
+    ))
+}
+
+
+test_bad_pdb_no_pod_maxUnavailable_int {
+    count(k8srestrictpoddisruptionbudgets.violation) == 1 with input as input_wrap(generate_pdb(
+        "maxUnavailable",
+        0,
+        "default",
+        {"matchLabels": {"key": "val"}}
+    ))
+}
+
+test_bad_pdb_no_pod_maxUnavailable_percent {
+    count(k8srestrictpoddisruptionbudgets.violation) == 1 with input as input_wrap(generate_pdb(
+        "maxUnavailable",
+        "0%",
+        "default",
+        {"matchLabels": {"key": "val"}}
+    ))
+}
+
+test_ok_pdb_no_pod_minAvailable_int {
+    count(k8srestrictpoddisruptionbudgets.violation) == 0 with input as input_wrap(generate_pdb(
+        "minAvailable",
+        0,
+        "default",
+        {"matchLabels": {"key": "val"}}
+    ))
+}
+
+test_ok_pdb_no_pod_minAvailable_percent {
+    count(k8srestrictpoddisruptionbudgets.violation) == 0 with input as input_wrap(generate_pdb(
+        "minAvailable",
+        "0%",
+        "default",
+        {"matchLabels": {"key": "val"}}
+    ))
+}
+
+test_ok_pdb_with_pod_minAvailable_int {
+    namespace := "default"
+    selector := {"matchLabels": {"key": "val"}}
+    labels := {"key": "val"}
+    inventories := [inventory | inventory := {"namespace": {namespace: {pod_controller_groups_kinds[i].group: {pod_controller_groups_kinds[i].kind: [generate_pod_controller(
+        pod_controller_groups_kinds[i].kind,
+        3,
+        namespace,
+        labels
+    )]}}}}]
+
+    res = [test | test = count(k8srestrictpoddisruptionbudgets.violation) == 0
+        with input as input_wrap(generate_pdb(
+            "minAvailable",
+            1,
+            namespace,
+            selector
+        )) with data.inventory as inventories[_]
+    ]
+    # TODO consider adding a count so that it is certain how many test cases that are run
path: helmfile.d/charts/gatekeeper/templates/policies/tests/restrict-pod-disruption-budgets.rego
user: login: viktor-f
body: Right, forgot about that one.
I was considering doing that now. But I'm not sure if it is needed or not, or even if it makes it easier to read.
Is this something that you would appreciate? It should be rather easy to add.
created_at: 2025-03-24T09:48:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,742 @@
+package test.k8srestrictpoddisruptionbudgets
+
+import data.k8srestrictpoddisruptionbudgets
+
+#
+# Help functions
+#
+
+generate_pdb(type, limit, namespace, selector) = obj {
+    obj := {
+        "kind": "PodDisruptionBudget",
+        "metadata": {
+            "name": "pdb-name",
+            "namespace": namespace
+        },
+        "spec": {
+            type: limit,
+            "selector": selector
+        }
+    }
+}
+
+generate_pod_controller(kind, replicas, namespace, labels) = obj {
+    allowed_kinds := [
+        "Deployment",
+        "StatefulSet",
+        "ReplicaSet",
+        "ReplicationController"
+    ]
+    kind == allowed_kinds[_]
+    obj := {
+        "kind": kind,
+        "metadata": {
+            "namespace": namespace,
+            "name": "controller-name"
+        },
+        "spec": {
+            "replicas": replicas,
+            "template": {
+                "metadata": {
+                    "labels": labels
+                }
+            }
+        }
+    }
+}
+
+generate_replicaset_under_deployment(replicas, namespace, labels) = obj {
+    obj := {
+        "kind": "ReplicaSet",
+        "metadata": {
+            "namespace": namespace,
+            "name": "controller-name",
+            "ownerReferences": [
+                {
+                    "kind": "Deployment"
+                }
+            ]
+        },
+        "spec": {
+            "replicas": replicas,
+            "template": {
+                "metadata": {
+                    "labels": labels
+                }
+            }
+        }
+    }
+}
+
+input_wrap(obj) = input {
+    input := {"review": {"object": obj}}
+}
+
+pod_controller_groups_kinds := [
+    {"group": "apps/v1", "kind": "Deployment"},
+    {"group": "apps/v1", "kind": "StatefulSet"},
+    {"group": "apps/v1", "kind": "ReplicaSet"},
+    {"group": "v1", "kind": "ReplicationController"}
+]
+
+#
+# Tests
+#
+
+# --- Test PDB as review object ---
+
+test_ok_pdb_no_pod_maxUnavailable_int {
+    count(k8srestrictpoddisruptionbudgets.violation) == 0 with input as input_wrap(generate_pdb(
+        "maxUnavailable",
+        1,
+        "default",
+        {"matchLabels": {"key": "val"}}
+    ))
+}
+
+test_ok_pdb_no_pod_maxUnavailable_percent {
+    count(k8srestrictpoddisruptionbudgets.violation) == 0 with input as input_wrap(generate_pdb(
+        "maxUnavailable",
+        "10%",
+        "default",
+        {"matchLabels": {"key": "val"}}
+    ))
+}
+
+
+test_bad_pdb_no_pod_maxUnavailable_int {
+    count(k8srestrictpoddisruptionbudgets.violation) == 1 with input as input_wrap(generate_pdb(
+        "maxUnavailable",
+        0,
+        "default",
+        {"matchLabels": {"key": "val"}}
+    ))
+}
+
+test_bad_pdb_no_pod_maxUnavailable_percent {
+    count(k8srestrictpoddisruptionbudgets.violation) == 1 with input as input_wrap(generate_pdb(
+        "maxUnavailable",
+        "0%",
+        "default",
+        {"matchLabels": {"key": "val"}}
+    ))
+}
+
+test_ok_pdb_no_pod_minAvailable_int {
+    count(k8srestrictpoddisruptionbudgets.violation) == 0 with input as input_wrap(generate_pdb(
+        "minAvailable",
+        0,
+        "default",
+        {"matchLabels": {"key": "val"}}
+    ))
+}
+
+test_ok_pdb_no_pod_minAvailable_percent {
+    count(k8srestrictpoddisruptionbudgets.violation) == 0 with input as input_wrap(generate_pdb(
+        "minAvailable",
+        "0%",
+        "default",
+        {"matchLabels": {"key": "val"}}
+    ))
+}
+
+test_ok_pdb_with_pod_minAvailable_int {
+    namespace := "default"
+    selector := {"matchLabels": {"key": "val"}}
+    labels := {"key": "val"}
+    inventories := [inventory | inventory := {"namespace": {namespace: {pod_controller_groups_kinds[i].group: {pod_controller_groups_kinds[i].kind: [generate_pod_controller(
+        pod_controller_groups_kinds[i].kind,
+        3,
+        namespace,
+        labels
+    )]}}}}]
+
+    res = [test | test = count(k8srestrictpoddisruptionbudgets.violation) == 0
+        with input as input_wrap(generate_pdb(
+            "minAvailable",
+            1,
+            namespace,
+            selector
+        )) with data.inventory as inventories[_]
+    ]
+    # TODO consider adding a count so that it is certain how many test cases that are run
path: helmfile.d/charts/gatekeeper/templates/policies/tests/restrict-pod-disruption-budgets.rego
user: login: davidumea
body: I don't think it's necessary, it should be clear if a test fails either way

Do as you wish :slightly_smiling_face: but please remove the comment
created_at: 2025-03-24T12:27:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,742 @@
+package test.k8srestrictpoddisruptionbudgets
+
+import data.k8srestrictpoddisruptionbudgets
+
+#
+# Help functions
+#
+
+generate_pdb(type, limit, namespace, selector) = obj {
+    obj := {
+        "kind": "PodDisruptionBudget",
+        "metadata": {
+            "name": "pdb-name",
+            "namespace": namespace
+        },
+        "spec": {
+            type: limit,
+            "selector": selector
+        }
+    }
+}
+
+generate_pod_controller(kind, replicas, namespace, labels) = obj {
+    allowed_kinds := [
+        "Deployment",
+        "StatefulSet",
+        "ReplicaSet",
+        "ReplicationController"
+    ]
+    kind == allowed_kinds[_]
+    obj := {
+        "kind": kind,
+        "metadata": {
+            "namespace": namespace,
+            "name": "controller-name"
+        },
+        "spec": {
+            "replicas": replicas,
+            "template": {
+                "metadata": {
+                    "labels": labels
+                }
+            }
+        }
+    }
+}
+
+generate_replicaset_under_deployment(replicas, namespace, labels) = obj {
+    obj := {
+        "kind": "ReplicaSet",
+        "metadata": {
+            "namespace": namespace,
+            "name": "controller-name",
+            "ownerReferences": [
+                {
+                    "kind": "Deployment"
+                }
+            ]
+        },
+        "spec": {
+            "replicas": replicas,
+            "template": {
+                "metadata": {
+                    "labels": labels
+                }
+            }
+        }
+    }
+}
+
+input_wrap(obj) = input {
+    input := {"review": {"object": obj}}
+}
+
+pod_controller_groups_kinds := [
+    {"group": "apps/v1", "kind": "Deployment"},
+    {"group": "apps/v1", "kind": "StatefulSet"},
+    {"group": "apps/v1", "kind": "ReplicaSet"},
+    {"group": "v1", "kind": "ReplicationController"}
+]
+
+#
+# Tests
+#
+
+# --- Test PDB as review object ---
+
+test_ok_pdb_no_pod_maxUnavailable_int {
+    count(k8srestrictpoddisruptionbudgets.violation) == 0 with input as input_wrap(generate_pdb(
+        "maxUnavailable",
+        1,
+        "default",
+        {"matchLabels": {"key": "val"}}
+    ))
+}
+
+test_ok_pdb_no_pod_maxUnavailable_percent {
+    count(k8srestrictpoddisruptionbudgets.violation) == 0 with input as input_wrap(generate_pdb(
+        "maxUnavailable",
+        "10%",
+        "default",
+        {"matchLabels": {"key": "val"}}
+    ))
+}
+
+
+test_bad_pdb_no_pod_maxUnavailable_int {
+    count(k8srestrictpoddisruptionbudgets.violation) == 1 with input as input_wrap(generate_pdb(
+        "maxUnavailable",
+        0,
+        "default",
+        {"matchLabels": {"key": "val"}}
+    ))
+}
+
+test_bad_pdb_no_pod_maxUnavailable_percent {
+    count(k8srestrictpoddisruptionbudgets.violation) == 1 with input as input_wrap(generate_pdb(
+        "maxUnavailable",
+        "0%",
+        "default",
+        {"matchLabels": {"key": "val"}}
+    ))
+}
+
+test_ok_pdb_no_pod_minAvailable_int {
+    count(k8srestrictpoddisruptionbudgets.violation) == 0 with input as input_wrap(generate_pdb(
+        "minAvailable",
+        0,
+        "default",
+        {"matchLabels": {"key": "val"}}
+    ))
+}
+
+test_ok_pdb_no_pod_minAvailable_percent {
+    count(k8srestrictpoddisruptionbudgets.violation) == 0 with input as input_wrap(generate_pdb(
+        "minAvailable",
+        "0%",
+        "default",
+        {"matchLabels": {"key": "val"}}
+    ))
+}
+
+test_ok_pdb_with_pod_minAvailable_int {
+    namespace := "default"
+    selector := {"matchLabels": {"key": "val"}}
+    labels := {"key": "val"}
+    inventories := [inventory | inventory := {"namespace": {namespace: {pod_controller_groups_kinds[i].group: {pod_controller_groups_kinds[i].kind: [generate_pod_controller(
+        pod_controller_groups_kinds[i].kind,
+        3,
+        namespace,
+        labels
+    )]}}}}]
+
+    res = [test | test = count(k8srestrictpoddisruptionbudgets.violation) == 0
+        with input as input_wrap(generate_pdb(
+            "minAvailable",
+            1,
+            namespace,
+            selector
+        )) with data.inventory as inventories[_]
+    ]
+    # TODO consider adding a count so that it is certain how many test cases that are run
path: helmfile.d/charts/gatekeeper/templates/policies/tests/restrict-pod-disruption-budgets.rego
user: login: viktor-f
body: Added now
created_at: 2025-03-25T07:35:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,181 @@
+package k8srestrictpoddisruptionbudgets
+
+# Reject PDB if maxUnavailable does not allow at least 1 pod disruption
+violation[{"msg": msg}] {
+    input.review.object.kind == "PodDisruptionBudget"
+    pdb := input.review.object
+
+    pdb.spec.maxUnavailable
+
+    not_valid_pdb_max_unavailable(pdb)
+    # TODO update messages with link to public docs
+    msg := sprintf(
+    "PodDisruptionBudget rejected: PodDisruptionBudget <%v> has maxUnavailable of %v, only positive integers or percentages are allowed for maxUnavailable",
+    [pdb.metadata.name, pdb.spec.maxUnavailable],
+    )
+}
+
+# Reject PDB if minAvailable does not allow at least 1 pod disruption
+violation[{"msg": msg}] {
+    input.review.object.kind == "PodDisruptionBudget"
+    pdb := input.review.object
+
+    pdb.spec.minAvailable
+
+    pod_controller_group_kind := pod_controller_groups_kinds[_]
+    objs := [controllers | controllers := data.inventory.namespace[pdb.metadata.namespace][pod_controller_group_kind.group][pod_controller_group_kind.kind]]
+    obj := objs[_][_]
+
+    not mismatched_selector(pdb, obj)
+
+    not_valid_pdb_min_available(obj, pdb)
+    not replica_set_under_deployment(obj)
+
+    msg := sprintf(
+    "PodDisruptionBudget rejected: %v <%v> has %v replica(s) but PodDisruptionBudget <%v> has minAvailable of %v, minAvailable should always be lower than replica(s), and not used when replica(s) is set to 1.",
+    [obj.kind, obj.metadata.name, obj.spec.replicas, pdb.metadata.name, pdb.spec.minAvailable],
+    )
+}
+
+# Reject pod controller if connected PDBs maxUnavailable does not allow at least 1 pod disruption
+violation[{"msg": msg}] {
+    input.review.object.kind == pod_controller_groups_kinds[_].kind
+    obj := input.review.object
+    not replica_set_under_deployment(obj)
+
+    pdb := data.inventory.namespace[obj.metadata.namespace]["policy/v1"].PodDisruptionBudget[_]
+
+    pdb.spec.maxUnavailable
+
+    not mismatched_selector(pdb, obj)
+
+    not_valid_pdb_max_unavailable(pdb)
+    msg := sprintf(
+    "%v rejected: %v <%v> has been selected by PodDisruptionBudget <%v> but has maxUnavailable of %v, only positive integers or percentages are allowed for maxUnavailable",
+    [obj.kind, obj.kind, obj.metadata.name, pdb.metadata.name, pdb.spec.maxUnavailable],
+    )
+}
+
+# Reject pod controller if connected PDBs minAvailable does not allow at least 1 pod disruption
+violation[{"msg": msg}] {
+    input.review.object.kind == pod_controller_groups_kinds[_].kind
+    obj := input.review.object
+    not replica_set_under_deployment(obj)
+
+    pdb := data.inventory.namespace[obj.metadata.namespace]["policy/v1"].PodDisruptionBudget[_]
+
+    pdb.spec.minAvailable
+
+    not mismatched_selector(pdb, obj)
+
+    not_valid_pdb_min_available(obj, pdb)
+    msg := sprintf(
+    "%v rejected: %v <%v> has %v replica(s) but PodDisruptionBudget <%v> has minAvailable of %v, minAvailable should always be lower than replica(s), and not used when replica(s) is set to 1.",
+    [obj.kind, obj.kind, obj.metadata.name, obj.spec.replicas, pdb.metadata.name, pdb.spec.minAvailable],
+    )
+}
+
+# The type of pod controller to validate
+pod_controller_groups_kinds := [
+    {"group": "apps/v1", "kind": "Deployment"},
+    {"group": "apps/v1", "kind": "StatefulSet"},
+    {"group": "apps/v1", "kind": "ReplicaSet"},
+    {"group": "v1", "kind": "ReplicationController"}
+]
path: helmfile.d/charts/gatekeeper/templates/policies/restrict-pod-disruption-budgets.rego
user: login: viktor-f
body: I will keep it like this for now then.
created_at: 2025-03-25T10:21:35Z
author_association: CONTRIBUTOR

PR comments:
user: login: davidumea
created_at: 2025-03-19T15:09:23Z
body: What happens if a pdb that violates any of the rules already exists? Will it have to be manually removed?
author_association: CONTRIBUTOR
user: login: viktor-f
created_at: 2025-03-19T15:34:54Z
body: > What happens if a pdb that violates any of the rules already exists? Will it have to be manually removed?

That is a good point. The PDB will be left in place, but you cannot edit it or any deployment in a way that continues to violate these rules. But there should not be any issue to modify the PDB or deployment so that it then is valid. Alternatively remove the PDB and start over.

author_association: CONTRIBUTOR
user: login: davidumea
created_at: 2025-03-20T13:10:02Z
body: > Denying PDBs and matching daemonsets/jobs/cronjobs if the PDB is matching a daemonsets/jobs/cronjobs. This is not a pattern we want to allow.

This was one of the patterns you mentioned that was not included in this PR, was it written correctly? If yes, could you expand?

---

I like that the PR description is very extensive and informative, thanks for that! I think it would be nice if you wrote in text format as well which patterns to not allow were added in PR.

> This adds a new gatekeeper policy that will deny any PodDisruptionBudget and connected Pod controller if the PodDisruptionBudget does not allow at least 1 Pod disruption.

I think this is good information but it doesn't explain what is actually happening under the hood :slightly_smiling_face: 

author_association: CONTRIBUTOR
user: login: viktor-f
created_at: 2025-03-20T15:10:32Z
body: > > Denying PDBs and matching daemonsets/jobs/cronjobs if the PDB is matching a daemonsets/jobs/cronjobs. This is not a pattern we want to allow.
> 
> This was one of the patterns you mentioned that was not included in this PR, was it written correctly? If yes, could you expand?

I think the text is accurate, but maybe not very easy to read. Regardless, the idea is that I think PDBs should not be allowed for daemonsets, cronjobs, and jobs.
The part of the reasoning for this is that the PDBs does not have full functionality on these resources ([on any resource that does not have the scale api](https://kubernetes.io/docs/tasks/run-application/configure-pdb/#arbitrary-controllers-and-selectors)).
For daemonsets it is also odd to have a PDB since the number of pods will change with the number of nodes and the number of nodes might not be controlled by the users that specify the PDB.
For Jobs and cronjobs the purpose is to at some point have the jobs complete, then the number of pods would naturally reduce and you will then eventually violate the PDB.

Does that make sense?

> 
> I like that the PR description is very extensive and informative, thanks for that! I think it would be nice if you wrote in text format as well which patterns to not allow were added in PR.
> 
> > This adds a new gatekeeper policy that will deny any PodDisruptionBudget and connected Pod controller if the PodDisruptionBudget does not allow at least 1 Pod disruption.
> 
> I think this is good information but it doesn't explain what is actually happening under the hood 🙂

Thanks, I will try to clarify this.
author_association: CONTRIBUTOR
user: login: viktor-f
created_at: 2025-03-20T15:27:31Z
body: > Thanks, I will try to clarify this.

I have now updated the PR description with some more details. Please let me know if this explains it well or if I should clarify further.
author_association: CONTRIBUTOR
user: login: viktor-f
created_at: 2025-03-20T16:10:39Z
body: Added a bunch of tests now. I have probably missed some tests, but this should cover most things. I at least have full code coverage:
```console
❯ opa test restrict-pod-disruption-budgets.rego tests/restrict-pod-disruption-budgets.rego -v -c
{
  "files": {
    "restrict-pod-disruption-budgets.rego": {
      ...
      "covered_lines": 98,                                                                                                                                  
      "coverage": 100                                                                                                                                       
    },                                                                                                                                                      
    "tests/restrict-pod-disruption-budgets.rego": {
      ...
      "covered_lines": 257,
      "coverage": 100
    }
  },
  "covered_lines": 355,
  "not_covered_lines": 0,
  "coverage": 100
}
```
author_association: CONTRIBUTOR
user: login: viktor-f
created_at: 2025-03-24T07:28:52Z
body: Public docs PR is now up as well https://github.com/elastisys/welkin/pull/1073
The code in this PR have been updated with links to the new public docs page (will not work until the public docs page is merged).
So I think that should be the last thing needed. Except for any additional comments you reviewers might have.
author_association: CONTRIBUTOR
user: login: viktor-f
created_at: 2025-03-25T10:41:49Z
body: Task for the scenarios that were not covered in this PR: https://github.com/elastisys/welkin-apps/issues/68
author_association: CONTRIBUTOR