Skip to content

Commit 62d4994

Browse files
committed
fix(spurctld,spur-cli): clear max-nodes on --max-nodes 0; pass proto request to update_partition
`update-partition --max-nodes 0` was a silent no-op: it arrives as max_nodes_value=Some(0) with clear_max_nodes unset, and the handler forwarded the raw request flag, so cluster.rs saw "no change". Collapse both inputs into a single derived clear bool (via resolve_max_nodes_update) and pass that through, so a literal 0 clears the limit as the proto documents. Add a unit test covering the four intents. Rework the client update_partition to take the proto UpdatePartitionRequest directly; both callers assemble the struct, dropping the 24 positional args, the redundant set_* bools, and the too_many_arguments allow. Fold the duplicated split_csv closures into one shared helper.
1 parent baec0d6 commit 62d4994

2 files changed

Lines changed: 81 additions & 158 deletions

File tree

crates/spur-cli/src/scontrol.rs

Lines changed: 50 additions & 150 deletions
Original file line numberDiff line numberDiff line change
@@ -442,49 +442,36 @@ pub async fn main_with_args(args: Vec<String>) -> Result<()> {
442442
priority_tier,
443443
preempt_mode,
444444
} => {
445-
update_partition(
446-
&args.controller,
447-
&name,
445+
let selector_map = match selector {
446+
Some(ref s) => parse_selector(s)?,
447+
None => HashMap::new(),
448+
};
449+
let req = spur_proto::proto::UpdatePartitionRequest {
450+
name,
448451
nodes,
449-
selector,
450-
clear_selector,
452+
selector: selector_map,
453+
set_selector: clear_selector || selector.is_some(),
451454
state,
452-
default,
455+
is_default: default,
453456
max_time,
454457
default_time,
455-
max_nodes,
458+
max_nodes_value: max_nodes,
456459
clear_max_nodes,
457460
min_nodes,
458-
if set_allow_accounts {
459-
Some(&allow_accounts)
460-
} else {
461-
None
462-
},
463-
if set_allow_groups {
464-
Some(&allow_groups)
465-
} else {
466-
None
467-
},
461+
allow_accounts: split_csv(&allow_accounts),
468462
set_allow_accounts,
463+
allow_groups: split_csv(&allow_groups),
469464
set_allow_groups,
470-
if set_deny_accounts {
471-
Some(&deny_accounts)
472-
} else {
473-
None
474-
},
475-
if set_deny_qos { Some(&deny_qos) } else { None },
465+
deny_accounts: split_csv(&deny_accounts),
476466
set_deny_accounts,
467+
deny_qos: split_csv(&deny_qos),
477468
set_deny_qos,
478-
if set_allow_qos {
479-
Some(&allow_qos)
480-
} else {
481-
None
482-
},
469+
allow_qos: split_csv(&allow_qos),
483470
set_allow_qos,
484471
priority_tier,
485472
preempt_mode,
486-
)
487-
.await
473+
};
474+
update_partition(&args.controller, req).await
488475
}
489476
ScontrolCommand::DeletePartition { name } => {
490477
delete_partition(&args.controller, &name).await
@@ -521,12 +508,6 @@ pub async fn main_with_args(args: Vec<String>) -> Result<()> {
521508
add_accounts,
522509
remove_accounts,
523510
} => {
524-
let split_csv = |s: &str| -> Vec<String> {
525-
s.split(',')
526-
.map(|s| s.trim().to_string())
527-
.filter(|s| !s.is_empty())
528-
.collect()
529-
};
530511
let channel = spur_client::connect_channel(&args.controller)
531512
.await
532513
.context("failed to connect to spurctld")?;
@@ -1217,59 +1198,34 @@ async fn parse_and_update_partition(controller: &str, params: &[String]) -> Resu
12171198
anyhow::bail!("scontrol update: PartitionName= is required");
12181199
}
12191200

1220-
let set_allow_accounts = allow_accounts.is_some();
1221-
let set_allow_groups = allow_groups.is_some();
1222-
let set_deny_accounts = deny_accounts.is_some();
1223-
let set_deny_qos = deny_qos.is_some();
1224-
let set_allow_qos = allow_qos.is_some();
1225-
1226-
update_partition(
1227-
controller,
1228-
&name,
1201+
// An ACL is applied only when its key appeared; an empty value clears it.
1202+
let req = spur_proto::proto::UpdatePartitionRequest {
1203+
name,
12291204
nodes,
1230-
None, // selector not supported in inline syntax
1231-
false,
1205+
selector: HashMap::new(), // selector not supported in inline syntax
1206+
set_selector: false,
12321207
state,
12331208
is_default,
12341209
max_time,
12351210
default_time,
1236-
max_nodes,
1211+
max_nodes_value: max_nodes,
12371212
clear_max_nodes,
12381213
min_nodes,
1239-
if set_allow_accounts {
1240-
allow_accounts.as_deref()
1241-
} else {
1242-
None
1243-
},
1244-
if set_allow_groups {
1245-
allow_groups.as_deref()
1246-
} else {
1247-
None
1248-
},
1249-
set_allow_accounts,
1250-
set_allow_groups,
1251-
if set_deny_accounts {
1252-
deny_accounts.as_deref()
1253-
} else {
1254-
None
1255-
},
1256-
if set_deny_qos {
1257-
deny_qos.as_deref()
1258-
} else {
1259-
None
1260-
},
1261-
set_deny_accounts,
1262-
set_deny_qos,
1263-
if set_allow_qos {
1264-
allow_qos.as_deref()
1265-
} else {
1266-
None
1267-
},
1268-
set_allow_qos,
1214+
set_allow_accounts: allow_accounts.is_some(),
1215+
allow_accounts: allow_accounts.as_deref().map(split_csv).unwrap_or_default(),
1216+
set_allow_groups: allow_groups.is_some(),
1217+
allow_groups: allow_groups.as_deref().map(split_csv).unwrap_or_default(),
1218+
set_deny_accounts: deny_accounts.is_some(),
1219+
deny_accounts: deny_accounts.as_deref().map(split_csv).unwrap_or_default(),
1220+
set_deny_qos: deny_qos.is_some(),
1221+
deny_qos: deny_qos.as_deref().map(split_csv).unwrap_or_default(),
1222+
set_allow_qos: allow_qos.is_some(),
1223+
allow_qos: allow_qos.as_deref().map(split_csv).unwrap_or_default(),
12691224
priority_tier,
12701225
preempt_mode,
1271-
)
1272-
.await
1226+
};
1227+
1228+
update_partition(controller, req).await
12731229
}
12741230

