blob: 79739cd9a2daae2b60b054eb482c0dde3d3479d5 [file]
##############################################################################
# ATF (Automated Testing Framework) python testsuite module
# Copyright (C) SchedMD LLC.
##############################################################################
import collections
import copy
import datetime
import enum
import errno
import glob
import json
# import glob
import logging
import math
import os
import pathlib
import pwd
import re
import shlex
import shutil
import signal
import socket
import stat
import subprocess
import sys
import time
import traceback
import jsondiff
import jsonpatch
import pexpect
import pytest
# slurmrestd
import requests
import yaml
# This module will be (un)imported in require_openapi_generator()
openapi_client = None
import importlib
import threading
##############################################################################
# ATF module functions
##############################################################################
default_command_timeout = 60
default_polling_timeout = 45
default_sql_cmd_timeout = 120
PERIODIC_TIMEOUT = 30
def get_open_port():
"""Finds an open port.
Warning: Race conditions abound so be ready to retry calling function;
Example:
>>> while not some_test(port):
>>> port = get_open_port()
Shamelessly based on:
https://stackoverflow.com/questions/2838244/get-open-tcp-port-in-python
"""
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind(("", 0))
s.listen(1)
port = s.getsockname()[1]
s.close()
return port
def node_range_to_list(node_expression):
"""Converts a node range expression into a list of node names.
Example:
>>> node_range_to_list('node[1,3-5]')
['node1', 'node3', 'node4', 'node5']
"""
node_list = []
output = run_command_output(
f"scontrol show hostnames {node_expression}", fatal=True, quiet=True
)
for line in output.rstrip().splitlines():
node_list.append(line)
return node_list
def node_list_to_range(node_list):
"""Converts a list of node names to a node range expression.
Example:
>>> node_list_to_range(['node1', 'node3', 'node4', 'node5'])
'node[1,3-5]'
"""
return run_command_output(
f"scontrol show hostlistsorted {','.join(node_list)}", fatal=True, quiet=True
).rstrip()
def range_to_list(range_expression):
"""Converts an integer range expression into a list of integers.
Example:
>>> range_to_list('1,3-5')
[1, 3, 4, 5]
"""
return list(map(int, node_range_to_list(f"[{range_expression}]")))
def list_to_range(numeric_list):
"""Converts a list of integers to an integer range expression.
Example:
>>> list_to_range([1, 3, 4, 5])
'1,3-5'
"""
node_range_expression = node_list_to_range(map(str, numeric_list))
return re.sub(r"^\[(.*)\]$", r"\1", node_range_expression)
def mask_to_list(mask):
"""Converts an integer bitmask to a sorted list of its set-bit indices.
Example:
>>> mask_to_list(0b1011)
[0, 1, 3]
"""
return [i for i in range(mask.bit_length()) if mask >> i & 1]
def get_coredumps():
"""
Return the coredumps with the expected pattern in the tmp dirs of the test
and in the logs dir (the logs dir is the cwd of the daemons).
"""
core_list = glob.glob(f"{properties['slurm-logs-dir']}/*.core*") + glob.glob(
f"{module_tmp_path}/../**/*.core*", recursive=True
)
# pytest creates a 'current' symlink alongside to tmp directories
core_set = set()
for core in core_list:
if core.endswith(".bt"):
continue
core_set.add(os.path.realpath(core))
return list(core_set)
def resolve_core_binary(core_path, execfn_path):
"""Return the on-disk binary whose build-id matches the executable
recorded in the coredump, or None if no match is found.
Handles the case where a symlink like may have been swapped between
in upgrades/mixed versions setups.
"""
# Get the build-ids of all the modules in the coredump.
# The executable is not necessarily the first module listed by eu-unstrip
core_bids = set()
result = run_command(
f"DEBUGINFOD_URLS= eu-unstrip -n --core {core_path}",
quiet=True,
user="root",
timeout=300,
)
for line in result["stdout"].splitlines():
m = re.match(r"\S+\s+([0-9a-f]+)", line)
if m:
core_bids.add(m.group(1))
if not core_bids:
logging.warning(f"Unable to extract build-ids from coredump {core_path}")
return None
candidates = [os.path.realpath(execfn_path)]
sp = properties.get("slurm-prefix")
if sp and execfn_path.startswith(sp + "/"):
rel = execfn_path[len(sp) + 1 :]
for key in ("old-slurm-prefix", "new-slurm-prefix"):
if key in properties:
candidates.append(f"{properties[key]}/{rel}")
for cand in candidates:
out = run_command_output(f"readelf -n {cand}", quiet=True, user="root")
m = re.search(r"Build ID:\s+([0-9a-f]+)", out)
if os.path.exists(cand) and m and m.group(1) in core_bids:
return cand
logging.warning(
f"No binary matches build-ids {sorted(core_bids)} for coredump {core_path} (candidates tried: {candidates})"
)
return None
def run_command(
command,
fatal=False,
timeout=default_command_timeout,
quiet=False,
chdir=None,
user=None,
input=None,
xfail=False,
env_vars=None,
background=False,
):
"""Executes a command and returns a dictionary result.
Args:
command (string): The command to execute. The command is run within a
bash subshell, so pipes, redirection, etc. are performed.
fatal (boolean): If True, a non-zero exit code (or zero if combined
with xfail) will result in the test failing.
timeout (integer): If the command does not exit before timeout number
of seconds, this function will return with an exit code of 110.
quiet (boolean): If True, logging is performed at the TRACE log level.
chdir (directory): Change to the specified directory before executing
the command.
user (user name): Run the command as the specified user. This requires
the invoking user to have unprompted sudo rights.
input (string): The specified input is supplied to the command as stdin.
xfail (boolean): If True, the command is expected to fail.
env_vars (string): A string to set environmental variables that is
prepended to the command when run.
background (boolean): If True, runs command in background and returns
immediately. Process can be accessed via results["process"]. Cannot
be used with fatal, xfail, timeout, capture_output, or check.
Returns:
A dictionary containing the following keys:
start_time: epoch start time
duration: number of seconds the command ran for
exit_code: exit code for the command
stdout: command stdout as a string
stderr: command stderr as a string
Example:
>>> run_command('ls -l', fatal=True)
{'command': 'ls -l', 'start_time': 1712268971.532, 'duration': 0.007, 'exit_code': 0, 'stdout': 'total 124\n-rw-rw-r-- 1 slurm slurm 118340 Apr 4 22:15 atf.py\n-rw-rw-r-- 1 slurm slurm 498 Apr 4 22:09 test.py\n-rw-rw-r-- 1 slurm slurm 1013 Apr 4 22:09 python_script.py\n', 'stderr': ''}
>>> run_command('ls /non/existent/path', xfail=True)
{'command': 'ls /non/existent/path', 'start_time': 1712269123.2, 'duration': 0.005, 'exit_code': 2, 'stdout': '', 'stderr': "ls: cannot access '/non/existent/path': No such file or directory\n"}
>>> run_command('sleep 5', timeout=2)
{'command': 'sleep 5', 'start_time': 1712269157.113, 'duration': 2.0, 'exit_code': 110, 'stdout': '', 'stderr': ''}
"""
additional_run_kwargs = {}
if chdir is not None:
additional_run_kwargs["cwd"] = chdir
if input is not None:
additional_run_kwargs["input"] = input
if timeout is not None:
additional_run_kwargs["timeout"] = timeout
if quiet:
log_command_level = logging.TRACE
log_details_level = logging.TRACE
else:
log_command_level = logging.NOTE
log_details_level = logging.DEBUG
if env_vars is not None:
command = env_vars.strip() + " " + command
# If user is not specified but test-user is set, then set user to test-user
if not user and properties["test-user-set"]:
user = properties["test-user"]
start_time = time.time()
invocation_message = "Running command"
if user is not None:
invocation_message += f" as user {user}"
invocation_message += f": {command}"
logging.log(log_command_level, invocation_message)
try:
if user is not None:
if not properties["sudo-rights"]:
pytest.skip(
"This test requires the test user to have unprompted sudo rights",
allow_module_level=True,
)
# Use su to honor ulimits, specially core
preserve = [
"PATH",
"SLURM_TESTSUITE_CONF",
*sorted(properties["lmod-touched-vars"]),
]
cmd = [
"sudo",
f"--preserve-env={','.join(preserve)}",
"-u",
user,
"/bin/bash",
"-lc",
command,
]
else:
cmd = command
# Run in background mode and return before completion if requested
if background:
# Create kwargs for Popen, excluding unsupported run() params
background_run_kwargs = additional_run_kwargs.copy()
# Check that parameters that don't make sense haven't been specified
if background_run_kwargs["timeout"] != default_command_timeout:
pytest.fail("Unsupported: timeout cannot be used with background=True")
del background_run_kwargs["timeout"]
if fatal:
pytest.fail("Unsupported: fatal cannot be used with background=True")
if xfail:
pytest.fail("Unsupported: xfail cannot be used with background=True")
if "input" in background_run_kwargs:
input_str = background_run_kwargs.pop("input")
else:
input_str = None
# Run command differently depending on if using sudo for security
if isinstance(cmd, list):
process = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
universal_newlines=True,
**background_run_kwargs,
)
else:
process = subprocess.Popen(
cmd,
shell=True,
executable="/bin/bash",
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
universal_newlines=True,
**background_run_kwargs,
)
# Handle input if provided
if input_str is not None:
process.stdin.write(input_str)
process.stdin.close()
# Return early with process object
results = {
"command": command,
"start_time": float(int(start_time * 1000)) / 1000,
"duration": float(int((time.time() - start_time) * 1000)) / 1000,
"exit_code": None,
"stdout": "",
"stderr": "",
"process": process,
}
return results
# Synchronous code path
if isinstance(cmd, list):
cp = subprocess.run(
cmd, capture_output=True, text=True, **additional_run_kwargs
)
else:
cp = subprocess.run(
cmd,
shell=True,
executable="/bin/bash",
capture_output=True,
text=True,
**additional_run_kwargs,
)
end_time = time.time()
duration = end_time - start_time
exit_code = cp.returncode
stdout = cp.stdout
stderr = cp.stderr
except subprocess.TimeoutExpired as e:
duration = e.timeout
exit_code = errno.ETIMEDOUT
# These are byte objects, not strings
stdout = e.stdout.decode("utf-8") if e.stdout else ""
stderr = e.stderr.decode("utf-8") if e.stderr else ""
if input is not None:
logging.log(log_details_level, f"Command input: {input}")
logging.log(log_details_level, f"Command exit code: {exit_code}")
logging.log(log_details_level, f"Command stdout: {stdout}")
logging.log(log_details_level, f"Command stderr: {stderr}")
logging.log(log_details_level, "Command duration: %.03f seconds", duration)
message = ""
if exit_code == errno.ETIMEDOUT:
message = f'Command "{command}" timed out after {duration} seconds'
elif exit_code != 0 and not xfail:
message = f'Command "{command}" failed with rc={exit_code}'
elif exit_code == 0 and xfail:
message = f'Command "{command}" was expected to fail but succeeded'
if (exit_code != 0 and not xfail) or (exit_code == 0 and xfail):
if stderr != "" or stdout != "":
message += ":"
if stderr != "":
message += f" {stderr}"
if stdout != "":
message += f" {stdout}"
if message != "":
message = message.rstrip()
if fatal:
pytest.fail(message)
elif not quiet:
logging.warning(message)
results = {}
results["command"] = command
results["start_time"] = float(int(start_time * 1000)) / 1000
results["duration"] = float(int(duration * 1000)) / 1000
results["exit_code"] = exit_code
results["stdout"] = stdout
results["stderr"] = stderr
return results
def run_command_error(command, **run_command_kwargs):
"""Executes a command and returns the standard error.
This function accepts the same arguments as run_command.
Args:
command (string): The command to execute. The command is run within a
bash subshell, so pipes, redirection, etc. are performed.
Returns:
The standard error (stderr) output of the command as a string.
Example:
>>> run_command_error('ls /non/existent/path')
"ls: cannot access '/non/existent/path': No such file or directory\n"
>>> run_command_error('grep foo /etc/passwd', quiet=True)
''
>>> run_command_error('echo error message >&2', xfail=True)
'error message\n'
"""
results = run_command(command, **run_command_kwargs)
return results["stderr"]
def run_command_output(command, **run_command_kwargs):
"""Executes a command and returns the standard output.
This function accepts the same arguments as run_command.
Args:
command (string): The command to execute. The command is run within a
Bash subshell, so pipes, redirection, etc. are performed.
Returns:
The standard output (stdout) of the command as a string.
Example:
>>> run_command_output('ls')
'file1.txt\nfile2.txt\nscript.py\n'
>>> run_command_output('echo Hello, World!')
'Hello, World!\n'
>>> run_command_output('grep foo /etc/passwd', xfail=True)
''
"""
results = run_command(command, **run_command_kwargs)
return results["stdout"]
def run_command_exit(command, **run_command_kwargs):
"""Executes a command and returns the exit code.
This function accepts the same arguments as run_command.
Args:
command (string): The command to execute. The command is run within a
Bash subshell, so pipes, redirection, etc. are performed.
Returns:
The exit code of the command as an integer.
Example:
>>> run_command_exit('ls')
0
>>> run_command_exit('grep foo /etc/passwd', xfail=True)
1
>>> run_command_exit('sleep 5', timeout=2)
110
"""
results = run_command(command, **run_command_kwargs)
return results["exit_code"]
def timer(
timeout=default_polling_timeout,
poll_interval=None,
fatal=False,
xfail=False,
quiet=False,
):
"""A timer to do-while a timeout is not triggered.
Iterates in a loop, sleeping between iterations, until the caller breaks
or the timeout is reached.
Args:
timeout (float): Maximum time in seconds to wait (default:
default_polling_timeout).
poll_interval (float): Seconds between iterations. Defaults to
timeout / 10.0
fatal (bool): If True, call pytest.fail() on timeout. Otherwise the
caller should use the for-else to detect the timeout.
xfail (bool): If True, a timeout is expected.
quiet (bool): If True, no progress is log.
Example:
>>> running = False
>>> for t in atf.timer(timeout=60, fatal=False):
... if get_job_state(job_id) == 'RUNNING':
... running = True
... break
... else:
... logging.warning("Job was not RUNNING before the timeout.")
... assert running, "Job should be running"
"""
if poll_interval is None:
poll_interval = timeout / 10.0
_not = " not" if not xfail else ""
message = f"Timer should{_not} timeout"
start = time.time()
remaining_time = timeout
while True:
# Run the caller loop (at least once, like a do-while)
yield int(remaining_time)
# Check for timeout
remaining_time = timeout - (time.time() - start)
if remaining_time <= 0:
msg = message + " (timeout)"
if not xfail:
if fatal:
pytest.fail(msg)
logging.warning(msg)
else:
logging.debug(msg)
return
# Wait for the next attempt
if not quiet:
logging.debug(message + f", remaining time: {remaining_time:.0f}s")
time.sleep(poll_interval)
def repeat_until(
callable,
condition,
timeout=default_polling_timeout,
poll_interval=None,
xfail=False,
fatal=False,
):
"""Repeats a callable until a condition is met or it times out.
The callable returns an object that the condition operates on.
Args:
callable (callable): Repeatedly called until the condition is met or
the timeout is reached.
condition (callable): A callable object that returns a boolean. This
function will return True when the condition call returns True.
timeout (integer): If timeout number of seconds expires before the
condition is met, return False.
poll_interval (float): Number of seconds to wait between condition
polls. This may be a decimal fraction. The default poll interval
depends on the timeout used, but varies between .1 and 1 seconds.
xfail (boolean): If True, a timeout is expected.
fatal (boolean): If True, the test will fail if condition is not met
(or if condition is met with xfail).
Returns:
True if the condition is met by the timeout, False otherwise.
Example:
>>> repeat_until(lambda : random.randint(1,10), lambda n: n == 5, timeout=30, poll_interval=1)
True
"""
begin_time = time.time()
if poll_interval is None:
if timeout <= 5:
poll_interval = 0.1
elif timeout <= 10:
poll_interval = 0.2
else:
poll_interval = 1
logging.debug(
f"Waiting until condition related to {condition.__code__.co_varnames} is met, or timeout after {timeout}s..."
)
condition_met = False
while time.time() < begin_time + timeout:
value = callable()
if condition(value):
logging.debug(f"Condition met, value: {value}")
condition_met = True
break
logging.debug(f"Condition not yet met, value: {value}")
time.sleep(poll_interval)
if not xfail and not condition_met:
if fatal:
pytest.fail(f"Condition was not met within the {timeout} second timeout")
else:
logging.warning(
f"Condition was not met within the {timeout} second timeout"
)
elif xfail and condition_met:
if fatal:
pytest.fail(
f"Condition was met within the {timeout} second timeout and wasn't expected"
)
else:
logging.warning(
f"Condition was met within the {timeout} second timeout and wasn't expected"
)
return condition_met
def repeat_command_until(command, condition, quiet=True, **repeat_until_kwargs):
"""Repeats a command until a condition is met or it times out.
This function accepts the same arguments as repeat_until.
Args:
quiet (boolean): If True, logging is performed at the TRACE log level.
Returns:
True if the condition is met by the timeout, False otherwise.
Example:
>>> repeat_command_until("scontrol ping", lambda results: re.search(r'is UP', results['stdout']))
True
"""
return repeat_until(
lambda: run_command(command, quiet=quiet),
condition,
**repeat_until_kwargs,
)
def pids_from_exe(executable):
"""Finds process IDs (PIDs) of running processes with given executable name.
Args:
executable (string): The name of the executable for which to find running processes.
Returns:
A list of integer process IDs (PIDs) for running processes that match the given
executable name.
Example:
>>> pids_from_exe('/usr/bin/python3')
[12345, 67890]
>>> pids_from_exe('/usr/sbin/sshd')
[54321]
>>> pids_from_exe('/bin/non-existent')
[]
"""
# Avoid issues with links:
executable = os.path.realpath(executable)
# We have to elevate privileges here, but forking off thousands of sudo
# commands is expensive, so we will sudo a dynamic bash script for speed
script = f"""cd /proc
for pid in `ls -d1 [0-9]*`;
do if [[ "$(readlink $pid/exe)" = "{executable}" ]];
then echo $pid;
fi
done"""
pids = []
output = run_command_output(script, user="root", quiet=True)
for line in output.rstrip().splitlines():
pids.append(int(line))
return pids
def is_slurmrestd_running():
"""Checks if slurmrestd is running.
Needs to be run after the related properties are set.
"""
def safe_request_openapi():
try:
return request_slurmrestd("openapi/v3")
except Exception as err:
logging.warn(err)
return None
# TODO: We could check also if the required plugins/parsers in properties
# are in the returned specs, but the format still depends on the version.
# Once v0.0.39 is removed, we could add the extra check.
return repeat_until(safe_request_openapi, lambda r: r and r.status_code == 200)
def is_slurmctld_running(quiet=False):
"""Checks whether slurmctld is running.
Args:
quiet (boolean): If True, logging is performed at the TRACE log level.
Returns:
True if the slurmctld is running, False otherwise.
Example:
>>> is_slurmctld_running()
True
>>> is_slurmctld_running(quiet=True)
True
"""
# Check whether slurmctld is running
if re.search(r"is UP", run_command_output("scontrol ping", quiet=quiet)):
return True
return False
def gcore(component, pid=None, sbin=True, core_name=None, sigprof=True):
"""Generates a gcore file for all pids running of a given Slurm component.
The gcore file will be save at slurm-logs-dir, where all the logs and kernel
coredumps should be generated too.
Args:
component (string): The name of the component. E.g. slurmctld, slurmdbd...
pid (integer): The specific PID of the process to gcore. All PIDs of component are gcored by default...
sbin: If True search for pids related to slurm-sbin-dir, or slurm-bin-dir otherwise.
core_name (string): Name prefix of the core files, component by default.
They are named <core_name>.core.<pid>.
sigprof (boolean): If True, also send SIGPROF after the gcore. Use
False when the process should keep running unaltered.
Returns:
None
"""
# Ensure that slurm-logs-dir is set.
if "slurm-logs-dir" not in properties:
properties["slurm-logs-dir"] = os.path.dirname(
get_config_parameter("SlurmctldLogFile", live=False, quiet=True)
)
if sbin:
prefix = properties["slurm-sbin-dir"]
else:
prefix = properties["slurm-bin-dir"]
pids = pids_from_exe(f"{prefix}/{component}")
if pid:
if pid not in pids:
logging.warning(
f"Requested PID {pid} is not in the obtained PIDs ({pids}), but using it anyway"
)
pids = [pid]
if not pids:
logging.warning(f"Process {prefix}/{component} not found")
if core_name is None:
core_name = component
logging.debug(f"Getting gcores (sigprof={sigprof}) of PIDs: {pids}")
for pid in pids:
run_command(
f"sudo gcore -o {properties['slurm-logs-dir']}/{core_name}.core {pid}"
)
# Sending SIGPROF should be the last thing, because if it's not handled
# properly it defaults to behave like SIGTERM.
if sigprof:
run_command(f"kill -SIGPROF {pid}", user="root")
def slurmd_pids(slurmd_name):
"""Returns the PIDs of the slurmds running for slurmd_name.
The slurmd's own SlurmdPidFile is used when it contains %n, so it is
per-node. This also finds a slurmd -F by the node name slurmctld assigned
to it, which is what slurmd expands %n with. The pid in it is checked to
still be a slurmd for slurmd_name, as a slurmd that was killed leaves a
stale pidfile whose pid may have been reused.
Otherwise, or without a slurmd_name, the processes of this installation's
slurmd binary are searched for an exact "-N slurmd_name" (no -N at all for
None), so node1 does not match node10, whatever the order of the slurmd
options.
Args:
slurmd_name (string or None): The node name, or None for a slurmd
started without -N whose node name is unknown (slurmd -F).
Returns:
A list of integer PIDs, empty if no such slurmd is running.
Example:
>>> slurmd_pids("node1")
[12345]
>>> slurmd_pids(None) # slurmd -F
[]
"""
def _args(pid):
try:
with open(f"/proc/{pid}/cmdline", "rb") as cmdline:
return cmdline.read().decode().split("\0")
except OSError:
# Already gone
return None
def _node_option(args):
return next((value for arg, value in zip(args, args[1:]) if arg == "-N"), None)
# get_config_parameter() casefolds values, and this is a path
pidfile = next(
(
str(value)
for key, value in get_config(live=False, quiet=True).items()
if key.casefold() == "slurmdpidfile"
),
None,
)
if slurmd_name and pidfile:
if "%n" in pidfile:
pidfile = pidfile.replace("%n", slurmd_name).replace(
"%h", socket.gethostname().split(".")[0]
)
pid = run_command_output(f"cat {pidfile}", user="root", quiet=True).strip()
if not pid.isdigit():
return []
args = _args(int(pid))
if (
not args
or os.path.basename(args[0]) != "slurmd"
or _node_option(args) not in (None, slurmd_name)
):
return []
return [int(pid)]
# If slurmd_name is not specified (-F)
pids = []
for pid in pids_from_exe(f"{properties['slurm-sbin-dir']}/slurmd"):
args = _args(pid)
if args is not None and _node_option(args) == slurmd_name:
pids.append(pid)
return pids
def start_slurmd(
slurmd_name=None, args="", wait="registered", fatal=True, xfail=False, quiet=False
):
"""Starts a slurmd, records it and waits for it to be usable.
Tests should start slurmds with this function instead of running slurmd
directly, so that what any slurmd needs is always in place (e.g. its TLS
node token with the s2n variant, or its spool directory).
Any slurmd already running for slurmd_name is stopped first, so this also
restarts a slurmd.
This function may only be used in auto-config mode.
Args:
slurmd_name (string): The node name, passed to slurmd as -N. With -F in
args, slurmd gets its node name from slurmctld (one of the FUTURE
nodes) and refuses -N, so slurmd_name is not passed and is only the
node expected to be assigned, used to find, wait for and clean up
the slurmd. It can only be None with -F, when that node is not
known.
args (string): Other slurmd options, e.g. "-Z -b --conf 'Feature=f1'".
wait (string): What to wait for after slurmd starts:
"registered": slurmctld has a registration from this slurmd (its
slurmd_start_time changed). This is the default. Without a
slurmd_name it is the same as "process".
"idle": registered and the node is IDLE.
"process": the slurmd process is running.
None: nothing, e.g. when slurmd is expected to die.
fatal (boolean): If True, fail the test if slurmd fails to start or
the wait times out. If False, a failure to start is returned and a
wait timeout is logged as a warning.
xfail (boolean): If True, slurmd is expected to fail to start.
quiet (boolean): If True, logging is performed at the TRACE log level.
Returns:
The run_command() results of starting slurmd.
Example:
>>> start_slurmd("node1")
>>> start_slurmd("dyn1", "-Z -b --conf 'Feature=f1'", wait="idle")
>>> start_slurmd("future1", "-F")
>>> start_slurmd(args="-F", wait="process")
>>> start_slurmd("node1", "--bad-option", fatal=False, xfail=True)
"""
def _slurmd_start_time():
"""The slurmd_start_time slurmctld has for slurmd_name, or None."""
node = get_nodes(quiet=True).get(slurmd_name)
if not node:
return None
start_time = node.get("slurmd_start_time")
if isinstance(start_time, dict):
start_time = start_time.get("number")
return start_time or None
# slurmd -F has no NodeName, so it derives one from its own hostname
# (slurmd.c) and %n/%h in SlurmdSpoolDir expand to that
dynamic_future = "-F" in args.split()
if slurmd_name is None and not dynamic_future:
pytest.fail("start_slurmd() needs a slurmd_name unless args include -F")
slurmd_label = slurmd_name or "-F"
if wait not in ("registered", "idle", "process", None):
pytest.fail(f"Invalid start_slurmd() wait value: {wait}")
if slurmd_name is None and wait in ("registered", "idle"):
wait = "process"
if slurmd_pids(slurmd_name):
logging.warning(f"slurmd for {slurmd_label} already running, stopping it")
if slurmd_name:
stop_slurmd(slurmd_name, quiet=quiet)
else:
# stop_slurmd(None) would stop every slurmd, not just this -F one
_stop_pids(
lambda: slurmd_pids(None),
"slurmd -F",
"TERM",
default_polling_timeout,
True,
quiet,
)
# Create the slurmd's spool and tmpfs directories, and remove at teardown
spool_name = slurmd_name or socket.gethostname().split(".")[0]
created_dirs = properties.setdefault("slurmd-dirs-created", [])
for dir_property in ("slurm-spool-dir", "slurm-tmpfs"):
if not properties.get(dir_property):
continue
path = properties[dir_property].replace("%n", spool_name)
run_command(f"mkdir -p {path}", user="root", fatal=True, quiet=True)
created_dirs.append(path)
# With TLS, a slurmd only starts if it can get its certificate
if slurmd_name:
_require_node_tls_credentials([slurmd_name], quiet=quiet)
# slurmd_start_time has one-second resolution, so a registration in the
# same second as the previous one would look like no registration at all.
# Start in a later second.
previous_start_time = None
if wait in ("registered", "idle"):
previous_start_time = _slurmd_start_time()
if previous_start_time:
for _ in timer(timeout=3, poll_interval=0.05, fatal=True, quiet=True):
if int(time.time()) > previous_start_time:
break
command = f"{properties['slurm-sbin-dir']}/slurmd"
if slurmd_name and not dynamic_future:
command += f" -N {slurmd_name}"
if args:
command += f" {args}"
logging.debug(f"Starting slurmd for {slurmd_label}...")
start_time = int(time.time())
results = run_command(command, user="root", fatal=fatal, xfail=xfail, quiet=quiet)
if results["exit_code"] != 0:
return results
properties.setdefault("slurmds-started", set()).add(slurmd_name)
def _wait_failed(message):
if fatal:
pytest.fail(message)
logging.warning(message)
# Poll often: the default interval (a tenth of the timeout) would add
# seconds to every start, as the first check usually comes too early
# (slurmd writes its pidfile, and registers, a bit after it daemonizes).
if wait == "process":
for _ in timer(poll_interval=0.2, quiet=quiet):
if slurmd_pids(slurmd_name):
break
else:
_wait_failed(f"slurmd for {slurmd_label} is not running")
elif wait in ("registered", "idle"):
for _ in timer(poll_interval=0.5, quiet=quiet):
registered = _slurmd_start_time()
if (
registered
and registered >= start_time
and registered != previous_start_time
):
break
else:
_wait_failed(f"slurmd for {slurmd_name} never registered with slurmctld")
return results
if wait == "idle" and not wait_for_node_state(slurmd_name, "IDLE"):
_wait_failed(f"Node {slurmd_name} never became IDLE")
return results
def _stop_pids(get_pids, label, signal, timeout, kill_on_timeout, quiet):
"""Signals the processes get_pids() returns and waits for them to exit.
See stop_slurmd() for the arguments and the return value.
"""
pids = get_pids()
if not pids:
return True
logging.debug(f"Sending SIG{signal} to {label} ({pids})...")
run_command(
f"kill -{signal} {' '.join(str(pid) for pid in pids)}",
user="root",
quiet=quiet,
)
if not timeout:
return True
if repeat_until(
get_pids, lambda pids: not pids, timeout=timeout, poll_interval=0.2
):
return True
if not kill_on_timeout or signal == "KILL":
logging.warning(f"{label} still running {timeout}s after SIG{signal}")
return False
logging.warning(f"{label} ignored SIG{signal} for {timeout}s, killing it")
pids = get_pids()
if pids:
run_command(
f"kill -KILL {' '.join(str(pid) for pid in pids)}",
user="root",
quiet=quiet,
)
return repeat_until(get_pids, lambda pids: not pids, poll_interval=0.2)
def stop_slurmd(
slurmd_name=None,
signal="TERM",
timeout=default_polling_timeout,
kill_on_timeout=True,
quiet=False,
):
"""Stops slurmds, or emulates a slurmd crash.
Works for any slurmd, also those started by Slurm itself (e.g. by a
ResumeProgram) rather than by start_slurmd().
Use slurmd_pids() instead to check whether a slurmd is running, as this
always signals it.
Args:
slurmd_name (string): The node to stop the slurmd of. If None, every
slurmd of this installation that is running.
signal (string): The signal to send. "TERM" (the default) asks
slurmd to shut down, "KILL" emulates a crash.
timeout (integer): Seconds to wait for the slurmds to exit. With 0,
only send the signal, e.g. to let the caller report a slurmd that
does not shut down instead of hiding it.
kill_on_timeout (boolean): If True, send SIGKILL to any slurmd still
running after the timeout, and wait for it again. If False, return
False instead, e.g. to check that slurmd shuts down in time.
quiet (boolean): If True, logging is performed at the TRACE log level.
Returns:
True if the slurmds exited (or timeout is 0), False otherwise.
Example:
>>> stop_slurmd("node1")
True
>>> stop_slurmd() # Every running slurmd
True
>>> stop_slurmd("node1", signal="KILL") # Emulate a crash
True
>>> stop_slurmd(timeout=30, kill_on_timeout=False) # Shuts down in 30s?
False
"""
started = properties.setdefault("slurmds-started", set())
if slurmd_name is None:
started.clear()
slurmd_exe = f"{properties['slurm-sbin-dir']}/slurmd"
return _stop_pids(
lambda: pids_from_exe(slurmd_exe),
"all slurmds",
signal,
timeout,
kill_on_timeout,
quiet,
)
started.discard(slurmd_name)
return _stop_pids(
lambda: slurmd_pids(slurmd_name),
f"slurmd for {slurmd_name}",
signal,
timeout,
kill_on_timeout,
quiet,
)
def start_dynamic_slurmd(
node_name,
extra_conf="",
wait_for_usable=True,
timeout=default_polling_timeout,
quiet=False,
):
"""Start a dynamic-normal slurmd (``slurmd -Z``) for a node.
Launches a dynamic-normal slurmd through start_slurmd() that self-registers
with the controller, optionally passing per-node configuration via slurmd's
``--conf`` option (e.g. ``"Gres=gpu:a100:2 MaxDrainedTres=gres/gpu:1"``).
Any slurmd already running for node_name is stopped first and the node
deleted from the controller, so the node is cleanly (re)registered. Pair
with stop_slurmd() and delete_node() to tear the node down.
This function may only be used in auto-config mode.
Args:
node_name (string): The name the dynamic node registers under.
extra_conf (string): Value passed to slurmd's ``--conf`` option (node
configuration such as ``Gres=`` or ``MaxDrainedTres=``). An empty
string omits ``--conf``.
wait_for_usable (boolean): If True, block until the node registers and
reaches a usable state (IDLE or DEGRADED, and not DRAIN). A node
that registers DRAINED (e.g. GRES missing beyond MaxDrainedTres)
will not satisfy this; pass False and inspect the state directly
for those cases.
timeout (integer): Seconds to wait for the node to become usable.
quiet (boolean): If True, logging is performed at the TRACE log level.
Returns:
The node name, for convenient assignment.
Example:
>>> node = start_dynamic_slurmd("node100", extra_conf="Gres=gpu:a100:2")
>>> start_dynamic_slurmd("node101", wait_for_usable=False)
"""
if not properties["auto-config"]:
require_auto_config("wants to start a dynamic slurmd")
# Only drop the controller's node record when a daemon was actually
# running: that is a restart, and the stale record would make the
# re-register look like an unexpected node reboot. If nothing was running
# the caller stopped the slurmd deliberately and kept the record, so the
# node state -- including any drained TRES -- must survive for the new
# daemon to re-register against.
if slurmd_pids(node_name):
stop_slurmd(node_name, timeout=timeout, quiet=quiet)
delete_node(node_name, quiet=quiet)
args = "-Z -v -b"
if extra_conf:
args += f" --conf {shlex.quote(extra_conf)}"
start_slurmd(node_name, args=args, wait="registered", quiet=quiet)
if wait_for_usable:
state = ""
for _ in timer(timeout=timeout, fatal=True):
state = (get_nodes(quiet=True).get(node_name) or {}).get("state") or ""
if ("IDLE" in state or "DEGRADED" in state) and "DRAIN" not in state:
break
assert (
"IDLE" in state or "DEGRADED" in state
), f"{node_name} registered as {state!r}, not IDLE or DEGRADED"
return node_name
def delete_node(node_name, quiet=False):
"""Delete a node from the controller with ``scontrol delete NodeName=``.
Not fatal: the record may already be gone, and this is also used from
fixture teardown, where aborting would mask the real failure. Use it after
stop_slurmd() so a later slurmd start for a dynamic node is a fresh
registration rather than an unexpected node reboot.
Args:
node_name (string): The node to delete.
quiet (boolean): If True, logging is performed at the TRACE log level.
Returns:
The run_command() results of the scontrol delete.
Example:
>>> delete_node("node100")
"""
return run_command(
f"scontrol delete NodeName={node_name}",
user=properties["slurm-user"],
fatal=False,
quiet=quiet,
)
def start_slurmctld(clean=False, quiet=False, also_slurmds=False, full_recovery=False):
"""Starts the Slurm controller daemon (slurmctld).
This function may only be used in auto-config mode.
Args:
clean (boolean): If True, clears previous slurmctld state.
quiet (boolean): If True, logging is performed at the TRACE log level.
also_slurmds (boolean): If True, also start all required slurmds.
full_recovery (boolean): If True, start slurmctld with -R to recover
all state (mutually exclusive with clean, which ignores state).
Returns:
None
Example:
>>> start_slurmctld() # Start slurmctld with default settings
>>> start_slurmctld(clean=True, quiet=True) # Start slurmctld with clean state and quiet logging
>>> start_slurmctld(full_recovery=True) # Start slurmctld with -R
"""
if clean and full_recovery:
pytest.fail("clean and full_recovery are mutually exclusive")
if not properties["auto-config"]:
require_auto_config("wants to start slurmctld")
# First start sackd if necessary, so clients can be run.
if _is_sackd_required(quiet=quiet):
start_sackd(quiet=quiet)
logging.debug("Starting slurmctld...")
if is_slurmctld_running(quiet=quiet):
logging.warning("Slurmctld was already started")
stop_slurmctld(quiet, also_slurmds)
# With TLS, every configured node must be able to get its certificate.
# This covers the slurmds that tests cannot provision in start_slurmd():
# those started by Slurm itself (e.g. a ResumeProgram for CLOUD nodes) and
# "slurmd -F", whose node name slurmctld assigns from the FUTURE nodes.
# All the nodes in slurm.conf, of any State
node_names = [
name for name in get_nodes(live=False, quiet=True) if name != "DEFAULT"
]
_require_node_tls_credentials(node_names, quiet=quiet)
# Start slurmctld
command = f"{properties['slurm-sbin-dir']}/slurmctld"
if clean:
command += " -c -i"
elif full_recovery:
command += " -R"
results = run_command(command, user=properties["slurm-user"], quiet=quiet)
if results["exit_code"] != 0:
pytest.fail(
f"Unable to start slurmctld (rc={results['exit_code']}): {results['stderr']}"
)
# Verify that slurmctld is running
if not repeat_command_until(
"scontrol ping", lambda results: re.search(r"is UP", results["stdout"])
):
logging.warning(
"scontrol ping is not responding, trying to get slurmctld core file..."
)
gcore("slurmctld")
pytest.fail("Slurmctld is not running")
else:
logging.debug("Slurmctld started successfully")
if also_slurmds:
slurmd_list = node_names
if not slurmd_list:
pytest.fail("Unable to determine the slurmd node names")
# (Multi)Slurmds, waiting below for all of them at once
for slurmd_name in slurmd_list:
start_slurmd(slurmd_name, wait="process", quiet=quiet)
# Verify that the slurmd is registered correctly
timeout = 30 + 5 * len(slurmd_list)
if not repeat_until(
lambda: get_nodes(quiet=True),
lambda nodes: all(
nodes[name]["state"][0] == "IDLE" for name in slurmd_list
),
timeout=timeout,
):
nodes = get_nodes(quiet=True)
non_idle = [
name for name in slurmd_list if nodes[name]["state"][0] != "IDLE"
]
logging.warning(
f"Getting the core files of the still not IDLE slurmds ({non_idle})"
)
for node in non_idle:
pid = run_command_output(
f"pgrep -f 'slurmd -N {slurmd_name}'", quiet=quiet
).strip()
gcore("slurmd", pid)
pytest.fail(f"Some nodes are not IDLE: {non_idle}")
logging.debug(f"All nodes are IDLE: {slurmd_list}")
def start_slurmdbd(clean=False, quiet=False):
"""Starts the Slurm DB daemon (slurmdbd).
This function may only be used in auto-config mode.
Args:
clean (boolean): If True, clears previous slurmdbd state.
quiet (boolean): If True, logging is performed at the TRACE log level.
Returns:
None
"""
if not properties["auto-config"]:
require_auto_config("wants to start slurmdbd")
# First start sackd if necessary, so clients can be run.
if _is_sackd_required(quiet=quiet):
start_sackd(quiet=quiet)
# Save and drop the original DB only once per module: a later call would
# save the DB already created or modified by the module.
if properties["accounting"] != "db-saved":
dump_accounting_database(properties["sql-db-backup"], drop=True)
properties["accounting"] = "db-saved"
logging.debug("Starting slurmdbd...")
if (
run_command_exit(
"sacctmgr show cluster", user=properties["slurm-user"], quiet=quiet
)
== 0
):
logging.warning("slurmdbd was already running")
else:
# Start slurmdbd
results = run_command(
f"{properties['slurm-sbin-dir']}/slurmdbd",
user=properties["slurm-user"],
quiet=quiet,
)
if results["exit_code"] != 0:
pytest.fail(
f"Unable to start slurmdbd (rc={results['exit_code']}): {results['stderr']}"
)
# Verify that slurmdbd is running
if not repeat_command_until(
"sacctmgr show cluster", lambda results: results["exit_code"] == 0
):
logging.warning(
"sacctmgr show cluster is not responding, trying to get slurmdbd core file..."
)
gcore("slurmdbd")
pytest.fail("Slurmdbd is not running")
else:
logging.debug("Slurmdbd started successfully")
def clean_slurm_conf_nodes(quiet=False):
"""Cleans the Slurm configuration file from unnecessary default node0.
This function may only be used in auto-config mode.
Args:
quiet (boolean): If True, logging is performed at the TRACE log level.
Returns:
None
"""
if not properties["auto-config"]:
require_auto_config("wants to start slurmds")
# Remove unnecessary default node0 from config to avoid being used or reserved
output = run_command_output(
f"cat {properties['slurm-config-dir']}/slurm.conf",
user=properties["slurm-user"],
quiet=quiet,
)
if len(re.findall(r"NodeName=", output)) > 1:
run_command(
f"sed -i '/NodeName=node0 /d' {properties['slurm-config-dir']}/slurm.conf",
user=properties["slurm-user"],
quiet=quiet,
)
def _is_sackd_required(quiet=False):
"""True when slurm.conf sets AuthInfo=disable_sack.
In that mode slurmd no longer binds /run/slurm/sack.socket (so
multi-slurmd on the same host can coexist), and a standalone sackd
is required to provide that socket for client commands.
"""
info = get_config_parameter("AuthInfo", live=False, default="", quiet=quiet) or ""
return "disable_sack" in info
def start_sackd(quiet=False):
"""Starts the SACK daemon (sackd).
sackd has no log-file option, so we launch it under Popen in foreground
(`-D -vvv`) and redirect both streams to <slurm-logs-dir>/sackd.log.
"""
if not properties["auto-config"]:
require_auto_config("wants to start sackd")
sackd_exe = f"{properties['slurm-sbin-dir']}/sackd"
if pids_from_exe(sackd_exe):
logging.warning("Sackd was already running, stopping it first.")
stop_sackd(quiet)
if "slurm-logs-dir" not in properties:
properties["slurm-logs-dir"] = os.path.dirname(
get_config_parameter("SlurmctldLogFile", live=False, quiet=quiet)
)
sackd_log = f"{properties['slurm-logs-dir']}/sackd.log"
logging.debug(f"Starting sackd; logs at {sackd_log}")
properties["sackd_log"] = open(sackd_log, "w")
cmd = [
"sudo",
"-u",
properties["slurm-user"],
sackd_exe,
"-D",
"-vvv",
]
properties["sackd"] = subprocess.Popen(
cmd,
stdin=subprocess.DEVNULL,
stdout=properties["sackd_log"],
stderr=properties["sackd_log"],
start_new_session=True,
)
# Wait for either the SACK socket to appear
for t in timer():
if properties["sackd"].poll() is not None:
rc = properties["sackd"].returncode
properties["sackd_log"].flush()
properties["sackd_log"].close()
properties.pop("sackd_log", None)
properties.pop("sackd", None)
pytest.fail(f"sackd exited (rc={rc}) before binding the socket.")
if os.path.exists("/run/slurm/sack.socket"):
logging.debug("Sackd started successfully")
return
else:
pytest.fail("sackd never created /run/slurm/sack.socket")
def stop_sackd(quiet=False):
"""Stops the SACK daemon (sackd) if running."""
if not properties["auto-config"]:
require_auto_config("wants to stop sackd")
failures = []
proc = properties.pop("sackd", None)
if proc is None:
logging.debug("sackd was not running...")
else:
logging.debug("Stopping sackd...")
proc.send_signal(signal.SIGINT)
try:
proc.wait(timeout=default_polling_timeout)
logging.debug("No sackd is running.")
except subprocess.TimeoutExpired:
logging.warning("sackd didn't exit on SIGINT; sending SIGKILL")
proc.kill()
try:
proc.wait(timeout=default_polling_timeout)
logging.debug("No sackd is running.")
except Exception:
msg = "sackd didn't exit on SIGKILL"
failures.append(msg)
logging.warning(msg)
log_fh = properties.pop("sackd_log", None)
if log_fh is not None:
try:
log_fh.close()
except Exception:
msg = "Unable to close sackd logs"
failures.append(msg)
logging.warning(msg)
if failures:
return failures
return None
def start_slurm(clean=False, quiet=False):
"""Starts all applicable Slurm daemons.
This function examines the Slurm configuration files to determine which daemons
need to be started.
This function may only be used in auto-config mode.
Args:
clean (boolean): If True, clears previous slurmctld state.
quiet (boolean): If True, logging is performed at the TRACE log level.
Returns:
None
Example:
>>> start_slurm() # Start all Slurm daemons with default settings
>>> start_slurm(clean=True, quiet=True) # Start all Slurm daemons with clean state and quiet logging
"""
if not properties["auto-config"]:
require_auto_config("wants to start slurm")
# First start sackd if necessary, so clients can be run.
if _is_sackd_required(quiet=quiet):
start_sackd(quiet=quiet)
# Determine whether slurmdbd should be included
if properties["accounting"]:
start_slurmdbd(clean, quiet)
clean_slurm_conf_nodes(quiet)
# Start slurmctld
start_slurmctld(clean, quiet, also_slurmds=True)
# Start slurmrestd if required
if properties["slurmrestd-started"]:
start_slurmrestd()
def stop_slurmctld(quiet=False, also_slurmds=False, signal=None):
"""Stops the Slurm controller daemon (slurmctld), or emulates its crash.
This function may only be used in auto-config mode.
Args:
also_slurmds (boolean): If True, also stop all slurmds.
quiet (boolean): If True, logging is performed at the TRACE log level.
signal (string): If None (the default), slurmctld is shut down with
scontrol shutdown. Otherwise it is sent this signal instead, e.g.
"KILL" to emulate a crash. The slurmds, with also_slurmds, are
still shut down normally.
Returns:
None, or the list of failures if anything could not be stopped.
Example:
>>> stop_slurmctld() # Stop slurmctld with default logging
>>> stop_slurmctld(quiet=True) # Stop slurmctld with quiet logging
>>> stop_slurmctld(signal="KILL") # Emulate a slurmctld crash
"""
rc = None
failures = []
if not properties["auto-config"]:
require_auto_config("wants to stop slurmctld")
# Stop slurmctld
if signal:
logging.debug(f"Sending SIG{signal} to slurmctld...")
_stop_pids(
lambda: pids_from_exe(f"{properties['slurm-sbin-dir']}/slurmctld"),
"slurmctld",
signal,
0,
False,
quiet,
)
else:
command = "scontrol shutdown"
if not also_slurmds:
command += " slurmctld"
logging.debug("Stopping slurmctld...")
else:
logging.debug("Stopping slurmctld and slurmds...")
results = run_command(command, user=properties["slurm-user"], quiet=quiet)
if results["exit_code"] != 0:
failures.append(f"Command {command} failed: {results}")
# Verify that slurmctld is not running
gcore_timeout = 60
for t in timer(timeout=gcore_timeout, poll_interval=1):
if not pids_from_exe(f"{properties['slurm-sbin-dir']}/slurmctld"):
logging.debug("No slurmctld is running.")
break
else:
logging.warning(
f"Slurmctld is still stopping after {gcore_timeout}s, getting an early core file"
)
gcore("slurmctld", core_name="slurmctld.early_gcore", sigprof=False)
for t in timer(timeout=gcore_timeout, poll_interval=1):
if not pids_from_exe(f"{properties['slurm-sbin-dir']}/slurmctld"):
logging.debug("No slurmctld is running.")
break
else:
failures.append("Slurmctld is still running")
logging.warning("Getting the core files of the still running slurmctld")
gcore("slurmctld")
if also_slurmds:
if get_version("sbin/slurmd") < (24, 11):
# FIXED: t20764.
slurmd_list = [
name for name in get_nodes(live=False, quiet=True) if name != "DEFAULT"
]
if not slurmd_list:
failures.append("Unable to determine the slurmd node names")
for slurmd_name in slurmd_list:
run_command(
f"sudo systemctl stop {slurmd_name}_slurmstepd.scope", quiet=quiet
)
if signal:
# slurmctld did not shut down any slurmd
stop_slurmd(timeout=0, kill_on_timeout=False, quiet=quiet)
else:
# Only the recorded slurmds scontrol shutdown may not reach, e.g.
# those of deleted dynamic nodes or of an unregistered slurmd -F,
# not the ones already shutting down.
for slurmd_name in list(properties.get("slurmds-started", ())):
if slurmd_name:
stop_slurmd(
slurmd_name, timeout=0, kill_on_timeout=False, quiet=quiet
)
else:
_stop_pids(
lambda: slurmd_pids(None), "slurmd -F", "TERM", 0, False, quiet
)
properties["slurmds-started"] = set()
# Verify that slurmds are not running
if not repeat_until(
lambda: pids_from_exe(f"{properties['slurm-sbin-dir']}/slurmd"),
lambda pids: len(pids) == 0,
timeout=default_polling_timeout * 2,
):
logging.warning("Some slurmd was not stopped.")
logging.warning(
"Sometimes slurm may be just rebooting or similar when shutdown was run"
)
logging.debug("Shutting down slurmds manually")
for pid in pids_from_exe(f"{properties['slurm-sbin-dir']}/slurmd"):
run_command(f"kill {pid}", user="root")
if not repeat_until(
lambda: pids_from_exe(f"{properties['slurm-sbin-dir']}/slurmd"),
lambda pids: len(pids) == 0,
timeout=default_polling_timeout * 2,
):
failures.append("Some slurmds are still running")
logging.warning("Getting the core files of the still running slurmds")
gcore("slurmd")
else:
logging.debug("No slurmd is running.")
else:
logging.debug("No slurmd is running.")
if failures:
rc = failures
return rc
def stop_slurmdbd(quiet=False):
"""Stops the Slurm DB daemon (slurmdbd).
This function may only be used in auto-config mode.
Args:
quiet (boolean): If True, logging is performed at the TRACE log level.
Returns:
None
"""
rc = None
failures = []
if not properties["auto-config"]:
require_auto_config("wants to stop slurmdbd")
logging.debug("Stopping slurmdbd...")
# Stop slurmdbd
# Some tests may fill up the DB and sacctmgr may need some extra time
results = run_command(
"sacctmgr shutdown",
user=properties["slurm-user"],
quiet=quiet,
timeout=default_command_timeout * 5,
)
if results["exit_code"] != 0:
failures.append(
f"Command \"sacctmgr shutdown\" failed with rc={results['exit_code']}"
)
# Verify that slurmdbd is not running (we might have to wait for rollups to complete)
if not repeat_until(
lambda: pids_from_exe(f"{properties['slurm-sbin-dir']}/slurmdbd"),
lambda pids: len(pids) == 0,
timeout=default_polling_timeout * 2,
):
failures.append("Slurmdbd is still running")
logging.warning("Getting the core files of the still running slurmdbd")
gcore("slurmdbd")
else:
logging.debug("No slurmdbd is running.")
if failures:
rc = failures
return rc
def stop_slurm(fatal=True, quiet=False):
"""Stops all applicable Slurm daemons.
This function examines the Slurm configuration files to determine which daemons
need to be stopped.
This function may only be used in auto-config mode.
Args:
fatal (boolean): If True, a failure to stop all daemons will result in the
test failing.
quiet (boolean): If True, logging is performed at the TRACE log level.
Returns:
True if all Slurm daemons were stopped, False otherwise.
Example:
>>> stop_slurm() # Stop all Slurm daemons with default settings
True
>>> stop_slurm(fatal=False, quiet=True) # Stop all Slurm daemons with non-fatal failures and quiet logging
False
"""
failures = []
if not properties["auto-config"]:
require_auto_config("wants to stop slurm")
# Determine whether slurmdbd should be included
if properties["accounting"]:
err = stop_slurmdbd(quiet)
if err:
failures.extend(err)
# Stop slurmctld and slurmds
err = stop_slurmctld(quiet=quiet, also_slurmds=True)
if err:
failures.extend(err)
# Stop slurmrestd if was started
if properties["slurmrestd-started"]:
properties["slurmrestd"].send_signal(signal.SIGINT)
try:
properties["slurmrestd"].wait(timeout=60)
except Exception:
properties["slurmrestd"].kill()
properties["slurmrestd_log"].close()
# Stop sackd if it was needed for this configuration.
if _is_sackd_required(quiet=quiet):
err = stop_sackd(quiet=quiet)
if err:
failures.extend(err)
if failures:
for fail in failures:
logging.warning(fail)
if fatal:
pytest.fail(failures[0])
return False
else:
return True
def restart_slurmctld(clean=False, quiet=False, full_recovery=False):
"""Restarts the Slurm controller daemon (slurmctld).
This function may only be used in auto-config mode.
Args:
clean (boolean): If True, clears previous slurmctld state.
quiet (boolean): If True, logging is performed at the TRACE log level.
full_recovery (boolean): If True, start slurmctld with -R to recover
all state (mutually exclusive with clean, which ignores state).
Returns:
None
Example:
>>> restart_slurmctld() # Restart slurmctld with default settings
>>> restart_slurmctld(clean=True, quiet=True) # Restart slurmctld with clean state and quiet logging
>>> restart_slurmctld(full_recovery=True) # Restart slurmctld with -R
"""
if clean and full_recovery:
pytest.fail("clean and full_recovery are mutually exclusive")
stop_slurmctld(quiet=quiet)
start_slurmctld(clean=clean, quiet=quiet, full_recovery=full_recovery)
def restart_slurm(clean=False, quiet=False):
"""Restarts all applicable Slurm daemons.
This function may only be used in auto-config mode.
Args:
clean (boolean): If True, clears previous slurmctld state.
quiet (boolean): If True, logging is performed at the TRACE log level.
Returns:
None
Example:
>>> restart_slurm() # Restart all Slurm daemons with default settings
>>> restart_slurm(clean=True, quiet=True) # Restart all Slurm daemons with clean state and quiet logging
"""
stop_slurm(quiet=quiet)
start_slurm(clean=clean, quiet=quiet)
def require_slurm_running():
"""Ensures that the Slurm daemons are running.
In local-config mode, the test is skipped if Slurm is not running.
In auto-config mode, Slurm is started if necessary.
In order to avoid multiple restarts of Slurm (in auto-config), this function
should be called at the end of the setup preconditions.
Args:
None
Returns:
None
Example:
>>> require_slurm_running() # Ensure Slurm is running or start it in auto-config mode
"""
global nodes
if properties["auto-config"]:
if not is_slurmctld_running(quiet=True):
# Check and report the mixed components
versions = dict()
versions["slurmdbd"] = get_version("sbin/slurmdbd")
versions["slurmctld"] = get_version("sbin/slurmctld")
versions["slurmd"] = get_version("sbin/slurmd")
versions["scontrol"] = get_version("bin/scontrol")
if len(set(versions.values())) == 1:
logging.info(
f"Starting Slurm with all components in the same version: {versions['slurmctld']}"
)
else:
logging.info(f"Starting Slurm in a mixed version setup: {versions}")
properties["slurm-started"] = True
start_slurm(clean=True, quiet=True)
else:
if not is_slurmctld_running(quiet=True):
pytest.skip(
"This test requires slurm to be running", allow_module_level=True
)
# As a side effect, build up initial nodes dictionary
nodes = get_nodes(quiet=True)
config = get_config()
properties["slurmctld_host"] = config["SlurmctldHost[0]"].split("(")[0]
properties["slurmctld_port"] = config["SlurmctldPort"]
def is_upgrade_setup(
old_slurm_prefix="/opt/slurm-old",
new_slurm_prefix="/opt/slurm-new",
old_build_prefix="",
new_build_prefix="",
old_source_prefix="",
new_source_prefix="",
force_old=False,
):
"""
Return True if we have two Slurms configured in the system.
"""
if not os.path.exists(f"{old_slurm_prefix}/bin"):
logging.debug(f"Old prefix {old_slurm_prefix} not exists.")
return False
if not os.path.exists(f"{new_slurm_prefix}/bin"):
logging.debug(f"New prefix {new_slurm_prefix} not exists.")
return False
# Add the right properties
setup_upgrades(
old_slurm_prefix,
new_slurm_prefix,
old_build_prefix,
new_build_prefix,
old_source_prefix,
new_source_prefix,
force_old,
)
return True
def require_upgrades(
old_slurm_prefix="/opt/slurm-old",
new_slurm_prefix="/opt/slurm-new",
old_build_prefix="",
new_build_prefix="",
old_source_prefix="",
new_source_prefix="",
force_old=True,
):
"""Checks if has two different versions installed.
If they are not, skip.
"""
if not properties["auto-config"]:
require_auto_config("to change/upgrade Slurm setup")
if not is_upgrade_setup(
old_slurm_prefix,
new_slurm_prefix,
old_build_prefix,
new_build_prefix,
old_source_prefix,
new_source_prefix,
force_old,
):
pytest.skip("This test needs an upgrade setup")
# Double-check that old_version <= new_version
old_version = get_version(slurm_prefix=old_slurm_prefix)
new_version = get_version(slurm_prefix=new_slurm_prefix)
if old_version > new_version:
pytest.skip(
f"Old version ({old_version}) has to be older than new version ({new_version})"
)
logging.info(f"Required upgrade setup found: {old_version} and {new_version}")
def setup_upgrades(
old_slurm_prefix="/opt/slurm-old",
new_slurm_prefix="/opt/slurm-new",
old_build_prefix="",
new_build_prefix="",
old_source_prefix="",
new_source_prefix="",
force_old=False,
):
"""
Adds the necessary atf.properties[] with the old/new paths.
If force_old is specified itt also update the links pointing to the old
paths, and they will be restored in the global teardown.
"""
# TODO: We should use slurm-new(-build) instead of slurm-git(-build)
if old_build_prefix == "":
old_build_prefix = properties["slurm-build-dir"]
if new_build_prefix == "":
new_build_prefix = f"{properties['slurm-build-dir']}/../slurm-build-new"
if old_source_prefix == "":
old_source_prefix = properties["slurm-source-dir"]
if new_source_prefix == "":
new_source_prefix = f"{properties['slurm-source-dir']}/../slurm-git"
properties["old-slurm-prefix"] = old_slurm_prefix
properties["new-slurm-prefix"] = new_slurm_prefix
properties["old-build-prefix"] = old_build_prefix
properties["new-build-prefix"] = new_build_prefix
properties["old-source-prefix"] = old_source_prefix
properties["new-source-prefix"] = new_source_prefix
properties["forced_upgrade_setup"] = force_old
if force_old:
logging.debug(
"Setting bin/ and sbin/ pointing to old version and saving a backup..."
)
run_command(
f"sudo mv {properties['slurm-sbin-dir']} {module_tmp_path}/upgrade-sbin",
quiet=True,
fatal=True,
)
run_command(
f"sudo mv {properties['slurm-bin-dir']} {module_tmp_path}/upgrade-bin",
quiet=True,
fatal=True,
)
run_command(
f"sudo mkdir {properties['slurm-sbin-dir']} {properties['slurm-bin-dir']}",
quiet=True,
fatal=True,
)
run_command(
f"sudo ln -s {properties['old-slurm-prefix']}/sbin/* {properties['slurm-sbin-dir']}/",
quiet=True,
fatal=True,
)
run_command(
f"sudo ln -s {properties['old-slurm-prefix']}/bin/* {properties['slurm-bin-dir']}/",
quiet=True,
fatal=True,
)
def upgrade_component(component, new_version=True):
"""Upgrades a component creating the required links, and restarts it if necessary.
This function needs require_upgrades() to be already run to work properly.
Args:
component (string): The bin/ or sbin/ component of Slurm to check.
new_version (boolean): Set it false to downgrade to the older version instead.
Returns:
A tuple representing the version. E.g. (25.05.0).
"""
if (
"old-slurm-prefix" not in properties.keys()
or "new-slurm-prefix" not in properties.keys()
):
pytest.fail("To upgrade_components() first we need to call require_upgrades()")
if not os.path.exists(f"{properties['slurm-prefix']}/{component}"):
pytest.fail(f"Unknown or not existing {component}")
if new_version:
upgrade_prefix = properties["new-slurm-prefix"]
else:
upgrade_prefix = properties["old-slurm-prefix"]
# Stop components when necessary
if component == "sbin/slurmdbd":
stop_slurmdbd()
elif component == "sbin/slurmctld":
stop_slurmctld()
run_command(
f"sudo rm -f {properties['slurm-prefix']}/{component}",
quiet=True,
fatal=True,
)
run_command(
f"sudo ln -s {upgrade_prefix}/{component} {properties['slurm-prefix']}/{component}",
quiet=True,
fatal=True,
)
# Restart components when necessary
if component == "sbin/slurmdbd":
start_slurmdbd()
elif component == "sbin/slurmctld":
start_slurmctld()
def get_slurmd_C():
"""Return a dict with the main values reported by 'slurmd -C'
Numeric fields (CPUs, Boards, SocketsPerBoard, CoresPerSocket,
ThreadsPerCore, RealMemory) are returned as integers. Other fields
(NodeName and Gres) are returned as strings.
"""
fields = [
"NodeName",
"CPUs",
"Boards",
"SocketsPerBoard",
"CoresPerSocket",
"ThreadsPerCore",
"RealMemory",
"Gres",
]
integer_fields = (
"CPUs",
"Boards",
"SocketsPerBoard",
"CoresPerSocket",
"ThreadsPerCore",
"RealMemory",
)
output = run_command_output("slurmd -C", fatal=True)
keys = re.findall(r"(" + "|".join(fields) + r")=(\S+)", output)
slurmd_c = dict(keys)
for field in integer_fields:
if field in slurmd_c:
slurmd_c[field] = int(slurmd_c[field])
return slurmd_c
def get_version(component="sbin/slurmctld", slurm_prefix=""):
"""Returns the version of the Slurm component as a tuple.
It calls the component with -V and converts the output into a tuple.
It skips if the component doesn't exist in the current Slurm version.
Args:
component (string): The bin/ or sbin/ component of Slurm to check.
It also supports "config.h" to obtain the VERSION in the header.
slurm_prefix (string): The path where the component is. By default the defined in testsuite.conf.
If component is "config.h", then it's the build dir.
Returns:
A tuple representing the version. E.g. (25.05.0).
"""
# Components that don't exist in all supported versions, with the version
# they were added in.
added_in = {
"bin/swait": (26, 5),
}
if component in added_in:
require_version(
added_in[component],
component="bin/scontrol",
slurm_prefix=slurm_prefix,
reason=f"{component} was added in {added_in[component]}",
)
# TODO: Ticket 25155 - Remove fatal=False once 25.11 is not supported
fatal = True
if component == "bin/sh5util":
logging.warning(
"Ticket 25155: Exit code of 'sh5util -V' is incorrect for versions older than 26.05"
)
fatal = False
if component == "config.h":
if slurm_prefix == "":
slurm_prefix = properties["slurm-build-dir"]
header = pathlib.Path(f"{slurm_prefix}/config.h")
if not header.exists():
pytest.fail("Unable to access to config.h to get Slurm version")
version_str = re.search(
r'#define\s+VERSION\s+"([^"]+)"', header.read_text()
).group(1)
else:
if slurm_prefix == "":
slurm_prefix = f"{properties['slurm-sbin-dir']}/.."
version_str = (
run_command_output(
f"{slurm_prefix}/{component} -V", quiet=True, user="root", fatal=fatal
)
.strip()
.replace("slurm ", "")
)
return tuple(int(n) for p in version_str.split(".") for n in [p.split("-")[0]])
def get_build_config(name, fatal=False):
"""Returns the value the config.h of the Slurm build defines name to.
Assumes upgrade setups build the old and new Slurm with the same config.h.
Args:
name (string): The config.h macro to look up.
fatal (bool): Fail when config.h is missing. Otherwise, log a warning.
Returns:
The value name is defined to, an empty string if it is defined without
a value, or None if it is not defined or config.h is missing.
Example:
>>> get_build_config("HAVE_NUMA")
'1'
>>> get_build_config("HAVE_NO_SUCH_FEATURE")
None
"""
header = pathlib.Path(f"{properties['slurm-build-dir']}/config.h")
if not header.exists():
msg = f"Unable to access {header} to check for {name}"
if fatal:
pytest.fail(msg)
logging.warning(msg)
return None
for line in header.read_text().splitlines():
if not line.startswith("#"):
continue
# Autoconf indents some directives, e.g. "# define WORDS_BIGENDIAN 1"
words = line[1:].split(maxsplit=2)
if words[:2] == ["define", name]:
return words[2].strip() if len(words) > 2 else ""
return None
def require_build_config(name, value=None, reason=None):
"""Skips unless the config.h of the Slurm build defines name.
Fails if config.h is missing, since that is a misconfigured testsuite
rather than a build without the feature.
Args:
name (string): The config.h macro to require.
value (string or None): If value is a string match the macro exactly, if
value is None allow any defined macro as #ifdef.
reason (string): The reason the macro is required.
Returns:
None
Example:
>>> require_build_config("HAVE_NUMA")
"""
macro_value = get_build_config(name, fatal=True)
equal = value is not None
if (equal and macro_value != value) or (not equal and macro_value is None):
if not reason:
reason = f"This test requires Slurm built with {name}"
if equal:
reason = f"{reason} {value}"
pytest.skip(reason, allow_module_level=True)
# Unique PS1 sentinel used to synchronize on the shell prompt with pexpect.
# The set form uses '\$' (literal backslash + dollar) so the *echoed input*
# of the PS1 assignment does not match the *expect regex* below, which has
# no backslash. Bash interprets '\$' in PS1 as the prompt-escape that renders
# as '$' for a normal user and '#' for root -- hence '[\$\#]' in the regex.
_PEXPECT_PROMPT_SET = "PS1='[TEST_PROMPT]\\$ '"
_PEXPECT_PROMPT_RE = re.compile(r"\[TEST_PROMPT\][\$\#] ")
def run_command_pexpect(cmd):
"""Spawn `cmd` under pexpect with live transcript logging.
Args:
cmd (string): the command line to pass to `pexpect.spawn`.
Returns:
A `pexpect.spawn` child with `logfile_read` set to `sys.stdout`.
Example:
>>> child = atf.run_command_pexpect("salloc")
>>> child.expect("Nodes.*are ready for job") # cmd-specific sync
>>> atf.setup_pexpect_prompt(child) # switch to prompt sync
>>> child.sendline("hostname")
>>> atf.wait_pexpect_prompt(child)
>>> # child.before now contains the output of `hostname`
"""
child = pexpect.spawn(cmd, encoding="utf-8")
child.logfile_read = sys.stdout
return child
def setup_pexpect_prompt(child):
"""Install a unique PS1 sentinel on a pexpect child shell.
Note: `wait_pexpect_prompt` consumes the buffer up to the sentinel
prompt, so any output that arrived before this call -- including
command startup messages -- will only be available via `child.before`
(not via subsequent `expect()` calls). Wait for any cmd-specific
startup output BEFORE calling this.
"""
child.sendline("unset PROMPT_COMMAND")
child.sendline(_PEXPECT_PROMPT_SET)
wait_pexpect_prompt(child)
def wait_pexpect_prompt(child):
"""Wait for the pexpect-managed shell prompt sentinel to appear.
Blocks until the unique PS1 sentinel configured by
`setup_pexpect_prompt()` is seen on the stream. Use this after any
`sendline()` to know the shell has finished the previous command and
is ready for the next one.
"""
child.expect(_PEXPECT_PROMPT_RE)
def require_expect():
"""Setup the expect directory to be used later with run_expect_test()"""
# Create a tmp expect dir from sources with the right permissions
properties["expect_dir"] = f"{module_tmp_path}/expect"
run_command(
f"rsync -a {properties['testsuite_base_dir']}/expect/ {properties['expect_dir']}/",
user=properties["slurm-user"],
fatal=True,
quiet=True,
)
# Create the globals.local
properties["expect_globals_file"] = f"{properties['expect_dir']}/globals.local"
colorize = 1
if "no-color" in properties and properties["no-color"]:
colorize = 0
globals_local_content = f"""\
set testsuite_user {get_user_name()}
set testsuite_colorize {colorize}
set testsuite_cleanup_on_failure false
# These are not necessary because we should use SLURM_TESTSUITE_CONF
# set slurm_dir {properties['slurm-prefix']}
# set build_dir {properties['slurm-build-dir']}
# set src_dir {properties['slurm-source-dir']}
"""
run_command(
f"cat > {properties['expect_globals_file']}",
input=globals_local_content,
user=properties["slurm-user"],
fatal=True,
quiet=True,
)
def run_expect_test(test_num=None, fail=True):
"""Run the expect test corresponding to the test_name.
Args:
test_num (string): The expect test number (e.g. "3.17"). If None, it is
derived from properties["test_name"].
fail (bool): If True (default), call pytest.fail in case of failure.
Returns:
(reason, returncode)
Note: pytest.skip() is always raised when rc > 127 (expect-level skip)
"""
reason = None
if test_num is None:
test_num = (
properties["test_name"]
.replace("test_", "")
.replace("_py", "")
.replace("_", ".")
)
# Most expect tests assume that are run by SlurmUser.
# We need to preserve any env vars that module_load/unload/purge touched, so the lmod-loaded
# and SLURM_TESTSUITE_CONF.
preserve = [
"PATH",
"SLURM_TESTSUITE_CONF",
*sorted(properties["lmod-touched-vars"]),
]
cmd = (
f"sudo"
f" -u {properties['slurm-user']}"
f" --preserve-env={','.join(preserve)}"
f" SLURM_LOCAL_GLOBALS_FILE={properties['expect_globals_file']}"
f" {properties['expect_dir']}/test{test_num}"
)
# Expect tests need to be launch in the expect_dir.
proc = subprocess.Popen(
cmd,
cwd=properties["expect_dir"],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
shell=True,
text=True,
)
stdout = ""
for line in proc.stdout:
print(line, end="")
stdout += line
proc.wait()
# If it passed just end
if proc.returncode == 0:
return reason, 0
# Clean the stdout to search for the main reasons to not pass
ansi_escape = re.compile(r"\x1B\[[0-?]*[ -/]*[@-~]")
stdout = ansi_escape.sub("", stdout)
# Get the actual body of the test
sections = [s for s in stdout.split("=" * 78 + "\n")]
if len(sections) < 3:
pytest.fail("Something failed while running the expect test")
body = sections[2]
# Search for the main reason to not pass (as in regression.py)
fatals = re.findall(r"(?ms)\[[^\]]+\][ \[]+Fatal[ \]:]+(.*?) \(fail[^\)]+\)$", body)
errors = re.findall(
r"(?ms)\[[^\]]+\][ \[]+Error[ \]:]+(.*?) \(subfail[^\)]+\)$", body
)
warnings = re.findall(
r"(?ms)\[[^\]]+\][ \[]+Warning[ \]:]+((?:(?!Warning).)*) \((?:sub)?skip[^\)]+\)$",
body,
)
if fatals:
reason = fatals[0]
elif errors:
reason = errors[0]
elif warnings:
reason = warnings[0]
# Forward the expect result to pytest
if proc.returncode > 127:
pytest.skip(reason)
elif fail:
pytest.fail(reason)
return reason, proc.returncode
def require_version(
min_version,
component="sbin/slurmctld",
slurm_prefix="",
reason=None,
max_version=None,
):
"""Checks if the component is at least the required version, or skips.
Args:
min_version (tuple): The tuple representing the minimal version required, version should be >=.
max_version (tuple): The tuple representing the maximal version required, version should be <.
component (string): The bin/ or sbin/ component of Slurm to check.
slurm_prefix (string): The path where the component is. By default the defined in testsuite.conf.
reason (string): The reason why the version of the component is required.
"""
component_version = get_version(component, slurm_prefix)
if component_version >= min_version and (
max_version is None or component_version < max_version
):
return
if not reason:
reason = f"The version of {component} is {component_version}, required is {min_version}"
if max_version:
reason += f" up to {max_version} (not included)"
pytest.skip(reason, allow_module_level=True)
def request_slurmctld(request, user=None):
"""
Returns the slurmctld response of a given request.
It needs slurmctld >= 25.11 listening HTTP requests.
"""
token = None
if user is not None:
token = "".join(
run_command_output(
f"scontrol token username={user}",
fatal=True,
quiet=True,
user=properties["slurm-user"],
)
.strip()
.split("\n")[-1]
.split("=")[1:]
)
headers = None
# run_command_output worked and returned in time, set JWT headers
if user is not None and token is not None:
headers = {"X-SLURM-USER-NAME": user, "X-SLURM-USER-TOKEN": token}
logging.debug(f"HTTP request to slurmctld with user: {user}, token: {token}")
resp = requests.get(
f"http://{properties['slurmctld_host']}:{properties['slurmctld_port']}/{request}",
headers=headers,
)
logging.debug(f"HTTP request to slurmctld returned: {resp.text}")
return resp
def request_slurmrestd(request):
"""Returns the slurmrestd response of a given request.
It needs slurmrestd to be running (see require_slurmrestd())
"""
return requests.get(
f"{properties['slurmrestd_url']}/{request}",
headers=properties["slurmrestd-headers"],
)
def get_deprecated_openapi_spec_patch(openapi_spec, undeprecate=False):
"""
Returns a JsonPatch object that can be applied to the openapi_spec to
fully deprecate it, as we should do for older specs in newer Slurm
versions.
If undeprecate is set to True, then it does the opposite, it returns a
JsonPatch to remove all deprecate marks of the openapi_specs. This
should NOT be used in general, but as we generated some openapi_specs
with newer Slurm versions, we may need to undeprecate them (see v41).
"""
ops = []
def _escape_json_pointer(s: str) -> str:
return s.replace("~", "~0").replace("/", "~1")
for raw_path, path_obj in openapi_spec.get("paths", {}).items():
escaped_path = _escape_json_pointer(raw_path)
for method, operation in path_obj.items():
if method.lower() not in (
"get",
"post",
"put",
"delete",
"patch",
"head",
"options",
):
continue
# 1. Mark the operation itself as deprecated
if not undeprecate:
ops.append(
{
"op": "add",
"path": f"/paths/{escaped_path}/{method}/deprecated",
"value": True,
}
)
else:
ops.append(
{
"op": "remove",
"path": f"/paths/{escaped_path}/{method}/deprecated",
}
)
# 2. Mark every parameter as deprecated
params = operation.get("parameters", [])
for i, _ in enumerate(params):
if not undeprecate:
ops.append(
{
"op": "add",
"path": f"/paths/{escaped_path}/{method}/parameters/{i}/deprecated",
"value": True,
}
)
else:
ops.append(
{
"op": "remove",
"path": f"/paths/{escaped_path}/{method}/parameters/{i}/deprecated",
}
)
return jsonpatch.JsonPatch(ops)
def assert_openapi_spec_eq(spec_a, spec_b):
"""
Asserts that two OpenAPI specifications are equal, ignoring some info and
description fields.
Note that atf.properties["openapi_spec"] saves the specs used to generate
the python openapi_client from slurmrestd and we have some json specs in
the testsuite_data_dir that can be read with the openapi_spec feature.
Usually we want to assert both are equal.
Args:
spec_a: one loaded json spec
spec_b: the other loaded json spec
Returns:
None, but a pytest assert call.
"""
# Copy specs so we can change some things
_spec_a = copy.deepcopy(spec_a)
_spec_b = copy.deepcopy(spec_b)
# Avoid checking versions that change with each new release
_spec_a["info"]["x-slurm"] = None
_spec_b["info"]["x-slurm"] = None
_spec_a["info"]["version"] = None
_spec_b["info"]["version"] = None
_spec_a["tags"] = None
_spec_b["tags"] = None
# Recursively remove all description fields
def _strip_descriptions(oas):
"""Recursively removes all description fields in oas
Returns:
oas with all description fields set to None
"""
if type(oas) is dict:
for key, value in oas.items():
if key.casefold() == "description".casefold():
oas[key] = None
else:
oas[key] = _strip_descriptions(value)
elif type(oas) is list:
for value in oas:
value = _strip_descriptions(value)
return oas
_spec_a = _strip_descriptions(_spec_a)
_spec_b = _strip_descriptions(_spec_b)
d = jsondiff.diff(_spec_a, _spec_b)
assert (
len(d) == 0
), f"OpenAPI specs should be equal(equivalent, but these differences were found: {d}"
def require_openapi_generator(version="7.3.0"):
"""Generates an OpenAPI client using OpenAPI-Generator, or skips if not available (even in auto-config).
It needs slurmrestd to be running (see require_slurmrestd()).
It also sets the necessary OPENAPI_GENERATOR_VERSION and JAVA_OPTS
environment variables.
Args:
version (string): the required version.
Returns:
None
"""
# Require specific testing version
os.environ["OPENAPI_GENERATOR_VERSION"] = version
# Work around: https://github.com/OpenAPITools/openapi-generator/issues/13684
os.environ["JAVA_OPTS"] = (
"--add-opens java.base/java.util=ALL-UNNAMED --add-opens java.base/java.lang=ALL-UNNAMED"
)
ogc_version = (
run_command_output("openapi-generator-cli version").strip().split("\n")[-1]
)
if ogc_version != version:
pytest.skip(
f"test requires openapi-generator-cli version {version} (not {ogc_version})",
allow_module_level=True,
)
# allow pointing to an existing OpenAPI generated client
opath = module_tmp_path
if "SLURM_TESTSUITE_OPENAPI_CLIENT" in os.environ:
opath = os.environ["SLURM_TESTSUITE_OPENAPI_CLIENT"]
pyapi_path = f"{opath}/pyapi/"
spec_path = f"{opath}/openapi.json"
# Always create path if needed
os.makedirs(opath, exist_ok=True)
if not os.path.exists(spec_path):
r = request_slurmrestd("openapi/v3")
if r.status_code != 200:
pytest.fail(f"Error requesting openapi specs from slurmrestd: {r}")
properties["openapi_spec"] = json.loads(r.text)
if properties["openapi_spec"] is None:
pytest.fail(f"Error parsing OpenAPI specs from slurmrestd: {r.text}")
with open(spec_path, "w") as f:
f.write(r.text)
f.close()
if not os.path.exists(pyapi_path):
run_command(
f"openapi-generator-cli generate -i '{spec_path}' -g python-pydantic-v1 --strict-spec=true -o '{pyapi_path}'",
fatal=True,
timeout=60,
)
sys.path.insert(0, pyapi_path)
# Re-import openapi_client
# Regular import doesn't work if was already imported by another test.
global openapi_client
module_name = "openapi_client"
module_prefix = module_name + "."
for mod in list(sys.modules):
if mod == module_name or mod.startswith(module_prefix):
del sys.modules[mod]
openapi_client = importlib.import_module(module_name)
importlib.reload(openapi_client)
properties["openapi_config"] = openapi_client.Configuration()
properties["openapi_config"].host = properties["slurmrestd_url"]
properties["openapi_config"].access_token = properties["slurmrestd-headers"][
"X-SLURM-USER-TOKEN"
]
def openapi_util():
"""
Returns a UtilApi client from OpenAPI.
It needs require_openapi_generator() to be run first.
"""
return openapi_client.UtilApi(
openapi_client.ApiClient(properties["openapi_config"])
)
def openapi_slurm():
"""
Returns a SlurmApi client from OpenAPI.
It needs require_openapi_generator() to be run first.
"""
return openapi_client.SlurmApi(
openapi_client.ApiClient(properties["openapi_config"])
)
def openapi_slurmdb():
"""
Returns a SlurmdbApi client from OpenAPI.
It needs require_openapi_generator() to be run first.
"""
return openapi_client.SlurmdbApi(
openapi_client.ApiClient(properties["openapi_config"])
)
def get_config(live=True, source="slurm", quiet=True, delimiter="="):
"""Returns the Slurm configuration as a dictionary.
Args:
live (boolean):
If True, the configuration information is obtained via
a query to the relevant Slurm daemon (e.g., scontrol show config).
If False, the configuration information is obtained by directly
parsing the relevant Slurm configuration file (e.g. slurm.conf).
source (string):
If live is True, source should be either scontrol or sacctmgr.
If live is False, source should be the name of the config file
without the .conf prefix (e.g. slurmdbd).
quiet (boolean): If True, logging is performed at the TRACE log level.
delimiter (string): The delimiter between the parameter name and the value.
Returns:
A dictionary comprised of the parameter names and their values.
For parameters that can have multiple lines and subparameters,
the dictionary value will be a dictionary of dictionaries.
Example:
>>> get_config()
{'AccountingStorageBackupHost': '(null)', 'AccountingStorageEnforce': 'none', 'AccountingStorageHost': 'localhost', 'AccountingStorageExternalHost': '(null)', 'AccountingStorageParameters': '(null)', 'AccountingStoragePort': '0', 'AccountingStorageTRES': 'cpu,mem,energy,node,billing,fs/disk,vmem,pages', ...}
>>> get_config(live=False, source='slurm')
{'SlurmctldHost': 'nathan-atf-docstrings', 'SlurmUser': 'slurm', 'SlurmctldLogFile': '/var/slurm/log/slurmctld.log', 'SlurmctldPidFile': '/var/slurm/run/slurmctld.pid', 'SlurmctldDebug': 'debug3', 'SlurmdLogFile': '/var/slurm/log/slurmd.%n.log', 'SlurmdPidFile': '/var/slurm/run/slurmd.%n.pid', ...}
>>> get_config(live=True, source='scontrol', quiet=True)
{'AccountingStorageBackupHost': '(null)', 'AccountingStorageEnforce': 'none', 'AccountingStorageHost': 'localhost', 'AccountingStorageExternalHost': '(null)', 'AccountingStorageParameters': '(null)', 'AccountingStoragePort': '0', 'AccountingStorageTRES': 'cpu,mem,energy,node,billing,fs/disk,vmem,pages', ...}
"""
slurm_dict = {}
if live:
if source == "slurm" or source == "controller" or source == "scontrol":
command = "scontrol"
elif source == "slurmdbd" or source == "dbd" or source == "sacctmgr":
command = "sacctmgr"
else:
pytest.fail(f"Invalid live source value ({source})")
output = run_command_output(f"{command} show config", fatal=True, quiet=quiet)
for line in output.splitlines():
if match := re.search(rf"^\s*(\S+)\s*{re.escape(delimiter)}\s*(.*)$", line):
slurm_dict[match.group(1)] = match.group(2).rstrip()
else:
config = source
config_file = f"{properties['slurm-config-dir']}/{config}.conf"
# We might be looking for parameters in a config file that has not
# been created yet. If so, we just want this to return an empty dict
output = run_command_output(
f"cat {config_file}", user=properties["slurm-user"], quiet=quiet
)
for line in output.splitlines():
if match := re.search(
rf"^\s*(\S+?)\s*{re.escape(delimiter)}\s*(.*)$", line
):
parameter_name, parameter_value = (
match.group(1),
match.group(2).rstrip(),
)
if parameter_name.lower() in [
"downnodes",
"name",
"nodename",
"nodeset",
"partitionname",
"switchname",
]:
instance_name, subparameters = parameter_value.split(" ", 1)
subparameters_dict = {}
for subparameter_name, subparameter_value in re.findall(
rf" *([^= ]+) *{re.escape(delimiter)} *([^ ]+)", subparameters
):
# Reformat the value if necessary
if is_integer(subparameter_value):
subparameter_value = int(subparameter_value)
elif is_float(subparameter_value):
subparameter_value = float(subparameter_value)
elif subparameter_value == "(null)":
subparameter_value = None
subparameters_dict[subparameter_name] = subparameter_value
if parameter_name not in slurm_dict:
slurm_dict[parameter_name] = {}
slurm_dict[parameter_name][instance_name] = subparameters_dict
else:
# Reformat the value if necessary
if is_integer(parameter_value):
parameter_value = int(parameter_value)
elif is_float(parameter_value):
parameter_value = float(parameter_value)
elif parameter_value == "(null)":
parameter_value = None
slurm_dict[parameter_name] = parameter_value
return slurm_dict
def get_config_parameter(name, default=None, **get_config_kwargs):
"""Obtains the value for a Slurm configuration parameter.
This function accepts the same arguments as get_config.
Args:
name (string): The parameter name.
default (string or None): This value is returned if the parameter
is not found.
Returns:
The value of the specified parameter, or the default if not found.
Example:
>>> get_config_parameter('JobAcctGatherFrequency')
'30'
>>> get_config_parameter('MaxJobCount', default='10000')
'10000'
>>> get_config_parameter('partitionname', default='debug', live=True, source='scontrol')
'debug'
"""
config_dict = get_config(**get_config_kwargs)
# Convert keys to lower case so we can do a case-insensitive search
lower_dict = dict(
(key.casefold(), str(value).casefold()) for key, value in config_dict.items()
)
if name.casefold() in lower_dict:
return lower_dict[name.casefold()]
else:
return default
def config_parameter_includes(name, value, **get_config_kwargs):
"""Checks whether a configuration parameter includes a specific value.
When a parameter may contain a comma-separated list of values, this
function can be used to determine whether a specific value is within
the list.
This function accepts the same arguments as get_config.
Args:
name (string): The parameter name.
value (string): The value you are looking for.
Returns:
True if the specified string value is found within the parameter
value list, False otherwise.
Example:
>>> config_parameter_includes('SlurmdParameters', 'config_overrides')
False
"""
config_dict = get_config(**get_config_kwargs)
# Convert keys to lower case so we can do a case-insensitive search
lower_dict = dict((key.casefold(), value) for key, value in config_dict.items())
if name.casefold() in lower_dict and value.lower() in map(
str.lower, lower_dict[name.casefold()].split(",")
):
return True
else:
return False
def set_config_parameter(
parameter_name,
parameter_value,
source="slurm",
restart=False,
delimiter="=",
):
"""Sets the value of the specified configuration parameter.
This function modifies the specified slurm configuration file and
reconfigures slurm (or restarts slurm if restart=True). A backup
is automatically created and the original configuration is restored
after the test completes.
This function may only be used in auto-config mode.
Args:
parameter_name (string): The parameter name.
parameter_value (string): The parameter value.
Use a value of None to unset a parameter.
source (string): Name of the config file without the .conf prefix.
restart (boolean): If True and slurm is running, slurm will be
restarted rather than reconfigured.
delimiter (string): The delimiter between the parameter name and the value.
Note:
When setting a complex parameter (one which may be repeated and has
its own subparameters, such as with nodes, partitions and gres),
the parameter_value should be a dictionary of dictionaries.
AccountingStorageType is a special case: it also updates
properties["accounting"], and if slurm is running it is stopped
before the change and started after it, so slurmdbd is stopped or
started as needed.
Returns:
None
Example:
>>> set_config_parameter("ClusterName", "cluster1")
>>> set_config_parameter("required", "/tmp/spank_plugin.so", source="plugstack", delimiter=" ")
"""
if not properties["auto-config"]:
require_auto_config("wants to modify parameters")
if source == "dbd":
config = "slurmdbd"
else:
config = source
config_file = f"{properties['slurm-config-dir']}/{config}.conf"
slurmctld_running = is_slurmctld_running(quiet=True)
# Handle AccountingStorageType as a special case: it decides whether
# slurmdbd is part of slurm, so stop slurm with the current value and
# start it again with the new one.
accounting_storage_type = (
config == "slurm" and parameter_name.casefold() == "accountingstoragetype"
)
if accounting_storage_type and slurmctld_running:
stop_slurm(quiet=True)
# Remove all matching parameters and append the new parameter
lines = []
output = run_command_output(
f"cat {config_file}", user=properties["slurm-user"], quiet=True
)
for line in output.splitlines():
if not re.search(rf"(?i)^\s*{parameter_name}\s*{re.escape(delimiter)}", line):
lines.append(line)
if isinstance(parameter_value, dict):
for instance_name in parameter_value:
line = f"{parameter_name}{delimiter}{instance_name}"
for subparameter_name, subparameter_value in parameter_value[
instance_name
].items():
line += f" {subparameter_name}{delimiter}{subparameter_value}"
lines.append(line)
elif parameter_value is not None:
lines.append(f"{parameter_name}{delimiter}{parameter_value}")
input = "\n".join(lines)
run_command(
f"cat > {config_file}",
input=input,
user=properties["slurm-user"],
fatal=True,
quiet=True,
)
if accounting_storage_type:
if str(parameter_value).casefold() == "accounting_storage/slurmdbd":
if not properties["accounting"]:
properties["accounting"] = "enabled"
else:
# Restore the original DB now, the teardown won't do it anymore
if properties["accounting"] == "db-saved":
failures = restore_accounting_database(properties["sql-db-backup"])
if failures:
pytest.fail("; ".join(failures))
properties["accounting"] = None
# Remove clustername state file if we aim to change the cluster name
if parameter_name.lower() == "clustername":
state_save_location = get_config_parameter(
"StateSaveLocation", live=slurmctld_running, quiet=True
)
run_command(
f"rm -f {state_save_location}/clustername",
user=properties["slurm-user"],
quiet=True,
)
# Reconfigure (or restart) slurm controller if it is already running
if slurmctld_running:
if accounting_storage_type:
start_slurm(quiet=True)
elif source != "slurm" or parameter_name.lower() in [
"rebootprogram",
]:
restart_slurm(quiet=True)
elif restart or parameter_name.lower() in [
"authtype",
"controlmach",
"plugindir",
"statesavelocation",
"slurmctldhost",
"slurmctldport",
"slurmdport",
]:
restart_slurmctld(quiet=True)
else:
run_command(
"scontrol reconfigure", user=properties["slurm-user"], quiet=True
)
def add_config_parameter_value(name, value, source="slurm"):
"""Appends a value to configuration parameter list.
When a parameter may contain a comma-separated list of values, this
function can be used to add a value to the list.
This function may only be used in auto-config mode.
Args:
name (string): The parameter name.
value (string): The value to add.
source (string): Name of the config file without the .conf prefix.
Returns:
None
Example:
>>> add_config_parameter_value('SlurmdParameters', 'config_overrides')
"""
if config_parameter_includes(name, value, live=False, quiet=True, source=source):
return
original_value_string = get_config_parameter(
name, live=False, quiet=True, source=source
)
if original_value_string is None:
set_config_parameter(name, value, source=source)
else:
value_list = original_value_string.split(",")
value_list.append(value)
set_config_parameter(name, ",".join(value_list), source=source)
def remove_config_parameter_value(name, value, source="slurm"):
"""Removes a value from a configuration parameter list.
When a parameter may contain a comma-separated list of values, this
function can be used to remove a value from the list.
This function may only be used in auto-config mode.
Args:
name (string): The parameter name.
value (string): The value to remove.
source (string): Name of the config file without the .conf prefix.
Returns:
None
Example:
>>> remove_config_parameter_value('SlurmdParameters', 'config_overrides')
"""
if not config_parameter_includes(
name, value, live=False, quiet=True, source=source
):
return
value_list = get_config_parameter(
name, live=False, quiet=True, source=source
).split(",")
value_list.remove(value.casefold())
if value_list:
set_config_parameter(name, ",".join(value_list), source=source)
else:
set_config_parameter(name, None, source=source)
def is_tool(tool):
"""Returns True if the tool is found in PATH.
Args:
tool (string): The name of the tool to check for in the PATH environment variable.
Returns:
True if the tool is found in PATH, False otherwise.
Example:
>>> is_tool('ls')
True
>>> is_tool('uninstalled-tool-name')
False
"""
from shutil import which
return which(tool) is not None
def get_node_cpu_topology(node=None, require_numa=False):
"""Return the CPU topology a node reports, or skip.
Maps every CPU id the node presents to the socket, core and NUMA node
holding it. This map may include cpuset-restricted CPUs.
Args:
node (string): The node to read or None to read the test host directly.
require_numa (boolean): Skip unless a NUMA node is reported for every
CPU. Otherwise an unreported NUMA node is returned as None.
Returns:
A map from cpu_id to a dict of socket, core, numa_node.
Example:
>>> get_node_cpu_topology('node1')[3]
{'socket': 0, 'core': 1, 'numa_node': 0}
"""
command = "lscpu -p=CPU,CORE,SOCKET,NODE -b"
node_str = "the test host"
if node is None:
require_tool("lscpu")
results = run_command(command, quiet=True, fatal=True)
else:
node_str = f"node {node}"
srun = f"srun --nodelist={node} -N1"
run_command(f"{srun} true", quiet=True, fatal=True)
results = run_command(f"{srun} {command}", quiet=True)
if results["exit_code"] != 0:
pytest.skip(f"This test requires lscpu on {node_str}")
topology = {}
for line in results["stdout"].splitlines():
if not line or line.startswith("#"):
continue
fields = line.split(",")
if len(fields) < 3 or not all(field.isdigit() for field in fields[:3]):
pytest.skip(f"This test requires lscpu on {node_str} to report a CPU")
cpu_id, core, socket = (int(field) for field in fields[:3])
numa_field = fields[3] if len(fields) > 3 else ""
if not numa_field.isdigit():
if require_numa:
pytest.skip(
f"This test requires lscpu on {node_str} to report a NUMA node"
)
numa_node = None
else:
numa_node = int(numa_field)
topology[cpu_id] = {"socket": socket, "core": core, "numa_node": numa_node}
if not topology:
pytest.skip(f"This test requires lscpu on {node_str} to report a CPU")
return topology
def require_tool(tool):
"""Skips if the supplied tool is not found.
Args:
tool (string): The name of the tool to check for in the PATH environment variable.
Returns:
None
Example:
>>> require_tool('ls')
>>> require_tool('uninstalled-tool-name')
"""
if not is_tool(tool):
msg = f"This test requires '{tool}' and it was not found"
pytest.skip(msg, allow_module_level=True)
def require_mpi(mpi_option="pmix", mpi_compiler="mpicc"):
"""Skips if the build lacks the mpi_option plugin or mpi_compiler is missing.
The pmi2 and cray_shasta plugins are always built, so they are never
skipped and a failure to load one fails the test. The pmix plugin is
built only when configure found libpmix (HAVE_PMIX).
Args:
mpi_option (string): The value to use with --mpi when submitting jobs.
mpi_compiler (string): The required compiler in the system.
Returns:
None
"""
require_tool(mpi_compiler)
if mpi_option.startswith("pmix"):
require_build_config(
"HAVE_PMIX", reason=f"Slurm was built without the mpi/{mpi_option} plugin"
)
def require_lmod():
"""
Skips if Lmod (environment modules) is not available.
"""
require_tool("lmod")
def _module(action, *modules):
"""
Run `lmod python <action> [<modules>...]` and apply env changes
to the current Python process's os.environ.
"""
# Lmod's `python` shell emits Python code that mutates os.environ.
output = run_command_output(
f"lmod python {action} {' '.join(modules)}", fatal=True, quiet=True
)
# Applying the module and saving the difference so we can preserve them
# in sudo commands
before = dict(os.environ)
exec(output, {"os": os})
for k in set(before) | set(os.environ):
if before.get(k) != os.environ.get(k):
properties["lmod-touched-vars"].add(k)
def module_load(*modules):
"""
Load Lmod environment module(s) into the current process's env.
Args:
*modules: Module name(s) to load (e.g. "openmpi/5.0.10").
Example:
>>> require_lmod()
>>> module_load("openmpi/5.0.10")
"""
_module("load", *modules)
def module_unload(*modules):
"""
Unload Lmod environment module(s) from the current process's env.
Args:
*modules: Module name(s) to unload.
"""
_module("unload", *modules)
def module_purge():
"""Unload all currently loaded Lmod environment modules."""
_module("purge")
def require_influxdb(influx_client="influx", jobacct_gather="jobacct_gather/cgroup"):
"""Require the influx client available and the right config.
With auto-config the default values would work. Without you may need to setup the acct_gather values in the testsuite.conf file.
Args:
influx_client (string): The name of the influxdb client. Default: influx.
jobacct_gather (string): To use influxdb we need to set some JobAcctGatherType. Default: cgroup.
Returns:
None
"""
require_tool(influx_client)
require_config_parameter("JobAcctGatherType", jobacct_gather)
require_config_parameter("AcctGatherProfileType", "acct_gather_profile/influxdb")
require_config_parameter(
"ProfileInfluxDBHost",
f"{properties['influxdb_host']}:{properties['influxdb_port']}",
source="acct_gather",
)
require_config_parameter(
"ProfileInfluxDBDatabase", properties["influxdb_db"], source="acct_gather"
)
require_config_parameter("ProfileInfluxDBDefault", "ALL", source="acct_gather")
require_config_parameter("ProfileInfluxDBRTPolicy", "autogen", source="acct_gather")
request_influxdb(f"CREATE DATABASE {properties['influxdb_db']}")
properties["influxdb-started"] = True
def request_influxdb(query):
"""Send a query using the influx client. Requires to run require_influxdb first.
Args:
query (string): The query to send,
Returns:
The stdout returned by the influxdb client.
If the clients fails somehow, this function will fatal.
"""
return run_command_output(
f"influx -host {properties['influxdb_host']} -port {properties['influxdb_port']} -database {properties['influxdb_db']} -format column -execute \"{query}\"",
fatal=True,
)
def require_whereami():
"""Compiles the whereami.c program to be used by tests.
This function installs the whereami program. To get the
correct output, TaskPlugin is required in the slurm.conf
file before slurm starts up.
ex: TaskPlugin=task/cgroup,task/affinity
The file will be installed in the testsuite/python/lib/scripts
directory where the whereami.c file is located
Args:
None
Returns:
None
Examples:
>>> atf.require_whereami()
>>> print('\nwhereami is located at', atf.properties['whereami'])
>>> output = atf.run_command(f"srun {atf.properties['whereami']}",
>>> user=atf.properties['slurm-user'])
"""
require_config_parameter("TaskPlugin", "task/cgroup,task/affinity")
# If the file already exists and we don't need to recompile
dest_file = f"{properties['testsuite_scripts_dir']}/whereami"
if os.path.isfile(dest_file):
properties["whereami"] = dest_file
return
source_file = f"{properties['testsuite_scripts_dir']}/whereami.c"
if not os.path.isfile(source_file):
pytest.skip("Could not find whereami.c!", allow_module_level=True)
run_command(
f"gcc {source_file} -o {dest_file}", fatal=True, user=properties["slurm-user"]
)
properties["whereami"] = dest_file
def require_config_file(
filename,
content,
):
"""
Ensures that a configuration file exists with the required content.
In local-config mode, the test is skipped if the required config file
doesn't exists or is not equivalent.
In auto-config mode, save current config file if exists and creates the
required one.
Note that it cannot be used with some base config files like slurm.conf,
use require_config_parameter() for those config files instead.
Supported extensions for local-config are .conf and .yaml.
Args:
filename (string): The filename of the config path (e.g. "resources.yaml").
content (string): The desired content of the config file.
Returns:
None
It will fail or skip the test if required config file cannot be set.
"""
# Some config files need to be modified, cannot be created from scratch
if filename in {
"slurm.conf",
"slurmdbd.conf",
"cgroup.conf",
"testsuite.conf",
}:
pytest.fail(f"Use require_config_param() for file {filename}")
file_path = f"{properties['slurm-config-dir']}/{filename}"
# If not auto-condig:
if not properties["auto-config"]:
supported_ext = {".conf", ".yaml"}
file, ext = os.path.splitext(filename)
match ext:
case ".yaml":
desired_yaml = yaml.safe_load(content)
local_yaml = None
if os.path.exists(file_path):
with open(file_path, "r", encoding="utf-8") as f:
local_yaml = yaml.safe_load(f)
if desired_yaml != local_yaml:
pytest.skip(f"{filename} is not as required by the test")
else:
logging.debug(f"Found compatible required {filename}")
return
case ".conf":
# TODO: Ignore blank or commented lines
with open(file_path, "r", encoding="utf-8") as f:
local_content = f.read()
if content != local_content:
pytest.skip(f"{filename} is not as required by the test")
else:
logging.debug(f"Found compatible required {filename}")
return
case _:
pytest.fail(f"Supported config files are only {supported_ext}")
pytest.fail(
"Internal error: function should already skip, fail or return in local-config"
)
# In auto-config
run_command(
f"cat > {file_path}", input=content, user=properties["slurm-user"], fatal=True
)
def require_config_parameter(
parameter_name,
parameter_value,
operator="==",
source="slurm",
skip_message=None,
delimiter="=",
):
"""Ensures that a configuration parameter has the required value.
In local-config mode, the test is skipped if the required configuration is not set.
In auto-config mode, sets the required configuration value if necessary.
Args:
parameter_name (string): The parameter name.
parameter_value: The target parameter value or a list of them.
If a list of acceptable values is given, the requirement is
considered satisfied when the current value matches ANY of them.
In auto-config mode, when none match, the FIRST listed value is
used as the target — so callers should list their preferred
default first.
If a dict is passed then multiple values are assigned.
None means parameter is required to be unset.
operator (string): By default the observed current value and the
desired values are expected to be equal ("=="), but we can require
just "<=" or ">=" too (usually for numerical values).
If a dict is passed as parameter_value, only "==" is supported.
source (string): Name of the config file without the .conf prefix.
skip_message (string): Message to be displayed if in local-config mode
and parameter not present.
delimiter (string): The delimiter between the parameter name and the value.
Note:
When requiring a complex parameter (one which may be repeated and has
its own subparameters, such as with nodes, partitions and gres),
the parameter_value should be a dictionary of dictionaries. See the
fourth example for multi-line parameters.
Returns:
None
Examples:
>>> require_config_parameter('SelectType', 'select/cons_tres')
>>> require_config_parameter('SlurmdTimeout', 5, '<=')
>>> require_config_parameter('SelectType', ['select/cons_tres', 'select/linear'])
>>> require_config_parameter('Name', {'gpu': {'File': '/dev/tty0'}, 'mps': {'Count': 100}}, source='gres')
>>> require_config_parameter("PartitionName", {"primary": {"Nodes": "ALL"}, "dynamic1": {"Nodes": "ns1"}, "dynamic2": {"Nodes": "ns2"}, "dynamic3": {"Nodes": "ns1,ns2"}})
"""
_ALLOWED_OPERATORS = ("==", "<=", ">=")
if operator not in _ALLOWED_OPERATORS:
pytest.fail(
f"Unsupported operator {operator!r} (allowed: {_ALLOWED_OPERATORS})"
)
# Dict case: params like PartitionName. The outer key is the stanza
# identifier; the inner dict is the stanza's sub-key=value pairs.
if isinstance(parameter_value, dict):
if operator != "==":
pytest.fail(
f"A dict parameter_value only supports operator '==', "
f"not {operator!r}"
)
tmp1_dict = dict()
for k1, v1 in parameter_value.items():
tmp2_dict = dict()
for k2, v2 in v1.items():
if isinstance(v2, str):
tmp2_dict[k2.casefold()] = v2.casefold()
else:
tmp2_dict[k2.casefold()] = v2
tmp1_dict[k1.casefold()] = tmp2_dict
parameter_value = tmp1_dict
observed_value = get_config_parameter(
parameter_name, live=False, source=source, quiet=True, delimiter=delimiter
)
if str(observed_value) == str(parameter_value):
return
if properties["auto-config"]:
set_config_parameter(
parameter_name, parameter_value, source=source, delimiter=delimiter
)
else:
if skip_message is None:
skip_message = f"This test requires the {parameter_name} parameter to be {parameter_value} (but it is {observed_value})"
pytest.skip(skip_message, allow_module_level=True)
return
if isinstance(parameter_value, (list, tuple)):
values = list(parameter_value)
else:
values = [parameter_value]
if not values:
pytest.fail(
f"require_config_parameter({parameter_name!r}, ...) needs at least one value"
)
values = [v.casefold() if isinstance(v, str) else v for v in values]
if operator != "==" and len(values) > 1:
pytest.fail(
f"Using operator {operator!r} with multiple values doesn't make sense, right?"
)
observed_value = get_config_parameter(
parameter_name, live=False, source=source, quiet=True, delimiter=delimiter
)
if not observed_value and None in values:
# already unset, and unset is acceptable
return
condition_satisfied = False
if observed_value:
for v in values:
if v is None:
# None means expected unset, but parameter was set
continue
try:
obs = type(v)(observed_value)
except ValueError:
# The observed value cannot be converted to the expected
# type (e.g. "infinite" for an int parameter), so it cannot
# satisfy this requirement; try the next acceptable value.
continue
if operator == "==" and obs == v:
condition_satisfied = True
break
if operator == "<=" and obs <= v:
condition_satisfied = True
break
if operator == ">=" and obs >= v:
condition_satisfied = True
break
if not condition_satisfied:
target = values[0]
if properties["auto-config"]:
set_config_parameter(
parameter_name, target, source=source, delimiter=delimiter
)
else:
if skip_message is None:
skip_message = f"This test requires the {parameter_name} parameter to be {parameter_value} (but it is {observed_value})"
pytest.skip(skip_message, allow_module_level=True)
def require_config_parameter_includes(name, value, source="slurm"):
"""Ensures that a configuration parameter list contains the required value.
In local-config mode, the test is skipped if the configuration parameter
list does not include the required value.
In auto-config mode, adds the required value to the configuration parameter
list if necessary.
Args:
name (string): The parameter name.
value: The required value, in one of these forms:
* string: A single token that must be present in the comma list.
* (key, val) tuple: A "key=val" pair.
(e.g. ``('sched_interval', 1)`` in ``SchedulerParameters``).
* list: A set of acceptable alternatives (strings or tuples).
The requirement is satisfied if ANY of them is satisfied.
source (string): Name of the config file without the .conf prefix.
Returns:
None
Example:
>>> # Single value:
>>> require_config_parameter_includes('SlurmdParameters', 'config_overrides')
>>>
>>> # key=value: replaces any existing 'sched_interval=<other>' token:
>>> require_config_parameter_includes(
... 'SchedulerParameters', ('sched_interval', 1))
>>>
>>> # Any of several acceptable values; if none present in auto-config,
>>> # adds 'CR_Core_Memory' (the first listed):
>>> require_config_parameter_includes(
... 'SelectTypeParameters',
... ['CR_Core_Memory', 'CR_Memory'],
... )
"""
# A list means "one of these alternatives"; anything else is a single
# requirement (string token, or (key, val) tuple). Wrap the single case
# in a list so we can iterate uniformly below.
values = value if isinstance(value, list) else [value]
if not values:
pytest.fail(
f"require_config_parameter_includes({name!r}, ...) needs at least one value"
)
def _as_token(v):
"""Render a requirement as the token that must appear in the list."""
if isinstance(v, tuple):
if len(v) != 2:
pytest.fail("Only supporting key-value tuples")
return f"{v[0]}={v[1]}"
return str(v)
def _is_satisfied(v):
return config_parameter_includes(
name, _as_token(v), source=source, live=False, quiet=True
)
already_present = any(_is_satisfied(v) for v in values)
if properties["auto-config"]:
if already_present:
return
first = values[0]
if isinstance(first, tuple):
# (key, val): replace any existing "key=<other>" token in place;
# otherwise append. This avoids ambiguous duplicate keys.
key, _ = first
kv_token = _as_token(first)
current = get_config_parameter(name, live=False, quiet=True, source=source)
tokens = current.split(",") if current else []
key_prefix = f"{key.casefold()}="
match_idx = next(
(
i
for i, t in enumerate(tokens)
if t.casefold().startswith(key_prefix)
),
None,
)
if match_idx is not None:
tokens[match_idx] = kv_token
set_config_parameter(name, ",".join(tokens), source=source)
else:
add_config_parameter_value(name, kv_token, source=source)
else:
add_config_parameter_value(name, _as_token(first), source=source)
else:
if not already_present:
pytest.skip(
f"This test requires the {name} parameter to include any of these: {value}",
allow_module_level=True,
)
def require_config_parameter_excludes(name, value, source="slurm"):
"""Ensures that a configuration parameter list does not contain a value.
In local-config mode, the test is skipped if the configuration parameter
includes the specified value. In auto-config mode, removes the specified
value from the configuration parameter list if necessary.
Args:
name (string): The parameter name.
value (string): The value we do not want to be in the list.
source (string): Name of the config file without the .conf prefix.
Returns:
None
Example:
>>> require_config_parameter_excludes('SlurmdParameters', 'config_overrides')
"""
if properties["auto-config"]:
remove_config_parameter_value(name, value, source=source)
else:
if config_parameter_includes(
name, value, source=source, live=False, quiet=True
):
pytest.skip(
f"This test requires the {name} parameter to exclude {value}",
allow_module_level=True,
)
def require_tls():
"""Skips the test unless the cluster is configured to use TLS.
Unlike require_config_parameter('TLSType', ...), this never enables TLS in
auto-config mode: a working TLSType needs certificate infrastructure (a CA,
per-daemon certificates and the TLSParameters pointing at them) that cannot
be synthesized here, so a cluster without it is skipped in both modes.
The config file is consulted first so that a non-TLS cluster is skipped
without having to start Slurm. That read does not follow Include
directives, so a TLSType found only in an included file reads as absent
here; fall back to the live value when the file says nothing and a
controller is available.
Returns:
None
"""
tls_type = get_config_parameter("TLSType", default=None, live=False, quiet=True)
if tls_type is None and is_slurmctld_running(quiet=True):
tls_type = get_config_parameter("TLSType", default=None, quiet=True)
if not tls_type or tls_type.casefold() == "tls/none":
pytest.skip(
"This test requires the TLSType parameter to be set to a TLS "
"plugin such as tls/s2n (run under the -s2n test variant)",
allow_module_level=True,
)
def _require_node_tls_credentials(node_names, quiet=False):
"""Ensures slurmds with the given node names can get their TLS certificates.
Tests do not call this: start_slurmctld() does it for every configured
node, and start_slurmd() for the node it starts.
Without TLS, or for nodes that can already get their token, nothing is
done.
Args:
node_names (list): The node names the slurmds will register as.
quiet (boolean): If True, logging is performed at the TRACE log level.
Returns:
None
"""
if not node_names:
return
# get_config_parameter() casefolds values, and these are paths
config = {
key.casefold(): str(value)
for key, value in get_config(live=False, quiet=True).items()
}
tls_type = config.get("tlstype")
if not tls_type or tls_type.casefold() == "tls/none":
return
if not config.get("certmgrtype") or config.get("certmgrtype") == "None":
# Without certmgr, slurmd loads a static slurmd_cert_file (with %n
# expanded) signed by the CA, which the test environment does not use
slurmd_cert = re.search(
r"(?:^|,)slurmd_cert_file=([^,]+)", config.get("tlsparameters", "")
)
if slurmd_cert:
for node_name in node_names:
if not os.path.exists(slurmd_cert.group(1).replace("%n", node_name)):
pytest.fail(
f"No TLS certificate for node {node_name} and "
"provisioning static slurmd_cert_file certificates is "
"not supported"
)
return
certmgr_params = dict(
param.split("=", 1)
for param in config.get("certmgrparameters", "").split(",")
if "=" in param
)
token_script = certmgr_params.get("get_node_token_script")
validate_script = certmgr_params.get("validate_node_script")
if not token_script or not validate_script:
pytest.fail(
"CertmgrParameters needs get_node_token_script and "
"validate_node_script to provision certmgr node tokens"
)
# The token script is the source of truth: a node it returns a token for
# can get its certificate without any help. One sudo for all the nodes.
def _missing():
return run_command_output(
f"for n in {' '.join(node_names)}; do "
f"{token_script} $n >/dev/null 2>&1 || echo $n; done",
user="root",
quiet=quiet,
).split()
missing = _missing()
if not missing:
return
# Locate the files the scripts use, e.g.:
# TOKEN_PATH=/opt/slurm/etc/$1_token.txt
# NODE_TOKEN_LIST_FILE=/opt/slurm/etc/node_token_list.txt
token_path = re.search(
r"^TOKEN_PATH=(\S*)\$1(\S*)$",
run_command_output(f"cat {token_script}", user="root", quiet=True),
re.MULTILINE,
)
token_list = re.search(
r"^NODE_TOKEN_LIST_FILE=(\S+)$",
run_command_output(f"cat {validate_script}", user="root", quiet=True),
re.MULTILINE,
)
if not token_path or not token_list:
pytest.fail(
f"Unable to provision certmgr node tokens for {missing}: "
f"{token_script} or {validate_script} has an unknown format"
)
# Hex keeps the tokens free of characters special to validate_node_script's
# grep; ownership and 0600 mode are what get_node_token_script requires
logging.debug(f"Provisioning certmgr node tokens for {missing}")
slurm_user = properties["slurm-user"]
commands = []
for node_name in missing:
token = os.urandom(16).hex()
token_file = f"{token_path.group(1)}{node_name}{token_path.group(2)}"
commands.append(
f"printf '%s' '{token}' > {token_file} && chmod 0600 {token_file}"
f" && chown {slurm_user}: {token_file}"
f" && echo '{node_name}: {token}' >> {token_list.group(1)}"
)
run_command(" && ".join(commands), user="root", fatal=True, quiet=quiet)
if still_missing := _missing():
pytest.fail(f"{token_script} still has no token for {still_missing}")
def require_tty(number):
"""Creates a TTY device file if it does not exist.
Args:
number (integer): The number of the TTY device.
Returns:
None
Example:
>>> require_tty(1)
>>> require_tty(2)
"""
tty_file = f"/dev/tty{number}"
if not os.path.exists(tty_file):
run_command(
f"mknod -m 666 {tty_file} c 4 {number}", user="root", fatal=True, quiet=True
)
## Use this to create an entry in gres.conf and create an associated tty
# def require_gres_device(name):
#
# gres_value = get_config_parameter('Name', live=False, source='gres', quiet=True)
# if gres_value is None or name not in gres_value:
# if not properties['auto-config']:
# pytest.skip(f"This test requires a '{name}' gres device to be defined in gres.conf", allow_module_level=True)
# else:
# require_tty(0)
# require_config_parameter('Name', {name: {'File': '/dev/tty0'}}, source='gres')
def require_auto_config(reason=""):
"""Ensures that auto-config mode is being used.
This function skips the test if auto-config mode is not enabled.
Args:
reason (string): Augments the skip reason with a context-specific
explanation for why the auto-config mode is needed by the test.
Returns:
None
Example:
>>> require_auto_config("wants to set the Epilog")
"""
if not properties["auto-config"]:
message = "This test requires auto-config to be enabled"
if reason != "":
message += f" ({reason})"
pytest.skip(message, allow_module_level=True)
def require_accounting(modify=False, enabled=True):
"""Ensures that Slurm accounting is configured (or not).
In local-config mode, the test is skipped if Slurm accounting is not
configured as required. In auto-config mode, configures (or unconfigures)
Slurm accounting if necessary.
Args:
modify (boolean): If True, this indicates to the ATF that the test
will modify the accounting database (e.g. adding accounts, etc).
In local-config mode the test is skipped unless
--allow-slurmdbd-modify is used. In auto-config mode it makes no
difference: the original database is always saved and dropped
before slurmdbd is started, so the module starts with an empty
one, and it is restored when the module completes.
enabled (boolean): If False, ensures that Slurm accounting is NOT
configured. Incompatible with modify=True.
Returns:
None
Example:
>>> require_accounting()
>>> require_accounting(modify=True)
>>> require_accounting(enabled=False)
"""
if not enabled:
if modify:
pytest.fail("require_accounting(): modify=True requires enabled=True")
require_config_parameter(
"AccountingStorageType",
None,
skip_message="This test requires accounting not to be configured",
)
return
if (
modify
and not properties["auto-config"]
and not properties["allow-slurmdbd-modify"]
):
require_auto_config("wants to modify the accounting database")
require_config_parameter(
"AccountingStorageType",
"accounting_storage/slurmdbd",
skip_message="This test requires accounting to be configured",
)
def require_slurmrestd(openapi_plugins, data_parsers):
properties["openapi_plugins"] = openapi_plugins
properties["data_parsers"] = data_parsers
if properties["auto-config"]:
properties["slurmrestd-started"] = True
elif "SLURM_TESTSUITE_SLURMRESTD_URL" in os.environ:
properties["slurmrestd_url"] = os.environ["SLURM_TESTSUITE_SLURMRESTD_URL"]
# Setup auth token
setup_slurmrestd_headers()
# Check version is the expected one
if not is_slurmrestd_running():
pytest.skip(
f"This test needs slurmrestd running in SLURM_TESTSUITE_SLURMRESTD_URL but cannot connect with {os.environ['SLURM_TESTSUITE_SLURMRESTD_URL']}",
allow_module_level=True,
)
else:
pytest.skip(
"This test requires to start slurmrestd or SLURM_TESTSUITE_SLURMRESTD_URL",
allow_module_level=True,
)
def start_slurmrestd():
os.environ["SLURM_JWT"] = "daemon"
port = None
attempts = 0
if "slurm-logs-dir" not in properties:
properties["slurm-logs-dir"] = os.path.dirname(
get_config_parameter("SlurmctldLogFile", live=False, quiet=True)
)
properties["slurmrestd_log"] = open(
f"{properties['slurm-logs-dir']}/slurmrestd.log", "w"
)
if not properties["slurmrestd_log"]:
pytest.fail(
f"Unable to open slurmrestd log: {properties['slurm-logs-dir']}/slurmrestd.log"
)
while not port and attempts < 15:
if "slurmrestd_port" not in properties or attempts > 0:
port = get_open_port()
else:
port = properties["slurmrestd_port"]
attempts += 1
args = [
"slurmrestd",
"-a",
"jwt",
"-s",
properties["openapi_plugins"],
]
if properties["data_parsers"] is not None:
args.extend(["-d", properties["data_parsers"]])
args.append(f"localhost:{port}")
logging.debug(f"Trying to start slurmrestd: {args}")
properties["slurmrestd"] = subprocess.Popen(
args,
stdin=subprocess.DEVNULL,
stdout=properties["slurmrestd_log"],
stderr=properties["slurmrestd_log"],
)
s = None
for i in range(100):
if properties["slurmrestd"].poll():
break
try:
s = socket.create_connection(("localhost", port))
break
except Exception as e:
logging.debug(f"Unable to connect to port {port}: {e}")
time.sleep(1)
if s:
s.close()
break
logging.debug(f"slurmrestd accepting on port {port} but is still running")
properties["slurmrestd"].kill()
properties["slurmrestd"].wait()
port = None
if not port:
pytest.fail(f"Unable start slurmrestd after trying {attempts} different ports")
del os.environ["SLURM_JWT"]
properties["slurmrestd_port"] = port
properties["slurmrestd_url"] = f"http://localhost:{port}/"
# Setup auth token
setup_slurmrestd_headers()
# Check slurmrestd is up
if not is_slurmrestd_running():
pytest.fail("Slurmrestd not responding")
def setup_slurmrestd_headers():
# Create the headers with the token to connect later
token = (
run_command_output("scontrol token lifespan=600", fatal=True)
.replace("SLURM_JWT=", "")
.replace("\n", "")
)
if token == "":
logging.warning("unable to get auth/jwt token")
properties["slurmrestd-headers"] = {
"X-SLURM-USER-NAME": get_user_name(),
"X-SLURM-USER-TOKEN": token,
}
def get_user_name():
"""Returns the username of the current user.
Args:
None
Returns:
The username of the current user.
Example:
>>> get_user_name()
'john_doe'
"""
return pwd.getpwuid(os.getuid()).pw_name
def cancel_jobs(
job_list,
timeout=default_polling_timeout,
poll_interval=0.1,
**run_command_kwargs,
):
"""Cancels a list of jobs and waits for them to complete.
Args:
job_list (list): A list of job ids to cancel. All 0s will be ignored.
timeout (integer): Number of seconds to wait for jobs to be done before
timing out.
poll_interval (float): Number of seconds to wait between job state
polls.
Returns:
True if all jobs were successfully cancelled and completed within
the timeout period, False otherwise.
Example:
>>> cancel_jobs([1234, 5678], timeout=60, fatal=True)
True
>>> cancel_jobs([9876, 5432], timeout=30, fatal=False)
False
"""
# Get the quiet and fatal values but don't forward them
quiet = run_command_kwargs.pop("quiet", None)
fatal = run_command_kwargs.pop("fatal", None)
# Filter list to ignore job_ids being 0
job_list = [i for i in job_list if i != 0]
job_list_string = " ".join(str(i) for i in job_list)
if job_list_string == "":
return True
run_command(
f"scancel {job_list_string}", fatal=fatal, quiet=quiet, **run_command_kwargs
)
for t in timer(timeout=timeout, poll_interval=poll_interval):
jobs = get_jobs(quiet=True, use_json=True, **run_command_kwargs)
jobs_not_in_system, jobs_not_done = [], []
for job_id in job_list:
if job_id not in jobs:
jobs_not_in_system.append(job_id)
continue
job_state = jobs[job_id]["job_state"]
if not job_state:
pytest.fail(f"JobState info not found for job {job_id}: {jobs[job_id]}")
for state in job_state:
if not is_job_state_done(state):
jobs_not_done.append(job_id)
break
if jobs_not_in_system and not quiet:
logging.warning(
f"Cancelled job(s) {jobs_not_in_system} not anymore in the system"
)
if not jobs_not_done:
if not quiet:
logging.debug(f"Jobs {job_list} successfully cancelled")
return True
if not quiet:
logging.debug(f"Cancelled job(s) {jobs_not_done} still not DONE")
else:
if fatal:
pytest.fail("Unable to cancel all jobs")
return False
def cancel_all_jobs(
timeout=default_polling_timeout, poll_interval=0.1, fatal=False, quiet=False
):
"""Cancels all jobs in the system, and waits for them to be cancelled.
Args:
fatal (boolean): If True, a timeout will result in the test failing.
timeout (integer): If timeout number of seconds expires before the
jobs are verified to be cancelled, fail.
quiet (boolean): If True, logging is performed at the TRACE log level.
Returns:
True if all jobs were successfully cancelled and completed within
the timeout period, False otherwise.
Example:
>>> cancel_all_jobs(timeout=60, fatal=True)
True
>>> cancel_all_jobs(timeout=30, fatal=False)
False
"""
jobs = get_jobs(quiet=True, use_json=True, user=properties["slurm-user"])
if not jobs:
logging.debug("No jobs to cancel")
return True
return cancel_jobs(
jobs.keys(),
fatal=fatal,
quiet=quiet,
timeout=timeout,
poll_interval=poll_interval,
user=properties["slurm-user"],
)
def is_integer(value):
try:
int(value)
except ValueError:
return False
else:
return True
def is_float(value):
try:
float(value)
except ValueError:
return False
else:
return True
def get_nodes(live=True, quiet=False, **run_command_kwargs):
"""Returns the node configuration as a dictionary of dictionaries.
If the live argument is not True, the dictionary will contain the literal
configuration values and the DEFAULT node will be separately indexed.
Args:
live (boolean):
If True, the node configuration information is obtained via a query
to the slurmctld daemon (e.g. scontrol show config).
If False, the node configuration information is obtained by
directly parsing the Slurm configuration file (slurm.conf).
quiet (boolean): If True, logging is performed at the TRACE log level.
Returns:
A dictionary of dictionaries where the first level keys are the node
names and with their values being a dictionary of configuration
parameters for the respective node.
Example:
>>> get_nodes()
{'node1': {'NodeName': 'node1', 'Arch': 'x86_64', 'CoresPerSocket': 1, 'CPUAlloc': 0, 'CPUEfctv': 1, 'CPUTot': 1, 'CPULoad': 91.4, 'AvailableFeatures': None, 'ActiveFeatures': None, 'Gres': None, 'NodeAddr': 'slurm-host', 'NodeHostName': 'slurm-host', 'Port': 6821, 'Version': '24.05.0-0rc1', ...}}
>>> get_nodes(live=False, quiet=True)
{'node1': {'NodeHostname': 'slurm-host', 'Port': 6821, 'NodeName': 'node1'}}
"""
nodes_dict = {}
if live:
# TODO: Remove extra debug info for t22858 instead of fatal
run_command_kwargs.pop("fatal", None)
result = run_command(
"scontrol show nodes --json -F",
fatal=False,
quiet=quiet,
**run_command_kwargs,
)
if result["exit_code"]:
logging.debug(
"Fatal failure of 'scontrol show nodes', probably due 'Unable to contact slurm controller' (t22858)"
)
logging.debug("Getting gcore from slurmctld before fatal.")
gcore("slurmctld")
pytest.fail(
f"Command 'scontrol show nodes -oF' failed with rc={result['exit_code']}: {result['stderr']}"
)
output = result["stdout"]
node_dict_json = json.loads(output)["nodes"]
node_dict = {}
for node in node_dict_json:
# Add the node dictionary to the nodes dictionary
nodes_dict[node["name"]] = node
else:
# Get the config dictionary
config_dict = get_config(live=False, quiet=quiet)
# Convert keys to lower case so we can do a case-insensitive search
lower_config_dict = dict(
(key.lower(), value) for key, value in config_dict.items()
)
# DEFAULT will be included separately
if "nodename" in lower_config_dict:
for node_expression, node_expression_dict in lower_config_dict[
"nodename"
].items():
port_expression = (
node_expression_dict["Port"]
if "Port" in node_expression_dict
else ""
)
# Break up the node expression and port expression into lists
node_list = node_range_to_list(node_expression)
port_list = range_to_list(port_expression)
# Iterate over the nodes in the expression
for node_index in range(len(node_list)):
node_name = node_list[node_index]
# Add the parameters to the temporary node dictionary
node_dict = dict(node_expression_dict)
node_dict["NodeName"] = node_name
if node_index < len(port_list):
node_dict["Port"] = int(port_list[node_index])
# Add the node dictionary to the nodes dictionary
nodes_dict[node_name] = node_dict
return nodes_dict
def get_node_parameter(
node_name, parameter_name, default=None, live=True, **run_command_kwargs
):
"""Obtains the value for a node configuration parameter.
This function accepts the same keyword arguments as run_command
(e.g. user='slurm').
Args:
node_name (string): The node name.
parameter_name (string): The parameter name.
default (string or None): This value is returned if the parameter
is not found.
live (boolean):
If True, the node configuration information is obtained via a query
to the slurmctld daemon (e.g. scontrol show config).
If False, the node configuration information is obtained by
directly parsing the Slurm configuration file (slurm.conf).
Returns:
The value of the specified node parameter, or the default if not found.
Example:
>>> get_node_parameter('node1', 'State')
'IDLE'
>>> get_node_parameter('node2', 'RealMemory', default=4)
1
>>> get_node_parameter('node3', 'Partitions', default='primary', live=False)
'primary'
"""
nodes_dict = get_nodes(live=live, quiet=True, **run_command_kwargs)
if node_name in nodes_dict:
node_dict = nodes_dict[node_name]
else:
pytest.fail(f"Node ({node_name}) was not found in the node configuration")
if parameter_name in node_dict:
return node_dict[parameter_name]
else:
return default
def get_node_detail(node_name, quiet=True):
"""Returns the output of "scontrol show node <node> -d".
The detailed (-d) form is the only one that carries the GRES details and
the drained TRES; a plain "scontrol show node" (and therefore get_nodes()
and get_node_parameter()) omits them because gathering them is expensive.
Args:
node_name (string): The node name.
quiet (boolean): If True, logging is performed at the TRACE log level.
Returns:
The command output as a string.
Example:
>>> get_node_detail('node1')
'NodeName=node1 Arch=x86_64 ... DrainedTRES=gpu(IDX:0)(ecc)\\n...'
"""
return run_command_output(
f"scontrol show node {node_name} -d",
user=properties["slurm-user"],
quiet=quiet,
)
def get_node_tres_drained(node_name, quiet=True):
"""Returns a node's DrainedTRES= value, or None when nothing is drained.
The field is omitted entirely by "scontrol show node -d" when the node has
no drained TRES, so None (rather than an empty string) means "nothing
drained".
Args:
node_name (string): The node name.
quiet (boolean): If True, logging is performed at the TRACE log level.
Returns:
The DrainedTRES value as a string, or None if the node has none.
Example:
>>> get_node_tres_drained('node1')
'gpu(IDX:0)(ecc),cpu(IDX:2)(flaky)'
"""
# A reason can contain spaces, so take the rest of the line.
match = re.search(
r"^\s*DrainedTRES=(.*?)\s*$",
get_node_detail(node_name, quiet=quiet),
re.MULTILINE,
)
return match.group(1) if match else None
def update_tres_drain(
node_name,
tres_name,
selector="",
action="Drain",
reason=None,
fatal=False,
quiet=False,
user=None,
):
"""Drains or resumes a node-local TRES with "scontrol update TresName=".
Args:
node_name (string): The node the TRES lives on.
tres_name (string): The TRES to act on ("gres/gpu", "gres/gpu:a100",
"cpu" or "mem").
selector (string): The TRES to act on, as the scontrol field it is
given with (e.g. "Index=0,2", "Index=ALL", "Count=512",
"Uuid=GPU-..."). An empty string omits the selector, which is
useful to check that it is required.
action (string): "Drain", "Resume" or "SetDrain".
reason (string): Why the TRES are being drained. None omits Reason,
which is required when draining and refused when resuming.
fatal (boolean): If True, the test fails if the command fails.
quiet (boolean): If True, logging is performed at the TRACE log level.
user (string): The user to run scontrol as. Defaults to SlurmUser.
Returns:
The run_command() result dictionary, so the caller can assert on the
exit code of a request it expects to be refused.
Example:
>>> update_tres_drain('node1', 'gres/gpu', 'Index=0', reason='ecc')
>>> update_tres_drain('node1', 'cpu', 'Index=0-1', action='Resume')
"""
command = f"scontrol update TresName={tres_name} NodeName={node_name}"
if selector:
command += f" {selector}"
command += f" Action={action}"
if reason is not None:
command += f" Reason={shlex.quote(reason)}"
return run_command(
command, user=user or properties["slurm-user"], fatal=fatal, quiet=quiet
)
def fake_gpus_conf(*devices, gpu_type="a100"):
"""Builds a fake_gpus.conf body for the given devices.
Used with gres.conf "Autodetect=full". slurmd numbers the devices in
device file order, not in the order of the lines, so index i is devices[i]
only when the ttys are listed in ascending order.
Args:
*devices: One tuple per device. Either ``(tty, uuid)`` or
``(tty, gpu_type, uuid)``, where tty is the /dev/ttyN number that
backs the fake device and uuid is what it reports (use "(null)"
for a device that reports none).
gpu_type (string): The GPU type used for two-element device tuples.
Returns:
The file content as a string.
Example:
>>> fake_gpus_conf((0, "GPU-aaa"), (1, "GPU-bbb"))
'a100|2|(null)|(null)|/dev/tty0|GPU-aaa|nvidia_gpu_env\\na100|...'
>>> fake_gpus_conf((0, "h100", "GPU-ccc"))
'h100|2|(null)|(null)|/dev/tty0|GPU-ccc|nvidia_gpu_env\\n'
"""
content = ""
for device in devices:
if len(device) == 2:
tty, uuid = device
device_type = gpu_type
else:
tty, device_type, uuid = device
content += (
f"{device_type}|2|(null)|(null)|/dev/tty{tty}|{uuid}|nvidia_gpu_env\n"
)
return content
def get_node_field(node_name, field, quiet=True):
"""Returns a field of "scontrol show node -d" as its literal text.
State is best read this way: the JSON node state is a list of exact flag
names, so a substring check such as "INVAL" not in state would always pass
against it.
Args:
node_name (string): The node name.
field (string): The field name, e.g. "State".
quiet (boolean): If True, logging is performed at the TRACE log level.
Returns:
The field value as a string, or None if the node has no such field.
Example:
>>> get_node_field('node1', 'State')
'IDLE+DEGRADED'
"""
match = re.search(
rf"\b{re.escape(field)}=(\S+)", get_node_detail(node_name, quiet=quiet)
)
return match.group(1) if match else None
def restart_slurmd_with_fake_gpus(node_name, *devices, timeout=60):
"""Rewrites fake_gpus.conf to exactly ``devices`` and restarts only slurmd.
slurmd autodetects the new device set and registers with the running
controller. The node's configured Gres is left alone, so a shorter device
set registers as fewer devices than configured. Fails the test unless the
node then settles neither DOWN nor INVAL.
Args:
node_name (string): The node whose slurmd to restart.
*devices: The devices, as for fake_gpus_conf().
timeout (integer): Seconds to wait for the node to settle.
Returns:
None
Example:
>>> restart_slurmd_with_fake_gpus('node1', (0, 'GPU-aaa'), (2, 'GPU-ccc'))
"""
require_config_file("fake_gpus.conf", fake_gpus_conf(*devices))
start_slurmd(node_name)
for _ in timer(timeout=timeout, fatal=True):
state = get_node_field(node_name, "State")
if state is not None and "DOWN" not in state and "INVAL" not in state:
break
def gpu_gres_conf(node_name, count=2, gpu_type="a100"):
"""Builds a gres.conf body giving one node autodetected GPUs.
The devices are backed by /dev/tty0..N-1, so they line up with the fake
devices of a fake_gpus_conf() written for the same count. Naming the node
keeps the GPUs off any other node in the cluster.
Args:
node_name (string): The node the GPUs belong to.
count (integer): How many GPUs the node has.
gpu_type (string): The GPU type the devices are configured with.
Returns:
The file content as a string.
Example:
>>> gpu_gres_conf("node100", count=1)
'Autodetect=full\\nNodeName=node100 Name=gpu File=/dev/tty0 Type=a100\\n'
"""
content = "Autodetect=full\n"
for tty in range(count):
content += f"NodeName={node_name} Name=gpu File=/dev/tty{tty} Type={gpu_type}\n"
return content
def set_gpu_set(node_name, fake_gpus, count):
"""Rewrites fake_gpus.conf and the node's configured Gres, then restarts.
set_node_parameter() rewrites the NodeName line and restarts Slurm with
clean=False, so the controller restores the node, including any saved
UUID order. gres.conf is
left alone (typically "Autodetect=full"); only the fake device set and the
configured count change, kept equal so config_overrides never marks the
node INVALID.
This function may only be used in auto-config mode.
Args:
node_name (string): The static node whose device set is changing.
fake_gpus (string): The new fake_gpus.conf content (see
fake_gpus_conf()).
count (integer): The number of devices, used as the configured
"gpu:a100:<count>".
Returns:
None
Example:
>>> set_gpu_set('node1', fake_gpus_conf((1, GPU_B), (2, GPU_C)), 2)
"""
require_config_file("fake_gpus.conf", fake_gpus)
set_node_parameter(node_name, "Gres", f"gpu:a100:{count}")
def get_node_events(node_name):
"""Returns a node's accounting events as raw '|'-separated lines.
An open (active) event has an End field of "Unknown"; a closed event has an
End time. Every event is returned, including one opened in the current
second: "start=" makes sacctmgr ask the database for "time_start < now",
which hides an event until the clock ticks past the second it was opened
in, and that is a flake source for anything polling for a fresh event. The
bare "all_time" keyword drops the time predicate entirely instead.
The queried format is nodename|reason|tres|tresreference|end|state|start.
The state and start fields are last so the earlier indexes keep their
meaning.
Args:
node_name (string): The node name.
Returns:
The sacctmgr output as a string.
"""
command = (
f"sacctmgr -n -P list events all_time Event=Node Nodes={node_name} "
"format=nodename,reason,tres,tresreference,end,state,start"
)
return run_command_output(
command,
user=properties["slurm-user"],
fatal=True,
quiet=True,
)
def get_open_node_event(node_name):
"""Returns (reason, tres, tres_reference) of a node's open (active) event.
A node has one event open at a time, whatever took its resources away: a
whole-node event covering a Down or Drain, or a drained TRES event covering
the TRES drained on it. Opening either closes the other, so there is never
more than one to choose between.
Args:
node_name (string): The node name.
Returns:
A (reason, tres, tres_reference) tuple, or None if no event is open.
"""
for parts in _open_node_events(node_name):
return parts[1], parts[2], parts[3]
return None
def _open_node_events(node_name):
"""Yields the split fields of a node's open events, in query order.
Args:
node_name (string): The node name.
"""
for line in (get_node_events(node_name) or "").splitlines():
parts = line.split("|")
if len(parts) < 6 or parts[0] != node_name:
continue
if parts[4].strip() != "Unknown":
continue
yield parts
def get_open_node_event_reason(node_name):
"""Returns the Reason column of a node's open (active) event, or None."""
event = get_open_node_event(node_name)
return event[0] if event else None
def get_open_node_event_tres(node_name):
"""Returns the TRES column of a node's open (active) event, or None."""
event = get_open_node_event(node_name)
return event[1] if event else None
def get_open_node_event_tres_reference(node_name):
"""Returns the TresReference column of a node's open event, or None."""
event = get_open_node_event(node_name)
return event[2] if event else None
def get_open_node_event_count(node_name):
"""Returns how many of a node's accounting events are open (active).
A node has one event open at a time: opening one closes whatever was open
for it. More than one open at once would double count, since the rollup
adds up every event row overlapping the hour.
Args:
node_name (string): The node name.
Returns:
The number of open events (int).
"""
return len(list(_open_node_events(node_name)))
def get_overlapping_node_events(node_name):
"""Returns a node's accounting events that overlap one another in time.
A node has one event open at a time and each new one closes the last, so
none of a node's events should overlap at all. Any that do would double
count, since the rollup adds up every event row overlapping the hour.
Event times are printed as YYYY-MM-DDTHH:MM:SS, which sorts correctly as a
string. An open event ("Unknown" end) extends to infinity.
Args:
node_name (string): The node name.
Returns:
A list of (start, end) tuples that start before another event ended.
Empty when they are disjoint.
"""
intervals = []
for line in (get_node_events(node_name) or "").splitlines():
parts = line.split("|")
if len(parts) < 7 or parts[0] != node_name:
continue
end = parts[4].strip()
intervals.append((parts[6].strip(), "9999" if end == "Unknown" else end))
overlapping = []
prev_end = None
for start, end in sorted(intervals):
if prev_end is not None and start < prev_end:
overlapping.append((start, end))
# Events are sorted by start, so the running bound is the latest end
# seen: a long event followed by a short one still has to be caught.
prev_end = max(prev_end, end) if prev_end is not None else end
return overlapping
def get_dbd_agent_queue_size(quiet=True):
"""Returns how many messages slurmctld still has queued for slurmdbd.
Args:
quiet (boolean): If True, logging is performed at the TRACE log level.
Returns:
The "DBD Agent queue size" sdiag reports (int).
Example:
>>> get_dbd_agent_queue_size()
0
"""
output = run_command_output("sdiag", quiet=quiet, fatal=True)
match = re.search(r"DBD Agent queue size:\s*(\d+)", output)
if match is None:
pytest.fail(f"sdiag reported no DBD agent queue size:\n{output}")
return int(match.group(1))
def wait_for_dbd_agent_drained(timeout=default_polling_timeout, fatal=False):
"""Waits until slurmctld has nothing left queued for slurmdbd.
Messages such as node events reach the database only when the agent
delivers them. Two empty readings in a row are required, so a queue that
is only momentarily empty does not pass.
Args:
timeout (integer): The number of seconds to wait before timing out.
fatal (boolean): If True, a timeout will cause the test to fail.
Returns:
True if the queue drained before the timeout, False otherwise.
Example:
>>> wait_for_dbd_agent_drained(fatal=True)
True
"""
empty_polls = 0
for _ in timer(timeout=timeout, fatal=fatal):
empty_polls = empty_polls + 1 if get_dbd_agent_queue_size() == 0 else 0
if empty_polls == 2:
return True
return False
def set_node_parameter(node_name, new_parameter_name, new_parameter_value):
"""Sets the value of the specified node configuration parameter.
This function sets a node property for the specified node and restarts
the relevant slurm daemons. A backup is automatically created and the
original configuration is restored after the test completes.
This function may only be used in auto-config mode.
Args:
node_name (string): The node name.
new_parameter_name (string): The parameter name.
new_parameter_value (string): The parameter value.
Use a value of None to unset a node parameter.
Returns:
None
Example:
>>> set_node_parameter('node1', 'Features', 'f1')
"""
if not properties["auto-config"]:
require_auto_config("wants to modify node parameters")
config_file = f"{properties['slurm-config-dir']}/slurm.conf"
# Read the original slurm.conf into a list of lines
output = run_command_output(
f"cat {config_file}", user=properties["slurm-user"], quiet=True
)
original_config_lines = output.splitlines()
new_config_lines = original_config_lines.copy()
# Locate the node among the various NodeName definitions
found_node_name = False
for line_index in range(len(original_config_lines)):
line = original_config_lines[line_index]
words = re.split(r" +", line.strip())
if not words[0]:
continue
if words[0][0] == "#":
continue
parameter_name, parameter_value = words[0].split("=", 1)
if parameter_name.lower() != "nodename":
continue
# We found a NodeName line. Read in the node parameters
node_expression = parameter_value
port_expression = ""
original_node_parameters = collections.OrderedDict()
for word in words[1:]:
parameter_name, parameter_value = word.split("=", 1)
if parameter_name.lower() == "port":
port_expression = parameter_value
else:
original_node_parameters[parameter_name] = parameter_value
node_list = node_range_to_list(node_expression)
port_list = range_to_list(port_expression)
# Determine whether our node is included in this node expression
if node_name in node_list:
# Yes. We found the node
found_node_name = True
node_index = node_list.index(node_name)
# Delete the original node definition
new_config_lines.pop(line_index)
# Add the modified definition for the specified node
modified_node_parameters = original_node_parameters.copy()
if new_parameter_value is None:
if new_parameter_name in modified_node_parameters:
del modified_node_parameters[new_parameter_name]
else:
modified_node_parameters[new_parameter_name] = new_parameter_value
modified_node_line = f"NodeName={node_name}"
if node_index < len(port_list):
modified_node_line += f" Port={port_list[node_index]}"
for parameter_name, parameter_value in modified_node_parameters.items():
modified_node_line += f" {parameter_name}={parameter_value}"
new_config_lines.insert(line_index, modified_node_line)
# If the node was part of an aggregate node definition
node_list.remove(node_name)
if len(node_list):
# Write the remainder of the aggregate using the original attributes
remainder_node_expression = node_list_to_range(node_list)
remainder_node_line = f"NodeName={remainder_node_expression}"
if node_index < len(port_list):
port_list.pop(node_index)
if port_list:
remainder_port_expression = list_to_range(port_list)
remainder_node_line += f" Port={remainder_port_expression}"
for parameter_name, parameter_value in original_node_parameters.items():
remainder_node_line += f" {parameter_name}={parameter_value}"
new_config_lines.insert(line_index, remainder_node_line)
break
if not found_node_name:
pytest.fail(
f"Invalid node specified in set_node_parameter(). Node ({node_name}) does not exist"
)
# Write the config file back out with the modifications
new_config_string = "\n".join(new_config_lines)
run_command(
f"echo '{new_config_string}' > {config_file}",
user=properties["slurm-user"],
fatal=True,
quiet=True,
)
# Restart slurm controller if it is already running
if is_slurmctld_running(quiet=True):
restart_slurm(quiet=True)
def get_reservations(quiet=False, **run_command_kwargs):
"""Returns the reservations as a dictionary of dictionaries.
Args:
quiet (boolean): If True, logging is performed at the TRACE log level.
Returns:
A dictionary of dictionaries where the first level keys are the
reservation names and with their values being a dictionary of
configuration parameters for the respective reservation.
Example:
>>> get_reservations()
{'resv1': {'ReservationName': 'resv1', 'StartTime': '2024-04-06T00:00:17', 'EndTime': '2024-04-06T00:15:17', 'Duration': '00:15:00', 'Nodes': 'node1', 'NodeCnt': 1, 'CoreCnt': 1, 'Features': None, 'PartitionName': 'primary', 'Flags': 'DAILY,REPLACE_DOWN,PURGE_COMP=00:01:00', 'TRES': 'cpu=1', 'Users': 'atf', 'Groups': None, 'Accounts': None, 'Licenses': None, 'State': 'INACTIVE', 'BurstBuffer': None, 'MaxStartDelay': None}}
"""
resvs_dict = {}
resv_dict = {}
output = run_command_output(
"scontrol show reservations -o", quiet=quiet, **run_command_kwargs
)
for line in output.splitlines():
if line == "":
continue
while match := re.search(r"^ *([^ =]+)=(.*?)(?= +[^ =]+=| *$)", line):
parameter_name, parameter_value = match.group(1), match.group(2)
# Remove the consumed parameter from the line
line = re.sub(r"^ *([^ =]+)=(.*?)(?= +[^ =]+=| *$)", "", line)
# Reformat the value if necessary
if is_integer(parameter_value):
parameter_value = int(parameter_value)
elif is_float(parameter_value):
parameter_value = float(parameter_value)
elif parameter_value == "(null)":
parameter_value = None
# Add it to the temporary resv dictionary
resv_dict[parameter_name] = parameter_value
if "ReservationName" in resv_dict:
# Add the resv dictionary to the resvs dictionary
resvs_dict[resv_dict["ReservationName"]] = resv_dict
# Clear the resv dictionary for use by the next resv
resv_dict = {}
return resvs_dict
def get_reservation_parameter(resv_name, parameter_name, default=None):
"""Obtains the value for a reservation configuration parameter.
Args:
resv_name (string): The reservation name.
parameter_name (string): The parameter name.
default (string or None): This value is returned if the parameter
is not found.
Returns:
The value of the specified reservation parameter, or the default if not found.
Example:
>>> get_reservation_parameter('resv1', 'PartitionName')
'primary'
>>> get_reservation_parameter('resv2', 'EndTime', default='2024-04-06T00:15:17')
'2024-04-06T00:15:17'
"""
resvs_dict = get_reservations()
if resv_name in resvs_dict:
resv_dict = resvs_dict[resv_name]
else:
pytest.fail(f"reservation ({resv_name}) was not found")
if parameter_name in resv_dict:
return resv_dict[parameter_name]
else:
return default
def is_super_user():
"""Checks if the current user is a super user.
Args:
None
Returns:
True if the current user is a super user, False otherwise.
Example:
>>> is_super_user()
False
"""
uid = os.getuid()
if uid == 0:
return True
user = pwd.getpwuid(uid)[0]
if get_config_parameter("SlurmUser") == user:
return True
return False
def require_sudo_rights():
"""Skips the test if the test user does not have unprompted sudo privileges.
Args:
None
Returns:
None
Example:
>>> require_sudo_rights()
"""
if not properties["sudo-rights"]:
pytest.skip(
"This test requires the test user to have unprompted sudo privileges",
allow_module_level=True,
)
def submit_job_sbatch(sbatch_args='--wrap "sleep 60"', **run_command_kwargs):
"""Submits a job using sbatch and returns the job id.
The submitted job will automatically be cancelled when the test ends.
Args:
sbatch_args (string): The arguments to sbatch.
Returns:
The job id.
Note:
The background parameter is ignored and always treated as False, since
sbatch is already non-blocking and the function needs to capture the
job ID from output.
Example:
>>> submit_job_sbatch('--wrap "echo Hello"')
1234
>>> submit_job_sbatch('-J myjob --wrap "echo World"', fatal=True)
5678
"""
# Since sbatch is non-blocking anyway, make sure we're not in the background
run_command_kwargs["background"] = False
output = run_command_output(f"sbatch {sbatch_args}", **run_command_kwargs)
if match := re.search(r"Submitted \S+ job (\d+)", output):
job_id = int(match.group(1))
properties["submitted-jobs"].append(job_id)
return job_id
else:
return 0
# Returns results
def run_job(srun_args, **run_command_kwargs):
"""Runs a job using srun and returns the run_command results dictionary.
If the srun command times out, it will automatically be cancelled when the
test ends.
Args:
srun_args (string): The arguments to srun.
Returns:
The srun run_command results dictionary.
Example:
>>> run_job('-n 4 --output=output.txt ./my_executable', timeout=60)
{'command': 'srun -n 4 --output=output.txt ./my_executable', 'start_time': 1712276708.827, 'duration': 60.0, 'exit_code': 110, 'stdout': '', 'stderr': 'srun: Requested partition configuration not available now\nsrun: job 15 queued and waiting for resources\n'}
>>> run_job('-n 1 --error=error.txt ./my_executable', fatal=True)
{'command': 'srun -n 1 --error=error.txt my_executable', 'start_time': 1712277798.016, 'duration': 0.06, 'exit_code': 0, 'stdout': 'foo bar\n', 'stderr': ''}
"""
results = run_command(f"srun {srun_args}", **run_command_kwargs)
return results
# Return exit code
def run_job_exit(srun_args, **run_command_kwargs):
"""Runs a job using srun and returns the exit code.
If the srun command times out, it will automatically be cancelled when the
test ends.
Args:
srun_args (string): The arguments to srun.
Returns:
The exit code from srun.
Example:
>>> run_job_exit('-n 4 --output=output.txt ./my_executable', timeout=60)
2
>>> run_job_exit('-n 2 --error=error.txt ./my_executable', fatal=True)
0
"""
results = run_job(srun_args, **run_command_kwargs)
return results["exit_code"]
# Return output
def run_job_output(srun_args, **run_command_kwargs):
"""Runs a job using srun and returns the standard output.
If the srun command times out, it will automatically be cancelled when the
test ends.
Args:
srun_args (string): The arguments to srun.
Returns:
The standard output from srun.
Example:
>>> run_job_output('-n 4 ./my_executable', timeout=60)
'stdout of the command'
>>> run_job_output('-n 2 --output=output.txt ./my_executable', fatal=True)
''
"""
results = run_job(srun_args, **run_command_kwargs)
return results["stdout"]
# Return error
def run_job_error(srun_args, **run_command_kwargs):
"""Runs a job using srun and returns the standard error.
If the srun command times out, it will automatically be cancelled when the
test ends.
Args:
srun_args (string): The arguments to srun.
Returns:
The standard error from srun.
Example:
>>> run_job_error('-n 4 --output=output.txt ./my_executable', timeout=60)
'stderr of the command'
>>> run_job_error('-n 200000 ./my_executable', fatal=True) # Will automatically fail the test due to resources
"""
results = run_job(srun_args, **run_command_kwargs)
return results["stderr"]
# Return job id
def submit_job_srun(srun_args, return_process=False, **run_command_kwargs):
"""Runs a job using srun and returns the job id.
This function obtains the job id by adding the -v option to srun and parsing
out the job id. If the srun command times out, it will automatically be
cancelled when the test ends.
Args:
srun_args (string): The arguments to srun.
return_process (boolean): If True, with background=True, also return
the srun process, so the caller can keep reading its stderr after
the job id.
Returns:
The job id (int) or 0 if failed, or the tuple (job id, process) with
return_process.
Example:
>>> submit_job_srun('-n 4 --output=output.txt ./my_executable')
12345
>>> submit_job_srun('-n 2 --output=output.txt ./my_executable', timeout=60)
67890
>>> submit_job_srun('-n 2 --output=output.txt ./my_executable', background=True)
34567
>>> job_id, process = submit_job_srun('./my_executable', background=True, return_process=True)
"""
if return_process and not run_command_kwargs.get("background", False):
pytest.fail("submit_job_srun(): return_process requires background=True")
verbose_arg = "-v"
if run_command_kwargs.get("background", False):
# Use timeout for finding job id
timeout = run_command_kwargs.pop("timeout", 30)
# Extract fatal/xfail to handle at job submission level
fatal = run_command_kwargs.pop("fatal", False)
xfail = run_command_kwargs.pop("xfail", False)
if get_version("bin/srun") < (25, 11):
# Older versions may not print the jobid with -v until job is scheduled
verbose_arg = "--begin=now+1"
logging.warning(
"Using {verbose_arg} to be able to extract jobid when using srun background"
)
results = run_command(f"srun {verbose_arg} {srun_args}", **run_command_kwargs)
if run_command_kwargs.get("background", False):
job_id = None
start_time = time.time()
while time.time() - start_time < timeout:
line = _readline_with_timeout(results["process"].stderr, timeout=5)
if line is None:
# Timeout or EOF
if results["process"].poll() is not None:
break
continue
if match := re.search(r"job(id)?[ =](\d+)", line, re.IGNORECASE):
job_id = int(match.group(2))
break
if results["process"].poll() is not None:
break
else:
if match := re.search(r"job(id)?[ =](\d+)", results["stderr"], re.IGNORECASE):
job_id = int(match.group(2))
else:
job_id = None
if job_id is not None:
properties["submitted-jobs"].append(job_id)
# Handle fatal/xfail for background jobs
if run_command_kwargs.get("background", False):
if fatal:
if xfail and job_id is not None:
pytest.fail(
f'Command "srun -v {srun_args}" was expected to fail but got job id {job_id}'
)
elif not xfail and job_id is None:
pytest.fail(f'Command "srun -v {srun_args}" failed to get job id')
job_id = job_id if job_id else 0
if return_process:
return job_id, results["process"]
return job_id
# Reads in progress (thread and result) by stream, see _readline_with_timeout()
_pending_readlines = {}
def _readline_with_timeout(stream, timeout=default_command_timeout):
"""Read a line from a stream with a timeout.
A read that times out is not abandoned, as it would still be blocked in
readline() and would swallow the next line. It is kept in progress and
the next call for the same stream waits for it, so no line is lost.
Args:
stream: The stream to read from (e.g., process.stderr)
timeout: Maximum seconds to wait for a line
Returns:
The line read from the stream, or None if timeout/EOF
"""
if stream not in _pending_readlines:
result = [None]
def read_line():
try:
result[0] = stream.readline()
except Exception:
pass
thread = threading.Thread(target=read_line)
thread.daemon = True
thread.start()
_pending_readlines[stream] = (thread, result)
thread, result = _pending_readlines[stream]
thread.join(timeout)
if thread.is_alive():
# Timeout occurred
return None
del _pending_readlines[stream]
return result[0]
# Return job id (command should not be interactive/shell)
def submit_job_salloc(salloc_args, **run_command_kwargs):
"""Submits a job using salloc and returns the job id.
The submitted job will automatically be cancelled when the test ends.
Args:
salloc_args (string): The arguments to salloc.
Returns:
The job id (int) or 0 if failed.
Example:
>>> submit_job_salloc('-N 1 -t 60 --output=output.txt ./my_executable')
12345
>>> submit_job_salloc('-N 2 -t 120 --output=output.txt ./my_executable', timeout=60)
67890
>>> submit_job_salloc('-N 2 -t 120 --output=output.txt ./my_executable', background=True)
34567
"""
# If background=True, use timeout for finding job id
if run_command_kwargs.get("background", False):
timeout = run_command_kwargs.pop("timeout", 30)
# Extract fatal/xfail to handle at job submission level
fatal = run_command_kwargs.pop("fatal", False)
xfail = run_command_kwargs.pop("xfail", False)
results = run_command(f"salloc {salloc_args}", **run_command_kwargs)
if run_command_kwargs.get("background", False):
job_id = None
start_time = time.time()
while time.time() - start_time < timeout:
line = _readline_with_timeout(results["process"].stderr, timeout=5)
if line is None:
# Timeout or EOF
if results["process"].poll() is not None:
break
continue
if match := re.search(r"job allocation (\d+)", line):
job_id = int(match.group(1))
break
if results["process"].poll() is not None:
break
else:
if match := re.search(r"Granted job allocation (\d+)", results["stderr"]):
job_id = int(match.group(1))
else:
job_id = None
if job_id is not None:
properties["submitted-jobs"].append(job_id)
# Handle fatal/xfail for background jobs
if run_command_kwargs.get("background", False):
if fatal:
if xfail and job_id is not None:
pytest.fail(
f'Command "salloc {salloc_args}" was expected to fail but got job id {job_id}'
)
elif not xfail and job_id is None:
pytest.fail(f'Command "salloc {salloc_args}" failed to get job id')
return job_id if job_id else 0
# Return job id
def submit_job(command, job_param, job, *, wrap_job=True, **run_command_kwargs):
"""Submits a job using the given command and returns the job id.
Args:
command (string): The command to submit the job (salloc, srun, sbatch).
job_param (string): The arguments to the job.
job (string): The command or job file to be executed.
wrap_job (boolean): If True, the job will be wrapped when the command is 'sbatch'.
Returns:
The job id.
Example:
>>> submit_job('salloc', '-N 1 -t 60', './my_executable', quiet=True)
12345
>>> submit_job('srun', '-N 2 -t 120', './my_executable', quiet=True)
67890
>>> submit_job('sbatch', '-N 1 -t 60', './my_script.sh', wrap_job=False, quiet=True)
23456
"""
# Make sure command is a legal command to run a job
assert command in [
"salloc",
"srun",
"sbatch",
], f"Invalid command '{command}'. Should be salloc, srun, or sbatch."
if command == "salloc":
return submit_job_salloc(f"{job_param} {job}", **run_command_kwargs)
elif command == "srun":
return submit_job_srun(f"{job_param} {job}", **run_command_kwargs)
elif command == "sbatch":
# If the job should be wrapped, do so before submitting
if wrap_job:
job = f"--wrap '{job}'"
return submit_job_sbatch(f"{job_param} {job}", **run_command_kwargs)
def run_job_nodes(srun_args, **run_command_kwargs):
"""Runs a job using srun and returns the allocated node list.
This function obtains the job id by adding the -v option to srun and parsing
out the allocated node list. If the srun command times out, it will
automatically be cancelled when the test ends.
Args:
srun_args (string): The arguments to srun.
Returns:
The allocated node list for the job.
Example:
>>> run_job_nodes('-N 2 hostname', quiet=True)
['node001', 'node002']
>>> run_job_nodes('-N 1 --exclude=node001 hostname', quiet=True)
['node002']
"""
results = run_command(f"srun -v {srun_args}", **run_command_kwargs)
node_list = []
if results["exit_code"] == 0:
if match := re.search(r"jobid \d+: nodes\(\d+\):`([^']+)'", results["stderr"]):
node_list = node_range_to_list(match.group(1))
return node_list
def get_qos(name=None, **run_command_kwargs):
"""Returns the QOSes in the system as a dictionary of dictionaries.
Args:
name: The name of a specific QOS of which to get parameters.
Returns:
A dictionary of dictionaries where the first level keys are the QOSes names
and with their values being a dictionary of configuration parameters for
the respective QOS.
"""
command = "sacctmgr --json show qos"
if id is not None:
command += f" {name}"
output = run_command_output(command, **run_command_kwargs)
qos_dict = {}
qos_list = json.loads(output)["qos"]
for qos in qos_list:
qos_dict[qos["name"]] = qos
return qos_dict
def get_jobs(job_id=None, dbd=False, use_json=False, **run_command_kwargs):
"""Returns the jobs in the system as a dictionary of dictionaries.
Args:
job_id (integer): The id of a specific job of which to get parameters.
dbd (boolean): If True, obtain the jobs from sacct instead of scontrol.
Returns:
A dictionary of dictionaries where the first level keys are the job ids
and with their values being a dictionary of configuration parameters for
the respective job.
Example:
>>> get_jobs()
{38: {'JobId': 38, 'JobName': 'wrap', 'UserId': 'atf(1002)', 'GroupId': 'atf(1002)', ...},
39: {'JobId': 39, 'JobName': 'wrap', 'UserId': 'atf(1002)', 'GroupId': 'atf(1002)', ...}}
>>> get_jobs(job_id='12345', quiet=True)
{12345: {'JobId': '12345', 'JobName': 'foo.sh', 'UserId': 'test(1003)', ...}}
"""
jobs_dict = {}
if dbd:
command = "sacct --json -X"
if job_id is not None:
command += f" -j {job_id}"
output = run_command_output(command, **run_command_kwargs)
jobs_list = json.loads(output)["jobs"]
for job in jobs_list:
jobs_dict[job["job_id"]] = job
else:
# TODO: We should always use --json to properly parse Slurm parameters.
# This is still optional because using it means that the keys
# of the jobs_dict will be different in all existing tests.
if use_json:
command = "scontrol --json -d -a show jobs"
if job_id is not None:
command += f" {job_id}"
output = run_command_output(command, **run_command_kwargs)
try:
jobs_list = json.loads(output)["jobs"]
except json.JSONDecodeError as e:
msg = f"Error parsing scontrol output: {output}\n{e}"
if "fatal" in run_command_kwargs and run_command_kwargs["fatal"]:
pytest.fail(msg)
else:
logging.warning(msg)
return jobs_dict
for job in jobs_list:
jobs_dict[job["job_id"]] = job
return jobs_dict
command = "scontrol -d -o show jobs"
if job_id is not None:
command += f" {job_id}"
# TODO: Remove extra debug info for t22858 instead of forwarding fatal
run_command_kwargs.pop("fatal", None)
result = run_command(command, fatal=False, **run_command_kwargs)
if result["exit_code"]:
logging.debug(
f"Fatal failure of {command}, probably due 'Unable to contact slurm controller' (t22858)"
)
logging.debug("Getting gcore from slurmctld before fatal.")
gcore("slurmctld")
pytest.fail(
f"Command {command} failed with rc={result['exit_code']}: {result['stderr']}"
)
output = result["stdout"]
job_dict = {}
for line in output.splitlines():
if line == "":
continue
original_line = line
while match := re.search(r"^ *([^ =]+)=(.*?)(?= +[^ =]+=| *$)", line):
param_name, param_value = match.group(1), match.group(2)
# Remove the consumed parameter from the line
line = re.sub(r"^ *([^ =]+)=(.*?)(?= +[^ =]+=| *$)", "", line)
# Reformat the value if necessary
if is_integer(param_value):
param_value = int(param_value)
elif is_float(param_value):
param_value = float(param_value)
elif param_value == "(null)":
param_value = None
# Add it to the temporary job dictionary
job_dict[param_name] = param_value
# Add the job dictionary to the jobs dictionary
if "JobId" in job_dict:
jobs_dict[job_dict["JobId"]] = job_dict
# Clear the job dictionary for use by the next job
job_dict = {}
elif job_dict:
# A line carrying parameters but no JobId belongs to no job of
# its own, the way a detail line of the record before it does.
# Keeping it would attribute its parameters to the next job.
logging.debug(f"Ignoring job line without a JobId: {original_line}")
job_dict = {}
return jobs_dict
def get_steps(job_id, **run_command_kwargs):
"""Returns the steps of a given job_id as a dictionary of dictionaries.
Args:
job_id (string): The specific JobId to retrieve information for.
Returns:
A dictionary of dictionaries where the first level keys are the step ids
and with their values being a dictionary of configuration parameters for
the respective step.
Example:
>>> get_steps(44)
{'44.batch': {'StepId': '44.batch', 'UserId': 1002, 'StartTime': '2024-04-05T01:17:53', ...},
'44.0': {'StepId': 44.0, 'UserId': 1002, 'StartTime': '2024-04-05T01:17:54', ...}}
"""
steps_dict = {}
step_dict = {}
command = f"scontrol -d -o show steps {job_id}"
result = run_command(command, **run_command_kwargs)
if result["exit_code"]:
logging.debug("scontrol command failed, no steps returned")
return step_dict
output = result["stdout"]
for line in output.splitlines():
if line == "":
continue
while match := re.search(r"^ *([^ =]+)=(.*?)(?= +[^ =]+=| *$)", line):
param_name, param_value = match.group(1), match.group(2)
# Remove the consumed parameter from the line
line = re.sub(r"^ *([^ =]+)=(.*?)(?= +[^ =]+=| *$)", "", line)
# Reformat the value if necessary
if is_integer(param_value):
param_value = int(param_value)
elif is_float(param_value) and param_name != "StepId":
param_value = float(param_value)
elif param_value == "(null)":
param_value = None
# Add it to the temporary step dictionary
step_dict[param_name] = param_value
# Add the step dictionary to the steps dictionary
if step_dict:
steps_dict[str(step_dict["StepId"])] = step_dict
# Clear the step dictionary for use by the next step
step_dict = {}
return steps_dict
def get_job(job_id, quiet=False):
"""Returns job information for a specific job as a dictionary.
Args:
job_id (integer): The id of the job for which information is requested.
quiet (boolean): If True, logging is performed at the TRACE log level.
Returns:
A dictionary containing parameters for the specified job. If the job id
is not found, an empty dictionary is returned.
Example:
>>> get_job(51)
{'JobId': 51, 'JobName': 'wrap', 'UserId': 'atf(1002)', 'GroupId': 'atf(1002)', ...}
>>> get_job(182, quiet=True)
{'JobId': 182, 'JobName': 'foo.sh', 'UserId': 'atf(1002)', 'GroupId': 'atf(1002)', ...}
"""
jobs_dict = get_jobs(job_id, quiet=quiet)
return jobs_dict[job_id] if job_id in jobs_dict else {}
def get_job_parameter(job_id, parameter_name, default=None, **run_command_kwargs):
"""Returns the value of a specific parameter for a given job.
Args:
job_id (integer): The id of the job for which the parameter value is requested.
parameter_name (string): The name of the parameter whose value is to be obtained.
default (string or None): The value to be returned if the parameter is not found.
Returns:
The value of the specified job parameter, or the default value if the
parameter is not found.
Example:
>>> get_job_parameter(12345, 'UserId')
'atf(1002)'
>>> get_job_parameter(67890, 'Partition', default='normal', quiet=True)
'primary'
"""
jobs_dict = get_jobs(**run_command_kwargs)
if job_id in jobs_dict:
job_dict = jobs_dict[job_id]
else:
pytest.fail(f"Job ({job_id}) was not found in the job configuration")
if parameter_name in job_dict:
return job_dict[parameter_name]
else:
return default
def get_stepmgr_host(job_id, **run_command_kwargs):
"""Returns the host running a stepmgr-enabled job's step manager.
The stepmgr runs in the job's extern slurmstepd on its BatchHost, which
is the host commands such as swait talk to directly.
Args:
job_id (integer): The id of the job whose stepmgr host is requested.
Note:
fatal= is not honored: get_jobs() drops it. This function always
pytest.fail()s when the job has no BatchHost.
Returns:
The hostname of the job's stepmgr.
Example:
>>> get_stepmgr_host(12345)
'node1'
"""
# get_jobs() pops fatal=, so get_job_parameter() cannot fail on a
# missing value; fail explicitly rather than returning None.
host = get_job_parameter(job_id, "BatchHost", **run_command_kwargs)
if not host:
pytest.fail(f"Job ({job_id}) has no BatchHost, so it has no stepmgr")
return host
# swait -v logs this on stderr once the stepmgr has accepted its subscribe
# request. swait(1) documents it under -v, so it is a contract, not an
# incidental log line.
SWAIT_SUBSCRIBED = "subscribed to stepmgr"
# Script exit code meaning "the subscribe sentinel never appeared", kept
# well clear of swait's own 0/1/2/3 so a harness timeout is not read as a
# feature failure.
SWAIT_SUBSCRIBE_TIMEOUT_RC = 90
def swait_await_subscribe(out_file, quote="'"):
"""Returns a shell fragment that blocks until a backgrounded swait -v
has subscribed.
Signalling a step before the subscribe lands makes the stepmgr
fast-return 0 with no output, which reads as a content failure rather
than the timing problem it is. The fragment exits
SWAIT_SUBSCRIBE_TIMEOUT_RC instead, so an exhausted poll names itself.
Args:
out_file (string): File the backgrounded swait -v writes stderr to.
quote (string): Quote character to wrap the sentinel in. Pass '"'
to embed the fragment in a single-quoted --wrap.
Returns:
The shell fragment, as a string.
Example:
>>> swait_await_subscribe("swait.out")
"for _ in $(seq 150); do grep -q 'subscribed to stepmgr' ..."
"""
return (
f"for _ in $(seq 150); do "
f"grep -q {quote}{SWAIT_SUBSCRIBED}{quote} {out_file} 2>/dev/null "
f"&& break; "
f"sleep 0.2; "
f"done; "
f"grep -q {quote}{SWAIT_SUBSCRIBED}{quote} {out_file} 2>/dev/null || "
f"exit {SWAIT_SUBSCRIBE_TIMEOUT_RC}; "
)
def get_pending_async_step(job_id):
"""Returns the StepId of the job's pending --async step, or None.
Args:
job_id (integer): The id of the job to inspect.
Returns:
The StepId (e.g. '123.1'), or None if none appeared in time.
Example:
>>> get_pending_async_step(123)
'123.1'
"""
for _ in timer(timeout=30, poll_interval=0.5, quiet=True):
for sid, info in get_steps(job_id, quiet=True).items():
# Skip .0: before it starts running it can itself read
# PENDING for an instant and shadow the async step we want.
if sid.endswith((".0", ".batch", ".extern", ".interactive")):
continue
if info.get("State") == "PENDING":
return sid
return None
def get_data_parser_number(value):
"""Returns the number carried by a data_parser no-val field.
A no-val field (UINT32_NO_VAL and friends) is a bare int under a
complex-mode data_parser and a {set, infinite, number} object under the
default one, so a test that reads one has to accept both shapes.
Args:
value: The field as parsed from the command's JSON or YAML output.
Returns:
The number the field carries, or None if it is unset.
Example:
>>> get_data_parser_number({'set': True, 'infinite': False, 'number': 7})
7
>>> get_data_parser_number({'set': False, 'infinite': False, 'number': 0})
None
"""
if isinstance(value, dict):
return value["number"] if value.get("set") else None
return value
def get_data_parser_flag(value):
"""Returns the single flag carried by a data_parser flag field.
A flag field renders as a bare string or as a one-element list,
depending on the data_parser in use.
Args:
value: The field as parsed from the command's JSON or YAML output.
Returns:
The flag string.
Example:
>>> get_data_parser_flag(['SUCCESS'])
'SUCCESS'
"""
return value[0] if isinstance(value, list) else value
def get_job_gres_index_blocks(job_id, **run_command_kwargs):
"""Returns the GRES device indexes of a running job, one set per block.
`scontrol show job -d` prints one Nodes= block per group of nodes that
share an identical allocation, with one IDX list per GRES record. This
returns one set per block, holding every IDX list of the block with any
ranges expanded, so a caller can tell a job holding {0,1} on one node and
{2,3} on another from one holding {0,1,2,3} on a single node.
Args:
job_id (integer): The id of the job whose device indexes are wanted.
Returns:
A list of sets of integer device indexes, one per block, in the order
printed.
Example:
>>> get_job_gres_index_blocks(12345)
[{0, 1}]
"""
run_command_kwargs.setdefault("fatal", True)
output = run_command_output(f"scontrol show job -d {job_id}", **run_command_kwargs)
blocks = [
set().union(
*(range_to_list(idx) for idx in re.findall(r"IDX:([\d,\-]+)", line))
)
for line in output.splitlines()
if line.strip().startswith("Nodes=") and "IDX:" in line
]
if not blocks:
pytest.fail(f"Expected a GRES IDX list for job {job_id} in: {output}")
return blocks
def get_job_gres_indexes(job_id, **run_command_kwargs):
"""Returns the set of GRES device indexes allocated to a running job.
This unions every block, so the result is only interpretable for a job on
a single node. Use get_job_gres_index_blocks() for a multi-node job, where
the union cannot tell which devices came from which node.
Args:
job_id (integer): The id of the job whose device indexes are wanted.
Returns:
A set of integer device indexes, unioned over every node and GRES
record of the job.
Example:
>>> get_job_gres_indexes(12345)
{0, 1}
"""
return set().union(*get_job_gres_index_blocks(job_id, **run_command_kwargs))
def get_job_id_from_array_task(array_job_id, array_task_id, fatal=False, quiet=True):
"""Returns the raw job id of a task of a job array.
Args:
array_job_id (integer): The id of the job array.
array_task_id (integer): The id of the task of the job array.
fatal (boolean): If True, fails if the raw job id is not found in the system.
quiet (boolean): If True, logging is performed at the TRACE log level.
Returns:
The raw job id of the given task of a job array, or 0 if not found.
Example:
>>> get_job_id_from_array_task(234, 2)
241
"""
jobs_dict = get_jobs(quiet=quiet)
for job_id, job_values in jobs_dict.items():
# Non-array jobs left in the system by sibling tests carry neither
# key, so use .get() rather than raising on them.
if (
job_values.get("ArrayJobId") == array_job_id
and job_values.get("ArrayTaskId") == array_task_id
):
return job_id
if fatal:
pytest.fail(f"{array_job_id}_{array_task_id} was not found in the system")
return 0
def get_step_parameter(step_id, parameter_name, default=None, quiet=False):
"""Returns the value of a specific parameter for a given step.
Args:
step_id (string): The id of the step for which the parameter value is requested.
parameter_name (string): The name of the parameter whose value is to be obtained.
default (string or None): The value to be returned if the parameter is not found.
quiet (boolean): If True, logging is performed at the TRACE log level.
Returns:
The value of the specified step parameter, or the default value if the
parameter is not found.
Example:
>>> get_step_parameter("45.0", 'NodeList')
'node[2,4]'
>>> get_step_parameter("60.1", 'Partition', default='normal', quiet=True)
'primary'
"""
steps_dict = get_steps(step_id, quiet=quiet)
if step_id not in steps_dict:
logging.debug(f"Step ({step_id}) was not found in the step list")
return default
step_dict = steps_dict[step_id]
if parameter_name in step_dict:
return step_dict[parameter_name]
else:
return default
def _parse_json_tasks(output, hex_fields):
task_data = []
for line in output.split("\n"):
if line.strip(): # Skip empty lines
data = json.loads(line)
for key in hex_fields:
data[key] = int(data[key], 16)
task_data.append(data)
return task_data
def parse_taskget(output):
"""Parse taskget output, return an array of reported task bindings"""
return _parse_json_tasks(output, ["mask"])
def parse_numaget(output):
"""Parse numaget output, return an array of reported task bindings"""
return _parse_json_tasks(output, ["mem_mask", "allowed_mask", "policy_mask"])
def get_licenses(
**run_command_kwargs,
):
"""Returns the licenses/HRES in the system as a dictionary of dictionaries.
Returns:
A dictionary of dictionaries. The first level keys are "license_name"
for licenses defined in slurm.conf or remote licenses defined in the
Slurm database, and "license_name(layer_name)" for HRES (only in Slurm
>= 26.11). Their values are a dictionary of parameters for the
respective license. A test using get_licenses() for HRES should use
atf.require_version((26, 11), component="bin/scontrol"). If duplicate
entries are found (which would only happen for Slurm < 26.11 with
HRES), an empty dictionary is returned.
Example:
>>> get_licenses()
{'lic1': {'LicenseName': 'lic1', 'Total': 30, 'Used': 0, 'Free': 30, 'LayerName': '', ...},
'lic2': {'LicenseName': 'lic2', 'Total': 10, 'Used': 0, 'Free': 10, 'LayerName': '', ...},
'power(leaf1)': {'LicenseName': 'power', 'Total': 2000, 'Used': 2000, 'Free': 0, 'LayerName': 'leaf1', ...},
'power(root)': {'LicenseName': 'power', 'Total': 100000, 'Used': 2000, 'Free': 98000, 'LayerName': 'root', ...}}
>>> get_licenses()["lic1"]
{'LicenseName': 'lic1', 'ConfTotal': 30, 'Total': 30, 'BaseUsage': 0,
'Used': 0, 'Free': 30, 'Remote': False, 'Reserved': 0,
'LastConsumed': 0, 'LastDeficit': 0, 'LastUpdate': 0, 'LayerName': '',
'ParentName': '', 'DisableHRES': False, 'DisableLayer': False,
'Nodes': '', 'Base': []}
>>> get_licenses()["power(leaf1)"]
{'LicenseName': 'power', 'ConfTotal': 2000, 'Total': 2000,
'BaseUsage': 0, 'Used': 2000, 'Free': 0, 'Remote': False,
'Reserved': 0, 'LastConsumed': 0, 'LastDeficit': 0, 'LastUpdate': 0,
'LayerName': 'leaf1', 'ParentName': 'root', 'DisableHRES': False,
'DisableLayer': False, 'Nodes': 'node[1-3]', 'Base': []}
"""
licenses_dict = {}
command = "scontrol --json show licenses"
output = run_command_output(command, **run_command_kwargs)
try:
lics_list = json.loads(output)["licenses"]
except json.JSONDecodeError as e:
msg = f"Error parsing scontrol output: {output}\n{e}"
if run_command_kwargs.get("fatal"):
pytest.fail(msg)
else:
logging.warning(msg)
return licenses_dict
for lic in lics_list:
key = lic["LicenseName"]
# LayerName is only included in Slurm >= 26.11. Before that, the layers
# of a HRES have the same LicenseName and cannot be told apart.
if lic.get("LayerName"):
key += f"({lic['LayerName']})"
if key in licenses_dict:
msg = f"Duplicate license {key}: HRES layers need Slurm >= 26.11"
if run_command_kwargs.get("fatal"):
pytest.fail(msg)
else:
logging.warning(msg)
return {}
licenses_dict[key] = lic
return licenses_dict
def get_log(component, num_lines=0, fatal=False, quiet=True):
"""Returns the log of a Slurm daemon.
The log file is the one configured for the daemon (SlurmctldLogFile,
SlurmdLogFile or LogFile in slurmdbd.conf), read from the config files so
it works even when the daemons are down. slurmrestd and sackd are started
by the testsuite with their output redirected to the log directory of
slurmctld.
Args:
component (string): 'slurmctld', 'slurmdbd', 'slurmrestd', 'sackd',
'slurmd' to get the logs of all the slurmds concatenated, or a
node name to get the log of its slurmd.
num_lines (integer): Number of trailing lines to return (tail), of
each log file. If 0, the whole log is returned.
fatal (boolean): If True, fail the test if the log cannot be read.
quiet (boolean): If True, the command and the log are logged at the
TRACE log level.
Returns:
The requested log content as a string, or an empty string if the log
cannot be read and fatal is False.
Example:
>>> get_log("slurmctld", num_lines=15)
'[2026-10-26T20:12:01.123] debug: sched: Running job scheduler...'
>>> get_log("node2", fatal=True)
'[2026-10-26T20:11:58.456] slurmd version 26.11.0 started...'
>>> get_log("slurmd")
'[2026-10-26T20:11:58.123] slurmd version 26.11.0 started...\\n...'
"""
def _conf_value(name, source):
conf = get_config(live=False, source=source, quiet=True)
return next(
(v for k, v in conf.items() if k.casefold() == name.casefold()), None
)
match component:
case "slurmctld":
log_file = _conf_value("SlurmctldLogFile", "slurm")
case "slurmdbd":
log_file = _conf_value("LogFile", "slurmdbd")
case "slurmrestd" | "sackd":
log_file = _conf_value("SlurmctldLogFile", "slurm")
if log_file:
log_file = f"{os.path.dirname(log_file)}/{component}.log"
case "slurmd":
log_file = _conf_value("SlurmdLogFile", "slurm")
if log_file:
# All the nodes (the shell expands the glob)
log_file = log_file.replace("%n", "*")
case node:
log_file = _conf_value("SlurmdLogFile", "slurm")
if log_file:
log_file = log_file.replace("%n", node)
if not log_file:
message = f"No log file configured for {component}"
if fatal:
pytest.fail(message)
logging.warning(message)
return ""
if num_lines:
command = f"tail -q -n {num_lines} {log_file}"
else:
command = f"cat {log_file}"
return run_command_output(command, user="root", fatal=fatal, quiet=quiet)
def get_log_time(line):
"""Returns the timestamp of a Slurm log line.
Args:
line (string): A line of a Slurm daemon log, as returned by get_log().
Returns:
The timestamp of the line in seconds since the epoch (float), taking
the log time as local time, or None if the line does not start with a
timestamp.
Example:
>>> end = get_log_time("[2026-10-26T21:21:24.680] Saving all slurm state")
>>> start = get_log_time("[2026-10-26T21:19:52.979] Reconfigure signal (SIGHUP) received")
>>> end - start
91.70099997520447
>>> get_log_time(" continuation of a multi-line message")
None
"""
match = re.match(r"\[(\d{4}-\d\d-\d\dT[\d:.]+)\]", line)
if not match:
return None
return datetime.datetime.fromisoformat(match.group(1)).timestamp()
def get_log_delays(component, start_regex, end_regex, min_delay=0):
"""Returns the delays between two events in the log of a Slurm daemon.
Each line matching end_regex is paired with the latest line matching
start_regex before it that was not paired yet. Lines without a timestamp
are ignored.
Args:
component (string): 'slurmctld', 'slurmdbd', 'slurmrestd', 'sackd',
or a node name to get the log of its slurmd. Use a single log, so
not 'slurmd': the concatenated logs of all slurmds are not sorted
by time.
start_regex (string): Regular expression of the line starting the event.
end_regex (string): Regular expression of the line ending the event.
min_delay (float): Only the delays of at least these seconds are
returned.
Returns:
A list of (start_line, end_line, delay) tuples, with the delay in
seconds, in the order of the end lines.
Example:
>>> get_log_delays(
... "slurmctld",
... r"Reconfigure signal|slurmctld terminating",
... r"Saving all slurm state",
... min_delay=30,
... )
[('[2026-10-26T21:19:52.979] Reconfigure signal (SIGHUP) received', '[2026-10-26T21:21:24.680] Saving all slurm state', 91.70099997520447)]
"""
delays = []
start = None
for line in get_log(component).splitlines():
line_time = get_log_time(line)
if line_time is None:
continue
if re.search(end_regex, line):
if start is not None:
delay = line_time - start[1]
if delay >= min_delay:
delays.append((start[0], line, delay))
start = None
elif re.search(start_regex, line):
start = (line, line_time)
return delays
class SetMatch(enum.Enum):
EQUAL = "=="
INTERSECTS = "~="
def wait_for_node_state(
nodename,
desired_node_state,
operator=SetMatch.INTERSECTS,
timeout=default_polling_timeout,
poll_interval=1,
fatal=False,
reverse=False,
):
"""Wait for a specified node state to be reached on one or more nodes.
Polls the node state every poll interval seconds, waiting up to the timeout
for the specified node state to be reached. When given a list of nodes,
the per-node condition must hold for *all* of them; with reverse=True, the
per-node condition is inverted (so a list with reverse=True means "no node
satisfies the condition").
A single query to slurmctld is issued per poll regardless of the number of
nodes being monitored.
Args:
nodename (string or list/set of strings): A node name, or a list of
node names, whose state is being monitored.
desired_node_state (string or list/set of strings): A state name, or a
list of state names, that the node(s) are expected to reach.
operator (SetMatch or string): How desired_node_state is matched
against the actual node state list. Accepts either a SetMatch
member or its underlying string value ("~=" or "=="). Defaults to
~= (SetMatch.INTERSECTS). See SetMatch.
timeout (integer): The number of seconds to wait before timing out.
poll_interval (float): Number of seconds between node state polls.
Defaults to 1s.
fatal (boolean): If True, a timeout will cause the test to fail.
reverse (boolean): If True, wait for the per-node condition to be
false instead of true.
Returns:
Boolean value indicating whether the desired condition was reached on
all the specified node(s) before the timeout.
Example:
>>> wait_for_node_state('node1', 'IDLE', timeout=60, poll_interval=5)
True
>>> wait_for_node_state('node2', 'DOWN', timeout=30, fatal=True)
False
>>> wait_for_node_state(['node1', 'node2', 'node3'], 'IDLE', fatal=True)
True
>>> wait_for_node_state('node1', ['REBOOT_REQUESTED', 'REBOOT_ISSUED'], fatal=True)
True
>>> wait_for_node_state(node_list, 'IDLE', operator=SetMatch.EQUAL, fatal=True)
True
>>> wait_for_node_state(node_list, 'IDLE', operator='==', fatal=True)
True
"""
try:
operator = SetMatch(operator)
except ValueError:
pytest.fail(
f"Unsupported operator {operator!r} "
f"(allowed: {[m.value for m in SetMatch]})"
)
nodes_to_check = {nodename} if isinstance(nodename, str) else set(nodename)
desired_set = (
{desired_node_state}
if isinstance(desired_node_state, str)
else set(desired_node_state)
)
def matches(state):
if operator is SetMatch.EQUAL:
ok = set(state) == desired_set
else: # SetMatch.INTERSECTS
ok = bool(desired_set & set(state))
return ok != reverse
for _ in timer(timeout=timeout, poll_interval=poll_interval, fatal=fatal):
nodes = get_nodes(quiet=True)
if all(matches(nodes[n]["state"]) for n in nodes_to_check):
return True
return False
def wait_for_step(job_id, step_id, state="RUNNING", **repeat_until_kwargs):
"""Wait for the specified step of a job to reach a state.
Continuously polls the step state until it matches or until a timeout
occurs.
Args:
job_id (integer or string): The id of the job. For an array task,
either its raw job id or "<array_job_id>_<task_id>".
step_id (integer or string): The id of the step within the job, or
the name of a special step such as "batch".
state (string): The step state to wait for, or None to wait only for
the step to exist. Defaults to RUNNING. Existence alone is not
enough: a step queued waiting for resources is already listed by
`scontrol show step`, under the StepId it was assigned at
submission, while it is still PENDING.
Returns:
A boolean value indicating whether the specified step reached the
state or not.
Example:
>>> wait_for_step(1234, 0, timeout=60, poll_interval=5, fatal=True)
True
>>> wait_for_step(5678, 1, timeout=30)
False
>>> wait_for_step("5678_3", 0, timeout=30)
True
"""
# The JSON "id" is always the raw "<job_id>.<step_id>", with "+<comp>" for
# a het step, and array tasks carry their ids separately under "array".
array_job_id, _, task_id = str(job_id).partition("_")
if task_id:
# Unless stepmgr is used, scontrol only sends "<array_job_id>.<step_id>"
# to the controller for "<array_job_id>_<task_id>.<step_id>", which only
# matches the task whose raw job id is <array_job_id>, so filter here.
query = array_job_id
def _is_step(step):
return (
step["array"]["job_id"] == int(array_job_id)
and step["array"]["task_id"] == int(task_id)
and step["id"].split("+")[0].split(".", 1)[1] == str(step_id)
)
else:
query = f"{job_id}.{step_id}"
def _is_step(step):
return step["id"].split("+")[0] == query
def _get_steps():
out = run_command_output(f"scontrol --json show step {query}", quiet=True)
try:
steps = json.loads(out)["steps"]
except (json.JSONDecodeError, KeyError):
logging.debug(f"Unable to parse scontrol output: {out}")
return []
# Keep only the fields used here in repeat_until logs.
return [
{
"id": step["id"],
"array": {
"job_id": step["array"]["job_id"],
"task_id": step["array"]["task_id"],
},
"state": step["state"],
}
for step in steps
]
return repeat_until(
_get_steps,
lambda steps: any(
_is_step(step) and (state is None or state in step["state"])
for step in steps
),
**repeat_until_kwargs,
)
def wait_for_step_count_snippet(count, op="-ge", timeout=60):
"""Return Bash lines that wait for the script's job to reach a step count.
Polls `scontrol show step` from inside a batch script until the number of
the job's steps with numeric StepIds (excluding .batch/.extern, which
never match) bears the given test(1) integer relation to count. Aborts
the script on timeout: the gate orders step submissions, so a script
that ran anyway would report the missing ordering as a product failure.
Args:
count (integer): The step count to compare against.
op (string): A test(1) integer relation (e.g. -ge, -eq). Defaults
to -ge: wait until at least count steps are registered.
timeout (integer): Seconds to wait before aborting the script.
Returns:
Bash lines embedding the wait, terminated by a newline.
Example:
>>> make_bash_script("s.sh", wait_for_step_count_snippet(2) + "srun true\\n")
None
"""
pattern = "StepId=$SLURM_JOB_ID[.][0-9]+([[:space:]+]|$)"
reached = (
f'[ "$(scontrol -o show step "$SLURM_JOB_ID" 2>/dev/null | '
f'grep -cE "{pattern}")" {op} {count} ]'
)
return (
f"for _ in $(seq 1 {int(timeout * 2)}); do {reached} && break; "
"sleep 0.5; done\n"
f"{reached} || {{ echo STEP_COUNT_NOT_{op}_{count}; exit 1; }}\n"
)
def wait_for_step_snippet(step_id, timeout=30):
"""Return Bash lines that wait for a step of the script's job to register.
Polls `scontrol show step` from inside a batch script until the step is
listed, under the StepId it was assigned at submission (a step queued
waiting for resources is already listed, while still PENDING). Aborts
the script on timeout: the step's registration is a precondition for
whatever the script does to it next.
Args:
step_id (integer): The id of the step within $SLURM_JOB_ID.
timeout (integer): Seconds to wait before aborting the script.
Returns:
Bash lines embedding the wait, terminated by a newline.
Example:
>>> make_bash_script("s.sh", "srun true &\\n" + wait_for_step_snippet(0))
None
"""
pattern = f"StepId=$SLURM_JOB_ID[.]{step_id}([[:space:]+]|$)"
return (
f"for _ in $(seq 1 {int(timeout * 2)}); do "
f'scontrol -o show step "$SLURM_JOB_ID" 2>/dev/null | '
f'grep -qE "{pattern}" && break; sleep 0.5; done\n'
f'scontrol -o show step "$SLURM_JOB_ID" 2>/dev/null | '
f'grep -qE "{pattern}" || '
f"{{ echo STEP_{step_id}_NOT_REGISTERED; exit 1; }}\n"
)
def hog_allocation_snippet(ready_file, srun_args="--exclusive -n2", timeout=30):
"""Return Bash lines that hog a node's CPUs and block until they are held.
Launches a background srun that touches ready_file once its tasks hold
the node's CPUs, then blocks until that file exists, so a following
srun sees a genuinely busy allocation. Aborts the script on timeout:
the busy allocation is a precondition, so a script that ran anyway
would report the missing contention as a product failure.
Args:
ready_file (string): Path the hog touches once it holds the CPUs.
srun_args (string): Resource arguments for the hog srun. Defaults
to "--exclusive -n2" (both CPUs of a 2-CPU node).
timeout (integer): Seconds to wait for the hog before aborting.
Returns:
Bash lines embedding the hog and the readiness gate, terminated by a
newline.
Example:
>>> make_bash_script("s.sh", hog_allocation_snippet("ready") + "wait\\n")
None
"""
return (
f"srun {srun_args} sh -c 'touch \"{ready_file}\"; exec sleep infinity' &\n"
f"for _ in $(seq 1 {int(timeout * 2)}); do "
f"[ -f '{ready_file}' ] && break; sleep 0.5; done\n"
f"[ -f '{ready_file}' ] || {{ echo HOG_NOT_READY; exit 1; }}\n"
)
def wait_for_step_accounted(job_id, step_id, **repeat_until_kwargs):
"""Wait for specified job step to appear in accounting database (`sacct`).
Continuously polls the database until the step is accounted for or until a
timeout occurs.
Args:
job_id (integer): The id of the job.
step_id (integer): The id of the step within the job.
Returns:
A boolean value indicating whether the specified step is accounted for
in the database or not.
Example:
>>> wait_for_step_accounted(1234, 0, timeout=60, poll_interval=5, fatal=True)
True
>>> wait_for_step_accounted(5678, 1, timeout=30)
False
"""
step_str = f"{job_id}.{step_id}"
return repeat_until(
lambda: run_command_output(f"sacct -j {job_id} -o jobid"),
lambda out: re.search(rf"{step_str}", out) is not None,
**repeat_until_kwargs,
)
def wait_for_job_accounted(job_id, field="Start", value=None, **repeat_until_kwargs):
"""Wait for specified job field to appear in accounting database (`sacct`).
Continuously polls the database until the job field is accounted for or until a
timeout occurs. If a value is specified, it waits until the field has that value.
Args:
job_id (integer): The id of the job.
field (string): The field to query from the job.
value (string): The desired value to wait for.
None means "any value but empty"
Returns:
A boolean value indicating whether the specified job is accounted for
in the database or not.
Example:
>>> wait_for_job_accounted(1234, timeout=60, poll_interval=5, fatal=True)
True
>>> wait_for_job_accounted(5678, "End", timeout=30)
False
"""
if not value:
value = r"."
return repeat_until(
lambda: run_command_output(f"sacct -XPnj {job_id} -o {field}"),
lambda out: re.search(value, out.strip()) is not None,
**repeat_until_kwargs,
)
def is_job_state_done(job_state):
"""Return True if job_state is one of the final JobStates, or False otherwise"""
done_states = [
"NOT_FOUND",
"BOOT_FAIL",
"CANCELLED",
"COMPLETED",
"DEADLINE",
"FAILED",
"NODE_FAIL",
"OUT_OF_MEMORY",
"TIMEOUT",
"PREEMPTED",
]
return job_state in done_states
def wait_for_job_state(
job_id,
desired_job_state,
desired_reason=None,
timeout=default_polling_timeout,
poll_interval=None,
**run_command_kwargs,
):
"""Wait for the specified job to reach the desired state.
Continuously polls the job state until the desired state is reached or until
a timeout occurs.
Some supported job states are aggregate states, which may include multiple
discrete states. Some logic is built-in to fail if a job reaches a state
that makes the desired job state impossible to reach.
Current supported aggregate states:
- DONE
Args:
job_id (integer): The id of the job.
desired_job_state (string): The desired state of the job.
desired_reason (string): Optional reason to also match.
timeout (integer): The number of seconds to poll before timing out.
poll_interval (float): Time (in seconds) between job state polls.
Returns:
Boolean value indicating whether the job reached the desired state.
Example:
>>> wait_for_job_state(1234, 'COMPLETED', timeout=300, poll_interval=10, fatal=True)
True
>>> wait_for_job_state(5678, 'RUNNING', timeout=60)
False
"""
if poll_interval is None:
if timeout <= 5:
poll_interval = 0.1
elif timeout <= 10:
poll_interval = 0.2
else:
poll_interval = 1
# Get the quiet, fatal and xfail values but don't forward them
quiet = run_command_kwargs.pop("quiet", None)
fatal = run_command_kwargs.pop("fatal", None)
xfail = run_command_kwargs.pop("xfail", None)
if quiet:
log_level = logging.TRACE
else:
log_level = logging.DEBUG
# We don't use repeat_until here because we support pseudo-job states and
# we want to allow early return (e.g. for a DONE state if we want RUNNING)
xfail_str = ""
if xfail:
xfail_str = "not "
message = (
f"Waiting for job ({job_id}) to {xfail_str}reach state {desired_job_state}"
)
if desired_reason is not None:
message += f" and reason {desired_reason}"
logging.log(log_level, message)
begin_time = time.time()
while time.time() < begin_time + timeout:
job_state = get_job_parameter(
job_id, "JobState", default="NOT_FOUND", quiet=True, **run_command_kwargs
)
message = f"Job ({job_id}) is in state {job_state}, but we are waiting for {desired_job_state}"
if is_job_state_done(job_state):
if desired_job_state == "DONE" or job_state == desired_job_state:
message = f"Job ({job_id}) is in the {xfail_str}desired state {desired_job_state}"
reason = get_job_parameter(
job_id,
"Reason",
default="NOT_FOUND",
quiet=True,
**run_command_kwargs,
)
if desired_reason is None or reason == desired_reason:
if desired_reason is not None:
message += (
f" with the {xfail_str}desired reason {desired_reason}"
)
if not xfail:
logging.log(log_level, message)
else:
logging.warning(message)
return True
else:
message += (
f", but with reason {reason} and we waited for {desired_reason}"
)
if not xfail:
if fatal:
pytest.fail(message)
else:
logging.warning(message)
else:
logging.log(log_level, message)
return False
elif job_state == desired_job_state:
message = (
f"Job ({job_id}) is in the {xfail_str}desired state {desired_job_state}"
)
reason = get_job_parameter(
job_id, "Reason", default="NOT_FOUND", quiet=True, **run_command_kwargs
)
if desired_reason is None or reason == desired_reason:
if desired_reason is not None:
message += f" with the {xfail_str}desired reason {desired_reason}"
if not xfail:
logging.log(log_level, message)
else:
logging.warning(message)
return True
else:
message += (
f", but with reason {reason} and we waited for {desired_reason}"
)
logging.log(log_level, message)
time.sleep(poll_interval)
message = f"Job ({job_id}) did not reach the {desired_job_state} state"
if desired_reason is not None:
message += f" or the reason {desired_reason}"
message += f" within the {timeout} second(s) timeout"
if not xfail:
if fatal:
pytest.fail(message)
else:
logging.warning(message)
else:
logging.log(log_level, message)
return False
def job_stays_pending(
job_id,
timeout=3,
pending_timeout=default_polling_timeout,
poll_interval=0.2,
):
"""Wait for a job to be pending, then check that it stays pending.
wait_for_job_state(job_id, "PENDING") returns as soon as the job is seen
pending, which every newly submitted job is before the scheduler starts it.
Use this instead to show that something (e.g. a drained TRES) is actually
holding the job back.
Args:
job_id (integer): The id of the job.
timeout (integer): Seconds to watch the job for once the scheduler has
given it a pending reason. The job must not leave PENDING during
this window.
pending_timeout (integer): Seconds to wait for the job to be pending in
the first place, and again for the scheduler to give it a reason.
poll_interval (float): Time (in seconds) between job state polls.
Returns:
True if the job became pending and was still pending for the whole
window, False if it never became pending, the scheduler never gave it
a reason, or it started during the window.
Example:
>>> job_stays_pending(1234)
True
"""
if not wait_for_job_state(job_id, "PENDING", timeout=pending_timeout):
return False
# A new job is pending with Reason=None until the scheduler first looks at
# it, so a window started before then could pass without anything holding
# the job back. Start the window once the scheduler has given a reason.
def _state_and_reason():
job = get_job(job_id, quiet=True)
return job.get("JobState"), job.get("Reason")
if not repeat_until(
_state_and_reason,
lambda state_reason: state_reason[0] != "PENDING"
or state_reason[1] not in (None, "None"),
timeout=pending_timeout,
poll_interval=poll_interval,
):
return False
started = repeat_until(
lambda: get_job_parameter(job_id, "JobState", default="NOT_FOUND", quiet=True),
lambda job_state: job_state != "PENDING",
timeout=timeout,
poll_interval=poll_interval,
)
return not started
def check_steps_delayed(
job_id,
expected_delayed,
first_parallel_step=0,
min_delay_seconds=2,
**repeat_until_kwargs,
):
"""Verify that job steps were delayed waiting for resources.
Reads per-step Start timestamps from sacct after the job ends. A step is
considered "delayed" if its Start is at least min_delay_seconds after the
earliest start within the parallel-batch group. The count of such steps
must equal expected_delayed.
Args:
job_id (integer): The job ID that we're interested in.
expected_delayed (integer): The number of steps that should have been
delayed waiting for resources.
first_parallel_step (integer): The step ID at which the parallel
batch begins. Steps with a lower ID (typically sequential
pre-steps in the job script) are ignored. Defaults to 0, which
considers all steps as part of the parallel batch.
min_delay_seconds (integer): A step is "delayed" if its Start is at
least this many seconds after the earliest step Start of the
considered set. Default 2 — fits between sub-second
parallel-batch jitter and the >=3s gap created by typical test
step sleeps.
Returns:
True if exactly expected_delayed steps were delayed, else False.
"""
# End being recorded implies all step rows have flushed (steps end before their parent job).
if not wait_for_job_accounted(job_id, field="End", **repeat_until_kwargs):
logging.error(f"check_steps_delayed: job {job_id} End not in accounting")
return False
# -n omits header, -P pipe-separates, -j scopes to this job only.
out = run_command_output(f"sacct -nPj {job_id} -o jobid,start", quiet=True)
# Numeric step IDs only — skips ".batch" and ".extern" rows.
step_re = re.compile(rf"^{job_id}\.(\d+)\|(\S+)$")
parsed = []
for line in out.splitlines():
m = step_re.match(line.strip())
if not m:
continue
stepnum = int(m.group(1))
# Skip sequential pre-steps that aren't part of the parallel batch.
if stepnum < first_parallel_step:
continue
ts = m.group(2)
try:
parsed.append(
(stepnum, datetime.datetime.strptime(ts, "%Y-%m-%dT%H:%M:%S"))
)
except ValueError:
logging.error(f"check_steps_delayed: step {stepnum} has bad Start {ts!r}")
return False
if not parsed:
logging.error(
f"check_steps_delayed: no step rows for job {job_id} at or "
f"after step {first_parallel_step}"
)
return False
# Sort by start time; tie-break on step number for stable ordering.
parsed.sort(key=lambda x: (x[1], x[0]))
earliest = parsed[0][1]
threshold = datetime.timedelta(seconds=min_delay_seconds)
delayed_count = sum(1 for _, ts in parsed if ts - earliest >= threshold)
if delayed_count != expected_delayed:
summary = ", ".join(f"{n}@{t.isoformat()}" for n, t in parsed)
logging.error(
f"check_steps_delayed: expected {expected_delayed} delayed "
f"step(s), got {delayed_count} (>= {min_delay_seconds}s after "
f"earliest start). Step starts: {summary}"
)
return False
return True
def create_node(node_dict):
"""Creates a node with the properties described by the supplied dictionary.
This function is currently only used as a helper function within other
library functions (e.g. require_nodes). It modifies the Slurm configuration
file and restarts the relevant Slurm daemons. A backup is automatically
created, and the original configuration is restored after the test
completes. This function may only be used in auto-config mode.
Args:
node_dict (dictionary): A dictionary containing the desired node
properties.
Returns:
None
Example:
>>> create_node({'NodeName': 'new_node', 'CPUs': 4, 'RealMemory': 16384})
"""
if not properties["auto-config"]:
require_auto_config("wants to add a node")
config_file = f"{properties['slurm-config-dir']}/slurm.conf"
# Read the original slurm.conf into a list of lines
output = run_command_output(
f"cat {config_file}", user=properties["slurm-user"], quiet=True
)
original_config_lines = output.splitlines()
new_config_lines = original_config_lines.copy()
# Locate the last NodeName definition
last_node_line_index = 0
for line_index in range(len(original_config_lines)):
line = original_config_lines[line_index]
if re.search(r"(?i)^ *NodeName", line) is not None:
last_node_line_index = line_index
if last_node_line_index == 0:
last_node_line_index = line_index
# Build up the new node line
node_line = ""
if "NodeName" in node_dict:
node_line = f"NodeName={node_dict['NodeName']}"
node_range = node_dict.pop("NodeName")
if "Port" in node_dict:
node_line += f" Port={node_dict['Port']}"
node_dict.pop("Port")
for parameter_name, parameter_value in sorted(node_dict.items()):
node_line += f" {parameter_name}={parameter_value}"
# Add the new node line
new_config_lines.insert(last_node_line_index + 1, node_line)
# Write the config file back out with the modifications
new_config_string = "\n".join(new_config_lines)
run_command(
f"echo '{new_config_string}' > {config_file}",
user=properties["slurm-user"],
fatal=True,
quiet=True,
)
# Create the required node directories
for node_name in node_range_to_list(node_range):
spool_dir = properties["slurm-spool-dir"].replace("%n", node_name)
tmpfs_dir = properties["slurm-tmpfs"].replace("%n", node_name)
properties["nodes"].append(node_name)
run_command(f"sudo mkdir -p {spool_dir}", fatal=True, quiet=True)
run_command(f"sudo mkdir -p {tmpfs_dir}", fatal=True, quiet=True)
# Restart slurm if it is already running
if is_slurmctld_running(quiet=True):
restart_slurm(quiet=True)
def _hardware_node_parameters(requirements_list):
"""Resolve hardware-matching requirements against the test host's topology.
Args:
requirements_list (list of tuples): List of (parameter_name,
parameter_value) tuples, as passed to require_nodes().
Returns:
A 2-tuple (hardware_values, remaining_requirements). hardware_values
is a dict of the topology parameters to apply to any node created to
satisfy require_nodes(). remaining_requirements is requirements_list
with the parameters checked here removed.
"""
if not properties["auto-config"]:
logging.warning(
"Assuming the nodes are configured with the hardware they run on. "
"A test failing right after this may be running on a node whose "
"configuration does not match what 'slurmd -C' reports for it."
)
return ({}, requirements_list)
hardware = get_slurmd_C()
sockets = hardware["Boards"] * hardware["SocketsPerBoard"]
available = {
"CPUs": hardware["CPUs"],
"Cores": sockets * hardware["CoresPerSocket"],
"Sockets": sockets,
"Boards": hardware["Boards"],
"SocketsPerBoard": hardware["SocketsPerBoard"],
"CoresPerSocket": hardware["CoresPerSocket"],
"ThreadsPerCore": hardware["ThreadsPerCore"],
}
write_params = (
"Boards",
"SocketsPerBoard",
"CoresPerSocket",
"ThreadsPerCore",
"CPUs",
)
# Cores is checked against the hardware and then dropped because it is
# derived from the topology rather than written to the node line.
drop_params = write_params + ("Sockets", "Cores")
remaining_requirements = []
for requirement in requirements_list:
parameter_name, parameter_value = requirement[0:2]
if parameter_name in available and available[parameter_name] < int(
parameter_value
):
pytest.skip(
f"This test requires a node with {parameter_value} "
f"{parameter_name} matching its hardware, but the host has "
f"{available[parameter_name]}",
allow_module_level=True,
)
if parameter_name not in drop_params:
remaining_requirements.append(requirement)
hardware_values = {name: hardware[name] for name in write_params}
logging.info(f"Nodes will be created with the detected hardware: {hardware_values}")
return (hardware_values, remaining_requirements)
# requirements_list is a list of (parameter_name, parameter_value) tuples.
# Uses non-live node info because must copy from existing node config line
# We implemented requirements_list as a list of tuples so that this could
# later be extended to include a comparator, etc.
# atf.require_nodes(1, [('CPUs', 4), ('RealMemory', 40)])
# atf.require_nodes(2, [('Gres', 'gpu:1,mps:100')])
def require_nodes(requested_node_count, requirements_list=[], require_hardware=False):
"""Ensure that a requested number of nodes have the required properties.
In local-config mode, the test is skipped if an insufficient number of
nodes possess the required properties.
In auto-config mode, nodes are created as needed with the required
properties.
Args:
requested_node_count (integer): Number of required nodes.
requirements_list (list of tuples): List of (parameter_name,
parameter_value) tuples.
require_hardware (boolean): In auto-config mode, nodes created by this
call are given the CPU topology reported by 'slurmd -C' on the test
host, and existing nodes must be configured to the reported topology
to qualify. The test is skipped when the detected hardware cannot
satisfy the CPU, core or socket requirements. In local-config mode
a warning is logged and the node configuration is assumed to match
the hardware already.
Currently supported node requirement types include:
CPUs
Cores
RealMemory
Gres
Features
Other node requirement types will still be appended to the requirements,
but this could stop slurm from starting.
Returns:
At least requested_node_count node names with the required properties.
Example:
>>> require_nodes(2, [('CPUs', 4), ('RealMemory', 40)])
['node1', 'node2']
>>> require_nodes(2, [('CPUs', 2), ('RealMemory', 30), ('Features', 'gpu,mpi')])
['node1', 'node2', 'node3']
>>> require_nodes(2, [('CPUs', 4), ('Sockets', 1)])
['node4', 'node5']
>>> require_nodes(1, [('CPUs', 4)], require_hardware=True)
['node2', 'node3']
"""
hardware_values = {}
if require_hardware:
hardware_values, requirements_list = _hardware_node_parameters(
requirements_list
)
# Always read from slurm.conf (live=False), so we never use --json.
nodes_dict = get_nodes(live=False, quiet=True)
original_nodes = {}
default_node = {}
# Instantiate original node names from nodes_dict
for node_name in nodes_dict:
# Split out the default node
if node_name == "DEFAULT":
default_node = nodes_dict[node_name]
else:
original_nodes[node_name] = {}
# Populate with any default parameters
if default_node:
for node_name in original_nodes:
for parameter_name, parameter_value in default_node.items():
if parameter_name.lower() != "nodename":
original_nodes[node_name][parameter_name] = parameter_value
# Merge in parameters from nodes_dict
for node_name in original_nodes:
for parameter_name, parameter_value in nodes_dict[node_name].items():
if parameter_name.lower() != "nodename":
original_nodes[node_name][parameter_name] = parameter_value
# Check to see how many qualifying nodes we have
qualifying_node_names = []
node_count = 0
nonqualifying_node_count = 0
first_node_name = ""
node_indices = {}
augmentation_dict = {}
for node_name in sorted(original_nodes):
lower_node_dict = dict(
(key.lower(), value) for key, value in original_nodes[node_name].items()
)
node_count += 1
if node_count == 1:
first_node_name = node_name
# Build up node indices for use when having to create new nodes
match = re.search(r"^(.*?)(\d*)$", node_name)
node_prefix, node_index = match.group(1), match.group(2)
if node_index == "":
node_indices[node_prefix] = node_indices.get(node_prefix, [])
else:
node_indices[node_prefix] = node_indices.get(node_prefix, []) + [
int(node_index)
]
node_qualifies = True
if hardware_values:
boards = int(lower_node_dict.get("boards", 1))
sockets_per_board = int(lower_node_dict.get("socketsperboard", 1))
cores_per_socket = int(lower_node_dict.get("corespersocket", 1))
threads_per_core = int(lower_node_dict.get("threadspercore", 1))
default_cpus = (
boards * sockets_per_board * cores_per_socket * threads_per_core
)
node_hardware = {
"Boards": boards,
"SocketsPerBoard": sockets_per_board,
"CoresPerSocket": cores_per_socket,
"ThreadsPerCore": threads_per_core,
"CPUs": int(lower_node_dict.get("cpus", default_cpus)),
}
if "sockets" in lower_node_dict or any(
node_hardware[parameter_name] != hardware_values[parameter_name]
for parameter_name in hardware_values.keys()
):
if node_qualifies:
node_qualifies = False
nonqualifying_node_count += 1
for requirement_tuple in requirements_list:
parameter_name, parameter_value = requirement_tuple[0:2]
if parameter_name in ["CPUs", "RealMemory"]:
if parameter_name.lower() in lower_node_dict:
if int(lower_node_dict[parameter_name.lower()]) < int(
parameter_value
):
if node_qualifies:
node_qualifies = False
nonqualifying_node_count += 1
if nonqualifying_node_count == 1:
augmentation_dict[parameter_name] = parameter_value
else:
if node_qualifies:
node_qualifies = False
nonqualifying_node_count += 1
if nonqualifying_node_count == 1:
augmentation_dict[parameter_name] = parameter_value
elif parameter_name == "Cores":
boards = int(lower_node_dict.get("boards", 1))
sockets_per_board = int(lower_node_dict.get("socketsperboard", 1))
cores_per_socket = int(lower_node_dict.get("corespersocket", 1))
sockets = boards * sockets_per_board
cores = sockets * cores_per_socket
parameter_value = int(parameter_value)
if cores < parameter_value:
if node_qualifies:
node_qualifies = False
nonqualifying_node_count += 1
if nonqualifying_node_count == 1:
augmentation_dict["CoresPerSocket"] = math.ceil(
parameter_value / sockets
)
elif parameter_name == "Gres":
if parameter_name.lower() in lower_node_dict:
gres_list = parameter_value.split(",")
for gres_value in gres_list:
if match := re.search(r"^(\w+)(?::(\w+))?:(\d+)$", gres_value):
(
required_gres_name,
required_gres_type,
required_gres_value,
) = (
match.group(1),
match.group(2),
match.group(3),
)
else:
pytest.fail(
"Gres requirement must be of the form <name>[:<type>]:<count>"
)
required_gres_key = required_gres_name
if required_gres_type:
required_gres_key += f":{required_gres_type}"
if match := re.search(
rf"{re.escape(required_gres_key)}:(\d+)",
lower_node_dict[parameter_name.lower()],
):
if int(match.group(1)) < int(required_gres_value):
if node_qualifies:
node_qualifies = False
nonqualifying_node_count += 1
if nonqualifying_node_count == 1:
augmentation_dict[parameter_name] = parameter_value
else:
if node_qualifies:
node_qualifies = False
nonqualifying_node_count += 1
if nonqualifying_node_count == 1:
augmentation_dict[parameter_name] = parameter_value
else:
if node_qualifies:
node_qualifies = False
nonqualifying_node_count += 1
if nonqualifying_node_count == 1:
augmentation_dict[parameter_name] = parameter_value
elif parameter_name == "Features":
required_features = set(parameter_value.split(","))
features = lower_node_dict.get("features", [])
features = features[0] if features else ""
node_features = set(features.split(","))
if not required_features.issubset(node_features):
if node_qualifies:
node_qualifies = False
nonqualifying_node_count += 1
if nonqualifying_node_count == 1:
augmentation_dict[parameter_name] = parameter_value
else:
logging.debug(
f"{parameter_name} is not a supported node requirement type."
)
logging.debug(
f"{parameter_name}={parameter_value} will be added anyways!"
)
augmentation_dict[parameter_name] = parameter_value
if node_qualifies:
node_qualifies = False
nonqualifying_node_count += 1
if node_qualifies:
qualifying_node_names.append(node_name)
# Not enough qualifying nodes
if len(qualifying_node_names) < requested_node_count:
# If auto-config, configure what is required
if properties["auto-config"]:
# Create new nodes to meet requirements ignoring default node0
new_node_count = requested_node_count
# If we already have a qualifying node, we will use it as the template
if qualifying_node_names:
template_node_name = qualifying_node_names[0]
template_node = nodes_dict[template_node_name].copy()
# Otherwise we will use the first node as a template and augment it
else:
template_node_name = first_node_name
template_node = nodes_dict[template_node_name].copy()
for parameter_name, parameter_value in augmentation_dict.items():
template_node[parameter_name] = parameter_value
if hardware_values:
# Drop every spelling of the topology keys so each is set once
hardware_keys = {key.lower() for key in hardware_values}
hardware_keys.add("sockets")
# Iterate a snapshot, not the real dict so we can delete
for key in list(template_node):
if key.lower() in hardware_keys:
del template_node[key]
template_node.update(hardware_values)
base_port = int(nodes_dict[template_node_name]["Port"])
# Build up a list of available new indices starting after the template
match = re.search(r"^(.*?)(\d*)$", template_node_name)
template_node_prefix, template_node_index = match.group(1), int(
match.group(2)
)
used_indices = sorted(node_indices[template_node_prefix])
new_indices = []
new_index = template_node_index
for i in range(new_node_count):
new_index += 1
while new_index in used_indices:
new_index += 1
new_indices.append(new_index)
# Create a new aggregate node
# Later, we could consider collapsing the node into the template node if unmodified
new_node_dict = template_node.copy()
if new_node_count == 1:
new_node_dict["NodeName"] = template_node_prefix + str(new_indices[0])
new_node_dict["Port"] = base_port - template_node_index + new_indices[0]
else:
new_node_dict["NodeName"] = (
f"{template_node_prefix}[{list_to_range(new_indices)}]"
)
new_node_dict["Port"] = list_to_range(
list(
map(lambda x: base_port - template_node_index + x, new_indices)
)
)
new_node_names = [
template_node_prefix + str(new_index) for new_index in new_indices
]
create_node(new_node_dict)
return qualifying_node_names + new_node_names
# If local-config, skip
else:
message = f"This test requires {requested_node_count} nodes"
if requirements_list:
message += f" with {requirements_list}"
pytest.skip(message, allow_module_level=True)
return qualifying_node_names
def make_bash_script(script_name, script_contents):
"""Creates an executable Bash script with the specified contents.
Args:
script_name (string): Name of the script to create.
script_contents (string): Contents of the script.
Returns:
None
Example:
>>> make_bash_script("my_script.sh", "echo 'Hello, World!'") # This creates an executable Bash script named "my_script.sh" with the contents "echo 'Hello, World!'"
"""
with open(script_name, "w") as f:
f.write("#!/bin/bash\n")
f.write(script_contents)
run_command(f"chmod 755 {script_name}", user="root", fatal=True, quiet=True)
if properties["test-user-set"]:
run_command(
f"chown {properties['test-user']} {script_name}",
user="root",
fatal=True,
quiet=True,
)
def wait_for_file(file_name, **repeat_until_kwargs):
"""Waits for the specified file to be present.
This function waits up to the specified timeout seconds for the file to be
present, polling every poll_interval seconds. The default timeout and
poll_interval are inherited from repeat_until.
Args:
file_name (string): The file name.
Returns:
True if the file was found, False otherwise.
Example:
>>> wait_for_file("my_file.txt", timeout=30, poll_interval=0.5)
True
"""
logging.debug(f"Waiting for file ({file_name}) to be present")
return repeat_until(
lambda: os.path.isfile(file_name), lambda exists: exists, **repeat_until_kwargs
)
def assert_file_contents(
file_name,
expected,
contains=False,
strip=True,
message=None,
timeout=default_polling_timeout,
poll_interval=None,
quiet=False,
):
"""Waits for a file to exist and its content to match `expected`, or assert False.
Args:
file_name (string): The file name.
expected (string): The expected file content.
contains (bool): If True, `expected` must be a substring of the
file content. If False (default), the content must equal
`expected` exactly.
strip (bool): If True (default), whitespace is stripped from the
file content before comparison.
message (string): Optional custom message on timeout. If None, a
default message including `file_name`, `expected`, and the
last observed content is used.
timeout (float): Maximum time in seconds to wait (default:
default_polling_timeout). Applies both to the existence gate
and to the content wait.
poll_interval (float): Seconds between content checks. Defaults
to timeout / 10.0.
quiet (bool): If True, silences the per-iteration `cat` logs and
the timer's progress/timeout logs. The existence-check log
(from wait_for_file) is DEBUG-level and unaffected.
Example:
>>> assert_file_contents("tasks-0.out", "task-0")
>>> assert_file_contents("job.out", "hello", contains=True)
"""
wait_for_file(file_name, timeout=timeout, poll_interval=poll_interval, fatal=True)
text = ""
for _ in timer(timeout=timeout, poll_interval=poll_interval, quiet=quiet):
text = run_command_output(f"cat {file_name}", quiet=quiet)
if strip:
text = text.strip()
if (expected in text) if contains else (text == expected):
return
assert False, (
message
or f"File {file_name} content should {'contain' if contains else 'be'} {expected!r}; got {text!r}"
)
def dump_accounting_database(dst, drop=False):
"""Dumps the accounting database to a gzipped SQL file.
Works in both auto-config and local-config modes. The dump can be used
later by restore_accounting_database (e.g. by require_accounting(modify=True)
for restore-on-teardown) or as a debugging artifact attached to a failing
test.
Args:
dst (string): Destination file path. Always gzipped; callers should
use a .sql.gz suffix.
drop (boolean): If True, also drop the database after dumping it.
Only allowed in auto-config mode.
Returns:
None
Example:
>>> dump_accounting_database(f"{atf.properties['slurm-logs-dir']}/slurm_acct_db.sql.gz")
"""
if drop and not properties["auto-config"]:
pytest.fail("The accounting database can only be dropped in auto-config")
mysqldump_path = shutil.which("mysqldump")
if mysqldump_path is None:
pytest.fail(
"Unable to dump the accounting database. mysqldump was not found in your path"
)
mysql_path = shutil.which("mysql")
if mysql_path is None:
pytest.fail(
"Unable to dump the accounting database. mysql was not found in your path"
)
slurmdbd_dict = get_config(live=False, source="slurmdbd", quiet=True)
database_host, database_port, database_name, database_user, database_password = (
slurmdbd_dict.get(key)
for key in [
"StorageHost",
"StoragePort",
"StorageLoc",
"StorageUser",
"StoragePass",
]
)
mysql_options = ""
if database_host:
mysql_options += f" -h {database_host}"
if database_port:
mysql_options += f" -P {database_port}"
if database_user:
mysql_options += f" -u {database_user}"
else:
mysql_options += f" -u {properties['slurm-user']}"
if database_password:
mysql_options += f" -p {database_password}"
if not database_name:
database_name = "slurm_acct_db"
mysql_command = f"{mysql_path} {mysql_options} -e \"USE '{database_name}'\""
if run_command_exit(mysql_command, quiet=True) != 0:
logging.debug(f"Slurm accounting database ({database_name}) is not present")
return
if os.path.isfile(dst):
logging.warning(f"Dump file already exists ({dst})")
else:
mysqldump_command = (
f"{mysqldump_path} {mysql_options} {database_name} | gzip -c > {dst}"
)
run_command(
mysqldump_command, fatal=True, quiet=False, timeout=default_sql_cmd_timeout
)
if drop:
run_command(
f'{mysql_path} {mysql_options} -e "drop database {database_name}"',
fatal=True,
quiet=False,
timeout=default_sql_cmd_timeout,
)
def restore_accounting_database(src):
"""Restores the accounting database from a previous dump.
This function may only be used in auto-config mode. The dump file is
removed after a successful restore.
This behaves like a teardown/finally step: it never aborts but returns
a list of failures for the caller to handle.
Args:
src (string): Source dump file path (gzipped, as produced by
dump_accounting_database).
Returns:
list[str]: One message per step that failed (empty if the restore
completed cleanly).
Example:
>>> restore_accounting_database(properties["sql-db-backup"])
[]
"""
# Should be single line failures
failures = []
if not properties["auto-config"]:
return failures
mysql_path = shutil.which("mysql")
if mysql_path is None:
failures.append(
"Unable to restore the accounting database. mysql was not found in your path"
)
return failures
slurmdbd_dict = get_config(live=False, source="slurmdbd", quiet=True)
database_host, database_port, database_name, database_user, database_password = (
slurmdbd_dict.get(key)
for key in [
"StorageHost",
"StoragePort",
"StorageLoc",
"StorageUser",
"StoragePass",
]
)
if not database_name:
database_name = "slurm_acct_db"
base_command = mysql_path
if database_host:
base_command += f" -h {database_host}"
if database_port:
base_command += f" -P {database_port}"
if database_user:
base_command += f" -u {database_user}"
else:
base_command += f" -u {properties['slurm-user']}"
if database_password:
base_command += f" -p {database_password}"
# If DB exists, drop it and try to restore the dump file
mysql_command = f"{base_command} -e \"USE '{database_name}'\""
if run_command_exit(mysql_command, quiet=True) == 0:
result = run_command(
f'{base_command} -e "drop database {database_name}"',
quiet=False,
timeout=default_sql_cmd_timeout,
)
if result["exit_code"] != 0:
failures.append(f"Failed to drop the accounting database ({database_name})")
# If the dump file doesn't exist, it has probably already been
# restored by a previous call to restore_accounting_database
if not os.path.isfile(src):
logging.debug(
f"Slurm accounting database backup ({src}) is not present. It has probably already been restored."
)
return failures
dump_stat = os.stat(src)
if not (dump_stat.st_size == 0 and dump_stat.st_mode & stat.S_ISVTX):
result = run_command(
f'{base_command} -e "create database {database_name}"',
quiet=False,
)
if result["exit_code"] != 0:
failures.append(
f"Failed to create the accounting database ({database_name})"
)
result = run_command(
f"gunzip -c {src} | {base_command} {database_name}",
quiet=False,
timeout=default_sql_cmd_timeout,
)
if result["exit_code"] != 0:
failures.append(f"Failed to restore the accounting database dump ({src})")
# In either case, remove the dump file
result = run_command(f"rm -f {src}", quiet=False)
if result["exit_code"] != 0:
failures.append(f"Failed to remove the accounting database dump ({src})")
return failures
def run_check_test(source_file, build_args=""):
"""Compiles and runs a libcheck test
Args:
source_file (string): The name of the source file, relative to testsuite_check_dir/tests.
build_args (string): Additional string to be appended to the build command.
"""
import xmltodict
check_test = (
f"{module_tmp_path}/{os.path.splitext(os.path.basename(source_file))[0]}"
)
xml_test = check_test + ".xml"
# Add python/data and the scheck helpers to the include path
build_args = " ".join(
[
build_args,
"-lcheck -lm -lsubunit",
f"-I{properties['testsuite_data_dir']}",
f"-I{properties['testsuite_check_dir']}/include",
]
)
compile_against_libslurm(
f"{properties['testsuite_check_dir']}/tests/{source_file}",
check_test,
full=True,
build_args=build_args,
fatal=True,
quiet=True,
)
# Run the libcheck test setting an xml output. Also export `srcdir`
# so the test can locate any sidecar file (e.g. topology.conf)
#
# stdbuf -oL forces line-buffered stdout so printf() output survives
# libcheck's _exit() on assertion failure (default fully-buffered pipe
# would otherwise discard the buffered lines).
src_dir = os.path.dirname(
f"{properties['testsuite_check_dir']}/tests/{source_file}"
)
result = run_command(
f"stdbuf -oL {check_test}",
quiet=True,
env_vars=f"CK_XML_LOG_FILE_NAME={xml_test} srcdir={src_dir}",
)
logging.info(f"{result['stdout']}")
if result["exit_code"]:
logging.error(f"\n{result['stderr']}")
# Parse the xml output
if not os.path.exists(xml_test):
pytest.fail(f"Test results not found: {xml_test}")
with open(xml_test) as f:
xml_data = xmltodict.parse(f.read())
return xml_data["testsuites"]["suite"]
def compile_against_libslurm(
source_file,
dest_file,
build_args="",
full=False,
shared=False,
new_prefixes=False,
**run_command_kwargs,
):
"""Compiles a test program against either libslurm.so or libslurmfull.so.
This function compiles the specified source file against the Slurm library,
either libslurm.so or libslurmfull.so, and creates the target binary file.
Args:
source_file (string): The name of the source file.
dest_file (string): The name of the target binary file.
build_args (string): Additional string to be appended to the build command.
full (boolean): Use libslurmfull.so instead of libslurm.so.
shared (boolean): Produces a shared library (adds the -shared compiler option
and adds a .so suffix to the output file name).
**run_command_kwargs: Auxiliary arguments to be passed to the
run_command function (e.g., quiet, fatal, timeout, etc.).
Returns:
None
Example:
>>> compile_against_libslurm("my_test.c", "my_test", build_args="-Wall -Werror")
"""
slurm_prefix = properties["slurm-prefix"]
slurm_source = properties["slurm-source-dir"]
slurm_build = properties["slurm-build-dir"]
if new_prefixes:
slurm_prefix = properties["new-slurm-prefix"]
slurm_source = properties["new-source-prefix"]
slurm_build = properties["new-build-prefix"]
if full:
slurm_library = "slurmfull"
else:
slurm_library = "slurm"
if os.path.isfile(f"{slurm_prefix}/lib64/slurm/lib{slurm_library}.so"):
lib_dir = "lib64"
else:
lib_dir = "lib"
if full:
lib_path = f"{slurm_prefix}/{lib_dir}/slurm"
else:
lib_path = f"{slurm_prefix}/{lib_dir}"
command = f"gcc {source_file} -g -pthread"
if shared:
command += " -fPIC -shared"
command += f" -o {dest_file}"
command += f" -I{slurm_source} -I{slurm_build} -I{slurm_prefix}/include -Wl,-rpath={lib_path} -L{lib_path} -l{slurm_library} -lresolv"
if build_args != "":
command += f" {build_args}"
run_command(command, **run_command_kwargs)
def get_partitions(**run_command_kwargs):
"""Returns the Slurm partition configuration as a dictionary of dictionaries.
Args:
**run_command_kwargs: Auxiliary arguments to be passed to the
run_command function (e.g., quiet, fatal, timeout, etc.).
Returns:
A dictionary of dictionaries, where the first-level keys are the
partition names, and the values are dictionaries containing the
configuration parameters for the respective partitions.
Example:
>>> get_partitions(quiet=True)
{'partition1': {'PartitionName': 'partition1', 'AllowGroups': 'ALL', 'Defaults': 'YES', ...},
'partition2': {'PartitionName': 'partition2', 'AllowGroups': 'group1,group2', 'Defaults': 'YES', ...}}
"""
partitions_dict = {}
output = run_command_output("scontrol show partition -o", **run_command_kwargs)
partition_dict = {}
for line in output.splitlines():
if line == "":
continue
while match := re.search(r"^ *([^ =]+)=(.*?)(?= +[^ =]+=| *$)", line):
param_name, param_value = match.group(1), match.group(2)
# Remove the consumed parameter from the line
line = re.sub(r"^ *([^ =]+)=(.*?)(?= +[^ =]+=| *$)", "", line)
# Reformat the value if necessary
if is_integer(param_value):
param_value = int(param_value)
elif is_float(param_value):
param_value = float(param_value)
elif param_value == "(null)":
param_value = None
# Add it to the temporary partition dictionary
partition_dict[param_name] = param_value
# Add the partition dictionary to the partitions dictionary
partitions_dict[partition_dict["PartitionName"]] = partition_dict
# Clear the partition dictionary for use by the next partition
partition_dict = {}
return partitions_dict
def get_partition_parameter(partition_name, parameter_name, default=None):
"""Obtains the value for a Slurm partition configuration parameter.
This function retrieves the value of the specified parameter for the given
partition. If the parameter is not present, the default value is returned.
Args:
partition_name (string): The name of the partition.
parameter_name (string): The name of the parameter to retrieve.
default (string or None): The default value to return if the parameter is
not found.
Returns:
The value of the specified partition parameter, or the default if not
found.
Example:
>>> get_partition_parameter('my_partition', 'AllowAccounts')
'ALL'
>>> get_partition_parameter('second_partition', 'DefaultTime', '00:30:00')
'00:30:00'
"""
partitions_dict = get_partitions()
if partition_name in partitions_dict:
partition_dict = partitions_dict[partition_name]
else:
pytest.fail(
f"Partition ({partition_name}) was not found in the partition configuration"
)
if parameter_name in partition_dict:
return partition_dict[parameter_name]
else:
return default
def set_partition_parameter(partition_name, new_parameter_name, new_parameter_value):
"""Sets the value of the specified partition configuration parameter.
This function the specified partition property and reconfigures
the slurm daemons. A backup is automatically created and the
original configuration is restored after the test completes.
This function may only be used in auto-config mode.
Args:
partition_name (string): The partition name.
new_parameter_name (string): The parameter name.
new_parameter_value (string): The parameter value.
Use a value of None to unset a partition parameter.
Example:
>>> set_partition_parameter('partition1', 'MaxTime', 'INFINITE')
"""
if not properties["auto-config"]:
require_auto_config("wants to modify partition parameters")
config_file = f"{properties['slurm-config-dir']}/slurm.conf"
# Read the original slurm.conf into a list of lines
output = run_command_output(
f"cat {config_file}", user=properties["slurm-user"], quiet=True
)
original_config_lines = output.splitlines()
new_config_lines = original_config_lines.copy()
# Locate the partition among the various Partition definitions
found_partition_name = False
for line_index in range(len(original_config_lines)):
line = original_config_lines[line_index]
words = re.split(r" +", line.strip())
if not words[0]:
continue
if words[0][0] == "#":
continue
parameter_name, parameter_value = words[0].split("=", 1)
if parameter_name.lower() != "partitionname":
continue
if parameter_value == partition_name:
# We found a matching PartitionName line
found_partition_name = True
# Read in the partition parameters
original_partition_parameters = collections.OrderedDict()
for word in words[1:]:
parameter_name, parameter_value = word.split("=", 1)
original_partition_parameters[parameter_name] = parameter_value
# Delete the original partition definition
new_config_lines.pop(line_index)
# Add the modified definition for the specified partition
modified_partition_parameters = original_partition_parameters.copy()
if new_parameter_value is None:
if new_parameter_name in modified_partition_parameters:
del modified_partition_parameters[new_parameter_name]
else:
modified_partition_parameters[new_parameter_name] = new_parameter_value
modified_partition_line = f"PartitionName={partition_name}"
for (
parameter_name,
parameter_value,
) in modified_partition_parameters.items():
modified_partition_line += f" {parameter_name}={parameter_value}"
new_config_lines.insert(line_index, modified_partition_line)
break
if not found_partition_name:
pytest.fail(
f"Invalid partition name specified in set_partition_parameter(). Partition {partition_name} does not exist"
)
# Write the config file back out with the modifications
new_config_string = "\n".join(new_config_lines)
run_command(
f"echo '{new_config_string}' > {config_file}",
user=properties["slurm-user"],
fatal=True,
quiet=True,
)
# Reconfigure slurm controller if it is already running
if is_slurmctld_running(quiet=True):
run_command("scontrol reconfigure", user=properties["slurm-user"], quiet=True)
def default_partition():
"""Returns the name of the default Slurm partition.
This function retrieves the Slurm partition configuration and returns the
name of the partition that is marked as the default.
Args:
None
Returns:
The name of the default Slurm partition.
Example:
>>> default_partition()
'my_default_partition'
"""
partitions_dict = get_partitions()
for partition_name in partitions_dict:
if partitions_dict[partition_name]["Default"] == "YES":
return partition_name
def get_run_dir_path():
if "slurm-run-dir" not in properties:
properties["slurm-run-dir"] = get_config_parameter(
"SlurmdPidFile", live=False, quiet=True
)
return os.path.dirname(properties["slurm-run-dir"])
# This is supplied for ease-of-use in test development only.
# Tests should not use this permanently. Use logging.debug() instead.
def log_debug(msg):
logging.debug(msg)
##############################################################################
# ATF module initialization
##############################################################################
# This is a logging filter that adds a new LogRecord traceback attribute
class TraceBackFilter(logging.Filter):
def filter(self, record):
call_stack = []
within_atf_context = False
for frame_summary in (traceback.extract_stack())[-5::-1]:
if within_atf_context:
if "testsuite/python" not in frame_summary.filename:
break
else:
if "testsuite/python" in frame_summary.filename:
within_atf_context = True
else:
continue
function = frame_summary.name
short_filename = frame_summary.filename.rpartition("testsuite/python/")[2]
lineno = frame_summary.lineno
call_stack.append(f"{function}@{short_filename}:{lineno}")
record.traceback = ",".join(call_stack)
return True
# Add a new traceback LogRecord attribute
logging.getLogger().addFilter(TraceBackFilter())
# Add a custom TRACE logging level
# This has to be done early enough to allow pytest --log-level=TRACE to be used
logging.TRACE = logging.NOTSET + 5
logging.addLevelName(logging.TRACE, "TRACE")
def _trace(message, *args, **kwargs):
logging.log(logging.TRACE, message, *args, **kwargs)
logging.trace = _trace
logging.getLogger().trace = _trace
# Add a custom NOTE logging level in between INFO and DEBUG
logging.NOTE = logging.DEBUG + 5
logging.addLevelName(logging.NOTE, "NOTE")
def _note(message, *args, **kwargs):
logging.log(logging.NOTE, message, *args, **kwargs)
logging.note = _note
logging.getLogger().note = _note
# The module-level temporary directory is initialized in conftest.py
module_tmp_path = None
# Instantiate and populate testrun-level properties
properties = {}
# Initialize directory properties
properties["testsuite_base_dir"] = str(pathlib.Path(__file__).resolve().parents[2])
properties["testsuite_python_lib"] = properties["testsuite_base_dir"] + "/python/lib"
properties["slurm-source-dir"] = str(pathlib.Path(__file__).resolve().parents[3])
properties["slurm-build-dir"] = properties["slurm-source-dir"]
properties["slurm-prefix"] = "/usr/local"
properties["testsuite_scripts_dir"] = (
properties["testsuite_base_dir"] + "/python/scripts"
)
properties["testsuite_data_dir"] = properties["testsuite_base_dir"] + "/python/data"
properties["testsuite_check_dir"] = properties["testsuite_base_dir"] + "/python/check"
properties["influxdb_host"] = "localhost"
properties["influxdb_port"] = 8086
properties["influxdb_db"] = "slurm"
# Override directory properties with values from testsuite.conf file
testsuite_config = {}
# The default location for the testsuite.conf file (in SRCDIR/testsuite)
# can be overridden with the SLURM_TESTSUITE_CONF environment variable.
testsuite_config_file = os.getenv(
"SLURM_TESTSUITE_CONF", f"{properties['testsuite_base_dir']}/testsuite.conf"
)
if not os.path.isfile(testsuite_config_file):
pytest.fail(
f"The unified testsuite configuration file (testsuite.conf) was not found. This file can be created from a copy of the sample provided in the source tree at SRCDIR/testsuite/testsuite.conf.sample. By default, this file is expected to be found in SRCDIR/testsuite ({properties['testsuite_base_dir']}). If placed elsewhere, set the SLURM_TESTSUITE_CONF environment variable to the full path of your testsuite.conf file."
)
with open(testsuite_config_file, "r") as f:
for line in f.readlines():
if match := re.search(r"^\s*(\w+)\s*=\s*(.*)$", line):
testsuite_config[match.group(1).lower()] = match.group(2)
if "slurmsourcedir" in testsuite_config:
properties["slurm-source-dir"] = testsuite_config["slurmsourcedir"]
if "slurmbuilddir" in testsuite_config:
properties["slurm-build-dir"] = testsuite_config["slurmbuilddir"]
if "slurminstalldir" in testsuite_config:
properties["slurm-prefix"] = testsuite_config["slurminstalldir"]
if "slurmconfigdir" in testsuite_config:
properties["slurm-config-dir"] = testsuite_config["slurmconfigdir"]
if "influxdb_host" in testsuite_config:
properties["influxdb_host"] = testsuite_config["influxdb_host"]
if "influxdb_port" in testsuite_config:
properties["influxdb_port"] = testsuite_config["influxdb_port"]
if "influxdb_db" in testsuite_config:
properties["influxdb_db"] = testsuite_config["influxdb_db"]
# TODO: The SlurmConfigDirBase should be passed from the testsuite.conf too,
# instead of the hadcoded prefix+etc.orig form here.
properties["slurm-config-orig-dir"] = properties["slurm-prefix"] + "/etc.orig"
# Set derived directory properties
# The environment (e.g. PATH, SLURM_CONF) overrides the configuration.
# If the Slurm clients and daemons are not in the current PATH
# but can be found using the configured SlurmInstallDir, add the
# derived bin and sbin dir to the current PATH.
properties["slurm-bin-dir"] = f"{properties['slurm-prefix']}/bin"
if squeue_path := shutil.which("squeue"):
properties["slurm-bin-dir"] = os.path.dirname(squeue_path)
elif os.access(f"{properties['slurm-bin-dir']}/squeue", os.X_OK):
os.environ["PATH"] += ":" + properties["slurm-bin-dir"]
properties["slurm-sbin-dir"] = f"{properties['slurm-prefix']}/sbin"
if slurmctld_path := shutil.which("slurmctld"):
properties["slurm-sbin-dir"] = os.path.dirname(slurmctld_path)
elif os.access(f"{properties['slurm-sbin-dir']}/slurmctld", os.X_OK):
os.environ["PATH"] += ":" + properties["slurm-sbin-dir"]
properties["slurm-config-dir"] = re.sub(
r"\${prefix}", properties["slurm-prefix"], properties["slurm-config-dir"]
)
if slurm_conf_path := os.getenv("SLURM_CONF"):
properties["slurm-config-dir"] = os.path.dirname(slurm_conf_path)
# Derive the slurm-user value
properties["slurm-user"] = "root"
slurm_config_file = f"{properties['slurm-config-dir']}/slurm.conf"
if not os.path.isfile(slurm_config_file):
pytest.fail(
f"The python testsuite was expecting your slurm.conf to be found in {properties['slurm-config-dir']}. Please create it or use the SLURM_CONF environment variable to indicate its location."
)
if os.access(slurm_config_file, os.R_OK):
with open(slurm_config_file, "r") as f:
for line in f.readlines():
if match := re.search(r"^\s*(?i:SlurmUser)\s*=\s*(.*)$", line):
properties["slurm-user"] = match.group(1)
else:
# slurm.conf is not readable, we will try reading it as root
results = run_command(
f"grep -i SlurmUser {slurm_config_file}", user="root", quiet=True
)
if results["exit_code"] == 0:
pytest.fail(f"Unable to read {slurm_config_file}")
for line in results["stdout"].splitlines():
if match := re.search(r"^\s*(?i:SlurmUser)\s*=\s*(.*)$", line):
properties["slurm-user"] = match.group(1)
properties["submitted-jobs"] = []
properties["lmod-touched-vars"] = set()
if "slurmtestuser" in testsuite_config:
properties["test-user"] = testsuite_config["slurmtestuser"]
properties["test-user-set"] = True
else:
properties["test-user"] = pwd.getpwuid(os.getuid()).pw_name
properties["test-user-set"] = False
properties["test-user-uid"] = pwd.getpwnam(properties["test-user"]).pw_uid
properties["test-user-gid"] = pwd.getpwnam(properties["test-user"]).pw_gid
properties["auto-config"] = False
properties["allow-slurmdbd-modify"] = False
properties["slurmrestd-started"] = False
# Accounting state of the module: None if not configured, "enabled" if
# configured, or "db-saved" if also the original DB was already saved (and
# dropped, in auto-config). Only updated by start_slurmdbd() and
# set_config_parameter("AccountingStorageType"); reset by conftest's
# module_setup().
properties["accounting"] = None
# Instantiate a nodes dictionary. These are populated in require_slurm_running.
nodes = {}
# Check if user has sudo privileges
results = subprocess.run(
"sudo -ln | grep -q '(ALL.*) NOPASSWD: ALL'",
shell=True,
capture_output=True,
text=True,
)
if results.returncode == 0:
properties["sudo-rights"] = True
else:
properties["sudo-rights"] = False