feat(spurctld): scope k0s cluster to a subset of nodes - #577
Conversation
spur k8s up provisioned k0s over the controller's entire registered inventory, with no way to target a subset. Add node selection via --nodes (hostlist), --partition, and --selector key=val, combined as a union and persisted as the cluster's member scope. The reconcile loop assigns k0s roles only to member nodes; un-scoped nodes get no role and stay schedulable for Spur, which also gives a clean per-node opt-out. Empty selection enrolls the whole inventory (back-compat).
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #577 +/- ##
==========================================
+ Coverage 75.83% 75.96% +0.13%
==========================================
Files 166 166
Lines 64696 65158 +462
==========================================
+ Hits 49058 49496 +438
- Misses 15638 15662 +24 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
This PR adds first-class node scoping for the managed k0s cluster lifecycle (spur k8s up) so that provisioning/role assignment is limited to an explicitly selected subset of the registered inventory, while persisting that resolved membership in Raft state for consistent reconciliation across restarts.
Changes:
- Add
--nodes,--partition, and repeatable--selector key=valtospur k8s up, and expose resolved membership viaspur k8s status. - Persist
member_nodesin the replicated k0s cluster state/WAL and filter role assignment to in-scope nodes. - Extend proto surface area and add unit + e2e tests covering scoping, rejection cases, and backward-compatible WAL replay.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/native_host/e2e/test_k8s_scheduling.py | Adds an e2e assertion that k8s up --nodes <node> records and reports a single-node membership scope. |
| tests/native_host/e2e/cluster.py | Adds a helper to parse the members: line from spur k8s status. |
| proto/slurm.proto | Appends request fields for scoping inputs and a status field for the resolved member set. |
| crates/spurctld/src/server.rs | Resolves/records membership scope on cluster_up, enforces scope immutability once assigned, and returns member nodes in status. |
| crates/spurctld/src/cluster.rs | Extends set_k0s_phase to include persisted member_nodes and clears scope on reset. |
| crates/spurctld/src/cluster_k8s.rs | Filters provisioning assignments by state.is_member, adds scope resolution, and enforces pinned-CP validity within candidate scope. |
| crates/spur-core/src/wal.rs | Adds member_nodes to the WAL op with #[serde(default)] and includes a frozen pre-feature payload replay test. |
| crates/spur-core/src/k0s.rs | Adds persisted member_nodes plus is_member() semantics (empty = whole inventory). |
| crates/spur-cli/src/k8s.rs | Adds CLI flags, selector parsing, and prints membership scope in k8s status. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Reject duplicate --selector keys in the CLI instead of silently dropping earlier values (last-wins would change the intended label AND-match). Error when a supplied --selector matches no node, mirroring the --partition guard, so a typo isn't silently ignored. Clear the recorded member scope and control-plane set on any k8s down so the next up starts from a clean cluster identity rather than reusing stale state. Drop internal ticket refs from comments.
Clearing the member scope and control-plane set on `down` takes effect immediately, but node roles drain on later reconcile ticks. A bare `k8s up` landing in that window saw roles still present (assigned) yet an emptied scope, so it reused the empty scope and silently enrolled the whole inventory while re-electing the control plane. Reject an up while the cluster is tearing down (phase Down with roles still present) so it can't reuse half-cleared state.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 9 changed files in this pull request and generated no new comments.
Suppressed comments (2)
crates/spurctld/src/server.rs:2237
- The assigned-cluster guard compares
member_nodesvectors directly, but an emptymember_nodesis a sentinel meaning “whole inventory”. That makes a semantically no-op request like--nodes <all nodes>(or a selector/partition that expands to all nodes) look different from the recorded empty scope and get rejected as a scope change.
if scope_requested && member_nodes != state.member_nodes {
return Err(Status::failed_precondition(
"cluster membership is already assigned; tear the cluster down \
(spur k8s down --reset) before changing the node scope",
));
crates/spurctld/src/cluster_k8s.rs:132
provision_assignmentsfilters nodes withstate.is_member, which does a linear scan ofmember_nodesfor every node. For large clusters or large scopes this becomes O(N*M) work per reconcile tick. Sincemember_nodesis a fixed list, build aHashSetonce and do O(1) membership checks (and skip filtering entirely when the scope is empty = whole inventory).
let mut nodes = cluster.get_nodes();
nodes.retain(|n| state.is_member(&n.name));
Summary
spur k8s upprovisioned the managed k0s cluster over the controller's entire registered node inventory — the reconcile loop assigned one control plane plus a worker role to every other agent node, with no way to target a subset. On a large shared scheduler that meansk8s uptries to build a cluster spanning all nodes, and there was no clean way to keep a node out (setting a node's local[cluster].enabled=falsedidn't help: the controller still assigned it a role, the node refused to start k0s, and the cluster stalled inProvisioning).This adds node-selection scoping to
spur k8s up:--nodes <hostlist>(e.g.gpu[01-08])--partition <name>--selector key=val(repeatable; a node matches when all pairs match its labels)The three are combined as a union and resolved (fail-closed) to a concrete member set that is persisted in the Raft-replicated cluster state. The reconcile loop assigns k0s roles only to member nodes; un-scoped nodes get no role and stay schedulable for the Spur batch scheduler. That also gives a clean per-node opt-out — a node simply left out of the scope is never claimed, so it can't stall bring-up.
Empty selection enrolls the whole inventory, so existing single-cluster usage is unchanged.
Approach
--control-plane-node(s)outside the selected scope is rejected rather than silently relocated.--resetfirst), mirroring the existing control-plane-change guard.k8s down --resetclears the scope; a bare re-up preserves it.spur k8s statusnow shows the member scope.Persistence / compatibility
#[serde(default)], and a frozen pre-feature payload test guards replay so an upgraded controller cannot crash on an old Raft log entry.Known limitations / follow-ups
k8s uptime into a fixed member set; it is not re-evaluated as inventory changes (a node added later that would match the selector does not auto-join, and a member that deregisters is simply skipped). Changing the membership requiresk8s down --resetthenup. This is intentional (membership is frozen for the cluster's lifetime), and documented in the CLI/proto help.k8s upcalls on a fresh cluster can race (last-writer-wins on the recorded scope/control-plane set). This is a pre-existing property of the handler (it already applied to the control-plane set) and is unchanged by this PR; a follow-up can serialize the handler.Testing
is_membersemantics; the reconcile filter (out-of-scope node stays un-roled); WAL round-trip + frozen pre-feature replay fixture; handler-level tests for scoped up, CP-outside-scope rejection, bare-re-up scope preservation, and scope-change rejection; CLI flag parsing. Behavioral tests were confirmed to fail against the unfixed code.--nodes,--partition, and--selectoreach scoped membership correctly; a job targeting a scoped (k8s-reserved) node pended with the reserved reason while a job on an out-of-scope node ran to completion; control-plane-outside-scope, unknown-node, and scope-change-on-assigned-cluster were all rejected with the expected errors;down --resetcleared the scope and a bare re-up preserved it.