blob: d43110d2b65494f4f193b6017be24e0d543e79fc [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""Test setting node Extra with scontrol and slurmd for extra_constraints.
With SchedulerParameters=extra_constraints a node's Extra is parsed as JSON
and jobs filter nodes on it with --extra. scontrol rejects malformed Extra and
leaves the node unchanged. slurmd --extra stores any string, but Extra that
slurmctld cannot parse never matches a job.
How long slurmd --extra persists across restarts is covered by test_150_5.
Ticket 18133.
"""
import time
import pytest
import atf
INVALID_EXTRA = "Invalid extra constraints specification"
@pytest.fixture(scope="module", autouse=True)
def setup():
atf.require_config_parameter_includes("SchedulerParameters", "extra_constraints")
atf.require_config_parameter_includes("SchedulerParameters", ("sched_interval", 1))
# slurmd -b below simulates a reboot so slurmctld applies its --extra.
# Keep the node from being set DOWN as unexpectedly rebooted.
atf.require_config_parameter("ReturnToService", 2)
atf.require_nodes(2)
atf.require_slurm_running()
@pytest.fixture(scope="module")
def extra_node():
"""The node to set Extra on.
The other nodes have no Extra, so a job with --extra can only run here.
"""
return sorted(atf.get_nodes(fatal=True))[-1]
@pytest.fixture(autouse=True)
def reset_extra(extra_node):
yield
atf.run_command(
f"scontrol update nodename={extra_node} extra=",
user=atf.properties["slurm-user"],
fatal=True,
)
def assert_job_runs_on(node, request):
job_id = atf.submit_job_sbatch(
f"--extra='{request}' -o /dev/null --wrap true", fatal=True
)
assert atf.wait_for_job_state(
job_id, "COMPLETED"
), f"Verify job with --extra='{request}' runs"
assert (
atf.get_job_parameter(job_id, "NodeList", fatal=True) == node
), f"Verify job with --extra='{request}' runs on {node}"
def assert_job_pends(request):
job_id = atf.submit_job_sbatch(
f"--extra='{request}' -o /dev/null --wrap true", fatal=True
)
assert atf.wait_for_job_state(
job_id, "PENDING", desired_reason="Constraints"
), f"Verify job with --extra='{request}' pends with Reason=Constraints"
# Cases of (node Extra, job --extra request matching it). How requests match is
# covered by test_145_7.
VALID_EXTRA = [
('{"key":"value"}', "key=value"),
('{"number":123}', "number=123"),
('{"bool":true}', "bool=true"),
('{"spaces":" value with spaces "}', "spaces= value with spaces "),
('{"unicode":"\u00e9"}', "unicode=\u00e9"),
('{"special":"@#$%^*"}', "special=@#$%^*"),
('{"a":1,"b":2,"c":3}', "a=1,b=2,c=3"),
('{"long":"' + "x" * 1000 + '"}', "long=" + "x" * 1000),
]
@pytest.mark.parametrize(
"extra,request_expr",
VALID_EXTRA,
ids=[
"string",
"number",
"bool",
"spaces",
"unicode",
"special",
"multiple",
"long",
],
)
def test_scontrol_valid_extra(extra_node, extra, request_expr):
"""Verify scontrol sets valid JSON Extra and jobs filter on it"""
atf.run_command(
f"scontrol update nodename={extra_node} extra='{extra}'",
user=atf.properties["slurm-user"],
fatal=True,
)
assert (
atf.get_node_parameter(extra_node, "extra") == extra
), "Verify scontrol set the node Extra"
assert_job_runs_on(extra_node, request_expr)
# Malformed for every serializer that can provide JSON, as some accept unquoted
# strings
@pytest.mark.parametrize("extra", ['{"unclosed":"value}', "{", "}"])
def test_scontrol_invalid_extra(extra_node, extra):
"""Verify scontrol rejects malformed Extra"""
atf.run_command(
f"scontrol update nodename={extra_node} extra='{{\"a\":1}}'",
user=atf.properties["slurm-user"],
fatal=True,
)
result = atf.run_command(
f"scontrol update nodename={extra_node} extra='{extra}'",
user=atf.properties["slurm-user"],
fatal=True,
xfail=True,
)
assert result["exit_code"] != 0, "Verify scontrol rejects the Extra"
assert INVALID_EXTRA in result["stderr"], "Verify scontrol reports why"
assert (
atf.get_node_parameter(extra_node, "extra") == '{"a":1}'
), "Verify the node Extra is unchanged"
@pytest.mark.parametrize(
"extra,matches",
[('{"key":"value"}', True), ('{"key":"value"', False)],
ids=["valid", "invalid"],
)
def test_slurmd_extra(extra_node, extra, matches):
"""Verify slurmd --extra sets Extra and only valid JSON is used by jobs"""
# slurmctld only applies --extra when slurmd_start_time changes and the
# boot time (set by -b) is after the node last responded. Both have a one
# second resolution.
atf.stop_slurmd(extra_node)
stopped = int(time.time())
for t in atf.timer(fatal=True):
if int(time.time()) > stopped:
break
atf.start_slurmd(extra_node, f"-b --extra '{extra}'", wait="idle")
assert (
atf.get_node_parameter(extra_node, "extra") == extra
), "Verify slurmd set the node Extra even if it is not valid JSON"
if matches:
assert_job_runs_on(extra_node, "key=value")
else:
assert_job_pends("key=value")