blob: 6d73c8240078d9a3ee506c457a4cd66bb747389e [file]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""Regression test for bug 25211 clearing job_desc fields via job_submit/lua.
job_submit/lua could not clear job string fields on slurm_job_modify by
assigning "" (empty string). The fix whitelists the fields whose
controller modify path has a clearing branch; assignment to other string
fields on modify remains a no-op.
Group A - whitelist (clearing must work via Lua hook setting "" on modify):
admin_comment, comment, cpus_per_tres, dependency, exc_nodes, extra,
features, licenses, mem_per_tres, prefer, req_nodes, reservation,
tres_bind, tres_freq, tres_per_job, tres_per_node, tres_per_socket,
tres_per_task, wckey. Several whitelisted fields can also be cleared
in a single slurm_job_modify() call, and a clear overrides a value the
user supplies in that same request.
Group B - gres legacy alias (= tres_per_node).
"""
import pytest
import atf
pytestmark = pytest.mark.slow
# ---------------------------------------------------------------------------
# Whitelisted fields. Both the Lua hook below and the Group A test are
# generated from this table, so a field cannot gain a hook branch without
# also gaining coverage.
#
# (lua_field, scontrol_field, seed_kind, seed)
#
# seed_kind "sbatch" seeds the field via submit arguments, "update" via a
# scontrol update. A None seed_kind means the seed or the trigger is
# irregular and the field has a hand-written test below.
# ---------------------------------------------------------------------------
_CLEAR_FIELDS = [
("admin_comment", "AdminComment", None, None),
("comment", "Comment", None, None),
("cpus_per_tres", "CpusPerTres", "update", "CpusPerTres=cpu:2"),
("dependency", "Dependency", None, None),
# On a single-node cluster this excludes the only node, so
# _release_and_verify() cannot pass unless the clear reached the
# scheduler rather than just the displayed field.
("exc_nodes", "ExcNodeList", "sbatch", "--exclude={node}"),
("extra", "Extra", "update", "Extra=extra_val"),
("features", "Features", "sbatch", "--constraint=testfeat157"),
("gres", "TresPerNode", None, None),
("licenses", "Licenses", "sbatch", "--licenses=test157_lic:1"),
("mem_per_tres", "MemPerTres", "update", "MemPerTres=cpu:100"),
("prefer", "Prefer", "sbatch", "--prefer=testpref157"),
("req_nodes", "ReqNodeList", "sbatch", "--nodelist={node}"),
("reservation", "Reservation", None, None),
("tres_bind", "TresBind", "update", "TresBind=gres/gpu:closest"),
("tres_freq", "TresFreq", "update", "TresFreq=gpu:1000"),
("tres_per_job", "TresPerJob", "update", "TresPerJob=cpu:2"),
("tres_per_node", "TresPerNode", "update", "TresPerNode=cpu:2"),
("tres_per_socket", "TresPerSocket", "update", "TresPerSocket=cpu:1"),
("tres_per_task", "TresPerTask", "update", "TresPerTask=cpu:1"),
("wckey", "WCKey", "sbatch", "--wckey=test157_wckey"),
]
# The hand-written test covering each None seed_kind row above, enforced in
# setup() so such a row cannot land without coverage.
_HAND_WRITTEN_TESTS = {
"admin_comment": "test_A_admin_comment_clear",
"comment": "test_A_comment_clear",
"dependency": "test_A_dependency_clear",
"gres": "test_B_gres_alias_clear",
"reservation": "test_A_reservation_clear",
}
# admin_comment and comment are triggered via job_desc.extra so the sentinel
# does not occupy the field being cleared.
_EXTRA_TRIGGERED = ("admin_comment", "comment")
# Fields the multi-field case clears in a single slurm_job_modify() call.
# Every other case clears exactly one field per call, so nothing else would
# catch a clear that only works in isolation.
#
# (lua_field, scontrol_field, sbatch seed)
_MULTI_FIELDS = [
("features", "Features", "--constraint=testfeat157"),
("licenses", "Licenses", "--licenses=test157_lic:1"),
("prefer", "Prefer", "--prefer=testpref157"),
]
_MULTI_SENTINEL = "TEST_MULTI_CLEAR"
# job_submit_plugins.shtml documents these three as resetting to an empty
# string, and every other clearable field as resetting to NULL.
_CLEARS_TO_EMPTY = ("admin_comment", "comment", "extra")
_SENTINEL = {
field: f"TEST_{field.upper().replace('_', '')}_CLEAR"
for field, _, _, _ in _CLEAR_FIELDS
}
_GROUP_A_CASES = [
(field, _SENTINEL[field], show_field, seed_kind, seed)
for field, show_field, seed_kind, seed in _CLEAR_FIELDS
if seed_kind is not None
]
_LUA_CLEARS = "\n\n".join(
f' {"if" if index == 0 else "elseif"} '
f'{"e" if field in _EXTRA_TRIGGERED else "c"} == '
f'"{_SENTINEL[field]}" then\n job_desc.{field} = ""'
for index, (field, _, _, _) in enumerate(_CLEAR_FIELDS)
)
_LUA_CLEARS += f'\n\n elseif c == "{_MULTI_SENTINEL}" then\n' + "\n".join(
f' job_desc.{field} = ""' for field, _, _ in _MULTI_FIELDS
)
_LUA_CONTENT = f"""--[[
job_submit.lua for bug 25211 regression tests.
slurm_job_modify clears a whitelisted field by assigning "" (empty
string). A single Lua file drives every subtest; the sentinel arrives
via scontrol update Comment=<sentinel>, except for admin_comment and
comment, which are triggered via Extra=<sentinel>.
--]]
function slurm_job_submit(job_desc, part_list, submit_uid)
return slurm.SUCCESS
end
function slurm_job_modify(job_desc, job_rec, part_list, modify_uid)
local c = job_desc.comment or ""
local e = job_desc.extra or ""
{_LUA_CLEARS}
end
return slurm.SUCCESS
end
slurm.log_info("bug25211 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_version(
(26, 11),
"sbin/slurmctld",
reason="Ticket 25211: job_submit/lua clear-on-empty modify lands in 26.11",
)
# _GROUP_A_CASES drops each None seed_kind row, so without this a new row
# would generate a Lua branch that nothing exercises.
uncovered = [
field
for field, _, seed_kind, _ in _CLEAR_FIELDS
if seed_kind is None and _HAND_WRITTEN_TESTS.get(field) not in globals()
]
assert not uncovered, f"clearable fields with no test: {uncovered}"
atf.require_config_parameter_includes("JobSubmitPlugins", "lua")
atf.require_config_file("job_submit.lua", _LUA_CONTENT)
atf.require_config_parameter_includes("Licenses", "test157_lic:10")
# Accounting + TrackWCKey are needed so a job's wckey is recorded and
# visible to scontrol; without it the wckey case cannot observe the
# field being cleared. wckeys must not be enforced, or the controller
# rejects clearing the wckey to "" with ESLURM_INVALID_WCKEY.
atf.require_accounting(modify=True)
atf.require_config_parameter("TrackWCKey", "Yes", source="slurmdbd")
atf.require_config_parameter("TrackWCKey", "Yes")
atf.require_config_parameter_excludes("AccountingStorageEnforce", "wckeys")
# A fake GPU gres lets the tres_bind / tres_freq cases populate
# TresBind/TresFreq without real accelerator hardware.
atf.require_config_parameter("SelectType", "select/cons_tres")
atf.require_config_parameter("SelectTypeParameters", "CR_CPU")
atf.require_tty(0)
atf.require_config_parameter("Name", {"gpu": {"File": "/dev/tty0"}}, source="gres")
atf.require_config_parameter_includes("GresTypes", "gpu")
atf.require_nodes(1, [("Gres", "gpu:1"), ("Features", "testfeat157,testpref157")])
atf.require_slurm_running()
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _submit_held(*, extra_args=None, comment=None):
"""Submit a held batch job and return its job id.
The payload is a trivial command so the job completes quickly once
released (see _release_and_verify).
"""
args = "--hold -n1 --output=/dev/null --wrap='true'"
if comment is not None:
args += f" --comment={comment}"
if extra_args is not None:
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)
def _expected_cleared(field):
"""The value a cleared whitelisted field reads back as.
job_submit_plugins.shtml documents admin_comment, comment and extra as
resetting to an empty string, and the rest as resetting to NULL.
"""
return "" if field in _CLEARS_TO_EMPTY else None
def _release_and_verify(job_id):
"""Release a held job and confirm it launches and completes cleanly.
Clearing a whitelisted field must never leave the job unable to start
(e.g. a slurmstepd failure on a now-NULL field), so each clear test
runs the job to completion and requires it to reach COMPLETED.
"""
atf.run_command(f"scontrol release {job_id}", fatal=True, quiet=True)
atf.wait_for_job_state(job_id, "COMPLETED", fatal=True)
def _get_usable_node():
"""Return the name of a node that can still run jobs."""
for name, info in atf.get_nodes(quiet=True).items():
if "IDLE" in info["state"]:
return name
pytest.fail("No usable node available")
# ===========================================================================
# Group A - Whitelist: clearing must work
# ===========================================================================
@pytest.mark.parametrize(
"field, sentinel, show_field, seed_kind, seed",
_GROUP_A_CASES,
ids=[row[0] for row in _GROUP_A_CASES],
)
def test_A_clear(field, sentinel, show_field, seed_kind, seed):
"""Lua sets a whitelisted field to "" on modify -> the field is cleared."""
if "{node}" in seed:
seed = seed.format(node=_get_usable_node())
if seed_kind == "sbatch":
job_id = _submit_held(extra_args=seed)
else:
job_id = _submit_held()
atf.run_command(
f"scontrol update JobId={job_id} {seed}", fatal=True, quiet=True
)
before = _field(job_id, show_field)
assert (
before is not None and before != ""
), f"pre-condition: {show_field} must be set, got {before!r}"
result = _modify(job_id, Comment=sentinel)
assert result["exit_code"] == 0, f"scontrol update failed: {result['stderr']}"
after = _field(job_id, show_field)
expected = _expected_cleared(field)
assert (
after == expected
), f"{show_field} must be {expected!r} once cleared, got {after!r}"
comment_after = _field(job_id, "Comment")
assert (
comment_after == sentinel
), f"clearing {field} must not disturb Comment, got {comment_after!r}"
_release_and_verify(job_id)
def test_A_multi_field_clear():
"""Lua clears several whitelisted fields in one modify -> all are cleared."""
job_id = _submit_held(extra_args=" ".join(seed for _, _, seed in _MULTI_FIELDS))
for _, show_field, _ in _MULTI_FIELDS:
before = _field(job_id, show_field)
assert (
before is not None and before != ""
), f"pre-condition: {show_field} must be set, got {before!r}"
result = _modify(job_id, Comment=_MULTI_SENTINEL)
assert result["exit_code"] == 0, f"scontrol update failed: {result['stderr']}"
for field, show_field, _ in _MULTI_FIELDS:
after = _field(job_id, show_field)
expected = _expected_cleared(field)
assert (
after == expected
), f"{show_field} must be {expected!r} once cleared, got {after!r}"
comment_after = _field(job_id, "Comment")
assert (
comment_after == _MULTI_SENTINEL
), f"a multi-field clear must not disturb Comment, got {comment_after!r}"
_release_and_verify(job_id)
def test_A_clear_overrides_same_request_value():
"""Lua clears a field the user sets in the same modify -> the clear wins."""
control_id = _submit_held()
result = _modify(control_id, Features="testfeat157", Comment="TEST_SAMERPC_CONTROL")
assert result["exit_code"] == 0, f"scontrol update failed: {result['stderr']}"
control = _field(control_id, "Features")
assert (
control == "testfeat157"
), f"pre-condition: Features must be set in the same request, got {control!r}"
_release_and_verify(control_id)
job_id = _submit_held()
result = _modify(job_id, Features="testfeat157", Comment=_SENTINEL["features"])
assert result["exit_code"] == 0, f"scontrol update failed: {result['stderr']}"
after = _field(job_id, "Features")
assert after is None, f"Features must be cleared, got {after!r}"
_release_and_verify(job_id)
def test_A_admin_comment_clear():
"""Lua sets admin_comment="" on modify -> AdminComment must be cleared."""
job_id = _submit_held()
# AdminComment requires admin (slurm-user) privileges to set.
atf.run_command(
f"scontrol update JobId={job_id} AdminComment=admin_val",
user=atf.properties["slurm-user"],
fatal=True,
quiet=True,
)
assert _field(job_id, "AdminComment") == "admin_val", "pre-condition"
# admin_comment is privileged, so the modify must also run as
# slurm-user for the controller to accept the resulting assignment.
result = atf.run_command(
f"scontrol update JobId={job_id} Extra={_SENTINEL['admin_comment']}",
user=atf.properties["slurm-user"],
quiet=True,
)
assert result["exit_code"] == 0, f"scontrol update failed: {result['stderr']}"
ac_after = _field(job_id, "AdminComment")
assert ac_after == _expected_cleared(
"admin_comment"
), f"AdminComment must be cleared, got {ac_after!r}"
extra_after = _field(job_id, "Extra")
assert (
extra_after == _SENTINEL["admin_comment"]
), f"clearing admin_comment must not disturb Extra, got {extra_after!r}"
_release_and_verify(job_id)
def test_A_comment_clear():
"""Lua sets comment="" on modify (triggered via Extra) -> Comment cleared."""
job_id = _submit_held(comment="some_comment")
assert _field(job_id, "Comment") == "some_comment", "pre-condition"
result = _modify(job_id, Extra=_SENTINEL["comment"])
assert result["exit_code"] == 0, f"scontrol update failed: {result['stderr']}"
comment_after = _field(job_id, "Comment")
assert comment_after == _expected_cleared(
"comment"
), f"Comment must be cleared, got {comment_after!r}"
extra_after = _field(job_id, "Extra")
assert (
extra_after == _SENTINEL["comment"]
), f"clearing comment must not disturb Extra, got {extra_after!r}"
_release_and_verify(job_id)
def test_A_clear_on_running_job():
"""Clearing a whitelisted field must also work on a RUNNING job.
Group A otherwise only modifies held jobs, but the documented
behavior is not qualified by job state and scontrol accepts Comment
on a running job.
"""
job_id = atf.submit_job_sbatch(
"-n1 --output=/dev/null --comment=some_comment --wrap='sleep infinity'",
fatal=True,
)
atf.wait_for_job_state(job_id, "RUNNING", fatal=True)
result = _modify(job_id, Extra=_SENTINEL["comment"])
assert result["exit_code"] == 0, f"scontrol update failed: {result['stderr']}"
comment_after = _field(job_id, "Comment")
assert comment_after == _expected_cleared(
"comment"
), f"Comment must be cleared on a running job, got {comment_after!r}"
extra_after = _field(job_id, "Extra")
assert (
extra_after == _SENTINEL["comment"]
), f"clearing comment must not disturb Extra, got {extra_after!r}"
def test_A_dependency_clear():
"""Lua sets dependency="" on modify -> Dependency must be cleared."""
# Depend on a real held job so the dependency is valid but stays
# unsatisfied, rather than relying on a non-existent job id.
dep_job_id = _submit_held()
job_id = _submit_held(extra_args=f"--dependency=afterany:{dep_job_id}")
dep_before = _field(job_id, "Dependency")
assert (
dep_before is not None and dep_before != ""
), f"pre-condition: Dependency must be set, got {dep_before!r}"
result = _modify(job_id, Comment=_SENTINEL["dependency"])
assert result["exit_code"] == 0, f"scontrol update failed: {result['stderr']}"
dep_after = _field(job_id, "Dependency")
assert dep_after == _expected_cleared(
"dependency"
), f"Dependency must be cleared, got {dep_after!r}"
comment_after = _field(job_id, "Comment")
assert (
comment_after == _SENTINEL["dependency"]
), f"clearing dependency must not disturb Comment, got {comment_after!r}"
_release_and_verify(job_id)
@pytest.fixture
def reservation():
"""Create a reservation for the test and delete it on teardown."""
resv_name = "test157_resv"
node = _get_usable_node()
atf.run_command(
f"scontrol create reservation reservationname={resv_name} "
f"user={atf.properties['test-user']} start=now duration=60 "
f"nodes={node} flags=ignore_jobs",
user=atf.properties["slurm-user"],
fatal=True,
quiet=True,
)
yield resv_name
atf.run_command(
f"scontrol delete reservation {resv_name}",
user=atf.properties["slurm-user"],
quiet=True,
)
def test_A_reservation_clear(reservation):
"""Lua sets reservation="" on modify -> Reservation must become (null)."""
job_id = _submit_held(extra_args=f"--reservation={reservation}")
resv_before = _field(job_id, "Reservation")
assert (
resv_before == reservation
), f"pre-condition: Reservation must be {reservation}, got {resv_before!r}"
result = _modify(job_id, Comment=_SENTINEL["reservation"])
assert result["exit_code"] == 0, f"scontrol update failed: {result['stderr']}"
resv_after = _field(job_id, "Reservation")
assert resv_after == _expected_cleared(
"reservation"
), f"Reservation must be cleared, got {resv_after!r}"
comment_after = _field(job_id, "Comment")
assert (
comment_after == _SENTINEL["reservation"]
), f"clearing reservation must not disturb Comment, got {comment_after!r}"
# The job is not run to completion here: the reservation still holds the
# cluster's only node, so a job no longer in the reservation would stay
# pending. Clearing a scheduler-side field cannot break job launch.
# ===========================================================================
# Group B - gres legacy alias
# ===========================================================================
def test_B_gres_alias_clear():
"""Lua sets gres="" (legacy alias) on modify -> TresPerNode must be cleared."""
job_id = _submit_held(extra_args="--gres=gpu:1")
tpn_before = _field(job_id, "TresPerNode")
assert (
tpn_before is not None and tpn_before != ""
), f"pre-condition: TresPerNode must be set, got {tpn_before!r}"
result = _modify(job_id, Comment=_SENTINEL["gres"])
assert result["exit_code"] == 0, f"scontrol update failed: {result['stderr']}"
tpn_after = _field(job_id, "TresPerNode")
assert tpn_after == _expected_cleared(
"gres"
), f"TresPerNode must be cleared via gres alias, got {tpn_after!r}"
comment_after = _field(job_id, "Comment")
assert (
comment_after == _SENTINEL["gres"]
), f"clearing gres must not disturb Comment, got {comment_after!r}"
_release_and_verify(job_id)