blob: d660b33421daabbe104e937da20fe5ecc468dac4 [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""Test that a step, and each component of a het step, runs in its own bundle.
A het step can mix runtimes per component: some under runtime/none, some under
runtime/oci with a --container bundle, some taking DefRuntimePlugin. Each
component given a bundle must run its tasks in that bundle, with
SLURM_CONTAINER naming it, and every other component must run its tasks on the
host without SLURM_CONTAINER. runtime/none and runtime/oci with no bundle both
run tasks on the host, so this does not tell those two apart.
salloc's interactive step is a separate srun that takes its container from its
own options or environment, not the job's, so it is tested here too, as is what
salloc exports to a command it runs.
No real container runtime is needed. With IgnoreFileConfigJson, runtime/oci
runs RunTimeRun as the job user without reading a config.json, so the one
configured here prints a marker file from the bundle's rootfs and the
SLURM_CONTAINER line of the task's environment file, then execs the task on
the host. A task's output therefore shows whether runtime/oci ran it, and
with which bundle.
"""
import collections
import os
import re
import pytest
import atf
task_marker = "task_ran"
# The het step with the most nodes below: 1+1+1+1+2.
max_nodes = 6
# Setup
@pytest.fixture(scope="module", autouse=True)
def setup():
# --runtime is parsed by the client, and client commands are upgraded
# after every daemon, so gating srun also covers slurmd and slurmctld.
atf.require_version(
(26, 11), "bin/srun", reason="Issue 50190: --runtime was added in 26.11"
)
# Components that name no --runtime must land on runtime/oci.
atf.require_config_parameter("DefRuntimePlugin", "runtime/oci")
# salloc only takes --container for an interactive step, and one that
# needs no terminal is enough to show where its task ran.
atf.require_config_parameter_includes("LaunchParameters", "use_interactive_step")
atf.require_config_parameter(
"InteractiveStepOptions", f'"--interactive --preserve-env echo {task_marker}"'
)
atf.require_config_file(
"oci.conf",
"IgnoreFileConfigJson=true\n"
"CreateEnvFile=newline\n"
'RunTimeRun="cat %b/rootfs/marker; grep ^SLURM_CONTAINER= %e; exec %@"\n',
)
# Only backfill starts het jobs, so run it every second.
atf.require_config_parameter_includes("SchedulerParameters", ("bf_interval", 1))
# A het job's components are co-scheduled, so each needs its own nodes.
atf.require_nodes(max_nodes)
atf.require_slurm_running()
@pytest.fixture(scope="module")
def bundles():
"""Two bundles in the test's cwd, each a rootfs holding only a marker
naming the bundle. --container takes an absolute path."""
paths = {}
for name in ("a", "b"):
bundle = os.path.abspath(f"bundle_{name}")
atf.run_command(
f"mkdir -p {bundle}/rootfs && echo in_rootfs_{name} > {bundle}/rootfs/marker",
fatal=True,
)
paths[name] = bundle
return paths
# Each component is (options, bundle name or None, node count). A component
# with no bundle runs on the host whatever its runtime: runtime/none because it
# ignores containers, and runtime/oci because it has no bundle to run in. The
# cases with one component show that a step that is not heterogeneous runs in
# its bundle too.
permutations = {
"oci": [("--runtime=oci --container={a}", "a", 1)],
"default_container": [("--container={a}", "a", 1)],
"none,oci": [
("--runtime=none", None, 1),
("--runtime=oci --container={a}", "a", 1),
],
"oci,none": [
("--runtime=oci --container={a}", "a", 1),
("--runtime=none", None, 1),
],
"oci_a,oci_b": [
("--runtime=oci --container={a}", "a", 1),
("--runtime=oci --container={b}", "b", 1),
],
# The mix from Issue 50190: DefRuntimePlugin with and without a bundle,
# interleaved with runtime/none, and a multi-node component.
"default,none,oci,none,default_container": [
("", None, 1),
("--runtime=none", None, 1),
("--runtime=oci --container={a}", "a", 1),
("--runtime none", None, 1),
("--container={b}", "b", 2),
],
}
@pytest.mark.parametrize("components", permutations.values(), ids=permutations.keys())
def test_step_runtime_per_component(bundles, components):
"""Every task shows exactly its own component's bundle, or none."""
args = " : ".join(
f"-N{nodes} {options.format(**bundles)}" for options, _, nodes in components
)
result = atf.run_command(
f"srun -l {args} /bin/sh -c 'echo {task_marker}; printenv SLURM_CONTAINER; true'"
)
assert result["exit_code"] == 0, f"srun failed: {result['stderr']}"
lines = collections.defaultdict(list)
for line in result["stdout"].splitlines():
match = re.match(r"^\s*(\d+): (.*)$", line)
if match:
lines[int(match.group(1))].append(match.group(2))
# srun -l numbers tasks across components in component order, one task
# per node here.
task = 0
for comp, (options, bundle, nodes) in enumerate(components):
if bundle:
expected = [
f"in_rootfs_{bundle}",
f"SLURM_CONTAINER={bundles[bundle]}",
task_marker,
]
else:
expected = [task_marker]
for _ in range(nodes):
assert sorted(lines[task]) == sorted(expected), (
f"task {task} of component {comp} ({options!r}) should show"
f" {expected}, got {lines[task]}"
f" (stdout {result['stdout']!r}, stderr {result['stderr']!r})"
)
task += 1
assert sorted(lines) == list(
range(task)
), f"expected output from tasks 0-{task - 1} only, got tasks {sorted(lines)}"
def test_salloc_interactive_step_runs_in_bundle(bundles):
"""salloc --container runs its interactive step in the bundle.
The interactive step's srun takes its container from its own options or
environment rather than the job's, so salloc has to hand the bundle to that
srun for the step to run in it.
"""
result = atf.run_command(f"salloc -N1 --container={bundles['a']}")
assert result["exit_code"] == 0, f"salloc failed: {result['stderr']}"
lines = result["stdout"].splitlines()
for expected in (
"in_rootfs_a",
f"SLURM_CONTAINER={bundles['a']}",
task_marker,
):
assert expected in lines, (
f"the interactive step should print {expected!r}"
f" (stdout {result['stdout']!r}, stderr {result['stderr']!r})"
)
@pytest.fixture
def no_interactive_step():
"""Drops use_interactive_step for one test. The other tests need it, so it
is put back afterwards."""
atf.remove_config_parameter_value("LaunchParameters", "use_interactive_step")
yield
atf.add_config_parameter_value("LaunchParameters", "use_interactive_step")
def test_salloc_container_requires_interactive_step(bundles, no_interactive_step):
"""salloc refuses --container without use_interactive_step.
salloc has no step of its own to run in a bundle, so it needs an
interactive step to hand --container to.
"""
result = atf.run_command(f"salloc -N1 --container={bundles['a']} true", xfail=True)
assert result["exit_code"] != 0, "salloc should refuse --container"
assert (
"--container requires LaunchParameters=use_interactive_step" in result["stderr"]
), f"salloc should say why it refused --container: {result['stderr']!r}"
def test_salloc_command_gets_container(bundles):
"""salloc --container gives the command it runs SLURM_CONTAINER.
salloc(1) lists SLURM_CONTAINER among the variables it sets for the
executed program when --container is given. The command runs under salloc
itself, outside any step, so it sees salloc's own environment.
"""
result = atf.run_command(
f"salloc -N1 --container={bundles['a']} printenv SLURM_CONTAINER"
)
assert result["exit_code"] == 0, f"salloc failed: {result['stderr']}"
assert result["stdout"].splitlines() == [bundles["a"]], (
f"the command should see SLURM_CONTAINER={bundles['a']}"
f" (stdout {result['stdout']!r}, stderr {result['stderr']!r})"
)
def test_salloc_command_gets_no_runtime_inputs(bundles):
"""salloc --container exports no runtime input variables to a command.
Only the interactive step's srun is given the bundle as a runtime input.
A command salloc runs must not get SRUN_CONTAINER, SBATCH_CONTAINER or
SALLOC_CONTAINER, or every srun, sbatch or salloc it starts would use the
bundle.
"""
result = atf.run_command(
f"salloc -N1 --container={bundles['a']} /bin/sh -c "
"'printenv SRUN_CONTAINER SBATCH_CONTAINER SALLOC_CONTAINER; true'"
)
assert result["exit_code"] == 0, f"salloc failed: {result['stderr']}"
assert result["stdout"] == "", (
"the command should get none of SRUN_CONTAINER, SBATCH_CONTAINER or"
" SALLOC_CONTAINER"
f" (stdout {result['stdout']!r}, stderr {result['stderr']!r})"
)
def _markers(output):
"""The bundle markers runtime/oci's RunTimeRun printed, one per run."""
return [line for line in output.splitlines() if line.startswith("in_rootfs_")]
# Each case is (batch components, bundle of the batch step or None, id). The
# batch step runs once, in the first component, so only that component's
# runtime and bundle apply to it. The batch script then runs a step of its own,
# which takes no bundle from the job.
batch_cases = {
"oci": (["--runtime=oci --container={a}"], "a"),
"default": (["--container={a}"], "a"),
"none": (["--runtime=none"], None),
"oci,oci": (
["--runtime=oci --container={a}", "--runtime=oci --container={b}"],
"a",
),
"oci,none": (["--runtime=oci --container={a}", "--runtime=none"], "a"),
"none,oci": (["--runtime=none", "--runtime=oci --container={b}"], None),
"default,oci": (["--container={a}", "--runtime=oci --container={b}"], "a"),
"none,oci,oci": (
[
"--runtime=none",
"--runtime=oci --container={a}",
"--runtime=oci --container={b}",
],
None,
),
}
@pytest.mark.parametrize(
"components, bundle", batch_cases.values(), ids=batch_cases.keys()
)
def test_sbatch_batch_step_uses_first_component(bundles, components, bundle):
"""The batch step of an sbatch job runs in its first component's bundle.
A heterogeneous batch job runs one batch script, in its first component, so
the runtime of any other component does not apply to it. A step the script
starts takes no bundle from the job, so it runs on the host.
"""
script = "batch_runtime.sh"
atf.make_bash_script(
script, f"echo {task_marker}\nsrun -N1 /bin/sh -c 'echo step_ran'\n"
)
output = "batch_runtime.out"
args = " : ".join(f"-N1 {c.format(**bundles)}" for c in components)
job_id = atf.submit_job_sbatch(f"{args} --output={output} {script}", fatal=True)
atf.wait_for_job_state(job_id, "COMPLETED", fatal=True)
lines = atf.run_command_output(f"cat {output}", fatal=True).splitlines()
for expected in (task_marker, "step_ran"):
assert expected in lines, f"the batch job should print {expected!r}: {lines}"
expected_markers = [f"in_rootfs_{bundle}"] if bundle else []
assert _markers("\n".join(lines)) == expected_markers, (
f"only the batch step should run in the first component's bundle,"
f" expected {expected_markers}, got {lines}"
)
if bundle:
assert (
f"SLURM_CONTAINER={bundles[bundle]}" in lines
), f"the batch step should see SLURM_CONTAINER={bundles[bundle]}: {lines}"
# Each case is (components, expected bundle of the interactive step or None).
# salloc starts one srun for its interactive step, and that srun runs in the
# first component, so salloc hands it only that component's bundle. The
# bundles of the others are ignored, whatever runtime they name. A component
# that names no --runtime takes DefRuntimePlugin, runtime/oci here.
salloc_mixes = {
"default_a,default_b": (["--container={a}", "--container={b}"], "a"),
"nothing,default_b": (["", "--container={b}"], None),
"oci,none": (["--runtime=oci --container={a}", "--runtime=none"], "a"),
"none,oci": (["--runtime=none", "--runtime=oci --container={b}"], None),
"oci,oci": (
["--runtime=oci --container={a}", "--runtime=oci --container={b}"],
"a",
),
"none,oci,oci": (
[
"--runtime=none",
"--runtime=oci --container={a}",
"--runtime=oci --container={b}",
],
None,
),
}
@pytest.mark.parametrize(
"components, bundle", salloc_mixes.values(), ids=salloc_mixes.keys()
)
def test_salloc_het_interactive_step_uses_first_component(bundles, components, bundle):
"""The interactive step of a het salloc runs in its first component's
bundle, with SLURM_CONTAINER naming it, or on the host when that
component has none. The bundles of the other components are ignored."""
args = " : ".join(f"-N1 {c.format(**bundles)}" for c in components)
result = atf.run_command(f"salloc {args}")
assert result["exit_code"] == 0, f"salloc failed: {result['stderr']}"
lines = result["stdout"].splitlines()
assert task_marker in lines, (
"the interactive step should have run"
f" (stdout {result['stdout']!r}, stderr {result['stderr']!r})"
)
# Only RunTimeRun prints SLURM_CONTAINER, so it cannot be checked on the host.
if bundle:
containers = [line for line in lines if line.startswith("SLURM_CONTAINER=")]
assert containers == [f"SLURM_CONTAINER={bundles[bundle]}"], (
f"the interactive step should see SLURM_CONTAINER={bundles[bundle]}"
f" (stdout {result['stdout']!r}, stderr {result['stderr']!r})"
)
expected = [f"in_rootfs_{bundle}"] if bundle else []
assert _markers(result["stdout"]) == expected, (
f"the interactive step should use the first component's bundle,"
f" expected {expected}"
f" (stdout {result['stdout']!r}, stderr {result['stderr']!r})"
)
def test_srun_runtime_env_mixes_with_options(bundles):
"""SRUN_RUNTIME applies to the components that name no --runtime, and a
component that names one overrides it.
The first component takes runtime/none from SRUN_RUNTIME, which ignores
SRUN_CONTAINER, so it runs on the host rather than in bundle b as
DefRuntimePlugin would. The second names runtime/oci and bundle a, so it
runs in it.
"""
result = atf.run_command(
f"srun -l -N1 : -N1 --runtime=oci --container={bundles['a']}"
f" /bin/sh -c 'echo {task_marker}'",
env_vars=f"SRUN_RUNTIME=none SRUN_CONTAINER={bundles['b']}",
)
assert result["exit_code"] == 0, f"srun failed: {result['stderr']}"
lines = collections.defaultdict(list)
for line in result["stdout"].splitlines():
match = re.match(r"^\s*(\d+): (.*)$", line)
if match:
lines[int(match.group(1))].append(match.group(2))
assert sorted(lines[0]) == [task_marker], (
"the first component should run on the host, not in bundle b, under"
" SRUN_RUNTIME=none"
f" (stdout {result['stdout']!r}, stderr {result['stderr']!r})"
)
assert sorted(lines[1]) == sorted(
["in_rootfs_a", f"SLURM_CONTAINER={bundles['a']}", task_marker]
), (
"the second component's --runtime=oci should beat SRUN_RUNTIME=none"
f" (stdout {result['stdout']!r}, stderr {result['stderr']!r})"
)