blob: ee6c6505daffdf4b5fe83ca50b46247945144931 [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""A job update's new ReqNodeList is checked against its new partition.
With AccountingStorageEnforce=limits and EnforcePartLimits, slurmctld validates
an unprivileged user's job update that changes the partition, QOS or account. A
new ReqNodeList in that same update naming a node outside the new partition must
be rejected, and the job keeps its partition and node list. A job must never be
left in a partition its required nodes are not in, and a rejected update must
leave the job able to run where it could before.
"""
import pytest
import atf
_PARTITION = "only_first_node"
@pytest.fixture(scope="module", autouse=True)
def setup():
atf.require_version(
(26, 11),
"sbin/slurmctld",
reason="Ticket 16573: a new ReqNodeList skipped the partition check before 26.11",
)
atf.require_accounting(modify=True)
atf.require_config_parameter_includes("AccountingStorageEnforce", "limits")
atf.require_config_parameter("EnforcePartLimits", "ALL")
atf.require_nodes(2)
atf.require_slurm_running()
test_user = atf.properties["test-user"]
slurm_user = atf.properties["slurm-user"]
atf.run_command(
f"sacctmgr -i add user {test_user} "
f"cluster={atf.get_config_parameter('ClusterName')} account=root",
user=slurm_user,
fatal=True,
)
# Wait for slurmctld to get the association
for _ in atf.timer(fatal=True):
output = atf.run_command_output(
f"scontrol show assoc_mgr flags=assoc users={test_user} accounts=root",
user=slurm_user,
fatal=True,
)
if f"Account=root UserName={test_user}(" in output:
break
node_a = sorted(atf.get_nodes(quiet=True, fatal=True))[0]
atf.run_command(
f"scontrol create PartitionName={_PARTITION} Nodes={node_a}",
user=slurm_user,
fatal=True,
)
@pytest.fixture(scope="module")
def nodes():
"""node_a is in both partitions, node_b only in the default one"""
return sorted(atf.get_nodes(quiet=True, fatal=True))[:2]
def _update_job(job_id, update):
return atf.run_command(
f"scontrol update JobId={job_id} {update}",
user=atf.properties["test-user"],
)
def test_req_nodes_outside_partition_rejected(nodes):
"""Verify a ReqNodeList outside the new partition is rejected"""
node_a, node_b = nodes
default_partition = atf.default_partition()
job_id = atf.submit_job_sbatch(
f'-p {default_partition} -w {node_a} -H --wrap "sleep infinity"', fatal=True
)
result = _update_job(job_id, f"Partition={_PARTITION} ReqNodeList={node_b}")
assert (
result["exit_code"] != 0
), f"Requiring {node_b}, which is not in {_PARTITION}, should be rejected"
assert (
"Requested nodes not in this partition" in result["stderr"]
), f"Unexpected error for job {job_id}: {result['stderr']}"
assert (
atf.get_job_parameter(job_id, "ReqNodeList", fatal=True) == node_a
), f"The rejected update should leave job {job_id} requiring {node_a}"
assert (
atf.get_job_parameter(job_id, "Partition", fatal=True) == default_partition
), f"The rejected update should leave job {job_id} in {default_partition}"
def test_req_nodes_inside_partition_accepted(nodes):
"""Verify a ReqNodeList inside the new partition is accepted"""
node_a, node_b = nodes
job_id = atf.submit_job_sbatch(
f'-p {atf.default_partition()} -H --wrap "sleep infinity"', fatal=True
)
result = _update_job(job_id, f"Partition={_PARTITION} ReqNodeList={node_a}")
assert (
result["exit_code"] == 0
), f"Requiring {node_a}, which is in {_PARTITION}, should succeed: {result['stderr']}"
assert (
atf.get_job_parameter(job_id, "Partition", fatal=True) == _PARTITION
), f"Job {job_id} should be in {_PARTITION}"
assert (
atf.get_job_parameter(job_id, "ReqNodeList", fatal=True) == node_a
), f"Job {job_id} should require {node_a}"
def test_later_failure_keeps_req_nodes_in_partition(nodes):
"""Verify a failure after the partition change keeps the nodes in it"""
node_a, node_b = nodes
job_id = atf.submit_job_sbatch(
f'-p {atf.default_partition()} -w {node_b} -H --wrap "sleep infinity"',
fatal=True,
)
# The invalid feature fails the update after the new partition and node
# list are in place
result = _update_job(
job_id, f"Partition={_PARTITION} ReqNodeList={node_a} Features=nosuch"
)
assert result["exit_code"] != 0, f"Updating job {job_id} should fail"
assert (
"Invalid feature specification" in result["stderr"]
), f"Unexpected error for job {job_id}: {result['stderr']}"
partition = atf.get_job_parameter(job_id, "Partition", fatal=True)
req_nodes = atf.get_job_parameter(job_id, "ReqNodeList", fatal=True)
assert not (
partition == _PARTITION and req_nodes == node_b
), f"Job {job_id} is in {_PARTITION} but requires {node_b}, which is not in it"
def test_rejected_update_keeps_longer_req_nodes(nodes):
"""Verify a rejected update keeps a job runnable on any of its nodes"""
node_a, node_b = nodes
default_partition = atf.default_partition()
# Keep both nodes busy so the job has to pend
blockers = {
node: atf.submit_job_sbatch(
f'-p {default_partition} -w {node} --exclusive --wrap "sleep infinity"',
fatal=True,
)
for node in nodes
}
for blocker_id in blockers.values():
atf.wait_for_job_state(blocker_id, "RUNNING", fatal=True)
job_id = atf.submit_job_sbatch(
f'-p {default_partition} -N1 -w {node_a},{node_b} --wrap "sleep infinity"',
fatal=True,
)
atf.wait_for_job_state(job_id, "PENDING", fatal=True)
req_nodes = atf.get_job_parameter(job_id, "ReqNodeList", fatal=True)
# The invalid feature fails the update after the new node list is in place
result = _update_job(job_id, f"ReqNodeList={node_a} Features=nosuch")
assert result["exit_code"] != 0, f"Updating job {job_id} should fail"
assert (
"Invalid feature specification" in result["stderr"]
), f"Unexpected error for job {job_id}: {result['stderr']}"
assert (
atf.get_job_parameter(job_id, "ReqNodeList", fatal=True) == req_nodes
), f"The rejected update should leave job {job_id} requiring {req_nodes}"
# The job needs only one of its nodes, so it must run once node_b is free
atf.cancel_jobs([blockers[node_b]], fatal=True)
assert atf.wait_for_job_state(
job_id, "RUNNING"
), f"Job {job_id} should start on {node_b} once it is free"
assert (
atf.get_job_parameter(job_id, "NodeList", fatal=True) == node_b
), f"Job {job_id} should run on {node_b}"
def test_rejected_license_update_keeps_longer_req_nodes(nodes):
"""Verify a rejected license update keeps a job runnable on any of its nodes"""
node_a, node_b = nodes
default_partition = atf.default_partition()
# Keep both nodes busy so the job has to pend
blockers = {
node: atf.submit_job_sbatch(
f'-p {default_partition} -w {node} --exclusive --wrap "sleep infinity"',
fatal=True,
)
for node in nodes
}
for blocker_id in blockers.values():
atf.wait_for_job_state(blocker_id, "RUNNING", fatal=True)
job_id = atf.submit_job_sbatch(
f'-p {default_partition} -N1 -w {node_a},{node_b} --wrap "sleep infinity"',
fatal=True,
)
atf.wait_for_job_state(job_id, "PENDING", fatal=True)
# The invalid license fails the update after its node lists are rebuilt
result = _update_job(job_id, "Licenses=nosuch:1")
assert result["exit_code"] != 0, f"Updating job {job_id} should fail"
assert (
"Invalid license specification" in result["stderr"]
), f"Unexpected error for job {job_id}: {result['stderr']}"
# The job needs only one of its nodes, so it must run once node_b is free
atf.cancel_jobs([blockers[node_b]], fatal=True)
assert atf.wait_for_job_state(
job_id, "RUNNING"
), f"Job {job_id} should start on {node_b} once it is free"
assert (
atf.get_job_parameter(job_id, "NodeList", fatal=True) == node_b
), f"Job {job_id} should run on {node_b}"