| ############################################################################ | |
| # 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) |