Skip to content

Detect custom resources stuck deleting on an operator's finalizer - #2053

Merged
nadaverell merged 19 commits into
mainfrom
feat/stuck-terminating-custom-resources
Oct 12, 2026
Merged

nadaverell merged 19 commits into
mainfrom
feat/stuck-terminating-custom-resources

Conversation

@nadaverell

@nadaverell nadaverell commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

VictoriaMetrics' operator was uninstalled while its VMAgent and VMSingle objects still carried apps.victoriametrics.com/finalizer. They stayed deleting for weeks, kept workloads running, and left the Helm release stuck uninstalling. Radar's terminating detector covered built-in resources but missed these custom resources.

This adds stuck-deletion findings for watched custom resources and explains their finalizers. The primary advice is to repair or restore the controller. Manual removal appears separately, only for reviewed catalog entries and only under an explicit intentional-uninstall or permanent-scale-down condition.

Behavior and read rules

  • Scan existing watched CRD stores; start no new watches. After 10 minutes deleting, emit a warning. After 30 minutes, controller-backed findings become critical only with evidence of an installed controller that is down or not fully healthy. Healthy, unidentified, unrecognized or unreadable controllers stay warning. Protection and garbage-collection finalizers retain their documented severity rules.
  • Use Issues follow Radar's read rules on every surface #2071's subject/evidence authorization: ordinary namespaced subjects require namespace access; Secrets require the additional Secret read; cluster-scoped subjects require exact-kind list. Controller inventory follows the same evidence granularity: namespace access for namespaced reads and exact-kind list for cluster-wide reads. Incomplete inventory cannot establish a missing/down controller or enable cleanup. A synchronized namespace still produces findings when another namespace's informer has not synchronized.
  • Keep Issues follow Radar's read rules on every surface #2071's per-resource coverage envelope and withheld count. A user allowed to get a cluster-scoped object but not list its kind sees that a finding was withheld, without its payload.
  • Shared issue composition feeds REST/MCP issues, per-resource findings, diagnose, GitOps and CNPG. The home Active Issues panel uses /api/issues. AI resource-list and search summaries include the composed findings and incur their scan cost; /api/dashboard, MCP get_dashboard and cluster://health exclude the dynamic scan.
  • Fold deletion conditions on the same subject into its termination row. Time the issue from the deletion request; the UI labels it “since deletion.”

Controller recognition and boundaries

pkg/finalizers/catalog.go owns pure finalizer identity, workload/image matching and per-controller cleanup policy. It recognizes Argo CD, Flux, VictoriaMetrics and Karpenter. Detection is generic across watched CRDs; controller-specific diagnosis is catalog-driven. New recognition defaults to cleanup disabled. The catalog replaces the exported insights.FinalizerOwner / insights.ResolveFinalizerOwner names; current supported Hub code has no call sites for them. The integration guide documents where to add entries and the positive/negative fixtures required.

Recognition uses workload names/labels, pod-template labels and published image repository paths. Registry hosts, tags and digests are ignored; mirrors must preserve the vendor repository path as trailing segments; proxy-cache prefixes are supported. Flux Helm requires the published image path because unrelated Helm controllers share its name and labels. A matching name/label with an unrecognized image leaves identity unconfirmed and blocks cleanup instead of counting as an absent controller. Role predicates distinguish Argo application controllers from other components sharing their image, and VictoriaMetrics operators from config reloaders. Name-only guesses for uncataloged finalizers remain candidates: they cannot establish ownership, change severity or unlock cleanup.

The inventory checks Deployments, StatefulSets, DaemonSets and recognized pods elsewhere. All copies contribute to health; a healthy copy cannot hide a failing one. Reuse observations by resolved controller within each caller's composition. Health summaries live in pkg/health and are shared with GitOps. Argo CD and Karpenter can run outside the cluster and never opt into cleanup; Karpenter also releases infrastructure.

Conditional cleanup

No match is not proof of an uninstall. The finding says “No recognized controller found,” stays warning and leads with restoration. Only Flux and VictoriaMetrics catalog entries opt into conditional_cleanup after a complete negative inventory or permanent-scale-down candidate.

The additive field contains the user-confirmed condition, agent guidance, caution and dry-run commands. Each JSON Patch tests the object UID and exact finalizer at its index before removing one finalizer. Re-read after each removal. The Issues UI keeps cleanup collapsed beneath Next step; it performs no mutation. Removal releases only this object’s finalizer. Older VM operators also finalize managed Kubernetes objects, so deleting the parent can leave children stuck and Pods running; restoring the operator after the parent is gone may not clean them up. Repair/restore advice says the controller can resume cleanup, without guaranteeing completion. MCP guidance prohibits presenting removal as the next step before the user confirms the condition.

Mixed finalizers retain the existing warning policy when one controller is healthy, while preserving repair, restore and check advice for every other finalizer. Unknown controllers, weak candidates, incomplete access, off-cluster installations and protection finalizers get no removal commands.

