Repository: yonahd/kor (go)

Issue:
pdb not linked to deployment check giving false-positives
I have a PodDisruptionBudget using the following selector:
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: app-name
spec:
  maxUnavailable: 1
  selector:
    matchLabels:
      app.kubernetes.io/name: app-name
      app.kubernetes.io/instance: app-instance
      
      
 I also have a deployment with the same selector in the helm manifest:
 apiVersion: apps/v1
kind: Deployment
metadata:
  name: app-name
spec:
  replicas: 2
  selector:
    matchLabels:
      app.kubernetes.io/name: app-name
      app.kubernetes.io/instance: app-instance
      
 
 
 It seems that the tool reports a false-positive that this pdb is not linked to any deployments/statefulsets
 
 

Pull request:
refactor: PDB label selection
<!-- Please read [CONTRIBUTING.md](CONTRIBUTING.md) for additional information on contributing to this repository! -->

<!--
  !!!! README !!!! Please fill this out.

  Please follow conventional commit naming conventions:

  https://www.conventionalcommits.org/en/v1.0.0/#summary
-->

<!-- A short description of what your PR does and what it solves. -->

## What this PR does / why we need it?
This PR changes the behavior of how `kor` maps unused PDBs.
Currently, it validates the PDB label selector against Deployments/StatefulSets direct metadata labels, while it should validate the template labels, as PDBs select pods and through `.metadata.ownerReferences` field finds the respective workload controllers and not the other way around.

For example, the following Deployment is valid, but `kor` will map it as unused, as `.metadata.labels` field is not matched, even if the template labels match:
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: app-name
spec:
  replicas: 2
  selector:
    matchLabels:
      app.kubernetes.io/name: app-name
      app.kubernetes.io/instance: app-instance
  template:
    metadata:
      labels:
        app.kubernetes.io/name: app-name
        app.kubernetes.io/instance: app-instance
