blob: 674b079ba78778736a7c8c463008ce7c66ed829e [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""Validate sbatch --external handling across job and hetjob shapes.
--external comes from the command line or from #SBATCH directives. Each case
checks one of three outcomes: sbatch refuses the submission itself, the
controller refuses it, or the job is created with a particular shape. Created
jobs are inspected while still PENDING and then cancelled, so nothing waits
on a backfill cycle.
BatchFlag is the discriminator throughout: a component that got EXTERNAL_JOB
has no script and reports BatchFlag=0, while one that did not reports
BatchFlag=1.
"""
import re
import pytest
import atf
# One external node per external component of the widest hetjob here
external_nodes = ["extnode1", "extnode2", "extnode3"]
# The one node that runs a slurmd, for components that carry a batch script
compute_node = "node1"
external_partition = "external"
compute_partition = "primary"
@pytest.fixture(scope="module", autouse=True)
def setup():
# slurmctld is always at least as new as sbatch across supported
# upgrades, so gating on sbatch covers the controller side too.
atf.require_version(
(26, 5, 3),
"bin/sbatch",
reason="Issue 50997: per-component --external is available in 26.05.3",
)
atf.require_config_parameter("SelectType", "select/cons_tres")
# Overwrite rather than append: the CR_* allocation types are mutually
# exclusive, so adding CR_CPU to a config already setting CR_Core_Memory
# yields "cr_core_memory,CR_CPU", which slurmctld rejects.
atf.require_config_parameter("SelectTypeParameters", "CR_CPU")
# This module starts no slurmdbd
atf.require_accounting(enabled=False)
# Static EXTERNAL nodes: a node created with "scontrol create" is also
# flagged DYNAMIC_NORM, which drags the allocation path into alias and
# address lookups that have nothing to resolve for an external node.
#
# The compute node needs NodeAddr because slurmctld has to reach its
# slurmd and the bare name does not resolve here. The external nodes are
# deliberately left without one, since nothing should contact a node that
# has no slurmd.
node_config = {compute_node: {"CPUs": 4, "NodeAddr": "localhost"}}
for node_name in external_nodes:
node_config[node_name] = {"CPUs": 4, "State": "EXTERNAL"}
atf.require_config_parameter("NodeName", node_config)
atf.require_config_parameter(
"PartitionName",
{
compute_partition: {"Nodes": compute_node, "Default": "YES"},
external_partition: {"Nodes": ",".join(external_nodes)},
},
)
# require_slurm_running() starts a slurmd for every NodeName line, and an
# EXTERNAL node has none, so start the controller and only the compute
# node's slurmd, as test_141_1 does for static CLOUD nodes.
atf.start_slurmctld(clean=True)
atf.start_slurmd(compute_node, wait="idle")
yield
# stop_slurmctld() returns failures rather than failing
failures = atf.stop_slurmctld(also_slurmds=True)
assert failures is None, f"Failures stopping the daemons: {failures}"
@pytest.fixture(autouse=True)
def submitted_jobs():
"""Collect this test's leader job ids and cancel them afterwards.
Only leaders: scancel refuses a pending non-leader hetjob component.
"""
leader_job_ids = []
yield leader_job_ids
if leader_job_ids:
atf.cancel_jobs(leader_job_ids, fatal=True, quiet=True)
@pytest.fixture
def script():
"""A batch script in the test's own working directory."""
script_name = "job.sh"
atf.make_bash_script(script_name, "sleep infinity")
return script_name
@pytest.fixture
def directive_script():
"""Return a function writing a script that defines its own components.
Each argument gives one component's options. sbatch splits them on the
"#SBATCH hetjob" directives between them, so the resulting command line
carries no ":" at all.
"""
def _directive_script(*component_options):
script_name = "hetjob.sh"
directives = "\n#SBATCH hetjob\n".join(
f"#SBATCH {options}" for options in component_options
)
atf.make_bash_script(script_name, f"{directives}\nsleep infinity\n")
return script_name
return _directive_script
def submit_refused(args):
"""Run sbatch, failing the test unless it refuses. Returns the result."""
return atf.run_command(f"sbatch {args}", xfail=True, fatal=True)
def submit_refused_by_controller(args):
"""Run sbatch expecting slurmctld to refuse the job. Returns the result."""
result = submit_refused(args)
assert (
"Submitted batch job" not in result["stdout"]
), f"sbatch reported a job: {result['stdout']!r}"
# The refusal must leave slurmctld running
assert atf.is_slurmctld_running(), "slurmctld stopped answering after the refusal"
return result
@pytest.fixture
def submit_ok(submitted_jobs):
"""Return a function running sbatch and returning the leader job id."""
def _submit_ok(args):
result = atf.run_command(f"sbatch {args}", fatal=True)
match = re.search(r"Submitted batch job (\d+)", result["stdout"])
assert match, f"sbatch printed no job id: {result['stdout']!r}"
leader_job_id = int(match.group(1))
submitted_jobs.append(leader_job_id)
return leader_job_id
return _submit_ok
def components(leader_job_id):
"""Return the job's components in offset order, leader first."""
jobs = atf.get_jobs(fatal=True)
assert leader_job_id in jobs, f"Job {leader_job_id} not found"
leader = jobs[leader_job_id]
if not leader.get("HetJobId"):
return [leader]
return [jobs[job_id] for job_id in sorted(atf.range_to_list(leader["HetJobIdSet"]))]
def batch_flags(leader_job_id):
"""Return each component's BatchFlag, in offset order."""
return [component["BatchFlag"] for component in components(leader_job_id)]
# ---------------------------------------------------------------------------
# Single component: unchanged behaviour, no ":" anywhere
# ---------------------------------------------------------------------------
def test_single_regular_job(submit_ok, script):
"""A plain batch job still carries its script."""
leader_job_id = submit_ok(f"-n1 -p {compute_partition} {script}")
assert batch_flags(leader_job_id) == [1], "Regular job should be a batch job"
def test_single_external_job(submit_ok):
"""--external alone submits a scriptless job."""
leader_job_id = submit_ok(f"--external -n1 -p {external_partition}")
assert batch_flags(leader_job_id) == [0], "External job should have no script"
def test_single_external_with_script_refused(script):
"""--external and a script contradict each other."""
result = submit_refused(f"--external -n1 {script}")
assert (
"script arguments not permitted" in result["stderr"].lower()
), f"Unexpected refusal: {result['stderr']!r}"
def test_single_external_with_wrap_refused():
"""--external and --wrap contradict each other."""
result = submit_refused('--external -n1 --wrap="sleep infinity"')
assert (
"wrap option not permitted" in result["stderr"].lower()
), f"Unexpected refusal: {result['stderr']!r}"
# ---------------------------------------------------------------------------
# Hetjobs
# ---------------------------------------------------------------------------
def test_het_all_external(submit_ok):
"""Every component external: no component carries a script."""
leader_job_id = submit_ok(
f"--external -n1 -p {external_partition}"
f" : --external -n1 -p {external_partition}"
)
assert batch_flags(leader_job_id) == [0, 0], "No component should be a batch job"
def test_het_mixed_leader_wrap(submit_ok):
"""--wrap stands in for the leader's script in a mixed hetjob."""
leader_job_id = submit_ok(
f'--wrap="sleep infinity" -n1 -p {compute_partition}'
f" : -n1 --external -p {external_partition}"
)
assert batch_flags(leader_job_id) == [
1,
0,
], "Leader should carry the wrapped script and the second component should not"
def test_het_mixed_three_components(submit_ok, script):
"""A regular leader, an external component and a further regular one."""
leader_job_id = submit_ok(
f"-n1 -p {compute_partition}"
f" : -n1 --external -p {external_partition}"
f" : -n1 -p {compute_partition} {script}"
)
assert batch_flags(leader_job_id) == [
1,
0,
1,
], "Only the external component should lack a script"
def test_het_external_leader_with_script_refused(script):
"""An external leader cannot be given a script, even in a hetjob.
sbatch refuses this itself, since the script belongs to the leader.
"""
result = submit_refused(f"--external -n1 : -n1 -p {compute_partition} {script}")
assert (
"script arguments not permitted" in result["stderr"].lower()
), f"Unexpected refusal: {result['stderr']!r}"
def test_het_external_leader_with_regular_component_refused():
"""An external leader cannot be followed by a regular component.
Nothing on the command line is contradictory, so sbatch builds it and the
controller is the one that refuses: only the leader supplies the shared
script and an external leader has none.
"""
result = submit_refused_by_controller(
f"--external -n1 -p {external_partition}" f" : -n1 -p {compute_partition}"
)
# Match what only the controller says. sbatch's own refusals in this file
# also carry the word "external", so a looser match would still pass if a
# change made sbatch reject the command line before ever submitting it.
stderr = result["stderr"].lower()
assert (
"cannot follow an external hetjob leader" in stderr
), f"Unexpected refusal: {result['stderr']!r}"
assert (
"invalid configuration for external job" in stderr
), f"Unexpected refusal: {result['stderr']!r}"
# ---------------------------------------------------------------------------
# Components defined by the script rather than the command line
# ---------------------------------------------------------------------------
def test_script_defined_mixed_hetjob(submit_ok, directive_script):
"""A script names its own components, one of them external.
There is no ":" on the command line here, so nothing about the components
is known until the script is read, one component at a time.
"""
script_name = directive_script(
f"-n1 -p {compute_partition}",
f"-n1 --external -p {external_partition}",
)
leader_job_id = submit_ok(script_name)
assert batch_flags(leader_job_id) == [
1,
0,
], "Leader should carry the script and the second component should not"
def test_script_defined_external_does_not_carry_forward(submit_ok, directive_script):
"""A directive marks the component it appears under and no later one."""
script_name = directive_script(
f"-n1 -p {compute_partition}",
f"-n1 --external -p {external_partition}",
f"-n1 -p {compute_partition}",
)
leader_job_id = submit_ok(script_name)
assert batch_flags(leader_job_id) == [
1,
0,
1,
], "Only the component given --external should lack a script"
# ---------------------------------------------------------------------------
# --external given by a directive on the first component
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"component_options, expected_messages",
[
pytest.param(
[f"-n1 --external -p {external_partition}", f"-n1 -p {compute_partition}"],
[
"cannot follow an external hetjob leader",
"invalid configuration for external job",
],
id="external_leader_regular_component",
),
pytest.param(
[
f"-n1 --external -p {external_partition}",
f"-n1 --external -p {external_partition}",
],
["invalid configuration for external job"],
id="all_external",
),
pytest.param(
[f"--external -n1 -p {external_partition}"],
["invalid configuration for external job"],
id="single_job",
),
],
)
def test_script_defined_external_first_component_refused(
directive_script, component_options, expected_messages
):
"""A directive cannot make the first component external.
sbatch reads the script before it parses the directives, so the first
component keeps the script and the controller refuses the job.
"""
result = submit_refused_by_controller(directive_script(*component_options))
stderr = result["stderr"].lower()
for message in expected_messages:
assert message in stderr, f"Unexpected refusal: {result['stderr']!r}"
# ---------------------------------------------------------------------------
# Either side of the leader/component boundary
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"second_component",
[
pytest.param(f"--external -n1 -p {external_partition}", id="first_token"),
pytest.param(f"-n1 --external -p {external_partition}", id="after_n1"),
],
)
def test_external_in_second_component(submit_ok, script, second_component):
"""--external in the second component marks it alone, wherever it sits.
As the first token it sits right after the ":", next to the leader's
options. Either way the leader keeps the script.
"""
leader_job_id = submit_ok(
f"-n1 -p {compute_partition} : {second_component} {script}"
)
assert batch_flags(leader_job_id) == [
1,
0,
], "Leader should carry the script and the second component should not"
def test_external_last_token_of_leader():
"""--external closing the leader belongs to it."""
result = submit_refused(
f"-n1 -p {compute_partition} --external"
f" : -n1 -p {external_partition}"
' --wrap="sleep infinity"'
)
assert (
"wrap option not permitted" in result["stderr"].lower()
), f"Unexpected refusal: {result['stderr']!r}"