blob: 93c4a5816dd1418c121ff9e9d3e18028a339ecb7 [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
import logging
import re
import pytest
import atf
OUTPUT_FILE = "./file.out"
ERROR_FILE = "./file.err"
# The spank_plugin plugin returns -ESPANK_ERROR on the targeted callback.
# See ESPANK_ERROR in slurm/slurm_errno.h.
SPANK_ERROR_RC = -3000
# Hooks (without the slurm_spank_ prefix) that spank.8 documents to be called
# before each hook in the same context. Note that task_init_privileged and
# task_post_fork "can run in parallel", and that the order of task_post_fork
# and task_init is not documented.
HOOKS_BEFORE = {
"local": {
"init": [],
"init_post_opt": ["init"],
"local_user_init": ["init", "init_post_opt"],
"exit": ["init", "init_post_opt", "local_user_init"],
},
"allocator": {
"init": [],
"init_post_opt": ["init"],
"exit": ["init", "init_post_opt"],
},
"remote": {
"init": [],
"init_post_opt": ["init"],
"user_init": ["init", "init_post_opt"],
"task_init_privileged": ["init", "init_post_opt", "user_init"],
"task_post_fork": ["init", "init_post_opt", "user_init"],
"task_init": ["init", "init_post_opt", "user_init", "task_init_privileged"],
"task_exit": [
"init",
"init_post_opt",
"user_init",
"task_init_privileged",
"task_post_fork",
"task_init",
],
"exit": [
"init",
"init_post_opt",
"user_init",
"task_init_privileged",
"task_post_fork",
"task_init",
"task_exit",
],
},
}
# The only hooks that spank.8 documents to be called in each context. Remote
# context is not restricted.
HOOKS_IN_CONTEXT = {
"local": {"init", "init_post_opt", "local_user_init", "exit"},
"allocator": {"init", "init_post_opt", "exit"},
"slurmd": {"init", "slurmd_exit"},
"job_script": {"job_prolog", "job_epilog"},
}
@pytest.fixture(scope="module", autouse=True)
def setup(spank_plugin):
"""Setup test environment once for all tests"""
global test_node
logging.info("Setting up SPANK error code test environment")
# Ensure the SPANK plugin is included in plugstack.conf. The plugin is
# driven by SPANK_FAIL_TEST_FUNC / SPANK_FAIL_TEST_CTXT env vars set on
# each test invocation, so it's loaded with no args.
atf.require_config_parameter(
"required", spank_plugin, delimiter=" ", source="plugstack"
)
# Just to speed up the test
atf.require_config_parameter_includes("SchedulerParameters", "bf_interval=1")
# TODO: Add support/parametric test cases for proctrack/pgid
atf.require_config_parameter("ProctrackType", "proctrack/cgroup")
# Needed to requeue batch jobs on slurm_spank_init failures
# TODO: Remove DeferBatch requirement once issue #50628 is fixed
atf.require_config_parameter_excludes("PrologFlags", "DeferBatch")
atf.require_config_parameter_excludes("PrologFlags", "ForceRequeueOnFail")
atf.require_config_parameter("JobRequeue", "1")
# Needed by test_scrontab
atf.require_config_parameter_includes("ScronParameters", "enable")
# Start Slurm
logging.info("Starting Slurm daemons")
atf.require_nodes(1)
atf.require_slurm_running()
test_node = next(iter(atf.nodes))
@pytest.fixture(autouse=True)
def cleanup_between_tests():
"""Reset per-test state: resume any drained node and unlink batch I/O files."""
yield
if "DRAIN" in atf.get_node_parameter(test_node, "state"):
atf.run_command(
f"scontrol update NodeName={test_node} State=RESUME Reason='test_147_3'",
fatal=True,
user="slurm",
)
atf.wait_for_node_state(test_node, "IDLE", operator=atf.SetMatch.EQUAL, fatal=True)
atf.run_command(f"rm -f {OUTPUT_FILE} {ERROR_FILE}")
def get_hook_marker(spank_tmp, function, context):
"""Return (job_id, rc) recorded by the plugin when the hook ran.
The plugin writes it to a file instead of stderr, where output of
different processes (srun, slurmstepd and its forked tasks) can interleave.
"""
marker = f"{spank_tmp}/{function}_{context}_log"
atf.wait_for_file(marker, fatal=True)
for t in atf.timer():
content = atf.run_command_output(f'cat "{marker}"', fatal=True)
match = re.search(r"job_id=(\d+) rc=(-?\d+)", content)
if match:
return int(match.group(1)), int(match.group(2))
else:
assert False, f"SPANK plugin should record job_id and rc in {marker}"
def get_hooks_run(spank_tmp):
"""Return {(function, context): [rc, ...]} for every hook that ran.
Each hook appends one line to {function}_{context}_log per run.
"""
hooks = {}
output = atf.run_command_output(
f"grep -H . {spank_tmp}/slurm_spank_*_log 2>/dev/null || true"
)
for line in output.splitlines():
match = re.search(
rf"{spank_tmp}/(slurm_spank_\w+)_(local|remote|allocator|slurmd|job_script)_log:job_id=\d+ rc=(-?\d+)",
line,
)
if match:
hook = (match.group(1), match.group(2))
hooks.setdefault(hook, []).append(int(match.group(3)))
logging.debug(f"SPANK hooks run: {hooks}")
return hooks
def assert_hooks_run(spank_tmp, function, context, job_id, not_called=()):
"""Assert the hooks that ran match what spank.8 documents.
- The hooks documented before the failing one, in its context, ran and
succeeded, except the not_called ones (without slurm_spank_ prefix) that
the command doesn't call.
- No hook ran in a context where it's documented not to be called.
What runs after the failing hook is not documented, so it's not checked.
"""
# Let every hook of the job run before checking them all
if job_id > 0:
atf.wait_for_job_state(job_id, "DONE", fatal=True)
hooks = get_hooks_run(spank_tmp)
short_function = function.replace("slurm_spank_", "")
for hook in HOOKS_BEFORE[context][short_function]:
if hook in not_called:
continue
rcs = hooks.get((f"slurm_spank_{hook}", context))
assert (
rcs
), f"slurm_spank_{hook} should run before {function} in {context} context, but it didn't"
assert all(
rc == 0 for rc in rcs
), f"slurm_spank_{hook} should return 0 in {context} context, but returned {rcs}"
for hook, ctx in hooks:
if ctx in HOOKS_IN_CONTEXT:
assert (
hook.replace("slurm_spank_", "") in HOOKS_IN_CONTEXT[ctx]
), f"{hook} should not be called in {ctx} context, but it was"
def assert_node_drained(drains):
"""Assert whether the test node was drained or not.
Call it once the job is done, so a drain can't happen after the check.
"""
node_state = atf.get_node_parameter(test_node, "state")
if drains:
assert (
"DRAIN" in node_state
), f"Test node should be drained, but is in state {node_state}"
else:
assert (
"DRAIN" not in node_state
), f"Test node should not be drained, but is in state {node_state}"
def assert_job_end_state(job_id, allowed_states):
"""Wait for the job to finish, then assert its final JobState.
We wait for the aggregate DONE state and then read the actual terminal
state, rather than polling for FAILED/CANCELLED directly. This way an
unexpected outcome is reported explicitly: in particular a job that ends in
TIMEOUT (e.g. an allocation left running until its time limit because the
client tore down uncleanly) is surfaced as such, instead of hiding behind a
generic "did not reach FAILED/CANCELLED" wait timeout.
"""
assert atf.wait_for_job_state(
job_id, "DONE", fatal=True
), f"Job {job_id} should reach a terminal state, but did not"
state = atf.get_job_parameter(job_id, "JobState", default="NOT_FOUND")
assert (
state in allowed_states
), f"Job {job_id} should end in one of {allowed_states}, but ended in {state}"
# TODO: The spank.8 Errors table documents exit code 0 and the job not failing
# for srun with slurm_spank_user_init and slurm_spank_task_post_fork in
# remote context, but srun fails as it does under salloc. Follow the docs
# once issue #51216 clarifies it.
@pytest.mark.parametrize(
"function, context, xfail, drains, fails, jobid_assigned",
[
("slurm_spank_init", "local", True, False, True, False),
("slurm_spank_init_post_opt", "local", True, False, True, False),
("slurm_spank_local_user_init", "local", True, False, True, True),
("slurm_spank_init", "remote", True, False, False, True),
("slurm_spank_user_init", "remote", True, False, True, True),
("slurm_spank_task_init_privileged", "remote", True, False, True, True),
("slurm_spank_task_post_fork", "remote", True, False, True, True),
("slurm_spank_task_init", "remote", True, False, True, True),
("slurm_spank_task_exit", "remote", False, False, False, True),
("slurm_spank_exit", "local", False, False, True, False),
],
)
def test_srun(spank_tmp, function, context, xfail, drains, fails, jobid_assigned):
"""Validate srun behavior when SPANK callbacks fail."""
logging.info("Testing srun command")
logging.debug(f"Function {function}, xfail: {xfail}")
result = atf.run_command(
command=f"srun -K -I10 -W10 -w {test_node} -t1 true",
env_vars=f"SPANK_HOOK_CREATE_FILE=1 SPANK_FAIL_TEST_FUNC={function} SPANK_FAIL_TEST_CTXT={context}",
)
if xfail:
assert (
result["exit_code"] != 0
), f"srun should fail, but exited with {result['exit_code']}"
else:
assert (
result["exit_code"] == 0
), f"srun should succeed, but exited with {result['exit_code']}"
# Extract JobID and verify the SPANK plugin failed the targeted callback
job_id, plugin_rc = get_hook_marker(spank_tmp, function, context)
logging.debug(f"SPANK plugin failed {function} ({context}) of job {job_id}")
assert (
plugin_rc == SPANK_ERROR_RC
), f"SPANK plugin should return {SPANK_ERROR_RC}, but returned {plugin_rc}"
if jobid_assigned:
assert int(job_id) > 0, "JobID should be assigned"
if fails:
assert_job_end_state(int(job_id), ("FAILED", "CANCELLED"))
else:
assert int(job_id) == 0, "JobID should NOT be assigned"
assert_hooks_run(spank_tmp, function, context, int(job_id))
assert_node_drained(drains)
@pytest.mark.parametrize(
"function, context, xfail, drains, fails, jobid_assigned",
[
("slurm_spank_init", "allocator", True, False, True, False),
("slurm_spank_init_post_opt", "allocator", True, False, True, False),
("slurm_spank_init", "local", True, False, True, False),
("slurm_spank_init_post_opt", "local", True, False, True, False),
("slurm_spank_local_user_init", "local", True, False, True, True),
("slurm_spank_init", "remote", True, False, False, True),
("slurm_spank_user_init", "remote", True, False, True, True),
("slurm_spank_task_init_privileged", "remote", True, False, True, True),
("slurm_spank_task_post_fork", "remote", True, False, True, True),
("slurm_spank_task_init", "remote", True, False, True, True),
("slurm_spank_task_exit", "remote", False, False, False, True),
("slurm_spank_exit", "local", False, False, True, False),
("slurm_spank_exit", "allocator", False, False, True, False),
],
)
def test_salloc(spank_tmp, function, context, xfail, drains, fails, jobid_assigned):
"""Validate salloc+srun behavior when SPANK callbacks fail."""
logging.info("Testing salloc command")
logging.debug(f"Function {function}, xfail: {xfail}")
result = atf.run_command(
command=f"salloc -K -I10 -w {test_node} -t1 srun -K -I10 -W10 -t1 true",
env_vars=f"SPANK_HOOK_CREATE_FILE=1 SPANK_FAIL_TEST_FUNC={function} SPANK_FAIL_TEST_CTXT={context}",
)
if xfail:
assert (
result["exit_code"] != 0
), f"salloc should fail, but exited with {result['exit_code']}"
else:
assert (
result["exit_code"] == 0
), f"salloc should succeed, but exited with {result['exit_code']}"
# Extract JobID and verify the SPANK plugin failed the targeted callback
job_id, plugin_rc = get_hook_marker(spank_tmp, function, context)
logging.debug(f"SPANK plugin failed {function} ({context}) of job {job_id}")
assert (
plugin_rc == SPANK_ERROR_RC
), f"SPANK plugin should return {SPANK_ERROR_RC}, but returned {plugin_rc}"
if jobid_assigned:
assert int(job_id) > 0, "JobID should be assigned"
if fails:
assert_job_end_state(int(job_id), ("FAILED", "CANCELLED"))
assert_hooks_run(spank_tmp, function, context, int(job_id))
assert_node_drained(drains)
@pytest.mark.parametrize(
"function, context, xfail, drains, fails, jobid_assigned, outfile",
[
("slurm_spank_init", "allocator", True, False, True, False, False),
("slurm_spank_init_post_opt", "allocator", True, False, True, False, False),
("slurm_spank_init", "local", True, False, True, True, False),
("slurm_spank_init_post_opt", "local", True, False, True, True, False),
("slurm_spank_local_user_init", "local", True, False, True, True, False),
("slurm_spank_user_init", "remote", True, True, True, True, False),
("slurm_spank_task_init_privileged", "remote", True, False, True, True, False),
("slurm_spank_task_post_fork", "remote", True, True, True, True, False),
("slurm_spank_task_init", "remote", True, False, True, True, False),
("slurm_spank_task_exit", "remote", False, False, False, True, True),
("slurm_spank_exit", "local", False, False, False, True, True),
("slurm_spank_exit", "allocator", False, False, False, True, True),
],
)
def test_sbatch(
spank_tmp, function, context, xfail, drains, fails, jobid_assigned, outfile
):
"""Validate sbatch behavior when SPANK callbacks fail."""
logging.info("Testing sbatch command")
logging.debug(f"Function {function}, in context: {context}, xfail: {xfail}")
result = atf.run_command(
command=f"sbatch --no-requeue -W -w {test_node} -t1 -o {OUTPUT_FILE} -e {ERROR_FILE} --wrap=\"srun echo 'IT_RAN'\"",
env_vars=f"SPANK_HOOK_CREATE_FILE=1 SPANK_FAIL_TEST_FUNC={function} SPANK_FAIL_TEST_CTXT={context}",
)
if xfail:
assert (
result["exit_code"] != 0
), f"sbatch should fail, but exited with {result['exit_code']}"
else:
assert (
result["exit_code"] == 0
), f"sbatch should succeed, but exited with {result['exit_code']}"
# Extract JobID
job_id = 0
output = result["stdout"]
pattern = r"Submitted batch job (\d+)"
match = re.search(pattern, output)
if match:
job_id = match.group(1)
logging.debug(f"Submitted batch job - Job ID: {job_id}")
if jobid_assigned:
assert int(job_id) > 0, "JobID should be returned"
else:
assert int(job_id) == 0, "JobID should NOT be returned"
# Verify the SPANK plugin failed the targeted callback
_, plugin_rc = get_hook_marker(spank_tmp, function, context)
assert (
plugin_rc == SPANK_ERROR_RC
), f"SPANK plugin should return {SPANK_ERROR_RC}, but returned {plugin_rc}"
if jobid_assigned and fails:
assert_job_end_state(int(job_id), ("FAILED", "CANCELLED"))
elif jobid_assigned and not fails:
assert_job_end_state(int(job_id), ("COMPLETED",))
if outfile:
atf.wait_for_file(OUTPUT_FILE, fatal=True)
for t in atf.timer():
content = atf.run_command_output(f'cat "{OUTPUT_FILE}"', fatal=True)
if "IT_RAN" in content:
break
else:
assert (
False
), f"Output file {OUTPUT_FILE} should contain 'IT_RAN', but got: {content}"
else:
# The targeted callback failed before the wrapped command could run, so
# the job must not have produced stdout. The file may be absent (job
# never started) or present but empty, either way IT_RAN must not appear.
# The job has already reached a terminal state above, so this is stable.
content = atf.run_command_output(f'cat "{OUTPUT_FILE}" 2>/dev/null || true')
assert (
"IT_RAN" not in content
), f"Output file {OUTPUT_FILE} should not contain 'IT_RAN', but got: {content}"
assert_hooks_run(spank_tmp, function, context, int(job_id))
assert_node_drained(drains)
def test_sbatch_init_remote(spank_tmp):
"""Validate sbatch behavior when slurm_spank_init fails in remote context.
With the default ESPANK_NODE_FAILURE mode the node is drained and the job
requeued instead of failed. So, unlike test_sbatch, this can't use
--no-requeue, that would fail the job, nor -W, that would wait forever for
the requeued job. The exit code is not checked: the job never terminates.
TODO: Check the exit code of the spank.8 Errors table once issue #51216
clarifies what it means for sbatch.
"""
function = "slurm_spank_init"
context = "remote"
job_id = atf.submit_job_sbatch(
f"-w {test_node} -t1 -o {OUTPUT_FILE} -e {ERROR_FILE} --wrap=\"srun echo 'IT_RAN'\"",
env_vars=f"SPANK_HOOK_CREATE_FILE=1 SPANK_FAIL_TEST_FUNC={function} SPANK_FAIL_TEST_CTXT={context}",
fatal=True,
)
# Verify the SPANK plugin failed the targeted callback of the job
marker_job_id, plugin_rc = get_hook_marker(spank_tmp, function, context)
assert (
marker_job_id == job_id
), f"SPANK plugin should fail {function} of job {job_id}, but it was job {marker_job_id}"
assert (
plugin_rc == SPANK_ERROR_RC
), f"SPANK plugin should return {SPANK_ERROR_RC}, but returned {plugin_rc}"
assert atf.repeat_until(
lambda: atf.get_node_parameter(test_node, "state"),
lambda state: "DRAIN" in state,
), f"Test node should be drained, but is in state {atf.get_node_parameter(test_node, 'state')}"
# The job should be requeued, not failed
assert atf.wait_for_job_state(
job_id, "PENDING"
), f"Job {job_id} should be requeued as PENDING, but is {atf.get_job_parameter(job_id, 'JobState')}"
assert atf.repeat_until(
lambda: int(atf.get_job_parameter(job_id, "Restarts", default=0)),
lambda restarts: restarts >= 1,
), f"Job {job_id} should be requeued, but Restarts={atf.get_job_parameter(job_id, 'Restarts')}"
# The targeted callback failed before the wrapped command could run
content = atf.run_command_output(f'cat "{OUTPUT_FILE}" 2>/dev/null || true')
assert (
"IT_RAN" not in content
), f"Output file {OUTPUT_FILE} should not contain 'IT_RAN', but got: {content}"
# The requeued job would never run on the drained node, cancel it before
# checking the hooks run, and before resuming the node on cleanup
atf.cancel_jobs([job_id], fatal=True)
assert_hooks_run(spank_tmp, function, context, job_id)
@pytest.fixture
def clean_crontab():
"""Start and end without crontab. Removing it also cancels its jobs."""
atf.run_command("scrontab -r", quiet=True)
yield
atf.run_command("scrontab -r", quiet=True)
@pytest.mark.parametrize(
"function, context, xfail, drains, fails",
[
("slurm_spank_init", "allocator", True, False, False),
("slurm_spank_exit", "allocator", False, False, False),
],
)
def test_scrontab(spank_tmp, clean_crontab, function, context, xfail, drains, fails):
"""Validate scrontab behavior when SPANK callbacks fail.
The cron job is scheduled once a year, so it stays PENDING and never runs.
"""
logging.info("Testing scrontab command")
logging.debug(f"Function {function}, xfail: {xfail}")
job_name = "test_147_3_scrontab"
result = atf.run_command(
"scrontab -",
input=f"#SCRON --job-name={job_name}\n0 0 1 1 * true\n",
env_vars=f"SPANK_HOOK_CREATE_FILE=1 SPANK_FAIL_TEST_FUNC={function} SPANK_FAIL_TEST_CTXT={context}",
)
if xfail:
assert (
result["exit_code"] != 0
), f"scrontab should fail, but exited with {result['exit_code']}"
else:
assert (
result["exit_code"] == 0
), f"scrontab should succeed, but exited with {result['exit_code']}"
# Verify the SPANK plugin failed the targeted callback
_, plugin_rc = get_hook_marker(spank_tmp, function, context)
assert (
plugin_rc == SPANK_ERROR_RC
), f"SPANK plugin should return {SPANK_ERROR_RC}, but returned {plugin_rc}"
# A cron job, if any was submitted, should not fail
job_states = atf.run_command_output(
f"squeue --me --noheader --format=%T --name={job_name}", fatal=True
).split()
logging.debug(f"Cron job states: {job_states}")
if not fails:
assert (
"FAILED" not in job_states
), f"Cron job should not fail, but its state is {job_states}"
# The cron job never runs, so there's no job to wait for.
# spank.8 is ambiguous about init_post_opt in scrontab: it's documented for
# allocator context, but the Errors table only lists init and exit for
# scrontab, and scrontab doesn't call it.
# TODO: Remove not_called once issue #51216 is fixed
assert_hooks_run(spank_tmp, function, context, 0, not_called=("init_post_opt",))
assert_node_drained(drains)