| ############################################################################## | |
| # ATF (Automated Testing Framework) python testsuite module | |
| # Copyright (C) SchedMD LLC. | |
| ############################################################################## | |
| import collections | |
| import errno | |
| # import glob | |
| import logging | |
| import math | |
| import os | |
| import pwd | |
| import pathlib | |
| import pytest | |
| import re | |
| import shutil | |
| import stat | |
| import socket | |
| import subprocess | |
| import sys | |
| import time | |
| import traceback | |
| # slurmrestd | |
| import requests | |
| import signal | |
| import json | |
| # This module will be (un)imported in require_openapi_generator() | |
| openapi_client = None | |
| import importlib | |
| ############################################################################## | |
| # 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 run_command( | |
| command, | |
| fatal=False, | |
| timeout=default_command_timeout, | |
| quiet=False, | |
| chdir=None, | |
| user=None, | |
| input=None, | |
| xfail=False, | |
| env_vars=None, | |
| ): | |
| """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. | |
| 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 | |
| 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 and user != properties["test-user"]: | |
| if not properties["sudo-rights"]: | |
| pytest.skip( | |
| "This test requires the test user to have unprompted sudo rights", | |
| allow_module_level=True, | |
| ) | |
| cp = subprocess.run( | |
| [ | |
| "sudo", | |
| "--preserve-env=PATH", | |
| "-u", | |
| user, | |
| "/bin/bash", | |
| "-lc", | |
| command, | |
| ], | |
| capture_output=True, | |
| text=True, | |
| **additional_run_kwargs, | |
| ) | |
| else: | |
| cp = subprocess.run( | |
| command, | |
| 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 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 | |
| condition_met = False | |
| while time.time() < begin_time + timeout: | |
| if condition(callable()): | |
| condition_met = True | |
| break | |
| 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') | |
| [] | |
| """ | |
| # 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. | |
| """ | |
| # 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( | |
| lambda: request_slurmrestd("openapi/v3"), lambda r: 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("Process {prefix}/{component} not found") | |
| logging.debug(f"Getting gcores for PIDs: {pids}") | |
| for pid in pids: | |
| run_command( | |
| f"sudo gcore -o {properties['slurm-logs-dir']}/{component}.core {pid}" | |
| ) | |
| 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") | |
| logging.debug("Starting slurmctld...") | |
| if not is_slurmctld_running(quiet=quiet): | |
| # 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") | |
| else: | |
| logging.warning("Slurmctld was already started") | |
| 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: | |
| logging.debug(f"Starting slurmd for {slurmd_name}...") | |
| # Check whether slurmd is running | |
| slurmd_pgrep = run_command( | |
| f"pgrep -f 'slurmd -N {slurmd_name}'", quiet=quiet | |
| ) | |
| if slurmd_pgrep["exit_code"] != 0: | |
| # Start slurmd | |
| 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") | |
| else: | |
| logging.warning(f"slurmd for {slurmd_name} already running") | |
| logging.warning(f"slurmd_pgrep['stdout']: {slurmd_pgrep['stdout']}") | |
| logging.warning(f"slurmd_pgrep['stderr']: {slurmd_pgrep['stderr']}") | |
| logging.warning( | |
| f"slurmd_pgrep['exit_code']: {slurmd_pgrep['exit_code']}" | |
| ) | |
| # Verify that the slurmd is registered correctly | |
| if not repeat_until( | |
| lambda: get_nodes(quiet=True), | |
| lambda nodes: all(nodes[name]["state"] == ["IDLE"] for name in slurmd_list), | |
| ): | |
| nodes = get_nodes(quiet=True) | |
| non_idle = [ | |
| name for name in slurmd_list if nodes[name]["state"] != ["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") | |
| logging.debug("Starting slurmdbd...") | |
| if ( | |
| run_command_exit( | |
| "sacctmgr show cluster", user=properties["slurm-user"], quiet=quiet | |
| ) | |
| != 0 | |
| ): | |
| # 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 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") | |
| # Determine whether slurmdbd should be included | |
| if ( | |
| get_config_parameter("AccountingStorageType", live=False, quiet=quiet) | |
| == "accounting_storage/slurmdbd" | |
| ): | |
| start_slurmdbd(clean, quiet) | |
| # 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, | |
| ) | |
| # 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 with rc={results['exit_code']}") | |
| # 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, | |
| ): | |
| 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, | |
| ): | |
| 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.") | |
| 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=60, | |
| ): | |
| 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() | |
| 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) | |
| def is_upgrade_setup( | |
| old_slurm_prefix="/opt/slurm-old", new_slurm_prefix="/opt/slurm-new" | |
| ): | |
| """ | |
| Return True if we have two Slurms configured in the system. | |
| """ | |
| if not os.path.exists(old_slurm_prefix): | |
| logging.debug(f"Old prefix {old_slurm_prefix} not exists.") | |
| return False | |
| if not os.path.exists(new_slurm_prefix): | |
| logging.debug(f"New prefix {new_slurm_prefix} not exists.") | |
| return False | |
| return True | |
| def require_upgrades( | |
| old_slurm_prefix="/opt/slurm-old", new_slurm_prefix="/opt/slurm-new" | |
| ): | |
| """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(): | |
| 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}") | |
| properties["old-slurm-prefix"] = old_slurm_prefix | |
| properties["new-slurm-prefix"] = new_slurm_prefix | |
| 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_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. | |
| slurm_prefix (string): The path where the component is. By default the defined in testsuite.conf. | |
| Returns: | |
| A tuple representing the version. E.g. (25.05.0). | |
| """ | |
| if slurm_prefix == "": | |
| slurm_prefix = f"{properties['slurm-sbin-dir']}/.." | |
| return tuple( | |
| int(part) if part.isdigit() else 0 | |
| for part in run_command_output( | |
| f"sudo {slurm_prefix}/{component} -V", quiet=True | |
| ) | |
| .replace("slurm ", "") | |
| .strip() | |
| .split(".") | |
| ) | |
| def require_version(version, component="sbin/slurmctld", slurm_prefix=""): | |
| """Checks if the component is at least the required version, or skips. | |
| Args: | |
| version (tuple): The tuple representing the version. | |
| 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. | |
| Returns: | |
| A tuple representing the version. E.g. (25.05.0). | |
| """ | |
| component_version = get_version(component, slurm_prefix) | |
| if component_version < version: | |
| pytest.skip( | |
| f"The version of {component} is {component_version}, required is {version}" | |
| ) | |
| 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 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}") | |
| 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_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 backup_config_file(config="slurm"): | |
| """Backs up a configuration file. | |
| This function may only be used in auto-config mode. | |
| Args: | |
| config (string): Name of the config file to back up (without the .conf suffix). | |
| Returns: | |
| None | |
| Example: | |
| >>> backup_config_file('slurm') | |
| >>> backup_config_file('gres') | |
| >>> backup_config_file('cgroup') | |
| """ | |
| if not properties["auto-config"]: | |
| require_auto_config(f"wants to modify the {config} configuration file") | |
| properties["configurations-modified"].add(config) | |
| config_file = f"{properties['slurm-config-dir']}/{config}.conf" | |
| backup_config_file = f"{config_file}.orig-atf" | |
| # If a backup already exists, issue a warning and return (honor existing backup) | |
| if os.path.isfile(backup_config_file): | |
| logging.trace(f"Backup file already exists ({backup_config_file})") | |
| return | |
| # If the file to backup does not exist, touch an empty backup file with | |
| # the sticky bit set. restore_config_file will remove the file. | |
| if not os.path.isfile(config_file): | |
| run_command( | |
| f"touch {backup_config_file}", | |
| user=properties["slurm-user"], | |
| fatal=True, | |
| quiet=True, | |
| ) | |
| run_command( | |
| f"chmod 1000 {backup_config_file}", | |
| user=properties["slurm-user"], | |
| fatal=True, | |
| quiet=True, | |
| ) | |
| # Otherwise, copy the config file to the backup | |
| else: | |
| run_command( | |
| f"cp {config_file} {backup_config_file}", | |
| user=properties["slurm-user"], | |
| fatal=True, | |
| quiet=True, | |
| ) | |
| def restore_config_file(config="slurm"): | |
| """Restores a configuration file. | |
| This function may only be used in auto-config mode. | |
| Args: | |
| config (string): Name of config file to restore (without the .conf suffix). | |
| Returns: | |
| None | |
| Example: | |
| >>> restore_config_file('slurm') | |
| >>> restore_config_file('gres') | |
| >>> restore_config_file('cgroup') | |
| """ | |
| config_file = f"{properties['slurm-config-dir']}/{config}.conf" | |
| backup_config_file = f"{config_file}.orig-atf" | |
| properties["configurations-modified"].remove(config) | |
| # If backup file doesn't exist, it has probably already been | |
| # restored by a previous call to restore_config_file | |
| if not os.path.isfile(backup_config_file): | |
| logging.trace( | |
| f"Backup file does not exist for {config_file}. It has probably already been restored." | |
| ) | |
| return | |
| # If the sticky bit is set and the file is empty, remove both the file and the backup | |
| backup_stat = os.stat(backup_config_file) | |
| if backup_stat.st_size == 0 and backup_stat.st_mode & stat.S_ISVTX: | |
| run_command( | |
| f"rm -f {backup_config_file}", | |
| user=properties["slurm-user"], | |
| fatal=True, | |
| quiet=True, | |
| ) | |
| if os.path.isfile(config_file): | |
| run_command( | |
| f"rm -f {config_file}", | |
| user=properties["slurm-user"], | |
| fatal=True, | |
| quiet=True, | |
| ) | |
| # Otherwise, copy backup config file to primary config file | |
| # and remove the backup (.orig-atf) | |
| else: | |
| run_command( | |
| f"cp {backup_config_file} {config_file}", | |
| user=properties["slurm-user"], | |
| fatal=True, | |
| quiet=True, | |
| ) | |
| run_command( | |
| f"rm -f {backup_config_file}", | |
| user=properties["slurm-user"], | |
| fatal=True, | |
| quiet=True, | |
| ) | |
| def get_config(live=True, source="slurm", quiet=False, 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" | |
| # This has the side-effect of adding config to configurations-modified | |
| backup_config_file(config) | |
| # 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) | |
| if re.search(rf"plugin versions available: .*{mpi_option}", output) is None: | |
| pytest.skip( | |
| f"This test needs to be able to use --mpi={mpi_option}", | |
| allow_module_level=True, | |
| ) | |
| 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_parameter( | |
| parameter_name, | |
| parameter_value, | |
| condition=None, | |
| 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 (string): The target parameter value. | |
| condition (callable): If there is a range of acceptable values, a | |
| condition can be specified to test whether the current parameter | |
| value is sufficient. If not, the target parameter_value will be | |
| used (or the test will be skipped in the case of local-config mode). | |
| 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, lambda v: v <= 5) | |
| >>> 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"}}) | |
| """ | |
| if isinstance(parameter_value, dict): | |
| 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 | |
| elif isinstance(parameter_value, str): | |
| parameter_value = parameter_value.casefold() | |
| observed_value = get_config_parameter( | |
| parameter_name, live=False, source=source, quiet=True, delimiter=delimiter | |
| ) | |
| condition_satisfied = False | |
| if condition is None: | |
| # condition = lambda observed, desired: observed == desired | |
| if observed_value == parameter_value: | |
| condition_satisfied = True | |
| else: | |
| if condition(observed_value): | |
| condition_satisfied = True | |
| if not condition_satisfied: | |
| 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) | |
| 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 (string): The value we want to be in the list. | |
| source (string): Name of the config file without the .conf prefix. | |
| Returns: | |
| None | |
| Example: | |
| >>> require_config_parameter_includes('SlurmdParameters', 'config_overrides') | |
| """ | |
| if properties["auto-config"]: | |
| add_config_parameter_value(name, value, source=source) | |
| else: | |
| if not config_parameter_includes( | |
| name, value, source=source, live=False, quiet=True | |
| ): | |
| pytest.skip( | |
| f"This test requires the {name} parameter to include {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: | |
| backup_accounting_database() | |
| 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: | |
| port = get_open_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_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, | |
| fatal=False, | |
| quiet=False, | |
| ): | |
| """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. | |
| fatal (boolean): If True, a timeout will result in the test failing. | |
| 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_jobs([1234, 5678], timeout=60, fatal=True) | |
| True | |
| >>> cancel_jobs([9876, 5432], timeout=30, fatal=False) | |
| False | |
| """ | |
| # 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) | |
| for job_id in job_list: | |
| status = wait_for_job_state( | |
| job_id, | |
| "DONE", | |
| timeout=timeout, | |
| poll_interval=poll_interval, | |
| fatal=fatal, | |
| quiet=quiet, | |
| ) | |
| if not status: | |
| if fatal: | |
| pytest.fail( | |
| f"Job ({job_id}) was not cancelled within the {timeout} second timeout" | |
| ) | |
| return status | |
| return True | |
| def cancel_all_jobs( | |
| timeout=default_polling_timeout, poll_interval=0.1, fatal=False, quiet=False | |
| ): | |
| """Cancels all jobs by the test user 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 | |
| """ | |
| user_name = get_user_name() | |
| run_command(f"scancel -u {user_name}", fatal=fatal, quiet=quiet) | |
| return repeat_command_until( | |
| f"squeue -u {user_name} --noheader", | |
| lambda results: results["stdout"] == "", | |
| timeout=timeout, | |
| poll_interval=poll_interval, | |
| fatal=fatal, | |
| quiet=quiet, | |
| ) | |
| 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 | |
| backup_config_file("slurm") | |
| 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 | |
| # 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. | |
| Example: | |
| >>> submit_job_sbatch('--wrap "echo Hello"') | |
| 1234 | |
| >>> submit_job_sbatch('-J myjob --wrap "echo World"', fatal=True) | |
| 5678 | |
| """ | |
| 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 from srun. | |
| 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 | |
| """ | |
| results = run_job(" ".join(["-v", srun_args]), **run_command_kwargs) | |
| if match := re.search(r"jobid (\d+)", results["stderr"]): | |
| return int(match.group(1)) | |
| else: | |
| return 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. | |
| 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 | |
| """ | |
| results = run_command(f"salloc {salloc_args}", **run_command_kwargs) | |
| if match := re.search(r"Granted job allocation (\d+)", results["stderr"]): | |
| job_id = int(match.group(1)) | |
| properties["submitted-jobs"].append(job_id) | |
| return job_id | |
| else: | |
| return 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, **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: | |
| 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 fatal | |
| 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(step_id=None, **run_command_kwargs): | |
| """Returns the steps as a dictionary of dictionaries. | |
| Args: | |
| step_id (string): The specific step ID to retrieve information for. If | |
| not provided, information for all steps will be returned. | |
| 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.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', ...}, | |
| '45.batch': {'StepId': '45.batch', 'UserId': 1002, 'StartTime': '2024-04-05T01:17:53', ...}, | |
| '45.0': {'StepId': 45.0, 'UserId': 1002, 'StartTime': '2024-04-05T01:17:54', ...}} | |
| >>> get_steps(step_id='123.0', quiet=True) | |
| {'123.0': {'StepId': 123.0, 'UserId': 1002, 'StartTime': '2024-04-05T01:21:14', ...}} | |
| """ | |
| steps_dict = {} | |
| step_dict = {} | |
| command = "scontrol -d -o show steps" | |
| if step_id is not None: | |
| command += f" {step_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, quiet=False): | |
| """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. | |
| quiet (boolean): If True, logging is performed at the TRACE log level. | |
| 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(quiet=quiet) | |
| 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 | |
| def wait_for_node_state_any( | |
| nodename, | |
| desired_node_states, | |
| timeout=default_polling_timeout, | |
| poll_interval=None, | |
| fatal=False, | |
| reverse=False, | |
| ): | |
| """Wait for any of the specified node states to be reached. | |
| Polls the node state every poll interval seconds, waiting up to the timeout | |
| for the specified node state to be reached. | |
| Args: | |
| nodename (string): The name of the node whose state is being monitored. | |
| desired_node_states (iterable): The states that the node is expected to reach. | |
| timeout (integer): The number of seconds to wait before timing out. | |
| poll_interval (float): Number of seconds between node state polls. | |
| fatal (boolean): If True, a timeout will cause the test to fail. | |
| reverse (boolean): If True, wait for the node to lose the desired state. | |
| Returns: | |
| Boolean value indicating whether the node ever reached the desired state. | |
| Example: | |
| >>> wait_for_node_state_any('node1', ['IDLE', 'ALLOCATED'], timeout=60, poll_interval=5) | |
| True | |
| >>> wait_for_node_state_any('node2', ['DOWN'], timeout=30, fatal=True) | |
| False | |
| """ | |
| state_set = frozenset(desired_node_states) | |
| def any_overlap(state): | |
| return bool(state_set & set(state)) != reverse | |
| # Wrapper for the repeat_until command to do all our state checking for us | |
| repeat_until( | |
| lambda: get_node_parameter(nodename, "state"), | |
| any_overlap, | |
| timeout=timeout, | |
| poll_interval=poll_interval, | |
| fatal=fatal, | |
| ) | |
| return any_overlap(get_node_parameter(nodename, "state")) | |
| def wait_for_node_state( | |
| nodename, | |
| desired_node_state, | |
| timeout=default_polling_timeout, | |
| poll_interval=None, | |
| fatal=False, | |
| reverse=False, | |
| ): | |
| """Wait for a specified node state to be reached. | |
| Polls the node state every poll interval seconds, waiting up to the timeout | |
| for the specified node state to be reached. | |
| Args: | |
| nodename (string): The name of the node whose state is being monitored. | |
| desired_node_state (string): The state that the node is expected to reach. | |
| timeout (integer): The number of seconds to wait before timing out. | |
| poll_interval (float): Number of seconds between node state polls. | |
| fatal (boolean): If True, a timeout will cause the test to fail. | |
| reverse (boolean): If True, wait for the node to lose the desired state. | |
| Returns: | |
| Boolean value indicating whether the node ever reached the desired state. | |
| Example: | |
| >>> wait_for_node_state('node1', 'IDLE', timeout=60, poll_interval=5) | |
| True | |
| >>> wait_for_node_state('node2', 'DOWN', timeout=30, fatal=True) | |
| False | |
| """ | |
| # Figure out if we're waiting for the desired_node_state to be present or to be gone | |
| def has_state(state): | |
| return desired_node_state in state | |
| def not_state(state): | |
| return desired_node_state not in state | |
| # Wrapper for the repeat_until command to do all our state checking for us | |
| repeat_until( | |
| lambda: get_node_parameter(nodename, "state"), | |
| not_state if reverse else has_state, | |
| timeout=timeout, | |
| poll_interval=poll_interval, | |
| fatal=fatal, | |
| ) | |
| return (desired_node_state in get_node_parameter(nodename, "state")) != reverse | |
| 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, **repeat_until_kwargs): | |
| """Wait for specified job to appear in accounting database (`sacct`). | |
| Continuously polls the database until the job is accounted for or until a | |
| timeout occurs. | |
| Args: | |
| job_id (integer): The id of the job. | |
| 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, timeout=30) | |
| False | |
| """ | |
| return repeat_until( | |
| lambda: run_command_output(f"sacct -Xj {job_id} -o JobID"), | |
| lambda out: re.search(rf"{job_id}", out) is not None, | |
| **repeat_until_kwargs, | |
| ) | |
| def wait_for_job_state( | |
| job_id, | |
| desired_job_state, | |
| desired_reason=None, | |
| timeout=default_polling_timeout, | |
| poll_interval=None, | |
| fatal=False, | |
| quiet=False, | |
| xfail=False, | |
| ): | |
| """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. | |
| fatal (boolean): If True, a timeout will cause the test to fail. | |
| quiet (boolean): If True, logging is performed at the TRACE log level. | |
| xfail (boolean): If True, state (or reason) are not expected to be reached. | |
| 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 | |
| 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 | |
| ) | |
| message = f"Job ({job_id}) is in state {job_state}, but we are waiting for {desired_job_state}" | |
| if job_state in [ | |
| "NOT_FOUND", | |
| "BOOT_FAIL", | |
| "CANCELLED", | |
| "COMPLETED", | |
| "DEADLINE", | |
| "FAILED", | |
| "NODE_FAIL", | |
| "OUT_OF_MEMORY", | |
| "TIMEOUT", | |
| "PREEMPTED", | |
| ]: | |
| 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 | |
| ) | |
| 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 | |
| ) | |
| 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, job_output, expected_delayed): | |
| """Check the output file of a job for expected delayed steps. | |
| This function checks that the output file for a job contains the expected | |
| pattern of delayed job steps. Note that at the time of writing, this | |
| requires srun steps to have at least a verbosity level of "-vv" to log | |
| their f"srun: Step completed in JobId={job_id}, retrying" notification. | |
| Args: | |
| job_id (integer): The job ID that we're interested in. | |
| job_output (string): The content of the output file of the job. | |
| expected_delayed (integer): The initial number of delayed job steps. It | |
| is verified that this initial number of job steps are delayed and | |
| then this number of delayed job steps decrements one by one as | |
| running job steps finish. | |
| Returns: | |
| True if steps were delayed in the correct amounts and order, else False. | |
| Example: | |
| >>> check_steps_delayed(123, "srun: Received task exit notification for 1 task of StepId=1.0 (status=0x0000).\nsrun: node1: task 0: Completed\nsrun: debug: task 0 done\nsrun: Step completed in JobId=1, retrying\nsrun: Step completed in JobId=1, retrying\nsrun: Step created for StepId=1.1\nsrun: Received task exit notification for 1 task of StepId=1.1 (status=0x0000).\nsrun: node2: task 1: Completed\nsrun: debug: task 1 done\nsrun: debug: IO thread exiting\nsrun: Step completed in JobId=1, retrying\nsrun: Step created for StepId=1.2", 2) | |
| True | |
| >>> check_steps_delayed(456, "srun: Received task exit notification for 1 task of StepId=1.0 (status=0x0000).\nsrun: node1: task 0: Completed\nsrun: debug: task 0 done\nsrun: Step completed in JobId=1, retrying\nsrun: Step completed in JobId=1, retrying\nsrun: Step created for StepId=1.1\nsrun: Received task exit notification for 1 task of StepId=1.1 (status=0x0000).\nsrun: node2: task 1: Completed\nsrun: debug: task 1 done\nsrun: debug: IO thread exiting", 2) | |
| False | |
| """ | |
| # Iterate through each group of expected delayed steps. For example, | |
| # if there was a job that had 5 steps that could run in parallel but, due to | |
| # resource constrains, only allowed 3 steps to run at a time, we would | |
| # expect a group of 2 delayed job steps followed by a group of 1 delayed job | |
| # steps. For this example job, expected_delayed=2. | |
| # | |
| # The idea of the for loop below is to iterate through each group of delayed | |
| # job steps and replace the expected output as we go with re.sub. This | |
| # ensures that the delayed job step groups occur in the correct order. | |
| # | |
| # Each regex pattern matches part of the pattern we'd expect to see in the | |
| # output, replaces the matched text (see previous paragraph), and then makes | |
| # sure there is still text left to match for the rest of the pattern. If the | |
| # regex pattern doesn't match anything, then re.sub will match and replace | |
| # all the rest of the output and leave job_output empty. | |
| for delayed_grp_size in range(expected_delayed, 0, -1): | |
| # Match all lines before receiving an exit notification. This regex | |
| # pattern will match any line that doesn't contain "srun: Received task | |
| # exit notification". | |
| before_start_pattern = r"(^((?!srun: Received task exit notification).)*$\n)*" | |
| job_output = re.sub(before_start_pattern, "", job_output, 1, re.MULTILINE) | |
| if not job_output: | |
| logging.error(f"Pattern not found: {before_start_pattern}") | |
| return False | |
| # Match receiving the next exit notification. This regex pattern will | |
| # match the line where the exit notification is received when a step | |
| # that was already running finishes. | |
| exit_pattern = rf"srun: Received task exit notification for \[0-9]+ task of StepId={job_id}\.[0-9]+ \(status=0x[0-9A-Fa-f]+\)\.\n" | |
| job_output = re.sub(exit_pattern, "", job_output, 1, re.MULTILINE) | |
| if not job_output: | |
| logging.error(f"Pattern not found: {exit_pattern}") | |
| return False | |
| # Match lines we don't want before a step completion. After the exit | |
| # notification, we now match all lines that don't contain "srun: Step | |
| # completed". Sometimes an exit notification can be received multiple | |
| # times and any redundant exit notifications are also matched by this | |
| # pattern. | |
| before_completed_pattern = r"(^((?!srun: Step completed).)*$\n)*" | |
| job_output = re.sub(before_completed_pattern, "", job_output, 1, re.MULTILINE) | |
| if not job_output: | |
| logging.error(f"Pattern not found: {before_completed_pattern}") | |
| return False | |
| # Match number of lines retrying to start a delayed job step. Note that | |
| # this pattern searched for steps retrying "delayed_grp_size" number of | |
| # times. This is because every step that is delayed retries every time a | |
| # previously running step finishes. | |
| completed_pattern = rf"(srun: Step completed in JobId={job_id}, retrying\n){{{delayed_grp_size}}}" | |
| job_output = re.sub(completed_pattern, "", job_output, 1, re.MULTILINE) | |
| if not job_output: | |
| logging.error(f"Pattern not found: {completed_pattern}") | |
| return False | |
| # Match lines we don't want before a step creation. Due to steps running | |
| # in parallel, other lines of text can be output from already running | |
| # steps before we're told a new step has been created. This regex | |
| # pattern matches all lines that don't contain "srun: Step created". | |
| before_created_pattern = r"(^((?!srun: Step created).)*$\n)*" | |
| job_output = re.sub(before_created_pattern, "", job_output, 1, re.MULTILINE) | |
| if not job_output: | |
| logging.error(f"Pattern not found: {before_created_pattern}") | |
| return False | |
| # Match the step creation line for the delayed step | |
| created_pattern = rf"srun: Step created for StepId={job_id}\.[0-9]+" | |
| job_output = re.sub(created_pattern, "", job_output, 1, re.MULTILINE) | |
| if not job_output: | |
| logging.error(f"Pattern not found: {created_pattern}") | |
| 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 | |
| backup_config_file("slurm") | |
| 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)]) | |
| """ | |
| # If using local-config and slurm is running, use live node information | |
| # so that a test is not incorrectly skipped when slurm derives a non-single | |
| # CPUTot while the slurm.conf does not contain a CPUs property. | |
| if not properties["auto-config"] and is_slurmctld_running(quiet=True): | |
| live = True | |
| else: | |
| live = False | |
| # This should return separate nodes and a DEFAULT (unless live) | |
| nodes_dict = get_nodes(live=live, 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": | |
| # Translate CPUTot to CPUs for screening qualifying nodes | |
| if parameter_name.lower() == "cputot": | |
| parameter_name = "CPUs" | |
| 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(",")) | |
| node_features = set(lower_node_dict.get("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) | |
| os.chmod(script_name, 0o0700) | |
| 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 | |
| ) | |
| # Assuming this will only be called internally after validating accounting is configured and auto-config is set | |
| def backup_accounting_database(): | |
| """Backs up the accounting database. | |
| This function may only be used in auto-config mode. The database dump is | |
| automatically restored when the test ends. | |
| Args: | |
| None | |
| Returns: | |
| None | |
| Example: | |
| >>> backup_accounting_database() # Backs up Slurm accounting database to file in the test's temporary directory. | |
| """ | |
| if not properties["auto-config"]: | |
| return | |
| mysqldump_path = shutil.which("mysqldump") | |
| if mysqldump_path is None: | |
| pytest.fail( | |
| "Unable to backup the accounting database. mysqldump was not found in your path" | |
| ) | |
| mysql_path = shutil.which("mysql") | |
| if mysql_path is None: | |
| pytest.fail( | |
| "Unable to backup the accounting database. mysql was not found in your path" | |
| ) | |
| sql_dump_file = f"{str(module_tmp_path / '../../slurm_acct_db.sql')}" | |
| # If a dump already exists, issue a warning and return (honor existing dump) | |
| if os.path.isfile(sql_dump_file): | |
| logging.warning(f"Dump file already exists ({sql_dump_file})") | |
| 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} > {sql_dump_file}" | |
| ) | |
| run_command( | |
| mysqldump_command, fatal=True, quiet=False, timeout=default_sql_cmd_timeout | |
| ) | |
| def restore_accounting_database(): | |
| """Restores the accounting database from the backup. | |
| This function may only be used in auto-config mode. | |
| Args: | |
| None | |
| Returns: | |
| None | |
| Example: | |
| >>> restore_accounting_database() # Restores Slurm accounting database from previously created 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, | |
| ) | |
| sql_dump_file = f"{str(module_tmp_path / '../../slurm_acct_db.sql')}" | |
| # 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(sql_dump_file): | |
| logging.debug( | |
| f"Slurm accounting database backup ({sql_dump_file}) is s not present. It has probably already been restored." | |
| ) | |
| return | |
| dump_stat = os.stat(sql_dump_file) | |
| 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"{base_command} {database_name} < {sql_dump_file}", | |
| fatal=True, | |
| quiet=False, | |
| timeout=default_sql_cmd_timeout, | |
| ) | |
| # In either case, remove the dump file | |
| run_command(f"rm -f {sql_dump_file}", 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" | |
| compile_against_libslurm( | |
| f"{properties['testsuite_check_dir']}/{source_file}", | |
| check_test, | |
| full=True, | |
| build_args=build_args + "-lcheck -lm -lsubunit", | |
| fatal=True, | |
| quiet=True, | |
| ) | |
| # Run the libcheck test setting an xml output | |
| result = run_command( | |
| check_test, quiet=True, env_vars=f"CK_XML_LOG_FILE_NAME={xml_test}" | |
| ) | |
| # 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, | |
| **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") | |
| """ | |
| if full: | |
| slurm_library = "slurmfull" | |
| else: | |
| slurm_library = "slurm" | |
| if os.path.isfile( | |
| f"{properties['slurm-prefix']}/lib64/slurm/lib{slurm_library}.so" | |
| ): | |
| lib_dir = "lib64" | |
| else: | |
| lib_dir = "lib" | |
| if full: | |
| lib_path = f"{properties['slurm-prefix']}/{lib_dir}/slurm" | |
| else: | |
| lib_path = f"{properties['slurm-prefix']}/{lib_dir}" | |
| command = f"gcc {source_file} -g -pthread" | |
| if shared: | |
| command += " -fPIC -shared" | |
| command += f" -o {dest_file}" | |
| command += f" -I{properties['slurm-source-dir']} -I{properties['slurm-build-dir']} -I{properties['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 | |
| backup_config_file("slurm") | |
| 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 | |
| # 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_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 autogenerated sample found in BUILDDIR/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"] = properties["influxdb_host"] | |
| if "influxdb_port" in testsuite_config: | |
| properties["influxdb_host"] = properties["influxdb_port"] | |
| if "influxdb_db" in testsuite_config: | |
| properties["influxdb_db"] = properties["influxdb_db"] | |
| # 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 as test-user. 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["test-user"] = pwd.getpwuid(os.getuid()).pw_name | |
| 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 |