Skip to content

Edge clusters: shared-library extraction + spec + implementation - #1225

Draft
schmidt-scaled wants to merge 31 commits into
mainfrom
edge-clusters
Draft

Edge clusters: shared-library extraction + spec + implementation#1225
schmidt-scaled wants to merge 31 commits into
mainfrom
edge-clusters

Conversation

@schmidt-scaled

Copy link
Copy Markdown
Contributor

Summary

Two commits implementing the edge-clusters feature (see docs/edge_clusters_analysis.md and docs/edge_clusters_spec.md):

  1. simplyblock_lib — sbcli-agnostic infrastructure extracted from core/web so edge (and future services) reuse it without duplication: task lease/claim + TaskRunner poll-loop base, PollingService/PerItemSupervisor monitor 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.

  2. 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 = Cluster record with cluster_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 + EdgeTaskRunner services; per-cluster k8s clients + 2-vCPU SPDK pod template; v2 routers /clusters/{id}/edge-nodes + /edge-volumes.

Testing

  • Unit tier: 1270 passed locally (151 new: lib + edge, incl. API router tests).
  • Integration tier: 15 new FDB tests (lease CAS semantics, runner end-to-end, full edge lifecycle: outage → degraded → reassembly → active). Not yet executed — no Docker on the dev box; this PR exists to run them in CI.
  • ruff clean; mypy clean for all new/touched files.

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

michixs and others added 2 commits August 6, 2026 22:18
…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>
Comment thread simplyblock_lib/units.py
size_in_unit = size
unit = assume_unit
else:
m = re.match(r'^(?P<size_in_unit>\d+) ?(?P<unit>\w+)?$', size.strip())
Comment thread simplyblock_edge/edge_cluster_ops.py Fixed
…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>
Comment thread simplyblock_edge/edge_cluster_ops.py Fixed
Comment thread simplyblock_edge/edge_cluster_ops.py Fixed
Comment thread simplyblock_edge/edge_cluster_ops.py Fixed
Comment thread simplyblock_edge/edge_cluster_ops.py Fixed
Comment thread simplyblock_edge/edge_cluster_ops.py Fixed
Comment thread tests/unit/edge/test_failover_failback.py Fixed
Comment thread tests/unit/edge/test_failover_failback.py Fixed
Comment thread tests/unit/edge/test_failover_failback.py Fixed
…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:
michixs and others added 3 commits August 10, 2026 11:22
…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>
Comment thread edge_e2e/run_all.py
"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:
michixs and others added 5 commits August 10, 2026 18:59
…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 mxsrc left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, I have a few points, with no particular order:

  1. restart_node will restart without checking the node status. If that node (A) is the secondary of a failed node (B), this will trigger the rebuild of elvs_B from the copy, even though A is the leader. The minimal fix of this would be the restart refusing on nodes that lead stores they don't own.

  2. Edge{Node,Partition}.STATUS_REMOVED are never assigned anywhere. Are they simply introduced as a provision for a future change?

  3. delete_lvol returned errors are not checked. This should be made explicit if intentional.

  4. add_edge_node uses 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.

  1. Concurrent add_edge_node calls may violate the MAX_EDGE_NODES limit.

  2. k8s_token entry and storage (along with k8s_ca_cert) are not hanlded according to the mandated secret handling.

  3. _reassemble_node calls _publish_volume_ensure_crypto_stack for every
    volume in a loop with no per-volume isolation. an exception from one crypto
    volume's KMS lookup aborts the whole reassembly.

  4. k8s.py's CA-cert temp-file cache never evicts stale files on rotation. (_ca_files dict, 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

  5. I'd like to avoid the added top-level edge_e2e directory, this might be something we can fold in with e2e as e2e/{hyperscale,edge

  6. 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.

michixs and others added 3 commits August 11, 2026 20:15
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>
Comment thread edge_e2e/provision.py
"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
michixs and others added 8 commits August 13, 2026 00:10
First live node-add failure surfaced by status_reason (2026-08-13):
'EdgeK8sError: cpu-topology job timed out'. The shared cpu-topology job
template pins serviceAccountName to simplyblock-storage-node-sa, which the
helm chart creates on central clusters — nothing creates it on a bare edge
cluster, so every pod create was forbidden and the job could never start.
deploy_cpu_topology_job now ensures a bare ServiceAccount (the job runs a
host-prep script and makes no k8s API calls, so no RBAC is needed).

