blob: ce463a3595761840224d06df683db50e1302f19e [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""gres.conf UUID gives devices a UUID without AutoDetect.
A device UUID is otherwise only obtainable through AutoDetect. The UUID option
sets it straight from gres.conf, so Flags=env_uuid can put the UUID in the
vendor environment variables, instead of the numeric device index, with
AutoDetect=off.
There is deliberately no fake_gpus.conf here: nothing autodetects, so every UUID
seen by the tests can only have come from gres.conf.
"""
import re
import pytest
import atf
GPU_0 = "GPU-11111111-1111-1111-1111-111111111111"
GPU_1 = "GPU-22222222-2222-2222-2222-222222222222"
GRES_CONF = (
"AutoDetect=off\n"
f"Name=gpu File=/dev/tty0 Flags=env_uuid UUID={GPU_0}\n"
f"Name=gpu File=/dev/tty1 Flags=env_uuid UUID={GPU_1}\n"
)
@pytest.fixture(scope="module", autouse=True)
def setup():
atf.require_version(
(26, 11), "sbin/slurmd", reason="MR 4271: gres.conf UUID= is new in 26.11"
)
atf.require_config_parameter("SelectType", "select/cons_tres")
atf.require_config_parameter("SelectTypeParameters", "CR_CPU")
atf.require_config_parameter_includes("GresTypes", "gpu")
atf.require_config_parameter_includes("SlurmdParameters", "config_overrides")
atf.require_config_parameter_includes("GresTypes", "nic")
atf.require_config_parameter_includes("GresTypes", "shard")
atf.require_config_parameter("MaxNodeCount", 4)
atf.require_config_parameter("TreeWidth", 65533)
atf.require_config_parameter_includes("SlurmctldParameters", "cloud_reg_addrs")
atf.require_config_parameter_includes("DebugFlags", "Gres")
# UUID fatals run a throwaway slurmd; systemd scopes are not required.
atf.require_config_parameter("IgnoreSystemd", "yes", source="cgroup")
atf.require_tty(0)
atf.require_tty(1)
atf.require_config_file("gres.conf", GRES_CONF)
atf.require_nodes(1, [("Gres", "gpu:2"), ("CPUs", 2)])
atf.require_slurm_running()
@pytest.mark.parametrize("var", ["CUDA_VISIBLE_DEVICES", "ROCR_VISIBLE_DEVICES"])
def test_uuid_only_used_for_runtimes_that_accept_it(var):
"""Verify the runtimes that understand "GPU-<uuid>" get a UUID that only
gres.conf could supply.
No vendor env flag is set in gres.conf, so all four visible-devices
variables are set by default and this pins down which of them env_uuid is
allowed to rewrite.
"""
output = atf.run_job_output(f"-n1 --gpus=1 printenv {var}", fatal=True)
value = output.strip()
assert value in (
GPU_0,
GPU_1,
), f"{var} should be a configured UUID, got: {value!r}"
@pytest.mark.parametrize("var", ["ZE_AFFINITY_MASK", "GPU_DEVICE_ORDINAL"])
def test_uuid_not_used_for_runtimes_that_reject_it(var):
"""Verify index-only runtimes still get an index, not a UUID.
Level Zero takes a device (or device.subdevice) index and OpenCL takes an
ordinal. Neither can parse a UUID, so env_uuid must leave these two alone.
"""
output = atf.run_job_output(f"-n1 --gpus=1 printenv {var}", fatal=True)
value = output.strip()
assert re.fullmatch(
r"\d+", value
), f"{var} must stay a numeric index, got: {value!r}"
def test_env_uuid_lists_both_configured_uuids():
"""Verify a two-GPU job sees both configured UUIDs"""
output = atf.run_job_output(
"-n1 --gpus=2 printenv CUDA_VISIBLE_DEVICES",
fatal=True,
)
value = output.strip()
assert value in (
f"{GPU_0},{GPU_1}",
f"{GPU_1},{GPU_0}",
), f"CUDA_VISIBLE_DEVICES should list both UUIDs, got: {value!r}"
def _slurmd_pids(node_name):
result = atf.run_command(f"pgrep -f 'slurmd -N {node_name}'", quiet=True)
if result["exit_code"] != 0:
return []
return [int(pid) for pid in result["stdout"].splitlines()]
def _stop_slurmd(node_name):
"""Stop slurmd for node_name, escalating to SIGKILL if it won't exit."""
pids = _slurmd_pids(node_name)
if not pids:
return
atf.run_command(
f"kill -TERM {' '.join(str(pid) for pid in pids)}", user="root", quiet=True
)
for _ in atf.timer():
pids = _slurmd_pids(node_name)
if not pids:
return
else:
atf.run_command(
f"kill -KILL {' '.join(str(pid) for pid in pids)}",
user="root",
quiet=True,
)
def _run_throwaway_slurmd_gres(gres_conf, extra_args="-G -v", conf=None, xfail=True):
"""Overwrite gres.conf and run a throwaway slurmd against it.
The running test slurmd already has the module gres.conf in memory, so
swapping the file only affects this extra process. Restore afterwards.
`-G` loads GRES and exits, which is enough to hit UUID validation.
`conf` supplies the dynamic node's own resources. Without it slurmd has no
expected GRES count for this node and discards every record the gres.conf
declares, which is invisible to a test that only inspects the exit code.
"""
gres_path = f"{atf.properties['slurm-config-dir']}/gres.conf"
slurm_user = atf.properties["slurm-user"]
orig = atf.run_command_output(
f"cat {gres_path}", user=slurm_user, fatal=True, quiet=True
)
node_name = "UUIDfatal"
slurmd = f"{atf.properties['slurm-sbin-dir']}/slurmd"
try:
atf.run_command(
f"cat > {gres_path}",
input=gres_conf,
user=slurm_user,
fatal=True,
quiet=True,
)
conf_arg = f' --conf "{conf}"' if conf else ""
result = atf.run_command(
f"{slurmd} -N {node_name} -Z{conf_arg} {extra_args}",
user="root",
xfail=xfail,
quiet=True,
)
# -G logs at LOG_LEVEL_QUIET, so everything lands on stdout/stderr.
return result, f"{result['stdout']}\n{result['stderr']}"
finally:
atf.run_command(
f"cat > {gres_path}",
input=orig,
user=slurm_user,
fatal=True,
quiet=True,
)
_stop_slurmd(node_name)
def _uuid_records(haystack, uuid):
"""Return the set of GRES names whose slurmd -G record carries `uuid`.
slurmd -G prints one "Gres Name=<name> ... UUID=<value>" line per device,
and omits the UUID= field entirely when the device has none. Flags= carries
a separate flag literally spelled UUID, so match on "UUID=" plus the value
rather than on the bare word.
"""
return {
match.group(1)
for match in re.finditer(
rf"Gres Name=(\S+)[^\n]*\bUUID={re.escape(uuid)}(?:\s|$)", haystack
)
}
def _assert_slurmd_fatal_on_gres(gres_conf, needle, conf=None):
"""Overwrite gres.conf, start a throwaway slurmd, expect a UUID fatal."""
result, haystack = _run_throwaway_slurmd_gres(gres_conf, conf=conf)
assert result["exit_code"] not in (
0,
110,
), (
f"slurmd should have fataled on invalid UUID, "
f"rc={result['exit_code']}, output={haystack!r}"
)
assert (
needle in haystack
), f"expected {needle!r} in slurmd fatal output, got: {haystack!r}"
@pytest.fixture
def duplicate_cloud_gres():
"""Install duplicate cloud-node UUIDs and restore gres.conf."""
node_name = "UUIDcloud"
gres_path = f"{atf.properties['slurm-config-dir']}/gres.conf"
slurm_user = atf.properties["slurm-user"]
orig = atf.run_command_output(
f"cat {gres_path}", user=slurm_user, fatal=True, quiet=True
)
duplicate_gres = (
"AutoDetect=off\n"
f"NodeName={node_name} Name=gpu File=/dev/tty0 UUID={GPU_0}\n"
f"NodeName={node_name} Name=gpu File=/dev/tty1 UUID={GPU_0}\n"
)
atf.run_command(
f"cat > {gres_path}",
input=duplicate_gres,
user=slurm_user,
fatal=True,
quiet=True,
)
yield node_name
atf.run_command(
f"cat > {gres_path}",
input=orig,
user=slurm_user,
fatal=True,
quiet=True,
)
atf.run_command(
f"scontrol delete NodeName={node_name}",
user=slurm_user,
xfail=True,
quiet=True,
)
def test_uuid_rejects_file_range():
"""Verify UUID cannot sit on a File= that names more than one device"""
_assert_slurmd_fatal_on_gres(
"AutoDetect=off\n" f"Name=gpu File=/dev/tty[0-1] UUID={GPU_0}\n",
"UUID requires a single device file",
)
def test_uuid_rejects_missing_file():
"""Verify UUID without File or MultipleFiles is rejected"""
_assert_slurmd_fatal_on_gres(
f"AutoDetect=off\nName=gpu UUID={GPU_0}\n",
"UUID requires File or MultipleFiles",
)
def test_uuid_rejects_comma():
"""Verify UUID may not contain a comma (it is consumed as a list)"""
_assert_slurmd_fatal_on_gres(
"AutoDetect=off\n" f"Name=gpu File=/dev/tty0 UUID={GPU_0},{GPU_1}\n",
"may not contain a comma",
)
def test_uuid_rejects_empty():
"""Verify UUID= with no value is rejected.
An empty UUID= is a gres.conf parse error (the value never reaches
the UUID-is-empty fatal), but it must still keep slurmd from starting.
"""
result, haystack = _run_throwaway_slurmd_gres(
"AutoDetect=off\nName=gpu File=/dev/tty0 UUID=\n",
)
assert result["exit_code"] not in (
0,
110,
), (
f"slurmd should have fataled on empty UUID, "
f"rc={result['exit_code']}, output={haystack!r}"
)
assert (
"Parse error" in haystack and "UUID=" in haystack
), f"expected an empty-UUID parse error, got: {haystack!r}"
def test_uuid_rejects_duplicates():
"""Verify two devices of the same GRES name may not share a UUID"""
_assert_slurmd_fatal_on_gres(
"AutoDetect=off\n"
f"Name=gpu File=/dev/tty0 UUID={GPU_0}\n"
f"Name=gpu File=/dev/tty1 UUID={GPU_0}\n",
"Duplicate UUID",
)
def test_slurmctld_rejects_duplicate_uuids_without_exiting(
duplicate_cloud_gres,
):
"""Verify a bad cloud node is rejected without killing slurmctld."""
node_name = duplicate_cloud_gres
slurm_user = atf.properties["slurm-user"]
result = atf.run_command(
f"scontrol create NodeName={node_name} State=cloud",
user=slurm_user,
xfail=True,
quiet=True,
)
assert result["exit_code"] != 0, (
"slurmctld should reject a cloud node with duplicate UUIDs; " f"result={result}"
)
assert (
atf.run_command_exit("scontrol ping", user=slurm_user, quiet=True) == 0
), "slurmctld exited while rejecting a duplicate UUID"
assert (
node_name not in atf.get_nodes()
), f"cloud node {node_name} remained after its gres.conf was rejected"
def test_uuid_accepted_with_multiple_files():
"""Verify UUID is valid on MultipleFiles (one physical device).
Accepting the record is only half the promise: slurmd exits 0 whenever GRES
parsing completes, so rc alone cannot tell "UUID kept" from "UUID parsed and
dropped". gres.conf(5) says slurmd -G displays the UUID, so assert it is
there.
"""
result, haystack = _run_throwaway_slurmd_gres(
"AutoDetect=off\n" f"Name=gpu MultipleFiles=/dev/tty0,/dev/tty1 UUID={GPU_0}\n",
conf="Gres=gpu:1 CPUs=2",
xfail=False,
)
assert result["exit_code"] == 0, (
f"UUID with MultipleFiles should be accepted, "
f"rc={result['exit_code']}, output={haystack!r}"
)
assert _uuid_records(haystack, GPU_0), (
f"UUID {GPU_0} should be retained on the MultipleFiles record, "
f"output={haystack!r}"
)
def test_uuid_duplicate_ok_across_gres_names():
"""Verify UUID uniqueness is per GRES name, not cluster-wide.
Both records must keep the UUID; accepting the config while silently
dropping one of them would also exit 0.
"""
result, haystack = _run_throwaway_slurmd_gres(
"AutoDetect=off\n"
f"Name=gpu File=/dev/tty0 UUID={GPU_0}\n"
f"Name=nic File=/dev/tty1 UUID={GPU_0}\n",
conf="Gres=gpu:1,nic:1 CPUs=2",
xfail=False,
)
assert result["exit_code"] == 0, (
f"the same UUID on gpu and nic should be accepted, "
f"rc={result['exit_code']}, output={haystack!r}"
)
names = _uuid_records(haystack, GPU_0)
assert "gpu" in names and "nic" in names, (
f"both the gpu and nic records should keep UUID {GPU_0}, "
f"got records {names}, output={haystack!r}"
)
def test_uuid_rejects_duplicates_on_non_gpu_gres():
"""Verify UUID duplicate validation is not gpu-specific.
gres.conf(5) documents no exemption for other GRES types, and the
gres.conf parser validates UUID= for every Name=/NodeName= record
regardless of GRES name.
"""
_assert_slurmd_fatal_on_gres(
"AutoDetect=off\n"
f"Name=nic File=/dev/tty0 UUID={GPU_0}\n"
f"Name=nic File=/dev/tty1 UUID={GPU_0}\n",
"Duplicate UUID",
conf="Gres=nic:2 CPUs=2",
)
def test_uuid_rejects_comma_on_non_gpu_gres():
"""Verify UUID comma validation is not gpu-specific.
Same contract as above: the comma check in the gres.conf parser runs
before any GRES-type-specific handling, so it applies uniformly.
"""
_assert_slurmd_fatal_on_gres(
"AutoDetect=off\n" f"Name=nic File=/dev/tty0 UUID={GPU_0},{GPU_1}\n",
"may not contain a comma",
conf="Gres=nic:1 CPUs=2",
)
def test_uuid_on_shared_gres_with_count():
"""Verify a shared GRES may carry a UUID with Count greater than one.
gres.conf(5) makes the shared GRES an explicit exception to the
single-device rule: it "may still use a Count greater than one, since that
count describes shared TRES units carved out of the single device".
"""
result, haystack = _run_throwaway_slurmd_gres(
"AutoDetect=off\n"
f"Name=gpu File=/dev/tty0 UUID={GPU_0}\n"
f"Name=shard Count=4 File=/dev/tty0 UUID={GPU_0}\n",
conf="Gres=gpu:1,shard:4 CPUs=2",
xfail=False,
)
assert result["exit_code"] == 0, (
f"a shared GRES with Count>1 and a UUID should be accepted, "
f"rc={result['exit_code']}, output={haystack!r}"
)
names = _uuid_records(haystack, GPU_0)
assert "shard" in names, (
f"the shard record should carry UUID {GPU_0}, "
f"got records {names}, output={haystack!r}"
)
def test_uuid_on_shared_gres_without_sharing_uuid():
"""Verify a UUID set only on the shared GRES record is kept.
gres.conf(5) names the shared GRES as a consumer of the value, so a UUID
configured on the shard line is a value the admin expects to take effect.
"""
result, haystack = _run_throwaway_slurmd_gres(
"AutoDetect=off\n"
"Name=gpu File=/dev/tty0\n"
f"Name=shard Count=4 File=/dev/tty0 UUID={GPU_0}\n",
conf="Gres=gpu:1,shard:4 CPUs=2",
xfail=False,
)
assert result["exit_code"] == 0, (
f"a UUID on the shared GRES alone should be accepted, "
f"rc={result['exit_code']}, output={haystack!r}"
)
names = _uuid_records(haystack, GPU_0)
assert "shard" in names, (
f"the shard record should keep the UUID it configured, "
f"got records {names}, output={haystack!r}"
)
def test_uuid_conflict_between_shared_and_sharing_gres():
"""Verify a shared GRES UUID that disagrees with its GPU is rejected.
gres.conf(5) says a shared GRES inherits the UUID of the device it shares
and that a UUID configured on its own line must match, so a disagreeing
value stops slurmd rather than being silently discarded.
"""
_assert_slurmd_fatal_on_gres(
"AutoDetect=off\n"
f"Name=gpu File=/dev/tty0 UUID={GPU_0}\n"
f"Name=shard Count=4 File=/dev/tty0 UUID={GPU_1}\n",
"does not match UUID=",
conf="Gres=gpu:1,shard:4 CPUs=2",
)
def test_uuid_rejects_count_on_unshared_gres():
"""Verify Count greater than one is rejected on a non-shared GRES.
gres.conf(5) states the shared-GRES Count exception without stating the
rule it excepts, so pin the rule the exception implies.
"""
_assert_slurmd_fatal_on_gres(
"AutoDetect=off\n" f"Name=gpu File=/dev/tty0 Count=2 UUID={GPU_0}\n",
"count does not match File value",
)
def test_uuid_duplicate_ok_across_nodes():
"""Verify the same UUID may be used on a device of two different nodes.
gres.conf(5) scopes uniqueness to "all devices of the same GRES name on a
node", so a vendor serial that repeats across nodes is legal. Records for
other nodes are filtered out before the duplicate check runs.
"""
result, haystack = _run_throwaway_slurmd_gres(
"AutoDetect=off\n"
f"NodeName=UUIDfatal Name=gpu File=/dev/tty0 UUID={GPU_0}\n"
f"NodeName=UUIDother Name=gpu File=/dev/tty0 UUID={GPU_0}\n",
conf="Gres=gpu:1 CPUs=2",
xfail=False,
)
assert result["exit_code"] == 0, (
f"the same UUID on two different nodes should be accepted, "
f"rc={result['exit_code']}, output={haystack!r}"
)
assert _uuid_records(haystack, GPU_0) == {
"gpu"
}, f"this node's gpu should keep UUID {GPU_0}, output={haystack!r}"
@pytest.mark.parametrize(
"uuid",
[
"GPU-0123456789abcdef",
"8680a156-0800-0000-0300-000000000000",
"0123456789abcdef",
],
ids=["rsmi", "oneapi", "nrt"],
)
def test_uuid_accepts_any_autodetect_format(uuid):
"""Verify UUID imposes no format of its own.
gres.conf(5) says "the exact format varies by device and AutoDetect
mechanism", and the mechanisms disagree: rsmi zero-pads behind GPU-,
oneapi emits 8-4-4-4-12 with no prefix, nrt emits a raw serial. A
configured value has to match whatever AutoDetect reports, so the option
must accept all of them verbatim.
"""
result, haystack = _run_throwaway_slurmd_gres(
"AutoDetect=off\n" f"Name=gpu File=/dev/tty0 UUID={uuid}\n",
conf="Gres=gpu:1 CPUs=2",
xfail=False,
)
assert result["exit_code"] == 0, (
f"UUID {uuid!r} should be accepted, "
f"rc={result['exit_code']}, output={haystack!r}"
)
assert "gpu" in _uuid_records(
haystack, uuid
), f"UUID {uuid!r} should be retained verbatim, output={haystack!r}"