blob: e63bd44231f2ab05d5c4b32a1b9cf5cd2d37ee11 [file]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""Test dynamic node range creation and rollback of a failed create.
A `scontrol create NodeName=...` naming a range must create every node in it.
One that fails must create nothing and must leave every pre-existing node
exactly as it was, whether it fails on a duplicate name, past MaxNodeCount, or
on an invalid state.
"""
import re
import pytest
import atf
max_nodes = 6
# A failed create must not stamp its own configuration onto a node it collided
# with, so the node under test and the create that fails against it differ in
# every field assert_node_intact() checks.
intact_feature = "f1"
intact_cpus = 4
intact_memory = 1000
intact_spec = f"CPUs={intact_cpus} RealMemory={intact_memory}"
colliding_feature = "f2"
colliding_spec = "CPUs=8 RealMemory=2000"
@pytest.fixture(scope="module", autouse=True)
def setup():
atf.require_auto_config("Wants to set required dynamic node parameters")
atf.require_config_parameter("MaxNodeCount", max_nodes)
atf.require_config_parameter("PartitionName", {"primary": {"Nodes": "ALL"}})
# Needed for MaxNodeCount
atf.require_config_parameter("SelectType", "select/cons_tres")
# Needed for cons_tres
atf.require_config_parameter("SelectTypeParameters", "CR_CPU")
atf.require_slurm_running()
# Helper fixtures
@pytest.fixture
def create_node():
created_nodes = []
def scontrol_create_node(
node_name, state, feature, extra="", xfail=False, fatal=False
):
command = f"scontrol create NodeName={node_name} State={state}"
command += f" Feature={feature}"
if extra:
command += f" {extra}"
results = atf.run_command(
command,
user=atf.properties["slurm-user"],
xfail=xfail,
fatal=fatal,
)
if results["exit_code"] == 0:
# A range expression must be expanded so the teardown can look
# each node up by name
if "[" in node_name:
created_nodes.extend(atf.node_range_to_list(node_name))
else:
created_nodes.append(node_name)
return results
yield scontrol_create_node
delete_nodes_or_fail(created_nodes)
@pytest.fixture
def register_node():
registered_nodes = []
def slurmd_register_node(node_name, feature, fatal=False):
results = atf.run_command(
f"{atf.properties['slurm-sbin-dir']}/slurmd -N {node_name} -Z -b"
f" --conf 'feature={feature}'",
user="root",
fatal=fatal,
)
if results["exit_code"] == 0:
registered_nodes.append(node_name)
return results
yield slurmd_register_node
# The slurmd must go before create_node's teardown deletes the node. One
# that never came up leaves nothing to kill, and failing on that here would
# bury why the test itself failed.
for node_name in registered_nodes:
node_pid = atf.run_command_output(
f"pgrep -f '{atf.properties['slurm-sbin-dir']}/slurmd -N {node_name}'"
).strip()
if node_pid:
atf.run_command(f"kill {node_pid}", fatal=True, user="root")
# Nodes created as a side effect of a failed command are not tracked by
# create_node, which only records nodes whose creation succeeded
@pytest.fixture
def delete_nodes():
node_names = []
yield node_names
delete_nodes_or_fail(node_names)
# Each test gets its own prefix so its nodes cannot collide with those of
# another test. The prefix is derived from the test name, so a leaked node
# names the test that made it. Only the tail is kept, to stay well inside the
# host name length limit, and it must not end in a digit so that prefix[1-3]
# expands unambiguously.
@pytest.fixture
def unique_node_prefix(request):
return re.sub(r"[^a-zA-Z]", "", request.node.name)[-32:] + "node"
# Helper functions
def node_exists(node_name):
return node_name in atf.get_nodes(live=True, quiet=True)
def node_count():
return len(atf.get_nodes(live=True, quiet=True))
# Delete every node that still exists, then report all the failures at once. A
# fatal delete inside the loop would skip every node after it, leaking them
# into the next test's MaxNodeCount budget.
def delete_nodes_or_fail(node_names):
errors = []
for node_name in node_names:
if not node_exists(node_name):
continue
results = atf.run_command(
f"scontrol delete NodeName={node_name}",
user=atf.properties["slurm-user"],
)
if results["exit_code"] != 0:
errors.append(f"Failed to delete node {node_name}: {results['stderr']}")
if errors:
pytest.fail("; ".join(errors))
def assert_node_intact(node_name, state="FUTURE"):
assert node_exists(node_name), f"Node {node_name} should still exist"
node_states = atf.get_node_parameter(node_name, "state")
assert (
state in node_states
), f"Node {node_name} should still be {state}, got {node_states}"
assert (
"DYNAMIC_NORM" in node_states
), f"Node {node_name} should still be dynamic, got {node_states}"
features = atf.get_node_parameter(node_name, "features")
assert features == [
intact_feature
], f"Node {node_name} should still have feature {intact_feature}, got {features}"
cpus = atf.get_node_parameter(node_name, "cpus")
assert (
cpus == intact_cpus
), f"Node {node_name} should still have {intact_cpus} CPUs, got {cpus}"
real_memory = atf.get_node_parameter(node_name, "real_memory")
assert (
real_memory == intact_memory
), f"Node {node_name} should still have {intact_memory} RealMemory, got {real_memory}"
# An orphaned node keeps its record but loses its partition assignment,
# which leaves it visible but unschedulable. 'primary' has Nodes=ALL, so
# every intact node belongs to it.
partitions = atf.get_node_parameter(node_name, "partitions")
assert (
partitions
), f"Node {node_name} should still belong to a partition, got {partitions}"
assert (
"primary" in partitions
), f"Node {node_name} should still be in 'primary', got {partitions}"
# Tests
@pytest.mark.parametrize("state", ["FUTURE", "CLOUD"])
def test_range_create_makes_every_node(create_node, unique_node_prefix, state):
"""Verify a create naming a node range makes every node in the range"""
node_names = [f"{unique_node_prefix}{i}" for i in (1, 2, 3)]
before_count = node_count()
create_node(
f"{unique_node_prefix}[1-3]",
state,
intact_feature,
extra=intact_spec,
fatal=True,
)
assert node_count() == before_count + len(
node_names
), f"A create of {len(node_names)} nodes should add exactly that many nodes"
for node_name in node_names:
assert_node_intact(node_name, state=state)
@pytest.mark.skipif(
atf.get_version("sbin/slurmctld") < (26, 5, 5),
reason="Ticket 25501: scontrol create deleted pre-existing nodes on failure before 26.05.5",
)
@pytest.mark.parametrize(
"state, pre_existing_ids, create_range, not_created_ids",
[
("FUTURE", [1], "1", []),
("FUTURE", [2], "[1-2]", [1]),
("FUTURE", [3], "[1-3]", [1, 2]),
("FUTURE", [1], "[1-2]", [2]),
("FUTURE", [1, 3], "[1-3]", [2]),
("FUTURE", [2], "[1-3]", [1, 3]),
("CLOUD", [2], "[1-3]", [1, 3]),
],
ids=[
"single_node",
"range_failing_on_last",
"range_failing_on_last_after_two",
"range_failing_on_first",
"range_with_untouched_pre_existing",
"range_failing_in_middle",
"cloud_range_failing_in_middle",
],
)
def test_node_stability_when_create_collides_with_existing_node(
create_node,
delete_nodes,
unique_node_prefix,
state,
pre_existing_ids,
create_range,
not_created_ids,
):
"""
Verify a create failing on a duplicate node name leaves pre-existing nodes
intact and creates zero additional nodes.
"""
pre_existing = [f"{unique_node_prefix}{i}" for i in pre_existing_ids]
not_created = [f"{unique_node_prefix}{i}" for i in not_created_ids]
for node_name in pre_existing:
create_node(node_name, state, intact_feature, extra=intact_spec, fatal=True)
before_count = node_count()
# Nodes elsewhere in the nodelist must not be created either
delete_nodes.extend(not_created)
err = create_node(
f"{unique_node_prefix}{create_range}",
state,
colliding_feature,
extra=colliding_spec,
xfail=True,
fatal=True,
)["stderr"]
assert (
"already exists" in err
), f"scontrol should report the node already exists, got: {err}"
# Nodes created before the failing one must be rolled back
for node_name in not_created:
assert not node_exists(
node_name
), f"Node {node_name} should not have been created"
assert (
node_count() == before_count
), "A failed create should not change the number of nodes"
for node_name in pre_existing:
assert_node_intact(node_name, state=state)
# The rolled back names and their table slots must be reusable
for node_name in not_created:
create_node(node_name, state, intact_feature, extra=intact_spec, fatal=True)
assert_node_intact(node_name, state=state)
@pytest.mark.skipif(
atf.get_version("sbin/slurmctld") < (26, 5, 5),
reason="Ticket 25501: scontrol create deleted pre-existing nodes on failure before 26.05.5",
)
def test_node_stability_when_create_collides_with_static_node(create_node):
"""Verify a create colliding with a slurm.conf node leaves it intact"""
static_node = list(atf.get_nodes(live=False))[0]
before_states = atf.get_node_parameter(static_node, "state")
before_config = {
parameter: atf.get_node_parameter(static_node, parameter)
for parameter in ("cpus", "real_memory", "features")
}
err = create_node(
static_node,
"FUTURE",
colliding_feature,
extra=colliding_spec,
xfail=True,
fatal=True,
)["stderr"]
assert (
"already exists" in err
), f"scontrol should report the node already exists, got: {err}"
assert node_exists(static_node), f"Static node {static_node} should still exist"
after_config = {
parameter: atf.get_node_parameter(static_node, parameter)
for parameter in ("cpus", "real_memory", "features")
}
assert (
after_config == before_config
), f"Static node {static_node} config changed from {before_config} to {after_config}"
after_states = atf.get_node_parameter(static_node, "state")
assert (
after_states == before_states
), f"Static node {static_node} state changed from {before_states} to {after_states}"
# A static node deleted by mistake cannot be restored with scontrol create
assert (
"DYNAMIC_NORM" not in after_states
), f"Static node {static_node} should not have become dynamic, got {after_states}"
@pytest.mark.skipif(
atf.get_version("sbin/slurmctld") < (26, 5, 5),
reason="Ticket 25501: scontrol create deleted pre-existing nodes on failure before 26.05.5",
)
def test_node_stability_when_create_collides_with_busy_node(
create_node, register_node, delete_nodes, unique_node_prefix
):
"""Verify a create colliding with an allocated node leaves it and its job intact"""
# The busy node is the second entry so the first is created and must be
# rolled back before the collision, exercising the unwind against a node
# that cannot itself be deleted.
unmade_node = f"{unique_node_prefix}1"
busy_node = f"{unique_node_prefix}2"
create_node(busy_node, "FUTURE", intact_feature, extra=intact_spec, fatal=True)
register_node(busy_node, intact_feature, fatal=True)
assert atf.wait_for_node_state(
busy_node, "IDLE", fatal=True
), f"Node {busy_node} should be IDLE once its slurmd has registered"
job_id = atf.submit_job_sbatch(
f"-p primary -w {busy_node} --exclusive --wrap 'sleep infinity'",
fatal=True,
)
assert atf.wait_for_node_state(
busy_node, "ALLOCATED", fatal=True
), f"Node {busy_node} should be ALLOCATED while running job {job_id}"
before_config = {
parameter: atf.get_node_parameter(busy_node, parameter)
for parameter in ("cpus", "real_memory", "features")
}
before_count = node_count()
delete_nodes.append(unmade_node)
err = create_node(
f"{unique_node_prefix}[1-2]",
"FUTURE",
colliding_feature,
extra=colliding_spec,
xfail=True,
fatal=True,
)["stderr"]
assert (
"already exists" in err
), f"scontrol should report the node already exists, got: {err}"
assert node_exists(busy_node), f"Allocated node {busy_node} should still exist"
node_states = atf.get_node_parameter(busy_node, "state")
assert (
"ALLOCATED" in node_states
), f"Node {busy_node} should still be ALLOCATED, got {node_states}"
after_config = {
parameter: atf.get_node_parameter(busy_node, parameter)
for parameter in ("cpus", "real_memory", "features")
}
assert (
after_config == before_config
), f"Node {busy_node} config changed from {before_config} to {after_config}"
assert not node_exists(
unmade_node
), f"Node {unmade_node} should not have been created"
assert (
node_count() == before_count
), "A failed create should not change the number of nodes"
# The allocation the collision could have destroyed must still be live and
# releasable
atf.cancel_jobs([job_id], fatal=True)
assert atf.wait_for_node_state(
busy_node, "IDLE", fatal=True
), f"Node {busy_node} should return to IDLE once job {job_id} is gone"
@pytest.mark.skipif(
atf.get_version("sbin/slurmctld") < (26, 5, 5),
reason="Ticket 25501: scontrol create left a node behind past MaxNodeCount before 26.05.5",
)
def test_node_stability_when_create_exceeds_max_node_count(
create_node, delete_nodes, unique_node_prefix
):
"""Verify a create exceeding MaxNodeCount leaves pre-existing nodes intact"""
# Fill the table to one free slot, so the two node range below overruns
# MaxNodeCount by exactly one
if (base_nodes := node_count()) > max_nodes - 2:
pytest.fail(
f"Test needs 2 free node slots, have {max_nodes - base_nodes}"
f" (MaxNodeCount={max_nodes})"
)
pre_existing = []
for i in range(base_nodes, max_nodes - 1):
node_name = f"{unique_node_prefix}{i}"
create_node(node_name, "FUTURE", intact_feature, extra=intact_spec, fatal=True)
pre_existing.append(node_name)
before_count = node_count()
overrun_first = f"{unique_node_prefix}{max_nodes}"
overrun_second = f"{unique_node_prefix}{max_nodes + 1}"
delete_nodes.append(overrun_first)
delete_nodes.append(overrun_second)
err = create_node(
f"{unique_node_prefix}[{max_nodes}-{max_nodes + 1}]",
"FUTURE",
colliding_feature,
extra=colliding_spec,
xfail=True,
fatal=True,
)["stderr"]
assert (
"node table is full" in err
), f"scontrol should report the node table is full, got: {err}"
# The batch is rejected as a whole. overrun_first fits the last free slot,
# so it is the node a partial create would leave behind.
assert not node_exists(
overrun_first
), f"Node {overrun_first} should not have been created past MaxNodeCount"
assert not node_exists(
overrun_second
), f"Node {overrun_second} should not have been created past MaxNodeCount"
assert (
node_count() == before_count
), "A create past MaxNodeCount should not change the number of nodes"
for node_name in pre_existing:
assert_node_intact(node_name)
# The invalid state is rejected before any node table state is touched. This
# pins that early return, which the rollback path must never have to undo.
def test_node_stability_when_create_uses_invalid_state(
create_node, delete_nodes, unique_node_prefix
):
"""Verify a create rejected for its state leaves pre-existing nodes intact"""
create_node(
f"{unique_node_prefix}1",
"FUTURE",
intact_feature,
extra=intact_spec,
fatal=True,
)
rejected_nodes = [f"{unique_node_prefix}2", f"{unique_node_prefix}3"]
delete_nodes.extend(rejected_nodes)
err = create_node(
f"{unique_node_prefix}[2-3]",
"DOWN",
colliding_feature,
extra=colliding_spec,
xfail=True,
fatal=True,
)["stderr"]
assert (
"Invalid node state specified" in err
), f"scontrol should report an invalid node state, got: {err}"
for node_name in rejected_nodes:
assert not node_exists(
node_name
), f"Node {node_name} should not have been created with an invalid state"
assert_node_intact(f"{unique_node_prefix}1")