blob: f5521956d7757c053119108c28dd6f720c4137aa [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 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 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:
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-id of the coredump
core_bid = None
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_bid = m.group(1)
break
else:
logging.warning(f"Unable to extract build-id 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) == core_bid:
return cand
logging.warning(
f"No binary matches build-id {core_bid} for coredump {core_path} (candidates tried: {candidates})"
)
return None
def classify_coredump(bin_path, bt_file, failures, xfailures, slurm_prefix=""):
"""
Append a known reason either to failures or xfailures lists based on a list
of known coredumps, either to ignore them in old versions, or to report a
failure message that helps QA operations.
"""
bt = run_command_output(f"cat {bt_file}", quiet=True, fatal=True)
reason = "Ticket Unknown: Fixed issue in slurmrestd in 25.05: SIGABRT in con_set_polling(): has_in || has_out"
component = "sbin/slurmrestd"
if (
component in bin_path
and "Program terminated with signal SIGABRT" in bt
and "src/conmgr/con.c" in bt
and "src/conmgr/watch.c" in bt
and "con_set_polling" in bt
and "_handle_connection" in bt
and "has_in || has_out" in bt
):
if get_version(component, slurm_prefix=slurm_prefix) >= (25, 5):
failures.append(reason)
else:
xfailures.append(reason)
return
reason = "Ticket Unknown: Fixed issue in slurmdbd in 25.05: SIGABRT in _connection_fini_callback(): pthread_mutex_lock(): Invalid argument"
component = "sbin/slurmdbd"
if (
component in bin_path
and "Program terminated with signal SIGABRT" in bt
and "src/common/log.c" in bt
and "src/slurmdbd/rpc_mgr.c" in bt
and "_connection_fini_callback" in bt
and "_service_connection" in bt
and "fatal_abort" in bt
and "pthread_mutex_lock" in bt
):
if get_version(component, slurm_prefix=slurm_prefix) >= (25, 5):
failures.append(reason)
else:
xfailures.append(reason)
return
reason = "Ticket 24853: Issue in slurmdbd in 25.05: SIGABRT in slurm_persist_conn_recv_thread_init: service_conn"
component = "sbin/slurmdbd"
if (
component in bin_path
and "Program terminated with signal SIGABRT" in bt
and "src/common/persist_conn.c" in bt
and "src/slurmdbd/rpc_mgr.c" in bt
and "slurm_persist_conn_recv_thread_init" in bt
and "service_conn" in bt
and "__xassert_failed" in bt
):
if get_version(component, slurm_prefix=slurm_prefix)[:2] != (25, 5):
failures.append(reason)
else:
xfailures.append(reason)
return
reason = "Ticket 22310: Known issue when shutting down slurmdbd: SIGSEGV in _service_connection()"
component = "sbin/slurmdbd"
if (
component in bin_path
and "Program terminated with signal SIGSEGV" in bt
and "src/common/persist_conn.c" in bt
and "_service_connection" in bt
):
if get_version(component, slurm_prefix=slurm_prefix) >= (26, 5):
failures.append(reason)
else:
xfailures.append(reason)
return
reason = "Ticket 22326: Known issue in slurmdbd: SIGABRT in _list_find_first_lock(): Assertion (l != NULL)"
component = "sbin/slurmdbd"
if (
component in bin_path
and "Program terminated with signal SIGABRT" in bt
and "src/common/list.c" in bt
and "src/slurmdbd/proc_req.c" in bt
and "_list_find_first_lock" in bt
and "_process_service_connection" in bt
and "l != NULL" in bt
):
if get_version(component, slurm_prefix=slurm_prefix) >= (25, 5):
failures.append(reason)
else:
xfailures.append(reason)
return
reason = "Ticket 24122: Known issue in slurmctld: SIGABRT in conn_g_recv(): Assertion (plugin_inited == PLUGIN_INITED) failed"
component = "sbin/slurmctld"
if (
component in bin_path
and "Program terminated with signal SIGABRT" in bt
and "src/interfaces/conn.c" in bt
and "src/common/slurm_protocol_socket.c" in bt
and "src/common/forward.c" in bt
and "conn_g_recv" in bt
and "_fwd_tree_thread" in bt
and "plugin_inited == PLUGIN_INITED" in bt
):
# Fixed in 25.11.5 and 25.5.8
if (
(get_version(component, slurm_prefix=slurm_prefix) < (25, 5))
or (
(25, 5) < get_version(component, slurm_prefix=slurm_prefix)
and get_version(component, slurm_prefix=slurm_prefix) < (25, 5, 8)
)
or (
(25, 11) < get_version(component, slurm_prefix=slurm_prefix)
and get_version(component, slurm_prefix=slurm_prefix) < (25, 11, 5)
)
):
xfailures.append(reason)
else:
failures.append(reason)
return
reason = "Ticket 24562: Known issue when shutting down slurmdbd: SIGABORT in acct_storage_g_close_connection(): Assertion (plugin_inited != PLUGIN_NOT_INITED). Fixed in 25.11.7+."
component = "sbin/slurmdbd"
if (
component in bin_path
and "Program terminated with signal SIGABRT" in bt
and "src/interfaces/accounting_storage.c" in bt
and "acct_storage_g_close_connection" in bt
and "plugin_inited != PLUGIN_NOT_INITED" in bt
):
if get_version(component, slurm_prefix=slurm_prefix) >= (25, 11, 7):
failures.append(reason)
else:
xfailures.append(reason)
component = "sbin/slurmctld"
if (
component in bin_path
and "Program terminated with signal SIGABRT" in bt
and "src/interfaces/accounting_storage.c" in bt
and "clusteracct_storage_g_node_up" in bt
and "plugin_inited != PLUGIN_NOT_INITED" in bt
):
if get_version(component, slurm_prefix=slurm_prefix) >= (25, 11, 7):
failures.append(reason)
else:
xfailures.append(reason)
return
return
reason = (
"Ticket 24822: Known issue shutting down slurmd/slurmctld/srun with OpenSSL"
)
components = ["sbin/slurmd", "sbin/slurmctld", "bin/srun"]
component_match = next((c for c in components if c in bin_path), None)
if (
component_match
and (
"Program terminated with signal SIGABRT" in bt
or "Program terminated with signal SIGSEGV" in bt
)
and "OPENSSL_cleanup" in bt
):
if get_version(component_match, slurm_prefix=slurm_prefix) >= (26, 5):
failures.append(reason)
else:
xfailures.append(reason)
return
reason = "Ticket 24905: Known issue with slurmstepd with stepmgr"
component = "sbin/slurmstepd"
if (
component in bin_path
and "Program terminated with signal SIGSEGV" in bt
and "src/slurmd/slurmstepd/mgr.c" in bt
and "_spawn_job_container" in bt
and "for (uint32_t i = 0; i < step->node_tasks; i++)" in bt
):
# "slurmstepd -V" won't work, but slurmd should have the same version
if get_version("sbin/slurmd", slurm_prefix=slurm_prefix) >= (25, 11, 6):
failures.append(reason)
else:
xfailures.append(reason)
return
reason = "Ticket 24907: Known issue with slurmstepd and jobacct_gather"
component = "sbin/slurmstepd"
if (
component in bin_path
and "Program terminated with signal SIGABRT" in bt
and "src/interfaces/jobacct_gather.c" in bt
and "jobacctinfo_aggregate" in bt
and "step_partial_comp" in bt
and "dest=0x0" in bt
):
# "slurmstepd -V" won't work, but slurmd should have the same version
if get_version("sbin/slurmd", slurm_prefix=slurm_prefix) > (25, 11, 6):
failures.append(reason)
else:
xfailures.append(reason)
return
reason = "Ticket 24952: Known issue with slurmctld auth_g_destroy(): Assertion (g_context_num > 0)"
component = "sbin/slurmctld"
if (
component in bin_path
and "Program terminated with signal SIGABRT" in bt
and "src/interfaces/auth.c" in bt
and "auth_g_destroy" in bt
and "slurm_receive_msgs" in bt
and "g_context_num > 0" in bt
):
if get_version(component, slurm_prefix=slurm_prefix) >= (25, 5):
failures.append(reason)
else:
xfailures.append(reason)
return
reason = "Ticket 24991: Known issue with slurmstepd and _wait_for_job_running"
component = "sbin/slurmstepd"
if (
component in bin_path
and "Program terminated with signal SIGSEGV" in bt
and "src/slurmd/slurmstepd/req.c" in bt
and "_wait_for_job_running" in bt
and "___pthread_mutex_lock" in bt
):
# TODO: Add version when t24991 is fixed
failures.append(reason)
return
reason = "Ticket 25013: Known issue with slurmctld: Assertion (con_flag(con, FLAG_READ_EOF) || con_flag(con, FLAG_IS_LISTEN)). Fixed in 25.05.8+"
component = "sbin/slurmctld"
if (
component in bin_path
and "Program terminated with signal SIGABRT" in bt
and "src/conmgr/con.c" in bt
and "in close_con" in bt
and "con_flag(con, FLAG_READ_EOF) || con_flag(con, FLAG_IS_LISTEN)" in bt
):
if get_version(component, slurm_prefix=slurm_prefix) >= (25, 5, 8):
failures.append(reason)
else:
xfailures.append(reason)
return
reason = "Ticket 25070: Known issue with slurmd: fatal: _forward_thread: pthread_mutex_lock()/unlock(): Invalid argument. Fixed in 25.11.6+"
component = "sbin/slurmd"
if (
component in bin_path
and "Program terminated with signal SIGABRT" in bt
and "src/common/log.c" in bt
and "in fatal_abort" in bt
and "in _forward_thread" in bt
and (
"%s: pthread_mutex_lock(): %m" in bt
or "%s: pthread_mutex_unlock(): %m" in bt
)
):
if get_version(component, slurm_prefix=slurm_prefix) >= (25, 11, 6):
failures.append(reason)
else:
xfailures.append(reason)
return
reason = "Ticket 25095: Known issue with slurmctld: SIGABRT: slurmdb_destroy_assoc_usage fixed in 25.11.6"
component = "sbin/slurmctld"
if (
component in bin_path
and "Program terminated with signal SIGABRT" in bt
and "src/common/slurmdb_defs.c" in bt
and "in slurm_xfree" in bt
and "in slurmdb_destroy_assoc_usage" in bt
and "in slurmdb_free_assoc_rec_members" in bt
):
if get_version(component, slurm_prefix=slurm_prefix) >= (25, 11, 6):
failures.append(reason)
else:
xfailures.append(reason)
return
reason = "Ticket 25135: Known issue with slurmctld with s2n: SIGSEGV: _on_s2n_error"
component = "sbin/slurmctld"
if (
component in bin_path
and "Program terminated with signal SIGSEGV" in bt
and "src/plugins/tls/s2n/tls_s2n.c" in bt
and "src/interfaces/conn.c" in bt
and "in _on_s2n_error" in bt
and "in _negotiate" in bt
and "in tls_p_create_conn" in bt
and "s2n_connection_get_delay" in bt
):
# TODO: Add version when t25135 is fixed
failures.append(reason)
return
reason = "Ticket 25141: Known issue with slurmctld on reconfigure: SIGABRT in jobacct_storage_g_step_complete(): Assertion (plugin_inited != PLUGIN_NOT_INITED). Fixed in 25.11+"
component = "sbin/slurmctld"
if (
component in bin_path
and "Program terminated with signal SIGABRT" in bt
and "src/interfaces/accounting_storage.c" in bt
and "jobacct_storage_g_step_complete" in bt
and "_slurm_rpc_step_complete" in bt
and "plugin_inited != PLUGIN_NOT_INITED" in bt
):
if get_version(component, slurm_prefix=slurm_prefix) >= (25, 11):
failures.append(reason)
else:
xfailures.append(reason)
return
reason = "Ticket 25193: Known issue with slurmd: SIGABRT: double free or corruption (fasttop)"
component = "sbin/slurmd"
if (
component in bin_path
and "Program terminated with signal SIGABRT" in bt
and "malloc/malloc.c" in bt
and "in malloc_printerr" in bt
and "double free or corruption (fasttop)" in bt
):
# TODO: Add version when t25193 is fixed
failures.append(reason)
return
reason = "Issue 51060: slurmctld - SIGABRT: _kill_job_step(): Assertion (job_ptr->job_id == job_step_kill_msg->step_id.job_id) failed"
component = "sbin/slurmctld"
if (
component in bin_path
and "Program terminated with signal SIGABRT" in bt
and "src/slurmctld/job_mgr.c" in bt
and "_kill_job_step" in bt
and "job_ptr->job_id == job_step_kill_msg->step_id.job_id" in bt
):
# TODO: Add version when i51060 is fixed
failures.append(reason)
return
# If coredump is unknown, add it as failure
failures.append(f"Unknown coredump detected, see {bt_file}")
def classify_teardown_failure(message, xfail_teardowns, failures, xfailures):
"""
Append a teardown failure message either to failures or xfailures based on a
list of known teardown failures, either to xfail them in old versions, or to
report a failure message that helps QA operations.
This is the generic teardown-failure counterpart of classify_coredump():
instead of matching a coredump backtrace, it matches the failure message
against the xfail_teardown markers declared by the test module.
xfail_teardowns is the list of kwargs dicts from the module's
xfail_teardown markers, each with:
reason: human-readable explanation shown in the xfail report.
known_fail_msg: substring matched against the teardown failure message.
condition: boolean gate; xfail only when condition is truthy.
Reading the markers is pytest-specific, so it is left to the caller (the
module_teardown fixture in conftest.py).
"""
for xfail in xfail_teardowns:
known_fail_msg = xfail.get("known_fail_msg")
if not known_fail_msg or known_fail_msg not in message:
continue
if not xfail.get("condition", True):
continue
xfailures.append(xfail.get("reason") or message)
return
failures.append(message)
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):
"""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.
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")
logging.debug(f"Getting gcores and sending SIGPROF to PIDs: {pids}")
for pid in pids:
run_command(f"kill -SIGPROF {pid}", user="root")
run_command(
f"sudo gcore -o {properties['slurm-logs-dir']}/{component}.core {pid}"
)
def start_slurmd(slurmd_name, quiet=False):
"""Starts slurmd for node.
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
"""
# Check whether slurmd is running
slurmd_pgrep = run_command(f"pgrep -f 'slurmd -N {slurmd_name}'", quiet=quiet)
if slurmd_pgrep["exit_code"] == 0:
pids = slurmd_pgrep["stdout"].splitlines()
for pid in pids:
logging.warning(
f"slurmd for {slurmd_name} already running. Killing PID {pid}..."
)
run_command(f"kill -9 {pid}", user="root")
for t in timer():
if run_command(f"pgrep -f 'slurmd -N {slurmd_name}'", quiet=quiet) != 0:
break
else:
pytest.fail(f"Unable to kill already running slurmd ({pid})")
# Start slurmd
logging.debug(f"Starting slurmd for {slurmd_name}...")
results = run_command(
f"{properties['slurm-sbin-dir']}/slurmd -N {slurmd_name}",
user="root",
quiet=quiet,
)
if results["exit_code"] != 0:
pytest.fail(
f"Unable to start slurmd -N {slurmd_name} (rc={results['exit_code']}): {results['stderr']}"
)
# Verify that the slurmd is running
if run_command_exit(f"pgrep -f 'slurmd -N {slurmd_name}'", quiet=quiet) != 0:
pytest.fail(f"Slurmd -N {slurmd_name} is not running")
def start_slurmctld(clean=False, quiet=False, also_slurmds=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.
Returns:
None
Example:
>>> start_slurmctld() # Start slurmctld with default settings
>>> start_slurmctld(clean=True, quiet=True) # Start slurmctld with clean state and quiet logging
"""
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)
# Start slurmctld
command = f"{properties['slurm-sbin-dir']}/slurmctld"
if clean:
command += " -c -i"
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:
# Build list of slurmds
slurmd_list = []
output = run_command_output(
f"perl -nle 'print $1 if /^NodeName=(\\S+)/' {properties['slurm-config-dir']}/slurm.conf",
user=properties["slurm-user"],
quiet=quiet,
)
if not output:
pytest.fail("Unable to determine the slurmd node names")
for node_name_expression in output.rstrip().split("\n"):
if node_name_expression != "DEFAULT":
slurmd_list.extend(node_range_to_list(node_name_expression))
# (Multi)Slurmds
for slurmd_name in slurmd_list:
start_slurmd(slurmd_name, 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)
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 (
get_config_parameter("AccountingStorageType", live=False, quiet=quiet)
== "accounting_storage/slurmdbd"
):
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):
"""Stops the Slurm controller daemon (slurmctld).
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.
Returns:
None
Example:
>>> stop_slurmctld() # Stop slurmctld with default logging
>>> stop_slurmctld(quiet=True) # Stop slurmctld with quiet logging
"""
rc = None
failures = []
if not properties["auto-config"]:
require_auto_config("wants to stop slurmctld")
# Stop slurmctld
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
if not repeat_until(
lambda: pids_from_exe(f"{properties['slurm-sbin-dir']}/slurmctld"),
lambda pids: len(pids) == 0,
timeout=default_polling_timeout * 2,
):
failures.append("Slurmctld is still running")
logging.warning("Getting the core files of the still running slurmctld")
gcore("slurmctld")
else:
logging.debug("No slurmctld is running.")
if also_slurmds:
if get_version("sbin/slurmd") < (24, 11):
# FIXED: t20764.
slurmd_list = []
output = run_command_output(
f"perl -nle 'print $1 if /^NodeName=(\\S+)/' {properties['slurm-config-dir']}/slurm.conf",
quiet=quiet,
)
if not output:
failures.append("Unable to determine the slurmd node names")
else:
for node_name_expression in output.rstrip().split("\n"):
if node_name_expression != "DEFAULT":
slurmd_list.extend(node_range_to_list(node_name_expression))
for slurmd_name in slurmd_list:
run_command(
f"sudo systemctl stop {slurmd_name}_slurmstepd.scope", quiet=quiet
)
# 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
results = run_command(
"sacctmgr shutdown", user=properties["slurm-user"], quiet=quiet
)
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 (
get_config_parameter("AccountingStorageType", live=False, quiet=quiet)
== "accounting_storage/slurmdbd"
):
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):
"""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.
Returns:
None
Example:
>>> restart_slurmctld() # Restart slurmctld with default settings
>>> restart_slurmctld(clean=True, quiet=True) # Restart slurmctld with clean state and quiet logging
"""
stop_slurmctld(quiet=quiet)
start_slurmctld(clean=clean, quiet=quiet)
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'"""
fields = [
"NodeName",
"CPUs",
"Boards",
"SocketsPerBoard",
"CoresPerSocket",
"ThreadsPerCore",
"RealMemory",
"Gres",
]
output = run_command_output("slurmd -C", fatal=True)
keys = re.findall(r"(" + "|".join(fields) + r")=(\S+)", output)
return dict(keys)
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.
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).
"""
# 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]])
# 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)
else:
# Handle known (random) issues alreadi fixed.
if (
"slurm_reconfigure error:Zero Bytes were transmitted or received" in reason
and get_version("sbin/slurmctld") < (25, 11)
):
pytest.xfail(f"Ticket 25141: Fixed in 25.11+. {reason}")
if 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)
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.
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"
# 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,
)
slurmctld_running = is_slurmctld_running(quiet=True)
# 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 source != "slurm" or parameter_name.lower() in [
"accountingstoragetype",
"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 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 we cannot use the --mpi=mpi_option or the mpi_compiler is not available".
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)
output = run_command_output("srun --mpi=list", fatal=True)
name = re.escape(mpi_option)
if (
re.search(rf"^\s+{name}\s*$", output, re.MULTILINE) is None
and re.search(rf"plugin versions available:.*\b{name}\b", output) is None
):
pytest.skip(
f"This test needs to be able to use --mpi={mpi_option}",
allow_module_level=True,
)
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
obs = type(v)(observed_value)
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_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):
"""Ensures that Slurm accounting is configured.
In local-config mode, the test is skipped if Slurm accounting is not
configured. In auto-config mode, configures 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).
A database backup is automatically created and the original dump
is restored after the test completes.
Returns:
None
Example:
>>> require_accounting()
>>> require_accounting(modify=True)
"""
if properties["auto-config"]:
if (
get_config_parameter("AccountingStorageType", live=False, quiet=True)
!= "accounting_storage/slurmdbd"
):
set_config_parameter("AccountingStorageType", "accounting_storage/slurmdbd")
if modify:
dump_accounting_database(properties["sql-db-backup"])
else:
if modify and not properties["allow-slurmdbd-modify"]:
require_auto_config("wants to modify the accounting database")
elif (
get_config_parameter("AccountingStorageType", live=False, quiet=True)
!= "accounting_storage/slurmdbd"
):
pytest.skip(
"This test requires accounting to be configured",
allow_module_level=True,
)
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
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):
"""Obtains the value for a node configuration parameter.
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)
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 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 len(words) < 1:
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", fatal=True, 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, **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.
Returns:
The job id (int) or 0 if failed.
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
"""
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')
return job_id if job_id else 0
def _readline_with_timeout(stream, timeout=default_command_timeout):
"""Read a line from a stream with a timeout.
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
"""
result = [None]
def read_line():
try:
result[0] = stream.readline()
except Exception:
pass
thread = threading.Thread(target=read_line)
thread.daemon = True
thread.start()
thread.join(timeout)
if thread.is_alive():
# Timeout occurred
return None
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, fatal=True, quiet=True, **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, fatal=True, quiet=True, **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
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 job_dict:
jobs_dict[job_dict["JobId"]] = job_dict
# Clear the job dictionary for use by the next job
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_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():
if (
job_values["ArrayJobId"] == array_job_id
and job_values["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
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, **repeat_until_kwargs):
"""Wait for the specified step of a job to be running.
Continuously polls the step state until it becomes running 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 running or not.
Example:
>>> wait_for_step(1234, 0, timeout=60, poll_interval=5, fatal=True)
True
>>> wait_for_step(5678, 1, timeout=30)
False
"""
step_str = f"{job_id}.{step_id}"
return repeat_until(
lambda: run_command_output(f"scontrol -o show step {step_str}"),
lambda out: re.search(rf"StepId={step_str}", out) is not None,
**repeat_until_kwargs,
)
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 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)
# 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=[]):
"""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.
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:
None
Example:
>>> require_nodes(2, [('CPUs', 4), ('RealMemory', 40)])
>>> require_nodes(2, [('CPUs', 2), ('RealMemory', 30), ('Features', 'gpu,mpi')])
>>> require_nodes(2, [('CPUs', 4), ('Sockets', 1)])
"""
# 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_count = 0
node_count = 0
nonqualifying_node_count = 0
first_node_name = ""
first_qualifying_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
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 lower_node_dict[parameter_name.lower()] < 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 = lower_node_dict.get("boards", 1)
sockets_per_board = lower_node_dict.get("socketsperboard", 1)
cores_per_socket = lower_node_dict.get("corespersocket", 1)
sockets = boards * sockets_per_board
cores = sockets * cores_per_socket
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+):(\d+)$", gres_value):
(required_gres_name, required_gres_value) = (
match.group(1),
match.group(2),
)
else:
pytest.fail(
"Gres requirement must be of the form <name>:<count>"
)
if match := re.search(
rf"{required_gres_name}:(\d+)",
lower_node_dict[parameter_name.lower()],
):
if match.group(1) < required_gres_value:
if node_qualifies:
node_qualifies = False
nonqualifying_node_count += 1
if nonqualifying_node_count == 1:
augmentation_dict[parameter_name] = gres_value
else:
if node_qualifies:
node_qualifies = False
nonqualifying_node_count += 1
if nonqualifying_node_count == 1:
augmentation_dict[parameter_name] = gres_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_count += 1
if first_qualifying_node_name == "":
first_qualifying_node_name = node_name
# Not enough qualifying nodes
if qualifying_node_count < 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_count > 0:
template_node_name = first_qualifying_node_name
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
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)
)
)
create_node(new_node_dict)
# 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)
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):
"""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.
Returns:
None
Example:
>>> dump_accounting_database(f"{atf.properties['slurm-logs-dir']}/slurm_acct_db.sql.gz")
"""
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"
)
if os.path.isfile(dst):
logging.warning(f"Dump file already exists ({dst})")
return
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")
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
)
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.
Args:
src (string): Source dump file path (gzipped, as produced by
dump_accounting_database).
Returns:
None
Example:
>>> restore_accounting_database(properties["sql-db-backup"])
"""
if not properties["auto-config"]:
return
mysql_path = shutil.which("mysql")
if mysql_path is None:
pytest.fail(
"Unable to restore 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",
]
)
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:
run_command(
f'{base_command} -e "drop database {database_name}"',
fatal=True,
quiet=False,
timeout=default_sql_cmd_timeout,
)
# 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
dump_stat = os.stat(src)
if not (dump_stat.st_size == 0 and dump_stat.st_mode & stat.S_ISVTX):
run_command(
f'{base_command} -e "create database {database_name}"',
fatal=True,
quiet=False,
)
run_command(
f"gunzip -c {src} | {base_command} {database_name}",
fatal=True,
quiet=False,
timeout=default_sql_cmd_timeout,
)
# In either case, remove the dump file
run_command(f"rm -f {src}", fatal=True, quiet=False)
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.
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 to the include path
build_args = " ".join(
[
build_args,
"-lcheck -lm -lsubunit",
f"-I{properties['testsuite_data_dir']}",
]
)
compile_against_libslurm(
f"{properties['testsuite_check_dir']}/{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']}/{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}",
)
# 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())
logging.info(f"{result['stdout']}")
if result["exit_code"]:
logging.error(f"\n{result['stderr']}")
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", fatal=True, **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 len(words) < 1:
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
# 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