Skip to content

Commit 79adff5

Browse files
authored
Merge pull request #17 from mayur-tolexo/feat/sweep-cleanup-detectors
feat(sweep): cleanup detectors — orphaned Services, unused PVCs, idle namespaces, zombies
2 parents 87a21a4 + 99fe709 commit 79adff5

8 files changed

Lines changed: 707 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ Scan output rebuilt around making every recommendation understandable, plus a re
1111
safety fix. Driven by real-cluster feedback.
1212

1313
### Added
14+
- **`kubetidy sweep`** — find removable junk (the literal tidy): orphaned Services (selector
15+
matches no pods), unused PVCs (with estimated `$/mo` storage cost), idle namespaces, and
16+
zombie (scaled-to-zero) Deployments/StatefulSets. Read-only; `table`/`json` output.
1417
- **Card per recommendation.** Each finding is its own block: a bordered table of the observed
1518
**avg / p95 / p99 / peak** for CPU and memory beside the `request → proposed` change, with the
1619
workload name on its own line (no more truncated/again-wrapped wide table).

README.md

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -225,8 +225,8 @@ kubetidy ships as a single binary with two faces — use whichever you prefer:
225225
- `kubetidy <command>` (standalone)
226226

227227
Commands: **`scan`** (report), **`diff`** (reversible `kubectl patch` per recommendation),
228-
**`pr`** (a GitOps change set — patch files + a Markdown PR body), **`init`** (install the
229-
CRD + operator), and `version`.
228+
**`sweep`** (find removable junk), **`pr`** (a GitOps change set — patch files + a Markdown PR
229+
body), **`init`** (install the CRD + operator), and `version`.
230230

231231
### `scan` — score, dollars, and recommendations
232232