Validation

  • Documentation-only follow-up at 244e84263: simplified all three changed Markdown files by 61 lines, corrected recovery wording and the conventional-namespace GitOps lookup description, and retained safety rules and extension points. Scope, relative links and whitespace checked; behavior is unchanged.
  • Full frontend/embed/Go build and affected Issues/MCP suites at 7fa46cb63; conditional-cleanup UI tests passed. The broader root/pkg suites and 2,023 web / 4,504 k8s-ui tests (one existing skipped) remain evidence for unchanged paths from the preceding integration checks.
  • Regression coverage includes deletion thresholds, exact resource identity, per-namespace synchronization, namespace/Secret/cluster-subject authorization, inventory completeness, renamed/image-role recognition, multi-copy health, unknown/mixed finalizers, cleanup opt-in, condition folding and UID/index guards.
  • Actual VM lifecycle on existing EKS, no kind: Helm-renamed, namespace-scoped VictoriaMetrics operator v0.68.1 with real VMAgent/VMSingle workloads and operator-image reloaders. At b7cd15ff5, intentional operator uninstall left three real CRs deleting with healthy application/reloader Pods. No findings before ten minutes; afterward warning, restore-first advice and conditional cleanup. Reloaders were not mistaken for the operator. REST/MCP agreed; generated server dry run succeeded; wrong UID/finalizer guards failed unchanged; one reviewed apply deleted only its owned parent CR.
  • Restoring the operator deleted the two parents left intact and their main workloads. The bypassed parent’s seven built-in dependents stayed stuck, and all three auto-generated VMServiceScrapes remained deleting under this older operator. Those leftovers were inspected and cleaned separately with UID/finalizer guards. The operator release, owned namespace and all 26 created empty CRDs were removed.
  • At 72b521d1c, the actual healthy vmop operator was recognized by image on all three naturally stuck scrape CRs: warning, no cleanup, REST/MCP agreement. After intentional scale-to-zero, the corrected dependent-finalizer caution and collapsed/expanded UI were verified; generated previews and guarded applies removed only those owned scrape CRs. The 7fa46cb63 follow-up changes recovery wording/documentation and test copy only; unchanged behavior reuses those live runs.
  • Shared-mode EKS at 9e60c1573: stand-in fixtures with proxy authentication and three RBAC identities cover healthy, crashloop, image-pull failure, Deployment/DaemonSet without Pods, scaled-zero and unrecognized states; namespace-only callers get neither cleanup nor hidden controller names. REST/MCP agree; get-only cluster coverage reports a withheld finding; unreadable Secret evidence loses names/keys/onset. UI captures cover collapsed cleanup, restricted omission and withheld messaging. Those fixtures were 10–30 minutes deleting; the 30-minute critical ramp has tests and earlier live no-pod evidence at 07101583e.

Limits: object-level conditional cleanup is not a complete VM installation uninstall. The default Radar chart’s deployed ServiceAccount/Hub permissions, a live AI confirmation conversation, large-cluster performance and persistent/cloud-data cleanup remain unverified. v0.75.0 was installed and healthy during setup, but the intentional uninstall/recovery failure reproduction used v0.68.1. The live run used Helm for the operator and kubectl for the VM CRs; it did not reproduce the original application chart’s Helm-release bookkeeping. Earlier kind evidence is historical; this EKS run created no kind cluster. Review packet (owner-private).

After an operator was uninstalled in a real cleanup, its VMAgent and
VMSingle objects kept their finalizer for weeks, holding a Helm uninstall
open while the workloads kept running on a 20Gi disk. Radar's terminating
detection only covered typed kinds. Already-watched, synced custom
resources with a deletion timestamp now raise the same issue (warning after
10 minutes, critical after 30, onset at the deletion time), naming the
finalizers and how long deletion has been waiting.

Where the finalizer maps to a known controller (the GitOps catalog plus
VictoriaMetrics, or an operator workload matched by name or app label),
the issue says whether running controller pods were observed, only within
namespaces the caller can read and the cache covers; otherwise it says the
controller is unknown. The advice starts with the controller's logs and
permissions, and only for an intentionally removed controller shows an
exact patch_resource JSON Patch that tests the UID and finalizer value
before removing one finalizer, warning that its cleanup is skipped.
@nadaverell
nadaverell requested a review from hisco as a code owner October 10, 2026 03:20
@cursor

cursor Bot commented Oct 10, 2026

Copy link
Copy Markdown

Bugbot needs on-demand usage enabled

Bugbot uses usage-based billing for this team and requires on-demand usage to be enabled.

A team admin can enable on-demand usage in the Cursor dashboard.

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Detect custom resources stuck deleting on operator finalizers

✨ Enhancement 🧪 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Report stuck deletion for custom resources in already-watched, synced CRD caches.
• Add authorization-aware controller observations and guarded, manual finalizer-removal guidance.
• Document detection limits and test timing, access controls, and patch safety.
Diagram

graph TD
  A["Watched CRD caches"] --> B{"Synced CRD?"} --> C["Termination detector"] --> D["Issue composition"] --> E["Controller matching"] --> F["Authorized pod inventory"] --> G["Issues and guidance"]
  D --> G