```

In addition, added support for empty selectors & arbitrary pods.
- If a PDB has an empty selector, it's now considered as used (matches every pod)
> The behavior for an empty selector differs between the policy/v1beta1 and policy/v1 APIs for PodDisruptionBudgets. For policy/v1beta1 an empty selector matches zero pods, while for policy/v1 an empty selector matches every pod in the namespace.
- If a PDB has an empty selector, but no running pods are found in the namespace, it'll be considered as unused.
- PDBs support arbitrary pods (with no workload controllers), added validation for pods in addition to the Deployment/StatefulSet templates.
> You can use a PDB with pods controlled by another resource, by an "operator", or bare pods

## PR Checklist

- [ ] This PR adds K8s exceptions (false positives)
- [x] This PR adds new code
- [x] This PR includes tests for new/existing code
- [ ] This PR adds docs
<!-- - [ ] This PR does something else -->

## GitHub Issue

Closes #364

<!-- Notes that may be helpful for anyone reviewing this PR -->

## Notes for your reviewers

```
$ kor pdb --show-reason
Unused resources in namespace: "empty"
+---+---------------+----------------+--------------------------------------------------------------+
| # | RESOURCE TYPE | RESOURCE NAME  |                            REASON                            |
+---+---------------+----------------+--------------------------------------------------------------+
| 1 | Pdb           | app-name-empty | Pdb matches every pod (empty selector) but 0 pods run        |
| 2 | Pdb           | app-name2      | Pdb is not referencing any deployments, statefulsets or pods |
+---+---------------+----------------+--------------------------------------------------------------+
```

Based the feature on the official [PDB docs](https://kubernetes.io/docs/tasks/run-application/configure-pdb/).

Patch:
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index a5df3fb..285d958 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -41,6 +41,8 @@ As adding new orphaned resources capabilities requires the addition or modificat
 ├── cmd/kor
 │   └── <resource>s.go
 ├── pkg/kor
+│   ├── <resource>s
+│   │   └── <resource>s.json
 │   ├── all.go
 │   ├── create_test_resources.go
 │   ├── delete.go
@@ -56,6 +58,7 @@ As adding new orphaned resources capabilities requires the addition or modificat
 - `pkg/kor/all.go` - add your new resource to `kor all` command to map all unused resources.
 - `pkg/kor/delete.go` - add a deletion functionality to apply on unused instances of type \<resource>.
 - `pkg/kor/multi.go` - allow finding your new resource in a comma-separated query along other resources.
+- `pkg/kor/exceptions/<resource>s/<resource>s.json` - list default unused instances of type \<resource> to avoid false-positive results.
 - `cmd/kor/<resource>s.go` - add your new functionanilities to `kor` command-line.
 - `charts/kor/templates/role.yaml` - grant get/list/watch permissions to the new resource in a namespaces/cluster-scoped level.
 - `README.md` - introduce your added capabilities to `kor`.
diff --git a/README.md b/README.md
index 31aec8e..9024b1a 100644
--- a/README.md
+++ b/README.md
@@ -180,7 +180,7 @@ kor [subcommand] --help
 | Hpas            | HPAs not used in Deployments<br/> HPAs not used in StatefulSets                                                                                                                                                                   |                                                                                                                                                                       |
 | CRDs            | CRDs not used the cluster                                                                                                                                                                                                         |                                                                                                                                                                       |
 | Pvs             | PVs not bound to a PVC                                                                                                                                                                                                            |                                                                                                                                                                       |
-| Pdbs            | PDBs not used in Deployments<br/> PDBs not used in StatefulSets                                                                                                                                                                   |                                                                                                                                                                       |
+| Pdbs            | PDBs not used in Deployments / StatefulSets (templates) or in arbitrary Pods<br/>PDBs with empty selectors (match every pod) but no running pods in namespace                                                                                                                                                                   |                                                                                                                                                                       |
 | Jobs            | Jobs status is completed<br/>  Jobs status is suspended<br/>  Jobs failed with backoff limit exceeded (including indexed jobs) <br/> Jobs failed with dedaline exceeded                                                                                                                                              |                                                                                                                                                                       |
 | ReplicaSets     | replicaSets that specify replicas to 0 and has already completed it's work                                                                                                                                                        |
 | DaemonSets      | DaemonSets not scheduled on any nodes                                                                                                                                                                                             |
diff --git a/go.mod b/go.mod
index 4b35f21..7fd2980 100644
--- a/go.mod
+++ b/go.mod
@@ -5,14 +5,14 @@ go 1.22.0
 toolchain go1.22.2
 
 require (
-	github.com/fatih/color v1.17.0
+	github.com/fatih/color v1.18.0
 	github.com/olekukonko/tablewriter v0.0.5
 	github.com/prometheus/client_golang v1.20.5
 	github.com/spf13/cobra v1.8.1
-	k8s.io/api v0.31.1
-	k8s.io/apiextensions-apiserver v0.31.1
-	k8s.io/apimachinery v0.31.1
-	k8s.io/client-go v0.31.1
+	k8s.io/api v0.31.2
+	k8s.io/apiextensions-apiserver v0.31.2
+	k8s.io/apimachinery v0.31.2
+	k8s.io/client-go v0.31.2
 	k8s.io/utils v0.0.0-20240711033017-18e509b52bc8
 	sigs.k8s.io/yaml v1.4.0
 )
@@ -55,7 +55,7 @@ require (
 	github.com/x448/float16 v0.8.4 // indirect
 	golang.org/x/net v0.26.0 // indirect
 	golang.org/x/oauth2 v0.21.0 // indirect
-	golang.org/x/sys v0.22.0 // indirect
+	golang.org/x/sys v0.25.0 // indirect
 	golang.org/x/term v0.21.0 // indirect
 	golang.org/x/text v0.16.0 // indirect
 	golang.org/x/time v0.3.0 // indirect
diff --git a/go.sum b/go.sum
index 7541029..a29a68b 100644
--- a/go.sum
+++ b/go.sum
@@ -10,8 +10,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g=
 github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
-github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4=
-github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI=
+github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
+github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
 github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
 github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
 github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
@@ -143,8 +143,8 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
-golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
+golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA=
 golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -178,14 +178,14 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-k8s.io/api v0.31.1 h1:Xe1hX/fPW3PXYYv8BlozYqw63ytA92snr96zMW9gWTU=
-k8s.io/api v0.31.1/go.mod h1:sbN1g6eY6XVLeqNsZGLnI5FwVseTrZX7Fv3O26rhAaI=
-k8s.io/apiextensions-apiserver v0.31.1 h1:L+hwULvXx+nvTYX/MKM3kKMZyei+UiSXQWciX/N6E40=
-k8s.io/apiextensions-apiserver v0.31.1/go.mod h1:tWMPR3sgW+jsl2xm9v7lAyRF1rYEK71i9G5dRtkknoQ=
-k8s.io/apimachinery v0.31.1 h1:mhcUBbj7KUjaVhyXILglcVjuS4nYXiwC+KKFBgIVy7U=
-k8s.io/apimachinery v0.31.1/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo=
-k8s.io/client-go v0.31.1 h1:f0ugtWSbWpxHR7sjVpQwuvw9a3ZKLXX0u0itkFXufb0=
-k8s.io/client-go v0.31.1/go.mod h1:sKI8871MJN2OyeqRlmA4W4KM9KBdBUpDLu/43eGemCg=
+k8s.io/api v0.31.2 h1:3wLBbL5Uom/8Zy98GRPXpJ254nEFpl+hwndmk9RwmL0=
+k8s.io/api v0.31.2/go.mod h1:bWmGvrGPssSK1ljmLzd3pwCQ9MgoTsRCuK35u6SygUk=
+k8s.io/apiextensions-apiserver v0.31.2 h1:W8EwUb8+WXBLu56ser5IudT2cOho0gAKeTOnywBLxd0=
+k8s.io/apiextensions-apiserver v0.31.2/go.mod h1:i+Geh+nGCJEGiCGR3MlBDkS7koHIIKWVfWeRFiOsUcM=
+k8s.io/apimachinery v0.31.2 h1:i4vUt2hPK56W6mlT7Ry+AO8eEsyxMD1U44NR22CLTYw=
+k8s.io/apimachinery v0.31.2/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo=
+k8s.io/client-go v0.31.2 h1:Y2F4dxU5d3AQj+ybwSMqQnpZH9F30//1ObxOKlTI9yc=
+k8s.io/client-go v0.31.2/go.mod h1:NPa74jSVR/+eez2dFsEIHNa+3o09vtNaWwWwb1qSxSs=
 k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
 k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
 k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 h1:BZqlfIlq5YbRMFko6/PM7FjZpUb45WallggurYhKGag=
diff --git a/pkg/kor/pdbs.go b/pkg/kor/pdbs.go
index c700145..d7d5ce5 100644
--- a/pkg/kor/pdbs.go
+++ b/pkg/kor/pdbs.go
@@ -9,6 +9,7 @@ import (
 	"os"
 
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	labels "k8s.io/apimachinery/pkg/labels"
 	"k8s.io/client-go/kubernetes"
 	_ "k8s.io/client-go/plugin/pkg/client/auth/oidc"
 
@@ -52,34 +53,106 @@ func processNamespacePdbs(clientset kubernetes.Interface, namespace string, filt
 		}
 
 		selector := pdb.Spec.Selector
-		if selector == nil {
-			reason := "Pdb has no selector"
-			unusedPdbs = append(unusedPdbs, ResourceInfo{Name: pdb.Name, Reason: reason})
+		var hasMatchingTemplates, hasMatchingWorkloads bool
+
+		// Validate empty selector
+		if selector == nil || len(selector.MatchLabels) == 0 {
+			hasRunningPods, err := validateRunningPods(clientset, namespace)
+			if err != nil {
+				return nil, err
+			}
+
+			if !hasRunningPods {
+				reason := "Pdb matches every pod (empty selector) but 0 pods run"
+				unusedPdbs = append(unusedPdbs, ResourceInfo{Name: pdb.Name, Reason: reason})
+			}
+
 			continue
+		} else {
+			hasMatchingTemplates, err = validateMatchingTemplates(clientset, namespace, selector)
+			if err != nil {
+				return nil, err
+			}
+
+			hasMatchingWorkloads, err = validateMatchingWorkloads(clientset, namespace, selector)
+			if err != nil {
+				return nil, err
+			}
 		}
-		if len(selector.MatchLabels) == 0 {
-			reason := "Pdb has no selector"
+
+		if !hasMatchingTemplates && !hasMatchingWorkloads {
+			reason := "Pdb is not referencing any deployments, statefulsets or pods"
 			unusedPdbs = append(unusedPdbs, ResourceInfo{Name: pdb.Name, Reason: reason})
-			continue
 		}
-		deployments, err := clientset.AppsV1().Deployments(namespace).List(context.TODO(), metav1.ListOptions{
-			LabelSelector: metav1.FormatLabelSelector(selector),
-		})
-		if err != nil {
-			return nil, err
+	}
+
+	return unusedPdbs, nil
+}
+
+func validateRunningPods(clientset kubernetes.Interface, namespace string) (bool, error) {
+	pods, err := clientset.CoreV1().Pods(namespace).List(context.TODO(), metav1.ListOptions{
+		FieldSelector: "status.phase=Running",
+	})
+	if err != nil {
+		return false, err
+	}
+
+	// Field status.phase=Running can still reference Terminating pods
+	// Return true if at least one pod is running
+	for _, pod := range pods.Items {
+		if pod.ObjectMeta.DeletionTimestamp == nil {
+			return true, nil
 		}
-		statefulSets, err := clientset.AppsV1().StatefulSets(namespace).List(context.TODO(), metav1.ListOptions{
-			LabelSelector: metav1.FormatLabelSelector(selector),
-		})
-		if err != nil {
-			return nil, err
+	}
+
+	return false, nil
+}
+
+func validateMatchingTemplates(clientset kubernetes.Interface, namespace string, selector *metav1.LabelSelector) (bool, error) {
+	labelSelector, err := metav1.LabelSelectorAsSelector(selector)
+	if err != nil {
+		return false, err
+	}
+
+	deployments, err := clientset.AppsV1().Deployments(namespace).List(context.TODO(), metav1.ListOptions{})
+	if err != nil {
+		return false, err
+	}
+
+	for _, deployment := range deployments.Items {
+		deploymentLabels := labels.Set(deployment.Spec.Template.Labels)
+		if labelSelector.Matches(deploymentLabels) {
+			return true, nil
 		}
-		if len(deployments.Items) == 0 && len(statefulSets.Items) == 0 {
-			reason := "Pdb is not referencing any deployments or statefulsets"
-			unusedPdbs = append(unusedPdbs, ResourceInfo{Name: pdb.Name, Reason: reason})
+	}
+
+	statefulSets, err := clientset.AppsV1().StatefulSets(namespace).List(context.TODO(), metav1.ListOptions{})
+	if err != nil {
+		return false, err
+	}
+
+	for _, statefulSet := range statefulSets.Items {
+		statefulSetLabels := labels.Set(statefulSet.Spec.Template.Labels)
+		if labelSelector.Matches(statefulSetLabels) {
+			return true, nil
 		}
 	}
-	return unusedPdbs, nil
+
+	return false, nil
+}
+
+func validateMatchingWorkloads(clientset kubernetes.Interface, namespace string, selector *metav1.LabelSelector) (bool, error) {
+	pods, err := clientset.CoreV1().Pods(namespace).List(context.TODO(), metav1.ListOptions{
+		LabelSelector: metav1.FormatLabelSelector(selector),
+	})
+	if err != nil {
+		return false, err
+	}
+	if len(pods.Items) > 0 {
+		return true, nil
+	}
+
+	return false, nil
 }
 
 func GetUnusedPdbs(filterOpts *filters.Options, clientset kubernetes.Interface, outputFormat string, opts common.Opts) (string, error) {


Test patch:
diff --git a/pkg/kor/create_test_resources.go b/pkg/kor/create_test_resources.go
index bfcd22f..8e2c2ce 100644
--- a/pkg/kor/create_test_resources.go
+++ b/pkg/kor/create_test_resources.go
@@ -27,6 +27,11 @@ func CreateTestDeployment(namespace, name string, replicas int32, labels map[str
 		},
 		Spec: appsv1.DeploymentSpec{
 			Replicas: &replicas,
+			Template: corev1.PodTemplateSpec{
+				ObjectMeta: v1.ObjectMeta{
+					Labels: labels,
+				},
+			},
 		},
 	}
 }
@@ -40,6 +45,11 @@ func CreateTestStatefulSet(namespace, name string, replicas int32, labels map[st
 		},
 		Spec: appsv1.StatefulSetSpec{
 			Replicas: &replicas,
+			Template: corev1.PodTemplateSpec{
+				ObjectMeta: v1.ObjectMeta{
+					Labels: labels,
+				},
+			},
 		},
 	}
 }
diff --git a/pkg/kor/pdbs_test.go b/pkg/kor/pdbs_test.go
index d918365..36c4461 100644
--- a/pkg/kor/pdbs_test.go
+++ b/pkg/kor/pdbs_test.go
@@ -14,21 +14,34 @@ import (
 	"github.com/yonahd/kor/pkg/filters"
 )
 
+var testNamespace2 = "test-namespace2"
+
 func createTestPdbs(t *testing.T) *fake.Clientset {
 	clientset := fake.NewSimpleClientset()
+	namespaces := []string{testNamespace, testNamespace2}
+	var err error
 
-	_, err := clientset.CoreV1().Namespaces().Create(context.TODO(), &corev1.Namespace{
-		ObjectMeta: v1.ObjectMeta{Name: testNamespace},
-	}, v1.CreateOptions{})
+	for _, ns := range namespaces {
+		_, err := clientset.CoreV1().Namespaces().Create(context.TODO(), &corev1.Namespace{
+			ObjectMeta: v1.ObjectMeta{Name: ns},
+		}, v1.CreateOptions{})
 
-	if err != nil {
-		t.Fatalf("Error creating namespace %s: %v", testNamespace, err)
+		if err != nil {
+			t.Fatalf("Error creating namespace %s: %v", ns, err)
+		}
 	}
 
 	appLabels1 := map[string]string{
 		"app": "my-app",
 	}
 
+	appLabels2 := map[string]string{
+		"unused-app": "my-unused-app",
+	}
+
+	// Empty selector
+	appLabels3 := map[string]string{}
+
 	pdb1 := CreateTestPdb(testNamespace, "test-pdb1", appLabels1, AppLabels)
 	_, err = clientset.PolicyV1().PodDisruptionBudgets(testNamespace).Create(context.TODO(), pdb1, v1.CreateOptions{})
 	if err != nil {
@@ -41,7 +54,8 @@ func createTestPdbs(t *testing.T) *fake.Clientset {
 		t.Fatalf("Error creating fake %s: %v", "Pdb", err)
 	}
 
-	pdb3 := CreateTestPdb(testNamespace, "test-pdb3", AppLabels, AppLabels)
+	// Unused PDB - no matching templates / workloads
+	pdb3 := CreateTestPdb(testNamespace, "test-pdb3", appLabels2, AppLabels)
 	_, err = clientset.PolicyV1().PodDisruptionBudgets(testNamespace).Create(context.TODO(), pdb3, v1.CreateOptions{})
 	if err != nil {
 		t.Fatalf("Error creating fake %s: %v", "Pdb", err)
@@ -53,12 +67,26 @@ func createTestPdbs(t *testing.T) *fake.Clientset {
 		t.Fatalf("Error creating fake %s: %v", "Pdb", err)
 	}
 
+	// Unused PDB - kor/used: false
 	pdb5 := CreateTestPdb(testNamespace, "test-pdb5", AppLabels, UnusedLabels)
 	_, err = clientset.PolicyV1().PodDisruptionBudgets(testNamespace).Create(context.TODO(), pdb5, v1.CreateOptions{})
 	if err != nil {
 		t.Fatalf("Error creating fake %s: %v", "Pdb", err)
 	}
 
+	pdb6 := CreateTestPdb(testNamespace, "test-pdb6", appLabels3, AppLabels)
+	_, err = clientset.PolicyV1().PodDisruptionBudgets(testNamespace).Create(context.TODO(), pdb6, v1.CreateOptions{})
+	if err != nil {
+		t.Fatalf("Error creating fake %s: %v", "Pdb", err)
+	}
+
+	// Unused PDB - empty selector with 0 pods running
+	pdb7 := CreateTestPdb(testNamespace2, "test-pdb7", appLabels3, AppLabels)
+	_, err = clientset.PolicyV1().PodDisruptionBudgets(testNamespace2).Create(context.TODO(), pdb7, v1.CreateOptions{})
+	if err != nil {
+		t.Fatalf("Error creating fake %s: %v", "Pdb", err)
+	}
+
 	deployment1 := CreateTestDeployment(testNamespace, "test-deployment2", 1, appLabels1)
 	_, err = clientset.AppsV1().Deployments(testNamespace).Create(context.TODO(), deployment1, v1.CreateOptions{})
 	if err != nil {
@@ -71,23 +99,38 @@ func createTestPdbs(t *testing.T) *fake.Clientset {
 		t.Fatalf("Error creating fake %s: %v", "StatefulSet", err)
 	}
 
+	pod1 := CreateTestPod(testNamespace, "test-arbitrary-pod", "", nil, appLabels1)
+	_, err = clientset.CoreV1().Pods(testNamespace).Create(context.TODO(), pod1, v1.CreateOptions{})
+	if err != nil {
+		t.Fatalf("Error creating fake %s: %v", "Pod", err)
+	}
+
 	return clientset
 }
 
 func TestProcessNamespacePdbs(t *testing.T) {
 	clientset := createTestPdbs(t)
+	namespaces := []string{testNamespace, testNamespace2}
+	expectedUnusedPdbs := []string{"test-pdb3", "test-pdb5", "test-pdb7"}
+	totalUnusedPdbs := []ResourceInfo{}
 
-	unusedPdbs, err := processNamespacePdbs(clientset, testNamespace, &filters.Options{})
-	if err != nil {
-		t.Errorf("Expected no error, got %v", err)
+	for _, ns := range namespaces {
+		unusedPdbs, err := processNamespacePdbs(clientset, ns, &filters.Options{})
+		if err != nil {
+			t.Errorf("Expected no error, got %v", err)
+		}
+
+		totalUnusedPdbs = append(totalUnusedPdbs, unusedPdbs...)
 	}
 
-	if len(unusedPdbs) != 2 {
-		t.Errorf("Expected 2 unused pdb, got %d", len(unusedPdbs))
+	if len(totalUnusedPdbs) != len(expectedUnusedPdbs) {
+		t.Errorf("Expected %d unused pdbs, got %d", len(expectedUnusedPdbs), len(totalUnusedPdbs))
 	}
 
-	if unusedPdbs[0].Name != "test-pdb3" && unusedPdbs[1].Name != "test-pdb5" {
-		t.Errorf("Expected 'test-pdb3', got %s", unusedPdbs[0])
+	for i, expected := range expectedUnusedPdbs {
+		if totalUnusedPdbs[i].Name != expected {
+			t.Errorf("Expected '%s' in unused pdbs, got '%s'", expected, totalUnusedPdbs[i].Name)
+		}
 	}
 }
 
@@ -115,6 +158,11 @@ func TestGetUnusedPdbsStructured(t *testing.T) {
 				"test-pdb5",
 			},
 		},
+		testNamespace2: {
+			"Pdb": {
+				"test-pdb7",
+			},
+		},
 	}
 
 	var actualOutput map[string]map[string][]string