blob: eba7435a6593abb3fd64a6c66235d7895038ea2b [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""Test scontrol KV and KVPatch.
Cover jobs, nodes, partitions and reservations.
"""
import json
import shlex
import pytest
import atf
RESV_NAME = "resv_kv"
@pytest.fixture(scope="module", autouse=True)
def setup():
atf.require_version(
(26, 11), "bin/scontrol", reason="Issue 51034: KV was added in 26.11"
)
atf.require_slurm_running()
@pytest.fixture
def job_id():
"""Keep the job held so it stays pending throughout the test."""
return atf.submit_job_sbatch('--hold --wrap "sleep infinity"', fatal=True)
def kv_arg(tag, value):
"""Format a <tag>=<json> argument, quoted for the shell."""
return f"{tag}={shlex.quote(value)}"
def update(target, tag, value, fatal=True, **run_command_kwargs):
"""Run scontrol update <target> <tag>=<value>."""
return atf.run_command(
f"scontrol update {target} {kv_arg(tag, value)}",
fatal=fatal,
**run_command_kwargs,
)
def decode_kv(obj):
"""Return the KV document an object carries in --json output, or None."""
return obj.get("kv") or None
def job_kv(job_id):
return decode_kv(atf.get_jobs(job_id, use_json=True, fatal=True)[job_id])
def show_kv(entity, name, key):
output = atf.run_command_output(f"scontrol --json show {entity} {name}", fatal=True)
return decode_kv(json.loads(output)[key][0])
def test_job_kv_set_and_clear(job_id):
"""Verify KV= replaces the job's document and an empty KV= clears it."""
target = f"jobid={job_id}"
update(target, "KV", '{"a":1,"b":"x"}')
assert job_kv(job_id) == {"a": 1, "b": "x"}, "KV= should set the document"
update(target, "KV", '{"c":2}')
assert job_kv(job_id) == {"c": 2}, "KV= should replace the whole document"
update(target, "KV", "")
assert job_kv(job_id) is None, "An empty KV= should clear the document"
def test_job_kvpatch(job_id):
"""Verify KVPatch= applies every operation in order."""
target = f"jobid={job_id}"
update(target, "KV", '{"a":1}')
update(
target,
"KVPatch",
'[{"op":"add","path":"/b","value":2},'
'{"op":"replace","path":"/a","value":3}]',
)
assert job_kv(job_id) == {"a": 3, "b": 2}, "KVPatch= should apply every operation"
def test_job_kvpatch_no_change(job_id):
"""An unchanged nonempty patch returns SLURM_NO_CHANGE_IN_DATA."""
target = f"jobid={job_id}"
update(target, "KV", '{"a":1}')
result = update(
target,
"KVPatch",
'[{"op":"test","path":"/a","value":1}]',
xfail=True,
)
assert result["exit_code"] != 0, "No-change status should reach scontrol"
assert job_kv(job_id) == {"a": 1}, "A no-op patch must preserve the document"
def test_job_kvpatch_test_op(job_id):
"""Verify that a KVPatch that fails partway leaves the document unchanged.
The same compare-and-swap operation succeeds when the test matches.
"""
target = f"jobid={job_id}"
update(target, "KV", '{"state":"old"}')
result = update(
target,
"KVPatch",
'[{"op":"add","path":"/x","value":1},'
'{"op":"test","path":"/state","value":"other"}]',
xfail=True,
)
assert result["exit_code"] != 0, "A failing test operation should be rejected"
assert job_kv(job_id) == {
"state": "old"
}, "A rejected patch should not apply its earlier operations"
update(
target,
"KVPatch",
'[{"op":"test","path":"/state","value":"old"},'
'{"op":"replace","path":"/state","value":"new"}]',
)
assert job_kv(job_id) == {
"state": "new"
}, "A matching test should let the patch apply"
def test_job_kv_must_be_object(job_id):
"""Verify that a KV document that is not a JSON object is rejected."""
target = f"jobid={job_id}"
update(target, "KV", '{"a":1}')
result = update(target, "KV", "[1,2]", xfail=True)
assert result["exit_code"] != 0, "A KV that is not an object should be rejected"
assert job_kv(job_id) == {
"a": 1
}, "A rejected KV should leave the document unchanged"
def test_job_kv_size_limit(job_id):
"""Verify a KVPatch that takes the document over 1 MiB is refused."""
target = f"jobid={job_id}"
# One argument cannot hold 1 MiB, so grow the document 100 kB at a time.
# Ten values fit under the limit and the eleventh takes it over.
value = "x" * 100000
expected = {}
for i in range(10):
update(
target,
"KVPatch",
json.dumps([{"op": "add", "path": f"/k{i}", "value": value}]),
)
expected[f"k{i}"] = value
assert job_kv(job_id) == expected, "KVPatch should grow the document"
result = update(
target,
"KVPatch",
json.dumps([{"op": "add", "path": "/k10", "value": value}]),
xfail=True,
)
assert (
result["exit_code"] != 0
), "A KVPatch that takes the document over 1 MiB should be refused"
assert (
"byte limit" in result["stderr"]
), f"The refusal should name the limit: {result['stderr']!r}"
assert (
job_kv(job_id) == expected
), "A refused KVPatch should leave the document as it was"
@pytest.fixture
def admin():
return atf.properties["slurm-user"]
@pytest.fixture
def node(admin):
"""A node whose KV is cleared after the test."""
node = next(iter(atf.get_nodes(fatal=True)))
yield node
update(f"nodename={node}", "KV", "", user=admin)
@pytest.fixture
def partition(admin):
"""The default partition, with its KV cleared after the test."""
partition = atf.default_partition()
yield partition
update(f"partitionname={partition}", "KV", "", user=admin)
@pytest.fixture
def reservation(admin):
"""Create a reservation with KV={"c":3}, then delete it after the test."""
kv = kv_arg("KV", '{"c":3}')
atf.run_command(
f"scontrol create reservation reservationname={RESV_NAME} "
f"starttime=now+1hour duration=10 nodecnt=1 users={admin} {kv}",
user=admin,
fatal=True,
)
yield RESV_NAME
atf.run_command(f"scontrol delete reservation={RESV_NAME}", user=admin, fatal=True)
@pytest.mark.parametrize(
"entity,key",
[("node", "nodes"), ("partition", "partitions"), ("reservation", "reservations")],
)
def test_kv_operations(request, admin, entity, key):
"""Verify KV=, KVPatch= and clearing on other objects."""
name = request.getfixturevalue(entity)
target = f"{entity}name={name}"
if entity == "reservation":
assert show_kv(entity, name, key) == {
"c": 3
}, "KV= on create should set the document"
update(target, "KV", '{"a":1}', user=admin)
assert show_kv(entity, name, key) == {"a": 1}, "KV= should replace the document"
result = update(
target,
"KVPatch",
'[{"op":"test","path":"/a","value":1}]',
user=admin,
xfail=True,
)
assert result["exit_code"] != 0, "No-change status should reach scontrol"
assert show_kv(entity, name, key) == {"a": 1}, "A no-op must preserve the document"
update(target, "KVPatch", '[{"op":"add","path":"/b","value":2}]', user=admin)
assert show_kv(entity, name, key) == {
"a": 1,
"b": 2,
}, "KVPatch= should add the key"
update(target, "KVPatch", '[{"op":"remove","path":"/a"}]', user=admin)
assert show_kv(entity, name, key) == {"b": 2}, "KVPatch= should remove the key"
update(target, "KV", "", user=admin)
assert show_kv(entity, name, key) is None, "An empty KV= should clear the document"