Skip to content

feat(spurctld): add job submission validation hook (shell + lua) - #565

Open
yansun1996 wants to merge 6 commits into
ROCm:mainfrom
yansun1996:feat/job-submit-hook
Open

feat(spurctld): add job submission validation hook (shell + lua)#565
yansun1996 wants to merge 6 commits into
ROCm:mainfrom
yansun1996:feat/job-submit-hook

Conversation

@yansun1996

@yansun1996 yansun1996 commented Aug 3, 2026

Copy link
Copy Markdown
Member

Summary

Adds a site-controlled job-submission hook — a Spur equivalent of Slurm's job_submit plugin interface — with two backends:

  • Shell (hooks.job_submit): a script gets the resolved spec as JSON on stdin and decides via exit code + stdout (Spur-native).
  • Lua (hooks.job_submit_lua): a sandboxed script defines slurm_job_submit(job_desc, submit_uid) and mutates job_desc in place — literal parity with Slurm's job_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

  • Whitelisted modify. Both backends may only change policy/scheduling fields: 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).
  • Lua sandbox. The interpreter runs with only table/string/math/utf8/coroutine; os, io, package/require, and debug are 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_limit is exposed and accepted in minutes (Slurm convention); unset fields read as Lua nil.
  • Hook point. Runs in submit_job after default/QoS/account resolution and validation, before the size check and Raft propose, so edits are what get persisted/scheduled.
  • Fail-closed. A shell hook emitting unparseable JSON, a wrong-typed field, a Lua syntax/runtime error, a missing slurm_job_submit, or an exceeded resource limit all reject the submission rather than silently accepting it.
  • Post-modify re-validation. A hook-changed partition/account is re-checked against ACLs; a hook-set QoS is trusted (not re-authorized) but still existence-checked so an unknown QoS can't silently resolve to the limitless default.
  • Audit logging. Each decision (accept / reject / modified fields) is logged under a stable audit target with user/uid/partition/gpu context, tagged per backend (job_submit / job_submit_lua).
  • Upgrade-safe. The new config fields are additive/optional and not part of any persisted Raft/WAL type; proto is unchanged. Lua is vendored (Lua 5.4 built from source) so there is no system-Lua build dependency.

Known limitations

  • If a hook sets gres GPU entries alongside a user's explicit --gpus, the requests can conflict; since GPU demand is resolved later, this surfaces at schedule time, not submit.
  • The shell example requires jq; if absent it fails closed (all submissions rejected) — noted in the script header.

Test plan

  • Unit tests for both backends: accept / reject (message surfaced) / modify / non-whitelisted-field-ignored / every whitelisted field / fail-closed (malformed shell JSON, wrong type, Lua syntax/runtime error, missing entry point). Lua adds: sandbox denies os/io/require/dofile/loadfile/load, infinite loop is interrupted, time_limit minutes + whole-valued float, unchanged fields not reported, unset field reads as nil. Lua also audits non-whitelisted fields a script set (ignored-key parity with shell).
  • Controller wiring tests: shell and Lua reject/modify; modify to invalid partition and unknown QoS both rejected; shell→Lua chain; shell reject short-circuits Lua; Lua overrides a shell-set field.
  • Native-host e2e (tests/native_host/e2e/test_job_submit_hook.py) for both backends: reject reaches the CLI, modify persists and is queryable via scontrol, unconfigured hook is inert.
  • cargo clippy --workspace --exclude spur-ffi --all-targets clean; cargo fmt --all --check clean; targeted spur-core/spurctld suites pass.
  • Validated end-to-end on an isolated deployment (both backends): reject surfaces the script's message; auto-QoS / priority / comment / walltime-cap modifies persist and show in scontrol; unknown-QoS rejected; unconfigured hook inert; shell malformed-JSON fails closed. Lua specifically: os.execute and dofile fail 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

  • Hook-set QoS is trusted policy. A QoS set by the hook is checked for existence but not re-run through the per-user allow-list ACL (unlike a user-supplied --qos). The hook is the policy authority, so it can grant a QoS the user could not request directly.
  • time_limit encoding differs by backend. The shell hook receives the resolved spec verbatim, where time_limit is a [seconds, nanos] array; the Lua hook receives it as integer minutes (Slurm job_submit.lua convention). Both example scripts show the correct handling.
  • Over-cap time_limit pends, not rejects. A hook may raise time_limit above 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.

@codecov-commenter

codecov-commenter commented Aug 3, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.28643% with 27 lines in your changes missing coverage. Please review.

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:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@yansun1996
yansun1996 marked this pull request as ready for review August 3, 2026 23:41
Copilot AI review requested due to automatic review settings August 3, 2026 23:41

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_submit into 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_minutes currently accepts negative (and extremely large) integers. A negative value will create a negative chrono::Duration later, 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_str here 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.

Comment thread crates/spur-core/src/hooks.rs
Comment thread crates/spurctld/src/cluster.rs
@yansun1996 yansun1996 changed the title feat(spurctld): add job submission validation hook feat(spurctld): add job submission validation hook (shell + lua) Aug 4, 2026
@yansun1996
yansun1996 requested a review from Copilot August 4, 2026 01:16

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_LIMIT is 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, and submit_job runs 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_submit changes allow time_limit_minutes to be negative or extremely large. A negative value can bypass later max_time_minutes checks (because tl.num_minutes() becomes negative), and a very large value risks overflow when it is converted into a chrono::Duration. Since hooks are expected to be fail-closed on bad input, validate time_limit_minutes during parsing (both shell + Lua paths share take_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);

Comment thread crates/spur-core/src/hooks.rs

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_lua does file IO + potentially heavy CPU work (Lua execution/instruction budget) but is called directly from submit_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 inside block_in_place (or spawn_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.
@yansun1996
yansun1996 force-pushed the feat/job-submit-hook branch from 2cb1e28 to e94c609 Compare August 5, 2026 18:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants