Skip to content

Explain volume attach failures from incompatible disk types; audit PD volumes that can land on Hyperdisk-only nodes - #2069

Merged
nadaverell merged 3 commits into
mainfrom
fix/volume-attach-disk-compat
Oct 11, 2026
Merged

nadaverell merged 3 commits into
mainfrom
fix/volume-attach-disk-compat

Conversation

@nadaverell

@nadaverell nadaverell commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Why

A prod Redis StatefulSet was rescheduled onto an n4d-standard-4 node after a node-pool change. N4D machines attach only Hyperdisk, and Redis's volumes are premium-rwo (pd-ssd), so the volume never attached and the pods stayed in ContainerCreating for hours.

Radar reported the attach failure, but with the wrong event. diagnose and issues both showed:

ControllerPublish not permitted on node "…6uw0" due to backoff condition

That is the CSI attacher's retry notice. The event that names the cause was older and was dropped, because post-bind detection keeps only the latest event per pod:

googleapi: Error 400: pd-ssd disk type cannot be used by n4d-standard-4 machine type., badRequest

What changes

Diagnose and issues (post-bind detection)

  • When the latest attach event is a backoff echo, report the newest informative attach error for the same pod UID and the same volume. Onset is taken from the first real failure.
  • Parse Compute Engine's disk/machine incompatibility into a plain Cause and Action: "pd-ssd volumes can't attach to n4d-standard-4 nodes: the N4D machine series supports only Hyperdisk". The action offers two fixes: pin the workload to a compatible pool, or move it to a Hyperdisk StorageClass that every eligible pool supports.
  • When only echoes remain (the real event has expired), derive the same cause from the node's node.kubernetes.io/instance-type and the PV's provisioned disk type.
  • diagnose's startupBlockers now carry cause and action. Both are optional additions.
  • Events recorded against a predecessor pod's UID are ignored. StatefulSet pods are recreated under the same name, so without this a dead pod's error could be shown for its replacement.

Cluster Audit: new check pdVolumeHyperdiskOnlyNode (Reliability)

  • Flags a workload when it mounts a GCE Persistent Disk volume (pd-*) and its pods could be scheduled onto a node from a Hyperdisk-only machine series.
  • Constraints come from the owner's current pod template (Deployment, StatefulSet or DaemonSet). Node selector, required node affinity, volume node affinity and zone/region labels (including beta PV labels matched to stable node labels), and NoSchedule/NoExecute taints are all evaluated. Inter-pod affinity and topology spread are not, and the message says only what was checked.
  • Severity is danger when a pod is already on such a node, and warning when one could land there.
  • The disk type is trusted only for dynamically provisioned PVs (pv.kubernetes.io/provisioned-by). A static PV that merely borrows a class name is skipped. StorageClass parameter keys are matched case-insensitively, as the PD CSI driver matches them.
  • Evidence is gated per reader on list access to nodes, persistentvolumes and storageclasses. A reader missing any of them gets no evidence, and the check is silent.
  • Hyperdisk-only series come from a fixed table based on Compute Engine's disk-support matrix: A4/A4X, C4/C4A/C4D/C4N, G4, H4D, M4/M4N, N4/N4A/N4D, X4/X5, Z4D. Series with partial PD support (C3, C3D, H3) and unknown series are never flagged, so the table can only under-report.

New package pkg/gkedisk holds the shared knowledge. pkg/audit.CheckInput gains a PDPlacement field. Both are additive for Hub.

Decisions

Scenario ledger

Scenario Expected Proof
Real GCE error older than 10 min, latest event is a backoff echo Message shows the real error; Cause/Action set; onset from first failure TestDetectPostBindProblems_AttachBackoffShowsRealCause (fails on main)
Only echoes remain; node n4d, PV pd-ssd Cause derived from cache …AttachBackoffOnlyDerivesCause (fails on main)
Echo on a PD-capable node No cause invented …AttachBackoffOnCompatibleNode
Predecessor pod UID's events, old or recent Not used for the replacement …AttachIgnoresPredecessorPodEvents
Two volumes; echo for A, real error for B A keeps its own echo …AttachEchoMatchesItsOwnVolume
Unrelated attach error (timeout) No disk cause …UnrelatedAttachFailureHasNoDiskCause
Unpinned STS on pd-ssd, cluster has N4D Warning on the StatefulSet TestPDPlacement_UnpinnedStatefulSetReachesHyperdiskOnlyNode, TestRunFromCacheFlagsPDVolumeReachingHyperdiskOnlyNode
Pod already on N4D Danger …PodAlreadyOnHyperdiskOnlyNodeIsDanger
nodeSelector / NotIn affinity / untolerated taint Evaluated, passes …ConstraintsThatKeepPodsOff
Zonal disk, N4D only in another zone (nodeAffinity or zone labels) No finding …VolumeZoneKeepsPodOffOtherZones, …LegacyVolumeZoneLabel
PV has beta zone/region labels, node has stable labels Matching nodes remain eligible; mismatches are excluded …LegacyVolumeZoneLabel
Template pin added or removed vs running pod Template wins …ConstraintsComeFromOwnerTemplate
Mixed-case StorageClass Type: hyperdisk-balanced Classified as Hyperdisk, never as the PD default TestDiskType
Static PV, Hyperdisk volume, non-GCE storage, no N4D nodes Not evaluated TestDiskType, TestPDPlacement_NotApplicable
Reader can't list StorageClasses or nodes No evidence, no finding TestReadScopeResolvesPDPlacementGrants, denied-scope case in the runner test

Verification

  • make tsc passes.
  • make test passes.
  • cd pkg && go test ./audit ./gkedisk passes, including matching and mismatching beta-PV/stable-node zone and region cases.
  • make build passes (frontend, embed, Go binary).
  • Visual-test: skipped (backend diagnostics and audit logic; no rendered UI changes).

… volumes that can land on Hyperdisk-only nodes

Post-bind detection kept only the latest attach event per pod. Once the CSI
attacher backs off, that event is "ControllerPublish not permitted ... due to
backoff condition", which hides the Compute Engine error naming the cause
(e.g. "pd-ssd disk type cannot be used by n4d-standard-4 machine type").

- Show the newest informative attach error for the same pod UID and volume
  instead of the backoff echo, with onset from the first real failure.
- Parse Compute Engine's disk/machine incompatibility into Cause/Action; when
  only echoes remain, derive it from the node's machine type and the volume's
  provisioned disk type. diagnose's startupBlockers now carry cause/action.
- Reject events recorded against a predecessor pod UID (StatefulSet pods are
  recreated under the same name).
- New Cluster Audit check pdVolumeHyperdiskOnlyNode: workloads whose PD-backed
  volumes can be scheduled onto nodes from Hyperdisk-only machine series,
  evaluated against the owner's template, volume node affinity/zone labels and
  taints. Danger when a pod already sits on such a node. Evidence is gated on
  the reader's list access to nodes, persistentvolumes and storageclasses.
@nadaverell
nadaverell requested a review from hisco as a code owner October 11, 2026 15:17
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Explain incompatible disk attaches and audit risky PD placement

🐞 Bug fix ✨ Enhancement 🧪 Tests 🕐 40+ Minutes

Grey Divider

AI Description

• Replace CSI backoff echoes with the matching attach failure and explain incompatible disk types.
• Audit workloads whose Persistent Disks can reach Hyperdisk-only nodes, subject to reader
 permissions.
• Add coverage for event correlation, disk provenance, scheduling constraints, and audit visibility.
Diagram

graph TD
  Cache[("Resource cache")] --> Detector["Post-bind detection"] --> Surfaces["Diagnose and issues"]
  Cache --> Evidence["Scoped evidence"] --> Check["Placement audit"] --> Findings["Reliability findings"]
  Rules["GKE disk rules"] --> Detector & Evidence & Check
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Simulate full Kubernetes scheduling
  • ➕ Could account for inter-pod affinity, topology spread, and other scheduler filters.
  • ➖ Substantially more complex and costly to maintain against scheduler behavior.
  • ➖ Requires broader cluster state and authorization decisions.

Recommendation: Keep the bounded placement check: it evaluates the principal node and volume constraints, states its limits, and keeps cluster-wide evidence permission-gated. A full scheduler simulation is not justified for this targeted incompatibility.

Files changed (15) +1307 / -6

Enhancement (9) +502 / -0
pd_placement.goCollect permission-gated PD placement evidence +77/-0

Collect permission-gated PD placement evidence

• Collects Hyperdisk-only nodes and bound claims’ PVs and disk types from synced, cluster-wide caches. Returns no evidence unless the reader can list nodes, PVs, and StorageClasses.

internal/audit/pd_placement.go

runner.goFeed PD placement evidence into audit checks +1/-0

Feed PD placement evidence into audit checks

• Adds placement evidence collection to the cache-backed audit runner before checks execute.

internal/audit/runner.go

scope.goResolve cluster-resource grants for placement audits +6/-0

Resolve cluster-resource grants for placement audits

• Includes nodes, PVs, and StorageClasses when resolving a reader’s cluster-scoped list permissions.

internal/audit/scope.go

