blob: d97a4b9011b9ea0136a5eae0adfbd17bda05ae39 [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""The two node updates that change what is drained on a node.
Two halves, each under its own banner below. First "scontrol update
TresName=...", which drains and resumes a selection of TRES -- what the request
form accepts and refuses. Then "scontrol update NodeName=... State=...", which
resumes the node and, with it, everything drained on it.
The two are the whole administrative surface for drained TRES, and they meet in
one place: State=RESUME is the bulk form of Action=Resume, and
State=RESUME_KEEP_TRES is the way to opt out of it. Keeping them together is
what lets the State= half assert it resumes GRES, CPU and memory in one step,
which no per-TRES module can express.
What is deliberately elsewhere: what a drain then does to scheduling (the
per-TRES modules), the auto-drained absent device (test_190_13), and the
accounting interval an Action=Resume closes (test_190_9). The interval a
State=RESUME closes is checked here, with the resume itself.
The TresName= forms under test are the shared ones:
- a TRES can be named exactly one way, so a second selector is
ambiguous and is refused rather than resolved by precedence
- Action is required; TresName alone must not fall back to an assumed one
- a node-local TRES must say which node it lives on
- a device-tracked (topology) GRES identifies its TRES individually,
so a bare count cannot name one
- an index outside the node's device set is refused
- an Index list that is not well formed is refused
- Reason is required
The per-TRES parse paths keep their own coverage beside the TRES they belong to
(Count= for cpu/mem in test_190_4 and test_190_5, count-only GRES in
test_190_6); what lives here is the shared front end they all pass through.
GPUs are used as the carrier because a device-tracked GRES exercises both the
index and the count paths through it.
"""
import re
import pytest
import atf
pytestmark = pytest.mark.slow
# Node names need to be in the form of nodeNN for the s2n variant.
DYN_NODE = "node100"
@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="bin/sacctmgr",
reason="Ticket 25180: TresReference event format field added in 26.11",
)
atf.require_version(
(26, 11),
component="sbin/slurmd",
reason="Ticket 25180: GRES device slots keyed by UUID added in 26.11",
)
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", "gpu")
atf.require_tty(0)
atf.require_tty(1)
atf.require_tty(2)
atf.require_tty(3)
# The State= half asserts on the event RESUME_KEEP_TRES leaves open, and a
# drained GPU reaches the event table only if accounting tracks it.
atf.require_accounting()
atf.require_config_parameter_includes("AccountingStorageTRES", "gres/gpu")
atf.require_config_file("gres.conf", "Autodetect=full\n")
atf.require_slurm_running()
def _gpu_node(dynamic_slurmds, count=4):
"""Start the dynamic node with `count` untyped-UUID a100 GPUs."""
atf.require_config_file(
"fake_gpus.conf", atf.fake_gpus_conf(*((i, "(null)") for i in range(count)))
)
atf.restart_slurm(clean=True, quiet=True)
return dynamic_slurmds(DYN_NODE, f"Gres=gpu:a100:{count}")
# Drained well inside anything the node will report, so the request cannot fail
# for naming more memory than the node has.
MEM_DRAIN_MB = 256
# check_node_timers() only runs on the controller's PERIODIC_TIMEOUT tick, so a
# ResumeAfter fires some way after the delay it was given, not at it.
RESUME_AFTER_TIMEOUT = 120
def _set_node_state(node, state, **kwargs):
"""Run "scontrol update NodeName=<node> State=<state>"."""
return atf.run_command(
f"scontrol update NodeName={node} State={state}",
user=atf.properties["slurm-user"],
**kwargs,
)
def _drained(node):
"""The node's DrainedTRES text, or "" when nothing is drained."""
return atf.get_node_tres_drained(node) or ""
def _wait_nothing_drained(node):
"""Poll until the node reports no drained TRES at all."""
for _ in atf.timer(fatal=True):
text = _drained(node)
if text == "":
break
@pytest.fixture
def gpu_node(dynamic_slurmds):
"""A started dynamic node with two a100 GPUs, left clean at teardown."""
node = _gpu_node(dynamic_slurmds, count=2)
yield node
# Whatever the test left behind, put the node back before the next one.
atf.update_tres_drain(node, "gres/gpu", "Index=ALL", action="Resume", quiet=True)
atf.update_tres_drain(node, "cpu", "Index=ALL", action="Resume", quiet=True)
atf.update_tres_drain(node, "mem", "Count=ALL", action="Resume", quiet=True)
_set_node_state(node, "RESUME", quiet=True)
# ---------------------------------------------------------------------------
# The TresName= form: what a drain/resume request accepts and refuses.
# ---------------------------------------------------------------------------
def test_drain_parenthesized_idx_rejected(dynamic_slurmds):
"""A drain rejects a parenthesized index in TresName."""
node = _gpu_node(dynamic_slurmds)
result = atf.run_command(
f"scontrol update 'TresName=gres/gpu(IDX:0)' NodeName={node} "
"Action=Drain Reason=rng",
user=atf.properties["slurm-user"],
)
assert result["exit_code"] != 0, result
assert "TresName requires Index, Uuid or Count" in result["stderr"], result
assert atf.get_node_tres_drained(node) is None, atf.get_node_detail(node)
def test_drain_without_mode_rejected(dynamic_slurmds):
"""TresName without Action is rejected rather than assuming one."""
node = _gpu_node(dynamic_slurmds, count=2)
result = atf.run_command(
f"scontrol update TresName=gres/gpu NodeName={node} Index=0 " f"Reason=nomode",
user=atf.properties["slurm-user"],
)
assert result["exit_code"] != 0, result
assert (
"TresName requires Action=Drain, Resume or SetDrain" in result["stderr"]
), result
assert atf.get_node_tres_drained(node) is None, atf.get_node_detail(node)
assert "DEGRADED" not in (
atf.get_node_parameter(node, "state") or ""
), atf.get_node_detail(node)
@pytest.mark.parametrize("tres", ["gres/gpu", "cpu", "mem"])
def test_node_local_tres_requires_nodename(tres):
"""A node-local TRES has to say which node it lives on.
The requirement is keyed off the TRES rather than being unconditional, so
that cluster-scoped TRES (licenses) can omit NodeName when they land.
"""
result = atf.run_command(
f"scontrol update TresName={tres} Count=1 Action=Drain Reason=nonode",
user=atf.properties["slurm-user"],
)
assert result["exit_code"] != 0, result
assert "requires NodeName" in result["stderr"], result
def test_drain_index_range(dynamic_slurmds):
"""A drain accepts an index range in Index.
The comma inside the index list must stay with its spec, so this also
exercises the top-level splitter for an index range.
"""
node = _gpu_node(dynamic_slurmds)
# No quotes around the value: an index range has no shell metacharacters.
result = atf.update_tres_drain(node, "gres/gpu", "Index=0,2-3", reason="idx")
assert result["exit_code"] == 0, result
detail = atf.get_node_detail(node)
assert re.search(r"DrainedTRES=.*gpu\(IDX:0,2-3\)\(idx\)", detail), detail
@pytest.mark.parametrize(
("selector", "error"),
[
pytest.param("", "TresName requires Index, Uuid or Count", id="bare"),
pytest.param("Count=2", "Invalid generic resource", id="count"),
pytest.param(
"Index=0 Count=1",
"Count cannot be combined with Index or Uuid",
id="count-plus-index",
),
],
)
def test_drain_topo_count_forms_rejected(dynamic_slurmds, selector, error):
"""A topology GRES rejects a drain with no selector and one given as a count.
A count combined with an index is rejected as well.
"""
node = _gpu_node(dynamic_slurmds, count=3)
result = atf.update_tres_drain(node, "gres/gpu", selector, reason="cnt")
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)
def test_drain_out_of_range_index_rejected(dynamic_slurmds):
"""Draining a device index that does not exist is rejected."""
node = _gpu_node(dynamic_slurmds, count=2)
result = atf.update_tres_drain(node, "gres/gpu", "Index=5", reason="oor")
assert result["exit_code"] != 0, result
assert "Invalid generic resource" in result["stderr"], result
assert atf.get_node_tres_drained(node) is None, atf.get_node_detail(node)
def test_drain_malformed_index_list_rejected(gpu_node):
"""An Index list with an empty, open-ended or reversed part is refused.
Each is refused outright rather than read as some other set of devices,
so nothing is drained.
"""
for index in (",1", "1,", "0,,1", "-", "1-", "1-0"):
result = atf.update_tres_drain(
gpu_node, "gres/gpu", f"Index={index}", reason="shape"
)
assert result["exit_code"] != 0, f"Index={index} was accepted: {result}"
assert "Invalid Trackable RESource" in result["stderr"], result
assert atf.get_node_tres_drained(gpu_node) is None, atf.get_node_detail(
gpu_node
)
@pytest.mark.parametrize("action", ["Drain", "SetDrain"])
def test_drain_requires_reason(dynamic_slurmds, action):
"""A drain without a Reason is rejected and changes nothing.
scontrol checks the Reason before it looks at the TRES, so one TRES
covers them all.
"""
node = _gpu_node(dynamic_slurmds, count=2)
result = atf.update_tres_drain(node, "gres/gpu", "Index=0", action=action)
assert result["exit_code"] != 0, result
assert "You must specify a reason when draining TRES" in result["stderr"], result
assert atf.get_node_tres_drained(node) is None, atf.get_node_detail(node)
assert "DEGRADED" not in (
atf.get_node_parameter(node, "state") or ""
), "node should not be DEGRADED"
# ---------------------------------------------------------------------------
# State=RESUME resumes everything.
# ---------------------------------------------------------------------------
def test_resume_clears_gres_drain_on_only_degraded_node(gpu_node):
"""State=RESUME returns a drained GPU to service on a node that is
DEGRADED but was never DRAIN, clears DEGRADED, and closes the event.
This is the node the feature mostly exists for -- one healthy enough to keep
running jobs, with a single bad GPU. Its state is IDLE+DEGRADED, and
RESUME has to be accepted from that state, not only from DRAIN.
Resuming the node resumes its drained TRES with it, so it has to leave the
accounting event closed just as resuming each TRES by hand does -- an
interval left open charges for a GPU that came back.
"""
assert (
atf.update_tres_drain(gpu_node, "gres/gpu", "Index=0", reason="ecc")[
"exit_code"
]
== 0
), "drain update should succeed"
assert atf.wait_for_node_state(
gpu_node, "DEGRADED"
), f"node should be DEGRADED after the GPU drain: {atf.get_node_detail(gpu_node)}"
state = atf.get_node_parameter(gpu_node, "state") or ""
assert "DRAIN" not in state, f"node must not be DRAIN for this test: {state!r}"
for _ in atf.timer(fatal=True):
reason = atf.get_open_node_event_reason(gpu_node)
if reason == "ecc":
break
result = _set_node_state(gpu_node, "RESUME")
assert result["exit_code"] == 0, f"RESUME refused on a DEGRADED-only node: {result}"
_wait_nothing_drained(gpu_node)
assert "DEGRADED" not in (
atf.get_node_parameter(gpu_node, "state") or ""
), f"DEGRADED outlived the resume: {atf.get_node_parameter(gpu_node, 'state')!r}"
for _ in atf.timer(fatal=True):
count = atf.get_open_node_event_count(gpu_node)
if count == 0:
break
def test_resume_clears_every_tres_kind_at_once(gpu_node):
"""One State=RESUME clears GRES, CPU and memory drains together.
Each kind is tracked separately -- GRES on the gres_node_state_t, CPUs in a
bitmap, memory as a count -- so a resume that reached only some of them
would still look right in a single-TRES test.
"""
assert (
atf.update_tres_drain(gpu_node, "gres/gpu", "Index=0", reason="ecc")[
"exit_code"
]
== 0
), "drain update should succeed"
assert (
atf.update_tres_drain(gpu_node, "cpu", "Index=0", reason="cpu_hw")["exit_code"]
== 0
), "drain update should succeed"
assert (
atf.update_tres_drain(
gpu_node, "mem", f"Count={MEM_DRAIN_MB}", reason="bad_dimm"
)["exit_code"]
== 0
), "drain update should succeed"
drained = _drained(gpu_node).lower()
for kind in ("gpu", "cpu", "mem"):
assert kind in drained, f"{kind} drain not reported: {drained!r}"
assert (
_set_node_state(gpu_node, "RESUME")["exit_code"] == 0
), "state update should succeed"
_wait_nothing_drained(gpu_node)
# ---------------------------------------------------------------------------
# State=RESUME_KEEP_TRES resumes only the node.
# ---------------------------------------------------------------------------
def test_resume_keep_tres_preserves_drained_tres(gpu_node):
"""RESUME_KEEP_TRES clears the node's DRAIN but keeps its drained TRES.
Accounting has to follow: the whole-node event closes and the drained-TRES
event opens again in its place, carrying the reason the GPU was drained
with rather than the node's cleared one. Leaving no event open would stop
charging for a GPU that is still gone.
"""
assert (
atf.update_tres_drain(gpu_node, "gres/gpu", "Index=0", reason="ecc")[
"exit_code"
]
== 0
), "drain update should succeed"
assert atf.wait_for_node_state(
gpu_node, "DEGRADED"
), "gpu_node should reach DEGRADED"
before_drained = _drained(gpu_node)
assert (
_set_node_state(gpu_node, "DRAIN Reason=maintenance")["exit_code"] == 0
), "could not drain the node itself"
assert atf.wait_for_node_state(gpu_node, "DRAIN"), "gpu_node should reach DRAIN"
# The whole-node event has to take over first, or finding "ecc" open at
# the end would not show that the drained-TRES event opened again.
for _ in atf.timer(fatal=True):
reason = atf.get_open_node_event_reason(gpu_node)
if reason == "maintenance":
break
assert (
_set_node_state(gpu_node, "RESUME_KEEP_TRES")["exit_code"] == 0
), "state update should succeed"
for _ in atf.timer(fatal=True):
state = atf.get_node_parameter(gpu_node, "state") or ""
if "DRAIN" not in state:
break
assert "DEGRADED" in (
atf.get_node_parameter(gpu_node, "state") or ""
), f"RESUME_KEEP_TRES cleared DEGRADED: {atf.get_node_detail(gpu_node)}"
assert (
_drained(gpu_node) == before_drained
), f"RESUME_KEEP_TRES changed DrainedTRES: {_drained(gpu_node)!r}"
for _ in atf.timer(fatal=True):
reason = atf.get_open_node_event_reason(gpu_node)
if reason == "ecc":
break
assert (
atf.get_open_node_event_count(gpu_node) == 1
), f"expected a single open event: {atf.get_node_events(gpu_node)!r}"
def test_resume_after_timer_resumes_drained_tres(gpu_node):
"""A ResumeAfter timer resumes the node like State=RESUME.
That includes the node's drained TRES, so a GPU drained separately is
resumed along with the node's DRAIN.
"""
assert (
atf.update_tres_drain(gpu_node, "gres/gpu", "Index=0", reason="ecc")[
"exit_code"
]
== 0
), "drain update should succeed"
assert atf.wait_for_node_state(
gpu_node, "DEGRADED"
), "gpu_node should reach DEGRADED"
assert (
_set_node_state(gpu_node, "DRAIN Reason=timed ResumeAfter=1")["exit_code"] == 0
), "state update should succeed"
assert atf.wait_for_node_state(gpu_node, "DRAIN"), "gpu_node should reach DRAIN"
for _ in atf.timer(timeout=RESUME_AFTER_TIMEOUT, fatal=True):
state = atf.get_node_parameter(gpu_node, "state") or ""
if "DRAIN" not in state:
break
assert not _drained(
gpu_node
), f"ResumeAfter left the GPU drained: {_drained(gpu_node)!r}"
assert "DEGRADED" not in atf.get_node_parameter(
gpu_node, "state"
), atf.get_node_detail(gpu_node)
def test_resume_keep_tres_rejected_on_only_degraded_node(gpu_node):
"""RESUME_KEEP_TRES is refused on a node whose only fault is drained TRES.
There is nothing for it to do there, and accepting it would report a state
change that never happened. The admin wanted plain RESUME.
"""
assert (
atf.update_tres_drain(gpu_node, "gres/gpu", "Index=0", reason="ecc")[
"exit_code"
]
== 0
), "drain update should succeed"
assert atf.wait_for_node_state(
gpu_node, "DEGRADED"
), "gpu_node should reach DEGRADED"
result = _set_node_state(gpu_node, "RESUME_KEEP_TRES", quiet=True)
assert (
result["exit_code"] != 0
), f"RESUME_KEEP_TRES should be an invalid transition here: {result}"
assert "Invalid node state transition" in result["stderr"], result
assert (
_drained(gpu_node) != ""
), "the refused request must leave the drained TRES alone"
# ---------------------------------------------------------------------------
# Parsing.
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"state,resumes_tres",
[
("RESU", True),
("RESUME", True),
("RESUME_K", False),
("RESUME_KEEP_TRES", False),
],
)
def test_resume_state_abbreviations(gpu_node, state, resumes_tres):
"""Each name keeps its own meaning however far it is abbreviated.
scontrol matches State= values by prefix, and these two share one, so an
abbreviation of either could silently land in the other's branch.
"""
assert (
atf.update_tres_drain(gpu_node, "gres/gpu", "Index=0", reason="ecc")[
"exit_code"
]
== 0
), "drain update should succeed"
assert atf.wait_for_node_state(
gpu_node, "DEGRADED"
), "gpu_node should reach DEGRADED"
if not resumes_tres:
# RESUME_KEEP_TRES is only a valid transition on a node that has
# something besides drained TRES to resume.
assert (
_set_node_state(gpu_node, "DRAIN Reason=maintenance")["exit_code"] == 0
), "state update should succeed"
assert atf.wait_for_node_state(gpu_node, "DRAIN"), "gpu_node should reach DRAIN"
assert (
_set_node_state(gpu_node, state)["exit_code"] == 0
), "state update should succeed"
if resumes_tres:
_wait_nothing_drained(gpu_node)
else:
assert (
_drained(gpu_node) != ""
), f"State={state} should have left the GPU drained: {_drained(gpu_node)!r}"
def test_resume_underscore_alone_is_rejected(gpu_node):
""" "RESUME_" matches neither name and is refused rather than guessed at."""
result = _set_node_state(gpu_node, "RESUME_", quiet=True)
assert result["exit_code"] != 0, f"'RESUME_' should be invalid input: {result}"
assert "Invalid input: State=RESUME_" in result["stderr"], result
def test_state_degraded_is_rejected(gpu_node):
"""State=DEGRADED is not a state an admin can set."""
result = _set_node_state(gpu_node, "DEGRADED", quiet=True)
assert result["exit_code"] != 0, result
assert "Invalid input: State=DEGRADED" in result["stderr"], result
def _scontrol(command):
return atf.run_command(command, user=atf.properties["slurm-user"], quiet=True)
@pytest.mark.parametrize(
("command", "error"),
[
("scontrol update TresName=cpu bare", "Invalid input: bare"),
(
"scontrol update TresName=cpu TresName=mem NodeName=node1 "
"Index=0 Action=Drain Reason=twice",
"TresName specified more than once",
),
(
"scontrol update TresName=cpu NodeName=node1 Count=abc "
"Action=Drain Reason=badcount",
"Invalid value Count=abc for Count",
),
(
"scontrol update TresName=cpu NodeName=node1 Index=0 "
"Action=Nope Reason=badaction",
"Invalid Action: Nope",
),
(
"scontrol update TresName=cpu NodeName=node1 Index=0 "
"Action=Drain Reason=extra Color=blue",
"Update of this parameter is not supported: Color=blue",
),
(
"scontrol update TresName= NodeName=node1 Index=0 "
"Action=Drain Reason=empty",
"TresName must be specified",
),
(
"scontrol update TresName=cpu NodeName=node1 Index=ALL Count=1 "
"Action=Drain Reason=both",
"ALL already selects everything",
),
(
"scontrol update TresName=gres/gpu NodeName=node1 Index=ALL Uuid=GPU-a Action=Drain Reason=both",
"ALL already selects everything",
),
(
"scontrol update TresName=gres/gpu NodeName=node1 Uuid=ALL Index=0 Action=Drain Reason=both",
"ALL already selects everything",
),
("scontrol show drainedtres bare", "Invalid input: bare"),
("scontrol show drainedtres Foo=1", "Invalid option: Foo=1"),
("scontrol show drainedtres 'NodeName=node['", "hostlist_create error"),
],
)
def test_tres_request_front_end_rejects_bad_input(command, error):
"""scontrol refuses a TRES request before it reaches the controller."""
result = _scontrol(command)
assert result["exit_code"] != 0, result
assert error in result["stderr"], result
@pytest.mark.parametrize("flag", ["-a", "-F"])
def test_show_drainedtres_accepts_listing_flags(flag, gpu_node):
"""-a and -F widen the node query; the listing is the same."""
atf.update_tres_drain(gpu_node, "gres/gpu", "Index=0", reason="flag", fatal=True)
plain = _scontrol("scontrol show drainedtres")
result = _scontrol(f"scontrol {flag} show drainedtres")
assert result["exit_code"] == 0, result
assert f"NodeName={gpu_node} " in result["stdout"], result
assert result["stdout"] == plain["stdout"], result