Skip to content

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

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

Edge clusters: shared-library extraction + spec + implementation#1225
schmidt-scaled wants to merge 15 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
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