feat: add lblk (non-NVMe block device) support for storage nodes - #437
Draft
boddumanohar wants to merge 15 commits into
Draft
feat: add lblk (non-NVMe block device) support for storage nodes#437boddumanohar wants to merge 15 commits into
boddumanohar wants to merge 15 commits into
Conversation
boddumanohar
force-pushed
the
feat/lblk-block-device-support
branch
2 times, most recently
from
August 19, 2026 13:08
63b7682 to
d2953d2
Compare
boddumanohar
added a commit
that referenced
this pull request
Aug 19, 2026
…d-disk reuse blkForceFormat (StorageNodeSet fleet-wide + per-node override) threads through two places lblk's --force-format actually matters: - node_configure.py's --force-format flag (marks a partitioned disk eligible at configure time) - the add-node API's force_format param (triggers the actual wipefs at add-node time — configure-time eligibility alone doesn't wipe anything) Found while working through the lblk-e2e-testing.md test plan: PR #437 never wired this at all, so partitioned-disk reuse and the disk+own-partition-conflict test were both unreachable through the CRD. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Wires the sbcli lblk device mode (simplyblock/sbcli#1224, not yet merged) through the operator: a cluster-wide StorageCluster.spec.deviceMode ("nvme"|"lblk") passed to cluster-create, and per-node/fleet block-device selectors (enableLblk, blkNames, blkNamesExclude, blkSerials, lblkJournalPercent) on StorageNodeSet/StorageNode that flow into the per-node ConfigMap and from there into the DaemonSet init container's node_configure.py invocation (--lblk/--blk-names/--blk-names-exclude/ --blk-serials/--jm-percent), mirroring the existing PCIe selector wiring. Lets storage nodes run on cloud block volumes, virtio-blk disks, or other non-NVMe local disks instead of requiring physical NVMe hardware. Addresses #436. Depends on sbcli#1224 landing before device_mode=lblk/the lblk configure flags are recognized by the backend; sending them against an unpatched backend fails at cluster-create/node-configure time.
…it container node_configure.py's lblk eligibility check (--lblk/--blk-names/--blk-serials) inspects host block devices eagerly at config-generation time, unlike NVMe PCIe discovery which defers to the main container's SPDK startup. Without /dev and /sys mounted, every lblk device lookup failed with "not present" regardless of what was actually attached to the host — found live while testing device_mode=lblk against sbcli#1224 on a real cluster. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…d-disk reuse blkForceFormat (StorageNodeSet fleet-wide + per-node override) threads through two places lblk's --force-format actually matters: - node_configure.py's --force-format flag (marks a partitioned disk eligible at configure time) - the add-node API's force_format param (triggers the actual wipefs at add-node time — configure-time eligibility alone doesn't wipe anything) Found while working through the lblk-e2e-testing.md test plan: PR #437 never wired this at all, so partitioned-disk reuse and the disk+own-partition-conflict test were both unreachable through the CRD. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…s gone Reconcile fetched the parent StorageNodeSet before checking DeletionTimestamp, so a StorageNode whose parent had already been deleted (cascading delete racing ahead of its children) could never reach handleDeletion and got stuck requeuing "parent StorageNodeSet not found" forever. handleDeletion never used the parent, so check deletion first. Reproduced live during config-israel lblk cluster teardown: a leftover StorageNode from an earlier test was permanently stuck this way. Also add lblk e2e test plan and findings notes from testing on config-israel. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015pjXzhB8JQ6ySSm1vvwZq7
…ual exclusivity at admission time Two real gaps found while working through the lblk e2e test checklist on the GCP lblk cluster: 1. BlkNames/BlkNamesExclude/BlkSerials had no immutability guard at all, unlike EnableLblk. Adding a naive `self == oldSelf` rule (matching EnableLblk's existing pattern) doesn't catch the unset-to-set transition — Kubernetes skips CEL validation on optional fields when the old value was absent, by design. That's exactly the scenario that matters here: a StorageNodeSet created with no selector (auto-select) that later gets blkNames added via edit. Fixed using optionalOldSelf:true with an explicit oldSelf.hasValue()-based rule, on both StorageNodeSetSpec and StorageNodeOverrides. 2. enableLblk/blkNames/blkNamesExclude/blkSerials selectors and the NVMe PCIe selectors (pcieAllowList/pcieDenyList/pcieModel/driveSizeRange/deviceNames) were mutually exclusive only by backend convention — nothing at the CRD level enforced it, so setting both crashed the pod (CrashLoopBackOff) instead of failing at admission time. Fixed with an object-level CEL rule on both structs; a webhook wasn't needed since this is a same-object, multi-field check CEL can express directly, and it covers create+update for free. Needed +kubebuilder:validation:MaxItems=64 on the per-node-override string slices too, since the nested nodeConfigs map's unbounded CEL cost estimate otherwise exceeds the API server's rule-cost budget. Both fixes verified empirically end-to-end against a live GCP lblk cluster (unset->set rejected, set->different rejected, set->same-value no-op allowed, lblk+pcie combo rejected, lblk-only/pcie-only still succeed). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015pjXzhB8JQ6ySSm1vvwZq7
…-heal after SA recreation The storage-node-ds DaemonSet relied on Kubernetes' default auto-injected ServiceAccount token, which defaults to a ~1 hour expiration (kubelet refreshes at ~80% of that, so ~48 minutes). We hit this directly this session: the simplyblock-storage-node-sa ServiceAccount got deleted and recreated (exact trigger not conclusively root-caused — suspected informer-cache resync race under rapid CRD schema updates), and every already-running pod's mounted token stayed bound to the old, now-nonexistent ServiceAccount UID. The API server correctly rejects those with 401 Unauthorized, and nothing self-heals until kubelet's next refresh — in practice this read as a long, manual-intervention outage (had to force-delete pods to get fresh tokens). Mount an explicit short-lived (10 minute) projected ServiceAccountToken volume instead of relying on the default auto-injected one, with AutomountServiceAccountToken: false on the pod spec to avoid a duplicate mount at the same default path. This doesn't prevent a ServiceAccount recreation event, but bounds its blast radius to a few minutes of self-healing via kubelet's normal refresh cycle instead of requiring manual pod deletion. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015pjXzhB8JQ6ySSm1vvwZq7
Real, significant finding from live GCP testing: adding exactly one new node to an already-active cluster leaves it permanently stuck oscillating between IN_ACTIVATION and SUSPENDED. sbcli's cluster_expand() requires a minimum of 2 new nodes (they pair with each other as HA secondaries), and separately the operator has no code path that calls cluster complete-expand after add-node at all. Recovery required manually sn remove + sn delete the new node, then cluster activate. Also confirms the pre-existing lvol survived fully intact throughout, and notes commit 83b2ece's SA-token-expiration fix. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015pjXzhB8JQ6ySSm1vvwZq7
Documents five real gaps found while continuing the Error Cases and Compatibility checklist against the GCP lblk cluster and config-israel: - restart-device fails to recover an lblk device after the kernel renames its backing block device (stale bdev_aio survives teardown) - JM (journal) device failures are not proactively detected the way storage device failures are, despite reaching the data path as real I/O errors - leftover failed_device_migration tasks from an earlier node removal retry forever against devices confirmed deleted from the DB - partition-table-type detection (lsblk PTTYPE) silently breaks without /run/udev mounted, which the real production DaemonSet doesn't mount - once detection is fixed, the GPT journal-split path has a sector-size unit mismatch that corrupts partition boundaries on 4Kn disks Also confirms the "device disappears" storage-device case degrades gracefully, and MBR is correctly rejected once detection works. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015pjXzhB8JQ6ySSm1vvwZq7
…eadlock Found while continuing Error Cases testing: device_migration and new_device_migration task families can deadlock each other when both target the same node+distrib (SUSPENDED new_device_migration blocks device_migration; device_migration's mere existence blocks new_device_migration). Reproduced twice, stalled the whole cluster in ACTIVE - REBALANCING with all nodes unhealthy for 2+ hours. Also notes a JM-device recovery gap missed during the earlier device-disappears test (own oversight, fixed via restart-jm-device). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015pjXzhB8JQ6ySSm1vvwZq7
…esh-hole findings Completes the last two untested Error Cases: - SPDK process crash mid add-node: opposite of expected behavior. The bdts process's wrapper exits cleanly (code 0) instead of relaunching it, restartPolicy: Never means Kubernetes never restarts the container, and the operator polls forever with no remediation. The node gets permanently stuck in_creation with no CLI-level recovery path (remove/delete both refuse it) — required a raw DB write to escape. Hugepages are confirmed genuinely freed at the OS level once the pod is force-deleted, so it's a stale DB figure, not a literal leak. - Add-node retry leaves JM-mesh holes: confirmed via incidental evidence from repeated add/remove/delete cycling — all 3 surviving nodes carry a permanent stale failed remote-device bdev reference to a node/device that no longer exists anywhere in the DB. Node removal cleans up the removed node's own stack but never the peers' references to it. Cluster restored to ACTIVE/healthy afterward (cluster activate + manual DB cleanup of the wedged test node). All originally-listed Error Cases are now tested. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015pjXzhB8JQ6ySSm1vvwZq7
…straints Attempted the Hung IO on an AIO device case via a dm-linear passthrough on a disposable test node, to later dmsetup suspend/resume for a true indefinite-stall simulation. Blocked by two non-bug environment issues: node_configure.py's auto-discovery correctly excludes an already-held disk (so it never selected the wrapped device), and leaving the wrapper in place seemed to slow down add-node's device rescan across the whole host for unrelated disks. Neither isolated enough to call a confirmed bug, so marked partial with a suggested approach (pre-wrap the disk before first boot) for whoever picks this up next. Test node fully cleaned up; the real 3-node cluster was untouched throughout. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015pjXzhB8JQ6ySSm1vvwZq7
…esExclude/blkSerials The rule was "oldSelf.hasValue() && self == oldSelf.value()", which requires an old value to already exist for the object to be valid at all -- this rejected setting the field on a brand-new object's creation, and also rejected the very unset-to-set transition the optionalOldSelf pattern was introduced to allow. Confirmed live on a fresh config-israel install: kubectl apply of a new StorageNodeSet with blkNames set failed with "field is immutable" even though the object didn't exist yet. Corrected to "!oldSelf.hasValue() || self == oldSelf.value()": valid when there was no old value yet (first time setting, including at creation), or when the new value matches the unchanged old value. Same bug existed in the per-node StorageNode override fields (storagenode_types.go). Regenerated CRDs via make manifests + helm-charts sync-from-operator.sh. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015pjXzhB8JQ6ySSm1vvwZq7
…d sizing rebase buildPerNodeEnvFile gained a required *StorageCluster parameter on main (cluster-scoped SPDK sizing), but these two lblk-specific unit tests still called it with the old 2-arg signature -- caught as a compile failure after rebasing feat/lblk-block-device-support onto origin/main. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015pjXzhB8JQ6ySSm1vvwZq7
boddumanohar
force-pushed
the
feat/lblk-block-device-support
branch
from
August 21, 2026 11:50
d551c39 to
6fb2971
Compare
…omment reformatting CI failed three ways on the same root cause: golangci-lint's gofmt check, "Operator: Manifests" (dist/install.yaml staleness), and the real e2e suite (kubectl apply rejecting the CRD outright: "Syntax error: token recognition error at: '”'"). Go 1.19+ gofmt reformats doc comments as prose, which converts straight quotes to typographic quotes -- fatal here since the doc comment is a +kubebuilder:validation:XValidation:rule="..." marker whose content is a literal CEL expression, not prose. `self.pcieModel != ''` and `self.driveSizeRange != ''` became `self.pcieModel != "` (U+201D), invalid CEL syntax that the API server correctly rejected at apply time. Fixed by eliminating the quote characters instead of relying on exact byte preservation: replaced the empty-string comparisons with size(...) > 0, consistent with the size() calls already used earlier in the same rule for the list fields. Regenerated CRDs and dist/install.yaml (stale since the last two commits changed the CEL rule without a final rebuild). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015pjXzhB8JQ6ySSm1vvwZq7
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds operator support for sbcli's
lblkdevice mode (simplyblock/sbcli#1224, not yet merged), which lets storage nodes run on generic Linux block devices (SPDK AIO bdevs) instead of requiring physical NVMe hardware — cloud block volumes, virtio-blk disks, non-NVMe local disks.Closes/addresses #436.
StorageCluster.spec.deviceMode(nvme|lblk, defaultnvme, immutable): cluster-wide choice, threaded into thePOST /api/v2/clusters/create payload asdevice_mode.StorageNodeSet.spec(+ per-nodenodeConfigsoverride, +StorageNode.spec.overrides):enableLblk,blkNames,blkNamesExclude,blkSerials,lblkJournalPercent— mirrors the existingpcieAllowList/pcieDenyList/deviceNamesselector pattern.buildPerNodeEnvFile) as new env vars (LBLK,BLK_NAMES,BLK_NAMES_EXCLUDE,BLK_SERIALS,LBLK_JM_PERCENT), which the DaemonSet init container already sources and turns intonode_configure.pyflags — extended with--lblk/--blk-names/--blk-names-exclude/--blk-serials/--jm-percent, matching the existingPCI_ALLOWED/--pci-allowedwiring exactly.dist/install.yamlregenerated viamake generate manifests helm-syncandmake -C operator build-installer.Why this shape
sbcli#1224 does not expose device selection through the "add storage node" REST API — only a single
force_formatfield was added there. Device selection happens at the node-localsn configure --lblk/node_configure.pystep (already has ak8s=Truepath), and the cluster-wide mode is set once atcluster create. That's exactly the mechanism this operator's DaemonSet init container already drives for NVMe PCIe selection (PCI_ALLOWEDetc. →node_configure.py), so lblk selection is added as more of the same rather than a new integration point.Known gaps (v1, flagged for follow-up)
node_configure.py's own argparse validation, surfacing as an init-container CrashLoopBackOff rather than a clean CR-admission rejection.StorageNodeSet's lblk fields are only meaningful when the parentStorageCluster.spec.deviceMode == "lblk"— currently just documented in field comments.Test plan
go build ./...go vet ./...,golangci-lint runon touched packages — 0 issuesgo test ./...— all green, including new unit tests:TestBuildPerNodeEnvFile_LblkFleetDefaults/_LblkOverrideWinsOverFleet/ updated_ContainsAllRequiredKeysTestStorageClusterReconcileCreationSendsDeviceMode(sendslblk, omits by default)🤖 Generated with Claude Code