Skip to content

Commit 86b4fce

Browse files
committed
feat(cli): expose advisory capacity through stack and MCP
1 parent 175c1a0 commit 86b4fce

3 files changed

Lines changed: 93 additions & 0 deletions

File tree

‎src/main.rs‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1180,6 +1180,40 @@ struct BatchArgs {
11801180
out_dir: PathBuf,
11811181
}
11821182

1183+
#[derive(clap::Args, Debug)]
1184+
struct CapacityArgs {
1185+
/// Captured normalized capacity snapshot; no scheduler calls are made.
1186+
#[arg(long)]
1187+
snapshot: PathBuf,
1188+
/// Exact partition whose inventory must be complete.
1189+
#[arg(long)]
1190+
partition: String,
1191+
/// Physical host target, independent of the compiler target.
1192+
#[arg(long = "target")]
1193+
host_target: String,
1194+
/// Literal required feature; repeated features are combined with AND.
1195+
#[arg(long = "feature")]
1196+
features: Vec<String>,
1197+
/// CPUs required on one node.
1198+
#[arg(long)]
1199+
cpus: u64,
1200+
/// Scheduler memory required on one node, in MiB.
1201+
#[arg(long)]
1202+
memory_mib: u64,
1203+
/// GPUs required on one node.
1204+
#[arg(long, default_value_t = 0)]
1205+
gpus: u64,
1206+
/// Required GPU type.
1207+
#[arg(long)]
1208+
gpu_type: Option<String>,
1209+
/// RFC3339 evaluation time for deterministic replay; absent is now.
1210+
#[arg(long)]
1211+
generated_at: Option<String>,
1212+
/// Maximum age of the observation, in seconds.
1213+
#[arg(long, default_value_t = 300)]
1214+
max_age_seconds: u64,
1215+
}
1216+
11831217
#[derive(clap::Args, Debug)]
11841218
struct ShardArgs {
11851219
/// Jenkins build list of one node type: `Name-version-toolchain.eb
@@ -1648,6 +1682,8 @@ enum StackCommand {
16481682
after_long_help = "Examples:\n eb-stack stack shard --build-list rome.list --easyconfigs easybuild-easyconfigs/easybuild/easyconfigs --easyconfigs site-easyconfigs --installed-modules-dir /cvmfs/software.eessi.io/versions/2026.06/software/linux/x86_64/amd/zen2/modules/all --installed-modules-dir site-modules/all --shards 4 --weights build-seconds.tsv --out-dir shards\n eb-stack stack shard --build-list rome.list --easyconfigs easybuild-easyconfigs/easybuild/easyconfigs --commit-cache /scratch-shared/$USER/eb-stack-commit-trees --shards 4 --out-dir shards\n eb-stack stack shard --build-list rome.list --easyconfigs easybuild-easyconfigs/easybuild/easyconfigs --easyconfigs-repo easybuild-easyconfigs --materialize --build-robot-path /sw/easybuild/easyconfigs --shards 4 --out-dir shards"
16491683
)]
16501684
Shard(Box<ShardArgs>),
1685+
/// Inspect timestamped advisory scheduler capacity without placing jobs.
1686+
Capacity(Box<CapacityArgs>),
16511687
/// Write the peak build-tree inode footprint per software name.
16521688
///
16531689
/// The peak of `build.inodes_peak` over the records of each `--ledger`,
@@ -3592,6 +3628,30 @@ fn run_stack(command: StackCommand) -> Result<()> {
35923628
println!("{}", run.summary());
35933629
Ok(())
35943630
}
3631+
StackCommand::Capacity(args) => {
3632+
let bytes = std::fs::read(&args.snapshot)
3633+
.with_context(|| format!("--snapshot {}: not readable", args.snapshot.display()))?;
3634+
let evaluated_at = args
3635+
.generated_at
3636+
.unwrap_or_else(|| chrono::Utc::now().to_rfc3339());
3637+
let request = eb_stack::planning_capacity::CapacityRequest {
3638+
partition: args.partition,
3639+
host_target: args.host_target,
3640+
features: args.features.into_iter().collect(),
3641+
cpus: args.cpus,
3642+
memory_mib: args.memory_mib,
3643+
gpus: args.gpus,
3644+
gpu_type: args.gpu_type,
3645+
};
3646+
let report = eb_stack::planning_capacity::inspect(
3647+
&bytes,
3648+
&request,
3649+
&evaluated_at,
3650+
args.max_age_seconds,
3651+
)?;
3652+
println!("{}", serde_json::to_string_pretty(&report)?);
3653+
Ok(())
3654+
}
35953655
StackCommand::Shard(args) => {
35963656
let ShardArgs {
35973657
build_list,

‎src/mcp.rs‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,6 +457,12 @@ fn tool_catalog() -> Vec<Value> {
457457
("oom_resources", "string"),
458458
],
459459
),
460+
tool_with_optional(
461+
"eb_stack_capacity",
462+
"Report timestamped advisory scheduler capacity from a normalized snapshot, as stack capacity does. Partition, physical host target and literal AND features stay exact. Reports per-node resource fits without node names, queue-wait predictions, placement guarantees or admission changes. Missing partition/pending observations remain unknown.",
463+
&[("snapshot","string"),("partition","string"),("target","string"),("cpus","integer"),("memory_mib","integer")],
464+
&[("feature","array"),("gpus","integer"),("gpu_type","string"),("generated_at","string"),("max_age_seconds","integer")],
465+
),
460466
tool_with_optional(
461467
"eb_stack_shard",
462468
"Split one Jenkins build list into shards that can build at the same time, as `stack shard` does. Each line's closure is computed over easyconfigs minus the modules in installed lists and installed_modules_dir Lmod trees. Lines whose own easyconfig another line needs share a shard; any other module two or more shards need goes to prelude.list. Groups are packed into at most shards shards by weight (weights TSV, else default_weight). Writes prelude.list, shard-NN.list (input lines verbatim) and plan.json to out_dir. A line with --from-commit or --from-pr takes the easyconfig it names from that tree, and its dependencies from easyconfigs first and that tree second, as EasyBuild appends the fetched files to its robot path. Trees come from commit_tree (COMMIT=DIR), easyconfigs_repo (a git repository whose objects hold the commits, read in place), pr_tree (N=DIR), or commit_cache, a directory a missing tree is downloaded into from GitHub; a prelude module only such a tree provides keeps the line's source options. With materialize, also writes sources/v1/<commit>/ (the files the plan reads from each commit, with a SHA-256 manifest) and prelude.local.list and shard-NN.local.list, whose --from-commit lines read that directory through --robot-paths set to build_robot_path (the build host's robot paths, in EasyBuild's order) and then the directory; a line that cannot run that way is copied unchanged. A fetched line that still cannot be resolved, or an easyconfig that does not parse, is planned on its own and listed under warnings. Any other line that cannot be resolved is a tool error and nothing is written.",
@@ -934,6 +940,7 @@ fn call_tool(name: &str, arguments: &Value) -> Result<Value, String> {
934940
"eb_stack_shadow" => stack_shadow_tool(arguments),
935941
"eb_stack_batch" => stack_batch_tool(arguments),
936942
"eb_stack_shard" => stack_shard_tool(arguments),
943+
"eb_stack_capacity" => stack_capacity_tool(arguments),
937944
"eb_stack_footprints" => stack_footprints_tool(arguments),
938945
"eb_stack_admit" => stack_admit_tool(arguments),
939946
"eb_stack_arch" => stack_arch_tool(arguments),
@@ -3279,6 +3286,31 @@ fn stack_provided_tool(arguments: &Value) -> Result<Value, String> {
32793286
}))
32803287
}
32813288

