blob: 3615350db0250303fe846245b99a33d93bad3d42 [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""The Epilog environment reports a job's exit status and Slurm return code.
An Epilog receives SLURM_JOB_EXIT_CODE (the raw wait(2) status),
SLURM_JOB_EXIT_CODE2 (the same value as <status>:<signal>) and SLURM_JOB_RC
(the Slurm error code, or 0). A job Slurm failed must arrive with a wait(2)
status of 1:0 and the error code only in SLURM_JOB_RC.
Both Epilog and EpilogSlurmctld are covered: the slurmd Epilog is fed from
the kill job RPC and EpilogSlurmctld from job_common_env_vars(), so one is
not evidence for the other.
Each hook writes its environment to a file the test reads back, since they
run as SlurmdUser and SlurmUser rather than as the test user.
"""
import pathlib
import re
import pytest
import atf
# The two hooks that receive the variable, per prolog_epilog.shtml.
EPILOG_PARAMETERS = ["Epilog", "EpilogSlurmctld"]
# A working directory the stepd cannot open a job's output file in, which is
# how these tests make Slurm fail a job before its script ever runs. Two
# properties are load-bearing:
# - The job must use the DEFAULT output file. expand_stdio_fields() joins
# this directory onto slurm-%j.out when the step record is built, so the
# stepd opens an absolute path under it. An explicit --output would
# disarm the primitive and the job would simply run.
# - The directory must be one the stepd cannot CREATE, not merely one that
# is absent: on ENOENT it calls mkdirpath() and retries the open, so a
# missing directory under a writable parent is created and the job runs.
# Nesting it under /dev/null gives ENOTDIR instead, on every node and
# whichever user the job runs as.
UNUSABLE_WORK_DIR = "/dev/null/slurm_test_workdir"
@pytest.fixture(scope="module", autouse=True)
def setup(epilog_env_dir):
# Both hooks are configured BEFORE the daemons start. Setting them
# afterwards only triggers `scontrol reconfigure`, which the controller
# acknowledges before the slurmds have re-read their config - so the
# first job could finish before slurmd knew it had an Epilog to run.
for parameter in EPILOG_PARAMETERS:
script = f"{epilog_env_dir}/{parameter}.sh"
atf.make_bash_script(
script,
f'echo "rc=${{SLURM_JOB_RC-UNSET}}'
f" ec=${{SLURM_JOB_EXIT_CODE-UNSET}}"
f' ec2=${{SLURM_JOB_EXIT_CODE2-UNSET}}"'
f" > {epilog_env_dir}/{parameter}_$SLURM_JOB_ID\n",
)
atf.set_config_parameter(parameter, script)
atf.require_slurm_running()
yield
# ATF restores the configuration at module teardown, so this is only to
# stop the daemons running these scripts any earlier than that.
for parameter in EPILOG_PARAMETERS:
atf.set_config_parameter(parameter, None)
@pytest.fixture(scope="module")
def epilog_env_dir():
"""Directory the hooks drop one file per job and parameter into.
Tests already run inside an isolated working directory, so this is just a
subdirectory of it. The Epilog runs as SlurmdUser and EpilogSlurmctld as
SlurmUser, so the directory has to be writable by another account, and
the absolute path is what gets baked into the scripts.
"""
path = pathlib.Path("epilog_env")
path.mkdir(exist_ok=True)
path.chmod(0o777)
yield str(path.absolute())
# SLURM_JOB_RC is exported by slurmd for the Epilog and by slurmctld for
# EpilogSlurmctld. slurmctld is never older than slurmd under Slurm's upgrade
# order, so gating on slurmd covers both.
requires_job_rc = pytest.mark.skipif(
atf.get_version("sbin/slurmd") < (26, 11),
reason="Ticket 24195: SLURM_JOB_RC was added in 26.11",
)
@requires_job_rc
@pytest.mark.parametrize("parameter", EPILOG_PARAMETERS)
@pytest.mark.parametrize(
"wrap, expected_env",
[
# SLURM_JOB_EXIT_CODE is the raw wait(2) status, so exit 3 is 3 << 8.
("exit 0", "rc=0 ec=0 ec2=0:0"),
("exit 3", "rc=0 ec=768 ec2=3:0"),
],
)
def test_epilog_sees_rc_zero_for_a_job_that_ended_on_its_own(
epilog_env_dir, parameter, wrap, expected_env
):
"""Verify SLURM_JOB_RC is 0 when Slurm did not fail the job"""
job_id = atf.submit_job_sbatch(f"-N1 --no-requeue --wrap '{wrap}'", fatal=True)
atf.wait_for_job_state(job_id, "DONE", fatal=True)
atf.assert_file_contents(
f"{epilog_env_dir}/{parameter}_{job_id}",
expected_env,
message=(
f"A job running '{wrap}' ended on its own, so {parameter} should "
f"see '{expected_env}'. A non-zero rc means the exit status leaked "
"into the return code."
),
)
@requires_job_rc
@pytest.mark.skipif(
atf.get_version("bin/scontrol") < (26, 11),
reason="Ticket 24195: scontrol reports SlurmRC since 26.11",
)
@pytest.mark.parametrize("parameter", EPILOG_PARAMETERS)
def test_epilog_sees_the_error_code_only_in_rc(epilog_env_dir, parameter):
"""Verify a Slurm failure reaches SLURM_JOB_RC and not the exit code
The job's working directory cannot be used, so the stepd fails while
opening its output file and the script never runs.
"""
job_id = atf.submit_job_sbatch(
f"-N1 --no-requeue --chdir={UNUSABLE_WORK_DIR} --wrap 'exit 0'", fatal=True
)
atf.wait_for_job_state(job_id, "DONE", fatal=True)
env_file = f"{epilog_env_dir}/{parameter}_{job_id}"
# 256 is W_EXITCODE(1, 0): a genuine wait(2) status meaning "exited 1".
# A four-digit value here would be the Slurm error code leaking into the
# exit code.
atf.assert_file_contents(
env_file,
"ec=256 ec2=1:0",
contains=True,
message=(
f"A job Slurm failed should reach {parameter} with a real wait(2) "
"status of 1:0, not a value derived from the error code"
),
)
details = atf.run_command_output(
f"scontrol show job --details {job_id}", fatal=True
)
match = re.search(r"\bSlurmRC=(\d+)", details)
assert match, f"scontrol --details should print SlurmRC: {details}"
job_rc = match.group(1)
assert job_rc != "0", f"A job Slurm failed should have a SlurmRC: {details}"
contents = atf.run_command_output(f"cat {env_file}", fatal=True).strip()
assert contents.split()[0] == f"rc={job_rc}", (
f"{parameter} should see the job's SlurmRC, {job_rc}, in SLURM_JOB_RC; "
f"it saw {contents}"
)