blob: 582741d9fc8c544872c21209a98ca3559c05e885 [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""Basic life of a job using a hierarchical resource served by hres/lua.
A recording script writes one file per callback and job, which the test
checks for. Each callback gets its own file because hres_node_user_task_prolog
runs as the job's user and cannot write to a file another user created.
"""
import os
import re
import pytest
import atf
HRES = "lualife"
SCRIPT = "hres_lifecycle.lua"
JOB_OPTS = "-J test_154_4 -t1 -o /dev/null"
PROLOG_TIMEOUT = 5
# Keeps a mode=async job CONFIGURING long enough to cancel it there.
ASYNC_DELAY = 15
ENV_VAR = "HRES_154_4"
ENV_VALUE = "from_submit"
node = None
LUA_SCRIPT = """
local function record(config, job, name, text)
local f = io.open(config.record_dir .. "/" .. job.job_id .. "." .. name, "w")
if f then
f:write(text or "")
f:close()
end
end
local function fields(job)
return string.format("%s %d %s %s %s", job.user_name or "nil",
job.group_id, job.account or "nil",
job.partition or "nil", tostring(job.end_time > 0))
end
function hres_create_context(ctx, layers)
return slurm.SUCCESS
end
function hres_destroy_context(ctx)
return slurm.SUCCESS
end
function hres_maintenance(ctx, layers)
return slurm.SUCCESS
end
function hres_validate_args(ctx, args)
if args.mode == "bad" then
return slurm.ERROR
end
return slurm.SUCCESS
end
function hres_controller_job_prerun(ctx, job, prep)
if prep.plugin_args.mode == "fail" then
return slurm.ERROR
end
record(ctx.config, job, "controller_job_prerun")
record(ctx.config, job, "controller_job_prerun_fields", fields(job))
return slurm.SUCCESS, "token=" .. job.job_id
end
function hres_controller_job_async_prerun(ctx, job, prep)
if prep.plugin_args.mode == "async" then
os.execute("sleep " .. ctx.config.async_delay)
end
return slurm.SUCCESS
end
function hres_controller_job_revoke(ctx, job, prep)
record(ctx.config, job, "controller_job_revoke")
return slurm.SUCCESS
end
function hres_controller_job_epilog(ctx, job, prep)
record(ctx.config, job, "controller_job_epilog",
tostring(prep.async_running))
return slurm.SUCCESS
end
function hres_node_job_prolog(prep, job)
-- Only Lua instructions, so the PrologTimeout hook stops it.
if prep.plugin_args.mode == "hang" then
while true do end
end
record(prep.config, job, "node_job_prolog",
(prep.payload or "") .. " " .. (prep.plugin_args.mode or ""))
record(prep.config, job, "node_job_prolog_fields", fields(job))
return slurm.SUCCESS
end
function hres_node_job_epilog(prep, job)
record(prep.config, job, "node_job_epilog")
return slurm.SUCCESS
end
function hres_node_task_prolog(prep, job, node_tid, global_tid, env)
record(prep.config, job, "node_task_prolog")
record(prep.config, job, "node_task_prolog_env",
env[prep.config.env_var] or "nil")
record(prep.config, job, "node_task_prolog_fields", fields(job))
record(prep.config, job, "node_task_prolog_procid",
env.SLURM_PROCID or "nil")
return slurm.SUCCESS
end
function hres_node_task_epilog(prep, job, node_tid, global_tid, env)
record(prep.config, job, "node_task_epilog")
record(prep.config, job, "node_task_epilog_env",
env[prep.config.env_var] or "nil")
record(prep.config, job, "node_task_epilog_fields", fields(job))
record(prep.config, job, "node_task_epilog_procid",
env.SLURM_PROCID or "nil")
return slurm.SUCCESS
end
function hres_node_user_task_prolog(prep, job, node_tid, global_tid, env)
record(prep.config, job, "node_user_task_prolog")
record(prep.config, job, "node_user_task_prolog_env",
env[prep.config.env_var] or "nil")
record(prep.config, job, "node_user_task_prolog_fields", fields(job))
record(prep.config, job, "node_user_task_prolog_procid",
env.SLURM_PROCID or "nil")
return slurm.SUCCESS
end
return slurm.SUCCESS
"""
@pytest.fixture(scope="module", autouse=True)
def setup():
atf.require_version(
(26, 11),
"sbin/slurmd",
reason="Issue #51036: HRES plugins were added in 26.11",
)
global node
atf.require_build_config("HAVE_LUA", reason="hres/lua needs Lua")
node = atf.require_nodes(1)[0]
atf.require_config_parameter("PrologTimeout", PROLOG_TIMEOUT)
# The node job epilog must run without an Epilog script too.
atf.require_config_parameter("Epilog", None)
# disable_send_gids drops the user name, test_job_fields_and_env checks it.
atf.require_config_parameter_excludes("LaunchParameters", "disable_send_gids")
atf.require_config_file(SCRIPT, LUA_SCRIPT)
atf.require_config_file(
"resources.yaml",
f"""
- resource: {HRES}
mode: MODE_1
plugin:
type: hres/lua
config:
- name: path
value: {atf.properties['slurm-config-dir']}/{SCRIPT}
- name: record_dir
value: {atf.module_tmp_path}
- name: async_delay
value: "{ASYNC_DELAY}"
- name: env_var
value: {ENV_VAR}
layers:
- layer_name: "all"
nodes:
- "{node}"
count: 10
""",
)
atf.require_slurm_running()
def record_file(job_id, callback):
return f"{atf.module_tmp_path}/{job_id}.{callback}"
def test_lifecycle():
"""The job callbacks run, and the payload and arguments reach the node."""
job_id = atf.submit_job_sbatch(
f'{JOB_OPTS} -L "{HRES}:1[mode=fast]" --wrap "true"', fatal=True
)
atf.wait_for_job_state(job_id, "COMPLETED", fatal=True)
for callback in [
"controller_job_prerun",
"node_job_prolog",
"node_task_prolog",
"node_user_task_prolog",
"node_task_epilog",
"node_job_epilog",
"controller_job_epilog",
]:
assert atf.wait_for_file(
record_file(job_id, callback)
), f"hres_{callback} should run for the job"
atf.assert_file_contents(
record_file(job_id, "node_job_prolog"),
f"token={job_id} fast",
message="The node should get the pre-run payload and the job's arguments",
)
atf.assert_file_contents(
record_file(job_id, "controller_job_epilog"),
"false",
message="The controller epilog should see the async pre-run ended",
)
assert not os.path.exists(
record_file(job_id, "controller_job_revoke")
), "A job that started should not be revoked"
def test_job_fields_and_env():
"""Each side gets the job fields, and the task functions the environment."""
# Only the task's environment may have SLURM_PROCID.
job_id = atf.submit_job_sbatch(
f"{JOB_OPTS} --export=ALL,{ENV_VAR}={ENV_VALUE} "
f'-L "{HRES}:1[mode=fast]" --wrap "true"',
env_vars="env -u SLURM_PROCID",
fatal=True,
)
atf.wait_for_job_state(job_id, "COMPLETED", fatal=True)
for callback, procid in [
("node_task_prolog", "nil"),
("node_user_task_prolog", "0"),
("node_task_epilog", "0"),
]:
atf.assert_file_contents(
record_file(job_id, f"{callback}_env"),
ENV_VALUE,
message=f"hres_{callback} should see the job's environment",
)
atf.assert_file_contents(
record_file(job_id, f"{callback}_procid"),
procid,
message=f"hres_{callback} should get the documented environment",
)
user = re.match(r"(.+)\(\d+\)$", atf.get_job_parameter(job_id, "UserId"))[1]
gid = re.match(r".+\((\d+)\)$", atf.get_job_parameter(job_id, "GroupId"))[1]
account = atf.get_job_parameter(job_id, "Account") or "nil"
partition = atf.get_job_parameter(job_id, "Partition")
fields = f"{gid} {account} {partition} true"
for callback in [
"node_job_prolog",
"node_task_prolog",
"node_user_task_prolog",
"node_task_epilog",
]:
atf.assert_file_contents(
record_file(job_id, f"{callback}_fields"),
f"{user} {fields}",
message=f"hres_{callback} should get the job fields",
)
# The controller only has user_name if it got it without a user lookup.
controller_fields = atf.run_command_output(
f"cat {record_file(job_id, 'controller_job_prerun_fields')}", fatal=True
)
assert controller_fields in [
f"{user} {fields}",
f"nil {fields}",
], "hres_controller_job_prerun should get the job fields"
def test_validate_args_rejected():
"""Arguments the script rejects fail the submission."""
assert (
atf.submit_job_sbatch(
f'{JOB_OPTS} -L "{HRES}:1[mode=bad]" --wrap "true"',
xfail=True,
quiet=True,
)
== 0
), "Job should be rejected with arguments hres_validate_args refuses"
def test_update_args_rejected():
"""Arguments the script rejects fail a license update of a pending job."""
job_id = atf.submit_job_sbatch(
f'{JOB_OPTS} --hold -L "{HRES}:1[mode=fast]" --wrap "true"', fatal=True
)
assert (
atf.run_command_exit(
f"scontrol update jobid={job_id} Licenses='{HRES}:1[mode=bad]'",
xfail=True,
quiet=True,
)
!= 0
), "The update should be rejected with arguments hres_validate_args refuses"
assert (
atf.get_job_parameter(job_id, "Licenses") == f"{HRES}:1[mode=fast]"
), "A rejected update should keep the job's licenses"
atf.run_command(
f"scontrol update jobid={job_id} Licenses='{HRES}:1[mode=slow]'",
fatal=True,
)
assert (
atf.get_job_parameter(job_id, "Licenses") == f"{HRES}:1[mode=slow]"
), "An update with arguments the script accepts should apply"
def test_prerun_failure():
"""A failed pre-run holds the job, which never reaches a node."""
# Held, so the pre-run runs from the scheduler and not at submission,
# where a failure rejects the job instead.
job_id = atf.submit_job_sbatch(
f'{JOB_OPTS} --hold -L "{HRES}:1[mode=fail]" --wrap "true"', fatal=True
)
atf.run_command(f"scontrol release {job_id}", fatal=True)
for _ in atf.timer(fatal=True):
reason = atf.get_job_parameter(job_id, "Reason", default="", quiet=True)
if "HRES_pre-run_failed" in str(reason):
break
assert (
atf.get_job_parameter(job_id, "JobState") == "PENDING"
), "A held job should stay pending"
assert (
int(atf.get_job_parameter(job_id, "Priority")) == 0
), "A failed pre-run should hold the job"
assert not os.path.exists(
record_file(job_id, "node_job_prolog")
), "A job whose pre-run failed should not reach a node"
assert not os.path.exists(
record_file(job_id, "controller_job_revoke")
), "A failed pre-run cleans up after itself, it is not revoked"
def test_async_running_in_epilog():
"""The controller epilog of a job cancelled while CONFIGURING sees async_running."""
job_id = atf.submit_job_sbatch(
f'{JOB_OPTS} -L "{HRES}:1[mode=async]" --wrap "true"', fatal=True
)
atf.wait_for_job_state(job_id, "CONFIGURING", fatal=True)
atf.cancel_jobs([job_id], fatal=True)
atf.assert_file_contents(
record_file(job_id, "controller_job_epilog"),
"true",
message="The controller epilog should see the async pre-run still running",
)
@pytest.fixture
def resume_node():
yield
atf.run_command(
f"scontrol update nodename={node} state=resume",
user=atf.properties["slurm-user"],
quiet=True,
)
# Last, it drains the node.
def test_node_prolog_timeout(resume_node):
"""A node prolog stopped by PrologTimeout fails, drains the node and
requeues the job held."""
job_id = atf.submit_job_sbatch(
f'{JOB_OPTS} -L "{HRES}:1[mode=hang]" --wrap "true"', fatal=True
)
# The drain waits for PrologTimeout, then for the prolog failure to reach
# slurmctld, which can take longer than the default timeout.
assert atf.wait_for_node_state(
node, "DRAIN", timeout=PROLOG_TIMEOUT + 60
), "The node should be drained after its HRES prolog timed out"
assert "Prolog error" in str(
atf.get_node_parameter(node, "reason")
), "The node should be drained for its failed prolog"
assert atf.wait_for_job_state(
job_id, "PENDING", desired_reason="launch_failed_requeued_held"
), "The job should be requeued and held after its prolog failed"
assert (
int(atf.get_job_parameter(job_id, "Restarts", default=0)) == 1
), "The job should be requeued once"