blob: 40285325e0f282895f6461bc6531bd6bb26fff17 [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""Test that holding a job does not cut its node prolog short.
With PrologFlags=Alloc slurmctld must keep reporting the job as not ready
until every node prolog completes, whatever else happens to the job in the
meantime. `scontrol wait_job` asks exactly that question, so it stands in for
the srun step launch that the prolog is meant to gate.
The hold itself must also survive the prolog, so that a user hold is still a
user hold, and still releasable by its owner, after a later requeue.
Ticket: 25645
"""
import glob
import pytest
import atf
pytestmark = pytest.mark.slow
# How long to let `scontrol wait_job` block before calling it "still waiting".
# atf.run_command() reports a timed-out command as exit code 110.
WAIT_JOB_TIMEOUT = 20
COMMAND_TIMED_OUT = 110
# Every prolog runs on every allocated node, so the job spans more than one to
# exercise the per-node tracking rather than a single-bit special case.
NODE_COUNT = 2
@pytest.fixture(scope="module", autouse=True)
def setup():
atf.require_version(
(26, 11),
component="sbin/slurmctld",
reason="Ticket 25645: prolog tracking fixed in 26.11",
)
atf.require_config_parameter_includes("PrologFlags", "Alloc")
# scontrol wait_job returns success immediately unless both power save
# timeouts are set, so it cannot report on the prolog without them. No
# SuspendTime is set, so nothing is actually powered down.
atf.require_config_parameter("SuspendTimeout", 30)
atf.require_config_parameter("ResumeTimeout", 60)
# One prolog and one set of markers serve every test, keyed by job id. The
# completion marker is per node, by SLURMD_NODENAME rather than hostname,
# which is shared when the nodes are multiple slurmd on one host.
atf.make_bash_script(
prolog_script(),
f"""touch {marker("started")}
for _ in $(seq 1 240); do
[ -f {marker("proceed")} ] && break
sleep 0.5
done
touch {marker("finished")}.$SLURMD_NODENAME
""",
)
atf.set_config_parameter("Prolog", prolog_script())
atf.require_nodes(NODE_COUNT)
atf.require_slurm_running()
def prolog_script():
"""Path of the blocking prolog, stable for the whole module."""
return f"{atf.module_tmp_path}/prolog.sh"
def marker(name, job_id="$SLURM_JOB_ID"):
"""Path of a per-job handshake file between the test and the prolog.
Absolute because slurmd runs the prolog, so a path relative to the test's
own directory would resolve against the daemon's cwd instead. The default
expands inside the prolog script itself; callers pass the job id.
"""
return f"{atf.module_tmp_path}/{name}.{job_id}"
def get_reason(job_id):
return atf.get_job_parameter(job_id, "Reason", fatal=True)
def assert_job_not_ready(job_id):
"""Fails if slurmctld calls the job ready while its prolog still runs."""
# quiet=True: this call is meant to time out, and the timeout is logged as
# a WARNING that would otherwise look like a problem in a passing run.
result = atf.run_command(
f"scontrol wait_job {job_id}", timeout=WAIT_JOB_TIMEOUT, quiet=True
)
assert (
result["exit_code"] == COMMAND_TIMED_OUT
), "Job was reported ready before the node prolog finished"
def finish_prolog(job_id):
"""Unblocks the prolog and waits for it to finish on every node.
The wait is on the prolog's own markers rather than on slurmctld, because
a controller that wrongly believes the prolog is done would let this
return early and leave the caller testing the wrong thing.
"""
atf.run_command(f"touch {marker('proceed', job_id)}", fatal=True)
pattern = f"{marker('finished', job_id)}.*"
for t in atf.timer(fatal=True):
if len(glob.glob(pattern)) == NODE_COUNT:
break
result = atf.run_command(f"scontrol wait_job {job_id}")
assert result["exit_code"] == 0, "Job never became ready after the prolog"
@pytest.fixture(scope="function")
def prolog_job():
"""Submits a job whose node prolog blocks until it is unblocked.
Yields the job id with the job sitting at Reason=Prolog. Teardown always
unblocks the prolog so a failing test cannot leave it spinning.
"""
job_id = atf.submit_job_sbatch(
f"-J test_132_2 -N{NODE_COUNT} -t 5 --requeue -o /dev/null "
"--wrap 'sleep infinity'",
fatal=True,
)
assert atf.wait_for_file(marker("started", job_id)), "Prolog did not start"
assert get_reason(job_id) == "Prolog", "Job should be waiting on its prolog"
yield job_id
# Not fatal: this runs even when the test already failed, and a teardown
# failure here would obscure the real one.
atf.run_command(f"touch {marker('proceed', job_id)}")
def test_prolog_gates_job_ready(prolog_job):
"""A job whose node prolog is running is not ready. Baseline for the rest."""
assert_job_not_ready(prolog_job)
finish_prolog(prolog_job)
@pytest.mark.parametrize(
"hold_command, held_reason",
[("hold", "JobHeldAdmin"), ("uhold", "JobHeldUser")],
)
def test_hold_during_prolog_keeps_job_unready(prolog_job, hold_command, held_reason):
"""Holding a job must not make slurmctld call it ready early."""
atf.run_command(f"scontrol {hold_command} {prolog_job}", user="root", fatal=True)
# scontrol update is synchronous, so the reason is deterministic here. The
# hold is reported at once; the prolog is tracked separately.
assert (
get_reason(prolog_job) == held_reason
), f"Hold placed during the prolog should report {held_reason}"
assert_job_not_ready(prolog_job)
finish_prolog(prolog_job)
def test_release_during_prolog_keeps_job_unready(prolog_job):
"""Releasing a hold mid-prolog must not make the job ready early."""
atf.run_command(f"scontrol uhold {prolog_job}", user="root", fatal=True)
atf.run_command(f"scontrol release {prolog_job}", fatal=True)
assert_job_not_ready(prolog_job)
finish_prolog(prolog_job)
@pytest.mark.parametrize(
"hold_command, held_reason",
[("hold", "JobHeldAdmin"), ("uhold", "JobHeldUser")],
)
def test_hold_survives_prolog_and_requeue(prolog_job, hold_command, held_reason):
"""A hold placed during the prolog keeps its type across a requeue."""
atf.run_command(f"scontrol {hold_command} {prolog_job}", user="root", fatal=True)
finish_prolog(prolog_job)
assert (
get_reason(prolog_job) == held_reason
), f"{hold_command} was not preserved across prolog completion"
atf.run_command(f"scontrol requeue {prolog_job}", fatal=True)
# Poll on the reason too: a freshly requeued job transits Cleaning before
# the scheduler re-derives the hold reason, so a bare PENDING wait would
# race and could read either Cleaning or the pre-requeue value.
atf.wait_for_job_state(
prolog_job, "PENDING", desired_reason=held_reason, fatal=True
)
def test_hold_during_prolog_survives_restart(prolog_job):
"""A hold placed mid-prolog survives a slurmctld restart with the prolog."""
atf.run_command(f"scontrol uhold {prolog_job}", user="root", fatal=True)
atf.restart_slurmctld()
assert (
get_reason(prolog_job) == "JobHeldUser"
), "User hold was lost across a slurmctld restart"
assert_job_not_ready(prolog_job)
finish_prolog(prolog_job)
def test_owner_cannot_release_admin_hold(prolog_job):
"""An admin hold placed during the prolog is not releasable by the owner."""
atf.run_command(f"scontrol hold {prolog_job}", user="root", fatal=True)
finish_prolog(prolog_job)
# Only this case needs an unprivileged test user, since only it asserts
# that a release is DENIED. Note that slurmctld also permits a release by
# AdminLevel>=Operator, which is_super_user() does not cover.
if atf.is_super_user():
pytest.skip(
"Ticket 25645: a denied release needs an unprivileged SlurmTestUser"
)
result = atf.run_command(f"scontrol release {prolog_job}", xfail=True)
assert result["exit_code"] != 0, "Owner was able to release an admin hold"
assert (
"Access/permission denied" in result["stderr"]
), f"Expected a permission denial, got: {result['stderr']}"