blob: b0174f0d0af00b3fe1dd7538bc001469f6e892d5 [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""test_102_18.py - sacctmgr load/dump.
Tests for sacctmgr load and dump: file format semantics, additive vs clean
behavior, and error handling. Covers basic load/dump, additive load, load
with clean, comprehensive modify, and error/edge cases (no cluster, QOS
after cluster, missing parent, no parent warning, multiple clusters,
duplicate QOS, bad option format, no name, unknown option, no filename,
unreadable file, nothing after object, readonly mode, clean with multiple
clusters, duplicate file=, empty file, unbalanced quotes, nonexistent QOS).
Also covers a three-phase sacctmgr load workflow, additive preserve-omitted,
parent-unchanged modify, DefaultAccount on a separate Parent line,
DefaultWCKey when the WCKey row already exists, user-record modify scoped to
the named cluster, dump round-trip, and clean clearing association QOS.
Destructive clean=account/qos tests live in test_102_20.py.
Everything here is additive-load behavior. The tests gated on 26.11+ cover
sacctmgr load fixes that are already on master (association QOS merge,
DefaultAccount marking the default association, per-cluster scoping of the
load user modify); none of them exercises declarative load, which is
test_102_19.py. This file is the baseline that keeps the additive path from
regressing while declarative is built on the same code.
"""
import logging
import os
import re
import pytest
import atf
pytestmark = pytest.mark.slow
# Global variables for entity names
test_cluster = "test_cluster1"
test_cluster_second = "test_cluster_second"
test_account1 = "test_acct1"
test_account2 = "test_acct2"
test_user1 = "test_user1"
test_user2 = "test_user2"
test_qos1 = "test_qos1"
test_qos2 = "test_qos2"
test_qos3 = "test_qos3"
test_name = os.path.splitext(os.path.basename(__file__))[0]
# Names for the three-phase integration workflow (test_name-prefixed)
tc1 = f"{test_name}-cluster-1"
ta1 = f"{test_name}-account.1"
ta2 = f"{test_name}-account.2"
ta3 = f"{test_name}-account.3"
tu1 = f"{test_name}-user.1"
tu2 = f"{test_name}-user.2"
tu3 = f"{test_name}-user.3"
qs1 = "normal"
qs2 = f"{test_name}.qos"
qs3 = f"{test_name}.qos2"
CLASS_CAPACITY = "Capacity"
CLASS_CAPABILITY = "Capability"
CLASS_CAPAPACITY = "Capapacity"
# Association limits for the three-phase integration workflow: one entry per
# option a file line sets, holding the six values the workflow hands out to
# the cluster, the accounts and the users. The values only have to differ per
# index, which is what proves a value landed on the row it was meant for.
#
# The set is wide on purpose and the options are not interchangeable: the
# *CPUs and *CPUMins options go through the TRES conversion and the *Wall
# options through time parsing, so each one covers a different parse path.
#
# The option order here is the order they are written into the file.
ASSOC_LIMITS = {
"Fairshare": (1000, 2375, 3240, 4321, 5678, 6789),
"GrpCPUMins": (1100, 2000, 3300, 4000, 5500, 6600),
"GrpCPUs": (10, 20, 30, 40, 50, 60),
"GrpJobs": (120, 210, 310, 410, 510, 610),
"GrpNodes": (140, 230, 330, 430, 530, 630),
"GrpSubmitJobs": (130, 220, 320, 420, 520, 620),
"GrpWall": (60, 120, 180, 240, 300, 1440),
"MaxCPUMins": (110000, 220000, 330000, 420000, 550000, 660000),
"MaxCPUs": (150, 240, 340, 440, 540, 640),
"MaxJobs": (160, 250, 350, 450, 550, 650),
"MaxNodes": (180, 270, 370, 470, 570, 670),
"MaxSubmitJobs": (170, 260, 360, 460, 560, 660),
"MaxWall": (70, 140, 210, 280, 350, 2880),
}
# What a User line carries; the Grp* limits are account level only.
USER_LIMITS = (
"MaxCPUMins",
"MaxCPUs",
"MaxJobs",
"MaxNodes",
"MaxSubmitJobs",
"MaxWall",
)
QOS2_INFO1 = {
"Description": "qos_temp",
"Flags": "DenyOnLimit,EnforceUsageThreshold,NoDecay",
"GraceTime": "70",
"GrpJobsAccrue": "2",
"GrpJobs": "120",
"GrpSubmitJobs": "130",
"GrpTres": "cpu=1",
"GrpTresMins": "cpu=2",
"GrpTresRunMins": "cpu=3",
"GrpWall": "60",
"LimitFactor": "2.000000",
"MaxJobsPerAccount": "160",
"MaxJobsPerUser": "250",
"MaxJobsAccruePerAccount": "160",
"MaxJobsAccruePerUser": "250",
"MaxSubmitJobsPerAccount": "180",
"MaxSubmitJobsPerUser": "4",
"MaxTresMinsPerJob": "cpu=1",
"MaxTresPerAccount": "cpu=2",
"MaxTresPerJob": "cpu=3",
"MaxTresPerNode": "cpu=4",
"MaxTresPerUser": "cpu=5",
"MaxTRESRunMinsPerAccount": "cpu=1",
"MaxTRESRunMinsPerUser": "cpu=3",
"MaxWallDurationPerJob": "30",
"MinPrioThresh": "30",
"MinTRESPerJob": "cpu=10",
"Preempt": "normal",
"PreemptMode": "cancel",
"PreemptExemptTime": "60",
"Priority": "10010",
"UsageFactor": "0.500000",
"UsageThreshold": "2.500000",
}
QOS2_INFO2 = {
"Description": "qos_temp2",
"Flags": "DenyOnLimit",
"GraceTime": "80",
"GrpJobsAccrue": "3",
"GrpJobs": "110",
"GrpSubmitJobs": "110",
"GrpTres": "cpu=2",
"GrpTresMins": "cpu=1",
"GrpTresRunMins": "cpu=4",
"GrpWall": "70",
"LimitFactor": "2.400000",
"MaxJobsPerAccount": "120",
"MaxJobsPerUser": "210",
"MaxJobsAccruePerAccount": "120",
"MaxJobsAccruePerUser": "230",
"MaxSubmitJobsPerAccount": "190",
"MaxSubmitJobsPerUser": "2",
"MaxTresMinsPerJob": "cpu=4",
"MaxTresPerAccount": "cpu=1",
"MaxTresPerJob": "cpu=4",
"MaxTresPerNode": "cpu=5",
"MaxTresPerUser": "cpu=6",
"MaxTRESRunMinsPerAccount": "cpu=2",
"MaxTRESRunMinsPerUser": "cpu=4",
"MaxWallDurationPerJob": "70",
"MinPrioThresh": "10",
"MinTRESPerJob": "cpu=60",
"Preempt": qs3,
"PreemptMode": "requeue",
"PreemptExemptTime": "10",
"Priority": "10110",
"UsageFactor": "0.250000",
"UsageThreshold": "2.300000",
}
ASSOC_FMT_WITH_PART = (
"Cluster,Account,User,Partition,Fairshare,GrpCPUMins,GrpCPUs,GrpJobs,"
"GrpNodes,GrpSubmitJobs,GrpWall,MaxCPUs,MaxCPUMins,MaxJobs,MaxNodes,"
"MaxSubmitJobs,MaxWall,QOS"
)
ASSOC_FMT = (
"Cluster,Account,User,Fairshare,GrpCPUMins,GrpCPUs,GrpJobs,GrpNodes,"
"GrpSubmitJobs,GrpWall,MaxCPUs,MaxCPUMins,MaxJobs,MaxNodes,"
"MaxSubmitJobs,MaxWall,QOS"
)
@pytest.fixture(scope="module", autouse=True)
def setup():
atf.require_accounting(modify=True)
# Needed by the duplicate-WCKey test. Set here rather than in that test,
# since in auto-config mode this rewrites slurmdbd.conf and restarts the
# daemon, and doing that partway through the module would leave every
# later test running under a config the earlier ones did not have.
atf.require_config_parameter("TrackWCKey", "Yes", source="slurmdbd")
atf.require_config_parameter("TrackWCKey", "Yes")
atf.require_slurm_running()
@pytest.fixture(scope="function", autouse=True)
def setup_db():
"""Add slurm user on root before each test; remove test entities after."""
atf.run_command(
f"sacctmgr -i add user {atf.properties['slurm-user']} account=root",
user=atf.properties["slurm-user"],
)
yield
atf.run_command(
f"sacctmgr -i remove user {test_user1},{test_user2},{tu1},{tu2},{tu3}",
user=atf.properties["slurm-user"],
)
atf.run_command(
f"sacctmgr -i remove account {test_account1},{test_account2},{ta1},{ta2},{ta3}",
user=atf.properties["slurm-user"],
)
atf.run_command(
f"sacctmgr -i remove cluster {test_cluster},{test_cluster_second},{tc1}",
user=atf.properties["slurm-user"],
)
atf.run_command(
f"sacctmgr -i remove qos {test_qos1},{test_qos2},{test_qos3},{qs2},{qs3}",
user=atf.properties["slurm-user"],
)
def _limit(name, n):
"""The value of one association limit at index n (1-6)."""
return ASSOC_LIMITS[name][n - 1]
def _limits(n):
"""Association limit option string for index n (1-6)."""
return ":".join(f"{name}={_limit(name, n)}" for name in ASSOC_LIMITS)
def _user_limits(fs_n, max_n):
"""User line limit options: Fairshare from fs_n, the Max* set from max_n."""
opts = [f"Fairshare={_limit('Fairshare', fs_n)}"]
opts += [f"{name}={_limit(name, max_n)}" for name in USER_LIMITS]
return ":".join(opts)
def _qos_line(name, options):
opts = ":".join(f"{key}={value}" for key, value in options.items())
return f"QOS - '{name}':{opts}"
def write_load_file(path, content):
"""Write a sacctmgr load file as the slurm user (remote-exec safe)."""
atf.run_command(
f"cat > {path}",
input=content,
user=atf.properties["slurm-user"],
fatal=True,
)
def _write_config(path, lines):
write_load_file(path, "\n".join(lines) + "\n")
def _convert_time_str(time_str, units):
"""Match expect globals convert_time_str for QOS limit checks."""
if not time_str or time_str.strip() == "":
return ""
match = re.match(r"(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)", time_str.strip())
if not match:
return time_str
days, hours, mins, secs = match.groups()
hours = int(hours or 0)
mins = int(mins or 0)
secs = int(secs or 0)
if days:
hours += int(days) * 24
value = hours * 3600 + mins * 60 + secs
if units == "hours":
return str(value // 3600)
if units == "mins":
return str(value // 60)
return str(value)
def _check_qos_limits(qos_name, expected):
"""Port of expect globals_accounting check_qos_limits."""
fmt = ",".join(expected.keys())
output = atf.run_command_output(
f"sacctmgr -n -P list qos {qos_name} format={fmt}",
user=atf.properties["slurm-user"],
fatal=True,
)
values = output.strip().split("|")
assert len(values) == len(expected), (
f"QOS {qos_name} field count mismatch: got {len(values)} fields, "
f"expected {len(expected)}: {values!r}"
)
mismatches = []
for option, exp_val, in_val in zip(expected.keys(), expected.values(), values):
if exp_val == "-1" and in_val == "":
in_val = "-1"
elif ":" in str(exp_val):
pass
elif option == "GraceTime":
in_val = _convert_time_str(in_val, "secs")
elif (
"MaxWall" in option or "GrpWall" in option or option == "PreemptExemptTime"
):
in_val = _convert_time_str(in_val, "mins")
if str(exp_val) != str(in_val):
mismatches.append(f"{option} expected {exp_val!r} got {in_val!r}")
assert not mismatches, f"QOS {qos_name} limits mismatch: " + "; ".join(mismatches)
def get_assoc_rows(cluster, fmt_fields):
"""Return {(account, user, partition): {field: value}} for list assoc.
Parses -n -P output by field name so callers assert exact per-field
values instead of regex-bridged column matches. Partition is part of the
key so a partition-scoped association cannot overwrite the row for the
same account and user without one; it reads as "" when the format does
not request it.
"""
fields = fmt_fields.split(",")
output = atf.run_command_output(
f"sacctmgr -n -P list assoc cluster={cluster} format={fmt_fields}",
user=atf.properties["slurm-user"],
fatal=True,
)
rows = {}
for line in output.strip().split("\n"):
if not line.strip():
continue
values = [p.strip() for p in line.split("|")]
row = dict(zip(fields, values))
rows[(row["Account"], row["User"], row.get("Partition", ""))] = row
return rows
def _assert_assoc_rows(rows, expected, label):
"""Assert the associations are exactly the expected set, field by field.
Expected keys are (account, user) for the association with no partition,
or (account, user, partition). The set is compared exactly, so an
association the load should have removed fails here rather than going
unnoticed because every expected row still happens to be present.
"""
normalized = {}
for key, fields in expected.items():
if len(key) == 2:
key = (key[0], key[1], "")
normalized[key] = fields
assert set(rows) == set(normalized), (
f"{label}: association set mismatch; "
f"unexpected {sorted(set(rows) - set(normalized))!r}, "
f"missing {sorted(set(normalized) - set(rows))!r}"
)
for key, fields in normalized.items():
account, user, _partition = key
got = rows[key]
for field, value in fields.items():
got_val = got[field]
# sacctmgr renders wall limits as [D-]HH:MM:SS, but _limits()
# writes them into the file as minutes. Compare in minutes.
if field in ("GrpWall", "MaxWall"):
got_val = _convert_time_str(got_val, "mins")
assert got_val == str(value), (
f"{label}: account={account} user={user} field {field} "
f"expected {value!r}, got {got[field]!r}"
)
def _acct_assoc(n):
"""Expected account-level assoc fields when the line uses _limits(n)."""
return dict(
{name: _limit(name, n) for name in ASSOC_LIMITS},
QOS=qs1,
)
def _user_assoc(fs_n, max_n):
"""Expected user assoc fields: Fairshare from fs_n, Max* from max_n."""
return dict(
{name: _limit(name, max_n) for name in USER_LIMITS},
Fairshare=_limit("Fairshare", fs_n),
QOS=qs1,
)
def _root_user_assoc(n):
"""Expected (root, root) user assoc: Max* from n, QOS only."""
return dict(
{name: _limit(name, n) for name in USER_LIMITS},
QOS=qs1,
)
def _parse_parsable_row(output):
"""Return the field list from one sacctmgr -n -P row.
-P emits no trailing delimiter, so a line ending in '|' means the last
requested field is empty and the split has to keep it.
"""
line = output.strip()
if not line:
return []
return line.split("|")
def _assert_parsable_row(output, field_names, expected, label=""):
"""Assert sacctmgr -P output fields match expected values by name."""
parts = _parse_parsable_row(output)
assert len(parts) == len(field_names), (
f"{label}: expected {len(field_names)} fields, got {len(parts)}: " f"{parts!r}"
)
got = dict(zip(field_names, parts))
for key, val in expected.items():
assert (
got[key] == val
), f"{label}: field {key} expected {val!r}, got {got[key]!r}"
return got
def _assert_coordinators(expected_rows, label):
output = atf.run_command_output(
"sacctmgr -n -P list user format=User,Coordinator,AdminLevel withcoordinator",
user=atf.properties["slurm-user"],
fatal=True,
)
for user, coord, admin in expected_rows:
pattern = rf"{re.escape(user)}\|{re.escape(coord)}\|{re.escape(admin)}"
assert re.search(
pattern, output
), f"{label}: expected coordinator row {user}/{coord}/{admin} not in:\n{output}"
def _assert_acct_associations_consistent():
"""Asserts the stored associations have no duplicate (cluster, lineage).
Ports the modern half of testsuite/expect/globals's
check_acct_associations: lists every association's lineage path and
fails if the same (cluster, lineage) pair shows up twice, which would
mean two associations collapsed onto the same spot in the hierarchy.
The legacy lft/rgt hole check that function also does only matters for
clusters at or before Slurm 23.02, which is out of scope for the
current + 3 prior versions this testsuite runs against, so it is not
ported.
"""
output = atf.run_command_output(
"sacctmgr -n -P list assoc wopi wopl withd format=lineage,cluster",
fatal=True,
)
seen = set()
for line in output.strip().split("\n"):
if not line.strip():
continue
lineage, cluster = [p.strip() for p in line.split("|")]
key = (cluster, lineage)
assert (
key not in seen
), f"Cluster {cluster} has more than one association at lineage {lineage}"
seen.add(key)
def _load_file(path):
result = atf.run_command(
f"sacctmgr -i load {path}",
user=atf.properties["slurm-user"],
fatal=True,
)
return result["stdout"] + result["stderr"]
def _remove_integration_entities():
atf.run_command(
f"sacctmgr -i delete user {tu1},{tu2},{tu3}",
user=atf.properties["slurm-user"],
)
atf.run_command(
f"sacctmgr -i delete account {ta1},{ta2},{ta3}",
user=atf.properties["slurm-user"],
)
atf.run_command(
f"sacctmgr -i delete cluster {tc1}",
user=atf.properties["slurm-user"],
)
atf.run_command(
f"sacctmgr -i delete qos {qs2},{qs3}",
user=atf.properties["slurm-user"],
)
def _build_file_in():
lines = [
f"QOS - '{qs1}'",
f"QOS - '{qs3}'",
_qos_line(qs2, QOS2_INFO1),
]
lines.extend(
[
f"Cluster - '{tc1}':Classification={CLASS_CAPACITY}:{_limits(6)}:QOS='{qs1}'",
"Parent - 'root'",
(
f"Account - '{ta1}':Description='scienceacct':Organization='scienceorg':"
f"{_limits(5)}:QOS='{qs1}'"
),
(
f"Account - '{ta2}':Description='physicsacct':Organization='physicsorg':"
f"{_limits(4)}:QOS='{qs1}'"
),
f"Parent - '{ta1}'",
(
f"Account - '{ta3}':Description='theoryacct':Organization='theoryorg':"
f"{_limits(3)}:QOS='{qs1}'"
),
f"Parent - '{ta2}'",
(
f"User - '{tu1}':Coordinator='{ta2}':DefaultAccount='{ta2}':"
f"{_user_limits(1, 2)}:QOS='{qs1}':"
f"AdminLevel=Operator"
),
f"Parent - '{ta3}'",
(
f"User - '{tu2}':Coordinator='{ta3}':DefaultAccount='{ta3}':"
f"{_user_limits(2, 1)}:QOS='{qs1}':"
f"AdminLevel=Administrator"
),
]
)
return lines
def _build_file_in2():
lines = [_qos_line(qs2, QOS2_INFO2)]
lines.extend(
[
f"Cluster - '{tc1}':Classification={CLASS_CAPABILITY}",
"Parent - 'root'",
f"Account - '{ta1}'",
(
f"Account - '{ta3}':Description='scienceacct':Organization='scienceorg':"
f"{_limits(5)}:QOS='{qs1}'"
),
f"Parent - '{ta1}'",
f"Account - '{ta2}'",
f"Parent - '{ta2}'",
(
f"User - '{tu3}':Coordinator='{ta1},{ta2},{ta3}':DefaultAccount='{ta2}':"
f"{_user_limits(2, 2)}:QOS='{qs1}':"
f"AdminLevel=Administrator"
),
f"Parent - '{ta3}'",
(
f"User - '{tu2}':DefaultAccount='{ta3}':{_user_limits(3, 3)}:QOS='{qs1}':AdminLevel=Operator"
),
(f"User - '{tu3}':DefaultAccount='{ta3}':{_user_limits(3, 3)}:QOS='{qs1}'"),
f"Parent - '{ta1}'",
(f"User - '{tu3}':DefaultAccount='{ta1}':{_user_limits(2, 1)}:QOS='{qs1}'"),
]
)
return lines
def _build_file_in3():
return [
f"Cluster - '{tc1}':Classification={CLASS_CAPAPACITY}:{_limits(6)}:QOS='{qs1}'",
"Parent - 'root'",
(
f"Account - '{ta1}':Description='scienceacct':Organization='scienceorg':"
f"{_limits(5)}:QOS='{qs1}'"
),
(
f"Account - '{ta3}':Description='theoryacct':Organization='theoryorg':"
f"{_limits(5)}:QOS='{qs1}'"
),
f"Parent - '{ta1}'",
(
f"Account - '{ta3}':Description='scienceacct':Organization='scienceorg':"
f"{_limits(5)}:QOS='{qs1}'"
),
(
f"Account - '{ta2}':Description='physicsacct':Organization='physicsorg':"
f"{_limits(4)}:QOS='{qs1}':AdminLevel=Operator"
),
(
f"User - '{tu3}':Coordinator='{ta1},{ta2},{ta3}':DefaultAccount='{ta1}':"
f"{_user_limits(2, 1)}:QOS='{qs1}':"
f"AdminLevel=Administrator"
),
f"Parent - '{ta2}'",
(
f"User - '{tu1}':Coordinator='{ta2}':DefaultAccount='{ta2}':"
f"{_user_limits(1, 2)}:QOS='{qs1}':"
f"AdminLevel=Operator"
),
(f"User - '{tu3}':DefaultAccount='{ta2}':{_user_limits(2, 2)}:QOS='{qs1}'"),
f"Parent - '{ta3}'",
(
f"User - '{tu2}':Coordinator='{ta3}':DefaultAccount='{ta3}':"
f"{_user_limits(3, 3)}:QOS='{qs1}'"
),
(f"User - '{tu3}':DefaultAccount='{ta3}':{_user_limits(3, 3)}:QOS='{qs1}'"),
]
def test_load_file_three_phase_workflow():
"""Three-phase sacctmgr load: initial, additive modify, clean replace."""
atf.require_version(
(25, 5),
component="bin/sacctmgr",
reason="Issue 50238: QOS lines in sacctmgr load files added in 25.05",
)
file_in = "input"
file_in2 = "input2"
file_in3 = "input3"
_remove_integration_entities()
_write_config(file_in, _build_file_in())
_write_config(file_in2, _build_file_in2())
_write_config(file_in3, _build_file_in3())
out1 = _load_file(file_in)
assert f"For cluster {tc1}" in out1, "Load 1 should report cluster"
assert (
f"Classification: {CLASS_CAPACITY}" in out1
), "Load 1 should set Classification"
rows1 = get_assoc_rows(tc1, ASSOC_FMT_WITH_PART)
_assert_assoc_rows(
rows1,
{
("root", ""): _acct_assoc(6),
("root", "root"): _root_user_assoc(6),
(ta1, ""): _acct_assoc(5),
(ta3, ""): _acct_assoc(3),
(ta3, tu2): _user_assoc(2, 1),
(ta2, ""): _acct_assoc(4),
(ta2, tu1): _user_assoc(1, 2),
},
"Associations after load 1",
)
_assert_coordinators(
[
(tu1, ta2, "Operator"),
(tu2, ta3, "Administrator"),
],
"Coordinators after load 1",
)
_check_qos_limits(qs2, QOS2_INFO1)
out2 = _load_file(file_in2)
assert f"For cluster {tc1}" in out2, "Load 2 should report cluster"
assert (
f"{CLASS_CAPACITY} -> {CLASS_CAPABILITY}" in out2
), "Load 2 should report Classification change"
_check_qos_limits(qs2, QOS2_INFO2)
rows2 = get_assoc_rows(tc1, ASSOC_FMT)
_assert_assoc_rows(
rows2,
{
("root", ""): _acct_assoc(6),
("root", "root"): _root_user_assoc(6),
(ta1, ""): _acct_assoc(5),
(ta1, tu3): _user_assoc(2, 1),
(ta2, ""): _acct_assoc(4),
(ta2, tu1): _user_assoc(1, 2),
(ta2, tu3): _user_assoc(2, 2),
(ta3, ""): _acct_assoc(5),
(ta3, tu2): _user_assoc(3, 3),
(ta3, tu3): _user_assoc(3, 3),
},
"Associations after load 2",
)
_assert_coordinators(
[
(tu1, ta2, "Operator"),
(tu2, ta3, "Operator"),
(tu3, f"{ta1},{ta2},{ta3}", "Administrator"),
],
"Coordinators after load 2",
)
result3 = atf.run_command(
f"sacctmgr -i load {file_in3} clean",
user=atf.properties["slurm-user"],
fatal=True,
)
out3 = result3["stdout"] + result3["stderr"]
assert f"For cluster {tc1}" in out3, "Load 3 should report cluster"
assert (
f"Classification: {CLASS_CAPAPACITY}" in out3
), "Load 3 should set Classification"
rows3 = get_assoc_rows(tc1, ASSOC_FMT_WITH_PART)
_assert_assoc_rows(
rows3,
{
("root", ""): _acct_assoc(6),
("root", "root"): _root_user_assoc(6),
(ta1, ""): _acct_assoc(5),
(ta1, tu3): _user_assoc(2, 1),
(ta2, ""): _acct_assoc(4),
(ta2, tu1): _user_assoc(1, 2),
(ta2, tu3): _user_assoc(2, 2),
(ta3, ""): _acct_assoc(5),
(ta3, tu2): _user_assoc(3, 3),
(ta3, tu3): _user_assoc(3, 3),
},
"Associations after clean load",
)
_assert_coordinators(
[
(tu1, ta2, "Operator"),
(tu2, ta3, "Operator"),
(tu3, f"{ta1},{ta2},{ta3}", "Administrator"),
],
"Coordinators after clean load",
)
_assert_acct_associations_consistent()
def test_basic_load_and_dump():
"""A dump names every entity a load put in the database.
Covers the file format end to end, Cluster then QOS then Parent then
Account then User, and the Parent lines setting the association context
that the accounts and the user hang off. What the reloaded dump
preserves is test_dump_reload_round_trip_preserves_assoc.
"""
config_file = "basic_test.cfg"
dump_file = "dump_test1.cfg"
# Pre-create QOS entities (non-fatal: QOS persists across database restores)
atf.run_command(
f"sacctmgr -i add qos {test_qos1},{test_qos2}",
user=atf.properties["slurm-user"],
)
# Create a config file with cluster, accounts, and user
config_text = f"""Cluster - '{test_cluster}':Fairshare=100:QOS='{test_qos1}'
Parent - 'root'
Account - '{test_account1}':Description='Test Account 1':Organization='TestOrg':Fairshare=50:GrpJobs=10:MaxJobs=20:QOS='{test_qos1},{test_qos2}'
Account - '{test_account2}':Description='Test Account 2':Fairshare=30:GrpJobs=5:QOS='{test_qos1}'
Parent - '{test_account1}'
User - '{test_user1}':DefaultAccount='{test_account1}':Fairshare=25:MaxJobs=15:QOS='{test_qos1},{test_qos2}'
"""
write_load_file(config_file, config_text)
# Load the configuration
atf.run_command(
f"sacctmgr -i load {config_file}",
user=atf.properties["slurm-user"],
fatal=True,
)
# Dump the configuration
atf.run_command(
f"sacctmgr dump {test_cluster} file={dump_file}",
user=atf.properties["slurm-user"],
fatal=True,
)
# Verify dump file was created and contains expected content
for expected in (test_account1, test_account2, test_user1):
atf.assert_file_contents(dump_file, expected, contains=True)
def test_load_with_clean():
"""Bare clean removes the cluster's associations, not the records.
clean with no options removes the cluster along with its associations,
while the account, user and QOS records themselves survive. Reading only
list assoc cannot tell that apart from clean having deleted the records,
so the association and the record are asserted separately.
"""
config_file1 = "clean_test1.cfg"
config_file2 = "clean_test2.cfg"
# Pre-create QOS entities (non-fatal: QOS persists across database restores)
atf.run_command(
f"sacctmgr -i add qos {test_qos1}",
user=atf.properties["slurm-user"],
)
# Initial configuration with two accounts and a user under the second, so
# the clean load below leaves all three unlisted.
config_text1 = f"""Cluster - '{test_cluster}'
Parent - 'root'
Account - '{test_account1}':Fairshare=50:GrpJobs=10
Account - '{test_account2}':Fairshare=30:GrpJobs=5
Parent - '{test_account2}'
User - '{test_user1}':DefaultAccount='{test_account2}':Fairshare=25
"""
write_load_file(config_file1, config_text1)
atf.run_command(
f"sacctmgr -i load {config_file1}",
user=atf.properties["slurm-user"],
fatal=True,
)
# Verify both accounts exist
output = atf.run_command_output(
f"sacctmgr -n -P list assoc account={test_account1},{test_account2} cluster={test_cluster} format=Account",
fatal=True,
)
assert test_account1 in output, "Account 1 should exist after first load"
assert test_account2 in output, "Account 2 should exist after first load"
# Second load with clean option - only account1
config_text2 = f"""Cluster - '{test_cluster}'
Parent - 'root'
Account - '{test_account1}':Fairshare=60
"""
write_load_file(config_file2, config_text2)
atf.run_command(
f"sacctmgr -i load {config_file2} clean",
user=atf.properties["slurm-user"],
fatal=True,
)
# Verify account2 was removed
output = atf.run_command_output(
f"sacctmgr -n -P list assoc account={test_account2} cluster={test_cluster} format=Account",
fatal=True,
)
assert test_account2 not in output, "Account 2 should be removed with clean option"
# The association is gone, but the records it referenced are not. Without
# these an accidental escalation of clean to deleting account, user or QOS
# records would satisfy the assertion above.
output = atf.run_command_output(
f"sacctmgr -n -P list account {test_account2} format=Account",
user=atf.properties["slurm-user"],
fatal=True,
)
assert (
test_account2 in output
), "Bare clean should keep the account record, removing only its association"
output = atf.run_command_output(
f"sacctmgr -n -P list user {test_user1} format=User",
user=atf.properties["slurm-user"],
fatal=True,
)
assert test_user1 in output, "Bare clean should keep the user record"
output = atf.run_command_output(
f"sacctmgr -n -P list qos {test_qos1} format=Name",
user=atf.properties["slurm-user"],
fatal=True,
)
assert test_qos1 in output, "Bare clean should not remove QOS definitions"
output = atf.run_command_output(
f"sacctmgr -n -P list assoc account=root cluster={test_cluster} "
f"format=Account",
user=atf.properties["slurm-user"],
fatal=True,
)
assert "root" in output, "Bare clean should leave the root association"
# Verify account1 still exists with updated values
output = atf.run_command_output(
f"sacctmgr -n -P list assoc account={test_account1} cluster={test_cluster} format=Account,Fairshare",
fatal=True,
)
_assert_parsable_row(
output,
["Account", "Fairshare"],
{"Account": test_account1, "Fairshare": "60"},
"Account 1 fairshare after clean",
)
_assert_acct_associations_consistent()
def test_load_clean_clears_assoc_qos_when_omitted():
"""Load with clean and no QOS on the account line drops file-specific QOS.
After cluster flush, the account may inherit the cluster default QOS (normal);
the test checks that QOS from the prior load file (test_qos1) is not kept.
"""
atf.run_command(
f"sacctmgr -i add qos {test_qos1}",
user=atf.properties["slurm-user"],
)
cfg1 = "clean_qos_clear1.cfg"
cfg2 = "clean_qos_clear2.cfg"
config1 = f"""Cluster - '{test_cluster}'
Parent - 'root'
Account - '{test_account1}':Fairshare=50:QOS='{test_qos1}'
"""
config2 = f"""Cluster - '{test_cluster}'
Parent - 'root'
Account - '{test_account1}':Fairshare=50
"""
write_load_file(cfg1, config1)
write_load_file(cfg2, config2)
atf.run_command(
f"sacctmgr -i load {cfg1}",
user=atf.properties["slurm-user"],
fatal=True,
)
out_before = atf.run_command_output(
f"sacctmgr -n -P list assoc account={test_account1} user='' "
f"cluster={test_cluster} format=QOS",
fatal=True,
)
assert test_qos1 in out_before, "QOS should be set before clean load"
atf.run_command(
f"sacctmgr -i load {cfg2} clean",
user=atf.properties["slurm-user"],
fatal=True,
)
out_after = atf.run_command_output(
f"sacctmgr -n -P list assoc account={test_account1} user='' "
f"cluster={test_cluster} format=QOS",
fatal=True,
)
assert (
test_qos1 not in out_after
), "clean load without QOS must not keep QOS from the prior file"
@pytest.mark.skipif(
atf.get_version("bin/sacctmgr") < (26, 11),
reason="Ticket 23787: additive QOS merge (file QOS appended, not replaced) requires 26.11+",
)
def test_comprehensive_modify_behavior():
"""Test comprehensive modification covering account metadata, account associations, and user associations
Validates:
- Complete integration: Metadata, account associations, and user associations all modified
- Field-level modifications: All three categories updated together
- Additive QOS: Second load adds test_qos2 to existing test_qos1 (not replace)
- Parent - line context: User association created under test_account1 (from Parent line)
- DefaultAccount metadata: Independent from association account context
- Case sensitivity: Metadata assertions use .lower()
"""
config_file1 = "comprehensive_test1.cfg"
config_file2 = "comprehensive_test2.cfg"
# Pre-create QOS entities (non-fatal: QOS persists across database restores)
atf.run_command(
f"sacctmgr -i add qos {test_qos1},{test_qos2}",
user=atf.properties["slurm-user"],
)
# Initial configuration with many fields set
config_text1 = f"""Cluster - '{test_cluster}':Fairshare=100
Parent - 'root'
Account - '{test_account1}':Description='Initial Desc':Organization='InitOrg':Fairshare=50:GrpJobs=10:MaxJobs=20:Priority=100:QOS='{test_qos1}'
Parent - '{test_account1}'
User - '{test_user1}':DefaultAccount='{test_account1}':Fairshare=25:MaxJobs=15:Priority=50:QOS='{test_qos1}'
"""
write_load_file(config_file1, config_text1)
atf.run_command(
f"sacctmgr -i load {config_file1}",
user=atf.properties["slurm-user"],
fatal=True,
)
# Verify initial state - all three areas
metadata_output = atf.run_command_output(
f"sacctmgr -n -P list account {test_account1} format=Account,Description,Organization",
fatal=True,
)
assert (
"initial desc" in metadata_output.lower()
), "Initial description should be set"
assert "initorg" in metadata_output.lower(), "Initial organization should be set"
acct_assoc_output = atf.run_command_output(
f"sacctmgr -n -P list assoc account={test_account1} user='' cluster={test_cluster} format=Account,Fairshare,GrpJobs,MaxJobs,Priority,QOS",
fatal=True,
)
_assert_parsable_row(
acct_assoc_output,
["Account", "Fairshare", "GrpJobs", "MaxJobs", "Priority", "QOS"],
{
"Account": test_account1,
"Fairshare": "50",
"GrpJobs": "10",
"MaxJobs": "20",
"Priority": "100",
"QOS": test_qos1,
},
"Initial account association",
)
user_assoc_output = atf.run_command_output(
f"sacctmgr -n -P list assoc user={test_user1} account={test_account1} cluster={test_cluster} format=User,Account,Fairshare,MaxJobs,Priority,QOS",
fatal=True,
)
_assert_parsable_row(
user_assoc_output,
["User", "Account", "Fairshare", "MaxJobs", "Priority", "QOS"],
{
"User": test_user1,
"Account": test_account1,
"Fairshare": "25",
"MaxJobs": "15",
"Priority": "50",
"QOS": test_qos1,
},
"Initial user association",
)
# Second load - modify various fields
# test_user2 appears only here, so the same load that modifies covers
# adding alongside modifying.
config_text2 = f"""Cluster - '{test_cluster}'
Parent - 'root'
Account - '{test_account1}':Description='Modified Desc':Organization='ModOrg':Fairshare=60:GrpJobs=15:MaxJobs=25:Priority=200:QOS='{test_qos2}'
Parent - '{test_account1}'
User - '{test_user1}':DefaultAccount='{test_account1}':Fairshare=30:MaxJobs=18:Priority=75:QOS='{test_qos2}'
User - '{test_user2}':DefaultAccount='{test_account1}':Fairshare=20
"""
write_load_file(config_file2, config_text2)
output = atf.run_command_output(
f"sacctmgr -i load {config_file2}",
user=atf.properties["slurm-user"],
fatal=True,
)
logging.debug(f"sacctmgr load output: {output}")
# Check account metadata modifications
metadata_output = atf.run_command_output(
f"sacctmgr -n -P list account {test_account1} format=Account,Description,Organization",
fatal=True,
)
logging.debug(f"Account metadata after modification: {metadata_output}")
assert (
"modified desc" in metadata_output.lower()
), "Account description should be updated"
assert "modorg" in metadata_output.lower(), "Account organization should be updated"
# Check account association modifications
acct_assoc_output = atf.run_command_output(
f"sacctmgr -n -P list assoc account={test_account1} user='' cluster={test_cluster} format=Account,Fairshare,GrpJobs,MaxJobs,Priority,QOS",
fatal=True,
)
logging.debug(f"Account association after modification: {acct_assoc_output}")
acct_got = _assert_parsable_row(
acct_assoc_output,
["Account", "Fairshare", "GrpJobs", "MaxJobs", "Priority", "QOS"],
{
"Account": test_account1,
"Fairshare": "60",
"GrpJobs": "15",
"MaxJobs": "25",
"Priority": "200",
},
"Account association after modification",
)
acct_qos = set(acct_got["QOS"].split(","))
assert {
test_qos1,
test_qos2,
} <= acct_qos, "Account QOS should merge additively (both test_qos1 and test_qos2)"
# Check user association modifications
user_assoc_output = atf.run_command_output(
f"sacctmgr -n -P list assoc user={test_user1} account={test_account1} cluster={test_cluster} format=User,Account,Fairshare,MaxJobs,Priority,QOS",
fatal=True,
)
logging.debug(f"User association after modification: {user_assoc_output}")
user_got = _assert_parsable_row(
user_assoc_output,
["User", "Account", "Fairshare", "MaxJobs", "Priority", "QOS"],
{
"User": test_user1,
"Account": test_account1,
"Fairshare": "30",
"MaxJobs": "18",
"Priority": "75",
},
"User association after modification",
)
user_qos = set(user_got["QOS"].split(","))
assert {
test_qos1,
test_qos2,
} <= user_qos, "User QOS should merge additively (both test_qos1 and test_qos2)"
new_user_out = atf.run_command_output(
f"sacctmgr -n -P list assoc user={test_user2} account={test_account1} "
f"cluster={test_cluster} format=User,Fairshare",
fatal=True,
)
new_user = [p.strip() for p in new_user_out.strip().split("|")]
assert new_user[0] == test_user2, "User only in the second file should be added"
assert new_user[1] == "20", "Added user should take the Fairshare from the file"
ERROR_LOAD_CASES = [
pytest.param(
f"Parent - 'root'\nAccount - '{test_account1}':Fairshare=50\n",
("You need to specify a cluster name first",),
id="no-cluster",
),
pytest.param(
f"Cluster - '{test_cluster}'\nParent - 'nonexistent_parent'\n"
f"Account - '{test_account1}':Fairshare=50\n",
("You need to add this parent",),
id="parent-missing",
),
pytest.param(
f"Cluster - '{test_cluster}'\nParent - 'root'\n"
f"Account - '{test_account1}':Fairshare=50\n"
f"Cluster - 'test_cluster2'\nAccount - '{test_account2}':Fairshare=30\n",
("You can only add one cluster at a time",),
id="multiple-clusters",
),
pytest.param(
f"Cluster - '{test_cluster}'\nParent - 'root'\nAccount - ':Fairshare=50'\n",
("No name given",),
id="no-name",
),
pytest.param(
f"Cluster - '{test_cluster}'\nParent - 'root'\n"
f"Account - '{test_account1}':UnknownOption=50\n",
("Unknown option",),
id="unknown-option",
),
]
# Cases whose file carries QOS lines, therefore requiring sacctmgr 25.05+.
QOS_ERROR_LOAD_CASES = [
pytest.param(
f"Cluster - '{test_cluster}'\nQOS - 'bad_qos'\nParent - 'root'\n"
f"Account - '{test_account1}':Fairshare=50\n",
("You need to specify all QOS before",),
id="qos-after-cluster",
),
pytest.param(
f"QOS - 'duplicate_qos':Priority=100\nQOS - 'duplicate_qos':Priority=200\n"
f"Cluster - '{test_cluster}'\nParent - 'root'\n"
f"Account - '{test_account1}':Fairshare=50\n",
("has multiple entries",),
id="duplicate-qos",
),
pytest.param(
f"QOS - '{test_qos1}'\nCluster - '{test_cluster}'\nParent - 'root'\nAccount -\n",
("Nothing after object",),
id="nothing-after-object",
),
pytest.param(
f"QOS - '{test_qos1}'\nCluster - '{test_cluster}'\nParent - 'root'\n"
f"Account - 'a':Description='unclosed\n",
("quotes",),
id="unbalanced-quotes",
),
pytest.param(
f"QOS - '{test_qos1}'\nCluster - '{test_cluster}'\nParent - 'root'\n"
f"Account - '{test_account1}':Fairshare=50:QOS='nonexistent_qos_xyz'\n",
("bad qos", "nonexistent_qos_xyz"),
id="account-nonexistent-qos",
),
]
def _assert_stderr_has(stderr, expected, label):
"""Check stderr against expected substrings, all case-insensitive.
Every entry has to appear. An entry that is itself a tuple is an
alternation, for the messages that differ by which check rejected the
line first.
"""
for want in expected:
alts = want if isinstance(want, tuple) else (want,)
assert any(
a.lower() in stderr.lower() for a in alts
), f"{label}: stderr should mention one of {alts}: {stderr}"
def _check_error_load(content, expected):
"""Load content and check sacctmgr rejects it with the expected error."""
cfg = "error_load.cfg"
write_load_file(cfg, content)
result = atf.run_command(
f"sacctmgr -i load {cfg}",
user=atf.properties["slurm-user"],
fatal=True,
xfail=True,
)
_assert_stderr_has(result["stderr"], expected, "load of a malformed file")
@pytest.mark.parametrize("content, expected", ERROR_LOAD_CASES)
def test_error_load_rejected(content, expected):
"""sacctmgr load rejects a malformed file with a specific error message."""
_check_error_load(content, expected)
@pytest.mark.parametrize("content, expected", QOS_ERROR_LOAD_CASES)
def test_qos_error_load_rejected(content, expected):
"""sacctmgr load rejects a malformed file with QOS lines with a specific error message."""
atf.require_version(
(25, 5),
component="bin/sacctmgr",
reason="Issue 50238: QOS lines in sacctmgr load files added in 25.05",
)
_check_error_load(content, expected)
ERROR_LOAD_COMMAND_CASES = [
pytest.param(
{
"content": f"Cluster - '{test_cluster}'\nParent - 'root'\n"
f"Account - '{test_account1}':Fairshare50\n",
"cmd": "-i load {cfg}",
# Which of the two fires depends on how far the parse gets.
"expected": [("Bad format", "Unknown option")],
},
id="option-without-equals",
),
pytest.param(
{
"cmd": "-i load",
"expected": ["No filename given"],
},
id="no-filename",
),
pytest.param(
{
"cmd": "-i load file=/nonexistent/path/load.cfg",
"expected": ["Unable to read"],
},
id="unreadable-file",
),
pytest.param(
{
"content": f"QOS - '{test_qos1}'\nCluster - '{test_cluster}'\n"
f"Parent - 'root'\nAccount - '{test_account1}':Fairshare=50\n",
"cmd": "-r load file={cfg}",
"expected": ["readonly mode"],
},
id="readonly-mode",
),
pytest.param(
{
"content": f"Cluster - '{test_cluster}'\nParent - 'root'\n",
"content2": f"Cluster - '{test_cluster}'\nParent - 'root'\n",
"cmd": "-i load file={cfg} file={cfg2}",
"expected": ["File name already set"],
},
id="duplicate-file-option",
),
]
@pytest.mark.parametrize("case", ERROR_LOAD_COMMAND_CASES)
def test_error_load_command_rejected(case):
"""sacctmgr rejects a bad load command line with a specific message.
The file is fine in these cases, or absent; what is wrong is the command
around it, so the command is what varies. ERROR_LOAD_CASES above covers
the other direction, a fixed command against a malformed file.
"""
cfg = "error_load_cmd.cfg"
cfg2 = "error_load_cmd2.cfg"
if case.get("content"):
write_load_file(cfg, case["content"])
if case.get("content2"):
write_load_file(cfg2, case["content2"])
command = case["cmd"].format(cfg=cfg, cfg2=cfg2)
result = atf.run_command(
f"sacctmgr {command}",
user=atf.properties["slurm-user"],
fatal=True,
xfail=True,
)
_assert_stderr_has(result["stderr"], case["expected"], command)
def test_warning_no_parent_specified():
"""Test informational message when no parent is specified
Validates:
- Default parent is root: When no Parent - line is given, defaults to root
- Warning message: Prints informational message about defaulting to root
- This happens even when root is the intended parent (idiosyncrasy)
"""
config_file = "no_parent_warning.cfg"
# Don't specify Parent - should default to root with a warning
config_text = f"""Cluster - '{test_cluster}'
Account - '{test_account1}':Fairshare=50
"""
write_load_file(config_file, config_text)
output = atf.run_command_output(
f"sacctmgr -i load {config_file}",
user=atf.properties["slurm-user"],
fatal=True,
)
assert (
"No parent given creating off root" in output
), "Should show informational message about default parent"
# Verify account was still created successfully under root
result = atf.run_command_output(
f"sacctmgr -n -P list assoc account={test_account1} cluster={test_cluster} format=Account,ParentName",
fatal=True,
)
assert test_account1 in result, "Account should be created"
assert "root" in result, "Account should be under root"
def test_error_clean_multiple_clusters():
"""Test error when load clean is used and system has multiple clusters
Validates:
- clean=account/user/qos requires exactly one cluster in the system
"""
atf.require_version(
(25, 5),
component="bin/sacctmgr",
reason="Issue 50241: load clean= multi-cluster check added in 25.05",
)
# Ensure two clusters exist in the DB so the clean check triggers (atf may
# not be in accounting yet, so add both explicitly)
atf.run_command(
f"sacctmgr -i add cluster {test_cluster}",
user=atf.properties["slurm-user"],
)
atf.run_command(
f"sacctmgr -i add cluster {test_cluster_second}",
user=atf.properties["slurm-user"],
)
config_file = "clean_multi_cluster_error.cfg"
config_text = f"""QOS - '{test_qos1}'
Cluster - '{test_cluster}'
Parent - 'root'
Account - '{test_account1}':Fairshare=50
"""
write_load_file(config_file, config_text)
# clean=account (or user/qos) triggers the multi-cluster check; bare "clean" does not
result = atf.run_command(
f"sacctmgr -i load file={config_file} clean=account",
user=atf.properties["slurm-user"],
fatal=True,
xfail=True,
)
assert (
"only have one cluster" in result["stderr"]
), "Should show multi-cluster clean error"
def test_empty_file():
"""Test load of empty file succeeds with nothing new added
Validates:
- Empty file or only blank lines: exit 0, no cluster set, nothing added
"""
config_file = "empty_file.cfg"
write_load_file(config_file, "")
result = atf.run_command(
f"sacctmgr -i load {config_file}",
user=atf.properties["slurm-user"],
)
assert result["exit_code"] == 0, "Empty file load should succeed"
assert (
"Nothing new added" in result["stdout"]
), "Empty file should report nothing added"
def test_additive_second_load_preserves_omitted_fields():
"""Additive load must not reset limits or QOS omitted on the second file.
Without the declarative flag, a second load that only changes Fairshare
must leave GrpJobs, MaxJobs, and QOS from the first load intact.
"""
atf.run_command(
f"sacctmgr -i add qos {test_qos1},{test_qos2}",
user=atf.properties["slurm-user"],
)
cfg1 = "additive_preserve1.cfg"
cfg2 = "additive_preserve2.cfg"
config1 = f"""Cluster - '{test_cluster}'
Parent - 'root'
Account - '{test_account1}':Fairshare=50:GrpJobs=10:MaxJobs=20:QOS='{test_qos1}'
"""
config2 = f"""Cluster - '{test_cluster}'
Parent - 'root'
Account - '{test_account1}':Fairshare=75
"""
write_load_file(cfg1, config1)
write_load_file(cfg2, config2)
atf.run_command(
f"sacctmgr -i load {cfg1}",
user=atf.properties["slurm-user"],
fatal=True,
)
atf.run_command(
f"sacctmgr -i load {cfg2}",
user=atf.properties["slurm-user"],
fatal=True,
)
out = atf.run_command_output(
f"sacctmgr -n -P list assoc account={test_account1} user='' "
f"cluster={test_cluster} format=Fairshare,GrpJobs,MaxJobs,QOS",
fatal=True,
)
parts = out.strip().split("|")
assert len(parts) == 4, "Expect Fairshare|GrpJobs|MaxJobs|QOS"
assert parts[0].strip() == "75", "Fairshare updated from second load"
assert parts[1].strip() == "10", "GrpJobs omitted on second load => unchanged"
assert parts[2].strip() == "20", "MaxJobs omitted on second load => unchanged"
assert test_qos1 in parts[3], "QOS omitted on second load => unchanged"
def test_assoc_same_parent_other_fields_update():
"""Second additive load with unchanged Parent still updates association limits.
Regression for parent=parent skipping association modify on existing rows.
"""
cfg1 = "parent_unchanged1.cfg"
cfg2 = "parent_unchanged2.cfg"
config1 = f"""Cluster - '{test_cluster}'
Parent - 'root'
Account - '{test_account1}':Fairshare=50:GrpJobs=10:MaxJobs=20
"""
config2 = f"""Cluster - '{test_cluster}'
Parent - 'root'
Account - '{test_account1}':Fairshare=60:GrpJobs=15
"""
write_load_file(cfg1, config1)
write_load_file(cfg2, config2)
atf.run_command(
f"sacctmgr -i load {cfg1}",
user=atf.properties["slurm-user"],
fatal=True,
)
atf.run_command(
f"sacctmgr -i load {cfg2}",
user=atf.properties["slurm-user"],
fatal=True,
)
out = atf.run_command_output(
f"sacctmgr -n -P list assoc account={test_account1} user='' "
f"cluster={test_cluster} format=Fairshare,GrpJobs,MaxJobs",
fatal=True,
)
parts = out.strip().split("|")
assert len(parts) == 3, "Expect Fairshare|GrpJobs|MaxJobs"
assert parts[0].strip() == "60", "Fairshare should update when parent unchanged"
assert parts[1].strip() == "15", "GrpJobs should update when parent unchanged"
assert parts[2].strip() == "20", "MaxJobs omitted on second load => unchanged"
@pytest.mark.skipif(
atf.get_version("bin/sacctmgr") < (26, 11),
reason="Ticket 23787: DefaultAccount marking is_def on the association requires 26.11+",
)
def test_additive_load_default_account_separate_parent_line():
"""DefaultAccount must mark is_def on the association under a separate Parent.
list user reports default account from the association with is_def=1. The
load file may set DefaultAccount on a User line under one Parent while the
matching association appears under another Parent line in the same file.
"""
cfg = "def_acct_sep_parent.cfg"
config = f"""Cluster - '{test_cluster}'
Parent - 'root'
Account - '{test_account1}':Fairshare=50
Account - '{test_account2}':Fairshare=30
Parent - '{test_account1}'
User - '{test_user1}':DefaultAccount='{test_account2}':Fairshare=25
Parent - '{test_account2}'
User - '{test_user1}':Fairshare=10
"""
write_load_file(cfg, config)
atf.run_command(
f"sacctmgr -i load {cfg}",
user=atf.properties["slurm-user"],
fatal=True,
)
user_out = atf.run_command_output(
f"sacctmgr -n -P list user {test_user1} cluster={test_cluster} "
"format=User,DefaultA",
fatal=True,
)
parts = [p.strip() for p in user_out.strip().split("|")]
assert len(parts) == 2, "Expect User|DefaultA"
assert parts[1] == test_account2, (
"DefaultAccount from load file must be honored when the default "
"association is on a separate Parent line"
)
acct1_out = atf.run_command_output(
f"sacctmgr -n -P list assoc user={test_user1} account={test_account1} "
f"cluster={test_cluster} format=User,Fairshare",
fatal=True,
)
acct2_out = atf.run_command_output(
f"sacctmgr -n -P list assoc user={test_user1} account={test_account2} "
f"cluster={test_cluster} format=User,Fairshare",
fatal=True,
)
_assert_parsable_row(
acct1_out,
["User", "Fairshare"],
{"User": test_user1, "Fairshare": "25"},
"User association under first Parent",
)
_assert_parsable_row(
acct2_out,
["User", "Fairshare"],
{"User": test_user1, "Fairshare": "10"},
"User association under default-account Parent",
)
@pytest.mark.skipif(
atf.get_version("sbin/slurmdbd") < (26, 11),
reason="Ticket 23787: idempotent duplicate WCKey insert not rolling back the txn requires 26.11+",
)
def test_additive_load_duplicate_wckey_does_not_rollback_assocs():
"""Existing DefaultWCKey on a second additive load must not roll back new assocs.
When WCKey rows already exist, the idempotent insert must not reset the load
transaction and undo association adds from the same load.
"""
test_wckey = f"{test_user1}_wckey"
cfg1 = "wckey_dup1.cfg"
cfg2 = "wckey_dup2.cfg"
config1 = f"""Cluster - '{test_cluster}'
Parent - 'root'
Account - '{test_account1}':Fairshare=50
Parent - '{test_account1}'
User - '{test_user1}':DefaultAccount='{test_account1}':DefaultWCKey='{test_wckey}':Fairshare=25
"""
config2 = f"""Cluster - '{test_cluster}'
Parent - 'root'
Account - '{test_account2}':Fairshare=30
Parent - '{test_account2}'
User - '{test_user1}':DefaultAccount='{test_account1}':DefaultWCKey='{test_wckey}':Fairshare=20
"""
write_load_file(cfg1, config1)
write_load_file(cfg2, config2)
atf.run_command(
f"sacctmgr -i load {cfg1}",
user=atf.properties["slurm-user"],
fatal=True,
)
atf.run_command(
f"sacctmgr -i load {cfg2}",
user=atf.properties["slurm-user"],
fatal=True,
)
wckey_out = atf.run_command_output(
f"sacctmgr -n -P list user {test_user1} cluster={test_cluster} "
"format=User,DefaultW",
fatal=True,
)
wckey_parts = [p.strip() for p in wckey_out.strip().split("|")]
assert len(wckey_parts) == 2, "Expect User|DefaultW"
assert (
wckey_parts[1] == test_wckey
), "DefaultWCKey should remain set after second load"
acct2_out = atf.run_command_output(
f"sacctmgr -n -P list assoc user={test_user1} account={test_account2} "
f"cluster={test_cluster} format=User,Fairshare",
fatal=True,
)
_assert_parsable_row(
acct2_out,
["User", "Fairshare"],
{"User": test_user1, "Fairshare": "20"},
"Second-load association after duplicate DefaultWCKey",
)
@pytest.mark.skipif(
atf.get_version("bin/sacctmgr") < (26, 11),
reason="Ticket 23787: per-cluster scoping of the load user modify requires 26.11+",
)
def test_additive_load_user_modify_scoped_to_named_cluster():
"""DefaultAccount from load applies only to the cluster named in the file.
AdminLevel is stored once per user in the accounting DB (not per cluster),
so this test does not assert cluster-scoped AdminLevel behavior.
"""
atf.run_command(
f"sacctmgr -i add cluster {test_cluster},{test_cluster_second}",
user=atf.properties["slurm-user"],
)
atf.run_command(
f"sacctmgr -i add account {test_account1},{test_account2} "
f"cluster={test_cluster},{test_cluster_second} parent=root",
user=atf.properties["slurm-user"],
)
atf.run_command(
f"sacctmgr -i add user {test_user1} account={test_account1},{test_account2} "
f"cluster={test_cluster},{test_cluster_second} "
f"defaultaccount={test_account1}",
user=atf.properties["slurm-user"],
)
atf.run_command(
f"sacctmgr -i mod user {test_user1} cluster={test_cluster} "
f"set defaultaccount={test_account1}",
user=atf.properties["slurm-user"],
)
atf.run_command(
f"sacctmgr -i mod user {test_user1} cluster={test_cluster_second} "
f"set defaultaccount={test_account1}",
user=atf.properties["slurm-user"],
)
before_c1 = atf.run_command_output(
f"sacctmgr -n -P list user {test_user1} cluster={test_cluster} "
"format=User,DefaultA",
fatal=True,
)
before_c2 = atf.run_command_output(
f"sacctmgr -n -P list user {test_user1} cluster={test_cluster_second} "
"format=User,DefaultA",
fatal=True,
)
assert test_account1 in before_c1, "Cluster1 should start with DefaultAccount acct1"
assert test_account2 not in before_c2, "Cluster2 must not start at the loaded value"
assert test_account1 in before_c2, "Cluster2 should start with DefaultAccount acct1"
cfg = "user_scope_cluster1.cfg"
config = f"""Cluster - '{test_cluster}'
Parent - 'root'
Account - '{test_account1}'
Account - '{test_account2}'
Parent - '{test_account1}'
User - '{test_user1}':DefaultAccount='{test_account2}':Fairshare=50
"""
write_load_file(cfg, config)
atf.run_command(
f"sacctmgr -i load {cfg}",
user=atf.properties["slurm-user"],
fatal=True,
)
after_c1 = atf.run_command_output(
f"sacctmgr -n -P list user {test_user1} cluster={test_cluster} "
"format=User,DefaultA",
fatal=True,
)
after_c2 = atf.run_command_output(
f"sacctmgr -n -P list user {test_user1} cluster={test_cluster_second} "
"format=User,DefaultA",
fatal=True,
)
c1_parts = [p.strip() for p in after_c1.strip().split("|")]
c2_parts = [p.strip() for p in after_c2.strip().split("|")]
assert len(c1_parts) == 2, "Expect User|DefaultA for cluster1"
assert len(c2_parts) == 2, "Expect User|DefaultA for cluster2"
assert (
c1_parts[1] == test_account2
), "Load file for cluster1 should update DefaultAccount on cluster1"
assert (
c2_parts[1] == test_account1
), "Cluster2 DefaultAccount must be unchanged by load scoped to cluster1"
def test_dump_reload_round_trip_preserves_assoc():
"""Dump after load and additive reload of the dump reproduces association limits."""
atf.run_command(
f"sacctmgr -i add qos {test_qos1}",
user=atf.properties["slurm-user"],
)
cfg = "roundtrip_src.cfg"
dump_cfg = "roundtrip_dump.cfg"
config = f"""Cluster - '{test_cluster}':Fairshare=100
Parent - 'root'
Account - '{test_account1}':Fairshare=50:GrpJobs=10:MaxJobs=20:QOS='{test_qos1}'
"""
write_load_file(cfg, config)
atf.run_command(
f"sacctmgr -i load {cfg}",
user=atf.properties["slurm-user"],
fatal=True,
)
before = atf.run_command_output(
f"sacctmgr -n -P list assoc account={test_account1} user='' "
f"cluster={test_cluster} format=Fairshare,GrpJobs,MaxJobs,QOS",
fatal=True,
)
# Pin the pre-dump values so the round-trip comparison below cannot pass
# by finding the association absent both times.
before_parts = before.strip().split("|")
assert len(before_parts) == 4, "Expect Fairshare|GrpJobs|MaxJobs|QOS"
assert before_parts[0].strip() == "50", "Fairshare set by the initial load"
assert before_parts[1].strip() == "10", "GrpJobs set by the initial load"
assert before_parts[2].strip() == "20", "MaxJobs set by the initial load"
assert test_qos1 in before_parts[3], "QOS set by the initial load"
atf.run_command(
f"sacctmgr dump {test_cluster} file={dump_cfg}",
user=atf.properties["slurm-user"],
fatal=True,
)
atf.run_command(
f"sacctmgr -i load {dump_cfg}",
user=atf.properties["slurm-user"],
fatal=True,
)
after = atf.run_command_output(
f"sacctmgr -n -P list assoc account={test_account1} user='' "
f"cluster={test_cluster} format=Fairshare,GrpJobs,MaxJobs,QOS",
fatal=True,
)
assert (
before.strip() == after.strip()
), "Reloaded dump should match pre-dump association"
@pytest.mark.skipif(
atf.get_version("bin/sacctmgr") < (26, 11),
reason="Ticket 23787: partition-less load User line scoping requires 26.11+",
)
def test_partition_user_line_scopes_to_id():
"""Partition-less load User line must not reset partition assocs.
Creates a default (no Partition) user assoc and a partition-specific one.
A plain (non-declarative) load of a Partition-less User=Fairshare must
update only the default row; the partition row keeps its MaxJobs.
"""
# test_cluster is not the live cluster, so sacctmgr never resolves this
# name against a real partition. A literal keeps the test independent of
# whether the host config happens to define a default partition.
part = "load_part_23787"
cfg_full = "part_full.cfg"
cfg_partial = "part_partial.cfg"
config_full = f"""Cluster - '{test_cluster}'
Parent - 'root'
Account - '{test_account1}':Fairshare=50
Parent - '{test_account1}'
User - '{test_user1}':Fairshare=50
User - '{test_user1}':Fairshare=50:Partition='{part}':MaxJobs=3
"""
config_partial = f"""Cluster - '{test_cluster}'
Parent - 'root'
Account - '{test_account1}':Fairshare=50
Parent - '{test_account1}'
User - '{test_user1}':Fairshare=100
"""
write_load_file(cfg_full, config_full)
write_load_file(cfg_partial, config_partial)
atf.run_command(
f"sacctmgr -i load {cfg_full}",
user=atf.properties["slurm-user"],
fatal=True,
)
atf.run_command(
f"sacctmgr -i load {cfg_partial}",
user=atf.properties["slurm-user"],
fatal=True,
)
out = atf.run_command_output(
f"sacctmgr -n -P list assoc account={test_account1} user={test_user1} "
f"cluster={test_cluster} format=Fairshare,MaxJobs,Partition",
fatal=True,
)
by_part = {}
for ln in out.strip().split("\n"):
if not ln.strip():
continue
parts = [p.strip() for p in ln.split("|")]
assert len(parts) == 3, f"Unexpected assoc row: {ln!r}"
by_part[parts[2]] = parts
assert "" in by_part, "Default (no Partition) user assoc must exist"
assert by_part[""][0] == "100", "Default (no Partition) Fairshare should update"
assert part in by_part, "Partition-specific user assoc must still exist"
assert by_part[part][0] == "50", "Partition assoc Fairshare must stay untouched"
assert by_part[part][1] == "3", "Partition assoc MaxJobs must stay untouched"
@pytest.mark.skipif(
atf.get_version("bin/sacctmgr") < (26, 11),
reason="Ticket 23787: a rejected load modify stopping the load requires 26.11+",
)
def test_removing_qos_a_child_defaults_to_stops_the_load():
"""A rejected load modify must stop the load, not report success.
Dropping a QOS from an account while a user under it still has that
QOS as their DefaultQOS makes slurmdbd refuse the modify and discard
the whole open transaction. A plain (non-declarative) load must stop
there rather than committing later lines on top of a transaction the
server already threw away.
"""
atf.run_command(
f"sacctmgr -i add qos {test_qos1},{test_qos2}",
user=atf.properties["slurm-user"],
fatal=True,
)
cfg_full = "defqos_full.cfg"
cfg_drop = "defqos_drop.cfg"
# test_user1 gets no QOS of its own, so its DefaultQOS is only reachable
# through what test_account1 grants.
config_full = f"""Cluster - '{test_cluster}'
Parent - 'root'
Account - '{test_account2}':Fairshare=30
Account - '{test_account1}':Fairshare=50:QOS='{test_qos1},{test_qos2}'
Parent - '{test_account1}'
User - '{test_user1}':Fairshare=25:DefaultQOS='{test_qos1}'
"""
# test_account2 is listed first, so its modify is staged before the one
# that gets rejected. It is the witness for the rollback. test_user1 is
# left out, so the load does not change its DefaultQOS on the way past.
# A bare QOS list is additive under a plain load (it merges rather than
# replacing), so "-qos1" is needed here to actually drop it; declarative
# load is what makes a bare list replace, which is what the counterpart
# test in test_102_19.py uses instead.
config_drop = f"""Cluster - '{test_cluster}'
Parent - 'root'
Account - '{test_account2}':Fairshare=77
Account - '{test_account1}':Fairshare=50:QOS='-{test_qos1}'
"""
write_load_file(cfg_full, config_full)
write_load_file(cfg_drop, config_drop)
atf.run_command(
f"sacctmgr -i load {cfg_full}",
user=atf.properties["slurm-user"],
fatal=True,
)
result = atf.run_command(
f"sacctmgr -i load {cfg_drop}",
user=atf.properties["slurm-user"],
fatal=True,
xfail=True,
)
stderr = result["stderr"].lower()
assert (
"default qos" in stderr
), f"stderr should name the default-QOS problem, got: {result['stderr']}"
fs2 = atf.run_command_output(
f"sacctmgr -n -P list assoc account={test_account2} user='' "
f"cluster={test_cluster} format=Fairshare",
fatal=True,
)
assert (
fs2.strip() == "30"
), "Change staged before the rejected line must be rolled back, not kept"
qos_out = atf.run_command_output(
f"sacctmgr -n -P list assoc account={test_account1} user='' "
f"cluster={test_cluster} format=QOS",
fatal=True,
)
assert test_qos1 in qos_out, "Rejected modify must leave the QOS list alone"
@pytest.mark.skipif(
atf.get_version("bin/sacctmgr") < (26, 11),
reason="Ticket 23787: stopping a load on its own bad options requires 26.11+",
)
@pytest.mark.parametrize(
"bad_arg",
("no_such_option_23787", "no_such_option_23787=5"),
ids=["bare_word", "key_value"],
)
def test_bad_load_option_applies_nothing(bad_arg):
"""An argument the load cannot use must stop it before the file is read.
The options say what the load is meant to do, so one that cannot be used
has to stop it. Both forms are covered: a bare word is taken as a second
file name, while a key=value that matches nothing is an unknown option.
The tell that the file was never read is the absence of a line-level
complaint. exit_code is global and sticky, so without a check on the
arguments the load goes on to open the file and rejects its first line,
blaming "Problem with line(1)" for a line that is perfectly valid.
"""
cfg = "bad_option.cfg"
config = f"""Cluster - '{test_cluster}'
Parent - 'root'
Account - '{test_account1}':Fairshare=42
"""
write_load_file(cfg, config)
result = atf.run_command(
f"sacctmgr -i load {cfg} {bad_arg}",
user=atf.properties["slurm-user"],
fatal=True,
xfail=True,
)
assert result["stderr"].strip(), "A rejected argument should be reported"
assert (
"problem with line" not in result["stderr"].lower()
), "The load must stop on its arguments, before any line is parsed"
out = atf.run_command_output(
f"sacctmgr -n -P list assoc account={test_account1} user='' "
f"cluster={test_cluster} format=Account",
fatal=True,
)
assert not out.strip(), "A load with a bad argument must not apply the file"