12751231
/// Update a node's state via the controller.
@@ -1312,6 +1268,14 @@ async fn update_node(
13121268
Ok(())
13131269
}
13141270

1271+
/// Split a comma-separated list into trimmed, non-empty entries.
1272+
fn split_csv(s: &str) -> Vec<String> {
1273+
s.split(',')
1274+
.map(|s| s.trim().to_string())
1275+
.filter(|s| !s.is_empty())
1276+
.collect()
1277+
}
1278+
13151279
/// Parse "KEY=VALUE,KEY2=VALUE2" into a HashMap.
13161280
fn parse_selector(s: &str) -> Result<HashMap<String, String>> {
13171281
let mut map = HashMap::new();
@@ -1351,13 +1315,6 @@ async fn create_partition(
13511315
.context("failed to connect to spurctld")?;
13521316
let mut client = spur_proto::controller_client(channel);
13531317

1354-
let split_csv = |s: &str| -> Vec<String> {
1355-
s.split(',')
1356-
.map(|s| s.trim().to_string())
1357-
.filter(|s| !s.is_empty())
1358-
.collect()
1359-
};
1360-
13611318
client
13621319
.create_partition(spur_proto::proto::CreatePartitionRequest {
13631320
name: name.to_string(),
@@ -1384,78 +1341,21 @@ async fn create_partition(
13841341
Ok(())
13851342
}
13861343

1387-
/// Update a partition via the controller.
1388-
#[allow(clippy::too_many_arguments)]
1344+
/// Update a partition via the controller. The request is already the proto
1345+
/// struct, so callers assemble it directly rather than threading a long
1346+
/// positional field list through this sender.
13891347
async fn update_partition(
13901348
controller: &str,
1391-
name: &str,
1392-
nodes: Option<String>,
1393-
selector: Option<String>,
1394-
clear_selector: bool,
1395-
state: Option<String>,
1396-
is_default: Option<bool>,
1397-
max_time: Option<String>,
1398-
default_time: Option<String>,
1399-
max_nodes: Option<u32>,
1400-
clear_max_nodes: bool,
1401-
min_nodes: Option<u32>,
1402-
allow_accounts: Option<&str>,
1403-
allow_groups: Option<&str>,
1404-
set_allow_accounts: bool,
1405-
set_allow_groups: bool,
1406-
deny_accounts: Option<&str>,
1407-
deny_qos: Option<&str>,
1408-
set_deny_accounts: bool,
1409-
set_deny_qos: bool,
1410-
allow_qos: Option<&str>,
1411-
set_allow_qos: bool,
1412-
priority_tier: Option<u32>,
1413-
preempt_mode: Option<String>,
1349+
req: spur_proto::proto::UpdatePartitionRequest,
14141350
) -> Result<()> {
14151351
let channel = spur_client::connect_channel(controller)
14161352
.await
14171353
.context("failed to connect to spurctld")?;
14181354
let mut client = spur_proto::controller_client(channel);
14191355

1420-
let split_csv = |s: &str| -> Vec<String> {
1421-
s.split(',')
1422-
.map(|s| s.trim().to_string())
1423-
.filter(|s| !s.is_empty())
1424-
.collect()
1425-
};
1426-
1427-
let selector_map = if let Some(ref s) = selector {
1428-
parse_selector(s)?
1429-
} else {
1430-
HashMap::new()
1431-
};
1432-
1356+
let name = req.name.clone();
14331357
client
1434-
.update_partition(spur_proto::proto::UpdatePartitionRequest {
1435-
name: name.to_string(),
1436-
nodes,
1437-
selector: selector_map,
1438-
set_selector: clear_selector || selector.is_some(),
1439-
state,
1440-
is_default,
1441-
max_time,
1442-
default_time,
1443-
max_nodes_value: max_nodes,
1444-
clear_max_nodes,
1445-
min_nodes,
1446-
allow_accounts: allow_accounts.map(split_csv).unwrap_or_default(),
1447-
set_allow_accounts,
1448-
allow_groups: allow_groups.map(split_csv).unwrap_or_default(),
1449-
set_allow_groups,
1450-
deny_accounts: deny_accounts.map(split_csv).unwrap_or_default(),
1451-
set_deny_accounts,
1452-
deny_qos: deny_qos.map(split_csv).unwrap_or_default(),
1453-
set_deny_qos,
1454-
allow_qos: allow_qos.map(split_csv).unwrap_or_default(),
1455-
set_allow_qos,
1456-
priority_tier,
1457-
preempt_mode,
1458-
})
1358+
.update_partition(req)
14591359
.await
14601360
.context("failed to update partition")?;
14611361

crates/spurctld/src/server.rs

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1419,13 +1419,8 @@ impl SlurmController for ControllerService {
14191419
} else {
14201420
None
14211421
};
1422-
// `clear_max_nodes` and a literal 0 both mean "no limit" (0 documented
1423-
// as "clear limit" in the proto); neither can express a real 0-node cap.
1424-
let max_nodes = if req.clear_max_nodes || req.max_nodes_value == Some(0) {
1425-
None
1426-
} else {
1427-
req.max_nodes_value
1428-
};
1422+
let (max_nodes, clear_max_nodes) =
1423+
resolve_max_nodes_update(req.max_nodes_value, req.clear_max_nodes);
14291424

14301425
let selector = if req.set_selector || !req.selector.is_empty() {
14311426
Some(req.selector.into_iter().collect())
@@ -1445,7 +1440,7 @@ impl SlurmController for ControllerService {
14451440
max_time,
14461441
req.default_time,
14471442
max_nodes,
1448-
req.clear_max_nodes,
1443+
clear_max_nodes,
14491444
min_nodes,
14501445
allow_accounts,
14511446
allow_groups,
@@ -2591,6 +2586,20 @@ fn partition_rpc_status(err: PartitionError) -> Status {
25912586
}
25922587
}
25932588

2589+
/// Resolve an `UpdatePartitionRequest`'s max-nodes intent into the
2590+
/// `(max_nodes, clear)` pair `ClusterManager::update_partition` expects.
2591+
///
2592+
/// `clear_max_nodes` and a literal `max_nodes_value == 0` both mean "no limit"
2593+
/// (0 is documented as "clear limit" in the proto); neither can express a real
2594+
/// 0-node cap. The two inputs must be collapsed into a single `clear` bool that
2595+
/// is passed through — forwarding the raw request flag would drop a `--max-nodes
2596+
/// 0` clear, since that arrives as `Some(0)` with the flag unset.
2597+
fn resolve_max_nodes_update(max_nodes_value: Option<u32>, clear_flag: bool) -> (Option<u32>, bool) {
2598+
let clear = clear_flag || max_nodes_value == Some(0);
2599+
let max_nodes = if clear { None } else { max_nodes_value };
2600+
(max_nodes, clear)
2601+
}
2602+
25942603
fn cluster_err_to_status(err: anyhow::Error) -> Status {
25952604
if err.downcast_ref::<spur_core::auth::AuthError>().is_some() {
25962605
return Status::permission_denied(err.to_string());
@@ -2716,6 +2725,20 @@ mod tests {
27162725
assert_eq!(status.code(), Code::Internal);
27172726
}
27182727

2728+
#[test]
2729+
fn resolve_max_nodes_update_maps_intents() {
2730+
// `--max-nodes 0` (Some(0), flag unset) must resolve to a clear, not a
2731+
// silent no-op: cluster.rs only clears when the passed bool is true.
2732+
assert_eq!(resolve_max_nodes_update(Some(0), false), (None, true));
2733+
// Explicit clear flag, regardless of value.
2734+
assert_eq!(resolve_max_nodes_update(None, true), (None, true));
2735+
assert_eq!(resolve_max_nodes_update(Some(4), true), (None, true));
2736+
// A real positive cap is preserved and does not clear.
2737+
assert_eq!(resolve_max_nodes_update(Some(4), false), (Some(4), false));
2738+
// No value and no flag means "leave unchanged".
2739+
assert_eq!(resolve_max_nodes_update(None, false), (None, false));
2740+
}
2741+
27192742
fn make_node_info(name: &str) -> NodeInfo {
27202743
NodeInfo {
27212744
name: name.into(),

0 commit comments

Comments
 (0)