blob: b141017e1068c296281c73cea4f64e176e63d4ac [file]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""Test that powered down nodes are never left in the UNKNOWN state.
The UNKNOWN base state is only a placeholder used while the slurmctld waits
for a slurmd to register. A powered down node has no slurmd to answer, so the
placeholder can never be resolved and the node is stranded: it is left out of
the up/available node bitmaps, so it is never picked for a job and never gets
powered back up on its own.
A reconfigure, and a plain slurmctld restart, both rebuild the node records
out of slurm.conf (where such nodes carry the default State=UNKNOWN) and then
layer the saved state on top. The recovered POWERED_DOWN/POWERING_DOWN flag
must therefore land on an IDLE base state rather than on UNKNOWN.
"""
import os
import pytest
import atf
pytestmark = pytest.mark.slow
# Applied per test rather than module-wide. test_down_node_stays_down pins an
# invariant that already held before the guard was restored, so it passes on
# the broken releases too, and xfail_strict would report that as a failure.
xfail_missing_guard = pytest.mark.xfail(
(26, 5) <= atf.get_version("sbin/slurmctld") < (26, 5, 5),
reason="Ticket 25750: 53be38cdc7 dropped the UNKNOWN+POWERED_DOWN"
" guard in 26.05.0; restored in 26.05.5",
)
# Resolved from slurm.conf by setup(), which every test and helper runs after.
# The whole module operates on a single node.
node_name = None
# Time the node is allowed to spend in POWERING_DOWN. It also bounds how long
# the reconfigure/restart under test may take while the node is still expected
# to be POWERING_DOWN.
suspend_timeout = 60
resume_timeout = 60
# SuspendTime has to be a real value rather than INFINITE. An INFINITE
# SuspendTime turns off automatic power save entirely, and that also stops a
# job from resuming the node it was allocated. Make it long enough that no node
# suspends on its own while the tests run.
suspend_time = 3600
@pytest.fixture(scope="module", autouse=True)
def setup(power_scripts):
global node_name
suspend_script, resume_script = power_scripts
atf.require_nodes(1)
# Take the node atf manages rather than the first one slurm.conf happens to
# declare: this is the one whose spool and tmpfs directories conftest
# created and will clean up. It is only populated under auto-config, which
# this module needs anyway to rewrite the node line and stop slurmds.
node_name = atf.properties["nodes"][0]
atf.require_config_parameter_includes("DebugFlags", "Power")
atf.require_config_parameter("ReturnToService", 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("SuspendProgram", suspend_script)
atf.require_config_parameter("ResumeProgram", resume_script)
# State=UNKNOWN is the slurm.conf default, but pin it so the precondition
# this test cares about does not depend on how the node was declared.
# Edit the existing node line rather than replacing it with
# require_config_parameter("NodeName", {...}): that form drops every other
# NodeName line along with this node's NodeAddr/NodeHostname/Port. This node
# is not CLOUD, so _validate_slurmd_addr() does not skip it while it is
# powered down, and an unresolvable name there means slurmctld overwrites
# the recovered state with FUTURE / "NO NETWORK ADDRESS FOUND".
atf.set_node_parameter(node_name, "State", "UNKNOWN")
atf.require_slurm_running()
@pytest.fixture(scope="module")
def power_scripts():
"""Create the SuspendProgram and ResumeProgram, yielding their paths.
Gates on auto-config before building anything. The checks below fail on a
slurm.conf that takes the SlurmdPidFile default, which may occur under
local-config.
A SuspendProgram of /bin/true is not enough here: a slurmd that is left
running would answer the config push a reconfigure sends out and register
itself, which would hide the state we are trying to observe.
The ResumeProgram starts the slurmd back up. It keeps the "slurmd -N
<node>" prefix that atf.start_slurmd() matches on, and adds -b so the
slurmctld takes the registration as a fresh boot.
slurmscriptd runs both scripts, so slurm.conf needs absolute paths.
"""
if not atf.properties["auto-config"]:
pytest.skip("Needs auto-config to rewrite the node line and stop slurmds")
scontrol = f"{atf.properties['slurm-bin-dir']}/scontrol"
slurmd = f"{atf.properties['slurm-sbin-dir']}/slurmd"
# Kill by pidfile rather than 'pkill -f "slurmd -N $node"'. That pattern
# also matches a longer node name (node1 matches node10) and sudo's own
# command line, and pkill does not skip its own parent, so the script can
# signal sudo, exit non-zero, and have the slurmctld log a SuspendProgram
# failure.
# get_config_parameter() casefolds values, which would corrupt a path
# containing an uppercase character, so read the raw config instead.
config = atf.get_config(live=False, quiet=True)
pidfile = next(
(value for key, value in config.items() if key.casefold() == "slurmdpidfile"),
None,
)
if pidfile is None:
pytest.fail("slurm.conf must set SlurmdPidFile for the SuspendProgram")
if "%n" not in pidfile:
pytest.fail(
f"SlurmdPidFile ({pidfile}) must contain %n, otherwise every slurmd"
" shares one pidfile and the SuspendProgram kills the wrong one"
)
pidfile = pidfile.replace("%n", "$node")
suspend_script = os.path.abspath("suspend.sh")
atf.make_bash_script(
suspend_script,
f"""
for node in $({scontrol} show hostnames "$1"); do
sudo pkill -F {pidfile} -x slurmd
done
""",
)
resume_script = os.path.abspath("resume.sh")
atf.make_bash_script(
resume_script,
f"""
sleep 2 # bug 16459: let the slurmctld update the node state first
for node in $({scontrol} show hostnames "$1"); do
sudo {slurmd} -N $node -b
done
""",
)
yield suspend_script, resume_script
@pytest.fixture(scope="function", autouse=True)
def powered_up_node():
"""Start every test with the node up and IDLE, and leave it that way."""
atf.wait_for_node_state(node_name, "IDLE", fatal=True)
state = atf.get_node_parameter(node_name, "state")
if "POWERED_DOWN" in state or "POWERING_DOWN" in state:
pytest.fail(f"{node_name} must start powered up (state={state})")
yield
# Drop the saved state so the next test starts from a clean slate
atf.restart_slurmctld(clean=True)
atf.start_slurmd(node_name, quiet=True)
# wait_for_node_state matches by intersection, so an IDLE wait is also
# satisfied by IDLE+POWERED_DOWN. Check the power flags explicitly, or a
# teardown that left the node dirty only surfaces in the next test.
atf.wait_for_node_state(node_name, "IDLE", fatal=True)
state = atf.get_node_parameter(node_name, "state")
if "POWERED_DOWN" in state or "POWERING_DOWN" in state:
pytest.fail(f"{node_name} was left powered down by teardown (state={state})")
def power_down_node():
"""Ask the slurmctld to power the node down, stopping its slurmd."""
atf.run_command(
f"scontrol update nodename={node_name} state=POWER_DOWN",
user=atf.properties["slurm-user"],
fatal=True,
)
atf.wait_for_node_state(node_name, "POWERING_DOWN", fatal=True)
def wait_for_powered_down_node():
"""Wait for the node to finish powering down.
The node only leaves POWERING_DOWN once SuspendTimeout has expired, which
is deliberately longer than atf's default polling timeout.
"""
atf.wait_for_node_state(
node_name, "POWERED_DOWN", timeout=suspend_timeout + 15, fatal=True
)
def recover_node_state(method):
"""Make the slurmctld recover the node state from the state file.
Neither path needs a wait afterwards. scontrol reconfigure only returns
once the re-exec'd controller has recovered node state and told its parent
it started, and atf.restart_slurmctld() polls scontrol ping itself.
"""
if method == "reconfigure":
atf.run_command(
"scontrol reconfigure", user=atf.properties["slurm-user"], fatal=True
)
elif method == "restart":
atf.restart_slurmctld()
else:
pytest.fail(f"Unknown node state recovery method: {method}")
def assert_idle_not_unknown(power_flag, method):
state = atf.get_node_parameter(node_name, "state")
assert "UNKNOWN" not in state, (
f"{node_name} must not be UNKNOWN after a {method}. A {power_flag} node"
f" has no slurmd to clear the UNKNOWN state, so it would be stranded"
f" there (state={state})"
)
assert "IDLE" in state, f"{node_name} must be IDLE after a {method} (state={state})"
# The power flag is not latched for the duration of the test: SuspendTimeout
# is measured from the power down request and survives the recovery, so a
# slow reconfigure or restart can let a POWERING_DOWN node finish powering
# down before this runs. Say so, otherwise that reads as the regression.
assert power_flag in state, (
f"{node_name} must still be {power_flag} after a {method} (state={state})."
f" If the other power flag is set instead, the node changed power state"
f" before the recovery finished, which means SuspendTimeout is too short"
f" for this runner rather than a regression"
)
@xfail_missing_guard
@pytest.mark.parametrize("method", ["reconfigure", "restart"])
def test_powered_down_node_state(method):
"""Test that a POWERED_DOWN node is recovered as IDLE, not UNKNOWN."""
power_down_node()
wait_for_powered_down_node()
recover_node_state(method)
assert_idle_not_unknown("POWERED_DOWN", method)
@xfail_missing_guard
@pytest.mark.parametrize("method", ["reconfigure", "restart"])
def test_powering_down_node_state(method):
"""Test that a POWERING_DOWN node is recovered as IDLE, not UNKNOWN."""
power_down_node()
recover_node_state(method)
assert_idle_not_unknown("POWERING_DOWN", method)
def test_down_node_stays_down():
"""Test that a DOWN node is recovered as DOWN, not resurrected as IDLE.
The guard rewrites the base state to IDLE for any node that is still
UNKNOWN when the recovered power flag is applied. A node whose saved base
state was DOWN must not take that path: it has already been rewritten to
DOWN by then, so the power flag is applied without touching the base state.
That is the only branch of the guard the tests above never reach, and
losing it would put an unusable node back into the schedulable pool.
Not parametrized over the recovery method: both reach the same recovery
path, which the tests above establish.
"""
power_down_node()
wait_for_powered_down_node()
# ReturnToService only applies when a slurmd registers, which cannot happen
# here - the SuspendProgram stopped it and nothing restarts it before the
# assertions - so this case is unaffected by the value setup() pins.
atf.run_command(
f"scontrol update nodename={node_name} state=DOWN reason=test_141_5",
user=atf.properties["slurm-user"],
fatal=True,
)
atf.wait_for_node_state(node_name, "DOWN", fatal=True)
recover_node_state("restart")
state = atf.get_node_parameter(node_name, "state")
assert "DOWN" in state, (
f"{node_name} must still be DOWN after a restart. Resurrecting it as"
f" IDLE would return an unusable node to the schedulable pool"
f" (state={state})"
)
assert (
"IDLE" not in state
), f"{node_name} must not be rewritten to IDLE after a restart (state={state})"
assert (
"POWERED_DOWN" in state
), f"{node_name} must still be POWERED_DOWN after a restart (state={state})"
@xfail_missing_guard
def test_powered_down_node_is_allocatable():
"""Test that a POWERED_DOWN node can still be allocated after a reconfigure.
This is what the UNKNOWN base state costs in practice: an UNKNOWN node is
left out of the available node bitmaps, so a job asking for it is never
given the node and the node power up never starts.
"""
power_down_node()
wait_for_powered_down_node()
recover_node_state("reconfigure")
assert_idle_not_unknown("POWERED_DOWN", "reconfigure")
job_id = atf.submit_job_sbatch(f'-w {node_name} --wrap "hostname"', fatal=True)
# POWERED_DOWN is cleared as soon as the slurmctld picks the node and
# starts resuming it, so this covers the regression on its own: an UNKNOWN
# node is never picked and keeps the flag.
atf.wait_for_node_state(node_name, "POWERED_DOWN", reverse=True, fatal=True)
# Waiting for the job proves the node was really allocated. Clearing
# POWERED_DOWN alone does not: a resume that then fails clears the flag
# too, and the node ends up DOWN with the job never run. It also lets the
# ResumeProgram finish before teardown starts its own slurmd, which would
# otherwise leave two slurmds racing for this node's port.
atf.wait_for_job_state(job_id, "COMPLETED", timeout=resume_timeout + 60, fatal=True)