blob: da0afccc66e6b8b71198942da8abb76c8fa63440 [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""test_102_21.py - limits inherited from a parent account follow a move.
An association that does not set a limit itself takes its parent's, and the
database hands the controller that resolved value rather than an empty
column. Moving the association therefore changes what it enforces without
changing any of its own columns, and the controller has to be told.
The move has to be made after the controller has already been given the old
parent's value, or there is no stale value to get wrong: a child created
while the controller is running arrives with the column unset, and the
limit is reached by walking to the parent instead. Restarting between the
two is what puts the resolved value in the controller's record.
"""
import re
import pytest
import atf
test_parent1 = "test_par1_102_21"
test_parent2 = "test_par2_102_21"
test_child = "test_child_102_21"
# Every limit below is set to these on the two parents, the first being the
# tighter, so moving the child from the first to the second is always a move to
# a looser parent.
TIGHT = 5
LOOSE = 9
OWN_MAX_JOBS = 3
# Every limit slurmdbd inherits on a move. DefaultQOS is inherited as well but
# scontrol show assoc_mgr never prints it, so it cannot be observed here.
#
# scontrol prints a count limit as "Name=value(used)" and the rest bare, so
# each entry carries which shape to read.
#
# (sacctmgr keyword, scontrol label, printed with a (used) suffix)
INHERITED_LIMITS = [
("MaxJobs", "MaxJobs", True),
("MaxJobsAccrue", "MaxJobsAccrue", True),
("MaxSubmitJobs", "MaxSubmitJobs", True),
("MaxWall", "MaxWallPJ", False),
("MinPrioThresh", "MinPrioThresh", False),
("Priority", "Priority", False),
]
INHERITED_IDS = [kw for kw, _, _ in INHERITED_LIMITS]
# The subset a coordinator move is also checked against, so every limit that
# can trigger the refusal the coordinator case below is about. Grp* limits are
# checked but never inherited - get_parent_limits does not return them - and
# Priority is inherited but not checked, so neither can reach it.
GUARDED_LIMITS = [entry for entry in INHERITED_LIMITS if entry[0] != "Priority"]
LIMIT_IDS = [kw for kw, _, _ in GUARDED_LIMITS]
pytestmark = pytest.mark.slow
@pytest.fixture(scope="module", autouse=True)
def setup():
atf.require_auto_config("modifies accounts on the configured cluster")
atf.require_accounting(modify=True)
atf.require_slurm_running()
@pytest.fixture(scope="function", autouse=True)
def accounts():
"""Two parents with different MaxJobs, and a child under the first."""
su = atf.properties["slurm-user"]
cluster = atf.get_config_parameter("ClusterName")
atf.run_command(f"sacctmgr -i add user {su} account=root", user=su)
for acct in (test_parent1, test_parent2):
atf.run_command(
f"sacctmgr -i add account {acct} cluster={cluster}",
user=su,
fatal=True,
)
atf.run_command(
f"sacctmgr -i add account {test_child} cluster={cluster} "
f"parent={test_parent1}",
user=su,
fatal=True,
)
yield cluster
for acct in (test_child, test_parent1, test_parent2):
atf.run_command(f"sacctmgr -i delete account {acct}", user=su)
def child_limit(label, has_used):
"""The limit the controller holds for the child, or None when unset.
Anchored on the label so MaxJobs is not read off MaxJobsAccrue, and the
(used) suffix is required only for the limits that print one - demanding it
of MaxWallPJ would never match and would read as unset.
"""
out = atf.run_command_output(
f"scontrol show assoc_mgr accounts={test_child} flags=assoc",
user=atf.properties["slurm-user"],
quiet=True,
)
pattern = rf"\b{label}=(\d*)" + (r"\(" if has_used else "")
match = re.search(pattern, out)
if not match or not match.group(1):
return None
return int(match.group(1))
def child_max_jobs():
"""MaxJobs the controller holds for the child, or None when unset."""
return child_limit("MaxJobs", True)
def child_parent_account():
"""The parent account the controller holds for the child, or None."""
out = atf.run_command_output(
f"scontrol show assoc_mgr accounts={test_child} flags=assoc",
user=atf.properties["slurm-user"],
quiet=True,
)
match = re.search(r"\bParentAccount=(\S+?)\(", out)
if not match:
return None
return match.group(1)
def wait_for_child_limit(label, has_used, want):
for _ in atf.timer():
if child_limit(label, has_used) == want:
return True
return False
def set_parent_limits(keyword):
"""Give the two parents only this limit, tighter on the first.
One limit at a time on purpose. With every limit set at once, a move to the
looser parent raises all of them, so a coordinator refusal about any single
one would fail every case and name none of them.
"""
for acct, value in ((test_parent1, TIGHT), (test_parent2, LOOSE)):
atf.run_command(
f"sacctmgr -i modify account {acct} set {keyword}={value}",
user=atf.properties["slurm-user"],
fatal=True,
)
def move_child_to(parent):
atf.run_command(
f"sacctmgr -i modify account {test_child} set parent={parent}",
user=atf.properties["slurm-user"],
fatal=True,
)
@pytest.mark.skipif(
atf.get_version("sbin/slurmdbd") < (26, 11),
reason="Ticket 23787: slurmdbd only sends limits inherited from a new "
"parent on an account move from 26.11",
)
@pytest.mark.parametrize("keyword,label,has_used", INHERITED_LIMITS, ids=INHERITED_IDS)
def test_moved_account_inherits_new_parent_limit(keyword, label, has_used):
"""A limit the child does not set itself follows it to the new parent.
Run for every inherited limit rather than MaxJobs alone: each goes through
its own _inherits() call naming its own request field, own column and
parent column, so a slip in any one of them is only visible here.
DefaultQOS is inherited too, but scontrol show assoc_mgr does not print it.
"""
set_parent_limits(keyword)
# Restart so the controller is handed the limit resolved against the
# first parent. Without this the column is simply unset and there is
# nothing stale for the move to leave behind.
atf.restart_slurmctld()
assert child_limit(label, has_used) == TIGHT, (
f"Setup: controller should hold the first parent's {keyword} as the "
f"child's own after a restart"
)
move_child_to(test_parent2)
assert wait_for_child_limit(label, has_used, LOOSE), (
f"Controller still holds {child_limit(label, has_used)} after the "
f"move; it should enforce the new parent's {keyword} of {LOOSE}"
)
def test_moved_account_keeps_its_own_limit():
"""A limit the child sets itself is not replaced by the new parent's.
The expected MaxJobs is the value held before the move, so the move has to
be observed landing first. Read too early the assertion sees the pre-move
record and holds for a reason that has nothing to do with the limit.
"""
set_parent_limits("MaxJobs")
atf.run_command(
f"sacctmgr -i modify account {test_child} set maxjobs={OWN_MAX_JOBS}",
user=atf.properties["slurm-user"],
fatal=True,
)
atf.restart_slurmctld()
assert child_max_jobs() == OWN_MAX_JOBS, "Setup: own MaxJobs applied"
move_child_to(test_parent2)
for _ in atf.timer(fatal=True):
if child_parent_account() == test_parent2:
break
assert (
child_max_jobs() == OWN_MAX_JOBS
), "The account's own MaxJobs should survive the move"
@pytest.fixture(scope="function")
def coordinator():
"""Make the test user a coordinator of both parents.
Coordinator status is inherited down the tree, so coordinating the first
parent covers the child being moved, and the move itself is only permitted
to someone who coordinates the destination as well.
"""
su = atf.properties["slurm-user"]
user = atf.properties["test-user"]
# slurmdbd treats root and SlurmUser as admins, and an admin never takes
# the coordinator branch, so the cases below would pass without reaching
# what they guard. test-user falls back to the invoking user when
# slurmtestuser is unset in testsuite.conf.
assert user not in (su, "root"), (
f"test-user resolves to {user!r}, which slurmdbd treats as an admin; "
f"set slurmtestuser in testsuite.conf to an unprivileged account"
)
added = False
# 'add coordinator' needs the user to exist in accounting. Add only what is
# missing and remove exactly that, since a site-owned test user has to
# survive the run.
if not atf.run_command_output(
f"sacctmgr -n -P show user {user} format=User", user=su
).strip():
added = True
atf.run_command(
f"sacctmgr -i add user {user} account={test_parent1}",
user=su,
fatal=True,
)
atf.run_command(
f"sacctmgr -i add coordinator account={test_parent1},{test_parent2} "
f"names={user}",
user=su,
fatal=True,
)
yield user
atf.run_command(
f"sacctmgr -i remove coordinator "
f"account={test_parent1},{test_parent2} names={user}",
user=su,
)
if added:
atf.run_command(f"sacctmgr -i remove user {user}", user=su)
@pytest.mark.skipif(
atf.get_version("sbin/slurmdbd") < (26, 11),
reason="Ticket 23787: slurmdbd only sends limits inherited from a new "
"parent on an account move from 26.11",
)
@pytest.mark.parametrize("keyword,label,has_used", GUARDED_LIMITS, ids=LIMIT_IDS)
def test_coordinator_may_move_to_a_looser_parent(coordinator, keyword, label, has_used):
"""A coordinator may move an account to a parent with a higher limit.
The move makes the child inherit the new parent's MaxJobs, which is looser
than the one it had from the old parent, and the controller's cached record
still holds the old one. That must not be read as the coordinator raising a
limit: the request asks for no limit at all, and a coordinator of both
parents is allowed to move an account between them either way.
Without the inherited values being kept away from the coordinator check,
this fails with "Coordinators can not increase MaxJobs above the parent
limit" and the whole modify is aborted.
"""
set_parent_limits(keyword)
atf.restart_slurmctld()
assert child_limit(label, has_used) == TIGHT, (
f"Setup: controller should hold the first parent's {keyword} as the "
f"child's own after a restart"
)
result = atf.run_command(
f"sacctmgr -i modify account {test_child} set parent={test_parent2}",
user=coordinator,
)
assert result["exit_code"] == 0, (
f"a coordinator of both parents should be able to move an account to "
f"the looser one, but got: {result['stderr'].strip()!r}"
)
# A command that returned 0 without doing anything would otherwise pass.
assert wait_for_child_limit(label, has_used, LOOSE), (
f"the move reported success but the controller still holds "
f"{child_limit(label, has_used)}, not the new parent's {keyword} "
f"of {LOOSE}"
)