blob: b7c2c89b8744c42815c34156cfc48152b67b1008 [file] [edit]
############################################################################
# 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
E5 layout set on submit, read on submit, refused when malformed
(26.11 and later only)
"""
import os
import pytest
import atf
QOS_NAME = "test157_qos"
ACCOUNT_NAME = "test157_acct"
PARTITION_NAME = "test157_part"
LUA_LAYOUT = "lua157=set"
USER_LAYOUT = "user157=given"
requires_layout = pytest.mark.skipif(
atf.get_version("bin/sbatch") < (26, 11)
or atf.get_version("bin/scontrol") < (26, 11),
reason="Issue 51191: the job layout was added in 26.11",
)
# ---------------------------------------------------------------------------
# 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)
TEST_SUBMIT_SET_LAYOUT -- set layout = "{LUA_LAYOUT}" on submit (E5)
TEST_SUBMIT_READ_LAYOUT -- log_user the submitted layout (E5)
TEST_SUBMIT_BAD_LAYOUT -- set layout = "bad" on submit (E5)
--]]
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"
elseif job_desc.comment == "TEST_SUBMIT_SET_LAYOUT" then
job_desc.layout = "{LUA_LAYOUT}"
elseif job_desc.comment == "TEST_SUBMIT_READ_LAYOUT" then
slurm.log_user("layout is %s", job_desc.layout or "nil")
elseif job_desc.comment == "TEST_SUBMIT_BAD_LAYOUT" then
job_desc.layout = "bad"
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}"
@requires_layout
def test_E_set_layout_on_submit():
"""Lua assigns a layout on submit -> the job takes that layout."""
job_id = _submit_held(comment="TEST_SUBMIT_SET_LAYOUT")
layout = _field(job_id, "Layout")
assert layout == LUA_LAYOUT, f"Layout must be {LUA_LAYOUT!r}, got {layout!r}"
@requires_layout
def test_E_read_layout_on_submit():
"""Lua reads the layout the user submitted."""
result = atf.run_command(
f"sbatch --hold -n1 --output=/dev/null --layout={USER_LAYOUT} "
"--comment=TEST_SUBMIT_READ_LAYOUT --wrap='true'",
fatal=True,
)
assert (
f"layout is {USER_LAYOUT}" in result["stderr"]
), f"Lua should see the submitted layout, got: {result['stderr']}"
@requires_layout
def test_E_bad_layout_on_submit():
"""Lua assigns a malformed layout on submit -> the submit is refused."""
# No fatal=True: the refusal is what is being asserted.
result = atf.run_command(
"sbatch --hold -n1 --output=/dev/null "
"--comment=TEST_SUBMIT_BAD_LAYOUT --wrap='true'"
)
assert result["exit_code"] != 0, (
"slurmctld must refuse the malformed layout Lua set, got stdout: "
f"{result['stdout']}"
)
assert (
"Invalid layout" in result["stderr"]
), f"The refusal should say the layout is invalid, got: {result['stderr']}"