blob: fa7ab1a40e4ad85b4ef88fb5ebb8a1d60b0f6bbf [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""Test that scrontab builds a job description from a #SCRON line."""
import pytest
import atf
# 00:00 on January 1st fires at most once a year, so every job these tests
# submit stays PENDING for the whole run and never needs a working node.
schedule = "0 0 1 1 *"
# Setup
@pytest.fixture(scope="module", autouse=True)
def setup():
atf.require_config_parameter_includes("ScronParameters", "enable")
atf.require_slurm_running()
@pytest.fixture(autouse=True)
def clean_crontab():
"""Guarantees every test both starts and ends with no crontab.
Cleaning up front matters as much as cleaning up after: several tests prove
a crontab was rejected by checking that none is installed, and that only
means anything if none was installed to begin with. Without the leading
removal those tests borrow their precondition from whichever test ran
before, so a crontab left behind by an interrupted run makes a correct
scrontab look broken.
Removing the crontab also cancels the jobs it submitted.
"""
atf.run_command("scrontab -r", quiet=True)
yield
atf.run_command("scrontab -r", quiet=True)
def install_crontab(*lines):
"""Installs a crontab built from lines and returns the command result.
scrontab reads its input from stdin when the file argument is "-", so the
crontab never has to be staged on disk where the test user might not be
able to read it.
"""
crontab = "".join(f"{line}\n" for line in lines)
return atf.run_command("scrontab -", input=crontab)
def require_shared_builder():
"""Skips unless scrontab builds its description with the shared builder.
scrontab switched to slurm_opt_create_job_desc() in 26.11. Before that its
private filler neither validated its input nor forwarded every option it
accepted, so the behavior these tests pin does not exist on an older
install. The tests that pass on both sides are deliberately left ungated,
so they keep guarding the common path on every version.
The version comes from a sibling client binary, not from scrontab and not
from the default component. scrontab itself takes no -V, so it cannot be
asked. slurmctld is the wrong thing to ask because these behaviors are all
produced by the client, and doc/html/upgrades.shtml upgrades slurmctld
before the login nodes, so a 26.11 controller can still be paired with a
26.05 scrontab. Client commands ship as one package and are upgraded
together, so any of them reports scrontab's own version.
"""
atf.require_version(
(26, 11),
"bin/sbatch",
reason="Issue 50190: scrontab builds the job description with the shared builder in 26.11",
)
def get_cron_job_id(name):
"""Returns the id of the cron job named name, once slurmctld reports it."""
for _ in atf.timer():
job_ids = atf.run_command_output(
f"squeue --me --noheader --format=%A --name={name}", quiet=True
).split()
if len(job_ids) == 1:
return int(job_ids[0])
pytest.fail(f"Expected exactly one cron job named {name}, found {job_ids}")
def test_crontab_round_trip():
"""Verify a crontab can be installed, listed and removed."""
assert (
install_crontab("#SCRON --job-name=cron_round_trip", f"{schedule} sleep 1")[
"exit_code"
]
== 0
), "scrontab should accept a valid crontab"
listed = atf.run_command_output("scrontab -l")
assert schedule in listed, "scrontab -l should echo the installed schedule"
assert (
"#SCRON --job-name=cron_round_trip" in listed
), "scrontab -l should echo the installed #SCRON line"
job_id = get_cron_job_id("cron_round_trip")
assert (
atf.get_job_parameter(job_id, "CronJob") == "Yes"
), "The submitted job should be marked as a cron job"
atf.run_command("scrontab -r", fatal=True)
assert "no crontab" in atf.run_command_output(
"scrontab -l", xfail=True
), "scrontab -l should report no crontab once it is removed"
def test_job_name_defaults_to_the_command():
"""Verify scrontab names a job after its command when --job-name is absent.
scrontab supplies this itself, so it has to keep working now that the
shared builder fills in the rest of the description.
The command only has to be distinctive, not runnable. Every job here stays
PENDING for the whole run, so nothing ever executes it.
"""
command = "cron_169_1_default_name"
assert (
install_crontab(f"{schedule} {command} 1")["exit_code"] == 0
), "scrontab should accept a crontab with no #SCRON line"
job_id = get_cron_job_id(command)
assert (
atf.get_job_parameter(job_id, "JobName") == command
), "The job name should default to the first word of the command"
def test_scron_options_reach_slurmctld():
"""Verify #SCRON options are forwarded into the job description.
Several options are read back from one entry rather than one, so the case
guards the breadth of the forwarding path and not a single field. All of
these are forwarded on every supported version, which is why the case is
ungated.
"""
assert (
install_crontab(
"#SCRON --job-name=cron_scron_options",
"#SCRON --time=7",
"#SCRON --comment=cron_169_1_comment",
"#SCRON --nice=50",
f"{schedule} sleep 1",
)["exit_code"]
== 0
), "scrontab should accept #SCRON options"
job_id = get_cron_job_id("cron_scron_options")
assert (
atf.get_job_parameter(job_id, "TimeLimit") == "00:07:00"
), "--time from a #SCRON line should reach slurmctld"
assert (
atf.get_job_parameter(job_id, "Comment") == "cron_169_1_comment"
), "--comment from a #SCRON line should reach slurmctld"
assert (
atf.get_job_parameter(job_id, "Nice") == 50
), "--nice from a #SCRON line should reach slurmctld"
def test_options_do_not_leak_between_entries():
"""Verify a #SCRON option applies only to the entry that follows it.
scrontab.1 promises "Options are always reset in between each crontab
entry". That is the one invariant the man page states about #SCRON, and it
is the one a single-entry crontab can never observe: if option state leaked
forward, every other test here would still pass while a user's first entry
silently imposed its --time on all the later ones.
"""
assert (
install_crontab(
"#SCRON --job-name=cron_reset_first",
"#SCRON --time=7",
f"{schedule} sleep 1",
"#SCRON --job-name=cron_reset_second",
f"{schedule} sleep 1",
)["exit_code"]
== 0
), "scrontab should accept a crontab holding two entries"
first = get_cron_job_id("cron_reset_first")
second = get_cron_job_id("cron_reset_second")
assert first != second, "The two entries should submit two distinct jobs"
assert (
atf.get_job_parameter(first, "TimeLimit") == "00:07:00"
), "--time should apply to the entry that follows it"
assert (
atf.get_job_parameter(second, "TimeLimit") != "00:07:00"
), "--time should not carry over into the next crontab entry"
def test_oom_kill_step_reaches_slurmctld():
"""Verify an option scrontab parsed but used to discard now arrives.
scrontab accepted --oom-kill-step in a #SCRON line and then dropped it,
because its private filler never copied the field into the job
description. Building the description with the shared builder forwards it.
"""
require_shared_builder()
assert (
install_crontab(
"#SCRON --job-name=cron_oom_kill_step",
"#SCRON --oom-kill-step=1",
f"{schedule} sleep 1",
)["exit_code"]
== 0
), "scrontab should accept --oom-kill-step in a #SCRON line"
job_id = get_cron_job_id("cron_oom_kill_step")
assert (
atf.get_job_parameter(job_id, "OOMKillStep") == 1
), "--oom-kill-step from a #SCRON line should reach slurmctld"
def test_oom_kill_step_is_left_to_the_configuration():
"""Verify a crontab that says nothing about OOM killing follows the config.
scrontab used to leave oom_kill_step at the zero that
slurm_init_job_desc_msg() writes, which slurmctld reads as an explicit
request not to kill the whole step and so overrides TaskPluginParam. The
shared builder sends NO_VAL16 instead, and scontrol prints the field only
when it is not NO_VAL16.
"""
require_shared_builder()
assert (
install_crontab(
"#SCRON --job-name=cron_oom_default",
f"{schedule} sleep 1",
)["exit_code"]
== 0
), "scrontab should accept a crontab that sets no options"
job_id = get_cron_job_id("cron_oom_default")
assert (
atf.get_job_parameter(job_id, "OOMKillStep", default=None) is None
), "A cron job should not pin OOMKillStep when the crontab does not set it"
def test_arbitrary_distribution_without_nodelist_is_rejected():
"""Verify arbitrary distribution with no node list fails the whole crontab.
The shared builder validates this where scrontab's private filler did not,
so _entry_to_job() can now fail and the caller has to report it.
"""
require_shared_builder()
result = install_crontab(
"#SCRON --distribution=arbitrary",
f"{schedule} sleep 1",
)
assert result["exit_code"] != 0, "scrontab should reject the crontab"
assert (
"Arbitrary distribution" in result["stderr"]
), "scrontab should say why the job description could not be built"
assert (
"errors in your crontab" in result["stdout"]
), "scrontab should report the crontab as containing errors"
assert "no crontab" in atf.run_command_output(
"scrontab -l", xfail=True
), "A rejected crontab should not be installed"
def test_invalid_nodelist_is_rejected():
"""Verify an unparsable --nodelist fails the whole crontab."""
require_shared_builder()
result = install_crontab(
"#SCRON --nodelist=node[",
f"{schedule} sleep 1",
)
assert result["exit_code"] != 0, "scrontab should reject the crontab"
assert (
"Invalid node list" in result["stderr"]
), "scrontab should say why the job description could not be built"
assert "no crontab" in atf.run_command_output(
"scrontab -l", xfail=True
), "A rejected crontab should not be installed"
@pytest.mark.parametrize("option", ["--clusters=cluster1", "--immediate"])
def test_options_scrontab_cannot_set_are_rejected(option):
"""Verify options only salloc, sbatch and srun define stay unavailable.
These reach neither the job description nor slurmctld, because scrontab
never puts them in its option table. The #SCRON parser rejects the line
outright rather than accepting the option and then ignoring it.
"""
result = install_crontab(f"#SCRON {option}", f"{schedule} sleep 1")
assert result["exit_code"] != 0, f"scrontab should reject {option}"
assert (
"Invalid option found in #SCRON line" in result["stderr"]
), f"scrontab should report {option} as an invalid #SCRON option"
assert "no crontab" in atf.run_command_output(
"scrontab -l", xfail=True
), "A rejected crontab should not be installed"
def test_invalid_cron_spec_is_rejected():
"""Verify a malformed time specification fails the whole crontab."""
result = install_crontab("99 * * * * sleep 1")
assert result["exit_code"] != 0, "scrontab should reject a bad time spec"
assert "no crontab" in atf.run_command_output(
"scrontab -l", xfail=True
), "A rejected crontab should not be installed"
@pytest.mark.skipif(
atf.get_version("bin/sbatch") < (26, 11),
reason="Issue 50190: --runtime plugin ownership added in 26.11",
)
def test_runtime_mix_across_entries():
"""Each #SCRON entry records its own --runtime and --container.
One crontab mixes runtime/none, runtime/oci with a bundle, no runtime, and
a bundle with no runtime. An entry names its runtime for itself only: what
the entry before it asked for does not carry over, so the entry after a
runtime/oci bundle records neither.
"""
atf.set_config_parameter("DefRuntimePlugin", "runtime/oci")
entries = [
("cron_rt_none", "--runtime=none", "runtime/none", None),
(
"cron_rt_oci",
"--runtime=oci --container=/nonexistent/a",
"runtime/oci",
"/nonexistent/a",
),
("cron_rt_unset", "", None, None),
("cron_rt_bundle", "--container=/nonexistent/b", None, "/nonexistent/b"),
("cron_rt_none_again", "--runtime none", "runtime/none", None),
]
lines = []
for name, options, _, _ in entries:
lines += [f"#SCRON --job-name={name} {options}".rstrip(), f"{schedule} sleep 1"]
result = install_crontab(*lines)
assert result["exit_code"] == 0, f"scrontab should accept the crontab: {result}"
for name, options, runtime, container in entries:
job_id = get_cron_job_id(name)
assert (
atf.get_job_parameter(job_id, "Runtime") == runtime
), f"{name} ({options!r}) should record Runtime={runtime}"
assert (
atf.get_job_parameter(job_id, "Container") == container
), f"{name} ({options!r}) should record Container={container}"
@pytest.mark.skipif(
atf.get_version("bin/sbatch") < (26, 11),
reason="Issue 50190: --runtime plugin ownership added in 26.11",
)
@pytest.mark.parametrize(
"options, error",
[
("--runtime=none --container=/nonexistent/bundle", "is not an option of"),
("--runtime=oci --runtime=none", "--runtime may only be given once"),
("--runtime=bogus", "cannot create runtime context for runtime/bogus"),
],
ids=["none-with-container", "two-runtimes", "unknown-runtime"],
)
def test_runtime_errors_reject_the_crontab(options, error):
"""An #SCRON runtime error fails the whole crontab, and leaves no crontab
behind, however many valid entries come before it."""
result = install_crontab(
"#SCRON --job-name=cron_rt_valid --runtime=oci",
f"{schedule} sleep 1",
f"#SCRON {options}",
f"{schedule} sleep 1",
)
assert result["exit_code"] != 0, f"scrontab should reject #SCRON {options}"
assert error in result["stderr"], f"scrontab should report {error!r}"
assert "no crontab" in atf.run_command_output(
"scrontab -l", xfail=True
), "A rejected crontab should not be installed"