blob: 077344eec1732ff2b81f8714612ff0bf22e2b75a [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""Verify the exit code jobcomp/filetxt records for a completed job.
Unlike the client commands, jobcomp writes to a file a site is likely to
parse by machine. test_108_13 already pins the decode itself, so cover only
what is specific to this sink: the signal half of ExitCode,
DerivedExitCode, and SlurmRC.
"""
import re
import pytest
import atf
# Raised over atf's 45s default: the record is written during job
# completion, which on a loaded runner trails the DONE state by the whole
# epilog.
DONE_WAIT_SECS = 120
RECORD_WAIT_SECS = 60
@pytest.fixture(scope="module", autouse=True)
def setup():
atf.require_config_parameter("JobCompType", "jobcomp/filetxt")
atf.require_config_parameter("JobCompLoc", f"{atf.module_tmp_path}/jobcomp.log")
# jobcomp writes from the controller, and a stepmgr job's step exit codes
# never reach the controller's derived_ec, so DerivedExitCode would record
# 0:0 there no matter what the steps did.
atf.require_config_parameter_excludes("SlurmctldParameters", "enable_stepmgr")
atf.require_nodes(1, [("CPUs", 1)])
atf.require_slurm_running()
def _jobcomp_log():
"""Path for JobCompLoc. slurmctld reads it and its working directory is
not the test's, so this needs an absolute path rather than a relative
one in the test's cwd."""
return f"{atf.module_tmp_path}/jobcomp.log"
def _jobcomp_record(job_id):
"""The jobcomp/filetxt line for job_id, or None if it never appears."""
# The log holds one line per completed job and the write is not
# synchronous with the job reaching DONE, so poll for this job's own
# record. Neither call is fatal: the file does not exist until some job
# completes, and an exhausted poll is the caller's assertion to make.
for _ in atf.timer(timeout=RECORD_WAIT_SECS, poll_interval=1, quiet=True):
result = atf.run_command(f"cat {_jobcomp_log()}", quiet=True)
for line in result["stdout"].splitlines():
if f"JobId={job_id} " in line:
return line
return None
def _recorded_exit_code(record, field):
"""<status>:<signal> as jobcomp/filetxt renders the named field."""
# \b keeps ExitCode from also matching inside DerivedExitCode.
match = re.search(rf"\b{field}=(\d+:\d+)", record)
assert match, f"no {field} in the jobcomp record: {record!r}"
return match.group(1)
def test_jobcomp_filetxt_exit_code_rendering():
"""jobcomp/filetxt records ExitCode with the same <status>:<signal>
split the client commands render.
Only the signal case is exercised: it is the half that proves the
helper picked the right branch, and a <status>:0 job would also pass
under a naive implementation. test_108_13 covers the status half
across scontrol, squeue and sacct.
"""
job_id = atf.submit_job_sbatch(
"-N1 --time=5:00 --job-name=test_172_1 --wrap 'kill -KILL $$'",
fatal=True,
)
atf.wait_for_job_state(job_id, "DONE", timeout=DONE_WAIT_SECS, fatal=True)
record = _jobcomp_record(job_id)
assert record, f"no jobcomp record was written for job {job_id}"
assert (
_recorded_exit_code(record, "ExitCode") == "0:9"
), f"jobcomp ExitCode: {record!r}"
def test_jobcomp_filetxt_derived_exit_code_rendering():
"""DerivedExitCode goes through the same decode, and carries the step's
code rather than the script's."""
job_id = atf.submit_job_sbatch(
"-N1 --time=5:00 --job-name=test_172_1_derived "
"--wrap 'srun -n1 /bin/sh -c \"exit 123\"; exit 0'",
fatal=True,
)
atf.wait_for_job_state(job_id, "DONE", timeout=DONE_WAIT_SECS, fatal=True)
record = _jobcomp_record(job_id)
assert record, f"no jobcomp record was written for job {job_id}"
assert (
_recorded_exit_code(record, "ExitCode") == "0:0"
), f"jobcomp ExitCode: {record!r}"
assert (
_recorded_exit_code(record, "DerivedExitCode") == "123:0"
), f"jobcomp DerivedExitCode: {record!r}"
# A working directory the stepd cannot open the default output file in, so
# Slurm fails the job before its script runs. See test_101_14 for why it
# sits under /dev/null.
UNUSABLE_WORK_DIR = "/dev/null/slurm_test_workdir"
@pytest.mark.skipif(
atf.get_version("sbin/slurmctld") < (26, 11)
or atf.get_version("bin/scontrol") < (26, 11),
reason="Ticket 24195: SlurmRC was added in 26.11",
)
def test_jobcomp_filetxt_slurm_rc():
"""jobcomp/filetxt records the SlurmRC scontrol reports for a job Slurm
failed."""
job_id = atf.submit_job_sbatch(
f"-N1 --time=5:00 --no-requeue --chdir={UNUSABLE_WORK_DIR} " "--wrap 'exit 0'",
fatal=True,
)
atf.wait_for_job_state(job_id, "DONE", timeout=DONE_WAIT_SECS, fatal=True)
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}"
rc = match.group(1)
assert rc != "0", f"A job Slurm failed should have a SlurmRC: {details}"
record = _jobcomp_record(job_id)
assert record, f"no jobcomp record was written for job {job_id}"
assert re.search(
rf"\bSlurmRC={rc}\b", record
), f"jobcomp should record the job's SlurmRC, {rc}: {record!r}"