@@ -249,6 +249,30 @@ kubectl tidy diff # patches for every recommendation
249249
kubectl tidy diff --explain checkout # just the patch for one workload
250250
```
251251

252+
### `sweep` — find removable junk (the literal tidy)
253+
254+
`sweep` scans the cluster (read-only) for cleanup opportunities beyond rightsizing: Services
255+
whose selector matches no pods, PersistentVolumeClaims nothing mounts (with an estimated
256+
`$/mo`), namespaces with no running workloads, and Deployments/StatefulSets scaled to zero.
257+
258+
```sh
259+
kubectl tidy sweep # all categories, cluster-wide
260+
kubectl tidy sweep -n payments # scope to one namespace
261+
kubectl tidy sweep --storage-cost 0.08 # $/GiB-month for unused-PVC estimates
262+
kubectl tidy sweep -o json # machine-readable
263+
```
264+
265+
```
266+
Found 9 cleanup opportunities · ~$24/mo reclaimable
267+
268+
unused pvc (3)
269+
• logs/archive-2023 not mounted by any pod · 180Gi · ~$18/mo
270+
zombie workload (2)
271+
• ops/old-redis (StatefulSet) scaled to 0 replicas
272+
273+
Read-only: review before deleting. kubetidy never deletes anything.
274+
```
275+
252276
### `pr` — a reviewable GitOps change set
253277

254278
`pr` turns the scan into something you can merge: one strategic-merge patch file per

ROADMAP.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,8 @@ This roadmap is intentionally public and open for discussion — please weigh in
5656
on data maturity, so warm-up history isn't passed off as high-confidence.
5757
- [ ] Accountability: per-team showback + budgets
5858
- [ ] Multi-cluster aggregation (continuous, in-cluster)
59-
- [ ] Cleanup detectors (orphaned services/PVCs, idle namespaces, zombie workloads)
59+
- [x] **Cleanup detectors**`kubetidy sweep` finds orphaned Services, unused PVCs (with
60+
est. $/mo), idle namespaces, and zombie (scaled-to-zero) workloads. Read-only.
6061
- [ ] Interactive `scan -i` TUI to browse/filter recommendations and drill into `--explain`
6162

6263
## 💼 Commercial (open-core)

internal/cleanup/cleanup.go

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
// Package cleanup detects removable "junk" in a cluster — the literal tidy: orphaned Services,
2+
// unused PersistentVolumeClaims, idle namespaces, and zombie (scaled-to-zero) workloads. It is
3+
// PURE: callers gather the live objects (via client-go) and pass them in; this package only
4+
// reasons over them, so it is fully unit-testable with fakes. It never mutates anything.
5+
package cleanup
6+
7+
import (
8+
"fmt"
9+
"math"
10+
11+
appsv1 "k8s.io/api/apps/v1"
12+
corev1 "k8s.io/api/core/v1"
13+
"k8s.io/apimachinery/pkg/labels"
14+
)
15+
16+
// Category groups findings by the kind of cleanup opportunity.
17+
type Category string
18+
19+
// The cleanup categories the sweep reports.
20+
const (
21+
OrphanedService Category = "orphaned service"
22+
UnusedPVC Category = "unused pvc"
23+
IdleNamespace Category = "idle namespace"
24+
ZombieWorkload Category = "zombie workload"
25+
)
26+
27+
// Finding is one removable resource the sweep surfaces. It is read-only advice; nothing is
28+
// deleted. MonthlyCost is a best-effort estimate (PVC storage) — 0 when not applicable.
29+
type Finding struct {
30+
Category Category
31+
Kind string // Service, PersistentVolumeClaim, Namespace, Deployment, StatefulSet
32+
Namespace string // empty for cluster-scoped objects (Namespace)
33+
Name string
34+
Reason string // human-readable justification
35+
Detail string // optional extra (e.g. "50Gi", "scaled to 0")
36+
MonthlyCost float64 // estimated $/month reclaimable (PVC storage); 0 otherwise
37+
}
38+
39+
// Inputs are the live cluster objects the detectors reason over. The caller lists them.
40+
type Inputs struct {
41+
Services []corev1.Service
42+
Pods []corev1.Pod
43+
PVCs []corev1.PersistentVolumeClaim
44+
Namespaces []corev1.Namespace
45+
Deployments []appsv1.Deployment
46+
StatefulSets []appsv1.StatefulSet
47+
DaemonSets []appsv1.DaemonSet
48+
49+
// StoragePerGiBMonth prices unused PVCs ($/GiB-month). 0 disables PVC cost estimates.
50+
StoragePerGiBMonth float64
51+
}
52+
53+
// systemNamespaces are never flagged as idle (they legitimately hold cluster infra, sometimes
54+
// with no user workloads of their own).
55+
var systemNamespaces = map[string]bool{
56+
"kube-system": true, "kube-public": true, "kube-node-lease": true,
57+
"kubetidy-system": true, "default": true,
58+
}
59+
60+
// Detect runs all detectors and returns the combined findings (orphaned services, unused PVCs,
61+
// idle namespaces, zombie workloads), in that stable order.
62+
func Detect(in Inputs) []Finding {
63+
var out []Finding
64+
out = append(out, orphanedServices(in)...)
65+
out = append(out, unusedPVCs(in)...)
66+
out = append(out, idleNamespaces(in)...)
67+
out = append(out, zombieWorkloads(in)...)
68+
return out
69+
}
70+
71+
// orphanedServices finds Services whose selector matches no Pod — a dead routing target. It
72+
// skips Services with no selector (headless / manually-managed endpoints) and ExternalName
73+
// (which has no selector by design), since orphan-ness can't be inferred for those.
74+
func orphanedServices(in Inputs) []Finding {
75+
var out []Finding
76+
for i := range in.Services {
77+
svc := &in.Services[i]
78+
if svc.Spec.Type == corev1.ServiceTypeExternalName || len(svc.Spec.Selector) == 0 {
79+
continue
80+
}
81+
sel := labels.SelectorFromSet(svc.Spec.Selector)
82+
matched := false
83+
for j := range in.Pods {
84+
p := &in.Pods[j]
85+
if p.Namespace == svc.Namespace && sel.Matches(labels.Set(p.Labels)) {
86+
matched = true
87+
break
88+
}
89+
}
90+
if !matched {
91+
out = append(out, Finding{
92+
Category: OrphanedService,
93+
Kind: "Service",
94+
Namespace: svc.Namespace,
95+
Name: svc.Name,
96+
Reason: "no pods match its selector",
97+
Detail: selectorString(svc.Spec.Selector),
98+
})
99+
}
100+
}
101+
return out
102+
}
103+
104+
// unusedPVCs finds PersistentVolumeClaims not mounted by any Pod — storage you pay for but
105+
// nothing uses. Cost is estimated from the requested size and StoragePerGiBMonth.
106+
func unusedPVCs(in Inputs) []Finding {
107+
// Build the set of PVCs referenced by some pod, keyed by namespace/name.
108+
used := make(map[string]bool)
109+
for i := range in.Pods {
110+
p := &in.Pods[i]
111+
for _, v := range p.Spec.Volumes {
112+
if v.PersistentVolumeClaim != nil {
113+
used[p.Namespace+"/"+v.PersistentVolumeClaim.ClaimName] = true
114+
}
115+
}
116+
}
117+
118+
var out []Finding
119+
for i := range in.PVCs {
120+
pvc := &in.PVCs[i]
121+
if used[pvc.Namespace+"/"+pvc.Name] {
122+
continue
123+
}
124+
gib := pvcGiB(pvc)
125+
out = append(out, Finding{
126+
Category: UnusedPVC,
127+
Kind: "PersistentVolumeClaim",
128+
Namespace: pvc.Namespace,
129+
Name: pvc.Name,
130+
Reason: "not mounted by any pod",
131+
Detail: formatGiB(gib),
132+
MonthlyCost: gib * in.StoragePerGiBMonth,
133+
})
134+
}
135+
return out
136+
}
137+
138+
// idleNamespaces finds non-system namespaces with no running workloads: no Deployments,
139+
// StatefulSets, or DaemonSets with desired replicas, and no Pods.
140+
func idleNamespaces(in Inputs) []Finding {
141+
// Count "live" workload signal per namespace.
142+
live := make(map[string]bool)
143+
for i := range in.Deployments {
144+
if replicas(in.Deployments[i].Spec.Replicas) > 0 {
145+
live[in.Deployments[i].Namespace] = true
146+
}
147+
}
148+
for i := range in.StatefulSets {
149+
if replicas(in.StatefulSets[i].Spec.Replicas) > 0 {
150+
live[in.StatefulSets[i].Namespace] = true
151+
}
152+
}
153+
for i := range in.DaemonSets {
154+
live[in.DaemonSets[i].Namespace] = true // a DaemonSet always wants a pod per node
155+
}
156+
for i := range in.Pods {
157+
live[in.Pods[i].Namespace] = true
158+
}
159+
160+
var out []Finding
161+
for i := range in.Namespaces {
162+
ns := &in.Namespaces[i]
163+
if systemNamespaces[ns.Name] || ns.Status.Phase == corev1.NamespaceTerminating {
164+
continue
165+
}
166+
if !live[ns.Name] {
167+
out = append(out, Finding{
168+
Category: IdleNamespace,
169+
Kind: "Namespace",
170+
Name: ns.Name,
171+
Reason: "no running workloads",
172+
})
173+
}
174+
}
175+
return out
176+
}
177+
178+
// zombieWorkloads finds Deployments/StatefulSets explicitly scaled to zero — defined but doing
179+
// nothing, often a forgotten scale-down.
180+
func zombieWorkloads(in Inputs) []Finding {
181+
var out []Finding
182+
for i := range in.Deployments {
183+
d := &in.Deployments[i]
184+
if replicas(d.Spec.Replicas) == 0 {
185+
out = append(out, Finding{
186+
Category: ZombieWorkload, Kind: "Deployment", Namespace: d.Namespace, Name: d.Name,
187+
Reason: "scaled to 0 replicas",
188+
})
189+
}
190+
}
191+
for i := range in.StatefulSets {
192+
s := &in.StatefulSets[i]
193+
if replicas(s.Spec.Replicas) == 0 {
194+
out = append(out, Finding{
195+
Category: ZombieWorkload, Kind: "StatefulSet", Namespace: s.Namespace, Name: s.Name,
196+
Reason: "scaled to 0 replicas",
197+
})
198+
}
199+
}
200+
return out
201+
}
202+
203+
// TotalMonthlyCost sums the estimated reclaimable $/month across findings.
204+
func TotalMonthlyCost(findings []Finding) float64 {
205+
var sum float64
206+
for _, f := range findings {
207+
sum += f.MonthlyCost
208+
}
209+
return sum
210+
}
211+
212+
// replicas returns the desired replica count (nil defaults to 1, matching Kubernetes).
213+
func replicas(r *int32) int32 {
214+
if r == nil {
215+
return 1
216+
}
217+
return *r
218+
}
219+
220+
// pvcGiB returns the PVC's requested size in GiB (0 when unset).
221+
func pvcGiB(pvc *corev1.PersistentVolumeClaim) float64 {
222+
q, ok := pvc.Spec.Resources.Requests[corev1.ResourceStorage]
223+
if !ok {
224+
return 0
225+
}
226+
return float64(q.Value()) / (1 << 30)
227+
}
228+
229+
func formatGiB(gib float64) string {
230+
switch {
231+
case gib <= 0:
232+
return "size unknown"
233+
case gib == math.Trunc(gib):
234+
return fmt.Sprintf("%dGi", int64(gib))
235+
default:
236+
return fmt.Sprintf("%.1fGi", gib)
237+
}
238+
}
239+
240+
func selectorString(sel map[string]string) string {
241+
return labels.SelectorFromSet(sel).String()
242+
}

0 commit comments

Comments
 (0)