Edge clusters: shared-library extraction + spec + implementation - #1225
Edge clusters: shared-library extraction + spec + implementation#1225schmidt-scaled wants to merge 15 commits into
Conversation
…p 1) New sbcli-agnostic package (no imports from core/web/cli; persistence and models are injected) so the edge-clusters services can reuse the control plane's plumbing without duplication: - tasks/lease.py: TaskLease claim/refresh/heartbeat lifted from tasks_controller (which now delegates, keeping its public entry points). Fix: a successful claim/refresh now also stamps the caller's copy of the task, so a follow-up full-object write (marking RUNNING) no longer clobbers the committed owner back to its stale value. - tasks/runner.py: TaskRunner poll-loop base (cluster sweep, re-read, cancel finalize, retry ceiling, lease claim + heartbeat, exponential backoff, DB-wedge exit(1)) replacing the skeleton every tasks_runner_* hand-rolls; tasks_runner_fdb_backup converted as the reference (also gains a main() guard - it previously ran its loop at import). - monitors/: PollingService (sweep loop, error cadence, adaptive interval, wedge threshold) and PerItemSupervisor (thread-per-node respawn); device_monitor and health_check_service converted as references. - api/: v2 typed scalars + creation_response and AccessLogMiddleware (app.py and api/v2/util.py are now facades over the lib). - events.py, units.py, secrets.py: level-mirrored event logging, parse_size, and SecretStr unwrap helpers re-homed; former locations re-export. Tests: tests/unit/lib/ (75 tests, duck-typed fakes incl. a fresh-read atomic_update stand-in) and tests/integration/lib/ (lease CAS + runner end-to-end against real FDB). tox types now covers simplyblock_lib. Also adds docs/edge_clusters_analysis.md (codebase analysis and plan for the edge-clusters feature; this extraction is build-order step 1). Parity notes: UrlPath's validator was and remains inactive (bare callable in Annotated; v2 DTOs store absolute URLs that it would reject if wired) - documented in tests. parse_size's uppercase-decimal-kilo rejection quirk kept and pinned by test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
docs/edge_clusters_spec.md defines the v1 design; new simplyblock_edge
package implements it on the step-1 library bases:
- Tenancy: an edge cluster IS a Cluster record (cluster_type=edge, default
hyperscale keeps old records unchanged) — cluster-secret auth, the
/clusters/{id} tenancy shape and the task/event keyspace reuse for free.
Per-edge k8s access (api url / SA token / CA / namespace) lives on the
Cluster record; empty url = the CP's own cluster.
- Models: EdgeNode (BaseNodeObject statuses, partitions, is_primary,
lvstore_base), EdgeVolume — all keyed {cluster_id}/{uuid} so every read
is a bounded range read.
- stack.py: pure deterministic bdev planner — local stack per Michael's
rule (1 partition = aio, 2 = raid1, 3+ = raid5f), cross-node raid1
mirror with an nvme-tcp leg to the peer's replication subsystem,
lvstore on the mirror (2 nodes) or the local top (1 node). aio names
keyed by original partition slot so reassembly/replace stay stable.
- edge_cluster_ops.py: create cluster, add node (max 2; lazy lvstore;
1->2 expansion under an existing lvstore explicitly rejected per spec
§10), volume create/delete/resize/connect, admin shutdown/restart,
device replace/add, and the three task handlers (node reassembly with
raid re-add + volume republish, raid member replace, raid5 grow).
- status.py: pure node-status derivation (unreachable = mgmt-plane
verdict, never destructive; returned nodes need a reassembly task
before ONLINE; DOWN is admin intent and never auto-restarted) and
Michael's cluster rule verbatim (all-out = suspended, partial =
degraded, else active).
- services: EdgeMonitor (PollingService; bounded k8s+RPC probes,
per-cluster isolation) and EdgeTaskRunner (TaskRunner over the three
FN_EDGE_* families with host lease + backoff); swarm compose entries.
- k8s.py + edge_spdk_pod.yaml.j2: per-cluster kubernetes clients from
stored credentials; 2-vCPU hostNetwork SPDK pod rendered and deployed
by the CP — no snode agent, no init Job, no vfio (partitions via AIO).
- API: /clusters/{id}/edge-nodes + /edge-volumes v2 routers (DTOs local
to the module), mounted in the cluster tree.
Tests: 76 unit tests (pure planner/status matrices; ops, monitor, task
handlers and API routers against a stateful FakeSpdk + FakeEdgeK8s +
FakeKV with fresh-read CAS semantics; fakes shared via tests/_mocks.py)
plus FDB integration tests incl. a full lifecycle: create -> 2 nodes ->
volume -> secondary outage -> degraded -> restart task -> reassembly ->
active. Unit tier: 1270 green; ruff/mypy clean.
Deferred per spec §10: raid5f rebuild/grow capability check in the fork,
takeover/failback, CSI integration, CLI command group, operator CRD.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| size_in_unit = size | ||
| unit = assume_unit | ||
| else: | ||
| m = re.match(r'^(?P<size_in_unit>\d+) ?(?P<unit>\w+)?$', size.strip()) |
…le + e2e suite Spec corrections (docs/edge_clusters_spec.md v2): volumes are dynamic lvols over the lvstore between the nvmf target and the mirror; lvstore fail-over to the secondary and fail-back on primary restart are in scope; optional crypto bdevs keyed from the existing KMS. Core: - Active/passive client paths: every volume's subsystem + listener exists on BOTH nodes from create; only the lvstore host publishes the namespace, so a takeover activates the pre-connected second path via namespace-attach (no ANA). Connect info returns all paths, active first. - Fail-over (FN_EDGE_FAILOVER, monitor-enqueued when the lvstore host stops serving with an ONLINE peer): superblocked mirror reassembles degraded on the secondary via bdev_examine (explicit-create fallback, fork gate), volumes republished actively, lvstore_base flips. - Fail-back inside the returning primary's restart task: wait mirror resync, withdraw namespaces + release the raid on the secondary, assemble + reload on the primary, republish (active there / passive on the secondary). - Crypto volumes: create_volume(crypto=True) inserts a crypto bdev between lvol and fabric; AES_XTS keys from the cluster KMS (Vault or LocalKMS, hyperscale-identical handling), re-registered at every republish; DEKs deleted with the volume. - Device lifecycle for the e2e plan: graceful remove_device/restart_device ops + API, partition offline/unavailable statuses, monitor-side detection of lost backing devices (EBS force-detach -> unavailable, IO continues on raid redundancy). - POST /clusters/edge create endpoint (201 returns the cluster secret); hosts_lvstore on the node DTO; SPDK pod CPU env-overridable (default 1 vCPU for 4-vCPU edge hosts). e2e (e2e/edge/): AWS deployment infrastructure + staged suite per the test plan - boto3 provisioning (VPC, central k3s CP+3-worker cluster, 8 edge k3s clusters covering the 1/2/2p/4-drive matrix on 1- and 2-node variants, cloud-init k3s, tag-swept teardown), deploy.py (CP bootstrap hook, sgdisk partitioning, SA-token minting, API-driven cluster/node/volume creation = test 1), fio pod workload (2 jobs, iodepth 2, 10G, rwmix 30/70, max_latency=20s as the interruption detector, connects all paths), and tests 2-6: parallel fio, single/two-node reboot failovers (incl. lvstore fail-over/fail-back assertions and second-node repeat), device remove/ restart, EBS force-detach error + reattach + permanent replacement, and flaky/broken CP<->edge links (tc/iptables) with mandatory IO continuity. Tests: 105 edge unit tests green (failover/failback, crypto with LocalKMS against the kv fake, device lifecycle); unit tier 1288 green; ruff clean; mypy clean for touched files. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ssing Per Michael's clarification, fail-over/fail-back now uses the spdk-fork machinery instead of the cold-standby design, and 2-node clusters run ACTIVE/ACTIVE: - Each node OWNS a store (lvstore over a superblocked raid1 mirror whose legs are bdev_split halves of both nodes' local stacks) and runs a live SECONDARY instance of the peer's store (bdev_lvol_set_lvs_opts roles). - Volume creations run on the store leader and are REGISTERED on the pairing node's secondary instance (bdev_lvol_register) — the lvol bdev exists on both nodes, enabling TWO REAL PATHS per lvol: ANA optimized on the leader, non-optimized on the peer (listeners_create ana_state + nvmf_subsystem_listener_set_ana_state). Placement balances across stores; per-store client ports (4420/4421) bound fail-back fencing. - Fail-over = product flow: bdev_lvol_update_lvstore (refresh in-memory metadata) -> bdev_lvol_set_leader -> ANA flip. Monitor enqueues per-store FN_EDGE_FAILOVER; DOWN owners still fail over (availability wins). - Fail-back on node restart: legs re-added into the survivor's two raid instances, resync gate, then nvmf_port_block on the store's client port, set_leader(False, bs_nonleadership) on the peer, update + set_leader on the returning node, ANA flip, unblock. Restart-without-takeover resumes own leadership. Crypto bdevs exist on both nodes (keys re-fetched from the KMS at every republish). - Deploy-time SPDK vCPU choice 1-6 (API spdk_cpus): 1 = all threads on one core; 2 = app+lvs / nvmf; 3 = one core each; 4-6 add nvmf poller cores. Masks travel as pod env; lvs poller group placed via bdev_lvol_create_poller_group. The central clusters' CPU-topology node-preparation Job (storage_cpu_topology.yaml.j2) now also runs on every edge node before the SPDK pod deploys. Spec §4-5 rewritten for the adopted model; e2e suite updated (leader_of based fail-over/fail-back assertions, spdk_cpus knob, all-path connects). Unit tier 1294 green (100 edge tests incl. promotion, port-fenced fail-back, registration, ANA, cpu-layout matrix); ruff/mypy clean. Fork gates (spec §10): superblock examine-assembly of the mirror on the secondary, update_lvstore semantics over a raid1 leg mid-rebuild, rebuild- progress fields, raid5f rebuild/grow. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| rpc = node_rpc_client(node) | ||
| try: | ||
| rpc.subsystem_delete(volume.nqn) | ||
| except RPCException: |
| if volume.crypto: | ||
| try: | ||
| rpc.lvol_crypto_delete(volume.crypto_bdev) | ||
| except RPCException: |
…city retry fixes) # Conflicts: # simplyblock_core/models/cluster.py # tests/_mocks.py
Adopts the house retry convention introduced on main (AGENTS.md): the two hand-rolled deadline loops in edge_cluster_ops become Retrying(...) with explicit stop=/wait= and before_sleep logging. _raid_is_synced is split out as a pure predicate so the fail-back gate is testable on its own. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…leg, tier isolation) Three defects found auditing the suite against the requested scenarios: 1. The suite could not be COLLECTED: e2e/__init__.py imports the legacy e2e_tests framework at package-import time, so every module beneath e2e/ fails to import outside that environment. Moved the campaign to a top-level package edge_e2e/ (25 cases now collect). 2. Test 2's CENTRAL leg was a silent no-op: deploy.py never created a hyperscale pool/volume and never set state.central.fio_connect, which the test reads. deploy.py now creates pool + volume via sbctl and records the parsed connect info, so fio really runs on the central cluster in parallel with the eight edge clusters. 3. Nothing tied the stages together. Added run_all.py: provision -> deploy (test 1) -> tests 2-6, with --soak-cycles N for unattended fault soaks, --only for subsets, --skip-provision/--skip-deploy/--keep/--teardown-only, a per-run directory (stage logs, junit xml, pre/post cluster-status snapshots, on-failure cluster log capture), and non-zero exit for CI. Also: main's new repo-wide 30s per-test budget would have killed every campaign case, so the tier sets its own (3h) in conftest.py the way the migration tier does, tags cases with a registered edge_e2e marker, and norecursedirs keeps the campaign out of the unit/integration tiers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| "sudo journalctl -u k3s -u k3s-agent --no-pager -n 2000", | ||
| check=False, timeout=120) | ||
| (out / f"{label}-{node_name}-k3s.log").write_text(text or "") | ||
| except Exception: |
…ir tool First real AWS run of the campaign found two blockers: - cloud-init aborted on EVERY node before installing k3s: 'sgdisk' is not a package (it ships in gdisk), and with `set -e` the failed apt-get killed the script. Instances came up bare. - `python edge_e2e/provision.py` put edge_e2e/ on sys.path instead of the repo root, so the absolute package imports failed. Added a repo-root bootstrap so both script and -m invocation work. Adds repair_bootstrap.py, which replays the corrected bootstrap over SSH from state.json (idempotent, parallel across agents) so a fleet that lost its one-shot cloud-init can be recovered without re-provisioning. Also adds EDGE_E2E_CLUSTERS to topology.py to run a named subset of the matrix — the cheap 2-cluster validation run before the full fleet. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… install) The default bootstrap command was wrong in three ways, all found by running it against the live fleet: - It cloned a repo I had invented from the script names in docs/k8s_mgmt.md. The scripts live in the PUBLIC simplyblock-io/simplyBlockDeploy under bare-metal/ — the same source .github/workflows/e2e-bootstrap-k8s.yml and k8s-e2e.yaml use. (Lesson: when a doc names a script without a source, grep the workflows.) - bootstrap-cluster.sh takes its topology from ENVIRONMENT VARIABLES (MNODES, STORAGE_PRIVATE_IPS, KEY, BASTION_IP), not CLI inventory; run without them it prints "mgmt_private_ips:" empty and does nothing. The geometry flags had also moved on from the older workflow I copied (--max-lvol -> --max-subsys, --distr-ndcs -> --data-chunks-per-stripe), so the script rejected the command line outright. - sbctl is not on the base image; deploy.py assumed it. Install it (with pip) before reading cluster id/secret. Also opens 80/443 in the security group — the campaign's API client reaches the control plane over the ingress, which the SG previously blocked. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Third live-run finding: the bootstrap script targets simplyBlockDeploy's
terraform topology and cannot be reconfigured by env alone —
KEY="$HOME/.ssh/simplyblock-us-east-2.pem" # line 4: a plain assignment,
# not ${KEY:-...}, so passing
# KEY= has no effect
ssh -i "$KEY" -o ProxyCommand="... root@${BASTION_IP}" root@${node_ip}
i.e. it logs in as ROOT, through a BASTION, with a hardcoded key filename.
A flat public-subnet fleet fails all three (empty BASTION_IP resolved to
"root@", and ubuntu-only login).
deploy.py now prepares that shape before bootstrapping: enables root login
on the mgmt + worker nodes, copies the run's private key to the hardcoded
path on the mgmt node, and points BASTION_IP at the mgmt node itself (it is
its own bastion in this topology).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…m + CRs)
Michael's correction, twice over. First: a kubernetes-only deployment has no
business installing docker — I had been bending the fleet to satisfy
simplyBlockDeploy's bare-metal bootstrap-cluster.sh (root SSH, bastion
ProxyCommand, hardcoded key path, docker daemon), fixing each mismatch it
produced instead of questioning the script. Second: the operator does NOT own
the deployment — it sits ON TOP of the control plane and consumes its APIs,
so edge stays an API-tier feature (a future EdgeCluster CR will consume
POST /clusters/edge, exactly as StorageNode CRs consume the storage-node
APIs today).
bootstrap_central() now:
- installs helm, then `helm upgrade --install simplyblock-operator` from
the official chart (control plane + operator + cert-manager + CSI),
- waits for the ControlPlane CR to report status.phase=Ready,
- declares the central hyperscale cluster as StorageCluster + per-worker
StorageNode CRs,
- reads the backend cluster UUID from StorageCluster.status.uuid and the
secret via sbctl, for the API-driven edge flow that follows.
Deletes the entire bare-metal adaptation: prepare_bootstrap_ssh(),
ENABLE_ROOT_SSH, the key copy, BASTION_IP plumbing and the simplyBlockDeploy
clone. Charts/operator/CSI live in github.com/simplyblock/simplyblock-operator
(the standalone helm-charts repo is deprecated); note the org is
`simplyblock`, not the `simplyblock-io` in the stale e2e-bootstrap-k8s.yml.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
edge_e2e/state.json holds the per-run cluster secrets and the kubernetes ServiceAccount tokens minted for each edge site — committing it would publish live credentials. edge_e2e/runs/ holds bulky per-stage logs and cluster captures. Ignore both explicitly rather than relying on *.log. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
mxsrc
left a comment
There was a problem hiding this comment.
Okay, I have a few points, with no particular order:
-
restart_nodewill restart without checking the node status. If that node (A) is the secondary of a failed node (B), this will trigger the rebuild ofelvs_Bfrom the copy, even thoughAis the leader. The minimal fix of this would be the restart refusing on nodes that lead stores they don't own. -
Edge{Node,Partition}.STATUS_REMOVEDare never assigned anywhere. Are they simply introduced as a provision for a future change? -
delete_lvolreturned errors are not checked. This should be made explicit if intentional. -
add_edge_nodeuses threads rather than task-based async execution, leading to issues with execution reliability. This is the same issue with the regular storagenode add, as well as cluster start/shutdown/activate/expand, so probably out of scope here, but good to be aware of. Probably this should be reworked, but given the prevalence int he code base we might defer it until reworking this with proper async execution.
One thing that may be considered critical though is the lack of cleanup on failed add-node calls, they simply silently go missing along with the spawned thread.
-
Concurrent
add_edge_nodecalls may violate theMAX_EDGE_NODESlimit. -
k8s_tokenentry and storage (along withk8s_ca_cert) are not hanlded according to the mandated secret handling. -
_reassemble_nodecalls_publish_volume→_ensure_crypto_stackfor every
volume in a loop with no per-volume isolation. an exception from one crypto
volume's KMS lookup aborts the whole reassembly. -
k8s.py's CA-cert temp-file cache never evicts stale files on rotation. (_ca_filesdict,k8s.py:22-37) today this is a minor leak of public data. Since this is adjacent to certificate data this risks developing into an actual problem -
I'd like to avoid the added top-level edge_e2e directory, this might be something we can fold in with
e2ease2e/{hyperscale,edge -
The added API shape is non restful and contradicts spec and analysis. I'd like to propose this shape instead:
POST /clusters/ {..., cluster_type: "edge", k8s_api_url, k8s_token, k8s_ca_cert, k8s_namespace}
GET /clusters/{id}/ -> cluster_type now in the response
GET /clusters/{id}/edge/storage-nodes
POST /clusters/{id}/edge/storage-nodes
GET /clusters/{id}/edge/storage-nodes/{node_id}
POST /clusters/{id}/edge/storage-nodes/{node_id}/shutdown
POST /clusters/{id}/edge/storage-nodes/{node_id}/restart
POST /clusters/{id}/edge/storage-nodes/{node_id}/devices
PUT /clusters/{id}/edge/storage-nodes/{node_id}/devices
POST /clusters/{id}/edge/storage-nodes/{node_id}/devices/remove
POST /clusters/{id}/edge/storage-nodes/{node_id}/devices/restart
GET /clusters/{id}/edge/volumes
POST /clusters/{id}/edge/volumes
GET /clusters/{id}/edge/volumes/{vol_id}
PUT /clusters/{id}/edge/volumes/{vol_id}
DELETE /clusters/{id}/edge/volumes/{vol_id}
GET /clusters/{id}/edge/volumes/{vol_id}/connect
Folding the creation into the existing create would also inherit the name-locking avoiding reintroducing the name duplication.
The cluster type should also be introduced to the ClusterDTO.
Three more findings from live runs:
- helm under sudo has no ~/.kube/config and fell back to localhost:8080
("Kubernetes cluster unreachable"). k3s writes /etc/rancher/k3s/k3s.yaml;
the helm commands now pass KUBECONFIG explicitly.
- The AMI's default 8 GiB root volume cannot hold the control-plane image
set. run-1786464991 reached 87% used with ~1 GiB free and the kubelet
evicted FDB and admin-control pods (Evicted /
Init:ContainerStatusUnknown). Root volume is now explicit and sized
(EDGE_E2E_ROOT_DISK_GB, default 80), resolved from the AMI's own
RootDeviceName and applied to every launch.
- create_subnet without an AvailabilityZone let AWS pick us-east-1e, which
does not offer m5.xlarge, so RunInstances failed "Unsupported ... in your
requested Availability Zone". The AZ is now chosen as one that offers
EVERY instance type the run needs (intersection over
describe_instance_type_offerings).
Verified on the rebuilt fleet: cloud-init completes unattended, all three
k3s clusters form, and / has 75 GiB free.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The chart installs released simplyblock, which has no edge API — POST /clusters/edge 404s no matter what. But every push to any branch is already built and published by .github/workflows/docker-image.yml as simplyblock/simplyblock:<branch> and public.ecr.aws/simply-block/simplyblock:<branch>-<sha8>; scripts/setup_lblk_* pin exactly that (SB_TAG/SB_IMAGE/SIMPLY_BLOCK_DOCKER_IMAGE). So there was never an image to build — only one to select. deploy.py now derives <branch>-<sha8> from the checked-out commit (EDGE_E2E_SB_IMAGE / EDGE_E2E_BRANCH override it), passes it to helm as --set image.repository/image.tag, and installs sbctl from the same branch with pip git+... the way the soak scripts do. Also: the management API is a ClusterIP service (simplyblock-webappapi:5000) with no ingress, so nothing ever listened on port 80 of the mgmt node. It is now patched to NodePort, the port is read back into state.api_url, and the security group opens the NodePort range. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both defects were found by the first live edge run (2026-08-11), where a node add failed and the campaign could only report "Timed out waiting for: node ... -> online (last error: None)" after 900s. 1. The failure reason was thrown away. add_edge_node flipped the record to offline and re-raised into a detached API thread whose logger produced no output in the pod, so WHY existed nowhere — not the logs, not the record, not the API response. EdgeNode now carries status_reason, set on the failure path and cleared when the node comes online, and the v2 node DTO exposes it. edge_e2e.wait_node_status aborts as soon as a reason appears instead of burning the whole timeout. 2. A failed add was UNRETRYABLE. The record left behind counted toward MAX_EDGE_NODES, so a 1-node cluster with two failed attempts rejected every retry with "Edge clusters support at most 2 nodes" and could not recover without manual DB surgery. Records for the same hostname that never came online are now treated as the same node: they are dropped and retried into, and they do not count against the limit. Nodes that did come online still cap at MAX_EDGE_NODES and still reject a duplicate hostname. Tests: 103 edge unit tests (3 new — retry after repeated failure, reason recorded, cap still enforced); unit tier 1205 green; ruff clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| "RootDeviceName", "/dev/sda1") | ||
|
|
||
|
|
||
| def _describe_instance(ec2, instance_id, attempts=12, delay=5): |
|
|
||
|
|
||
| def test_established_nodes_still_capped_at_two(env): | ||
| kv, spdk, fake_k8s = env |
|
|
||
|
|
||
| def test_established_nodes_still_capped_at_two(env): | ||
| kv, spdk, fake_k8s = env |
|
|
||
|
|
||
| def test_established_nodes_still_capped_at_two(env): | ||
| kv, spdk, fake_k8s = env |
Summary
Two commits implementing the edge-clusters feature (see
docs/edge_clusters_analysis.mdanddocs/edge_clusters_spec.md):simplyblock_lib— sbcli-agnostic infrastructure extracted from core/web so edge (and future services) reuse it without duplication: task lease/claim +TaskRunnerpoll-loop base,PollingService/PerItemSupervisormonitor skeletons, v2 API scaffolding (typed scalars, creation response, access-log middleware), events/units/secrets helpers. Reference conversions:tasks_runner_fdb_backup,device_monitor,health_check_service. Includes a fix: a successful lease claim/refresh now stamps the caller's task copy so a follow-up full-object write can't clobber the committed owner.simplyblock_edge— spdk-only 1-2 node edge clusters managed by the same centralized CP over exactly two channels (edge k8s API + SPDK JSON-RPC, no snode agent): edge cluster =Clusterrecord withcluster_type=edge; deterministic bdev-stack planner (1 partition = aio / 2 = raid1 / 3+ = raid5f locally; cross-node raid1 mirror via nvme-tcp leg; lazy lvstore); volume CRUD + connect; node/cluster status derivation (unreachable = mgmt-plane verdict, DOWN sticky, returned nodes reassembled by task before ONLINE);EdgeMonitor+EdgeTaskRunnerservices; per-cluster k8s clients + 2-vCPU SPDK pod template; v2 routers/clusters/{id}/edge-nodes+/edge-volumes.Testing
Deferred (spec §10)
raid5f rebuild/grow fork-capability check, takeover/failback, CSI integration, CLI command group, operator CRD, auth scaling fixes (per-request cluster-secret scan).
🤖 Generated with Claude Code