blob: 15658268e90264251ffe1b566e2eb2445e866392 [file]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""Verify a slurm.conf per-node topology survives a config record split.
When several nodes are defined on one NodeName= line they share a single
config_record_t. Updating only a subset of those nodes (Weight=, Features=
or Gres=) splits the record via _dup_config() in src/slurmctld/node_mgr.c.
The duplicated record must carry topology_str; otherwise the split-off node
loses its configured topology.
The loss is not visible from `scontrol show node` immediately after the
split (the node record keeps its own topology_str copy), but it surfaces the
next time the node record is rebuilt from its config record. reset_node_topology()
does exactly that when a node finishes powering down (power_save.c), reading
node_ptr->config_ptr->topology_str as the value to restore. topology_orig_str
is only populated when the topology is NOT configured in slurm.conf (see
_restore_node_topology_str() in slurmctld/read_config.c), so with the topology
in slurm.conf a dropped config topology_str leaves nothing to fall back on and
the node's topology is cleared.
So the sequence is:
NodeName=node[1-2] ... Topology=tree_topo:sw_plain (shared config)
scontrol update NodeName=node1 Weight=<new> (splits the config)
scontrol update NodeName=node1 State=POWER_DOWN_FORCE (reset_node_topology)
Before the fix node1's topology is cleared; after the fix it is preserved.
"""
import re
import pytest
import atf
TOPO = "tree_topo:sw_plain"
NEW_WEIGHT = 5
@pytest.fixture(scope="module", autouse=True)
def setup():
atf.require_version(
(26, 11),
"sbin/slurmctld",
reason="Issue 51012: splitting a node config dropped topology_str before 26.11",
)
atf.require_config_parameter("SelectType", "select/cons_tres")
atf.require_config_parameter("SelectTypeParameters", "CR_Core_Memory")
# Power save must be enabled for reset_node_topology() to run on power
# down. SuspendTime=INFINITE keeps nodes from auto-suspending so the test
# controls the power-down explicitly; a short SuspendTimeout keeps the
# POWERED_DOWN transition quick.
atf.require_config_parameter("SuspendProgram", "/bin/true")
atf.require_config_parameter("ResumeProgram", "/bin/true")
atf.require_config_parameter("SuspendTime", "INFINITE")
atf.require_config_parameter("SuspendTimeout", 5)
atf.require_config_parameter("ResumeTimeout", 30)
atf.require_config_parameter_includes("SlurmctldParameters", "idle_on_node_suspend")
# A tree topology with a named leaf switch the nodes are pinned under, so
# the per-node Topology= string is meaningful and we can verify placement
# via `scontrol show topology`. The tree_topo: prefix is how the
# multi-topology dispatcher reads node_ptr->topology_str.
atf.require_config_file(
"topology.yaml",
"""
- topology: tree_topo
cluster_default: true
tree:
switches:
- switch: sw_root
children: sw_plain,sw_other
- switch: sw_plain
- switch: sw_other
""",
)
# Two nodes with identical config (including the slurm.conf topology and a
# non-default CpuBind) so they consolidate into a single config_record_t at
# startup (see consolidate_config_list() in read_slurm_conf()). CpuBind is
# one of the fields _dup_config() must copy onto the split-off record.
# require_nodes() assigns them distinct ports so both slurmds register
# under multi-slurmd.
atf.require_nodes(
2, [("CPUs", 1), ("RealMemory", 64), ("Topology", TOPO), ("CpuBind", "core")]
)
atf.require_slurm_running()
def _node_topology(name):
"""Return the node's currently reported Topology= string, or None."""
return atf.get_node_parameter(name, "topology")
def _switch_for_node(name):
"""Return the leaf SwitchName containing `name`, or None.
Only level-0 (leaf) switches list nodes, so intermediate switches are
ignored."""
out = atf.run_command_output("scontrol show topology", fatal=False)
for line in out.splitlines():
m = re.match(r"SwitchName=(\S+) Level=0 .*Nodes=(\S+)", line)
if not m:
continue
sw, nodes_expr = m.group(1), m.group(2)
if name in atf.node_range_to_list(nodes_expr):
return sw
return None
def test_topology_preserved_when_config_split_by_partial_update():
"""A partial Weight= update splits the shared config record; the split-off
node must keep its slurm.conf topology across a power-down cycle."""
# Derive the two nodes require_nodes() created rather than assuming the
# node1/node2 naming of a node0-template reference config: `victim` is
# updated (splitting its config record) and `sharer` stays untouched.
victim, sharer = sorted(atf.get_nodes(quiet=True))[:2]
# Precondition: both nodes share the configured topology.
assert (
_node_topology(victim) == TOPO
), f"{victim} should start with configured topology"
assert (
_node_topology(sharer) == TOPO
), f"{sharer} should start with configured topology"
# Update only the victim's weight. Because it shares a config record with
# the sharer, this splits the record for the victim via _dup_config().
atf.run_command(
f"scontrol update NodeName={victim} Weight={NEW_WEIGHT}",
user=atf.properties["slurm-user"],
fatal=True,
)
for _ in atf.timer(fatal=True):
if str(atf.get_node_parameter(victim, "weight")) == str(NEW_WEIGHT):
break
# The sharer must be unaffected by the split.
assert str(atf.get_node_parameter(sharer, "weight")) != str(
NEW_WEIGHT
), f"{sharer} should keep its original weight after the split"
# The split must not reset other shared config fields on the victim. CpuBind
# is copied by _dup_config() alongside topology_str; the victim must still
# match the untouched sharer.
assert atf.get_node_parameter(victim, "cpubind") == atf.get_node_parameter(
sharer, "cpubind"
), f"{victim} should keep its configured CpuBind after the config split"
# Force the victim to power down. On the POWERED_DOWN transition the
# controller calls reset_node_topology(), which rebuilds the victim's
# topology from its (split-off) config record.
atf.run_command(
f"scontrol update NodeName={victim} State=POWER_DOWN_FORCE",
user=atf.properties["slurm-user"],
fatal=True,
)
atf.wait_for_node_state(victim, "POWERED_DOWN", fatal=True)
# The split-off config record must still carry the topology, so the victim's
# topology is restored (not cleared) after the reset.
for _ in atf.timer():
if _node_topology(victim) == TOPO:
break
else:
pytest.fail(
f"{victim} topology should be preserved after config split, got {_node_topology(victim)!r}"
)
# And it should still be placed under the configured leaf switch.
for _ in atf.timer():
if _switch_for_node(victim) == "sw_plain":
break
else:
pytest.fail(
f"{victim} should remain under sw_plain, got {_switch_for_node(victim)!r}"
)