Also append the job's Warning events to cpu-topology failure messages so
the node's status_reason says WHY ('error looking up service account ...')
instead of just that a wait expired, and make edge_e2e's deploy re-runnable
on the same fleet (reuse the registered edge cluster from state instead of
re-POSTing its unique name; two-phase helm now uses --reset-values since
helm upgrade otherwise silently reuses the previous release's values).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The API endpoint kept a private copy of the add-node preconditions with the
pre-retry semantics (any non-removed same-hostname record => 400), so the
retry path shipped in d39506a was unreachable through the API: a failed
add left an offline record and every retry 400ed "already part of the
cluster" (live run 2026-08-13). Extract check_node_admission() in ops and
use it from both layers so they cannot diverge again.

Also scope the single-node-layout guard to fresh adds: a retry of a failed
SECOND-node add must pass even though the aborted active/active formation
may already have stamped the first node's lvstore_base.

Tests: 105 edge unit tests (2 new: API-layer admission matches ops after a
failed add; second-node retry admissible after partial formation).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The central job mutates kubelet config and restarts the kubelet, which on a
1-node k3s edge cluster restarts the embedded API server mid-node-add (the
channel the CP is using), and its kubeadm-style script crash-loops on k3s
regardless (BackoffLimitExceeded, live run 2026-08-13). Exclusive reactor
cores on edge are instead a provisioning-time prerequisite of the edge
cluster itself (k3s kubelet-arg cpu-manager-policy=static); scheduling
priority is no substitute for placement (RT throttling stalls pollers
50ms/s by default; CFS still time-shares the core). Re-enable explicitly
with SIMPLYBLOCK_EDGE_CPU_TOPOLOGY=true.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
cpu-manager-policy=static + reserved-cpus=0 baked into the k3s cloud-init
(server and agent): exclusive reactor cores for the Guaranteed SPDK pod are
now a provisioning-time property of the cluster, replacing the disabled
cpu-topology job on edge. Also: two-phase helm bootstrap fixes from tonight
(--reset-values; reuse registered edge clusters; run_all REPO path).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
state.json survives reprovisioning; blindly trusting its cluster_id/secret
against a fresh control plane 404ed every call (2026-08-13). Probe the
record first and re-register on failure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The SPDK pod requests hugepages-2Mi; nothing configured them on edge nodes,
so the pod was unschedulable ("Insufficient hugepages-2Mi", live run
2026-08-13). e2e cloud-init now sets vm.nr_hugepages=1536 BEFORE k3s
installs so the kubelet registers the capacity from the start.

EDGE_RPC_WAIT_TIMEOUT_SEC 120 -> 600 (env-overridable): the wait covers the
pod's FIRST multi-GB image pull on the edge uplink, not just process start.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Re-running phase 1 on an already-bootstrapped cluster downgrades the whole
control plane to the released build; its older-schema services sweep and
rewrite cluster records, silently wiping branch-added fields (cluster_type
flipped edge->hyperscale on every campaign rerun, 2026-08-13 — masqueraded
as an active FDB stripper). The StorageCluster's backend UUID is the
bootstrap marker; when present, go straight to the branch image.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
EDGE_SPDK_IMAGE/EDGE_PROXY_IMAGE defaulted to nonexistent placeholder tags;
the first successfully scheduled SPDK pod sat in ImagePullBackOff for 3h
(2026-08-13). Default to what central k8s storage nodes run: the ultra image
(the spdk fork whose primary/secondary lvstore processing edge REQUIRES) and
the simplyblock image for the RPC proxy.

Also append the ApiException body message to pod-create failures — a bare
"create pod ...: 403" in status_reason left the actual k8s reason
unknowable after the fact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
michixs and others added 8 commits August 13, 2026 20:37
The in-cluster fallback in api_client() silently redirected every edge
operation to the CP's OWN cluster when k8s_api_url was empty — observed
2026-08-13 after a schema round-trip wiped the k8s connection fields:
"edge" pod creates landed on the central cluster as the webappapi service
account and 403ed with nothing naming the real problem. For cluster_type
edge this is now a hard EdgeK8sError; the fallback remains for tests and
single-site setups.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The first pod that ever scheduled crash-looped: the template invented an
interface (SPDK_*_MASK env vars, bare images) that the images ignore. The
images define the contract, mirrored from the central storage-node pod:

- spdk-container runs the ultra image's run_distr_with_ssd.sh
  "<l_cores>" "<mem MB>". With PCI_ALLOWED empty and no distr bdevs the
  fork target behaves as a plain spdk_tgt with the fork's lvol/lvstore/
  nvmf modules — the spdk-only processing edge uses (decision 2026-08-13:
  ultra image as artifact is fine). l_cores is a static identity map; the
  image's adjust_cpu_mask.sh remaps it onto the kubelet-granted cpuset,
  which the CP cannot know at render time.
- spdk-proxy-container runs spdk_http_proxy_server.py; both share the
  Memory-backed /mnt/ramdisk where the RPC socket lives (path convention
  hardcoded in the proxy). Central's mounts/tolerations/hostNetwork
  mirrored, minus PCI passthrough, TLS and fluentd.
- The app starts with --wait-for-rpc, so core parameters are handed over
  via RPC in order (new _init_spdk_framework): framework_start_init ->
  framework_get_reactors (the ACTUAL lcores) -> bdev_lvol_create_poller_
  group once per process, mask built from the real reactor list. Replaces
  _apply_cpu_layout in both the add and reassembly paths. nvmf pre-init
  poll-group masks deferred (needs relative-mask support, spec §10).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…retry

Three fixes from the 2026-08-13 live runs, where every node add died right
after the get_version liveness check:

1. A retry now ADOPTS the stale attempt's uuid and rpc credentials instead
   of minting fresh ones. New identity while the previous attempt's
   hostNetwork pod still owns the rpc port was deterministically fatal:
   the new pod can't bind (proxy CrashLoop), get_version hits the OLD
   proxy with the NEW password (401 forever), and each orphaned pod eats
   the node's hugepage reservation until nothing schedules (3 leaked pods
   observed on one node). Stable identity makes the 409-tolerant redeploy
   genuinely idempotent.