tools_diagnose.goExpose causes and actions in startup blockers +4/-0

Expose causes and actions in startup blockers

• Adds optional cause and action fields to diagnose startup blockers and copies them from post-bind detections.

internal/mcp/tools_diagnose.go

checks.goRun the PD placement reliability check +6/-0

Run the PD placement reliability check

• Registers the new placement evaluation in the audit check runner; absent evidence leaves it silent.

pkg/audit/checks.go

disk_compat.goAssess PD workload reachability to incompatible nodes +265/-0

Assess PD workload reachability to incompatible nodes

• Adds a workload-level check using owner templates, node constraints, volume topology, and taints. Reports a warning for reachable Hyperdisk-only nodes and danger when a PD-backed pod is already placed on one.

pkg/audit/disk_compat.go

registry.goDocument the new reliability check +9/-0

Document the new reliability check

• Adds the check’s title, description, remediation options, and links to disk-support and scheduling references.

pkg/audit/registry.go

types.goAdd optional PD placement input +5/-0

Add optional PD placement input

• Extends CheckInput with a separate, optional evidence field for the new placement check.

pkg/audit/types.go

gkedisk.goShare GKE disk and machine compatibility rules +129/-0

Share GKE disk and machine compatibility rules

• Introduces conservative Hyperdisk-only series classification, attach-error parsing, and cause/action text. Determines disk types only for GCE volumes with dynamic-provisioning provenance and a matching StorageClass.

pkg/gkedisk/gkedisk.go

Bug fix (1) +120 / -3
detect_scheduling.goExplain the real cause behind attach backoffs +120/-3

Explain the real cause behind attach backoffs

• Correlates attach events by current pod and volume, substituting an informative failure for a later backoff echo. Produces cause and action from the Compute Engine error or, when only echoes remain, from cached node and provisioned-volume data.

internal/k8s/detect_scheduling.go

Tests (5) +685 / -3
pd_placement_test.goTest cached audit findings and denied access +72/-0

Test cached audit findings and denied access

• Exercises the cache-to-audit path for a PD-backed StatefulSet and verifies that a reader without cluster-resource grants receives no placement finding.

internal/audit/pd_placement_test.go

scope_test.goVerify placement grant resolution +37/-3

Verify placement grant resolution

• Tests that placement resources receive cluster-scoped grant checks and that missing StorageClass access suppresses evidence. Adjusts existing scope tests for the additional grant lookups.

internal/audit/scope_test.go

detect_postbind_attach_test.goCover attach backoffs and event correlation +185/-0

Cover attach backoffs and event correlation

• Tests recovery of older informative failures, cache-derived causes, predecessor UID rejection, and per-volume matching. Also verifies that compatible nodes and unrelated timeouts receive no disk-compatibility cause.

internal/k8s/detect_postbind_attach_test.go

disk_compat_test.goTest placement constraints and finding severity +281/-0

Test placement constraints and finding severity

• Covers owner attribution, template changes, node selectors, affinity, taints, volume zones, and warning-versus-danger findings. Confirms that inapplicable evidence produces no evaluation.

pkg/audit/disk_compat_test.go

gkedisk_test.goTest disk classification and attach explanations +110/-0

Test disk classification and attach explanations

• Tests machine-series classification, error and backoff recognition, disk-type provenance, and wording for fully versus partially PD-compatible series.

pkg/gkedisk/gkedisk_test.go

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

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

Copy link
Copy Markdown

Code Review by Qodo

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

Grey Divider


Remediation recommended

1. Old replicas hide disk placement risks 🐞 Bug ≡ Correctness
Description
pdPlacementOwner groups ReplicaSet pods under a Deployment, but pdPlacementTemplates then
evaluates the Deployment's current template instead of the ReplicaSet template that creates those
pods. During a rollout, an older ReplicaSet can still create pods with different node constraints,
so a newly pinned Deployment can incorrectly appear safe while those replicas remain eligible for
Hyperdisk-only nodes.
Code

pkg/audit/disk_compat.go[R190-192]

+	for _, d := range input.Deployments {
+		out[ResourceKey("apps", "Deployment", d.Namespace, d.Name)] = &d.Spec.Template.Spec
+	}
Evidence
The owner lookup maps a ReplicaSet-controlled pod to its Deployment, and that key selects only the
Deployment template. The repository also uses the UID-matched ReplicaSet template when it needs the
spec that creates replacement pods.

pkg/audit/disk_compat.go[104-110]
pkg/audit/disk_compat.go[188-199]
pkg/audit/disk_compat.go[213-221]
internal/issues/provider_pod_template.go[54-66]

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

## Issue description
Deployment-level attribution currently substitutes the Deployment's current template for the template of the ReplicaSet that creates each pod.
## Fix Focus Areas
- pkg/audit/disk_compat.go[88-110]
- pkg/audit/disk_compat.go[188-224]
## Recommended Fix
Keep findings attributed to the Deployment, but evaluate each pod's replacement eligibility using its UID-matched owning ReplicaSet template. Cover a rollout where old and new ReplicaSets have different node constraints.

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


2. Volume changes trigger misleading audits 🐞 Bug ≡ Correctness
Description
checkPDVolumeHyperdiskPlacement takes PVCs and bound-volume constraints from the observed pod but
takes node constraints from its owner's current template. If a Deployment template removes a
Persistent Disk claim while an older pod still mounts it, the check can warn that replacement pods
will fail to attach a volume they no longer use; adding a claim can likewise leave a replacement
risk unevaluated.
Code

pkg/audit/disk_compat.go[R73-77]

+		for _, vol := range pod.Spec.Volumes {
+			if vol.PersistentVolumeClaim == nil {
+				continue
+			}
+			key := pod.Namespace + "/" + vol.PersistentVolumeClaim.ClaimName
Evidence
Claims and PVs are collected exclusively from the observed pod before the check substitutes the
owner template for scheduling. The collector's evidence is keyed by bound claims, so that
substitution does not update the volume set being assessed.

pkg/audit/disk_compat.go[71-85]
pkg/audit/disk_compat.go[102-110]
internal/audit/pd_placement.go[62-74]

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 placement check combines an observed pod's PVCs with its owner's current scheduling template, which can describe a different set of volumes.
## Fix Focus Areas
- pkg/audit/disk_compat.go[71-110]
- pkg/audit/disk_compat.go[188-199]
## Recommended Fix
Evaluate replacement placement against the claims in the template that creates the replacement pod, resolving their bound PV constraints where available. Keep the observed pod's claims for the separate already-placed assessment, and test a template that adds or removes a claim.

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


3. Attach echoes blame the wrong disk ✓ Resolved
Description
attachFailureDiagnosis handles a backoff echo by returning an incompatibility for any Persistent
Disk mounted by the pod, without checking which volume emitted the echo. On a Hyperdisk-only node
with multiple PVCs, an echo for a Hyperdisk volume can therefore receive a Persistent Disk cause and
action even when the event has no informative predecessor.
Code

internal/k8s/detect_scheduling.go[R1614-1616]

+	for _, diskType := range podGCEDiskTypes(cache, pod) {
+		if gkedisk.IsPersistentDisk(diskType) {
+			return gkedisk.IncompatibilityCause(diskType, machineType), gkedisk.IncompatibilityAction(diskType)
Evidence
Event selection parses the echoed volume only for informative-event lookup, then calls diagnosis
without that identity. The fallback iterates disk types for every PVC on the pod and returns upon
finding any Persistent Disk.

internal/k8s/detect_scheduling.go[1444-1453]
internal/k8s/detect_scheduling.go[1587-1595]
internal/k8s/detect_scheduling.go[1607-1616]
internal/k8s/detect_scheduling.go[1649-1664]

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 backoff fallback diagnoses every mounted GCE disk rather than the volume named by the selected attach event.
## Fix Focus Areas
- internal/k8s/detect_scheduling.go[1444-1453]
- internal/k8s/detect_scheduling.go[1603-1619]
- internal/k8s/detect_scheduling.go[1633-1666]
## Recommended Fix
Pass the selected event's volume identity into the fallback and resolve only that volume's PVC and provisioned disk type. Leave cause and action empty when the echoed volume cannot be identified; test an echo for a Hyperdisk volume alongside a separate Persistent Disk PVC.

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


View medium (1)
4. Hyperdisk attach errors get the wrong cause and fix ✓ Resolved
Description
ParseIncompatibleAttach accepts any disk type, but IncompatibilityCause and
IncompatibilityAction assume it is a Persistent Disk. A Hyperdisk volume the machine can't take
(e.g. hyperdisk-extreme disk type cannot be used by n4-standard-4 machine type) gets the cause
"hyperdisk-extreme volumes can't attach to n4 nodes: the N4 machine series supports only Hyperdisk",
which contradicts itself, and a Hyperdisk volume on a PD-only series (e.g. n2) is told to "move the
data to a Hyperdisk StorageClass", which is the type that already fails.
Code

pkg/gkedisk/gkedisk.go[R117-122]

+func IncompatibilityCause(diskType, machineType string) string {
+	if HyperdiskOnly(machineType) {
+		return fmt.Sprintf("%s volumes can't attach to %s nodes: the %s machine series supports only Hyperdisk.",
+			diskType, machineType, strings.ToUpper(MachineSeries(machineType)))
+	}
+	return fmt.Sprintf("Compute Engine doesn't allow %s disks on %s machines, so the volume never attaches.", diskType, machineType)
Evidence
attachFailureDiagnosis passes whatever disk type the Compute Engine error contains straight into
IncompatibilityCause/IncompatibilityAction (detect_scheduling.go 1604-1605) and never checks
IsPersistentDisk. IncompatibilityCause picks the "supports only Hyperdisk" wording based only on
HyperdiskOnly(machineType), and IncompatibilityAction always recommends a Hyperdisk
StorageClass. Both strings end up in the issues and diagnose Cause/Action fields.

pkg/gkedisk/gkedisk.go[64-75]
pkg/gkedisk/gkedisk.go[116-129]
internal/k8s/detect_scheduling.go[1603-1606]

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

## Issue description
`IncompatibilityCause` uses the "supports only Hyperdisk" wording, and `IncompatibilityAction` recommends moving to Hyperdisk, even when the rejected disk type is itself a Hyperdisk type. A parsed `hyperdisk-*` error therefore gets a cause that contradicts itself and a fix that points at the disk type that already failed.
## Fix Focus Areas
- pkg/gkedisk/gkedisk.go[116-129]
- internal/k8s/detect_scheduling.go[1603-1606]
## Recommended Fix
Use the Hyperdisk-only wording only when `IsPersistentDisk(diskType) && HyperdiskOnly(machineType)`; otherwise use the generic "Compute Engine doesn't allow X disks on Y machines" cause. Make `IncompatibilityAction` suggest a Hyperdisk StorageClass only for PD disk types. For Hyperdisk disk types, suggest pinning to a machine series that supports that Hyperdisk type, or a StorageClass of a supported type. Add tests for `hyperdisk-extreme` on n4 and `hyperdisk-balanced` on n2.

ⓘ 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 tweak Display settings with a live preview to see your comment before it ships

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread pkg/audit/disk_compat.go
Comment thread pkg/audit/disk_compat.go
Comment thread internal/k8s/detect_scheduling.go Outdated
Comment thread pkg/gkedisk/gkedisk.go
The backoff fallback now resolves the volume the echo names instead of any
PD the pod mounts, and a rejected Hyperdisk type no longer gets the
"supports only Hyperdisk" cause or a move-to-Hyperdisk action.
@nadaverell
nadaverell merged commit 7abd601 into main Oct 11, 2026
9 checks passed
@nadaverell
nadaverell deleted the fix/volume-attach-disk-compat branch October 11, 2026 23:47
@nadaverell

Copy link
Copy Markdown
Contributor Author

Live verification (post-merge, on 7abd60120)

Repro: a temporary tainted 1-node n4d-standard-2 pool on a GKE 1.35 cluster, plus a StatefulSet pinned to it with a 4Gi premium-rwo (pd-ssd) claim. The attach failed as expected. GKE emitted both events:

  • ControllerPublish not permitted on node … due to backoff condition. In this run it appeared only during the first few seconds.
  • googleapi: Error 400: pd-ssd disk type cannot be used by n4d-standard-2 machine type., badRequest. This became the newest event and repeated on every retry. The live error code is InvalidArgument, not Internal; the parser doesn't depend on it.

The captured live objects and events were run through DetectPostBindProblems in three event states. States 2 and 3 used the real event text with adjusted timestamps.

Event state Before (9ae4cc6bb) After (7abd60120)
1. Real error newest (as observed) — Real error shown · cause + action ✓
2. Backoff echo newest (outage shape) Echo only · no cause/action Real error shown · cause + action ✓
3. Real error expired, echo only Echo only · no cause/action Echo shown · cause derived from node + PV/StorageClass ✓

Cause: pd-ssd volumes can't attach to n4d-standard-2 nodes: the N4D machine series supports only Hyperdisk.

Audit (pdVolumeHyperdiskOnlyNode) on real cluster snapshots:

  • Repro cluster: 1 finding out of 666, StatefulSet k1-test/pd-on-n4d, danger. The cluster's other PD workloads were not flagged; the taint keeps them off the node.
  • Production-shape cluster (e2 + n4d nodes, Redis on pd-ssd pinned to e2): 0 findings out of 605.
  • The same snapshot with the Redis nodeSelector removed, which is the outage configuration: 2 warnings, one for redis-master and one for redis-replicas, each naming the n4d node and its claims. This ran on the pre-7462bfdba branch head.

The test pool, namespace and disk were deleted afterwards.

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