blob: fb326748d24d4acb0d24fce65f612e1e33eaf057 [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""Power save activates from a per-node SuspendTime with no reconfigure.
Automatic power save is enabled when at least one node has a finite
SuspendTime and valid SuspendProgram/ResumeProgram are configured. The
power-save thread must start acting on a node the moment it gets a finite
SuspendTime through ``scontrol create``/``scontrol update`` -- without
requiring a ``scontrol reconfig`` to refresh the enabled flag.
This is exercised with the global SuspendTime set to INFINITE (so power save
is disabled at startup): once for a static node given a finite per-node
SuspendTime with ``scontrol update``, and once for a dynamic node created
with a finite SuspendTime via ``scontrol create``. In both cases the node
must power up for a job, go IDLE, and then suspend on the per-node schedule,
all with no reconfigure in between.
"""
import time
import pytest
import atf
pytestmark = pytest.mark.slow
node_name = "node1"
suspend_time = 10
suspend_timeout = 10
resume_timeout = 10
max_nodes = 5
# The power-save thread only rescans every power_save_interval, so a node can
# suspend up to one interval after suspend_time elapses.
power_save_interval = 10
@pytest.fixture(scope="module", autouse=True)
def setup():
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")
# Global SuspendTime INFINITE so automatic power save is disabled at
# startup; the per-node SuspendTime set later is what must enable it.
atf.require_config_parameter("SuspendTime", "INFINITE")
atf.require_config_parameter("SuspendTimeout", suspend_timeout)
atf.require_config_parameter("ResumeTimeout", resume_timeout)
atf.require_config_parameter_includes("SlurmctldParameters", "idle_on_node_suspend")
atf.require_config_parameter_includes(
"SlurmctldParameters", ("power_save_interval", power_save_interval)
)
atf.require_config_parameter_includes(
"SlurmctldParameters", ("power_save_min_interval", 0)
)
# Needed for the dynamic-node create-path test below.
atf.require_config_parameter("MaxNodeCount", max_nodes)
# A static cloud node registered in slurm.conf so a slurmd can register
# against it; the per-node SuspendTime is applied at runtime below.
atf.require_config_parameter("NodeName", {node_name: {"State": "CLOUD"}})
atf.require_config_parameter("PartitionName", {"cloud1": {"Nodes": "ALL"}})
# Don't run atf.require_slurm_running(): tests start slurmds manually.
atf.start_slurmctld(clean=True)
yield
atf.stop_slurmctld(also_slurmds=True)
@pytest.fixture(autouse=True)
def reset_state():
yield
atf.stop_slurmd()
atf.restart_slurmctld(clean=True)
atf.wait_for_node_state(node_name, "POWERED_DOWN", fatal=True)
def _power_up_and_idle(target=node_name, dynamic=False):
"""Power the given cloud node up through a job and return to IDLE.
dynamic selects '-Z' registration for a node created via 'scontrol
create' rather than declared on a NodeName line in slurm.conf -- without
it, slurmd looks up the name in its own slurm.conf-derived table and
fails to find it.
Returns the job id. Asserts the node reaches IDLE so the idle clock starts.
"""
job_id = atf.submit_job_sbatch(
f"-p cloud1 -w {target} --wrap 'srun hostname'", fatal=True
)
atf.wait_for_node_state(target, "ALLOCATED", fatal=True)
atf.wait_for_node_state(target, "POWERING_UP", fatal=True)
# TODO: Wait 2 seconds to avoid race condition between slurmd and
# slurmctld. Remove once bug 16459 is fixed.
time.sleep(2)
# start_slurmd() also provisions the node's TLS token, which a node
# created via 'scontrol create' does not otherwise have
dynamic_flag = " -Z" if dynamic else ""
atf.start_slurmd(target, args=f"-b{dynamic_flag}", wait="process")
atf.wait_for_node_state(target, "POWERING_UP", reverse=True, fatal=True)
atf.wait_for_job_state(
job_id, "COMPLETED", timeout=atf.PERIODIC_TIMEOUT + 15, fatal=True
)
assert "IDLE" in atf.get_node_parameter(target, "state"), (
"With idle_on_node_suspend, the cloud node should be IDLE once the"
" job completes"
)
return job_id
@pytest.mark.skipif(
atf.get_version("bin/scontrol") < (26, 11),
reason="#50873: per-node SuspendTime enabling power save at runtime",
)
def test_per_node_suspend_time_enables_power_save_no_reconfig():
assert "POWERED_DOWN" in atf.get_node_parameter(
node_name, "state"
), "Cloud node must start in POWERED_DOWN state"
# Give the node a finite per-node SuspendTime at runtime. This is the
# change that must enable automatic power save without a reconfigure.
atf.run_command(
f"scontrol update nodename={node_name} SuspendTime={suspend_time}",
fatal=True,
user=atf.properties["slurm-user"],
)
assert (
atf.get_node_parameter(node_name, "suspend_time")["number"] == suspend_time
), "Per-node SuspendTime update was not applied"
# Power up, run a job, and return to IDLE so the idle clock starts. No
# scontrol reconfig is issued anywhere in this test.
_power_up_and_idle()
# The node must suspend on the per-node SuspendTime schedule, proving the
# power-save thread is acting on the runtime per-node value with no
# reconfigure.
atf.wait_for_node_state(node_name, ["POWER_DOWN", "POWERING_DOWN"], fatal=True)
atf.wait_for_node_state(node_name, "POWERED_DOWN", fatal=True)
@pytest.mark.skipif(
atf.get_version("sbin/slurmctld") < (26, 11),
reason="#50873: per-node SuspendTime enabling power save at runtime",
)
@pytest.mark.skipif(
atf.get_version("bin/scontrol") < (26, 5),
reason="Ticket 24669: Per-node SuspendTime added in 26.05",
)
def test_dynamic_node_create_suspend_time_enables_power_save_no_reconfig():
"""The create path, not just update, must enable power save at runtime.
Mirrors test_per_node_suspend_time_enables_power_save_no_reconfig, but
the finite per-node SuspendTime is supplied by 'scontrol create' for a
brand new dynamic node instead of 'scontrol update' on an existing one --
the path a cloud/autoscaling workflow actually uses.
"""
dynamic_node = "d1"
atf.run_command(
f"scontrol create nodename={dynamic_node} state=cloud"
f" SuspendTime={suspend_time}",
fatal=True,
user=atf.properties["slurm-user"],
)
assert (
atf.get_node_parameter(dynamic_node, "suspend_time")["number"] == suspend_time
), "Per-node SuspendTime set at create time was not applied"
assert "POWERED_DOWN" in atf.get_node_parameter(
dynamic_node, "state"
), "Newly created cloud node must start in POWERED_DOWN state"
# Power up, run a job, and return to IDLE so the idle clock starts. No
# scontrol reconfig is issued anywhere in this test.
_power_up_and_idle(dynamic_node, dynamic=True)
# The node must suspend on the per-node SuspendTime schedule, proving the
# power-save thread acted on the value from 'scontrol create' with no
# reconfigure.
atf.wait_for_node_state(dynamic_node, ["POWER_DOWN", "POWERING_DOWN"], fatal=True)
atf.wait_for_node_state(dynamic_node, "POWERED_DOWN", fatal=True)