2. Hugepages are reserved at OS level from INSIDE the privileged SPDK pod
   (raise /proc/sys/vm/nr_hugepages to TOTAL_HP before launching), and the
   k8s hugepages-2Mi resource request is dropped — it made scheduling
   depend on the node being pre-provisioned with pages before kubelet
   start, an edge-site prerequisite we deliberately avoid.

3. EdgeRpcClient retries transport-level "connection error" up to 3 times:
   RPCClient._request2 does a single POST with no retry while the proxy
   closes its side after each response and requests reuses connections.
   Edge RPCs are idempotent by design, so a short bounded retry is safe.

Tests: 109 edge unit (2 new: identity adoption; rpc retry semantics x3).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Removing it made kubernetes set the POD-level hugetlb.2MB.max to 0, so
DPDK failed with "EAL: FATAL: Cannot init memory" while 1536 pages sat
free on the host (verified live 2026-08-13: container-level limits were
max, the pod-level limit was 0). The request and the launch script's
OS-level nr_hugepages top-up compose: one opens the cgroup, the other
fills the pool.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
framework_start_init died in the bdev subsystem: "could not allocate
spdk_bdev_io pool" — the fork's compiled-in pool defaults (the launch
script's hardcoded 200000) are sized for central nodes with ~10GB of SPDK
memory, not an edge node's. Mirror central's pre-init handover with
edge-sized values BEFORE framework_start_init: iobuf_set_options,
bdev_set_options (16k bdev_ios; also sets bdev_auto_examine=false, matching
our explicit-examine reassembly), accel_set_options. On an already-
initialized process the option calls fail and are skipped — the existing
'already initialized' handling covers the rest. Hugepages default doubled
to 2048MiB for headroom (SPDK -s 1536MB); all values env-overridable.

Also: a failed framework init leaves the app half-initialized and every
subsequent framework_start_init times out at the proxy — the retry path
must recreate the pod, done operationally for now (delete before rerun).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Nodes that came online after a previous campaign stopped waiting must not
fail the re-run: skip already-online nodes and tolerate "already part of
the cluster" from an add whose background thread finished late.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Its sweep calls update_cluster_status for EVERY cluster; an edge cluster
has no storage-node records, so the hyperscale formula verdicts it from an
empty node list and rewrote 'degraded' every interval — fighting the edge
monitor's 'active' forever (observed live 2026-08-13, clusters ping-ponged
degraded<->active). Edge cluster status is owned by the edge monitor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pytest-timeout's default signal method needs SIGALRM, which Windows lacks
— every test stage of the first campaign run to reach the test tier
INTERNALERROR'd at collection (2026-08-14), right after test 1 passed for
the first time. method="thread" works on both platforms.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

4 participants