blob: 3fc5c243d47c40e38f75bbe1b503ba2bb08458cf [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""Count-only GRES drain tests using bandwidth:lustre.
Tests that the scheduler correctly enforces per-node count-only GRES drain
across a discontinuous 4-node cluster (node99=skip, node100=NODE_A,
node101=NODE_GOOD, node102=NODE_B). All tests use bandwidth:lustre as the
representative count-only GRES; GPU GRES is always topology-based and cannot
be used with Flags=CountOnly.
Each test writes gres.conf and restarts slurm (same pattern as test_190_7.py).
This avoids MaxNodeCount exhaustion from static nodes left by previous test
modules (several call require_nodes(4,...)) and ensures a clean GRES state for
every test.
"""
import re
import pytest
import atf
pytestmark = pytest.mark.slow
# Node names need to be in the form of nodeNN for the s2n variant. The nodes
# are deliberately discontinuous: DYN_NODE_SKIP is registered but never
# targeted (it absorbs the scheduler's first-choice bias) and DYN_NODE_GOOD
# sits between the two drain nodes, so a job pinned to DYN_NODE/DYN_NODE_B
# exercises the discontinuous-bitmap paths.
DYN_NODE_SKIP = "node99" # index 0: registered but not used in jobs
DYN_NODE = "node100" # index 1: primary drain node (NODE_A)
DYN_NODE_GOOD = "node101" # index 2: healthy node between DYN_NODE and _B
DYN_NODE_B = "node102" # index 3: secondary drain node (NODE_B)
# Each dynamic slurmd on the same host must listen on a distinct port. Without
# explicit Port=, all four slurmds default to SlurmdPort (e.g. 6818) and only
# the first one can bind; subsequent nodes fail to register.
PORT_SKIP = 61599
PORT_A = 61600
PORT_GOOD = 61601
PORT_B = 61602
# 4 bandwidth:lustre TRES units per node -- enough headroom for drain+job tests.
BW_GRES_CONF = (
"NodeName=node[99-102]"
" AutoDetect=off Name=bandwidth Type=lustre Count=4 Flags=CountOnly\n"
)
@pytest.fixture(scope="module", autouse=True)
def setup():
atf.require_version(
(26, 11),
component="bin/scontrol",
reason="Ticket 25180: GRES drain added in 26.11",
)
atf.require_version(
(26, 11),
component="sbin/slurmd",
reason="Ticket 25180: node MaxDrainedTres added in 26.11",
)
# Use a larger MaxNodeCount than the 4-node minimum so that static nodes
# from previous test modules (several call require_nodes(4,...)) do not
# exhaust the limit before dynamic nodes can register.
atf.require_config_parameter("MaxNodeCount", 16)
atf.require_config_parameter("TreeWidth", 65533)
atf.require_config_parameter_includes("SlurmctldParameters", "cloud_reg_addrs")
atf.require_config_parameter("SelectType", "select/cons_tres")
atf.require_config_parameter("SelectTypeParameters", "CR_CPU")
atf.require_config_parameter_includes("GresTypes", "bandwidth")
atf.require_slurm_running()
@pytest.fixture
def bandwidth_dynamic_nodes(dynamic_slurmds):
"""Start the four bandwidth:lustre dynamic nodes on a clean cluster.
Yields a callable returning the (skip, a, good, b) node names.
``node_a_extra_conf`` appends per-node configuration (e.g. a
MaxDrainedTres limit) to DYN_NODE only.
"""
def _start(node_a_extra_conf=""):
atf.require_config_file("gres.conf", BW_GRES_CONF)
atf.restart_slurm(clean=True, quiet=True)
names = []
for node_name, port, extra_conf in (
(DYN_NODE_SKIP, PORT_SKIP, ""),
(DYN_NODE, PORT_A, node_a_extra_conf),
(DYN_NODE_GOOD, PORT_GOOD, ""),
(DYN_NODE_B, PORT_B, ""),
):
conf = f"Port={port} Gres=bandwidth:lustre:4"
if extra_conf:
conf += f" {extra_conf}"
names.append(dynamic_slurmds(node_name, conf))
return tuple(names)
return _start
def test_count_only_gres_multinode_symmetric_drain_blocks_drained_pair(
bandwidth_dynamic_nodes,
):
"""Symmetric bandwidth drain on the NODE_A/NODE_B pair blocks jobs on that pair.
Four nodes are registered: DYN_NODE_SKIP (index 0, bias guard), DYN_NODE
(NODE_A, index 1), DYN_NODE_GOOD (index 2, healthy between), DYN_NODE_B
(NODE_B, index 3). Each has 4 bandwidth:lustre TRES units.
Draining all 4 TRES units on NODE_A and NODE_B leaves 0 available on each.
A job requiring 1 bandwidth:lustre per task (1 task per node) pinned to
the discontinuous pair (node100, node102) stays PENDING; the identical
job pinned to the healthy pair (node99, node101) runs.
"""
node_skip, node_a, node_good, node_b = bandwidth_dynamic_nodes()
for node in (node_a, node_b):
result = atf.update_tres_drain(
node, "gres/bandwidth:lustre", "Count=4", reason="sym-drain"
)
assert result["exit_code"] == 0, f"drain on {node}: {result}"
assert atf.wait_for_node_state(
node, "DEGRADED"
), f"{node} should be DEGRADED after bandwidth drain"
job_drained = atf.submit_job_sbatch(
f"--nodes=2 -w {node_a},{node_b}"
f" --gres=bandwidth:lustre:1 --wrap='sleep infinity'",
fatal=True,
)
assert atf.job_stays_pending(
job_drained
), "job on drained pair must stay PENDING: both nodes have 0 bandwidth"
job_healthy = atf.submit_job_sbatch(
f"--nodes=2 -w {node_skip},{node_good}"
f" --gres=bandwidth:lustre:1 --wrap='sleep infinity'",
fatal=True,
)
assert atf.wait_for_job_state(
job_healthy, "RUNNING"
), "job on healthy pair must run: node99 and node101 have 4 bandwidth each"
def test_count_only_gres_multinode_fully_drained_node_excluded(
bandwidth_dynamic_nodes,
):
"""A fully-drained node cannot satisfy per-task bandwidth in a spanning job.
NODE_A's bandwidth is drained to 0; NODE_B is healthy (4 TRES units). A
2-node job pinned to the discontinuous pair (node100, node102) stays
PENDING because node100 cannot supply even 1 TRES unit. Resuming NODE_A's
drain makes the pair viable and the job starts.
"""
_, node_a, _, node_b = bandwidth_dynamic_nodes()
result = atf.update_tres_drain(
node_a, "gres/bandwidth:lustre", "Count=4", reason="full-drain"
)
assert result["exit_code"] == 0, result
assert atf.wait_for_node_state(
node_a, "DEGRADED"
), f"{node_a} should be DEGRADED after bandwidth drain"
assert "DEGRADED" not in (
atf.get_node_parameter(node_b, "state") or ""
), f"{node_b} must not be DEGRADED -- only {node_a} was drained"
job_id = atf.submit_job_sbatch(
f"--nodes=2 -w {node_a},{node_b}"
f" --gres=bandwidth:lustre:1 --wrap='sleep infinity'",
fatal=True,
)
assert atf.job_stays_pending(
job_id
), f"job must stay PENDING: {node_a} has 0 bandwidth"
result = atf.update_tres_drain(
node_a, "gres/bandwidth:lustre", "Count=4", action="Resume"
)
assert result["exit_code"] == 0, result
assert atf.wait_for_job_state(
job_id, "RUNNING"
), "job should start once NODE_A bandwidth drain is resumed"
def test_count_only_gres_exclusive_spanning_job_blocked_by_drain(
bandwidth_dynamic_nodes,
):
"""Exclusive spanning job blocked when both nodes have all bandwidth drained.
Combines --exclusive with per-node count-only GRES drain. The single-node
GPU test (test_190_7::test_exclusive_whole_node_excludes_drained_gpu)
covers a drained device; this test covers a count-only drain with the same
exclusive-spanning-job structure.
"""
_, node_a, _, node_b = bandwidth_dynamic_nodes()
for node in (node_a, node_b):
result = atf.update_tres_drain(
node, "gres/bandwidth:lustre", "Count=4", reason="excl-drain"
)
assert result["exit_code"] == 0, f"drain on {node}: {result}"
assert atf.wait_for_node_state(
node, "DEGRADED"
), f"{node} should be DEGRADED after bandwidth drain"
job_id = atf.submit_job_sbatch(
f"--exclusive --nodes=2 -w {node_a},{node_b}"
f" --gres=bandwidth:lustre:1 --wrap='sleep infinity'",
fatal=True,
)
assert atf.job_stays_pending(
job_id
), "exclusive spanning job must stay PENDING: both nodes have 0 bandwidth"
for node in (node_a, node_b):
result = atf.update_tres_drain(
node, "gres/bandwidth:lustre", "Count=4", action="Resume"
)
assert result["exit_code"] == 0, f"resume on {node}: {result}"
assert atf.wait_for_job_state(
job_id, "RUNNING"
), "exclusive spanning job should run once bandwidth drain is resumed on both nodes"
def test_count_only_gres_multinode_running_job_not_interrupted(
bandwidth_dynamic_nodes,
):
"""Draining bandwidth on one node mid-flight does not interrupt a running spanning job.
Mirror of test_190_7::test_gres_drain_allows_allocated_gpu for count-only GRES.
A spanning job that holds bandwidth from both nodes continues running after
an admin drain is applied to NODE_A.
"""
_, node_a, _, node_b = bandwidth_dynamic_nodes()
job_id = atf.submit_job_sbatch(
f"--nodes=2 -w {node_a},{node_b} --gres=bandwidth:lustre:1 --wrap='sleep infinity'",
fatal=True,
)
assert atf.wait_for_job_state(
job_id, "RUNNING"
), "spanning bandwidth job should reach RUNNING before drain"
# Drain bandwidth on NODE_A while the job is running.
result = atf.update_tres_drain(
node_a, "gres/bandwidth:lustre", "Count=2", reason="mid-run"
)
assert result["exit_code"] == 0, result
assert atf.wait_for_node_state(
node_a, "DEGRADED"
), f"{node_a} should be DEGRADED after mid-run drain"
job_state = atf.get_job_parameter(job_id, "JobState", quiet=True)
assert (
job_state == "RUNNING"
), f"spanning job must still be RUNNING after draining {node_a}: got {job_state!r}"
def test_count_only_gres_multinode_partial_drain_per_node(bandwidth_dynamic_nodes):
"""Asymmetric drain amounts on NODE_A and NODE_B are tracked independently.
NODE_A: drain 2 of 4 -> 2 remain available.
NODE_B: drain 3 of 4 -> 1 remains available.
A job needing 2 per node stays PENDING (NODE_B has only 1).
Partially resuming 2 on NODE_B (-> 3 available) lets the job start while
NODE_B stays DEGRADED (1 still drained).
"""
_, node_a, _, node_b = bandwidth_dynamic_nodes()
for node, cnt in ((node_a, 2), (node_b, 3)):
result = atf.update_tres_drain(
node, "gres/bandwidth:lustre", f"Count={cnt}", reason="partial"
)
assert result["exit_code"] == 0, f"drain on {node}: {result}"
assert atf.wait_for_node_state(node, "DEGRADED"), f"{node} should be DEGRADED"
# 2 per node: NODE_A has 2 (OK), NODE_B has 1 (insufficient) -> PENDING.
job_id = atf.submit_job_sbatch(
f"--nodes=2 -w {node_a},{node_b} --gres=bandwidth:lustre:2 --wrap='sleep infinity'",
fatal=True,
)
assert atf.job_stays_pending(
job_id
), "job must stay PENDING: NODE_B has only 1 bandwidth available (needs 2)"
# Resume 2 on NODE_B: 3 now available; NODE_B still DEGRADED (1 drained).
result = atf.update_tres_drain(
node_b, "gres/bandwidth:lustre", "Count=2", action="Resume"
)
assert result["exit_code"] == 0, result
assert (
atf.get_node_tres_drained(node_b) == "bandwidth:lustre:1(partial)"
), "NODE_B should still have 1 TRES unit drained"
assert atf.wait_for_job_state(
job_id, "RUNNING"
), "job should start: NODE_A has 2 available, NODE_B now has 3 available"
def test_count_only_gres_multinode_persists_across_slurmctld_restart(
bandwidth_dynamic_nodes,
):
"""Bandwidth drain on both nodes survives a non-clean slurmctld restart.
After restart the dynamic slurmds re-register; the controller restores their
drain state from its state files, leaving both nodes DEGRADED.
"""
_, node_a, _, node_b = bandwidth_dynamic_nodes()
expected = {}
for node, cnt in ((node_a, 2), (node_b, 3)):
result = atf.update_tres_drain(
node, "gres/bandwidth:lustre", f"Count={cnt}", reason="persist"
)
assert result["exit_code"] == 0, f"drain on {node}: {result}"
expected[node] = f"bandwidth:lustre:{cnt}(persist)"
for node in (node_a, node_b):
for _ in atf.timer(fatal=True):
if atf.get_node_tres_drained(node) == expected[node]:
break
atf.restart_slurmctld(clean=False, quiet=True)
for node in (node_a, node_b):
for _ in atf.timer(fatal=True):
if atf.get_node_tres_drained(node) == expected[node]:
break
assert atf.wait_for_node_state(
node, "DEGRADED"
), f"{node} should remain DEGRADED after controller restart"
def test_count_only_gres_multinode_over_capacity_capped(bandwidth_dynamic_nodes):
"""Draining more than a node's total bandwidth is accepted and capped at the total.
Requesting CNT:99 (>>4) on both nodes is accepted; the effective drain is
capped at 4, leaving 0 available. Resuming with the same over-limit amount
clears the drain on both nodes.
"""
_, node_a, _, node_b = bandwidth_dynamic_nodes()
for node in (node_a, node_b):
result = atf.update_tres_drain(
node, "gres/bandwidth:lustre", "Count=99", reason="over-cap"
)
assert (
result["exit_code"] == 0
), f"over-capacity drain on {node} should be accepted"
assert atf.wait_for_node_state(
node, "DEGRADED"
), f"{node} should be DEGRADED after over-capacity drain"
# Drain is capped at 4 -> 0 available; any bandwidth job on the pair is PENDING.
job_id = atf.submit_job_sbatch(
f"--nodes=2 -w {node_a},{node_b} --gres=bandwidth:lustre:1 --wrap='sleep infinity'",
fatal=True,
)
assert atf.job_stays_pending(
job_id
), "job must stay PENDING: over-capacity drain leaves 0 bandwidth on each node"
# Resuming the same over-limit amount clears the drain on both nodes.
for node in (node_a, node_b):
result = atf.update_tres_drain(
node, "gres/bandwidth:lustre", "Count=99", action="Resume"
)
assert result["exit_code"] == 0, f"resume on {node}: {result}"
assert atf.wait_for_job_state(
job_id, "RUNNING"
), "job should start once over-capacity drains are cleared on both nodes"
def test_count_only_gres_multinode_max_drained_tres_triggers_drain(
bandwidth_dynamic_nodes,
):
"""MaxDrainedTres triggers whole-node DRAIN on NODE_A independently of NODE_B.
NODE_A is started with MaxDrainedTres=gres/bandwidth:lustre:2. Draining 3
TRES units (> 2) exceeds the limit and transitions NODE_A to DRAIN. NODE_B
has no limit; draining 3 TRES units leaves it DEGRADED only.
"""
_, node_a, _, node_b = bandwidth_dynamic_nodes(
node_a_extra_conf="MaxDrainedTres=gres/bandwidth:lustre:2"
)
# Drain 3 on NODE_A (> MaxDrainedTres of 2) -> triggers whole-node DRAIN.
result = atf.update_tres_drain(
node_a, "gres/bandwidth:lustre", "Count=3", reason="over-limit"
)
assert result["exit_code"] == 0, result
assert atf.wait_for_node_state(
node_a, "DRAIN"
), f"{node_a} must enter DRAIN when drain (3) exceeds MaxDrainedTres (2)"
assert (
atf.get_node_parameter(node_a, "reason")
== "bandwidth:lustre has 3 drained, exceeding MaxDrainedTres limit of 2"
), atf.get_node_parameter(node_a, "reason")
# NODE_B has no MaxDrainedTres; drain 3 -> DEGRADED only.
result = atf.update_tres_drain(
node_b, "gres/bandwidth:lustre", "Count=3", reason="no-limit"
)
assert result["exit_code"] == 0, result
assert atf.wait_for_node_state(
node_b, "DEGRADED"
), f"{node_b} should be DEGRADED (no MaxDrainedTres limit)"
assert "DRAIN" not in (
atf.get_node_parameter(node_b, "state") or ""
), f"{node_b} must not enter DRAIN -- it has no MaxDrainedTres limit"
def test_count_only_gres_multinode_admin_drain_preserved_across_slurmd_rereg(
bandwidth_dynamic_nodes,
):
"""Admin bandwidth drain survives killing and restarting a node's slurmd.
The drain record lives in slurmctld state. When a dynamic slurmd is killed
and re-registers, the controller re-applies the stored drain so the node
returns DEGRADED with the same DrainedTRES as before. NODE_B (not
restarted) must remain undisturbed throughout.
"""
_, node_a, _, node_b = bandwidth_dynamic_nodes()
expected = {}
for node, cnt in ((node_a, 2), (node_b, 3)):
result = atf.update_tres_drain(
node, "gres/bandwidth:lustre", f"Count={cnt}", reason="rereg-test"
)
assert result["exit_code"] == 0, f"drain on {node}: {result}"
expected[node] = f"bandwidth:lustre:{cnt}(rereg-test)"
for node in (node_a, node_b):
for _ in atf.timer(fatal=True):
if atf.get_node_tres_drained(node) == expected[node]:
break
# Stop NODE_A's slurmd, keeping the controller's node record so the restart
# is a true re-registration against the stored drain state.
atf.stop_slurmd(node_a, quiet=True)
# Re-register NODE_A with the same configuration.
atf.start_dynamic_slurmd(
node_a,
extra_conf=f"Port={PORT_A} Gres=bandwidth:lustre:4",
)
# Drain must be preserved on NODE_A after re-registration.
for _ in atf.timer(fatal=True):
if atf.get_node_tres_drained(node_a) == expected[node_a]:
break
assert "DEGRADED" in (
atf.get_node_parameter(node_a, "state") or ""
), f"{node_a} must be DEGRADED after re-registration (drain preserved)"
# NODE_B was not touched; its drain must also be intact.
for _ in atf.timer(fatal=True):
if atf.get_node_tres_drained(node_b) == expected[node_b]:
break
########################################################################
# Single-node count-only GRES drain/resume mechanics.
#
# These use a per-test gres.conf on DYN_NODE only (distinct type/count
# layouts), not the shared node[99-102] config. They cover the count-only
# drain state machine (resume, typed drains, MaxDrainedTres, edge cases)
# rather than multinode scheduling.
########################################################################
def _single_node(dynamic_slurmds, gres_conf, extra_conf):
"""Write a single-node gres.conf, restart Slurm and start DYN_NODE."""
atf.require_config_file("gres.conf", gres_conf)
atf.restart_slurm(clean=True, quiet=True)
return dynamic_slurmds(DYN_NODE, extra_conf)
def test_gres_drain_all_count_only_gres(dynamic_slurmds):
"""Drain all count-only GRES after a prior partial drain."""
node = _single_node(
dynamic_slurmds,
f"NodeName={DYN_NODE} AutoDetect=off "
f"Name=bandwidth Type=lustre Count=4 Flags=CountOnly\n",
"Gres=bandwidth:lustre:4",
)
result = atf.update_tres_drain(node, "gres/bandwidth", "Count=1", reason="first")
assert result["exit_code"] == 0, "command should succeed"
assert re.search(
r"DrainedTRES=.*bandwidth:lustre:1\(first\)", atf.get_node_detail(node)
), "DrainedTRES should show the drain"
result = atf.update_tres_drain(node, "gres/bandwidth", "Count=ALL", reason="all")
assert result["exit_code"] == 0, "command should succeed"
detail = atf.get_node_detail(node)
assert re.search(r"bandwidth:lustre:4\(all\)", detail), detail
assert "(first)" not in detail, detail
def test_count_only_gres_resume(dynamic_slurmds):
"""A resume clears a count-only GRES drain (no device indexes)."""
node = _single_node(
dynamic_slurmds,
f"NodeName={DYN_NODE} AutoDetect=off "
f"Name=bandwidth Type=lustre Count=4 Flags=CountOnly\n",
"Gres=bandwidth:lustre:4",
)
result = atf.update_tres_drain(node, "gres/bandwidth", "Count=2", reason="co")
assert result["exit_code"] == 0, result
assert re.search(
r"DrainedTRES=.*bandwidth:lustre:2\(co\)", atf.get_node_detail(node)
), "DrainedTRES should show the drain"
assert "DEGRADED" in (
atf.get_node_parameter(node, "state") or ""
), "node should be DEGRADED"
result = atf.update_tres_drain(node, "gres/bandwidth", "Count=ALL", action="Resume")
assert result["exit_code"] == 0, result
assert atf.wait_for_node_state(node, "DEGRADED", reverse=True), (
"node still DEGRADED after count-only resume: "
f"{atf.get_node_parameter(node, 'state')!r}"
)
assert atf.get_node_tres_drained(node) is None, atf.get_node_detail(node)
def test_count_only_gres_partial_resume(dynamic_slurmds):
"""A resume subtracts from a count-only GRES drain by count."""
node = _single_node(
dynamic_slurmds,
f"NodeName={DYN_NODE} AutoDetect=off "
f"Name=bandwidth Type=lustre Count=4 Flags=CountOnly\n",
"Gres=bandwidth:lustre:4",
)
result = atf.update_tres_drain(node, "gres/bandwidth", "Count=3", reason="co")
assert result["exit_code"] == 0, result
assert re.search(
r"DrainedTRES=.*bandwidth:lustre:3\(co\)", atf.get_node_detail(node)
), "DrainedTRES should show the drain"
assert "DEGRADED" in (
atf.get_node_parameter(node, "state") or ""
), "node should be DEGRADED"
result = atf.update_tres_drain(node, "gres/bandwidth", "Count=1", action="Resume")
assert result["exit_code"] == 0, result
assert re.search(
r"DrainedTRES=.*bandwidth:lustre:2\(co\)", atf.get_node_detail(node)
), "DrainedTRES should show the drain"
assert "DEGRADED" in (
atf.get_node_parameter(node, "state") or ""
), "node should be DEGRADED"
result = atf.update_tres_drain(node, "gres/bandwidth", "Count=2", action="Resume")
assert result["exit_code"] == 0, result
assert atf.wait_for_node_state(node, "DEGRADED", reverse=True), (
"node still DEGRADED after partial count-only resume: "
f"{atf.get_node_parameter(node, 'state')!r}"
)
assert atf.get_node_tres_drained(node) is None, atf.get_node_detail(node)
def test_count_only_gres_set_drain_is_idempotent(dynamic_slurmds):
"""Action=SetDrain names the drained count, so repeating it changes nothing.
A count is where SetDrain and Drain differ: Drain adds to what is already
drained, while SetDrain replaces it. That makes SetDrain safe to re-run from
configuration management, and lets one request lower a drain as well as
raise it.
"""
node = _single_node(
dynamic_slurmds,
f"NodeName={DYN_NODE} AutoDetect=off "
f"Name=bandwidth Type=lustre Count=4 Flags=CountOnly\n",
"Gres=bandwidth:lustre:4",
)
result = atf.update_tres_drain(
node, "gres/bandwidth", "Count=2", action="SetDrain", reason="set2"
)
assert result["exit_code"] == 0, result
assert re.search(
r"DrainedTRES=.*bandwidth:lustre:2\(set2\)", atf.get_node_detail(node)
), atf.get_node_detail(node)
assert "DEGRADED" in (
atf.get_node_parameter(node, "state") or ""
), "node should be DEGRADED"
# Repeating the same request is a no-op.
before = atf.get_node_tres_drained(node)
result = atf.update_tres_drain(
node, "gres/bandwidth", "Count=2", action="SetDrain", reason="set2"
)
assert result["exit_code"] == 0, result
assert atf.get_node_tres_drained(node) == before, atf.get_node_detail(node)
# Action=Drain is cumulative, which is what makes the above meaningful.
result = atf.update_tres_drain(node, "gres/bandwidth", "Count=1", reason="add1")
assert result["exit_code"] == 0, result
assert re.search(
r"DrainedTRES=.*bandwidth:lustre:3\(add1\)", atf.get_node_detail(node)
), atf.get_node_detail(node)
# SetDrain can lower the drained count again, which Drain cannot.
result = atf.update_tres_drain(
node, "gres/bandwidth", "Count=1", action="SetDrain", reason="set1"
)
assert result["exit_code"] == 0, result
assert re.search(
r"DrainedTRES=.*bandwidth:lustre:1\(set1\)", atf.get_node_detail(node)
), atf.get_node_detail(node)
# Count=0 names an empty drained set, resuming everything.
result = atf.update_tres_drain(
node, "gres/bandwidth", "Count=0", action="SetDrain", reason="none"
)
assert result["exit_code"] == 0, result
assert atf.wait_for_node_state(node, "DEGRADED", reverse=True), (
"node still DEGRADED after SetDrain Count=0: "
f"{atf.get_node_parameter(node, 'state')!r}"
)
assert atf.get_node_tres_drained(node) is None, atf.get_node_detail(node)
def test_count_only_gres_set_drain_is_scoped_to_the_named_type(dynamic_slurmds):
"""SetDrain of one type leaves a different type and a different TRES.
SetDrain resumes whatever of the named TRES it does not list. That must
not reach a different type of the same GRES, or a different TRES.
"""
node = _single_node(
dynamic_slurmds,
f"NodeName={DYN_NODE} AutoDetect=off "
f"Name=bandwidth Type=alpha Count=2 Flags=CountOnly\n"
f"NodeName={DYN_NODE} AutoDetect=off "
f"Name=bandwidth Type=zeta Count=2 Flags=CountOnly\n",
"Gres=bandwidth:alpha:2,bandwidth:zeta:2",
)
assert (
atf.update_tres_drain(node, "gres/bandwidth:alpha", "Count=2", reason="alpha")[
"exit_code"
]
== 0
), "drain update should succeed"
assert (
atf.update_tres_drain(node, "gres/bandwidth:zeta", "Count=2", reason="zeta")[
"exit_code"
]
== 0
), "drain update should succeed"
assert (
atf.update_tres_drain(node, "cpu", "Index=0", reason="cpu0")["exit_code"] == 0
), "drain update should succeed"
result = atf.update_tres_drain(
node,
"gres/bandwidth:alpha",
"Count=1",
action="SetDrain",
reason="alpha_set",
)
assert result["exit_code"] == 0, result
drain = atf.get_node_tres_drained(node) or ""
assert re.search(r"bandwidth:alpha:1\(alpha_set\)", drain), drain
assert re.search(r"bandwidth:zeta:2\(zeta\)", drain), drain
assert re.search(r"cpu\(IDX:0\)\(cpu0\)", drain), drain
def _bandwidth_drained(node):
"""Maps each drained bandwidth type on the node to (count, reason)."""
drain = atf.get_node_tres_drained(node) or ""
return {
gres_type: (int(count), reason)
for gres_type, count, reason in re.findall(
r"bandwidth:(\w+):(\d+)\(([^)]*)\)", drain
)
}
def test_count_only_gres_type_aware_drain(dynamic_slurmds):
"""Count-only drains retain typed counts and a per-type reason.
Each type keeps the reason it was drained with, so draining one type later
does not relabel a type that was drained earlier for something else.
"""
node = _single_node(
dynamic_slurmds,
f"NodeName={DYN_NODE} AutoDetect=off "
f"Name=bandwidth Type=zeta Count=2 Flags=CountOnly\n"
f"NodeName={DYN_NODE} AutoDetect=off "
f"Name=bandwidth Type=alpha Count=2 Flags=CountOnly\n",
"Gres=bandwidth:zeta:2,bandwidth:alpha:2",
)
result = atf.update_tres_drain(node, "gres/bandwidth", "Count=3", reason="first")
assert result["exit_code"] == 0, result
# Which type an untyped count takes first is not documented. Only that the
# count is drained, one type fully and the other partly, with the reason.
detail = atf.get_node_detail(node)
drained = _bandwidth_drained(node)
assert sorted(count for count, _ in drained.values()) == [1, 2], detail
assert all(reason == "first" for _, reason in drained.values()), detail
full, partial = sorted(drained, key=lambda t: drained[t][0], reverse=True)
# The fully drained type should keep its job pending, not rejected,
# because the type is still configured.
pending_job = atf.submit_job_sbatch(
f"-w {node} --gres=bandwidth:{full}:1 --wrap true", fatal=True
)
assert atf.job_stays_pending(pending_job), "drained type must keep its job pending"
usable_job = atf.submit_job_sbatch(
f"-w {node} --gres=bandwidth:{partial}:1 --wrap true", fatal=True
)
atf.wait_for_job_state(usable_job, "DONE", fatal=True, timeout=60)
atf.cancel_jobs([pending_job])
result = atf.update_tres_drain(
node, f"gres/bandwidth:{partial}", "Count=1", reason="second"
)
assert result["exit_code"] == 0, result
detail = atf.get_node_detail(node)
# Only one type was named the second time, so the other keeps its reason.
assert re.search(rf"bandwidth:{full}:2\(first\)", detail), detail
assert re.search(rf"bandwidth:{partial}:2\(second\)", detail), detail
result = atf.update_tres_drain(
node, f"gres/bandwidth:{partial}", "Count=1", action="Resume"
)
assert result["exit_code"] == 0, result
detail = atf.get_node_detail(node)
assert re.search(rf"bandwidth:{partial}:1\(second\)", detail), detail
# Again the type resumed first is not documented, only how many are left
# and that whatever stays drained keeps the reason it was drained with.
result = atf.update_tres_drain(node, "gres/bandwidth", "Count=2", action="Resume")
assert result["exit_code"] == 0, result
reasons = {full: "first", partial: "second"}
drain = atf.get_node_tres_drained(node) or ""
drained = _bandwidth_drained(node)
assert sum(count for count, _ in drained.values()) == 1, drain
assert all(reason == reasons[t] for t, (_, reason) in drained.items()), drain
def test_count_only_gres_typed_max_drained_tres(dynamic_slurmds):
"""A typed MaxDrainedTres limit applies to its count-only GRES type."""
node = _single_node(
dynamic_slurmds,
f"NodeName={DYN_NODE} AutoDetect=off "
f"Name=bandwidth Type=alpha Count=3 Flags=CountOnly\n"
f"NodeName={DYN_NODE} AutoDetect=off "
f"Name=bandwidth Type=zeta Count=3 Flags=CountOnly\n",
(
"Gres=bandwidth:alpha:3,bandwidth:zeta:3 "
"MaxDrainedTres=gres/bandwidth:alpha:1"
),
)
result = atf.update_tres_drain(
node, "gres/bandwidth:alpha", "Count=2", reason="typed_limit"
)
assert result["exit_code"] == 0, result
assert atf.wait_for_node_state(
node, "DRAIN"
), f"node did not drain after typed limit was exceeded: {atf.get_node_detail(node)!r}"
assert (
atf.get_node_parameter(node, "reason")
== "bandwidth:alpha has 2 drained, exceeding MaxDrainedTres limit of 1"
), atf.get_node_parameter(node, "reason")
def test_count_only_gres_max_drained_tres_count_suffix(dynamic_slurmds):
"""A gres MaxDrainedTres count with a K suffix is that many times 1024.
Draining exactly the limit keeps the node DEGRADED. One more drains it,
and the reason gives the limit as the multiplied count.
"""
node = _single_node(
dynamic_slurmds,
f"NodeName={DYN_NODE} AutoDetect=off Name=bandwidth Type=lustre Count=4096 Flags=CountOnly\n",
"Gres=bandwidth:lustre:4096 MaxDrainedTres=gres/bandwidth:lustre:1K",
)
atf.update_tres_drain(
node, "gres/bandwidth:lustre", "Count=1024", reason="at_limit", fatal=True
)
assert atf.wait_for_node_state(
node, "DEGRADED"
), f"node should be DEGRADED at the limit: {atf.get_node_detail(node)!r}"
assert "DRAIN" not in (
atf.get_node_parameter(node, "state") or ""
), f"draining 1024 must not exceed a 1K limit: {atf.get_node_detail(node)!r}"
atf.update_tres_drain(
node, "gres/bandwidth:lustre", "Count=1", reason="over_limit", fatal=True
)
assert atf.wait_for_node_state(
node, "DRAIN"
), f"node did not drain over the 1K limit: {atf.get_node_detail(node)!r}"
assert (
atf.get_node_parameter(node, "reason")
== "bandwidth:lustre has 1025 drained, exceeding MaxDrainedTres limit of 1024"
), atf.get_node_parameter(node, "reason")
def test_show_drainedtres_lists_a_count_only_drain(dynamic_slurmds):
"""show drainedtres reports a count-only drain by Count, not Index."""
node = _single_node(
dynamic_slurmds,
f"NodeName={DYN_NODE} AutoDetect=off "
f"Name=bandwidth Type=alpha Count=4 Flags=CountOnly\n",
"Gres=bandwidth:alpha:4",
)
atf.update_tres_drain(
node, "gres/bandwidth:alpha", "Count=2", reason="bw_count", fatal=True
)
output = atf.run_command_output(
f"scontrol show drainedtres NodeName={node}",
user=atf.properties["slurm-user"],
fatal=True,
quiet=True,
)
lines = [line for line in output.splitlines() if "Reason=bw_count" in line]
assert len(lines) == 1, output
assert "TresName=gres/bandwidth:alpha" in lines[0], output
assert "Count=2" in lines[0], output
assert "Index=" not in lines[0] and "Uuid=" not in lines[0], output
def test_count_only_gres_drain_edge_cases(dynamic_slurmds):
"""Count-only drain validation, all, and oversized counts are deterministic."""
node = _single_node(
dynamic_slurmds,
f"NodeName={DYN_NODE} AutoDetect=off "
f"Name=bandwidth Type=alpha Count=2 Flags=CountOnly\n"
f"NodeName={DYN_NODE} AutoDetect=off "
f"Name=bandwidth Type=zeta Count=2 Flags=CountOnly\n",
"Gres=bandwidth:alpha:2,bandwidth:zeta:2",
)
for tres, selector, error in (
# unknown type
(
"gres/bandwidth:omega",
"Count=1",
"Invalid GRES specification (with and without type identification)",
),
# a count-only GRES has no device identity to index
("gres/bandwidth", "Index=0", "Invalid generic resource (gres) specification"),
# no selector at all
("gres/bandwidth", "", "TresName requires Index, Uuid or Count"),
):
result = atf.update_tres_drain(node, tres, selector, reason="invalid")
assert result["exit_code"] != 0, result
assert error in result["stderr"], result
assert atf.get_node_tres_drained(node) is None, atf.get_node_detail(node)
result = atf.update_tres_drain(node, "gres/bandwidth", "Count=0", reason="zero")
assert result["exit_code"] == 0, result
assert atf.get_node_tres_drained(node) is None, atf.get_node_detail(node)
result = atf.update_tres_drain(
node, "gres/bandwidth:alpha", "Count=ALL", reason="alpha_all"
)
assert result["exit_code"] == 0, result
result = atf.update_tres_drain(
node, "gres/bandwidth:zeta", "Count=99", reason="zeta_cap"
)
assert result["exit_code"] == 0, result
detail = atf.get_node_detail(node)
# Each type reports the reason it was actually drained with.
assert re.search(r"bandwidth:alpha:2\(alpha_all\)", detail), detail
assert re.search(r"bandwidth:zeta:2\(zeta_cap\)", detail), detail
result = atf.update_tres_drain(
node, "gres/bandwidth:alpha", "Count=ALL", action="Resume"
)
assert result["exit_code"] == 0, result
result = atf.update_tres_drain(
node, "gres/bandwidth:zeta", "Count=99", action="Resume"
)
assert result["exit_code"] == 0, result
assert atf.get_node_tres_drained(node) is None, atf.get_node_detail(node)