blob: a4d25d4ac48742cf2aa7cb6227fd10a276dbff96 [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""Test that scontrol power up/down reports the nodes it skips.
Ticket 24819: 'scontrol power up' on nodes that are not POWERED_DOWN (e.g. DOWN
nodes) and 'scontrol power down' on nodes that are already powering down
report the skipped nodes through err_msg, the same way 'scontrol reboot' does.
The exit code stays 0 and eligible nodes in the same request are still
powered up/down.
"""
import pytest
import atf
pytestmark = pytest.mark.slow
# The test never wants the automatic power save to suspend nodes on its own:
# nodes start powered down via State=CLOUD and node2 is kept plain DOWN by an
# admin reason, so a big SuspendTime keeps the scheduler out of the way.
suspend_time = 3600
# Nodes powered up during the test stay POWERING_UP (ResumeProgram=/bin/true
# never registers a slurmd) and must not flip to DOWN+POWERED_DOWN via
# ResumeTimeout in the middle of the test.
resume_timeout = 300
# Nodes powered down during the test must stay POWERING_DOWN and not flip to
# POWERED_DOWN via SuspendTimeout in the middle of the test.
suspend_timeout = 300
skip_msg = "Skipping power up of nodes"
skip_down_msg = "Skipping power down of nodes"
@pytest.fixture(scope="module", autouse=True)
def setup():
# slurmctld only sends the message to clients of 26.11 or later, and
# slurmctld is never older than scontrol
atf.require_version(
(26, 11),
"bin/scontrol",
reason="Ticket 24819: power up reports skipped nodes",
)
atf.require_config_parameter("SelectType", "select/cons_tres")
atf.require_config_parameter("SelectTypeParameters", "CR_CPU")
atf.require_config_parameter("TreeWidth", 65533)
atf.require_config_parameter("ResumeProgram", "/bin/true")
atf.require_config_parameter("SuspendProgram", "/bin/true")
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(
"NodeName",
{
"node1": {"State": "CLOUD"},
"node2": {"State": "CLOUD", "Port": atf.get_open_port()},
"node3": {"State": "CLOUD"},
"node4": {"State": "CLOUD", "Port": atf.get_open_port()},
},
)
atf.require_config_parameter(
"PartitionName",
{
"primary": {"Nodes": "ALL", "Default": "YES"},
},
)
# Don't run the usual atf.require_slurm_running() because the test starts
# the slurmd for node2 manually
atf.start_slurmctld(clean=True)
yield
# conftest doesn't stop daemons it didn't start, so stop them here. This
# also covers the manually started node2 slurmd, which is not one of the
# slurmds conftest knows about.
atf.stop_slurmctld(also_slurmds=True)
def skip_up(nodes):
return f"{skip_msg} {nodes} due to current node state."
def skip_down(nodes):
return f"{skip_down_msg} {nodes} due to current node state."
def powering_up(node):
# update_node() sets POWER_UP during the RPC, and the power save thread
# only adds POWERING_UP up to a second later, so check for either
return {"POWER_UP", "POWERING_UP"} & set(
atf.get_node_parameter(node, "state", fatal=True)
)
def test_power_up_reports_skipped_nodes():
slurm_user = atf.properties["slurm-user"]
# All cloud nodes start powered down
for node in ["node1", "node2", "node3", "node4"]:
atf.wait_for_node_state(node, "POWERED_DOWN", fatal=True)
# Baseline: powering up a POWERED_DOWN node is accepted silently
results = atf.run_command("scontrol power up node3", user=slurm_user)
assert results["exit_code"] == 0, f"power up node3 failed: {results['stderr']}"
assert (
skip_msg not in results["stderr"]
), f"POWERED_DOWN node must not be reported as skipped: {results['stderr']}"
atf.wait_for_node_state("node3", "POWERING_UP", fatal=True)
# Register a slurmd for node2 so it leaves POWERED_DOWN, then set it DOWN
# with an admin reason to emulate a node that died outside of power save
# (e.g. 'shutdown -h'), which is the scenario from Ticket 24819
atf.run_command(
f"{atf.properties['slurm-sbin-dir']}/slurmd -b -N node2",
fatal=True,
user="root",
)
atf.wait_for_node_state("node2", "IDLE", fatal=True)
# Polling for IDLE intersects IDLE+POWERED_DOWN, which a CLOUD node keeps
# until its registration completes, so explicitly wait for registration
# to clear POWERED_DOWN before setting the node DOWN
atf.wait_for_node_state("node2", "POWERED_DOWN", reverse=True, fatal=True)
atf.run_command(
"scontrol update nodename=node2 state=down reason=t24819",
user=slurm_user,
fatal=True,
)
atf.wait_for_node_state("node2", "DOWN", fatal=True)
assert "POWERED_DOWN" not in atf.get_node_parameter(
"node2", "state", fatal=True
), "node2 must not be POWERED_DOWN to reproduce the ticket scenario"
# The ticket case: power up of a DOWN node is rejected with a message
results = atf.run_command("scontrol power up node2", user=slurm_user)
assert results["exit_code"] == 0, f"power up node2 failed: {results['stderr']}"
assert (
skip_up("node2") in results["stderr"]
), f"Skipped node2 must be reported to the user: {results['stderr']}"
assert not powering_up("node2"), "node2 must not be powered up without force"
# The obsoleted 'update state=power_up' interface goes through the same
# RPC and must report the skip as well
results = atf.run_command(
"scontrol update nodename=node2 state=power_up", user=slurm_user
)
assert (
results["exit_code"] == 0
), f"update state=power_up failed: {results['stderr']}"
assert (
skip_up("node2") in results["stderr"]
), f"Skipped node2 must be reported to the user: {results['stderr']}"
assert not powering_up("node2"), "node2 must not be powered up without force"
# Mixed request: the eligible node is powered up and only the skipped one
# is reported. The message lists nodes as a hostlist range, so compare the
# whole message: a wrongly reported node1 would show up as node[1-2].
results = atf.run_command("scontrol power up node1,node2", user=slurm_user)
assert (
results["exit_code"] == 0
), f"power up node1,node2 failed: {results['stderr']}"
assert (
skip_up("node2") in results["stderr"]
), f"Skipped node2 must be reported to the user: {results['stderr']}"
atf.wait_for_node_state("node1", "POWERING_UP", fatal=True)
assert not powering_up("node2"), "node2 must not be powered up without force"
# 'power up force' still overrides the state check without any message
results = atf.run_command("scontrol power up force node2", user=slurm_user)
assert (
results["exit_code"] == 0
), f"power up force node2 failed: {results['stderr']}"
assert (
skip_msg not in results["stderr"]
), f"'power up force' must not report skipped nodes: {results['stderr']}"
atf.wait_for_node_state("node2", "POWERING_UP", fatal=True)
# Register a slurmd for node4 so it can be powered down. A repeated
# 'power down' of a node that is already powering down is rejected with
# a message, and so is a conflicting 'power up'
atf.run_command(
f"{atf.properties['slurm-sbin-dir']}/slurmd -b -N node4",
fatal=True,
user="root",
)
atf.wait_for_node_state("node4", "IDLE", fatal=True)
atf.wait_for_node_state("node4", "POWERED_DOWN", reverse=True, fatal=True)
# Power up of a node that is already powered up is skipped too
results = atf.run_command("scontrol power up node4", user=slurm_user)
assert results["exit_code"] == 0, f"power up node4 failed: {results['stderr']}"
assert (
skip_up("node4") in results["stderr"]
), f"Skipped node4 must be reported to the user: {results['stderr']}"
assert not powering_up("node4"), "node4 must not be powered up again"
# Mixed power down: node2, still powering up after the forced power up,
# is first sent powering down. Powering down node2 again together with
# node4 then skips node2 and still powers down the eligible node4.
results = atf.run_command("scontrol power down node2", user=slurm_user)
assert results["exit_code"] == 0, f"power down node2 failed: {results['stderr']}"
assert (
skip_down_msg not in results["stderr"]
), f"node2 must not be reported as skipped: {results['stderr']}"
atf.wait_for_node_state("node2", "POWERING_DOWN", fatal=True)
results = atf.run_command("scontrol power down node2,node4", user=slurm_user)
assert (
results["exit_code"] == 0
), f"power down node2,node4 failed: {results['stderr']}"
assert (
skip_down("node2") in results["stderr"]
), f"Skipped node2 must be reported to the user: {results['stderr']}"
atf.wait_for_node_state("node4", "POWERING_DOWN", fatal=True)
results = atf.run_command("scontrol power down node4", user=slurm_user)
assert (
results["exit_code"] == 0
), f"repeated power down node4 failed: {results['stderr']}"
assert (
skip_down("node4") in results["stderr"]
), f"Skipped node4 must be reported to the user: {results['stderr']}"
# Neither asap nor force overrides the check for a node already powering
# down
for option in ["asap", "force"]:
results = atf.run_command(
f"scontrol power down {option} node4", user=slurm_user
)
assert (
results["exit_code"] == 0
), f"power down {option} node4 failed: {results['stderr']}"
assert (
skip_down("node4") in results["stderr"]
), f"Skipped node4 must be reported to the user: {results['stderr']}"
results = atf.run_command("scontrol power up node4", user=slurm_user)
assert results["exit_code"] == 0, f"power up node4 failed: {results['stderr']}"
assert (
skip_up("node4") in results["stderr"]
), f"Skipped node4 must be reported to the user: {results['stderr']}"