blob: 56d3518600adbed4388dd8301f0aea3bc7d431e0 [file]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""Regression guardrails for bug 25211 job_submit/lua field clearing.
These exercise behavior that the fix must leave unchanged, so they are not
version gated and run on every supported version:
Group C - submit-path behavior is unchanged by the patch: assigning "" on
submit leaves the field NULL, discarding the user-supplied value.
Group D - non-whitelist guardrails (must NOT regress). Assigning "" to a
field outside the whitelist must leave that field alone. Every field
here reaches a controller branch that would act on "": partition and
qos revert to a default, mail_user reverts to the job owner, and
std_out, std_err and std_in are cleared outright. The plugin's
whitelist is the only thing keeping "" away from those branches, and
job_submit_plugins.shtml documents the fields as deliberately
excluded. Each case is seeded away from the value "" would produce,
so a regression is observable rather than silently identical. The
Lua hook and the test are both generated from _GROUP_D_CASES.
Group E - assigning a non-empty value must still take effect. job_desc is
documented as input/output and the hook as able to modify the job
parameters supplied by the user, so narrowing which fields "" clears
must not disturb ordinary assignment:
E1 features set on modify
E2 tres_per_node set on modify
E3 comment overwritten on modify
E4 features set on submit
"""
import os
import pytest
import atf
QOS_NAME = "test157_qos"
ACCOUNT_NAME = "test157_acct"
PARTITION_NAME = "test157_part"
# ---------------------------------------------------------------------------
# Group D - fields outside the whitelist. Both the Lua hook below and the
# Group D test are generated from this table, so a field cannot gain a hook
# branch without also gaining coverage.
#
# (lua_field, sentinel, scontrol_field, sbatch args, expected seeded value,
# non-empty value the hook assigns to prove the setter is live)
#
# "{path}" in either the seed or the expected value is substituted with a
# per-test temporary path. The assigned value cannot use it: the Lua hook
# is generated once at import, before any test has a working directory.
# ---------------------------------------------------------------------------
_GROUP_D_CASES = [
# "" would revert these to a default, so each is seeded off it.
(
"partition",
"TEST_PARTITION_CLEAR",
"Partition",
f"--partition={PARTITION_NAME}",
PARTITION_NAME,
"primary",
),
(
"qos",
"TEST_QOS_CLEAR",
"QOS",
f"--account={ACCOUNT_NAME} --qos={QOS_NAME}",
QOS_NAME,
"normal",
),
(
"mail_user",
"TEST_MAILUSER_CLEAR",
"MailUser",
"--mail-user=before@example.com --mail-type=END",
"before@example.com",
"after@example.com",
),
# "" would clear these outright.
(
"std_out",
"TEST_STDOUT_CLEAR",
"StdOut",
"--output={path}",
"{path}",
"test157_stdout.set",
),
(
"std_err",
"TEST_STDERR_CLEAR",
"StdErr",
"--error={path}",
"{path}",
"test157_stderr.set",
),
(
"std_in",
"TEST_STDIN_CLEAR",
"StdIn",
"--input={path}",
"{path}",
"test157_stdin.set",
),
]
# The same rows keyed by the sentinel that assigns a value instead of "".
_GROUP_D_SET_CASES = [
(field, sentinel.replace("_CLEAR", "_SET"), show_field, seed, set_value)
for field, sentinel, show_field, seed, _, set_value in _GROUP_D_CASES
]
# Each field gets a clearing branch and an assigning one, so the guardrail
# is always paired with proof that the setter it guards is reachable.
_GROUP_D_BRANCHES = [
(field, sentinel, "") for field, sentinel, _, _, _, _ in _GROUP_D_CASES
] + [
(field, sentinel, set_value)
for field, sentinel, _, _, set_value in _GROUP_D_SET_CASES
]
_GROUP_D_LUA = "\n\n".join(
f' {"if" if index == 0 else "elseif"} c == "{sentinel}" then\n'
f' job_desc.{field} = "{value}"'
for index, (field, sentinel, value) in enumerate(_GROUP_D_BRANCHES)
)
# ---------------------------------------------------------------------------
# Lua hook content - kept inline so the test is self-contained and
# require_config_file() can install it as the slurm-user.
# ---------------------------------------------------------------------------
_LUA_CONTENT = f"""--[[
job_submit.lua for bug 25211 guardrail regression tests.
Sentinel values (passed via --comment / scontrol update Comment=<sentinel>):
Submit-path verification (Group C):
TEST_SUBMIT_FEATURES_EMPTY -- set features = "" in slurm_job_submit
TEST_SUBMIT_TRESPERNODE_EMPTY -- set tres_per_node = "" in slurm_job_submit
TEST_SUBMIT_GRES_EMPTY -- set gres = "" in slurm_job_submit
TEST_SUBMIT_COMMENT_EMPTY -- set comment = "" in slurm_job_submit
The Group D branches are generated from _GROUP_D_CASES: per field, a
_CLEAR sentinel assigning "" and a _SET sentinel assigning a value.
Non-empty assignment (Group E):
TEST_SET_FEATURES -- set features = "testfeat157" (E1)
TEST_SET_TRESPERNODE -- set tres_per_node = "gres/gpu:1" (E2)
TEST_SET_COMMENT -- set comment = <new value> (E3)
TEST_SUBMIT_SET_FEATURES -- set features on the submit path (E4)
--]]
function slurm_job_submit(job_desc, part_list, submit_uid)
-- Group C: on the submit path "" leaves the field NULL, so each of
-- these assignments must discard the value the user submitted.
if job_desc.comment == "TEST_SUBMIT_FEATURES_EMPTY" then
job_desc.features = ""
elseif job_desc.comment == "TEST_SUBMIT_TRESPERNODE_EMPTY" then
job_desc.tres_per_node = ""
elseif job_desc.comment == "TEST_SUBMIT_GRES_EMPTY" then
job_desc.gres = ""
elseif job_desc.comment == "TEST_SUBMIT_COMMENT_EMPTY" then
job_desc.comment = ""
elseif job_desc.comment == "TEST_SUBMIT_SET_FEATURES" then
job_desc.features = "testfeat157"
end
return slurm.SUCCESS
end
function slurm_job_modify(job_desc, job_rec, part_list, modify_uid)
local c = job_desc.comment or ""
-- Group D: these fields are not whitelisted, so "" drops to NULL inside
-- the plugin, while a non-empty value still reaches the controller.
{_GROUP_D_LUA}
-- Group E: a non-empty assignment must reach the controller.
elseif c == "TEST_SET_FEATURES" then
job_desc.features = "testfeat157"
elseif c == "TEST_SET_TRESPERNODE" then
job_desc.tres_per_node = "gres/gpu:1"
elseif c == "TEST_SET_COMMENT" then
job_desc.comment = "TEST_SET_COMMENT_DONE"
end
return slurm.SUCCESS
end
slurm.log_info("bug25211 guardrail test hook loaded")
return slurm.SUCCESS
"""
# ---------------------------------------------------------------------------
# Module-level fixture: install Lua plugin config and start Slurm.
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module", autouse=True)
def setup():
atf.require_config_parameter_includes("JobSubmitPlugins", "lua")
atf.require_config_file("job_submit.lua", _LUA_CONTENT)
# Accounting is required so a job's QOS is recorded and visible to
# scontrol for the D2 guardrail.
atf.require_accounting(modify=True)
# A node feature and a fake GPU gres let the Group C jobs submit with
# Features/TresPerNode already set, so the hook has a value to destroy.
atf.require_tty(0)
atf.require_config_parameter("Name", {"gpu": {"File": "/dev/tty0"}}, source="gres")
atf.require_config_parameter_includes("GresTypes", "gpu")
# An empty partition resolves to the cluster default, so the partition
# job must sit in a non-default partition for that revert to be
# observable.
atf.require_config_parameter(
"PartitionName",
{
"primary": {"Nodes": "ALL", "Default": "YES"},
PARTITION_NAME: {"Nodes": "ALL"},
},
)
atf.require_nodes(1, [("Gres", "gpu:1"), ("Features", "testfeat157")])
atf.require_slurm_running()
# The controller maps an empty qos to "revert to the user's default
# QOS", so the qos job must hold a non-default QOS for that revert to
# be observable.
slurm_user = atf.properties["slurm-user"]
test_user = atf.properties["test-user"]
atf.run_command(f"sacctmgr -i add qos {QOS_NAME}", user=slurm_user, fatal=True)
atf.run_command(
f"sacctmgr -i add account {ACCOUNT_NAME}", user=slurm_user, fatal=True
)
atf.run_command(
f"sacctmgr -i add user {test_user} account={ACCOUNT_NAME} "
f"qos={QOS_NAME},normal",
user=slurm_user,
fatal=True,
)
yield
atf.run_command(
f"sacctmgr -i remove user {test_user} account={ACCOUNT_NAME}",
user=slurm_user,
quiet=True,
)
atf.run_command(
f"sacctmgr -i remove account {ACCOUNT_NAME}", user=slurm_user, quiet=True
)
atf.run_command(f"sacctmgr -i remove qos {QOS_NAME}", user=slurm_user, quiet=True)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _submit_held(*, comment=None, extra_args=None):
"""Submit a held batch job and return its job id.
extra_args is appended verbatim, so a case needing its own --output
can pass one and it wins over the default.
"""
args = "--hold -n1 --output=/dev/null --wrap='true'"
if comment is not None:
args += f" --comment={comment}"
if extra_args:
args += f" {extra_args}"
return atf.submit_job_sbatch(args, fatal=True)
def _modify(job_id, **kwargs):
"""Run scontrol update on job_id with arbitrary key=value pairs."""
parts = [f"{k}={v}" for k, v in kwargs.items()]
return atf.run_command(
f"scontrol update JobId={job_id} " + " ".join(parts),
quiet=True,
)
def _field(job_id, param):
"""Return get_job_parameter result, or None if absent or (null)."""
return atf.get_job_parameter(job_id, param, default=None, quiet=True)
# ===========================================================================
# Group C - Submit-path behavior unchanged by the patch
# ===========================================================================
# job_submit_plugins.shtml documents that on the submit path assigning ""
# to a string field leaves it NULL, discarding whatever the user supplied,
# and that the modify path's per-field clear semantics do not apply there.
# The gres alias is sampled alongside tres_per_node because it is
# documented as clearable in its own right on the modify path.
#
# The comment case is the asymmetry at its sharpest: the same assignment
# that clears Comment on modify instead destroys the user's own --comment
# value here, which is the sentinel itself.
_GROUP_C_CASES = [
("features", "--constraint=testfeat157", "TEST_SUBMIT_FEATURES_EMPTY", "Features"),
("tres_per_node", "--gres=gpu:1", "TEST_SUBMIT_TRESPERNODE_EMPTY", "TresPerNode"),
("gres", "--gres=gpu:1", "TEST_SUBMIT_GRES_EMPTY", "TresPerNode"),
("comment", "", "TEST_SUBMIT_COMMENT_EMPTY", "Comment"),
]
@pytest.mark.parametrize(
"seed, sentinel, show_field",
[case[1:] for case in _GROUP_C_CASES],
ids=[case[0] for case in _GROUP_C_CASES],
)
def test_C_submit_becomes_null(seed, sentinel, show_field):
"""On submit, assigning "" leaves the field NULL, dropping the user value."""
job_id = atf.submit_job_sbatch(
f"--hold -n1 --output=/dev/null {seed} --comment={sentinel} --wrap='true'",
fatal=True,
)
after = _field(job_id, show_field)
assert after is None, f"{show_field} must be null on submit path, got {after!r}"
# ===========================================================================
# Group D - Non-whitelist guardrails
# ===========================================================================
@pytest.mark.parametrize(
"sentinel, show_field, seed, expected",
[case[1:5] for case in _GROUP_D_CASES],
ids=[case[0] for case in _GROUP_D_CASES],
)
def test_D_non_whitelist_empty_ignored(sentinel, show_field, seed, expected):
"""Lua assigns "" to a non-whitelisted field on modify -> field unchanged."""
path = f"{os.getcwd()}/{show_field}.path"
job_id = _submit_held(extra_args=seed.format(path=path))
before = _field(job_id, show_field)
want = expected.format(path=path)
assert (
before == want
), f"pre-condition: {show_field} must be {want!r}, got {before!r}"
result = _modify(job_id, Comment=sentinel)
assert result["exit_code"] == 0, (
f'scontrol update must succeed when Lua sets {show_field} to "", '
f"stderr: {result['stderr']}"
)
after = _field(job_id, show_field)
assert (
after == before
), f"{show_field} must be unchanged: expected {before!r}, got {after!r}"
# Without this, test_D_non_whitelist_empty_ignored would keep passing if the
# field's Lua setter were renamed or removed, since an assignment that never
# happens also leaves the field unchanged.
@pytest.mark.parametrize(
"sentinel, show_field, seed, set_value",
[case[1:] for case in _GROUP_D_SET_CASES],
ids=[case[0] for case in _GROUP_D_SET_CASES],
)
def test_D_non_whitelist_set_works(sentinel, show_field, seed, set_value):
"""Lua assigns a non-empty value to a non-whitelisted field -> it applies."""
path = f"{os.getcwd()}/{show_field}.path"
job_id = _submit_held(extra_args=seed.format(path=path))
result = _modify(job_id, Comment=sentinel)
assert result["exit_code"] == 0, (
f"scontrol update must succeed when Lua sets {show_field} to "
f"{set_value!r}, stderr: {result['stderr']}"
)
# scontrol resolves a relative stdio path against the job's working
# directory, which is where the job was submitted from.
want = f"{os.getcwd()}/{set_value}" if "{path}" in seed else set_value
after = _field(job_id, show_field)
assert after == want, f"{show_field} must be {want!r}, got {after!r}"
# ===========================================================================
# Group E - Non-empty assignment still takes effect
# ===========================================================================
# job_submit_plugins.shtml documents job_desc as input/output and the hook
# as able to modify the job parameters supplied by the user. Narrowing
# which fields "" clears must leave ordinary assignment working.
_GROUP_E_CASES = [
("features", "TEST_SET_FEATURES", "Features", "testfeat157"),
("tres_per_node", "TEST_SET_TRESPERNODE", "TresPerNode", "gres/gpu:1"),
("comment", "TEST_SET_COMMENT", "Comment", "TEST_SET_COMMENT_DONE"),
]
@pytest.mark.parametrize(
"sentinel, show_field, expected",
[case[1:] for case in _GROUP_E_CASES],
ids=[case[0] for case in _GROUP_E_CASES],
)
def test_E_set_on_modify(sentinel, show_field, expected):
"""Lua assigns a non-empty value on modify -> the field takes that value."""
job_id = _submit_held()
before = _field(job_id, show_field)
assert (
before != expected
), f"pre-condition: {show_field} must not already be {expected!r}"
result = _modify(job_id, Comment=sentinel)
assert result["exit_code"] == 0, f"scontrol update failed: {result['stderr']}"
after = _field(job_id, show_field)
assert after == expected, f"{show_field} must be {expected!r}, got {after!r}"
def test_E_set_on_submit():
"""Lua assigns a non-empty value on submit -> the field takes that value."""
job_id = atf.submit_job_sbatch(
"--hold -n1 --output=/dev/null --comment=TEST_SUBMIT_SET_FEATURES "
"--wrap='true'",
fatal=True,
)
features_after = _field(job_id, "Features")
assert (
features_after == "testfeat157"
), f"Features must be set on submit path, got {features_after!r}"