feat(spurctld): add job submission validation hook (shell + lua) - #565
feat(spurctld): add job submission validation hook (shell + lua)#565yansun1996 wants to merge 6 commits into
Conversation
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #565 +/- ##
==========================================
+ Coverage 75.83% 76.16% +0.33%
==========================================
Files 166 166
Lines 64696 65691 +995
==========================================
+ Hits 49058 50031 +973
- Misses 15638 15660 +22 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
Adds a site-configurable job submission hook to spurctld (implemented as an external script) so clusters can enforce and/or mutate policy at submit-time before the job is persisted into the Raft log.
Changes:
- Wire
hooks.job_submitinto controller submission flow, applying a whitelisted set of spec edits and surfacing hook rejections to the caller. - Introduce a core hook runner and change-application helpers for the submit hook (stdin JSON contract, stdout change parsing).
- Add documentation/examples plus unit + native-host e2e coverage for accept/reject/modify behavior.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/native_host/e2e/test_job_submit_hook.py | New native-host e2e coverage for reject/modify/inert behavior through the CLI→controller path. |
| examples/spur.conf | Documents the new optional [hooks].job_submit configuration. |
| examples/hooks/job_submit.sh | Provides an example policy hook script demonstrating reject + modify patterns. |
| crates/spurctld/src/cluster.rs | Runs the submit hook during submit_job and applies hook-directed changes. |
| crates/spur-core/src/hooks.rs | Implements submit-hook execution, stdout parsing into typed changes, and spec mutation helpers. |
| crates/spur-core/src/config.rs | Adds HooksConfig.job_submit as an additive optional config field. |
Suppressed comments (2)
crates/spur-core/src/hooks.rs:257
time_limit_minutescurrently accepts negative (and extremely large) integers. A negative value will create a negativechrono::Durationlater, which can bypass partition max-time checks (tl.num_minutes() > max) and lead to nonsensical scheduling behavior. Consider validating this field as a non-negative, non-overflowing minute count and failing closed when invalid.
"reservation" => changes.reservation = Some(take_string(key, value)?),
"priority" => changes.priority = Some(take_u32(key, value)?),
"time_limit_minutes" => changes.time_limit_minutes = Some(take_i64(key, value)?),
"begin_time" => changes.begin_time = Some(take_datetime(key, value)?),
crates/spur-core/src/hooks.rs:244
- The context string "job_submit hook emitted unparseable JSON" is misleading when stdout is valid JSON but not an object (e.g., a number/array). Since
serde_json::from_strhere expects an object/map, the error should reflect the expected shape to make hook misconfigurations easier to diagnose.
fn parse_submit_changes(stdout: &str) -> anyhow::Result<SubmitHookChanges> {
let map: serde_json::Map<String, serde_json::Value> =
serde_json::from_str(stdout).context("job_submit hook emitted unparseable JSON")?;
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 10 changed files in this pull request and generated 1 comment.
Suppressed comments (2)
crates/spur-core/src/hooks.rs:346
LUA_INSTRUCTION_LIMITis set to 100,000,000 instructions. Even though this bounds infinite loops, it can still translate to multi-second CPU stalls per submission on slower hardware, andsubmit_jobruns synchronously on the async gRPC worker thread. Consider lowering the instruction budget (or additionally enforcing a wall-clock timeout) to keep a misbehaving policy from degrading controller availability.
/// Instruction budget before a lua script is interrupted (guards infinite loops).
const LUA_INSTRUCTION_LIMIT: u32 = 100_000_000;
crates/spur-core/src/hooks.rs:315
job_submitchanges allowtime_limit_minutesto be negative or extremely large. A negative value can bypass latermax_time_minuteschecks (becausetl.num_minutes()becomes negative), and a very large value risks overflow when it is converted into achrono::Duration. Since hooks are expected to be fail-closed on bad input, validatetime_limit_minutesduring parsing (both shell + Lua paths sharetake_i64).
fn take_i64(key: &str, value: &serde_json::Value) -> anyhow::Result<i64> {
// Lua arithmetic yields floats (`x / 2`), so accept a whole-valued float too.
if let Some(f) = value.as_f64() {
if value.as_i64().is_none() && f.fract() == 0.0 && f.is_finite() {
return Ok(f as i64);
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 10 changed files in this pull request and generated no new comments.
Suppressed comments (2)
crates/spur-core/src/hooks.rs:254
- On timeout the hook process is killed but never awaited/reaped. On Unix this can leave a zombie process behind until spurctld exits, and repeated timeouts could exhaust the process table. After killing the child, wait for it (best-effort, with a short timeout) before bailing.
Err(_) => {
let _ = child.kill().await;
anyhow::bail!(
"job_submit hook timed out after {SUBMIT_HOOK_TIMEOUT_SECS}s (script: {script_path})"
);
}
crates/spurctld/src/cluster.rs:408
run_submit_hook_luadoes file IO + potentially heavy CPU work (Lua execution/instruction budget) but is called directly fromsubmit_job, which is typically executed on a Tokio worker thread via the gRPC/REST handlers. Unlike the shell path, this can block the runtime and reduce controller throughput. Consider running the Lua hook insideblock_in_place(orspawn_blocking) as well, matching the shell hook’s scheduling behavior.
if let Some(script) = lua {
let ctx = self.submit_hook_ctx(spec)?;
let outcome = spur_core::hooks::run_submit_hook_lua(script, &ctx)
.map_err(|e| SubmitError::internal(format!("job_submit lua hook failed: {e}")))?;
self.apply_submit_outcome(spec, "job_submit_lua", outcome)?;
Add a site-controlled job_submit hook (Slurm job_submit.lua analog) that runs on the controller at submission. The script receives the resolved spec as JSON on stdin and can accept (exit 0), reject (non-zero exit, stderr shown to the user), or modify the job (JSON on stdout, restricted to a whitelist of policy/scheduling fields). Runs after default resolution and before the job is accepted; identity, script, and resource-count fields are not modifiable by construction.
Exercise the full spur sbatch -> gRPC -> submit hook -> CLI path that in-process tests cannot: a hook rejection's message must reach the submitting user, a modify must persist and be queryable via scontrol, and an unconfigured hook must leave submission unchanged.
Add a sandboxed Lua backend for the job_submit hook (Slurm job_submit/lua parity) alongside the shell backend. A script defines slurm_job_submit(job_desc, submit_uid), mutates job_desc in place, and returns slurm.SUCCESS to accept or non-zero to reject (message via slurm.log_user). Only whitelisted policy fields are read back, so a script cannot change identity, the job script, or resource counts. The interpreter runs sandboxed: no os/io/package/debug libraries, the filesystem and bytecode base globals (dofile/loadfile/load/loadstring/ collectgarbage) are removed, and memory and instruction ceilings guard against a runaway policy script. time_limit is exposed and accepted in minutes (Slurm convention). If both shell and Lua hooks are configured the shell runs first, then Lua, each on the evolving spec.
serde mapped a JSON null to Lua's null userdata sentinel, so a policy script comparing an unset field (e.g. `job_desc.time_limit == nil`, or `> N`) hit a runtime type error and the submission failed. Serialize with none/unit mapped to Lua nil so unset fields read naturally.
The lua backend read back only whitelisted keys, so a policy script setting an unsupported field (e.g. job_desc.nodes) was silently a no-op with no audit signal, unlike the shell path. Detect job_desc keys the script added or changed versus the input spec (which also lives in the table) and log them under the audit target, matching shell observability.
Address review feedback on the job_submit hook: - Reject a negative or out-of-range time_limit_minutes (a negative would slip past the partition max-time cap; a huge value panicked on the Duration conversion). Shared by the shell and Lua paths. - Bound the shell hook: a 30s wall-clock timeout kills a hung hook and a 1 MiB per-stream output cap keeps a chatty hook from growing controller memory; both fail closed. - Audit-log accept and reject decisions, not only modify, and treat an empty change set as accept (no misleading modified=[] line). - Require an absolute hook script path so a bare name cannot resolve via $PATH to the wrong binary. Adds unit tests for the time-limit bounds, absolute-path guard, Lua gres/begin_time modify, Lua non-integer return, and Lua partition/QOS revalidation, and makes the e2e inertness test prove a configured-but- absent hook leaves the job untouched.
2cb1e28 to
e94c609
Compare
Summary
Adds a site-controlled job-submission hook — a Spur equivalent of Slurm's
job_submitplugin interface — with two backends:hooks.job_submit): a script gets the resolved spec as JSON on stdin and decides via exit code + stdout (Spur-native).hooks.job_submit_lua): a sandboxed script definesslurm_job_submit(job_desc, submit_uid)and mutatesjob_descin place — literal parity with Slurm'sjob_submit/lua, so an existing Slurm Lua policy ports with minimal changes.Both run on the controller at submission (on the leader, before the job enters the Raft log) and can accept, reject (message shown to the user), or modify the job. If both are configured, shell runs first, then Lua, each on the evolving spec (mirroring Slurm's config-ordered plugin chain). Example policies:
examples/hooks/job_submit.sh,examples/hooks/job_submit.lua.The eight items in the request (check user/group/partition/GPUs, enforce walltime, auto-add QoS, add constraints, reject bad jobs, audit) are all expressible as site policy in either backend.
Approach
qos, partition, account, constraint, comment, reservation, priority, time_limit (minutes), begin_time, gres, hold. Changes are routed through one typed parser, so identity (user/uid/gid), the script/argv, and resource-count fields are structurally unmodifiable — a policy script cannot forge identity or alter what runs. Non-whitelisted keys are ignored + logged (shell) / never read (Lua).table/string/math/utf8/coroutine;os,io,package/require, anddebugare excluded, and the always-loaded base globals that reach the filesystem or bytecode loader (dofile,loadfile,load,loadstring,collectgarbage) are removed. A memory ceiling and an instruction-count interrupt guard against a runaway policy hanging or OOMing the controller.time_limitis exposed and accepted in minutes (Slurm convention); unset fields read as Luanil.submit_jobafter default/QoS/account resolution and validation, before the size check and Raft propose, so edits are what get persisted/scheduled.slurm_job_submit, or an exceeded resource limit all reject the submission rather than silently accepting it.audittarget with user/uid/partition/gpu context, tagged per backend (job_submit/job_submit_lua).Known limitations
gresGPU entries alongside a user's explicit--gpus, the requests can conflict; since GPU demand is resolved later, this surfaces at schedule time, not submit.jq; if absent it fails closed (all submissions rejected) — noted in the script header.Test plan
os/io/require/dofile/loadfile/load, infinite loop is interrupted,time_limitminutes + whole-valued float, unchanged fields not reported, unset field reads asnil. Lua also audits non-whitelisted fields a script set (ignored-key parity with shell).tests/native_host/e2e/test_job_submit_hook.py) for both backends: reject reaches the CLI, modify persists and is queryable viascontrol, unconfigured hook is inert.cargo clippy --workspace --exclude spur-ffi --all-targetsclean;cargo fmt --all --checkclean; targetedspur-core/spurctldsuites pass.scontrol; unknown-QoS rejected; unconfigured hook inert; shell malformed-JSON fails closed. Lua specifically:os.executeanddofilefail closed with no filesystem effect, an infinite loop is interrupted in well under a second while the controller stays responsive, and the shell→Lua chain applies both edits with distinct audit lines.Design notes
--qos). The hook is the policy authority, so it can grant a QoS the user could not request directly.time_limitencoding differs by backend. The shell hook receives the resolved spec verbatim, wheretime_limitis a[seconds, nanos]array; the Lua hook receives it as integer minutes (Slurmjob_submit.luaconvention). Both example scripts show the correct handling.time_limitpends, not rejects. A hook may raisetime_limitabove a partition's max; like an over-cap user submission, the job stays pending (the partition max-time check is a schedule-time gate), rather than being rejected at submit.Addresses SPUR-62.