3289+
fn stack_capacity_tool(arguments: &Value) -> Result<Value, String> {
3290+
let path = required_path(arguments, "snapshot")?;
3291+
let bytes =
3292+
std::fs::read(&path).map_err(|error| format!("snapshot {}: {error}", path.display()))?;
3293+
let request = crate::planning_capacity::CapacityRequest {
3294+
partition: required_string(arguments, "partition")?,
3295+
host_target: required_string(arguments, "target")?,
3296+
features: string_array(arguments, "feature")?.into_iter().collect(),
3297+
cpus: optional_u64(arguments, "cpus")?.ok_or("cpus is required")?,
3298+
memory_mib: optional_u64(arguments, "memory_mib")?.ok_or("memory_mib is required")?,
3299+
gpus: optional_u64(arguments, "gpus")?.unwrap_or(0),
3300+
gpu_type: optional_string(arguments, "gpu_type"),
3301+
};
3302+
let evaluated_at =
3303+
crate::site_commands::generated_at(optional_string(arguments, "generated_at"), false)
3304+
.map_err(workflow_error)?;
3305+
crate::planning_capacity::inspect(
3306+
&bytes,
3307+
&request,
3308+
&evaluated_at,
3309+
optional_u64(arguments, "max_age_seconds")?.unwrap_or(300),
3310+
)
3311+
.map_err(workflow_error)
3312+
}
3313+
32823314
fn stack_shard_tool(arguments: &Value) -> Result<Value, String> {
32833315
let shards = optional_u64(arguments, "shards")?.ok_or("shards is required")?;
32843316
let request = crate::site_commands::StackShardRequest {

‎tests/planning_capacity.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ fn mixed_resources_are_subtracted_and_unavailable_states_are_excluded() {
5959
assert_eq!(result["pending"]["status"], "unknown");
6060
assert!(result["pending"]["known_demand"].is_null());
6161
assert!(!result.to_string().contains("example-gpu-private-host"));
62+
assert!(!result.to_string().contains("\"name\":"));
6263
}
6364

6465
#[test]

0 commit comments

Comments
 (0)