Loading
High-Level Assessment

Reuse of existing watched caches, termination thresholds, and finalizer catalog keeps the signal consistent without adding cluster-wide watches. Live API scans or automatic finalizer removal would broaden access and operational risk, so the cache-bound, advisory approach is preferable.

Files changed (9) +551 / -2

Enhancement (5) +224 / -2
compose.goAttach controller observations to finalizer issues +18/-0

Attach controller observations to finalizer issues

• Resolves each terminating finalizer through an optional provider and adds the observation to the issue cause. Reuses observations within a composition pass.

internal/issues/compose.go

finalizers.goObserve matched controllers through authorized pod caches +101/-0

Observe matched controllers through authorized pod caches

• Matches finalizers to visible Deployments, StatefulSets, or catalog entries, then checks for running pods. Returns an unknown status when matching, cache coverage, or read authorization cannot support an observation.

internal/issues/finalizers.go

detect.goInclude dynamic termination findings in problem detection +2/-0

Include dynamic termination findings in problem detection

• Adds private finalizer metadata to detections and invokes the new custom-resource termination detector alongside existing problem checks.

internal/k8s/detect.go

detect_terminating.goDetect deletion-stuck resources in synced CRD stores +63/-0

Detect deletion-stuck resources in synced CRD stores

• Scans only already-watched, synced CRD caches and applies existing deletion-age thresholds. Produces finalizer-specific, dry-run-first JSON Patch examples that test the object UID and targeted value before removing one non-garbage-collection finalizer.

internal/k8s/detect_terminating.go

finalizers.goRecognize VictoriaMetrics and conservative operator matches +40/-2

Recognize VictoriaMetrics and conservative operator matches

• Adds a VictoriaMetrics finalizer catalog entry restricted to its resource API group. Introduces workload matching based on catalog identity or an exact finalizer domain plus operator-style names or app labels.

pkg/gitops/insights/finalizers.go

Tests (3) +305 / -0
finalizers_test.goTest controller evidence and issue authorization +132/-0

Test controller evidence and issue authorization

• Covers catalog and heuristic matches, non-running pods, unreadable inventories, and incomplete cache scope. Verifies namespaced and cluster-scoped custom-resource issues respect composition filters and access gates.

internal/issues/finalizers_test.go

detect_terminating_test.goTest CRD termination timing and patch safety +139/-0

Test CRD termination timing and patch safety

• Checks severity escalation, namespace and cluster scope, and the absence of a watch for a cold kind. Validates patch targeting and stale-patch rejection, and confirms detection does not mutate cached objects.

internal/k8s/detect_terminating_test.go

finalizers_test.goTest finalizer-to-controller matching boundaries +34/-0

Test finalizer-to-controller matching boundaries

• Covers catalog labels, operator names and labels, mismatched API groups, unrelated domains, substring lookalikes, and reserved Kubernetes domains.

pkg/gitops/insights/finalizers_test.go

Documentation (1) +22 / -0
mcp.mdDocument custom-resource termination issues and rescue limits +22/-0

Document custom-resource termination issues and rescue limits

• Explains watch and authorization boundaries, controller-observation caveats, and age thresholds. Documents dry-run JSON Patch guidance, cleanup risks, and the need to re-read after each removal.

docs/mcp.md

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Oct 10, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Users can see restricted custom objects ✓ Resolved
Description
detectDynamicTerminatingProblems emits names and finalizers from every cached CRD object in an
allowed namespace without checking whether the caller can list that custom-resource kind. A user
whose namespace access comes from Deployment permissions but who cannot read that CRD can receive
its deleting objects through issues, including their names and finalizers.
Code

internal/k8s/detect_terminating.go[R38-43]

+			det, ok := terminatingProblem(kind, gvr.Group, obj, now)
+			if !ok {
+				continue
+			}
+			det.Cause = fmt.Sprintf("Deletion has been pending for %s. %s.", det.Duration, det.Message)
+			det.TerminatingFinalizers = append([]string(nil), obj.GetFinalizers()...)
Evidence
Namespace discovery grants access when the user can list Pods OR Deployments; it does not require
permission for each CRD. The new detector reads all watched CRD objects in that namespace, while the
issues handler supplies namespace and cluster-scoped gates but no namespaced per-CRD list gate.

pkg/auth/permissions.go[216-262]
internal/k8s/detect_terminating.go[20-43]
internal/server/issues_handler.go[72-104]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The new termination detector exposes cached custom-resource details based on namespace access alone, even when the caller cannot list that CRD kind.
## Fix Focus Areas
- internal/k8s/detect_terminating.go[20-43]
- internal/issues/compose.go[176-179]
- internal/server/issues_handler.go[59-104]
## Recommended Fix
Before including a custom-resource termination issue in a user-facing response, check the caller's list permission for that resource's discovered group, resource name, and scope. Preserve the existing cluster-scoped and namespace filtering.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Controller pod counts bypass pod access ✓ Resolved
Description
FinalizerOwnerStatus checks CanReadRelated for a Pod reference, but that callback verifies
namespace access rather than permission to list Pods. When a user can list Deployments but not Pods
in the controller namespace, the issue can still report running pod counts derived from Radar’s Pod
cache.
Code

