Skip to content

fix(csi-driver): never format a device the plugin could not read - #482

Draft
boddumanohar wants to merge 4 commits into
mainfrom
fix/csi-refuse-to-format-an-unreadable-device
Draft

fix(csi-driver): never format a device the plugin could not read#482
boddumanohar wants to merge 4 commits into
mainfrom
fix/csi-refuse-to-format-an-unreadable-device

Conversation

@boddumanohar

Copy link
Copy Markdown
Member

The bug

NodeStageVolume hands every cold-staged device to mount-utils'
SafeFormatAndMount, which probes with blkid and cannot tell a device carrying
no filesystem from one whose reads failed. blkid exits 2 for both,
getDiskFormat maps that single exit code to ("", nil), and mkfs.ext4 -F -m0
runs. A volume behind a degraded NVMe-oF path — reads timing out under
nvme_core.io_timeout, or a controller past its ctrl_loss_tmo — is therefore
reformatted rather than staged. That is how a production cluster lost a volume.

The repair paths were already written against this hazard (restageVolume and
healVolumeBeforePublish use a plain Mount on purpose). Only the cold stage
was exposed, which is every node reboot, pod reschedule, failover clone, and
migrated volume.

Reproduced, not argued

On a live host, an ext4 filesystem under a dm-flakey error_reads table —
reads fail, writes still land, exactly what a stalled path does:

=== BEFORE ===
UUID=5098687b-37de-4a39-9e13-06c2f5210fa5  TYPE=ext4
lost+found  payload.bin  precious.txt

=== reads start failing ===
blkid: exit=2 output=[]
getDiskFormat() -> ("", nil)  ==>  existingFormat == ""  ==>  FORMAT

=== mkfs.ext4 -F -m0 runs ===
mkfs exit=0

=== AFTER ===
UUID=a9fa37da-9fad-45c2-94fe-797d7207a309  TYPE=ext4
lost+found

With reads still failing at mkfs time the outcome is worse than a clean
reformat: the discard lands, mkfs then fails partway, and the device is left
with no valid filesystem at all — an unmountable volume whose data is already
gone, presenting as a mount failure rather than as a reformat.

Both recipes are in operator/docs/tests/test-plan-node-stage-format.md.

The fix

Take the decision away from mount-utils. atlas-lib/blockfs reads the first
128 KiB of the device and reports what it found, keeping "read successfully,
found nothing" apart from "could not read":

Probe result formatAndMount
ext / XFS / Btrfs signature mount it, never format
LUKS, LVM2, swap, partition table refuse: data, even if unmountable
all zeros format — the only safe case
readable, unrecognized, non-zero format, with a warning (unchanged behavior)
could not be read refuse; kubelet retries

mount-utils still performs the format for a device proven blank, so the mkfs
flags, the XFS feature pinning, and the stripe geometry are untouched. Only its
decision is replaced.

Details worth knowing:

  • A signature found in a partially read device counts as formatted — the data
    is there whatever became of the rest. Zeros from a partial read never conclude
    blank.
  • The probe is bounded at 20s, under the kernel's 30s nvme_core.io_timeout, so
    a stalled path resolves as unreadable instead of hanging the stage. The read
    runs on its own goroutine because a read against a dead path is not
    interruptible.
  • A device holding a filesystem is mounted with a plain Mount, matching what
    the restage path already does. ext4 and XFS each replay their own journal.

Refusing to stage is the deliberate trade: an outage is recoverable and a wiped
volume is not.

Relationship to #481

@noctarius's #481 addresses the same incident from the other end, and the two are
complementary rather than alternatives — its per-claim on-disk-filesystem
annotation is a real feature and a genuine second line of defense. They will
conflict textually in nodeserver.go (both add ctx to stageVolume, and both
make the same house-style prose fixes); happy to rebase onto whichever lands
first.

One thing worth checking together, and the reason I did not simply add to #481:
its getDiskFormat keeps upstream's exit.ExitCode() == 2 -> return "", nil, so
an unreadable device still classifies as blank. #481 catches that case through
the claim annotation instead, which covers a volume this driver has staged since
the change but leaves two paths open — a volume with no annotation yet (anything
staged only by an older driver), and any stage where the claim cannot be read,
since annotatedFilesystem treats every error as "no annotation" and continues
to the format. Reading the device closes it without needing API access or RBAC.

Testing

Regression tests proven red first — mkfs.ext4 -F -m0 ran on both a device
holding a filesystem and a device that could not be read — then green.

The format decision lives in mount-utils' mount_linux.go, so these tests only
exercise anything on Linux
; on macOS FormatAndMountSensitive mounts without
probing and they pass vacuously. Build and run them on Linux:

CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go test -c -o /tmp/spdk.test ./pkg/spdk/
/tmp/spdk.test -test.run TestStageVolume -test.v
  • 16 probe cases in atlas-lib/blockfs, including EIO, timeout, cancellation, and
    partial reads, under -race.
  • 6 decision cases in csi-driver/pkg/spdk, covering both refusals, the blank
    device that must still be formatted, the mismatched filesystem, and raw block.
  • Full pkg/spdk on Linux: 36 passed, 0 failed, CSI sanity suite included.
    csi-driver and atlas-lib suites green under -race; make lint clean in
    both; all 9 house-style gates pass.

Live, on a real volume

Against a real simplyblock ext4 volume on a four-node K3s cluster, with blkid
reporting nothing (TestLive*, env-gated and skipped by default):

TestLiveStageVolumeDoesNotReformatARealVolume
  commands issued while staging: []
  staged filesystem contains: [lost+found payload.bin payload.md5 precious.txt]
  precious.txt survived: PRODUCTION DATA - MUST SURVIVE A RESTAGE

TestLiveUpstreamFormatDecisionOnARealVolume
  commands mount-utils chose for that same device:
    [blkid -p -s TYPE -s PTTYPE -o export /dev/nvme0n1] [mkfs.ext4 -F -m0 /dev/nvme0n1]

TestLiveProbeSeesAFreshVolumeAsBlank
  probe of /dev/nvme2n1: state=Blank

The fixed path issued no commands at all — it read the device, saw ext4, and
mounted it. A freshly provisioned lvol still reads as blank, so first-time
formatting is unaffected.

Not yet deployed end-to-end. Everything live ran as a cross-compiled test
binary; no cluster runs this image yet.

Upstream

kubernetes/kubernetes#140376
— same defect, reached through an fsck that corrupted the primary superblock,
open and untriaged since 2026-07-09 with no fix PR. Neither approach proposed
there covers a device that will not answer a read, so this is ours to fix.
mount-utils itself has issues disabled; it is a publishing mirror.

🤖 Generated with Claude Code

boddumanohar and others added 2 commits September 3, 2026 18:50
NodeStageVolume handed every cold-staged device to mount-utils'
SafeFormatAndMount, which probes with blkid and cannot tell a device
carrying no filesystem from one whose reads failed. blkid exits 2 for
both, mount-utils maps that single exit code to "unformatted," and
mkfs.ext4 -F -m0 runs. A volume behind a degraded NVMe-oF path, whose
reads time out under nvme_core.io_timeout or whose controller has passed
its ctrl_loss_tmo, was therefore reformatted rather than staged, and a
production cluster lost a volume's data that way.

Verified on a live host rather than argued from the source: an ext4
filesystem under a dm-flakey error_reads table, where reads fail and
writes still land, makes blkid exit 2 with empty output. mkfs then
discards the device's blocks and fails, leaving no valid filesystem at
all, or, if the path recovers in between, succeeds and leaves the
workload a silently empty volume.

Take the decision away from mount-utils. atlas-lib/blockfs reads the
start of the device and reports what it found, keeping "read
successfully, found nothing" apart from "could not read," and
formatAndMount formats only on the first. A device carrying a filesystem
is mounted as it is, one carrying a foreign signature or one that will
not answer a read fails the stage, and only a device proven to be all
zeros reaches mkfs. mount-utils still performs the format, so the flags,
the xfs feature pinning, and the stripe geometry are unchanged.

Refusing to stage is the deliberate trade: an outage is recoverable and
a wiped volume is not.

Upstream tracks the same defect, reached through a corrupted primary
superblock rather than an unreadable device, as
kubernetes/kubernetes#140376, open and untriaged since 2026-07-09.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…and back-fill it

The claim annotation storage.simplyblock.io/on-disk-filesystem tells the node
plugin what a volume already holds, so it can mount rather than format when the
device itself will not answer. Two things stand between that mechanism and
working on a real cluster.

The node plugin cannot write the annotation. Its ClusterRole grants get, list,
and watch on persistentvolumeclaims and nothing more, so the patch that records
the filesystem is refused. The refusal is logged and swallowed, by design, which
means the annotation silently never appears and the volume it was meant to
protect stays exposed. Both charts that grant the node plugin any claim access
now grant patch as well, with what needs it stated on the rule. The raw
manifests under csi-driver/deploy/kubernetes grant no claim access at all, so
that deployment path cannot use the annotation either way and is left alone.

Nothing back-fills the annotation for volumes that already exist. The plugin
records it from a volume's first successful stage onward, so a claim provisioned
by an older driver has nothing recorded until then, and a stage that cannot read
its device in the meantime has nothing to fall back on. The new script annotates
the fleet in one pass. It writes only for a claim a running pod is using, which
is the evidence that matters, since a volume mounted into a running pod has been
formatted while a merely bound claim may never have been staged. The filesystem
recorded is the one the claim's StorageClass asks for, which is what the driver
formatted it with. Raw block volumes, claims that are not bound, claims of other
provisioners, and claims that already record a filesystem are all skipped, and
the script reports what it would do until it is given --apply.

Verified against a four-node cluster: 6 simplyblock claims in use identified and
their filesystem read correctly from the StorageClass, 8 local-path claims, one
pending claim, and one raw block volume all skipped for the right reason, and a
throwaway claim annotated, re-read, and skipped on a second run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@boddumanohar

Copy link
Copy Markdown
Member Author

Pushed two commits that support the annotation mechanism in #481 rather than the probe in this PR, because both are needed for that mechanism to work on a real cluster and neither is in #481 today. Happy to move them there instead — say the word and I'll open them as a separate PR against blkid-workaournd.

The node plugin cannot write the annotation. simplyblock-csi-node-role grants get, list, watch on persistentvolumeclaims and nothing else, so the merge patch in recordOnDiskFilesystem is refused. The refusal is logged and swallowed — correctly, since a volume must still mount when the API is unreachable — which means the annotation silently never appears and the volume it protects stays exposed. On the test cluster the live role reads ["get","list","watch","patch","patch"], so the grant was added there by hand while testing; it is not in the chart source or in #481's diff, which is why this wasn't caught.

Both charts that grant the node plugin claim access now grant patch too, with the justification on the rule. csi-driver/deploy/kubernetes/node-rbac.yaml grants no claim access at all, so that path cannot use the annotation either way and I left it alone.

Nothing back-fills existing volumes. csi-driver/scripts/backfill-on-disk-filesystem.sh annotates the fleet in one pass, so the upgrade window closes immediately instead of one volume at a time. It writes only for a claim a running pod is using — a volume mounted into a running pod has been formatted, whereas a merely bound claim may never have been staged — and records the filesystem its StorageClass asks for. Raw block volumes, unbound claims, other provisioners, and claims that already record a filesystem are skipped. Dry run unless given --apply.

Verified against a four-node cluster: 6 simplyblock claims in use identified with the right filesystem, 8 local-path claims, one pending claim, and one raw block volume skipped for the right reason; a throwaway claim annotated, re-read, and skipped on a rerun.

Correction to what I wrote earlier in this PR

I said the annotation "leaves two paths open," which understated it in one direction and overstated it in another, and I have since tested both.

It is better than I said: the plugin writes the annotation on every successful stage, not only for newly provisioned volumes. An existing ext4 volume whose blkid works gets annotated on its first stage under the new driver, so a cluster back-fills itself as it runs, even without the script.

It is also narrower than I implied. The exposure is the window between deploying #481 and each volume's next successful stage — not the fleet indefinitely. What stays open is a volume that meets an unreadable device on its first stage after the upgrade (a node reboot is both the commonest cause of a cold stage and a moment when paths flap, so this ordering is not contrived), a claim recreated or restored without its annotation, and any stage where the claim cannot be read.

Tested on this branch, against a real ext4 filesystem under a dm-flakey error_reads table with the real blkid:

claim annotation #481 result
absent reformatted, data lost
ext4 mounted, data intact

and the same device, same failing reads, no annotation anywhere, on this PR:

refusing to stage: it could not be read (input/output error),
and a device that will not answer a read must not be assumed empty
→ UUID unchanged, precious.txt and payload.bin intact

The two remain complementary. The annotation is a real feature and a cheap second opinion; the probe is what holds when no record exists yet.

boddumanohar and others added 2 commits September 3, 2026 20:48
…y a loop device

The test plan said a real NVMe namespace could not be made to fail reads. It
can, and the recipe was wrong about why: a degraded path presents three ways,
and which one a reproduction reaches decides whether anything is destroyed.

Within ctrl_loss_tmo the kernel queues I/O, so the device stays present, blkid
blocks, and staging hangs rather than formatting. Past ctrl_loss_tmo the
controllers are deleted and the device node disappears, so blkid exits 2 but
mkfs then hits a missing path. Only fast_io_fail_tmo reaches the production
case: the controller keeps reconnecting while I/O fails immediately, so the
namespace stays present and unreadable.

Adds that recipe, run against a real simplyblock volume with two HA paths: the
probe returns exit 2 with the device present and its ext4 intact, and once the
path recovers mkfs exits 0 and the volume comes back holding nothing but
lost+found. CONFIG_FAULT_INJECTION is not required.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rageClass

The back-fill decided whether a claim was simplyblock's by looking up its
StorageClass and checking the provisioner. That reads a fact about the class
today to answer a question about a volume provisioned in the past, and the two
come apart: a class can be renamed, deleted, or have its fstype parameter edited
after the volumes it created were bound. A volume whose class is gone would be
skipped, and one whose class changed filesystem since would be annotated with a
filesystem that was never on it, which is worse than skipping it.

The PersistentVolume answers both questions per volume and cannot go stale.
spec.csi.driver names this driver, and spec.csi.fsType is what the volume was
actually provisioned with. The StorageClass parameter is kept only as the
fallback for a volume that records no filesystem of its own.

Verified against a four-node cluster: the same 6 simplyblock claims are matched
with the same filesystem, and the 8 local-path claims are now skipped as "not a
simplyblock volume" rather than "not a simplyblock class".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@noctarius
noctarius force-pushed the main branch 2 times, most recently from 60dceb7 to fbaabe4 Compare September 9, 2026 10:21
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