blob: e45c3b2d935b4adb8315e336fe469c36cb7c33e3 [file]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
import time
from pathlib import Path
import pytest
import atf
pytestmark = pytest.mark.slow
suspend_timeout = 100
resume_timeout = 100
suspend_time = -1
slurmd_bin = atf.properties["slurm-sbin-dir"] + "/slurmd"
script_preamble = """
SCRIPT_DIR="$( cd -- "$( dirname -- "${{BASH_SOURCE[0]:-$0}}"; )" &> /dev/null && pwd 2> /dev/null; )";
exec &> >(tee -a $SCRIPT_DIR/{script_name}.log)
PS4='+ $(date "+%y-%m-%dT%H:%M:%S") ($SLURM_NODE_NAME)\011 '
echo $@
set -x
"""
def resume_ctld_script(path):
content = f"""
SLURM_NODE_NAME=$1
{script_preamble.format(script_name=Path(path).stem)}
sleep 2 # wait for slurmctld to update node state
for node in $({atf.properties["slurm-bin-dir"]}/scontrol show hostname $SLURM_NODE_NAME); do
sudo {slurmd_bin} -N $node -b
done
"""
atf.make_bash_script(path, content)
return path
def resume_slurmd_script(path):
"""This one won't work; you can't power up a node from slurmd"""
content = f"""
SLURM_NODE_NAME=$1
{script_preamble.format(script_name=Path(path).stem)}
# This one won't work; you can't power up a node from slurmd
sudo {slurmd_bin} -N $SLURM_NODE_NAME -b
"""
atf.make_bash_script(path, content)
return path
def suspend_ctld_script(path):
pidfile_template = atf.properties["slurm-run-dir"] + "/slurmd.$node.pid"
content = f"""
SLURM_NODE_NAME=$1
{script_preamble.format(script_name=Path(path).stem)}
for node in $({atf.properties["slurm-bin-dir"]}/scontrol show hostname $SLURM_NODE_NAME); do
sudo pkill -F {pidfile_template}
done
"""
atf.make_bash_script(path, content)
return path
def suspend_slurmd_script(path):
pidfile_template = atf.properties["slurm-run-dir"] + "/slurmd.$SLURM_NODE_NAME.pid"
content = f"""
SLURM_NODE_NAME=$1
{script_preamble.format(script_name=Path(path).stem)}
sudo pkill -F {pidfile_template}
"""
atf.make_bash_script(path, content)
return path
def reboot_ctld_script(path):
pidfile_template = atf.properties["slurm-run-dir"] + "/slurmd.$node.pid"
content = f"""
SLURM_NODE_NAME=$1
{script_preamble.format(script_name=Path(path).stem)}
for node in $({atf.properties["slurm-bin-dir"]}/scontrol show hostnames $SLURM_NODE_NAME); do
sudo pkill -F {pidfile_template}
done
sleep 5
for node in $({atf.properties["slurm-bin-dir"]}/scontrol show hostname $SLURM_NODE_NAME); do
sudo {slurmd_bin} -N $node -b
done
"""
atf.make_bash_script(path, content)
return path
def reboot_slurmd_script(path):
pidfile_template = atf.properties["slurm-run-dir"] + "/slurmd.$SLURM_NODE_NAME.pid"
content = f"""
{script_preamble.format(script_name=Path(path).stem)}
sudo pkill -F {pidfile_template}
sleep 5
sudo {slurmd_bin} -N $SLURM_NODE_NAME -b
"""
atf.make_bash_script(path, content)
return path
@pytest.fixture(scope="module", autouse=True)
def setup():
atf.require_version(
(26, 5),
component="bin/scontrol",
reason="Issue 50669: PowerAction option added in 26.05",
)
atf.get_run_dir_path()
atf.require_config_parameter("ReturnToService", 2)
atf.require_config_parameter_includes("DebugFlags", "POWER")
atf.require_nodes(2)
atf.require_config_parameter("SuspendTime", suspend_time)
atf.require_config_parameter("SuspendTimeout", suspend_timeout)
atf.require_config_parameter("ResumeTimeout", resume_timeout)
atf.require_config_parameter(
"ResumeProgram", resume_ctld_script(f"{atf.module_tmp_path}/def_resume.sh")
)
atf.require_config_parameter(
"SuspendProgram", suspend_ctld_script(f"{atf.module_tmp_path}/def_suspend.sh")
)
atf.require_config_parameter(
"RebootProgram", reboot_slurmd_script(f"{atf.module_tmp_path}/def_reboot.sh")
)
atf.require_config_parameter(
"PowerAction",
{
"resume-ctld": {
"Location": "slurmctld",
"Program": resume_ctld_script(f"{atf.module_tmp_path}/resume_ctld.sh"),
},
"suspend-ctld": {
"Location": "slurmctld",
"Program": suspend_ctld_script(
f"{atf.module_tmp_path}/suspend_ctld.sh"
),
},
"reboot-ctld": {
"Location": "slurmctld",
"Program": reboot_ctld_script(f"{atf.module_tmp_path}/reboot_ctld.sh"),
},
"resume-slurmd": { # this one won't work; you can't power up a node from slurmd
"Location": "slurmd",
"Program": resume_slurmd_script(
f"{atf.module_tmp_path}/resume_slurmd.sh"
),
},
"suspend-slurmd": {
"Location": "slurmd",
"Program": suspend_slurmd_script(
f"{atf.module_tmp_path}/suspend_slurmd.sh"
),
},
"reboot-slurmd": {
"Location": "slurmd",
"Program": reboot_slurmd_script(
f"{atf.module_tmp_path}/reboot_slurmd.sh"
),
},
},
)
# idle_on_node_suspend clears DRAIN on every node that powers down, which
# would hide whether a power down kept a DRAIN that it did not set
atf.require_config_parameter_excludes("SlurmctldParameters", "idle_on_node_suspend")
atf.require_slurm_running()
yield
atf.run_command(f"rm -f {atf.module_tmp_path}/*.sh")
@pytest.fixture(scope="function")
def current_nodes():
nodes = atf.get_nodes(quiet=True)
for node in nodes:
assert "IDLE" in atf.get_node_parameter(node, "state"), f"{node} must be IDLE"
assert "POWERED_DOWN" not in atf.get_node_parameter(
node, "state"
), f"{node} must start powered up"
return list(nodes.keys())
@pytest.fixture(scope="function", autouse=True)
def reset_nodes(current_nodes):
yield
atf.cancel_all_jobs()
atf.restart_slurmctld(clean=True)
for node in current_nodes:
atf.start_slurmd(node, quiet=True)
time.sleep(2)
for node in current_nodes:
atf.wait_for_node_state(node, "IDLE", fatal=True)
atf.run_command(f"rm -f {atf.module_tmp_path}/*.log")
@pytest.fixture(scope="function")
def held_node(current_nodes):
"""Hold a node with a job, so a power down asap stays pending on it."""
node = current_nodes[0]
job_id = atf.submit_job_sbatch(
f"-J power_down_asap -t 5 -o /dev/null -w {node} --wrap 'sleep infinity'",
fatal=True,
)
atf.wait_for_job_state(job_id, "RUNNING", fatal=True)
return node, job_id
def test_reboot_action_invalid(current_nodes):
nodelist = atf.node_list_to_range(current_nodes)
res = atf.run_command(
f"scontrol reboot {nodelist} action=invalid", xfail=True, user="slurm"
)
assert res["exit_code"] == 1, "Expected exit code 1"
assert "Invalid power action" in res["stderr"], "expected error message"
def test_power_down_action_invalid(current_nodes):
nodelist = atf.node_list_to_range(current_nodes)
res = atf.run_command(
f"scontrol power down {nodelist} action=invalid", xfail=True, user="slurm"
)
assert res["exit_code"] == 1, "Expected exit code 1"
assert "Invalid power action" in res["stderr"], "expected error message"
def test_reboot_force(current_nodes):
nodelist = atf.node_list_to_range(current_nodes)
job_id = atf.submit_job_sbatch(
f"-N {len(current_nodes)} --wrap 'srun sleep 100'", fatal=True
)
for node in current_nodes:
atf.wait_for_node_state(node, "ALLOCATED", timeout=5, fatal=True)
atf.wait_for_job_state(job_id, "RUNNING", timeout=5, fatal=True)
atf.run_command(f"scontrol reboot {nodelist} force", fatal=True, user="slurm")
atf.wait_for_job_state(job_id, "PENDING", timeout=5, fatal=True)
for node in current_nodes:
atf.wait_for_node_state(
node, ["REBOOT_REQUESTED", "REBOOT_ISSUED"], timeout=10, fatal=True
)
for node in current_nodes:
atf.wait_for_node_state(node, "REBOOT_ISSUED", fatal=True)
for node in current_nodes:
atf.wait_for_node_state(node, "IDLE", fatal=True)
log_path = Path(f"{atf.module_tmp_path}/def_reboot.log")
assert log_path.exists(), "Log file must exist"
for node in current_nodes:
assert f"{node}" in log_path.read_text(), f"Log file must contain {node}"
def test_reboot_action_slurmctld(current_nodes):
nodelist = atf.node_list_to_range(current_nodes)
atf.run_command(
f"scontrol reboot {nodelist} action=reboot-ctld",
fatal=True,
user="slurm",
)
for node in current_nodes:
atf.wait_for_node_state(
node, ["REBOOT_REQUESTED", "REBOOT_ISSUED"], timeout=10, fatal=True
)
for node in current_nodes:
atf.wait_for_node_state(node, "REBOOT_ISSUED", fatal=True)
for node in current_nodes:
atf.wait_for_node_state(node, "IDLE", fatal=True)
log_path = Path(f"{atf.module_tmp_path}/reboot_ctld.log")
assert log_path.exists(), "Log file must exist"
assert f"{nodelist}" in log_path.read_text(), "Log file must contain the nodelist"
def test_reboot_asap(current_nodes):
nodelist = atf.node_list_to_range(current_nodes)
atf.run_command(
f"scontrol reboot {nodelist} asap",
fatal=True,
user="slurm",
)
for node in current_nodes:
atf.wait_for_node_state(node, "DRAIN", fatal=True)
job_id = atf.submit_job_sbatch(
f"-N {len(current_nodes)} --wrap 'srun sleep 100'", fatal=True
)
atf.wait_for_job_state(job_id, "PENDING", timeout=5, fatal=True)
for node in current_nodes:
atf.wait_for_node_state(
node, ["REBOOT_REQUESTED", "REBOOT_ISSUED"], timeout=10, fatal=True
)
for node in current_nodes:
atf.wait_for_node_state(node, "REBOOT_ISSUED", timeout=20, fatal=True)
for node in current_nodes:
atf.wait_for_node_state(node, ["IDLE", "ALLOCATED"], timeout=20, fatal=True)
def test_reboot_action_slurmd(current_nodes):
nodelist = atf.node_list_to_range(current_nodes)
atf.run_command(
f"scontrol reboot {nodelist} action=reboot-slurmd",
fatal=True,
user="slurm",
)
for node in current_nodes:
atf.wait_for_node_state(node, "REBOOT_REQUESTED", fatal=True)
for node in current_nodes:
atf.wait_for_node_state(node, "REBOOT_ISSUED", fatal=True)
for node in current_nodes:
atf.wait_for_node_state(node, "IDLE", fatal=True)
log_path = Path(f"{atf.module_tmp_path}/reboot_slurmd.log")
assert log_path.exists(), "Log file must exist"
for node in current_nodes:
assert f"{node}" in log_path.read_text(), f"Log file must contain {node}"
def test_power_down_up_action_slurmctld(current_nodes):
nodelist = atf.node_list_to_range(current_nodes)
atf.run_command(
f"scontrol power down {nodelist} action=suspend-ctld", fatal=True, user="slurm"
)
for node in current_nodes:
atf.wait_for_node_state(node, "POWERING_DOWN", fatal=True)
atf.run_command(
f"scontrol update nodename={nodelist} state=RESUME", fatal=True, user="slurm"
)
for node in current_nodes:
atf.wait_for_node_state(
node, "POWERED_DOWN", timeout=suspend_timeout + 5, fatal=True
)
for node in current_nodes:
atf.wait_for_node_state(node, "IDLE", fatal=True)
suspend_log_path = Path(f"{atf.module_tmp_path}/suspend_ctld.log")
assert suspend_log_path.exists(), "Log file must exist"
assert (
f"{nodelist}" in suspend_log_path.read_text()
), "Log file must contain the nodelist"
# powered down, ready for power up
atf.run_command(
f"scontrol power up {nodelist} action=resume-ctld", fatal=True, user="slurm"
)
for node in current_nodes:
atf.wait_for_node_state(node, "POWERING_UP", fatal=True)
for node in current_nodes:
atf.wait_for_node_state(
node, "POWERING_UP", fatal=True, reverse=True, timeout=resume_timeout + 5
)
resume_log_path = Path(f"{atf.module_tmp_path}/resume_ctld.log")
assert resume_log_path.exists(), "Log file must exist"
assert (
f"{nodelist}" in resume_log_path.read_text()
), "Log file must contain the nodelist"
def test_power_down_up_action_slurmd(current_nodes):
nodelist = atf.node_list_to_range(current_nodes)
atf.run_command(
f"scontrol power down {nodelist} action=suspend-slurmd",
fatal=True,
user="slurm",
)
for node in current_nodes:
atf.wait_for_node_state(node, "POWERING_DOWN", fatal=True)
atf.run_command(
f"scontrol update nodename={nodelist} state=RESUME", fatal=True, user="slurm"
)
for node in current_nodes:
atf.wait_for_node_state(
node, "POWERED_DOWN", timeout=suspend_timeout + 5, fatal=True
)
suspend_log_path = Path(f"{atf.module_tmp_path}/suspend_slurmd.log")
assert suspend_log_path.exists(), "Log file must exist"
for node in current_nodes:
assert (
f"{node}" in suspend_log_path.read_text()
), f"Log file must contain {node}"
# powered down, ready for power up
# this one won't work; you can't power up a node from slurmd
res = atf.run_command(
f"scontrol power up {nodelist} action=resume-slurmd", xfail=True, user="slurm"
)
assert res["exit_code"] == 1, "Expected exit code 1"
assert "Invalid power action" in res["stderr"], "expected error message"
@pytest.mark.skipif(
atf.get_version("sbin/slurmctld") < (26, 5, 5),
reason="Ticket 25110: _require_node_reg() sets NO_RESPOND on POWERING_DOWN"
" nodes before 26.05.5",
)
def test_undrain_powering_down_no_not_responding(current_nodes):
"""UNDRAIN must not leave a POWERING_DOWN node NOT_RESPONDING.
State=UNDRAIN is the only path that reaches _require_node_reg() with
POWERING_DOWN still set; the RESUME paths clear POWERING_DOWN first. A
POWERING_DOWN node is skipped by ping_nodes(), so a NoResp flag set here
would never be cleared and would keep the node out of avail_node_bitmap.
Ticket 25110.
"""
nodelist = atf.node_list_to_range(current_nodes)
atf.run_command(
f"scontrol power down {nodelist} action=suspend-ctld", fatal=True, user="slurm"
)
for node in current_nodes:
atf.wait_for_node_state(node, "POWERING_DOWN", fatal=True)
atf.run_command(
f"scontrol update nodename={nodelist} state=UNDRAIN", fatal=True, user="slurm"
)
for node in current_nodes:
st = atf.get_node_parameter(node, "state")
assert "POWERING_DOWN" in st, (
f"Setup error: {node} should still be POWERING_DOWN after UNDRAIN; "
f"the NOT_RESPONDING check is only meaningful there; state={st!r}"
)
assert "NOT_RESPONDING" not in st, (
f"POWERING_DOWN {node} must not be NOT_RESPONDING after UNDRAIN; "
f"state={st!r}"
)
@pytest.mark.skipif(
atf.get_version("sbin/slurmctld") < (26, 11),
reason="Ticket 25951: power down cleared an admin's DRAIN until 26.11",
)
def test_power_down_keeps_admin_drain(current_nodes):
"""Power down must not lift an admin's DRAIN.
A plain power down of an idle drained node is the case from the ticket.
The DRAIN was not set for the power down, so it must survive it.
Ticket 25951.
"""
node = current_nodes[0]
atf.run_command(
f"scontrol update nodename={node} state=DRAIN reason='drained by admin'",
fatal=True,
user=atf.properties["slurm-user"],
)
st = atf.get_node_parameter(node, "state")
assert (
"DRAIN" in st
), f"Setup error: {node} should be drained before the power down; state={st!r}"
atf.run_command(
f"scontrol power down {node}",
fatal=True,
user=atf.properties["slurm-user"],
)
# The DRAIN is settled when the power down starts. Check it there rather
# than at POWERED_DOWN, which is SuspendTimeout away.
atf.wait_for_node_state(node, "POWERING_DOWN", fatal=True)
st = atf.get_node_parameter(node, "state")
assert (
"DRAIN" in st
), f"The admin's DRAIN on {node} must survive the power down; state={st!r}"
def test_power_down_asap_lifts_own_drain(held_node):
"""Power down asap must lift the DRAIN that it set once powering down.
The asap request drains the node so its running jobs can finish first.
That drain is only for the power down, so it must be gone once the node
starts powering down. Ticket 25951.
"""
node, job_id = held_node
atf.run_command(
f"scontrol power down asap {node}",
fatal=True,
user=atf.properties["slurm-user"],
)
st = atf.get_node_parameter(node, "state")
assert "POWER_DOWN" in st, (
f"Setup error: {node} should be marked for power down while its job "
f"runs; state={st!r}"
)
assert (
"DRAIN" in st
), f"Power down asap must drain {node} while its job runs; state={st!r}"
atf.cancel_jobs([job_id], fatal=True)
# The DRAIN is settled when the power down starts. Check it there rather
# than at POWERED_DOWN, which is SuspendTimeout away.
atf.wait_for_node_state(node, "POWERING_DOWN", fatal=True)
st = atf.get_node_parameter(node, "state")
assert "DRAIN" not in st, (
f"The DRAIN set by power down asap must be lifted once {node} is "
f"powering down; state={st!r}"
)
@pytest.mark.skipif(
atf.get_version("sbin/slurmctld") < (26, 11),
reason="Ticket 25951: power down asap cleared an admin's DRAIN until 26.11",
)
@pytest.mark.parametrize("drain_when", ["before", "after"])
def test_power_down_asap_keeps_admin_drain(held_node, drain_when):
"""Power down asap must not lift a DRAIN that it did not set.
An admin drain on the node, whether set before the asap request or while
the request is pending, must survive the power down. Ticket 25951.
"""
node, job_id = held_node
drain_cmd = f"scontrol update nodename={node} state=DRAIN reason='drained by admin'"
if drain_when == "before":
atf.run_command(drain_cmd, fatal=True, user=atf.properties["slurm-user"])
atf.run_command(
f"scontrol power down asap {node}",
fatal=True,
user=atf.properties["slurm-user"],
)
if drain_when == "after":
atf.run_command(drain_cmd, fatal=True, user=atf.properties["slurm-user"])
st = atf.get_node_parameter(node, "state")
assert "POWER_DOWN" in st, (
f"Setup error: {node} should be marked for power down while its job "
f"runs; state={st!r}"
)
atf.cancel_jobs([job_id], fatal=True)
# The DRAIN is settled when the power down starts. Check it there rather
# than at POWERED_DOWN, which is SuspendTimeout away.
atf.wait_for_node_state(node, "POWERING_DOWN", fatal=True)
st = atf.get_node_parameter(node, "state")
assert (
"DRAIN" in st
), f"The admin's DRAIN on {node} must survive the power down; state={st!r}"
@pytest.mark.parametrize(
"drain_owner",
[
pytest.param(
"asap",
marks=pytest.mark.skipif(
atf.get_version("sbin/slurmctld") < (26, 11),
reason="Ticket 25951: power up force kept the DRAIN set by power down asap until 26.11",
),
),
"admin",
],
)
def test_power_up_force_drain(held_node, drain_owner):
"""Power up force must lift a DRAIN set by power down asap, but no other.
Power up force kills the node's jobs, so the asap drain that let them
finish has no purpose left. An admin's DRAIN must stay. Ticket 25951.
"""
node, job_id = held_node
if drain_owner == "admin":
atf.run_command(
f"scontrol update nodename={node} state=DRAIN reason='drained by admin'",
fatal=True,
user=atf.properties["slurm-user"],
)
atf.run_command(
f"scontrol power down asap {node}",
fatal=True,
user=atf.properties["slurm-user"],
)
st = atf.get_node_parameter(node, "state")
assert (
"DRAIN" in st
), f"Setup error: {node} should be drained while its job runs; state={st!r}"
atf.run_command(
f"scontrol power up force {node}",
fatal=True,
user=atf.properties["slurm-user"],
)
# slurmctld updates the node state before it replies to power up force
st = atf.get_node_parameter(node, "state")
if drain_owner == "asap":
assert "DRAIN" not in st, (
f"Power up force must lift the DRAIN set by power down asap on "
f"{node}; state={st!r}"
)
else:
assert (
"DRAIN" in st
), f"Power up force must keep the admin's DRAIN on {node}; state={st!r}"
# Let the power up that force started finish, bounded by ResumeTimeout, so
# its ResumeProgram cannot race the slurmd restart in reset_nodes
atf.wait_for_node_state(
node,
["POWER_UP", "POWERED_DOWN", "POWERING_UP"],
reverse=True,
timeout=resume_timeout + 5,
fatal=True,
)