blob: 802d823f688962f17ce56e8f96a5063069069e3c [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""Test the switch/lua hooks, jobinfo round trip and return codes."""
import os
import pathlib
import pytest
import atf
NODE_COUNT = 2
# Shorter than the program a "background" setup starts.
SWITCH_TIMEOUT = 10
SHARED_DIR = None
SPECIAL_STEPS_FILE = None
ENV_TOKEN = "SWITCHLUA_TOKEN"
ENV_STEP_JOB_ID = "SWITCHLUA_STEP_JOB_ID"
TOKEN_PREFIX = "jobinfo-"
DIRECTIVE_PREFIX = "switchlua:"
# A --network value that has the job set up on the default path.
MANAGED = "managed"
# The "switchlua:" token in --network selects each case's behavior.
switch_lua = f"""
-- Record a call of a slurmctld hook for the job.
local function record(hook, job)
local f = io.open("@SHARED_DIR@/hooks." .. job.job_id, "a")
if f ~= nil then
f:write(hook .. "\\n")
f:close()
end
end
local function directive(network)
if network == nil then
return ""
end
return string.match(network, "{DIRECTIVE_PREFIX}([%w_]+)") or ""
end
function slurm_switch_job_start(job)
local mode = directive(job.network)
record("slurm_switch_job_start", job)
if mode == "decline" then
return slurm.SUCCESS, nil
end
if mode == "empty" then
return slurm.SUCCESS, {{}}
end
-- Write to stdout and stderr, directly and from another program.
if mode == "output" then
io.write("io.write\\n")
io.stderr:write("io.stderr:write\\n")
os.execute("echo os.execute; echo os.execute >&2")
end
-- Start a program longer than switch_timeout and return while it runs.
if mode == "background" then
os.execute("sleep {3 * SWITCH_TIMEOUT} &")
end
return slurm.SUCCESS, {{
token = "{TOKEN_PREFIX}" .. job.job_id,
mode = mode,
count = 42,
flag = true,
nested = {{ inner = "x" }},
}}
end
-- Describe a jobinfo: nil, or its token and whether its other values survived.
local function describe(jobinfo)
if jobinfo == nil then
return "nil"
end
return string.format(
"token=%s,count=%s,flag=%s,inner=%s",
tostring(jobinfo.token),
tostring(jobinfo.count == 42),
tostring(jobinfo.flag == true),
tostring(type(jobinfo.nested) == "table" and jobinfo.nested.inner == "x")
)
end
function slurm_switch_job_complete(job, jobinfo)
local f = io.open("@SHARED_DIR@/jobinfo." .. job.job_id, "a")
if f ~= nil then
f:write(describe(jobinfo) .. "\\n")
f:close()
end
record("slurm_switch_job_complete", job)
return slurm.SUCCESS
end
function slurm_switch_step_preinit(step, stepinfo)
if stepinfo == nil then
return slurm.SUCCESS
end
if stepinfo.mode == "preinit_fail" then
return -1
end
if stepinfo.mode == "preinit_crash" then
error("deliberate error from slurm_switch_step_preinit")
end
return slurm.SUCCESS
end
function slurm_switch_step_init(step, stepinfo)
local env = {{ {ENV_STEP_JOB_ID} = tostring(step.job_id) }}
if stepinfo ~= nil then
env.{ENV_TOKEN} = tostring(stepinfo.token)
end
return slurm.SUCCESS, env
end
function slurm_switch_step_postfini(step, stepinfo)
if stepinfo ~= nil and stepinfo.mode == "postfini_drain" then
return -1
end
return slurm.SUCCESS
end
-- Record each call made by slurmctld (slurmstepd calls it too).
function slurm_switch_setup_special_steps()
local f = io.open("/proc/self/cmdline", "r")
local cmdline = ""
if f ~= nil then
cmdline = f:read("*a")
f:close()
end
if string.find(cmdline, "slurmctld", 1, true) then
f = io.open("@SPECIAL_STEPS_FILE@", "a")
if f ~= nil then
f:write("x\\n")
f:close()
end
end
return false
end
return slurm.SUCCESS
"""
# Setup
@pytest.fixture(scope="module", autouse=True)
def setup():
global SHARED_DIR, SPECIAL_STEPS_FILE
SHARED_DIR = f"{atf.module_tmp_path}/shared"
SPECIAL_STEPS_FILE = f"{SHARED_DIR}/special_steps"
atf.require_version(
(26, 11), "sbin/slurmd", reason="Issue 51019: switch/lua was added in 26.11"
)
atf.require_version(
(26, 11), "bin/srun", reason="Issue 51019: switch/lua was added in 26.11"
)
atf.require_build_config(
"HAVE_LUA",
reason="Issue 51019: Slurm was built without lua, so switch/lua is absent",
)
atf.require_nodes(NODE_COUNT)
atf.require_config_parameter("SwitchType", "switch/lua")
atf.require_config_parameter_includes(
"SlurmctldParameters", ("switch_timeout", SWITCH_TIMEOUT)
)
atf.require_config_parameter_includes(
"SchedulerParameters", ("batch_sched_delay", 0)
)
atf.require_config_file(
"switch.lua",
switch_lua.replace("@SPECIAL_STEPS_FILE@", SPECIAL_STEPS_FILE).replace(
"@SHARED_DIR@", SHARED_DIR
),
)
os.makedirs(SHARED_DIR)
os.chmod(SHARED_DIR, 0o777)
pathlib.Path(SPECIAL_STEPS_FILE).touch()
os.chmod(SPECIAL_STEPS_FILE, 0o666)
atf.require_slurm_running()
@pytest.fixture(scope="function")
def resume_drained_nodes():
"""Resume any node drained by a test."""
yield
for node, node_dict in atf.get_nodes(quiet=True).items():
if {"DOWN", "DRAIN", "FAIL"} & set(node_dict["state"]):
atf.run_command(
f"scontrol update nodename={node} state=resume",
user="root",
quiet=True,
)
def hooks_called(job_id):
"""Return the slurmctld hooks called for the job, one per call."""
return atf.run_command_output(
f"cat {SHARED_DIR}/hooks.{job_id}", quiet=True
).split()
def teardown_jobinfo(job_id):
"""Return how each slurm_switch_job_complete() call described its jobinfo."""
return atf.run_command_output(
f"cat {SHARED_DIR}/jobinfo.{job_id}", quiet=True
).split()
def test_jobinfo_reaches_the_step_environment():
"""Test that jobinfo from job_start reaches step_init."""
output = "token.out"
job_id = atf.submit_job_sbatch(
f'-N1 --network={MANAGED} --output={output} --wrap "srun printenv {ENV_TOKEN}"',
fatal=True,
)
assert atf.wait_for_job_state(
job_id, "COMPLETED", fatal=True
), f"job {job_id} did not complete"
atf.assert_file_contents(
output,
f"{TOKEN_PREFIX}{job_id}",
message=(
f"{ENV_TOKEN} did not carry the job id slurm_switch_job_start()"
" put in the jobinfo dictionary, so the dictionary did not reach"
" slurm_switch_step_init()"
),
)
assert "slurm_switch_job_start" in hooks_called(
job_id
), f"slurm_switch_job_start() was not recorded for job {job_id}"
def test_empty_jobinfo_still_manages_the_job():
"""Test that a job whose job_start returned {} still gets step functions."""
output = "empty.out"
job_id = atf.submit_job_sbatch(
f"-N1 --network={DIRECTIVE_PREFIX}empty --output={output}"
f' --wrap "srun printenv {ENV_STEP_JOB_ID}"',
fatal=True,
)
assert atf.wait_for_job_state(
job_id, "COMPLETED", fatal=True
), f"job {job_id} did not complete"
atf.assert_file_contents(
output,
str(job_id),
message=(
f"The srun step of job {job_id} did not get {ENV_STEP_JOB_ID}, so"
" slurm_switch_step_init() did not run for a job whose"
" slurm_switch_job_start() returned an empty dictionary"
),
)
@pytest.mark.parametrize(
"network, expected",
[
pytest.param(
MANAGED,
"token={token},count=true,flag=true,inner=true",
id="with_values",
),
pytest.param(
f"{DIRECTIVE_PREFIX}empty",
"token=nil,count=false,flag=false,inner=false",
id="empty",
),
],
)
def test_jobinfo_reaches_job_complete(network, expected):
"""Test that jobinfo from job_start reaches job_complete unchanged."""
job_id = atf.submit_job_sbatch(f'-N1 --network={network} --wrap "true"', fatal=True)
expected = expected.format(token=f"{TOKEN_PREFIX}{job_id}")
assert atf.wait_for_job_state(
job_id, "COMPLETED", fatal=True
), f"job {job_id} did not complete"
# The nodes are not held for the teardown, so the job can complete first.
for _ in atf.timer():
if teardown_jobinfo(job_id):
break
else:
pytest.fail(f"slurm_switch_job_complete() never ran for job {job_id}")
assert teardown_jobinfo(job_id) == [expected], (
f"slurm_switch_job_complete() got jobinfo {teardown_jobinfo(job_id)}"
f" for job {job_id}, so the dictionary slurm_switch_job_start() returned"
" did not reach it unchanged"
)
def test_step_record_is_visible_to_step_init():
"""Test that step_init sees the step record."""
output = "step.out"
job_id = atf.submit_job_sbatch(
f"-N1 --network={MANAGED} --output={output}"
f' --wrap "srun printenv {ENV_STEP_JOB_ID}"',
fatal=True,
)
assert atf.wait_for_job_state(
job_id, "COMPLETED", fatal=True
), f"job {job_id} did not complete"
atf.assert_file_contents(
output,
str(job_id),
message=(
f"{ENV_STEP_JOB_ID} did not match the job id, so step.job_id was"
" not readable from slurm_switch_step_init()"
),
)
def test_batch_step_does_not_run_step_init():
"""Test that step_init does not run for the batch step by default."""
output = "batch.out"
job_id = atf.submit_job_sbatch(
f"-N1 --network={MANAGED} --output={output}"
f" --wrap 'printenv {ENV_TOKEN} {ENV_STEP_JOB_ID}; echo rc=$?'",
fatal=True,
)
assert atf.wait_for_job_state(
job_id, "COMPLETED", fatal=True
), f"job {job_id} did not complete"
atf.assert_file_contents(
output,
"rc=1",
message=(
f"The batch step of job {job_id} got {ENV_TOKEN} or"
f" {ENV_STEP_JOB_ID}, so slurm_switch_step_init() ran for it although"
" slurm_switch_setup_special_steps() returned false"
),
)
def test_declined_job_gets_no_stepinfo():
"""Test that a nil jobinfo from job_start leaves the job unmanaged."""
result = atf.run_job(
f"-N1 --network=switchlua:decline sh -c 'printenv {ENV_TOKEN} {ENV_STEP_JOB_ID}; echo rc=$?'",
fatal=False,
)
assert result["exit_code"] == 0, (
"A job slurm_switch_job_start() declined did not run"
f" (stderr {result['stderr']!r})"
)
assert TOKEN_PREFIX not in result["stdout"], (
f"{ENV_TOKEN} was set for a job slurm_switch_job_start() declined"
f" (stdout {result['stdout']!r})"
)
# printenv prints only the variables that are set.
assert result["stdout"].strip() == "rc=1", (
f"{ENV_TOKEN} or {ENV_STEP_JOB_ID} was set, so slurm_switch_step_init()"
f" ran for a job slurm_switch_job_start() declined (stdout {result['stdout']!r})"
)
def test_job_without_network_is_not_set_up():
"""Test that a job without --network runs its steps with no setup."""
output = "unmanaged.out"
job_id = atf.submit_job_sbatch(
f"-N1 --output={output}"
f" --wrap \"srun sh -c 'printenv {ENV_STEP_JOB_ID} {ENV_TOKEN}; true'\"",
fatal=True,
)
assert atf.wait_for_job_state(
job_id, "COMPLETED", fatal=True
), f"job {job_id} did not complete"
# printenv prints only the variables that are set.
atf.assert_file_contents(
output,
"",
message=(
f"The srun step of job {job_id} got {ENV_STEP_JOB_ID} or"
f" {ENV_TOKEN}, so slurm_switch_step_init() ran for a job that was"
" not set up"
),
)
# A teardown starts as the job ends, so a short wait suffices.
for t in atf.timer(timeout=2, xfail=True):
if hooks_called(job_id):
pytest.fail(
f"slurmctld called {hooks_called(job_id)} for job {job_id} without --network"
)
@pytest.mark.parametrize(
"mode, what",
[
pytest.param("output", "wrote to stdout and stderr", id="output"),
pytest.param("background", "left a program running", id="background"),
],
)
def test_job_start_output_and_programs_do_not_fail_the_setup(mode, what):
"""Test that job_start can write output and return while a program runs."""
output = f"{mode}.out"
job_id = atf.submit_job_sbatch(
f"-N1 --no-requeue --network={DIRECTIVE_PREFIX}{mode} --output={output}"
f' --wrap "srun printenv {ENV_TOKEN}"',
fatal=True,
)
# A failed or timed out setup cancels a job that cannot be requeued.
assert atf.wait_for_job_state(job_id, "COMPLETED"), (
f"Job {job_id} did not complete after slurm_switch_job_start() {what}:"
f" {atf.get_job_parameter(job_id, 'JobState')}"
f" ({atf.get_job_parameter(job_id, 'Reason')})"
)
atf.assert_file_contents(
output,
f"{TOKEN_PREFIX}{job_id}",
message=(
f"{ENV_TOKEN} did not carry the jobinfo of job {job_id}, whose"
f" slurm_switch_job_start() {what}"
),
)
def test_preinit_failure_fails_the_step():
"""Test that a negative return from preinit fails the step."""
result = atf.run_job("-N1 --network=switchlua:preinit_fail true", fatal=False)
assert (
result["exit_code"] != 0
), "The step succeeded despite slurm_switch_step_preinit() returning -1"
# ESLURM_INTERCONNECT_FAILURE
assert (
"interconnect" in result["stderr"].lower()
), f"Expected an interconnect failure from srun, got {result['stderr']!r}"
def test_script_error_does_not_fail_the_step():
"""Test that a Lua error in preinit does not fail the step."""
result = atf.run_job("-N1 --network=switchlua:preinit_crash true", fatal=False)
assert result["exit_code"] == 0, (
"An error raised inside slurm_switch_step_preinit() failed the step"
f" (stderr {result['stderr']!r})"
)
def test_postfini_failure_drains_the_node(resume_drained_nodes):
"""Test that a negative return from postfini drains the node."""
nodes = atf.run_job_nodes("-N1 --network=switchlua:postfini_drain true", fatal=True)
assert nodes, "Could not determine which node the step ran on"
node = nodes[0]
assert atf.wait_for_node_state(
node, "DRAIN", fatal=True
), f"{node} was not drained after slurm_switch_step_postfini() returned -1"
reason = atf.get_node_parameter(node, "reason") or ""
assert (
"switch_g_job_postfini failed" in reason
), f"{node} was drained with an unexpected reason: {reason!r}"
def special_steps_asked():
"""How many times slurmctld has called slurm_switch_setup_special_steps()."""
output = atf.run_command_output(f"cat {SPECIAL_STEPS_FILE}", fatal=True, quiet=True)
return len(output.split())
def test_special_steps_is_not_asked_for_each_launch():
"""Test that slurmctld calls setup_special_steps once, not per launch."""
asked = special_steps_asked()
for _ in range(2):
job_id = atf.submit_job_sbatch('-N1 --wrap "true"', fatal=True)
assert atf.wait_for_job_state(
job_id, "COMPLETED", fatal=True
), f"job {job_id} did not complete"
after = special_steps_asked()
assert after > 0, (
f"{SPECIAL_STEPS_FILE} records no call from slurmctld, not even when it"
" loaded the plugin, so this test cannot see whether it asks the script"
)
assert after == asked, (
f"slurmctld called slurm_switch_setup_special_steps() {asked} times"
f" before two batch jobs and {after} after, so it asks the script on"
" every launch"
)