internal/issues/finalizers.go[R29-33]

+		if !allowed(Ref{Kind: "Pod", Namespace: namespace}) {
+			return unknown + " (controller pod inventory is unreadable)"
+		}
+		if !p.cache.KindCoversNamespace("pods", namespace) {
+			return unknown + " (Pod cache does not cover the controller namespace)"
Evidence
Namespace discovery accepts Deployment-list permission as an alternative to Pod-list permission. For
a namespaced Pod, issueRelatedResourceAccess falls through to preflightResourceGet, whose
ordinary namespaced branch checks namespace access but performs no Pod-specific authorization; the
resolver then lists cached Pods and reports their running count.

pkg/auth/permissions.go[226-262]
internal/server/issues_handler.go[172-196]
internal/server/server.go[2834-2852]
internal/issues/finalizers.go[28-62]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Controller observations reveal cached Pod counts without checking the caller's Pod list permission.
## Fix Focus Areas
- internal/issues/finalizers.go[27-45]
- internal/server/issues_handler.go[172-196]
## Recommended Fix
Supply an authorization gate for controller inventory that checks list permission for Pods in the namespace being queried, and the relevant workload permissions before using workload details. Return controller unknown when the caller cannot read that inventory.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

3. Namespace-filtered issues rescan every CRD ✓ Resolved
Description
DetectProblems now calls detectDynamicTerminatingProblems on each invocation, and that detector
lists every watched CRD store before filtering objects by namespace. The provider invokes
DetectProblems once per requested namespace, so a multi-namespace issues request scans the same
cached objects once for every namespace, even when none is deleting.
Code

internal/k8s/detect.go[208]

+	problems = append(problems, detectDynamicTerminatingProblems(GetDynamicResourceCache(), GetResourceDiscovery(), namespace, now)...)
Evidence
The provider loops over namespaces and calls k8s.DetectProblems for each. The new call enters a
loop over all watched GVRs, and ListWatchedReadOnly materializes each store’s cached items before
the detector applies its object-level namespace check.

internal/issues/provider.go[67-76]
internal/k8s/detect.go[204-209]
internal/k8s/detect_terminating.go[20-38]
pkg/k8score/dynamic_cache.go[1350-1370]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Multi-namespace issues requests repeat a full scan of watched custom-resource stores for each requested namespace.
## Fix Focus Areas
- internal/k8s/detect.go[205-209]
- internal/k8s/detect_terminating.go[20-38]
- internal/issues/provider.go[67-76]
## Recommended Fix
Collect dynamic termination detections once per issues composition and filter them to the requested namespaces, or use namespace-indexed cache reads instead of listing every watched store on each per-namespace invocation.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Tip of the day
💡 Did you know, you can commit Qodo's fix in one click with committable suggestions (GitHub & GitLab)

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread internal/k8s/detect_terminating.go
Comment thread internal/issues/finalizers.go Outdated
Comment thread internal/k8s/detect.go Outdated
…alizer patches

The custom-resource termination scan is its own provider method used only
by the issues path, so dashboard surfaces no longer list cluster-scoped
custom resources a caller can't read. In-use protection finalizers
(*.k8s.io, kubernetes.io) get an explanation of what still references the
object instead of a removal patch, Karpenter is in the controller catalog,
and no patch is offered while the controller is running. Controller health
uses the GitOps page's summarizer, now shared, so a crashlooping controller
is reported as such. Severity stays at warning while a matched controller
is running, a Deleting condition for the same object folds into the issue,
the advice adds the kubectl form, and namespace-restricted callers scan
custom resources once rather than once per namespace.
A controller with some ready pods (mid-rollout, or HA with a ready leader)
may be doing the cleanup, so the removal patch is no longer offered then;
severity is only capped when every pod is healthy. Karpenter's provider
finalizers (karpenter.k8s.aws, karpenter.azure.com) map to Karpenter.
Generic condition rows on deleting objects stay visible as before, the
GitOps controller line no longer reads degraded during a rollout, the
shared helpers are documented (including that a finalizer owner may have
no namespace), and tests pin that both issues handlers pass the list check.
A stuck custom resource now gets a ready-made finalizer-removal patch only
when Radar identified the finalizer's controller (catalog or workload match)
and saw it stopped after a complete cluster-wide workload search. Unknown
controllers, which today include Crossplane, Cluster API, Config Connector,
ACK and Strimzi, get advice to find and check the controller, no patch, and
stay at warning instead of going critical.

Karpenter and Argo CD can run off-cluster (EKS Auto Mode, AKS node
auto-provisioning, Amazon EKS Capabilities), so finding none of their pods
is reported as unconfirmed, not gone. Karpenter's finalizers drain nodes and
terminate instances, so it never gets a removal patch; a self-hosted
Karpenter seen down is critical with advice to restore it.

x-k8s.io finalizers named for in-use or protection (Kueue resource-in-use)
are treated as in-use guards. Flux ImageUpdateAutomation now resolves to
image-automation-controller.
@cursor

cursor Bot commented Oct 10, 2026

Copy link
Copy Markdown

Bugbot needs on-demand usage enabled

Bugbot uses usage-based billing for this team and requires on-demand usage to be enabled.

A team admin can enable on-demand usage in the Cursor dashboard.

A controller with a workload but no ready pod (crashlooping, image pull
failure, pending, or unable to create pods) was treated as stopped and
offered the finalizer-removal patch. It will finish the deletion once
fixed, so removing its finalizer skips cleanup it still intends to do.

Only an absent controller now gets the patch: a complete search found no
matching workload or catalog pod, or its Deployment or StatefulSet is
scaled to 0. An installed controller with no ready pod stays critical
after the threshold with advice to fix it and no patch.

Absence also needs DaemonSets searched, catalog workloads match on their
pod-template labels, and catalog pods outside the matched workloads are
checked before a scaled-down copy counts as absent. The in-use guard
advice no longer lists examples that only fit some guards.
Terminating rows carried the generic creation/after-healthy timing chip,
so a row whose clock counts from deletionTimestamp read "30m since
creation". Rows with deletion-based timing now read "since deletion"
and "pending since deletion was requested", for typed and custom
resources alike.

The termination_stuck catalog description no longer claims the owning
controller is unhealthy; Radar also raises it when the controller is
healthy or unknown.
…ller absent

A stopped in-cluster Argo CD or Karpenter copy no longer counts as
absent: a managed install outside the cluster may still own the
finalizer, so it is reported as unconfirmed with no removal patch.
Catalog controller pods are searched cluster-wide rather than only in
the conventional namespace. A healthy install beside a crashlooping or
partly ready one no longer caps the issue at warning.
…atalog pod

The cluster-wide catalog search could find a ready pod elsewhere and
mark the controller healthy while a matched workload was crashlooping,
capping the stuck deletion at warning.
@cursor

cursor Bot commented Oct 11, 2026

Copy link
Copy Markdown

Bugbot needs on-demand usage enabled

Bugbot uses usage-based billing for this team and requires on-demand usage to be enabled.

A team admin can enable on-demand usage in the Cursor dashboard.

…ditional cleanup

Catalog controllers (Argo CD, Flux, VictoriaMetrics, Karpenter) are now
also recognized by the repository path of their published image, on
pods and on Deployment/StatefulSet/DaemonSet pod templates, ignoring
registry host, tag and digest. A renamed install (Helm nameOverride) or
a mirror that keeps the path is recognized; a renamed Deployment with
replicas but no pods reads as down. Argo CD's shared image counts only
when the container runs argocd-application-controller.

A search that recognizes nothing is no longer called absent. It reads
"No recognized controller found" at warning, and a catalog controller
scaled to 0 lands in the same bucket. The next step is to restore the
controller; the finalizer removal moves out of the action into a new
additive conditional_cleanup field (condition, agent guidance, caution,
and the UID/index-tested dry-run patch per finalizer). The MCP issues
description tells agents to propose it only after the user confirms the
condition, and the Issues UI shows it as a collapsed section labeled
with the condition.

A first-DNS-label name match (cluster.example.io vs
rabbitmq-cluster-operator) now names a candidate only: it never decides
the controller's state, the severity, or a cleanup.

Recognized workloads and catalog pods outside them are always merged,
so a healthy workload no longer hides a crashlooping copy elsewhere.

Orphan-only finalizers say orphaning keeps the dependents. With the Pod
cache unavailable, a catalog owner is still named and only its state is
reported as unknown.
@cursor

cursor Bot commented Oct 12, 2026

Copy link
Copy Markdown

Bugbot needs on-demand usage enabled

Bugbot uses usage-based billing for this team and requires on-demand usage to be enabled.

A team admin can enable on-demand usage in the Cursor dashboard.

@nadaverell
nadaverell merged commit fbfe986 into main Oct 12, 2026
9 checks passed
@nadaverell
nadaverell deleted the feat/stuck-terminating-custom-resources branch October 12, 2026 21:43
nadaverell added a commit that referenced this pull request Oct 12, 2026
…claims, Released PVs) (#2055)

In a real cleanup every piece of unused storage was found by hand:
- about 311Gi of Loki and Mimir disks left behind after their apps were
deleted
- about 45Gi of 2.7-year-old PVCs in `default`
- an 8Gi Postgres PVC with no workload
- a PVC that survived its Helm uninstall
- two PVCs Pending for about three years on a WaitForFirstConsumer
storage class, which the issue detector deliberately ignores
- Released PVs stuck in a `VolumeFailedDelete` retry loop

Cluster Audit now reports these as **snapshot** Efficiency checks
(Medium on the existing Checks and MCP `get_cluster_audit` surfaces):

| Check | Reports |
|---|---|
| `pvcNoConsumer` | Bound PVCs older than a day with **no consumer Radar
can observe**: Pods, Deployments, ReplicaSets, StatefulSets (including
zero replicas and leftover ordinals after a scale-down), DaemonSets,
Jobs, CronJobs, generic ephemeral volumes, and Pending PVC clones (a
clone protects its source only while it is Pending). On Kubernetes
1.37+, the claim's `Unused` condition is used when present (below).
Shows size, storage class, age, bound PV and reclaim policy. |
| `pvcLongPending` | PVCs Pending for over 24h with no observed
consumer, WaitForFirstConsumer included. Shown as "Pending since
creation"; a claim never returns to Pending. A Pending claim that *has*
a consumer is already an outage issue, so it isn't double-reported as
waste. |
| `releasedPV` | Released PVs: those kept by a `Retain` policy (after a
24h grace when release time is known, or flagged with "release time
unknown" and PV age when it is not), and those whose deletion hasn't
completed after an hour of known release duration. A recent
`VolumeFailedDelete` warning establishes deletion failure immediately,
even without a phase-transition timestamp. Delete PVs without that
timestamp or a recent warning are not evaluated. **Failed** PVs (an
in-tree delete or recycle failed after the claim was deleted) are
reported immediately, with the latest current
`VolumeFailedDelete`/`VolumeFailedRecycle` warning or else the PV's
`status.message`. |

**Not waste by construction:**
- **Owned PVCs are skipped.** A PVC with a controller owner reference,
such as a hibernated CloudNativePG cluster's volumes, a stopped KubeVirt
VM's disks, or workflow `volumeClaimTemplates`, follows its owner's
lifecycle, so these checks leave it to the owner.
- **The check says "no consumer observed"**, never "unused" or "safe to
delete". The caveat about consumers Radar can't see (CRDs mounting
volumes intermittently, snapshot operations) lives in the check
description (shown once per check) and the remediation (returned with
every MCP finding), not on every row. Rows state facts only: size,
class, age, bound PV, reclaim policy, and the `Unused` time or
binding-mode evidence where present.
- **Remediation says how to reclaim.** No consumer: if nothing needs the
data, delete the claim; if its PV is Retain, reclaim that too. Retain
PVs: deleting the PV keeps its disk; for a dynamically provisioned
volume set the reclaim policy to Delete, otherwise delete the PV and
then the disk in your cloud provider. Both keep the "confirm nothing
needs the data" step first.

**Kubernetes 1.37 `Unused` condition** (PVC protection controller, beta,
on by default):
- `Unused=False` (a running Pod uses the claim) counts as a consumer,
even if Radar's Pod inventory missed it.
- With `Unused=True`, the 24h floor counts from when the claim became
unused instead of from its creation, so a claim whose workload was
deleted minutes ago isn't flagged. The finding adds "Kubernetes reports
it unused by any Pod since <time> (5d ago)". It reports the condition
rather than promising a minimum idle time: upstream notes the time can
be off in either direction because of controller delays. Right after the
feature is enabled (e.g. an upgrade to 1.37), every unused claim's
timestamp is the enable time, so long-unused claims are held back for a
day and then reported with that shorter age.
- No condition or `Unknown` keeps the creation-age behavior; absence
never implies unused. `pvcLongPending` doesn't use it.

**Deleting claims and volumes → `stuckTerminating`:**
- PVCs and PVs are now `stuckTerminating` subjects, with the same 5m/30m
ramp as other kinds. Deleting PVCs were already skipped by the storage
checks; deleting PVs are now skipped by `releasedPV` too, so a deleting
Bound PV no longer counts as a pass and a deleting Released PV isn't
reported twice.
- For `kubernetes.io/pvc-protection` and `kubernetes.io/pv-protection`,
the finding explains what holds the deletion and what happens when it's
released (the claim is deleted and the volume follows its reclaim
policy), suggests a backup if the deletion was unintended, and says not
to remove the finalizer. It never tells the user to stop Pods or delete
the claim: a deletion can't be cancelled, and completing it can delete
the data.
- The shared `stuckTerminating` remediation (shown in Checks and MCP)
now exempts storage finalizers from its "clear finalizers as a last
resort" advice.

**Coverage:**
- Inputs are gated on cache readiness. PVs and StorageClasses are
cluster-scoped and checked against the caller's exact grants per kind;
PVCs, workload templates, and events follow namespace visibility.
- `missingInputs` keeps its existing meaning: something was not
evaluated. A storage input is recorded only when its absence left a
claim or volume unevaluated, so unreadable evidence never looks like a
clean pass:
  - an unreadable PVC inventory (`persistentvolumeclaims`)
  - unknown consumers (`pvc-consumers`)
- a PV inventory the caller may read but Radar couldn't list
(`persistentvolumes`)
- a Released Delete volume still in its 1h grace period whose outcome
depends on events Radar can't see (`pv-deletion-events`)
- a Released Delete volume with a nil or zero phase-transition timestamp
and no recent deletion warning: release duration is unknown, so PV
creation age cannot establish a stalled deletion or a pass
(`pv-release-time`)
- Gaps that still let a finding fire are stated in the finding instead:
"reclaim policy not visible", "StorageClass binding mode not visible",
"Deletion warning events not fully visible". Radar's "Some checks could
not run" banner and Hub's "checks skipped" copy stay accurate, so no
consumer change is needed.
- Without a PV grant, Released PVs aren't subjects of that caller's
scan, so namespace-restricted users don't get a permanent missing input.
- **Failed PVs never pass and are never skipped.** The PV controller
sets `Failed` only when reclaiming failed, so the phase alone
establishes the finding. Events only add detail; incomplete coverage
adds the "Deletion warning events not fully visible" caveat, never
`pv-deletion-events`. The controller emits the warning once, at the
transition, and events expire (1h by default), so `status.message` is
the fallback.
- **What `releasedPV` counts:** Bound PVs pass, Released and Failed PVs
are judged, and Available/Pending PVs are not counted. They have no
claim to have outlived, so counting them as passes would only inflate
the pass rate (for example, a local-static-provisioner pool).

No Radar-kept usage history, cost column or delete action; those are
follow-ups. `pkg/audit` changes are additive.

**Testing**
- Table tests cover Pods, Deployments, ReplicaSets, StatefulSets,
DaemonSets, Jobs, CronJobs, generic ephemeral volumes, and Pending
clones through both reference fields (including owned, young, and
deleting destinations). Cross-namespace `dataSourceRef` references
protect only the source in the referenced namespace. Bound clones,
non-core references, and VolumeSnapshot references do not protect a
source. Tests also cover StatefulSet replicas 0 and leftover ordinals,
controller-owned (CNPG) claims, the 24h age floor at a fixed clock,
WaitForFirstConsumer with and without consumers, and Retain vs Delete
with and without events. Nil and zero release timestamps cover Delete
skips, immediate recent-warning findings, and Retain findings with
unknown release time and PV age. They also cover missing inputs only for
skipped subjects (otherwise the caveat is in the finding), PV
inventories that are unavailable vs. outside the caller's grant, and
unreadable namespaces suppressing findings.
- `Unused` condition: False with no observed consumer and with
incomplete inventory, True 2h ago on a 30-day-old claim, the exact 24h
boundary, future and zero timestamps, True 10 days ago with the evidence
sentence, zero-replica StatefulSet and Pod consumers, absent and
`Unknown` conditions, and Pending claims ignoring it.
- Audit counts, MCP audit scope (including `pv-release-time` appearing
only with a PV grant), and the `ChecksView` input labels.
- Failed PVs: a Delete PV with a current warning (event text and age),
with only `status.message`, with partial or unreadable events (caveat,
evaluated, not passed, no missing input), with a warning older than the
Failed transition (ignored), Recycle with `VolumeFailedRecycle`, and a
policy switched to Retain after the failure. Counts: Bound passes;
Failed is evaluated but not passed; Available and Pending are not
counted. `internal/audit` collects events for Failed PVs.
- `make test` and `make tsc` pass. The original change was checked live
on a kind cluster (an unconsumed bound PVC and a Released Retain PV
through REST and MCP).

**Live verification** (kind, Kubernetes v1.36.1 so
`lastPhaseTransitionTime` is populated; real binary in local and
proxy-auth modes; REST + MCP + Checks UI)
- **Earlier round** (independent live tester, head `3dc5f6a3`):
  - The prior proof re-run passed.
- Unreadable StorageClasses produce the "binding mode not visible"
caveat and no missing input.
- A namespace user without a PV grant gets no `persistentvolumes`
missing input.
  - A real skip (no Pod list) is listed as `pvc-consumers`.
- A Released Delete PV past its grace period is flagged, with the "not
fully visible" caveat under incomplete events.
- A CSI `VolumeFailedDelete` on a Released PV is reported as "deletion
is failing".
  - Warnings from before the current release are ignored.
  - The Checks view shows the "could not run" banner only on real skips.
  - MCP `get_cluster_audit` matches REST.
- The WaitForFirstConsumer, default-class and class-not-found messages
(readable StorageClasses) are covered by unit tests only.
- **That round found this bug:** a hostPath Delete PV whose in-tree
deletion failed went to `Failed` with a real `VolumeFailedDelete` event,
and was counted as a `releasedPV` **pass**.
- **After this fix** (head `4be95ee8`: the binary was built from
`3dc5f6a3` plus the uncommitted fix, whose diff is identical to the
`4be95ee8` commit; same repro: hostPath Delete PV at `/var/...`, claim
deleted, PV `Failed` with `status.message` set and one event in
`default`):
- Admin view, `releasedPV` evaluated 2, passed 1 (the Bound PV passes,
the Failed PV is flagged): `Failed PV: reclaiming the volume failed
after its claim was deleted (reclaim policy Delete). Latest reclaim
warning (2m ago): host_path deleter only supports /tmp/.+ but received
provided /var/fix2055-delete-fail. Capacity 1Mi; storage class "";
former claim fix2055-f3/claim-delete-fail; Failed for 2m.` MCP shows the
same text.
- A proxy-auth user with a PV grant who sees only the fixture namespace,
so the event in `default` is hidden: the same finding, carrying the
`Status message: host_path deleter only …` text plus "Deletion warning
events not fully visible". There is no `pv-deletion-events` missing
input, and REST and MCP match.
- The Checks UI renders the finding under "Released or Failed persistent
volume".
- Documentation commits and the README check-count test followed those
live checks. The release-time uncertainty and Pending-clone scenarios
have unit and MCP-handler coverage; their live verification remains
pending.

**Live verification on GKE 1.37.1** (nonprod control plane upgraded to
1.37.1; current head)
- The controller set `Unused=False` on a claim with a running Pod and
`Unused=True` (timestamped at Pod deletion) on two claims whose Pods
were deleted. A `standard-rwo` (WaitForFirstConsumer) clone of one of
them stayed Pending.
- A snapshot of those live objects ran through the audit with creation
and unused times shifted back to clear the 24h floor:

| Variant | Result |
|---|---|
| As captured (claims minutes old) | No storage findings; only the
pre-existing in-use claim evaluated, passing |
| Created 10d ago, unused 5d | `orphan` flagged with the Unused time (5d
ago); `clone-source` protected by the Pending clone; `in-use` passes;
the abandoned Pending clone flagged as `pvcLongPending` |
| Same, in-use Pod removed from the snapshot | `in-use` still passes on
`Unused=False` |
| Created 10d ago, unused since minutes ago | `orphan` and
`clone-source` held back (not evaluated) |
| Same as row 2, clone removed | `clone-source` flagged |

- Deleting PVC/PV routing, protection-finalizer copy (including that it
never advises completing the deletion) and the shared remediation's
storage exception are covered in `pkg/audit` tests; all three fail on
the previous head.
- `make test`, `make tsc` and the audit/MCP suites pass on the current
head, after merging main (which brought #2053).

**Live check of stuck deletion** (GKE 1.37.1, head `9ea8f6871`): a PVC
still mounted by a running Pod was deleted, then its bound PV. After 6
minutes both were reported by `stuckTerminating` as warnings: the PVC
held by `kubernetes.io/pvc-protection`, the PV by
`kubernetes.io/pv-protection` plus the two CSI finalizers, each with the
protection explanation. `releasedPV` counted only the cluster's other
(Bound) PV, so the deleting PV was neither passed nor double-reported.
Deleting the namespace then removed the PVC, PV and disk.

**Docs**
- README states the Cluster Audit check count again (44, the size of
`CheckRegistry`, which now includes the Persistent Disk placement check
main added in #2069); `TestREADMECheckCountMatchesRegistry` fails when
the two drift.
- The storage review checks section in `docs/configuration.md` describes
the consumer inventory, release-time uncertainty, thresholds, and
evidence gaps; the MCP audit-access bullet links to it instead of
repeating it.

**Rollout and compatibility**
- The MCP `get_cluster_audit` description now lists "stale PVCs/PVs"
under Efficiency; the short wording keeps the tool catalog within
`TestToolCatalogContextBudget` after main's growth.
- The three checks are on by default at Medium, so posture counts
(Medium findings, per-check pass rates) shift on upgrade for any cluster
with unclaimed PVCs or Released/Failed PVs. Each check can be disabled
in audit settings.
- `releasedPV` counts Bound PVs as passes; Available and Pending PVs are
not counted at all.
- New `missingInputs` keys: `pvc-consumers`, `pv-deletion-events`,
`pv-release-time`, plus `persistentvolumeclaims` / `persistentvolumes`
when those inventories are unavailable. They appear only when a claim or
volume went unevaluated, so Radar's "Some checks could not run" banner
and Hub's "checks skipped" copy stay accurate.
- `pkg/audit` changes are additive: new `CheckInput` fields and one
exported predicate, `PVNeedsReclaimEvents`. Radar Hub imports
`pkg/checks`, not `pkg/audit`, and keys checks by ID (titles are
display-only), so an older Hub renders the new checks from the cluster's
catalog. No new endpoint, so no feature flag is needed.

**Known limits (product calls, not bugs)**
- Findings within a check are ordered by severity, then kind, namespace
and name (`pkg/checks.sortEffectiveFindings`), not by size, so a large
disk can sit behind "View all". Size-first ordering needs a field on the
shared `pkg/checks` finding that Hub would also carry; it is a
follow-up.
- `pvcLongPending` stays Medium like the other two checks. A Pending
claim has no disk yet, but the detector can only emit High or Medium;
Low is an org override in Hub.
- StatefulSet scale-down leftovers are not flagged: every claim-template
ordinal counts as consumed, so `data-kafka-4` after a 5→3 scale-down
stays silent.
- Consumers Radar can't see cause false positives, each worded "no
consumer observed" with the CRD caveat. Examples: JupyterHub
`claim-<user>` after culling, CI caches and backup targets mounted by
short-lived Pods, KubeVirt VMs with direct PVC volumes, and snapshot
sources or clone operations beyond the observed Pending PVC references.
Ignored namespaces or disabling the check are the escape hatches.
- PVC subjects follow namespace visibility, as other namespaced audit
subjects do. Like those kinds, PVCs in a namespace that a
namespace-scoped informer doesn't cover are not reported as a missing
input.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant