Skip to content

feat: client-side compression/dedup via VDO (issue #277) - #402

Draft
boddumanohar wants to merge 22 commits into
mainfrom
issue-277-client-side-compression-impl
Draft

feat: client-side compression/dedup via VDO (issue #277)#402
boddumanohar wants to merge 22 commits into
mainfrom
issue-277-client-side-compression-impl

Conversation

@boddumanohar

@boddumanohar boddumanohar commented Aug 7, 2026

Copy link
Copy Markdown
Member

Summary

Implements the design in operator/docs/designs/design-issue-277-client-side-compression.md (PR #398) and its test plan in operator/docs/tests/test-plan-issue-277-client-side-compression.md, closing issue #277.

  • New Pool.spec.storageClassParameters.clientCompression / clientDeduplication fields, independently switchable, mapped through to client_compression/client_deduplication StorageClass parameters.
  • upsertStorageClass now composes a VDO-capable topology requirement (simplyblock.io/vdo-capable=true) with the existing DHCHAP topology gate when either client-side parameter is set.
  • New csi-driver/pkg/util/vdo.go: CreateOrAttachVDO, ResolveClonedVDO, DeactivateVDO, RemoveVDO (with a dmsetup-based fallback for the orphaned-stack case documented in the design's spike log), GrowVDO, SetVDOFeatures. Each volume gets its own PV/VG/vdo-pool/LV stack via LVM (lvcreate --type vdo).
  • nodeserver.go wiring: NodeStageVolume creates/attaches VDO between initiator.Connect and mount; NodeUnstageVolume deactivates it before disconnecting the raw device; restageVolume reattaches (never recreates) on reconnect; NodeExpandVolume grows the VDO stack before the filesystem resize; xfsStripeOptions is skipped once VDO is in play; buildAccessibleTopology surfaces the new node label.
  • Node capability: the CSI node DaemonSet's postStart hook installs/loads kmod-kvdo+vdo via nsenter (added hostPID: true), writing a marker file; newNodeServer reads it in the background and patches the node's simplyblock.io/vdo-capable label via a new RBAC patch/update grant on nodes.
  • csi-driver/deploy/image/Dockerfile now installs lvm2+vdo (amd64 only) and disables udev sync for LVM commands, both required to actually run LVM/VDO tooling from inside this container.

Verified end-to-end on the live test cluster

Created a real Pool with both clientCompression and clientDeduplication enabled, provisioned a PVC/Pod through the resulting StorageClass, and confirmed:

  • StorageClass correctly carries client_compression/client_deduplication = "True" and an allowedTopologies requiring vdo-capable=true.
  • The PVC/Pod is scheduled only onto the one node in the cluster whose kernel actually has a matching kmod-kvdo build (the other 3 nodes correctly advertise vdo-capable=false).
  • The pod's mount is a genuine VDO device (/dev/mapper/vdo-<lvol-uuid>-...), with VDOCompression/VDODeduplication both enabled (lvs).
  • Real compression+dedup savings: writing ~104MB across one original file and two exact duplicates of it yielded an 89% vdostats saving percent.
  • Re-provisioning: deleting and recreating the pod (same PVC, same node) reattaches the same VDO device rather than recreating it — data survives with an exact checksum match.
  • Expansion: growing the PVC 6Gi → 10Gi correctly grows the VDO pool and the filesystem online, with data intact throughout.
  • Multiple instances across a reboot: two real PVCs with distinct, checksummed data on the same node, node rebooted — both VDO instances reattached cleanly via fresh NodeStageVolume calls (kvdo module usage count exactly 2, both VDOOperatingMode: normal, both checksums matched). Caveat: the two stage sequences happened to run sequentially rather than genuinely overlapping, so LVM's internal locking under truly concurrent vgchange/pvscan calls remains unexercised.
  • Clone/snapshot resolution: both a direct PVC-to-PVC clone and a snapshot restore, each scheduled onto the same node as their still-live source (the specific co-location risk the design doc calls out) — ResolveClonedVDO correctly ran vgimportclone + lvrename, both mounted cleanly with data matching the source exactly, and all three volumes (source, clone, restore) coexisted with independent VG identities and no cross-contamination.
  • Stale state after an unclean disconnect: forcibly severed a real VDO volume's NVMe-oF connection at the host level (nvme disconnect) while the node and pod both stayed up, simulating "storage side disconnects while the node stays up." Kernel logs showed buffered reads/writes silently appearing to succeed for ~19s before the real failure surfaced, at which point VDO correctly fenced itself into read-only mode (and ext4 independently aborted its journal) rather than corrupting anything — Kubernetes reported the pod as healthy the whole time regardless. Deleting the pod afterward is now confirmed to clean up fully automatically, with no orphaned state left behind and no manual intervention needed.
  • XFS on top of VDO: provisioned a real VDO volume with fsType=xfs (every prior spike used ext4) — mkfs.xfs ran with no stripe-alignment flags (confirming the xfsStripeOptions skip fires correctly), mounted cleanly with the existing nouuid flag intact, compression/dedup stayed enabled, and reattach-on-recreate worked identically to ext4. No bugs found.
  • Crash-consistency of async write policy: forced vdo_write_policy=async explicitly, wrote one file with fsync() and one without, then genuinely crashed the node via sysrq (immediate reboot, zero filesystem sync — confirmed via a new boot timestamp, not a graceful reboot that would have proven nothing). The fsync()'d file survived with an exact checksum match; the non-fsync()'d file was lost entirely — the correct POSIX outcome. Resolves the design doc's open safety question: async correctly honors flush/FUA durability end-to-end through NVMe-oF to the simplyblock backend.

This process found and fixed nine real, hands-on-only-discoverable bugs beyond the original design (each is its own commit on this branch):

  1. buildAccessibleTopology only added the vdo-capable topology key when the label was already true — since CSINode's topology key set is captured once at plugin registration (seconds after pod start) while the label gets patched asynchronously afterward, the key was essentially never present at registration time, permanently breaking the topology gate until pod restart.
  2. The spdkcsi container image had no lvm2 installed at all (pvcreate: not found).
  3. lvm2 alone wasn't enough — lvcreate --type vdo shells out to vdoformat, which ships in the separate vdo package.
  4. lvcreate --type vdo failed with "device not cleared" — no udev daemon runs inside this container, so device-mapper's default udev-sync handshake never completes. Fixed with DM_DISABLE_UDEV=1.
  5. Most serious: NodeUnstageVolume was calling the destructive RemoveVDO (vgremove) on every routine unstage, not only when the volume was actually being deleted — an ordinary pod delete+recreate on the same node silently destroyed all VDO-backed data. Fixed by adding a non-destructive DeactivateVDO (vgchange -an only) for this path.
  6. GrowVDO's lvextend -l100%FREE used the absolute (not additive) percentage form, which computed a target smaller than the pool's current size on every real device resize. Fixed with -l+100%FREE.
  7. pvVGName trusted pvs's combined stdout+stderr output wholesale, so a duplicate-PV WARNING: line from a real clone test polluted both the identity comparison and the resulting log message. Harmless in that run (the dirty string still differed from the target either way), but fixed to parse just the actual field.
  8. DeactivateVDO (the non-destructive replacement from bug updated the api create param to include cr objects #5) had no fallback at all for an unreachable backing device — vgchange -an failed identically to how vgremove failed in the original spike, on every one of 18 retries, until kubelet gave up and force-removed the pod anyway, leaving the orphaned dm-vdo stack permanently stuck. Fixed by adding the same dmsetup remove fallback RemoveVDO already had.
  9. That fallback's device-name matching didn't account for device-mapper's dash-escaping (vdo-<uuid>vdo--<uuid-with-double-dashes> in dmsetup ls output), so it matched nothing and the stack stayed orphaned even with the fallback wired in. Fixed to escape the VG name the same way device-mapper does before matching.

Deliberate scope decisions (see design doc for full rationale)

  • ResolveClonedVDO's clone-collision detection is driven purely by the device's actual on-disk VG identity, not by threading VolumeContentSource through the volume context as the design doc originally proposed — simpler and correct either way, so no new plumbing was added.
  • GrowVDO's signature grows to the pool's new physical capacity (-l100%FREE/+100%FREE, then matching logical size) rather than taking an explicit newSize parameter, matching the 100%FREE convention already used at creation time.
  • VDO code lives in csi-driver/pkg/util/vdo.go per the design doc, not atlas-lib (raised as an idea mid-discussion, never confirmed).
  • lvm2/vdo are installed in this branch-tagged Dockerfile, not the shared base_image/Dockerfile_base — rebuilding that shared, cross-branch tag for an in-progress feature felt like the wrong blast radius; worth promoting there once this lands.
  • vdo has no aarch64 build in the configured repos, so it's installed amd64-only; client-side VDO is x86_64-only for now, matching every host used in this design's validation.

New finding, now fixed (separate, pre-existing gap — fixed here for the VDO case)

The CSI driver's CreateVolume did not populate PersistentVolume.spec.nodeAffinity, so once a PVC was bound, a pod using it could be rescheduled to any node — not just the one the topology gate originally selected. This was harmless before (a raw NVMe-oF connection works identically from any node) but is now materially important, since VDO state is node-local. Reproduced live during this branch's original verification: deleting and recreating the pod (not the PVC) let it land on a non-vdo-capable node, where it correctly failed to mount.

After rebasing onto origin/main, this branch picked up an upstream fix for the same underlying issue (#403) for the DHCHAP topology case (dhchapNodeLabelParam/dhchapAllowedNodeSegment). This PR adds the mirroring vdoCapableSegment for the VDO case: CreateVolume now merges a vdo-capable topology segment into AccessibleTopology whenever client_compression/client_deduplication is set, exactly the same way the DHCHAP segment is merged, so external-provisioner correctly pins PersistentVolume.spec.nodeAffinity. Covered by new unit tests (TestVDOCapableSegment); not yet re-verified live post-rebase (the original live reproduction predates this fix).

Explicitly out of scope for this PR

  • SetVDOFeatures (live compression/dedup toggle) is implemented but not wired into any update path — v1 non-goal per the design doc.
  • The pre-existing upsertStorageClass create-only bug (bug: upsertStorageClass is create-only — Pool StorageClassParameters edits on an existing Pool silently no-op #401) — orthogonal, tracked separately.
  • Whether kubelet reliably re-invokes NodeUnstageVolume on the original node specifically after that node goes NotReady and later rejoins (as opposed to the connection just being severed while the node stays healthy, which is what was tested) remains unverified — a different code path.
  • Genuinely concurrent (not just closely-timed) vgchange/pvscan calls actually racing at the LVM level remain unexercised — the multi-instance reboot test's two stage sequences happened to run sequentially rather than overlapping.

All items originally flagged as open in the test plan (XFS-on-VDO, crash-consistency of async, clone/snapshot resolution, multi-instance-across-a-reboot, stale-state-after-unclean-disconnect) are now verified — see above.

  • ensureDeviceConnected (block-volume reconnect) is untouched — every VDO example in the design is filesystem-mode; block-mode + VDO was never designed or tested.
  • Minor: NodeExpandVolume/GrowVDO isn't fully idempotent against a redundant re-invocation after the volume is already at its target size — logs a scary-looking but harmless error on kubelet's post-success reconciliation retry. Worth a follow-up polish pass.

Rebased onto origin/main

Rebased onto current origin/main (37 commits of drift), all 12 original commits preserved plus one
follow-up commit regenerating the CRD/install.yaml manifests. Notable upstream changes this branch now
sits on top of, and how they were reconciled:

  • PoolStoragePool rename (CRD, controller, files all renamed on main): git's rename detection
    followed this cleanly; PoolReconcilerStoragePoolReconciler, pool_types.go
    storagepool_types.go, simplyblockpool_controller.gosimplyblockstoragepool_controller.go. The
    new ClientCompression/ClientDeduplication fields and their wiring are all still present and intact
    post-rebase.
  • upsertStorageClasscreateStorageClassIfNotExists, now backed by new +k8s:immutable CEL
    markers on StorageClassParameters/DHCHAP: this looks like it resolves the semantic core of bug: upsertStorageClass is create-only — Pool StorageClassParameters edits on an existing Pool silently no-op #401
    (the create-only/no-drift-detection bug) via a different, arguably better mechanism — preventing the
    edit at the API/admission level instead of detecting drift after the fact. Not actioned further in this
    PR; flagging for whoever triages bug: upsertStorageClass is create-only — Pool StorageClassParameters edits on an existing Pool silently no-op #401.
  • dhchapNodeLabelParam/dhchapAllowedNodeSegment: main already fixes the "PV nodeAffinity isn't
    populated" gap flagged above under "New finding" — but only for the DHCHAP topology case, not the VDO
    one. This PR's vdo-capable topology requirement was merged alongside it in
    createStorageClassIfNotExists (both compose into one TopologySelectorTerm so they're ANDed, not
    ORed, when a pool needs both). A follow-up commit on this branch (vdoCapableSegment) closes the same
    gap for VDO specifically, mirroring dhchapAllowedNodeSegment exactly — see "New finding, now fixed"
    above.
  • New Filesystem StorageClassParameter (default xfs, from unrelated upstream commits) merged cleanly
    into mergeStorageClassParameters; not yet re-verified end-to-end against the VDO+XFS wiring now that
    xfs is the operator's own default rather than something the original live testing had to force by
    hand — the underlying logic is unchanged, but this specific path wasn't re-run live post-rebase.

go build ./... and go test ./pkg/util/... ./pkg/spdk/... (csi-driver) are clean post-rebase.
golangci-lint shows only pre-existing goconst findings in unrelated test files, none touching any file
this PR modifies.

Why draft

Unit tests from the test plan are not written yet. Draft status reflects that, not any doubt about the functional behavior above — that part is now hands-on verified against a real cluster, real NVMe-oF-backed lvols, and real compression/dedup savings.

Test plan

  • go build ./... clean for both operator and csi-driver modules
  • go test ./...pkg/spdk, pkg/util pass; internal/controller's envtest-based suite requires a local kubebuilder binary not present in this environment (pre-existing, unrelated); e2e package requires a live e2e cluster fixture (pre-existing, unrelated)
  • Manual end-to-end verification on the live test cluster (Pool → StorageClass → PVC/Pod → VDO device/compression/dedup confirmed → reattach-on-recreate confirmed → expand confirmed → multi-instance reboot confirmed → clone/snapshot resolution confirmed → stale-state cleanup after unclean disconnect confirmed → XFS-on-VDO confirmed → async write-policy crash-consistency confirmed via a genuine node crash)
  • Unit tests for vdo.go and the new wiring (test plan Sections 1-7)

🤖 Generated with Claude Code

boddumanohar added a commit that referenced this pull request Aug 7, 2026
Tested against the real implementation (PR #402), not just raw LVM
commands: two real PVCs with clientCompression/clientDeduplication on the
same node, distinct checksummed data, node rebooted. Both VDO instances
reattached cleanly via fresh NodeStageVolume calls (kubelet's own
bookkeeping resets on reboot too) -- kvdo module usage count exactly 2, both
VDOOperatingMode normal, both checksums matched exactly.

Caveat found and documented: the two NodeStageVolume LVM command sequences
happened to complete sequentially rather than genuinely overlapping, so
LVM's internal command locking under truly concurrent vgchange/pvscan calls
remains unexercised -- narrowed the open item accordingly rather than
closing it outright.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
boddumanohar added a commit that referenced this pull request Aug 7, 2026
…ation

Tested both clone paths against PR #402's real ResolveClonedVDO: a direct
PVC-to-PVC clone and a snapshot restore, both scheduled onto the same node
as their still-live source (the specific co-location scenario this finding
warns about). Both correctly resolved via vgimportclone + lvrename, mounted
cleanly with data matching the source exactly, and coexisted with the
source and each other with independent VG identities and no
cross-contamination.

Also corrected the "Detection" section: the implementation ended up simpler
than originally planned -- detection is unconditional and purely
device-identity-based, not gated on VolumeContentSource, so no separate
content-source plumbing was needed.

Found and fixed one bug along the way: the collision-detection log message
was picking up pvs's stderr WARNING: lines merged into its output instead of
just the VG name -- harmless in this run, but fixed to parse cleanly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
boddumanohar added a commit that referenced this pull request Aug 7, 2026
Deliberately reproduced against PR #402's real implementation: forcibly
disconnected a VDO volume's NVMe-oF subsystem at the host level while the
node stayed up, then deleted the pod. This exposed two real bugs (both now
fixed on that branch): DeactivateVDO had no fallback for an unreachable
device, and once added, the fallback's device-name matching didn't account
for device-mapper's dash-escaping and matched nothing. With both fixed,
cleanup is now fully automatic -- confirmed by reproducing the whole
sequence a second time.

Also documented an unplanned but valuable side observation: for ~19s after
disconnect, cached reads/writes silently appeared to succeed before the
real I/O failure surfaced, at which point VDO correctly fenced itself into
read-only mode and ext4 independently aborted its journal -- both layers
protected data correctly with no wiring needed from this design.

Narrowed the remaining open item: the node-NotReady-and-rejoin path is
still unverified (this test kept the node itself healthy throughout, only
the storage connection was severed).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@noctarius noctarius added the csi label Aug 7, 2026
@noctarius noctarius added this to the 26.4 milestone Aug 7, 2026
boddumanohar added a commit that referenced this pull request Aug 11, 2026
…lap as a known gap

#403 doesn't actually block #402 (it says so explicitly, and #402 already
works around it with a nodeSelector pin for its own verification), so
there's no need to carry the operator-side plumbing this fix grew to close
a real but narrow edge case: a single Kubernetes node listed in more than
one DHCHAP-gated pool's AllowedNodes.

Revert the dhchap_node_label StorageClass parameter and the operator change
that set it. hardPinTopologySegments goes back to matching the
"simplyblock.io/pool." prefix, with the multi-pool-overlap behavior now
documented as a deliberately accepted limitation (in both a code comment and
a test that documents current behavior rather than asserting correctness)
instead of solved. operator/ is now byte-for-byte identical to origin/main;
this PR is CSI-driver-only again.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
boddumanohar added a commit that referenced this pull request Aug 12, 2026
#403)

CreateVolume never populated AccessibleTopology for StorageClasses that
select their cluster directly via cluster_id (the common case), so
external-provisioner never set PV.spec.nodeAffinity. A bound PVC could then
be rescheduled onto any node, even when the StorageClass gates provisioning
to specific nodes via AllowedTopologies (DHCHAP's allowed-node label today,
and VDO's node-local state in the upcoming #402). Extract only the
segments that represent a genuine per-node constraint from the CSI
AccessibilityRequirements and echo them back, leaving plain
NVMe-oF-backed volumes (which behave identically from any node) unpinned.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
boddumanohar added a commit that referenced this pull request Aug 12, 2026
…lap as a known gap

#403 doesn't actually block #402 (it says so explicitly, and #402 already
works around it with a nodeSelector pin for its own verification), so
there's no need to carry the operator-side plumbing this fix grew to close
a real but narrow edge case: a single Kubernetes node listed in more than
one DHCHAP-gated pool's AllowedNodes.

Revert the dhchap_node_label StorageClass parameter and the operator change
that set it. hardPinTopologySegments goes back to matching the
"simplyblock.io/pool." prefix, with the multi-pool-overlap behavior now
documented as a deliberately accepted limitation (in both a code comment and
a test that documents current behavior rather than asserting correctness)
instead of solved. operator/ is now byte-for-byte identical to origin/main;
this PR is CSI-driver-only again.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
boddumanohar added a commit that referenced this pull request Aug 21, 2026
Tested against the real implementation (PR #402), not just raw LVM
commands: two real PVCs with clientCompression/clientDeduplication on the
same node, distinct checksummed data, node rebooted. Both VDO instances
reattached cleanly via fresh NodeStageVolume calls (kubelet's own
bookkeeping resets on reboot too) -- kvdo module usage count exactly 2, both
VDOOperatingMode normal, both checksums matched exactly.

Caveat found and documented: the two NodeStageVolume LVM command sequences
happened to complete sequentially rather than genuinely overlapping, so
LVM's internal command locking under truly concurrent vgchange/pvscan calls
remains unexercised -- narrowed the open item accordingly rather than
closing it outright.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
boddumanohar added a commit that referenced this pull request Aug 21, 2026
…ation

Tested both clone paths against PR #402's real ResolveClonedVDO: a direct
PVC-to-PVC clone and a snapshot restore, both scheduled onto the same node
as their still-live source (the specific co-location scenario this finding
warns about). Both correctly resolved via vgimportclone + lvrename, mounted
cleanly with data matching the source exactly, and coexisted with the
source and each other with independent VG identities and no
cross-contamination.

Also corrected the "Detection" section: the implementation ended up simpler
than originally planned -- detection is unconditional and purely
device-identity-based, not gated on VolumeContentSource, so no separate
content-source plumbing was needed.

Found and fixed one bug along the way: the collision-detection log message
was picking up pvs's stderr WARNING: lines merged into its output instead of
just the VG name -- harmless in this run, but fixed to parse cleanly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
boddumanohar added a commit that referenced this pull request Aug 21, 2026
Deliberately reproduced against PR #402's real implementation: forcibly
disconnected a VDO volume's NVMe-oF subsystem at the host level while the
node stayed up, then deleted the pod. This exposed two real bugs (both now
fixed on that branch): DeactivateVDO had no fallback for an unreachable
device, and once added, the fallback's device-name matching didn't account
for device-mapper's dash-escaping and matched nothing. With both fixed,
cleanup is now fully automatic -- confirmed by reproducing the whole
sequence a second time.

Also documented an unplanned but valuable side observation: for ~19s after
disconnect, cached reads/writes silently appeared to succeed before the
real I/O failure surfaced, at which point VDO correctly fenced itself into
read-only mode and ext4 independently aborted its journal -- both layers
protected data correctly with no wiring needed from this design.

Narrowed the remaining open item: the node-NotReady-and-rejoin path is
still unverified (this test kept the node itself healthy throughout, only
the storage connection was severed).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
boddumanohar and others added 13 commits August 21, 2026 14:05
Implements the design in design-issue-277-client-side-compression.md (PR #398):
new clientCompression/clientDeduplication Pool StorageClass params, VDO-capable
node topology gating, a new csi-driver/pkg/util/vdo.go managing per-volume VDO
stacks over LVM, and nodeserver.go wiring to create/reattach/grow/remove VDO
devices across stage/unstage/restage/expand.

Not yet covered (tracked as follow-ups): unit tests, deliberate exercise of
clone/snapshot VDO resolution, multi-instance+reboot, XFS-on-VDO, and
crash-consistency of async write policy.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
goconst nudged a look at nodeserver.go's raw == "true" checks against
client_compression/client_deduplication: mergeStorageClassParameters actually
emits "True" (capitalized), so those checks would never have matched. Replaced
with kube.BoolParam (the same parser already used for encryption/replicate),
factored into a shared vdoParams helper. Also regenerated dist/install.yaml
(missed by the earlier make manifests run) and fixed a goconst/unparam/lll
nit in vdo.go and simplyblockpool_controller.go.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Found live on the test cluster: CSINode's topology key set is captured once
at CSI plugin registration (seconds after pod start), while
advertiseVDOCapability patches the underlying node label asynchronously in
the background (can take minutes, gated on the postStart hook's dnf
install). Conditionally omitting the vdo-capable key when false meant it was
essentially never present at registration time, permanently breaking the
topology gate for that node until its csi-node pod restarted -- confirmed by
a real ProvisioningFailed error ("topology ... is not in requisite") when
provisioning against a Pool with clientCompression/clientDeduplication
enabled. Now always present, matching the existing
topologyKeyStorageNodeUUIDPrefix pattern's own documented rationale: only
the key's presence needs to be stable, not its value, since
external-provisioner reads the live Node label value fresh on every
provision.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
pkg/util/vdo.go execs pvcreate/vgcreate/lvcreate/vgchange/lvextend/dmsetup
etc. from inside the csi-node container, but base_image only ships
nvme-cli/e2fsprogs/xfsprogs -- confirmed live on the test cluster
(MountVolume.MountDevice failing with "pvcreate: executable file not found
in $PATH"). Added to this branch-tagged Dockerfile rather than
base_image/Dockerfile_base, since that image tag is shared across every
branch and rebuilding it would affect unrelated in-flight work; worth
promoting there once this feature lands.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…iner)

Confirmed live: lvcreate --type vdo failed with "device not cleared,
Aborting. Failed to wipe start of new LV" -- device-mapper's default
behavior waits on udev to create/settle the resulting device node, but this
container has no udev daemon running. DM_DISABLE_UDEV=1 is the standard fix
for LVM tooling run inside a container.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
lvm2 alone wasn't enough: lvcreate --type vdo shells out to vdoformat
internally to format the new VDO pool, and that binary ships in the separate
vdo package, not lvm2. Confirmed live ("/usr/bin/vdoformat: execvp failed:
No such file or directory").

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The multi-arch build was failing entirely on the arm64 leg: "No match for
argument: vdo" -- Oracle Linux 9's configured repos don't carry a vdo build
for aarch64. Client-side VDO is x86_64-only for now anyway (matches every
host used in this design's validation), so gate the install on TARGETARCH
rather than block the whole multi-arch image.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Critical bug caught live: deleting and recreating a pod on the same node
(same PVC) came back with an empty filesystem -- all test data gone.
NodeUnstageVolume was calling RemoveVDO (vgchange -an + vgremove -f) on
every routine unstage, but NodeUnstageVolume fires any time no pod on this
node currently needs the volume mounted, not only when the volume is
actually being deleted. vgremove destroys the VG's LVM metadata, which is
what makes VDO's compressed/deduplicated physical layout decodable back into
the original file bytes -- so this was silently destroying user data on an
entirely ordinary pod restart, directly contradicting the design's own
"must be re-included every time the node-side CSI driver re-provisions the
volume" requirement.

Added DeactivateVDO (vgchange -an only, non-destructive, reversible via
CreateOrAttachVDO's existing vgchange -ay reactivate path) and wired
NodeUnstageVolume to use it instead. RemoveVDO is kept for genuine
destroy/cleanup scenarios, just no longer called from the routine unstage
path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Confirmed live: lvextend -l100%FREE failed with "New size given (1024
extents) not larger than existing size (1535 extents)" during a real PVC
expand (6Gi -> 10Gi). Unlike lvcreate, lvextend's bare "100%FREE" is an
absolute target (100% of currently-free space alone), not "grow by" --
after the backend resize, free space (1024 extents) was smaller than the
pool's current size (1535 extents), so the absolute interpretation rejected
it as not larger. The "+" prefix makes it additive (current size + free
space), which is the actual intent.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Confirmed live during a real clone test: ResolveClonedVDO's own log message
came out polluted with LVM's duplicate-PV warnings ("WARNING: Not using
device /dev/nvme2n1 for PV ...") ahead of the actual VG name, because
runLVMCommand merges stdout+stderr and pvVGName trusted the whole trimmed
blob. Didn't cause a functional problem in that run (the dirty string still
correctly differed from the target VG name), but is a latent correctness
risk and produced a confusing log line. Now takes the first non-empty,
non-WARNING line instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Critical gap confirmed live: forcibly disconnected a VDO volume's NVMe-oF
connection at the host level (storage-side disconnect while the node stays
up), then deleted the pod. NodeUnstageVolume's DeactivateVDO call failed
with "Volume group ... not found" on every one of 18 retries -- the exact
same failure mode vgremove hits in RemoveVDO when the backing device is
gone, since vgchange -an also needs to read/write VG metadata that no
longer exists. Kubelet eventually force-removed the pod anyway, leaving the
orphaned dm-vdo stack permanently stuck with nothing left to clean it up --
my earlier fix (switching NodeUnstageVolume from the destructive RemoveVDO
to the safe DeactivateVDO) accidentally dropped the dmsetup fallback
robustness RemoveVDO already had for this exact case.

DeactivateVDO now falls back to the same direct dmsetup removal when
vgchange -an fails with a "not found"-style error (mirroring vgExists'
existing pattern-matching), gated on that specific failure signature so a
genuinely busy/in-use VG (device still reachable) is never forced. Kernel
logs from the same test also confirmed VDO's own fencing behavior worked
correctly and safely: once the disconnected device's I/O actually started
failing (~19s after disconnect, buffered writes had masked it until then),
VDO fenced itself into read-only mode and ext4 aborted its journal, rather
than corrupting data.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Confirmed live: the dmsetup fallback added for DeactivateVDO correctly
triggered ("vgchange -an failed ... falling back"), but matched zero dm
device names and left the orphaned stack in place. device-mapper flattens
"<vg>-<lv>" into a single dm name by doubling every literal "-" within the
VG/LV name components (e.g. vg "vdo-<uuid-with-dashes>" becomes
"vdo--<uuid-with-double-dashes>" in dmsetup ls output) -- the prefix match
was comparing against the unescaped VG name, which never matches. Escapes
the VG name the same way device-mapper does before matching.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
make generate/manifests/build-installer picked up the two new
StorageClassParameters fields (clientCompression/clientDeduplication)
against the renamed StoragePool CRD from origin/main.
@boddumanohar
boddumanohar force-pushed the issue-277-client-side-compression-impl branch from 88d5be8 to d201b02 Compare August 21, 2026 12:25
CreateVolume already merges a DHCHAP allowed-node topology segment into
AccessibleTopology so DHCHAP-gated PVs get correct nodeAffinity (#403).
Client-side VDO volumes had the exact same gap: nothing pinned their PV to
a vdo-capable node, so a pod recreated (not the PVC) could be rescheduled
onto a non-vdo-capable node and fail to mount, as found live during this
branch's cluster verification.

vdoCapableSegment mirrors dhchapAllowedNodeSegment: built straight from
client_compression/client_deduplication StorageClass parameters rather than
AccessibilityRequirements, since vdo-capable is a node-independent
constraint whose value is always the same fixed label, and a brand new
pool's requirement wouldn't be in AccessibilityRequirements until
CSINode's cached topology keys catch up anyway.
Each VDO volume connects over two redundant NVMe-oF HA paths, each surfacing
as its own local device node (nvme2n1, nvme3n1, ...) while presenting
byte-identical backend data. LVM's pvscan/pvs/vgs then see the same PV
signature on two devices and log "PV ... is duplicate for PVID ... on ...",
after which vgs/vgchange/lvcreate resolve non-deterministically depending on
which duplicate LVM's cache happens to pick -- sometimes correct, sometimes
resulting in a VG that "exists" per vgs but was never actually created by
this code, leaving no logical volume behind and failing mkfs with "No such
file or directory".

Found live: two independently-created pools (compression-only,
deduplication-only) both failed NodeStageVolume this way. Likely also
explains an earlier "Bad address" (EFAULT) failure this session, previously
attributed only to stale on-disk partition data from a prior tenant.

Fixes CreateOrAttachVDO, ResolveClonedVDO, and GrowVDO by scoping every LVM
invocation to the volume's own devicePath via --devices, so LVM never
considers the redundant HA path's duplicate device node at all. Also adds
operator/docs/tests/vdo-feature-scenarios.md, a Good/Error/Load scenario
matrix tracking what's been verified live for this feature.

DeactivateVDO/RemoveVDO/SetVDOFeatures are unchanged -- they address volumes
by VG name only (no devicePath in scope) and haven't shown this failure mode
in testing; scoping them would need a devicePath threaded through several
nodeserver.go call sites, a larger change deferred pending evidence they're
actually affected.
…gs lookup

The --devices scoping fix alone wasn't enough: `vgs --devices devicePath
vgname` still reported success for a VG name that had never actually been
created on that specific device -- confirmed live, immediately after the
--devices fix, on a brand-new never-before-seen lvol UUID. This host has an
LVM devices file (/etc/lvm/devices/system.devices) restricting default
visibility to unrelated devices; a name-based vgs lookup, even with
--devices, doesn't reliably tie its answer back to devicePath specifically.

vgExists now delegates to pvVGName (already used by ResolveClonedVDO for
exactly this class of identity question): it asks "what VG does this
device's own on-disk PV signature currently belong to" instead of "does a
VG with this name exist," which is answerable purely from the device's own
content and can't be confused by name-index staleness or devices-file
scoping quirks.
Even with the previous two fixes, one volume kept failing with the same
"No such file or directory" mkfs error. Live investigation (via direct SSH
to the node, since the device only exists for the few seconds of each
kubelet retry) found the actual state: `vgs` correctly reports the VG as
existing, but it has zero logical volumes (#LV 0) and no device-mapper
nodes at all. This is stale state from an earlier interrupted create --
pvcreate and vgcreate had succeeded (likely during the pre-fix duplicate-PV
confusion, which could corrupt the sequence mid-way) but lvcreate never
ran. CreateOrAttachVDO's exists-check was correct that the VG exists, but
wrong to treat that alone as "safe to reactivate": there was nothing to
reactivate, so every retry reactivated an empty VG and produced no
mountable device, forever.

vgHasLV now checks whether the VG actually contains lvolID's own logical
volume before taking the reactivate path. If the VG exists but is empty,
it's removed and creation falls through to a fresh pvcreate/vgcreate/
lvcreate instead of endlessly reactivating nothing.
…#6)

The compression-only/dedup-only test's CSI logs already contained a clean,
incidental proof: two different volumes' pvcreate/vgcreate/lvcreate calls
launched as separate OS processes within tens of milliseconds of each
other, genuinely overlapping rather than sequential. Both completed
correctly with no cross-contamination, confirming LVM's own locking
handles this safely.
#4: filled a real VDO volume with incompressible data until physical
exhaustion. XFS returned clean ENOSPC, vdostats confirmed 97% physical
utilization at that point, no corruption.

#5: a safe, reversible lvmlocal.conf proxy (restricting LVM's
activation/volume_list to force vgchange -ay to refuse activation without
touching the kvdo kernel module) was blocked by the safety classifier for
modifying a live host's system config. Left untested live per the user's
call; the same underlying guarantee is already covered by #4 and the
memory-failure evidence from Good #8 (any CreateOrAttachVDO/vgchange
failure hard-fails the stage, no raw-mount fallback -- just not for this
specific trigger).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants