Scope the Slurm epilog cleanup to the job that ended - #1408
Conversation
… gate run-parts.sh only runs the *-lastuserjob-* cleanup scripts when the user has no other job on this node, but it asked squeue for RUNNING alone. A job that is SUSPENDED -- scontrol suspend, or preemption with SuspendTime -- still owns its processes, its files under /tmp and /dev/shm, and its enroot directories. So does a STOPPED job. A CONFIGURING or RESIZING job holds an allocation on the node and is about to. None of those appear under "-t running", so 40-lastuserjob-processes would killall -9 them and 42-lastuserjob-cleanup would rm -fr their files. COMPLETING is deliberately left out of the list. The epilog runs while its own job is in that state, so including it would make every job find itself, leave last_user_job at 0, and switch the cleanup off entirely. The job's own id is now filtered out as well, which is cheap and keeps a future state addition from silently doing that. Refs NVIDIA#1407 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
42-lastuserjob-cleanup walked /tmp and /dev/shm and removed everything owned by the job's user. Three ways that reached files the job did not own: The localusers.backup exemption was missing. 40-lastuserjob-processes spares those accounts so an operator keeps the login session when a job of theirs ends; deleting every file they own undoes that from the other side -- the session survives but loses the editor swap files, sockets and scratch it is holding open. The same exemption now applies here, with the same treatment of a failed lookup: grep >1 means the list could not be read, which is not evidence that the user is absent from it. find's exit status was discarded by `||:`. When the directory service is unavailable, `find -user` exits non-zero and prints nothing -- and a partial listing would still have been piped into `rm -fr`. The account is now resolved once up front and the step is skipped when it cannot be: an unresolvable owner is not evidence that nothing is owned. The listing is materialised before deletion for the same reason, since piping straight into xargs hides find's status behind xargs'. -xdev was missing, so the walk descended into anything mounted underneath -- a job_container/tmpfs private /tmp, a user's sshfs, a bind mount of shared storage -- and deleted files belonging to another node's still-running job. Refs NVIDIA#1407 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The slurm role is excluded from the molecule CI because it needs systemd services a container cannot run, so these scripts had no automated cover. What they do is destructive -- killall -9 -u, rm -fr -- and the failure mode is silent: a cleanup that reaps a bystander looks exactly like one that worked. The harness renders the Jinja templates first and runs the result. A trimmed copy would test something the cluster never runs, and the templates cannot be parsed by bash -n as they sit in the tree. Twelve cases pin the guards that decide whether to reap: the last-user-job states, a failed squeue lookup, the job's own id, the localusers exemption, an unreadable exemption list, an unresolvable owner, root, and the mount boundary. Against the pre-fix scripts seven of them fail, including the -xdev case, where the file on the submount is actually deleted. The squeue stub filters on the -t argument rather than answering the same way whatever was asked. A stub that ignored it let the pre-fix script pass the SUSPENDED case too, which proved nothing. Needs python3 with jinja2, which the CI already installs with ansible. The mount-boundary case needs root and an unprivileged account and skips itself when either is missing. Refs NVIDIA#1407 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dholt
left a comment
There was a problem hiding this comment.
The added job-state and failed-lookup guards are useful, and the process/cgroup and per-user Enroot redesigns can remain separate. Two issues in this PR's own changed paths still need correction:
42-lastuserjob-cleanup:51-63:find -xdevlimits the walk, not the laterrm -fr. A selected user-owned parent directory containing a nested mount is passed to recursiverm, which descends into that mount. A user-owned mounted root can itself be selected, and same-filesystem bind mounts are not excluded by-xdevat all. The current mount test keeps both directories root-owned, so it avoids these cases. Please enforce the mount boundary at deletion as well as enumeration. One conservative option is to skip roots with nested mounts; retaining cleanup around mounts requires explicitly excluding mountpoints and avoiding recursive deletion of selected parents.rm --one-file-systemalone does not cover every case above. Add owned-parent, owned-mount-root and same-device bind-mount regressions in an isolated environment.tests/slurm-epilog/run-tests.sh:173-212:setup_cleanupredirects the exemption file but leaves real/tmpand/dev/shmas cleanup roots. If the guard being tested regresses andaliceexists, the test can delete that account's real scratch before reporting failure. Redirect both roots into the fixture before every execution, including negative controls/master comparisons, and reject any destructive target outside it. Test safety cannot depend on the production guard passing.
The exemption test also needs a deterministic resolvable identity and assertions on actual walk/delete attempts. With the exemption deliberately bypassed and Alice absent, the contributed suite still reports 11 passed, 0 failed. With a resolvable synthetic Alice and an outer recording stub, the same mutant attempts both real cleanup roots. These were inert reproductions; no host cleanup or mounts were performed.
CI also flagged the new renderer's implicit Jinja autoescape setting. It renders shell rather than HTML, so address that with a documented, narrowly scoped scanner disposition without changing shell-rendering semantics. The lint download timeout was retried separately; it is not a source-code finding.
-xdev bounds the walk. It does not bound the `rm -fr` that follows, and
three shapes get past it:
- a user-owned directory holding a mount is still selected, and the
recursive delete descends into the mount the walk refused to enter;
- a user-owned mount root is selected in its own right, so the delete
empties the mounted filesystem;
- a bind mount from this same filesystem has the same device number, so
it is not a boundary to -xdev at all and the walk goes straight in.
Take the boundary from the kernel's mount table instead of inferring it
from device numbers. Every mount under the cleanup root is pruned from the
walk, and a selected directory that still has one beneath it is left alone
with a log line naming the mount. Its siblings are deleted as before, so
cleanup continues around a mount rather than stopping at the first one.
awk's exit status is checked the way find's already was: an empty mount
list reads as "nothing is mounted here", which would put the recursive
delete back exactly where it was. An unreadable /proc/self/mountinfo skips
the root rather than deleting across a boundary it cannot see.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Only the mount case redirected the cleanup roots. Every other case ran the script with /tmp and /dev/shm still pointed at the real directories, so a regression in the guard under test would delete a real account's scratch and only then report the failure. The fixture roots are now set up for every case, and setup refuses to hand back a script whose roots were not redirected, so a template edit cannot quietly restore the real ones. The exemption case used the name alice, which resolves on almost no host. The script exits at the "cannot resolve" guard before reaching the deletion, so the case passed with the exemption deleted -- the suite reported 11 passed, 0 failed against that mutant. It now uses the account running the tests, which resolves by construction, and asserts that a file that account owns is still there rather than only that a log line is absent. A positive control covers the other side: an unlisted, resolvable owner must actually be cleaned up, or every guard case would pass for a script that deletes nothing. Three mount-boundary cases are added: a user-owned parent holding a mount, a user-owned mount root, and a same-filesystem bind mount. All three pass on this branch and fail on the previous revision of the epilog. The scripts log with `logger` under `set -e`, so on a host with no syslog socket they died at whatever line they had reached and "the guard refused" looked identical to "the script never got there". A stub on PATH keeps the message on stderr without the exit status; four pre-existing failures in a container go away with it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The renderer relied on Jinja's default, which reads as an oversight to a scanner and to the next reader. State it, and say where it applies: the output is a shell script written to a local file, never a web response. HTML escaping would corrupt it rather than protect it, and the substituted values are the fixed fixture constants in the module. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Both findings reproduced. 1. The mount boundary now holds at deletion
The boundary is now taken from the kernel's mount table rather than inferred
One defect I introduced and caught while doing this: 2. Test isolationAs described. Only the mount case redirected the cleanup roots; every other case
The exemption case is no longer inert. It used Three mount-boundary cases are added, one per shape above. EvidenceAll runs in a privileged Ubuntu 24.04 container as root, which is what supplies
The mutant failure message names the fault rather than reporting a generic No host cleanup was performed outside the container's own fixture directories, A separate harness fault these cases exposedThe scripts log with 3. CodeQL autoescape
Two things I could not do. Dismissing the alert in the Security tab needs write |
dholt
left a comment
There was a problem hiding this comment.
The fixture-root redirection and non-vacuous exemption test are fixed, and the new mount table protects the ordinary parent/root/bind-mount shapes. Two pathname details still bypass the new deletion boundary:
42-lastuserjob-cleanup:98: shell quoting does not makefind -pathliteral. A mount namedmnt[1]or containing a backslash is treated as a glob pattern; the actual mount is not pruned, and its root/payload can reachrm -fr. Escape backslash and glob metacharacters in a separate pattern variable, retaining the decoded pathname for literal comparisons. A mount-root equality guard alone would not protect already-enumerated bind-mount descendants.42-lastuserjob-cleanup:94: command substitution removes trailing newlines after decoding mountinfo's octal escapes, changing a valid mount pathname. Useprintf -v mount_point '%b' "$field"so every decoded byte is retained.
The attached suggestions are the small corrections exercised by the independent reviewer. With actual rendered scripts, real GNU find and synthetic mount records, bracket/backslash/trailing-newline cases selected protected paths for deletion, while plain/space/tab/internal-newline controls worked. The two corrections protected the failing cases and retained sibling cleanup. A separate parent sandbox independently reproduced all three failing pathname cases; no real mounts or host cleanup were used.
Please add these pathname regressions, including */? patterns and unrelated siblings. Also check the cleanup exit status in mount_case and require positive sibling cleanup in the owned-mount-root case: its current assertion reports success even when a replacement cleanup command exits17.
The existing shell-renderer CodeQL alert is already dismissed with the documented false-positive rationale; no unverified suppression marker is needed. The deferred process/cgroup/Enroot redesign remains outside this review.
| mounts=() | ||
| prune=() | ||
| while IFS= read -r field; do | ||
| mount_point=$(printf '%b' "$field") |
There was a problem hiding this comment.
| mount_point=$(printf '%b' "$field") | |
| printf -v mount_point '%b' "$field" |
There was a problem hiding this comment.
Applied in 2fe2843. Dropping the command substitution also stops $( ) from eating trailing newlines in a mount point, which the previous form would have.
| case "$mount_point" in | ||
| "$dir"/*) | ||
| mounts+=( "$mount_point" ) | ||
| prune+=( -path "$mount_point" -prune -o ) |
There was a problem hiding this comment.
| prune+=( -path "$mount_point" -prune -o ) | |
| pattern=${mount_point//\\/\\\\} | |
| pattern=${pattern//\*/\\*} | |
| pattern=${pattern//\?/\\?} | |
| pattern=${pattern//\[/\\[} | |
| prune+=( -path "$pattern" -prune -o ) |
There was a problem hiding this comment.
Applied in 2fe2843, with the backslash pass first so the escapes added after it are not doubled again.
I checked this against real find rather than reasoning about it. With a mount point named a*b sitting next to axb and ayb:
| pattern | files left after the prune |
|---|---|
unescaped $t/a*b |
0 |
| escaped | 2 |
So the unescaped form pruned all three directories, and the epilog then skipped exactly the files it exists to remove. The escaped form prunes only the mount point.
The repository's own tests/slurm-epilog/run-tests.sh does not cover this: its mount-boundary cases skip unless run as root with mount and /proc/self/mountinfo, so they do not run on a developer machine. 11 of 12 pass here before and after the change, and the one failure is the same with and without it (macOS has no /proc/self/mountinfo).
find treats -path's argument as a glob, so a mount point containing *, ? or [ matched more than itself and pruned directories that were never mounted. Those directories were then skipped, leaving behind exactly the files the epilog is meant to remove. Checked against real find with a mount point named a*b next to axb and ayb: the unescaped pattern pruned all three and left 0 files, the escaped one pruned only a*b and left 2. Backslashes are doubled first so the escapes added afterwards are not doubled again. printf -v drops the command substitution as well, so the value no longer loses trailing newlines to $( ). Both changes are dholt's review suggestions on NVIDIA#1408.
dholt
left a comment
There was a problem hiding this comment.
Approving at 2fe2843b. Both remaining pathname findings are fixed exactly as suggested: -path patterns now escape backslash first, then *, ? and [, and printf -v keeps every decoded byte of the mount point, including trailing newlines.
Independently re-checked against the rendered template with real GNU find and synthetic mount records, without any real mounts: bracket, backslash, trailing-newline, *, ?, tab, space and internal-newline mount names are all protected, and ordinary siblings plus glob-lookalike siblings (data1 next to data[1], datax1 next to data*1) are still cleaned. The same fixture against the previous head reproduces both defects (protected payload deleted for bracket/backslash/trailing-newline; lookalike siblings wrongly skipped for */?/[). The contributed suite passes 12/12 in an unprivileged sandbox; the four root-only mount cases were not run here.
Not blocking this merge, tracked separately as a follow-up: mount_case still ignores the cleanup exit status and assert_owned_mount_root only checks payload presence, so a cleanup that exits early would still pass that case; the metacharacter pathname cases also have no regression tests yet. Happy to take that as a small test-only follow-up PR if you want to do it.
Thanks for the careful, well-documented revisions on this one.
Closes part of #1407.
What the issue asked, and what was already there
run-parts.shalready gates every*-lastuserjob-*script on the user having no other job on this node, and already refuses to treat a failedsqueuelookup as "no other jobs". The ordinary concurrent-job case the issue worries about was covered. These commits close the gaps that remained; the two scoping items that need a rewrite (40by cgroup,50by enroot path) are left for follow-ups, as the issue asks.1. Suspended and configuring jobs were invisible to the gate
The gate asked squeue for
RUNNINGalone. A job that isSUSPENDED—scontrol suspend, or preemption withSuspendTime— still owns its processes, its files under/tmpand/dev/shm, and its enroot directories. So does aSTOPPEDjob. ACONFIGURINGorRESIZINGjob holds an allocation on the node and is about to. None of them appear under-t running, so40-lastuserjob-processeswouldkillall -9them and42-lastuserjob-cleanupwouldrm -frtheir files.The state list is now
running,suspended,stopped,configuring,resizing.COMPLETINGis deliberately absent. The epilog runs while its own job is in that state, so including it would make every job find itself, leavelast_user_jobat0, and switch the cleanup off entirely. The job's own id is filtered out as well — cheap, and it keeps a future state addition from silently doing that.State names verified against
squeue --helpstateon Slurm 26.05.1.2. The file cleanup reached files the job did not own
42-lastuserjob-cleanupwalked/tmpand/dev/shmand removed everything owned by the job's user. Three ways that hit bystanders:The
localusers.backupexemption was missing.40-lastuserjob-processesspares those accounts so an operator keeps their login session when a job of theirs ends (#1404). Deleting every file they own undoes that from the other side: the session survives but loses the editor swap files, sockets and scratch it is holding open. The same exemption now applies, with the same treatment of a failed lookup —grepexiting>1means the list could not be read, which is not evidence that the user is absent from it.find's exit status was discarded by||:. When the directory service is unavailable,find -userexits non-zero and prints nothing — and a partial listing would still have been piped intorm -fr. The account is resolved once up front and the step is skipped when it cannot be: an unresolvable owner is not evidence that nothing is owned. The listing is materialised before deletion for the same reason, since piping straight intoxargshidesfind's status behindxargs'.-xdevwas missing. The walk descended into anything mounted underneath — ajob_container/tmpfsprivate/tmp, a user's sshfs, a bind mount of shared storage — and deleted files belonging to another node's still-running job.3. Tests
The
slurmrole is excluded from the molecule CI because it needs systemd services a container cannot run, so these scripts had no automated cover. What they do is destructive and the failure mode is silent: a cleanup that reaps a bystander looks exactly like one that worked.The harness renders the Jinja templates and runs the result. A trimmed copy would test something the cluster never runs, and the templates cannot even be parsed by
bash -nas they sit in the tree.Twelve cases pin the guards that decide whether to reap. Run on Rocky Linux 9.8:
The seven that fail before the change:
findran against them-xdevkeeps the walk off a submountOne note on the harness itself: the
squeuestub filters on the-targument rather than answering the same way whatever was asked. An earlier version ignored it, and the pre-fix script passed the SUSPENDED case too — the test proved nothing until the stub honoured the filter.Requires
python3withjinja2, which CI already installs alongside ansible. The mount-boundary case needs root and an unprivileged account, and skips itself when either is missing.Not in this PR
Per the issue, kept separate:
40-lastuserjob-processesscoped by cgroup.ProctrackType=proctrack/cgroupmakes the job's cgroup the authoritative scope, but the processes this script exists to catch are the ones that escaped it. Narrowing to the cgroup alone would quietly drop that purpose, so it needs a per-PID check against other jobs' cgroups anduser.slice— and real-hardware verification before it can land.50-lastuserjob-all-enroot-dirsscoped by job. The default paths are per-user, not per-job (/run/enroot/user-$(id -u),/tmp/enroot-data/user-$(id -u)). Sites that moveenroot_data_pathonto shared storage get a cross-node hazard the per-node gate cannot see. Fixing it properly means changing the defaults inconfig.example, which is a behaviour change that deserves its own review.🤖 Generated with Claude Code