Skip to content

Commit 34d4d15

Browse files
yansun1996claude
andcommitted
fix(spurctld): pin jwt_key and interval_secs to startup; scope reconfigure to leader
Three correctness fixes on top of the live-reload work: - auth.jwt_key is now restart-only. Node tokens are HS256-signed with it (7-day TTL) and verified per-RPC; swapping the key live would instantly reject every outstanding node token and silently partition healthy nodes. ControllerService captures the key once at startup (resolve_startup_jwt_key) and verification uses that pinned value, mirroring Slurm's restart-only AuthType. admission.mode stays live (flipping it invalidates nothing). - scheduler.interval_secs is now pinned everywhere. The scheduler loop already captured it once at boot; the preemption requeue hold read it live from config(), so post-reconfigure the hold window drifted while the loop cadence did not. Capture it in ClusterManager at construction and read the pinned value, keeping interval_secs truly restart-only. - reconfigure is documented as leader-only. It runs on the Raft leader and swaps only the leader's in-memory config; no WAL entry carries config, so followers keep their startup config until restart (in k8s they re-read the same ConfigMap). Even a partition edit that propagates via WAL makes followers reconcile against their own stale config().nodes, so they get new partition membership but old node features until restart. Clarified in the reconfigure doc comment, CLI help/output, partitioning.rst, and kubernetes.rst. WAL-propagating config is a planned follow-up. Tests: reconfigure_does_not_adopt_new_jwt_key builds a real ControllerService, reconfigures the key, and proves a token minted with the startup key still verifies (and would fail under the new key). The max_batch_requeue test now drives maybe_requeue past the cap and asserts the consumer requeues after the new cap is applied, rather than only checking the swapped config value. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 85126d4 commit 34d4d15

5 files changed

Lines changed: 191 additions & 47 deletions

File tree

crates/spur-cli/src/scontrol.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -221,8 +221,9 @@ pub enum ScontrolCommand {
221221
#[arg(long)]
222222
name: String,
223223
},
224-
/// Re-read spur.conf and apply it live (partitions, nodes, licenses,
225-
/// hooks, scheduler tunables, etc.; ports/DB/raft need a restart)
224+
/// Re-read spur.conf and apply it live on the leader (partitions, nodes,
225+
/// licenses, hooks, scheduler tunables, etc.). Ports/DB/raft/jwt_key need a
226+
/// restart; followers converge on restart.
226227
Reconfigure,
227228
/// Create a reservation
228229
#[command(name = "create-reservation")]
@@ -1480,7 +1481,7 @@ async fn reconfigure(controller: &str) -> Result<()> {
14801481
client.reconfigure(()).await.context("reconfigure failed")?;
14811482

14821483
println!(
1483-
"Reconfiguration complete (listen ports, accounting DB, and raft peers still require a controller restart)"
1484+
"Reconfiguration complete on the leader (followers converge on restart; listen ports, accounting DB, raft peers, and jwt_key still require a controller restart)"
14841485
);
14851486
Ok(())
14861487
}

crates/spurctld/src/cluster.rs

Lines changed: 72 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,12 @@ pub struct ClusterManager {
223223
/// values live; sections captured once at startup (bound sockets, DB pool,
224224
/// scheduler loop interval) remain restart-only — see `reconfigure`.
225225
config: RwLock<Arc<SlurmConfig>>,
226+
/// Scheduler tick interval captured at startup. The scheduler loop's cadence
227+
/// is fixed once at boot (restart-only), so the preemption requeue hold —
228+
/// which is sized to that cadence — must read this pinned value, not the
229+
/// live `config()`, or the hold window would drift after `reconfigure`
230+
/// while the loop keeps ticking at the old rate.
231+
scheduler_interval_secs: u32,
226232
/// Path to the spur.conf file, re-read by `reconfigure()`. spur.conf is
227233
/// never written back — the Raft WAL is the sole source of runtime truth.
228234
/// None when running without a config file (e.g. in tests).
@@ -272,11 +278,13 @@ impl ClusterManager {
272278
let burst_buffer_total_gb = config.burst_buffer.total_gb;
273279
let fairshare_cache = Arc::new(FairshareCache::new());
274280
let first_job_id = config.controller.first_job_id;
281+
let scheduler_interval_secs = config.scheduler.interval_secs;
275282
let qos_cache = Arc::new(QosCache::new());
276283
let association_cache = Arc::new(AssociationCache::new());
277284

278285
let cm = Self {
279286
config: RwLock::new(Arc::new(config)),
287+
scheduler_interval_secs,
280288
config_path,
281289
jobs: RwLock::new(HashMap::new()),
282290
nodes: RwLock::new(HashMap::new()),
@@ -941,7 +949,7 @@ impl ClusterManager {
941949
// the maybe_requeue MAX_REQUEUE cap: Slurm always requeues a
942950
// preempted job regardless of its --requeue flag. This is a
943951
// deliberate divergence from the ordinary requeue path.
944-
let hold_secs = (self.config().scheduler.interval_secs as i64 * 2 + 3).max(5);
952+
let hold_secs = (self.scheduler_interval_secs as i64 * 2 + 3).max(5);
945953
let hold = Utc::now() + chrono::Duration::seconds(hold_secs);
946954
// Honor a later user --begin: compute the max on the leader so
947955
// followers apply one verbatim instant (no per-replica clock).
@@ -2440,19 +2448,32 @@ impl ClusterManager {
24402448
/// semantics in Slurm: runtime-only changes not reflected in the conf are
24412449
/// overwritten by the incoming conf values.
24422450
///
2443-
/// Reloaded live (readers take a fresh snapshot via `config()`):
2444-
/// `[[partitions]]`, `[[nodes]]` features/weight, `licenses`,
2451+
/// **Leader-only.** The command is forwarded to the Raft leader, and this
2452+
/// swaps only the leader's in-memory config — no WAL entry carries the new
2453+
/// config. Followers keep the config they read at startup until they
2454+
/// restart (in Kubernetes they re-read the same ConfigMap). Do not rely on
2455+
/// reconfigured non-partition state surviving an immediate failover.
2456+
/// Partition edits DO propagate (via partition WAL ops), but a follower
2457+
/// re-runs `reconcile_partitions` against its own stale `config().nodes`,
2458+
/// so after a partition edit followers pick up new partition membership but
2459+
/// keep old node features until restart. WAL-propagating config is a
2460+
/// planned follow-up.
2461+
///
2462+
/// Reloaded live on the leader (readers take a fresh snapshot via
2463+
/// `config()`): `[[partitions]]`, `[[nodes]]` features/weight, `licenses`,
24452464
/// `burst_buffer`, `scheduler` tunables (`complete_wait`, `resv_overrun`),
24462465
/// `controller.max_batch_requeue`, `hooks`, `notifications`, `federation`,
2447-
/// `power` suspend/resume commands, `admission.mode`, `auth.jwt_key`, and
2466+
/// `power` suspend/resume commands, `admission.mode`, and
24482467
/// `metrics.high_cardinality`.
24492468
///
24502469
/// Restart-only (baked in at startup — mirrors Slurm's restart-required set
2451-
/// of ports/plugins/StateSaveLocation): bind addresses and ports
2470+
/// of ports/plugins/StateSaveLocation/AuthType): bind addresses and ports
24522471
/// (`controller.listen_addr`, `metrics`/`rest_api` listeners), the
24532472
/// accounting database pool (`accounting.database_url`), Raft identity/peers,
2454-
/// `controller.first_job_id`, and the scheduler loop cadence
2455-
/// (`scheduler.interval_secs`, `max_jobs_per_cycle`, `topology`).
2473+
/// `controller.first_job_id`, `auth.jwt_key` (swapping it live would
2474+
/// instantly invalidate every outstanding node token), and the scheduler
2475+
/// loop cadence (`scheduler.interval_secs`, `max_jobs_per_cycle`,
2476+
/// `topology`).
24562477
pub fn reconfigure(&self) -> Result<(), anyhow::Error> {
24572478
let Some(ref path) = self.config_path else {
24582479
anyhow::bail!("reconfigure requires a config file path, but none is configured");
@@ -2557,7 +2578,7 @@ impl ClusterManager {
25572578
self.reconcile_partitions(&mut nodes);
25582579
}
25592580

2560-
info!("reconfigure: applied spur.conf; restart-only sections (listen ports, accounting DB, raft peers, scheduler cadence) unchanged until controller restart");
2581+
info!("reconfigure: applied spur.conf on this leader (followers converge on restart); restart-only sections (listen ports, accounting DB, raft peers, jwt_key, scheduler cadence) unchanged until controller restart");
25612582
Ok(())
25622583
}
25632584

@@ -4973,27 +4994,54 @@ mod tests {
49734994
(cm, conf_path)
49744995
}
49754996

4976-
#[tokio::test]
4977-
async fn reconfigure_reloads_max_batch_requeue_live() {
4997+
/// Consumer-driven: `maybe_requeue` must honor the new `max_batch_requeue`
4998+
/// after reconfigure, not just the swapped config value. A job whose
4999+
/// `requeue_count` sits between the old and new caps is a no-op under the
5000+
/// old cap but requeues to Pending under the new one.
5001+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5002+
async fn reconfigure_max_batch_requeue_changes_consumer_behavior() {
5003+
let conf = |cap: u32| {
5004+
format!(
5005+
"cluster_name = \"test\"\n\
5006+
[controller]\nmax_batch_requeue = {cap}\n\
5007+
[[partitions]]\nname = \"default\"\ndefault = true\nstate = \"UP\"\nnodes = \"ALL\"\n"
5008+
)
5009+
};
49785010
let dir = TempDir::new().unwrap();
4979-
let (cm, conf_path) = test_cluster_with_conf_file(
4980-
&dir,
4981-
"cluster_name = \"test\"\n[controller]\nmax_batch_requeue = 3\n",
4982-
)
4983-
.await;
4984-
assert_eq!(cm.config().controller.max_batch_requeue, 3);
5011+
let (cm, conf_path) = test_cluster_with_conf_file(&dir, &conf(3)).await;
5012+
register_node(&cm, "worker1", 8, 16000);
49855013

4986-
std::fs::write(
4987-
&conf_path,
4988-
"cluster_name = \"test\"\n[controller]\nmax_batch_requeue = 9\n",
4989-
)
4990-
.unwrap();
5014+
let job_id = run_job_on(&cm, "requeue-cap", "worker1");
5015+
// Put the job in a terminal, requeue-eligible state (Failed → Pending is
5016+
// a valid requeue transition and is NOT in the max-requeue hold set, so
5017+
// over-cap is a clean no-op) with 5 attempts already recorded.
5018+
{
5019+
let mut jobs = cm.jobs.write();
5020+
let job = jobs.get_mut(&job_id).unwrap();
5021+
job.state = JobState::Failed;
5022+
job.spec.requeue = true;
5023+
job.requeue_count = 5;
5024+
}
5025+
5026+
// Cap = 3, count = 5 → over cap → maybe_requeue is a no-op (stays Failed).
5027+
cm.maybe_requeue(job_id).unwrap();
5028+
assert_eq!(
5029+
cm.get_job(job_id).unwrap().state,
5030+
JobState::Failed,
5031+
"over-cap job must not requeue before reconfigure"
5032+
);
5033+
5034+
// Raise the cap past the attempt count and reconfigure.
5035+
std::fs::write(&conf_path, conf(9)).unwrap();
49915036
cm.reconfigure().unwrap();
49925037

5038+
// Cap = 9, count = 5 → under cap → maybe_requeue returns it to Pending.
5039+
cm.maybe_requeue(job_id).unwrap();
5040+
settle(&cm, job_id, JobState::Pending);
49935041
assert_eq!(
4994-
cm.config().controller.max_batch_requeue,
4995-
9,
4996-
"reconfigure must apply the new max_batch_requeue to the live config"
5042+
cm.get_job(job_id).unwrap().state,
5043+
JobState::Pending,
5044+
"after reconfigure raised the cap, the consumer must requeue the job"
49975045
);
49985046
}
49995047

crates/spurctld/src/server.rs

Lines changed: 93 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ pub struct ControllerService {
3434
client_addrs: BTreeMap<u64, String>,
3535
rpc_stats: Arc<RpcStatsCollector>,
3636
sched_stats: Arc<SchedStatsCollector>,
37+
/// JWT signing key for node tokens, captured at startup. Deliberately NOT
38+
/// re-read on `scontrol reconfigure`: swapping it live would instantly fail
39+
/// verification of every outstanding node token (7-day TTL), silently
40+
/// partitioning healthy nodes. Like Slurm's AuthType, it is restart-only.
41+
jwt_key: String,
3742
}
3843

3944
struct LeaderProxy {
@@ -89,6 +94,18 @@ impl LeaderProxy {
8994
}
9095
}
9196

97+
/// Resolve the node-token signing key from config at startup. Captured once by
98+
/// `serve` into `ControllerService::jwt_key`; deliberately not re-read on
99+
/// `reconfigure` (see the field doc). Falls back to a shared default so
100+
/// key-less dev clusters interoperate.
101+
fn resolve_startup_jwt_key(config: &spur_core::config::SlurmConfig) -> String {
102+
config
103+
.auth
104+
.jwt_key
105+
.clone()
106+
.unwrap_or_else(|| "spur-default-key".to_string())
107+
}
108+
92109
impl ControllerService {
93110
// tonic::Status is 176 bytes (over clippy's 128-byte threshold); fixed upstream in tonic 0.13+
94111
#[allow(clippy::result_large_err)]
@@ -140,8 +157,7 @@ impl ControllerService {
140157
fn validate_admission(&self, join_token: &str, hostname: &str) -> Result<String, Status> {
141158
use spur_core::config::AdmissionMode;
142159

143-
let config = self.cluster.config();
144-
if !matches!(config.admission.mode, AdmissionMode::Token) {
160+
if !matches!(self.cluster.config().admission.mode, AdmissionMode::Token) {
145161
return Ok(String::new());
146162
}
147163

@@ -156,9 +172,7 @@ impl ControllerService {
156172
spur_core::admission::validate_token(token_id, secret, &token_store)
157173
.map_err(|e| Status::permission_denied(e.to_string()))?;
158174

159-
let jwt_key = config.auth.jwt_key.as_deref().unwrap_or("spur-default-key");
160-
161-
spur_core::admission::generate_node_token(hostname, jwt_key.as_bytes())
175+
spur_core::admission::generate_node_token(hostname, self.jwt_key.as_bytes())
162176
.map_err(|e| Status::internal(e.to_string()))
163177
}
164178
}
@@ -623,17 +637,15 @@ impl SlurmController for ControllerService {
623637
}
624638
let req = request.into_inner();
625639

626-
let config = self.cluster.config();
627640
if matches!(
628-
config.admission.mode,
641+
self.cluster.config().admission.mode,
629642
spur_core::config::AdmissionMode::Token
630643
) {
631644
if req.node_token.is_empty() {
632645
return Err(Status::unauthenticated("node token required"));
633646
}
634-
let jwt_key = config.auth.jwt_key.as_deref().unwrap_or("spur-default-key");
635647
let identity =
636-
spur_core::admission::verify_node_token(&req.node_token, jwt_key.as_bytes())
648+
spur_core::admission::verify_node_token(&req.node_token, self.jwt_key.as_bytes())
637649
.map_err(|e| Status::unauthenticated(e.to_string()))?;
638650
if identity.hostname != req.hostname {
639651
return Err(Status::permission_denied("node token hostname mismatch"));
@@ -963,17 +975,15 @@ impl SlurmController for ControllerService {
963975

964976
let req = request.into_inner();
965977

966-
let config = self.cluster.config();
967978
if matches!(
968-
config.admission.mode,
979+
self.cluster.config().admission.mode,
969980
spur_core::config::AdmissionMode::Token
970981
) {
971982
if req.node_token.is_empty() {
972983
return Err(Status::unauthenticated("node token required"));
973984
}
974-
let jwt_key = config.auth.jwt_key.as_deref().unwrap_or("spur-default-key");
975985
let identity =
976-
spur_core::admission::verify_node_token(&req.node_token, jwt_key.as_bytes())
986+
spur_core::admission::verify_node_token(&req.node_token, self.jwt_key.as_bytes())
977987
.map_err(|e| Status::unauthenticated(e.to_string()))?;
978988
if identity.hostname != req.hostname {
979989
return Err(Status::permission_denied("node token hostname mismatch"));
@@ -1746,13 +1756,16 @@ pub async fn serve(
17461756

17471757
let leader_proxy = LeaderProxy::new(raft_handle.clone(), client_addrs.clone());
17481758

1759+
let jwt_key = resolve_startup_jwt_key(&cluster.config());
1760+
17491761
let service = ControllerService {
17501762
cluster,
17511763
client_addrs,
17521764
raft: raft_handle.clone(),
17531765
leader_proxy,
17541766
rpc_stats: rpc_stats.clone(),
17551767
sched_stats: sched_stats.clone(),
1768+
jwt_key,
17561769
};
17571770

17581771
let stats_layer = RpcStatsLayer::new(rpc_stats, raft_handle);
@@ -2327,6 +2340,73 @@ mod tests {
23272340
assert_eq!(status.message(), "partition 'gpu' not found");
23282341
}
23292342

2343+
/// GATE: `auth.jwt_key` is captured at startup and must NOT change on
2344+
/// `reconfigure`. Swapping it live would instantly invalidate every
2345+
/// outstanding node token. This drives the real capture path
2346+
/// (`resolve_startup_jwt_key`) and the real `reconfigure`, then proves the
2347+
/// running controller still verifies a token minted with the startup key.
2348+
#[tokio::test]
2349+
async fn reconfigure_does_not_adopt_new_jwt_key() {
2350+
use spur_core::admission::{generate_node_token, verify_node_token};
2351+
2352+
let dir = tempfile::TempDir::new().unwrap();
2353+
let conf_path = dir.path().join("spur.conf");
2354+
std::fs::write(
2355+
&conf_path,
2356+
"cluster_name = \"test\"\n[auth]\nplugin = \"jwt\"\njwt_key = \"old-secret\"\n",
2357+
)
2358+
.unwrap();
2359+
2360+
let config = spur_core::config::SlurmConfig::load_from_file(&conf_path).unwrap();
2361+
let cluster = Arc::new(
2362+
ClusterManager::new_with_config_path(config, dir.path(), Some(conf_path.clone()))
2363+
.unwrap(),
2364+
);
2365+
let handle = crate::raft::start_raft(1, &["[::1]:0".into()], dir.path(), cluster.clone())
2366+
.await
2367+
.unwrap();
2368+
handle
2369+
.raft
2370+
.wait(Some(std::time::Duration::from_secs(5)))
2371+
.metrics(|m| m.current_leader == Some(1), "leader elected")
2372+
.await
2373+
.unwrap();
2374+
cluster.set_raft(handle.raft);
2375+
2376+
// The controller captures the signing key exactly here, at startup.
2377+
let startup_key = resolve_startup_jwt_key(&cluster.config());
2378+
assert_eq!(startup_key, "old-secret");
2379+
let token = generate_node_token("node-1", startup_key.as_bytes()).unwrap();
2380+
2381+
// Operator edits jwt_key and reconfigures.
2382+
std::fs::write(
2383+
&conf_path,
2384+
"cluster_name = \"test\"\n[auth]\nplugin = \"jwt\"\njwt_key = \"new-secret\"\n",
2385+
)
2386+
.unwrap();
2387+
cluster.reconfigure().unwrap();
2388+
2389+
// The live config reflects the new key (proving reconfigure did swap
2390+
// config)...
2391+
assert_eq!(
2392+
cluster.config().auth.jwt_key.as_deref(),
2393+
Some("new-secret"),
2394+
"reconfigure must swap the live config"
2395+
);
2396+
// ...but the controller's captured key is unchanged, so tokens minted
2397+
// with the startup key still verify. A live-reloaded key would reject
2398+
// this token.
2399+
assert_eq!(startup_key, "old-secret", "captured key must not change");
2400+
assert!(
2401+
verify_node_token(&token, startup_key.as_bytes()).is_ok(),
2402+
"outstanding node token must still verify against the startup key"
2403+
);
2404+
assert!(
2405+
verify_node_token(&token, b"new-secret").is_err(),
2406+
"sanity: the token would fail under the new key (proving the key matters)"
2407+
);
2408+
}
2409+
23302410
#[test]
23312411
fn submit_rpc_status_maps_internal() {
23322412
use crate::cluster::SubmitError;

docs/deployment/kubernetes.rst

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,12 @@ Raft peers use StatefulSet DNS names. The node ID is auto-detected from the pod
7979
Adjust partition definitions and node resources to match your cluster hardware.
8080
Once the controller is running, ``scontrol reconfigure`` applies most section
8181
changes (partitions, nodes, licenses, hooks, scheduler tunables) without a
82-
restart; listen ports, the accounting database, and Raft peers still require
83-
restarting the controller. See :doc:`partitioning` for the full breakdown.
82+
restart; listen ports, the accounting database, Raft peers, and ``jwt_key``
83+
still require restarting the controller. ``reconfigure`` runs on the Raft
84+
leader only — followers keep their startup config until restarted, at which
85+
point they re-read this same ConfigMap and converge. To roll all controllers
86+
onto an updated ConfigMap, restart the StatefulSet pods. See
87+
:doc:`partitioning` for the full breakdown.
8488

8589
Submitting Jobs
8690
---------------

0 commit comments

Comments
 (0)