diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..bad44636b34555264f9823e84b29108d3b192b2d --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.vscode +**/*.pyc +logs +dev-23a/ \ No newline at end of file diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000000000000000000000000000000000000..99c03e1bba7b4d16c4ba5579549413e668be1908 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,52 @@ +image: python:latest + +stages: + - test-style + - test + +before_script: + - cat /etc/issue + - python -V + - which python + - python -m venv ../venv + - source ../venv/bin/activate + - which python + - pwd + - pip install -U pip + - pip --version + - pip install pytest black ruff + - pytest --version + + +style: + stage: test-style + image: python:latest + script: + - black --version + - ruff --version + - ruff . || true + - black --check --diff . || true + + +test3-09: + stage: test + image: python:3.9 + script: + - echo "run tests here later" + - python --version + +test3-10: + stage: test + image: python:3.10 + script: + - echo "run tests here later" + - python --version + + +test3-11: + stage: test + image: python:3.11 + script: + - echo "run tests here later" + - python --version + diff --git a/mpsd-software-environment.py b/mpsd-software-environment.py new file mode 100755 index 0000000000000000000000000000000000000000..efdd2c47fab1192fe5f0fd6810419c353e6e75da --- /dev/null +++ b/mpsd-software-environment.py @@ -0,0 +1,384 @@ +#!/usr/bin/env python3 +import os +import subprocess +import datetime +import argparse +import sys +from pathlib import Path +from typing import List, Tuple +import datetime + +about_tool = """ +Build toolchains using Spack.\n + +This function builds toolchains for MPSD-HPC at the appropriate directory, \n +for given system architecture and MPSD software stack version.\n +The toolchains are built using the bash script spack_setup.sh, and the results are logged. +""" +config_vars = { + "cmd_log_file": "install.log", + "build_log_file": f"logs/mpsd_spack_ver_toolchains_{datetime.datetime.now().replace(microsecond=0).isoformat()}.log", # TODO: modify toolchains,mpsd_spack_ver when the variable is available + "spack_environments_repo": "https://gitlab.gwdg.de/mpsd-cs/spack-environments.git", +} + + +# Helper class to change directory via context manager +class os_chdir: + """ + The os_chdir class is a context manager that + changes the current directory to a specified directory + and returns to the original directory after execution. + """ + + def __init__(self, new_dir): + self.new_dir = new_dir + self.saved_dir = os.getcwd() + + def __enter__(self): + os.chdir(self.new_dir) + + def __exit__(self, exc_type, exc_val, exc_tb): + os.chdir(self.saved_dir) + + +def setup_log_cmd( + mpsd_release: str, script_dir: str, msg: str = None, *args, **kwargs +) -> None: + """ + The setup_log_cmd function logs the command used to build the toolchains, + along with information about the software environment installer branch, the Spack environments branch, + and the commit hashes of each. It also logs steps taken in install process using the optional message argument. + + Args: + mpsd_release (str): The name of the release to install toolchains for. + script_dir (str): The path to the directory where the scripts are located. + msg (str, optional): An optional message to log in the command log file. + + Returns: + None + """ + release_base_dir = script_dir / mpsd_release + + with os_chdir(release_base_dir): + # Write to the log file with the following format + # -------------------------------------------------- + # 2023-02-29T23:32:01, install-software-environment.py --release dev-23a --install ALL + # Software environment installer branch: script_branch (commit hash: script_commit_hash) + # Spack environments branch: dev-23a (commit hash: spe_commit_hash) + # MSGs + with open(config_vars["cmd_log_file"], "a") as f: + if msg: + # Write the message to the log file + f.write(msg + "\n") + else: + # Write the header + f.write("-" * 50 + "\n") + + # Gather data to log + # call statement: + cmd_line = " ".join(sys.argv) + # script branch and commit hash + with os_chdir(script_dir): + script_branch = ( + subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + stdout=subprocess.PIPE, + ) + .stdout.decode() + .strip() + ) + script_commit_hash = ( + subprocess.run( + ["git", "rev-parse", "--short", "HEAD"], + stdout=subprocess.PIPE, + ) + .stdout.decode() + .strip() + ) + # spack-environments branch and commit hash from kwargs + spe_branch = kwargs.get("spe_branch", None) + spe_commit_hash = kwargs.get("spe_commit_hash", None) + + # Write to log file + f.write(f"{datetime.datetime.now().isoformat()}, {cmd_line}\n") + f.write( + f"Software environment installer branch: {script_branch} (commit hash: {script_commit_hash})\n" + ) + f.write( + f"Spack environments branch: {spe_branch} (commit hash: {spe_commit_hash})\n" + ) + + +def create_dir_structure(mpsd_release: str, script_dir: Path) -> None: + """ + The create_dir_structure function creates the directory structure for the specified release + and clones the Spack environments repository if it doesn't exist. + + Args: + - mpsd_release: A string representing the MPSD release version. + - script_dir: A Path object representing the path to the scripts directory. + + Returns: + - None + """ + # Create the directory structure for the release + release_base_dir = script_dir / mpsd_release + release_base_dir.mkdir(parents=True, exist_ok=True) + + with os_chdir(release_base_dir): + # Clone the spack-environments repo if it doesn't exist + if not os.path.exists("spack-environments"): + subprocess.run( + [ + "git", + "clone", + config_vars["spack_environments_repo"], + ] + ) + with os_chdir("spack-environments"): + # Git fetch and checkout the release branch and git pull to be sure that the resulting repo is up to date + subprocess.run(["git", "fetch", "--all"]) + checkout_result = subprocess.run(["git", "checkout", mpsd_release]) + if checkout_result.returncode != 0: + raise Exception( + "Release branch does not exist in spack-environment repo \n. Check for typos." + ) + subprocess.run(["git", "pull"]) + + +def get_release_info(mpsd_release: str, script_dir: Path) -> Tuple[str, str, List[str]]: + """ + Get information about the specified release, such as the branch and commit hash + of the Spack environments repository and the available toolchains. + + Args: + - mpsd_release (str) : the name of the release to get information for. + - script_dir (pathlib.Path): the base directory where releases are stored. + + Returns: + - spe_branch: str, the name of the branch for the Spack environments repository. + - spe_commit_hash: str, the commit hash for the Spack environments repository. + - available_toolchains: list, a list of strings representing the available toolchains for the release. + + Raises: + - Exception: If the release directory does not exist. Run `create_dir_structure()` first. + """ + # Get the info for release + release_base_dir = script_dir / mpsd_release + if not os.path.exists(release_base_dir): + raise Exception( + "Release directory does not exist. Run create_dir_structure() first." + ) + with os_chdir(release_base_dir): + with os_chdir("spack-environments"): + # Get the branch and commit hash of the spack-environments repo + spe_commit_hash = ( + subprocess.run(["git", "rev-parse", "HEAD"], stdout=subprocess.PIPE) + .stdout.decode() + .strip() + ) + spe_branch = ( + subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], stdout=subprocess.PIPE + ) + .stdout.decode() + .strip() + ) + available_toolchains = os.listdir("toolchains") + return spe_branch, spe_commit_hash, available_toolchains + + +def prepare_environment(mpsd_release: str, script_dir: Path) -> List[str]: + """ + - Creates the directory structure for the given MPSD release and clones the spack-environments repository. + - Determines the branch and commit hash of the spack-environments repository and the available toolchains. + - Logs the command usage. + + Args: + - mpsd_release (str): The name of the MPSD release to prepare the environment for. + - script_dir (pathlib.Path): The base directory to create the release folder and clone the spack-environments repository into. + + Returns: + - available_toolchains (list): A list of available toolchains for the given MPSD release. + """ + create_dir_structure(mpsd_release, script_dir) + spe_branch, spe_commit_hash, available_toolchains = get_release_info( + mpsd_release, script_dir + ) + setup_log_cmd( + mpsd_release, script_dir, spe_branch=spe_branch, spe_commit_hash=spe_commit_hash + ) + return available_toolchains + + +def install_environment( + mpsd_release: str, + toolchains: List[str], + script_dir: Path, + force_reinstall: bool = False, + enable_build_cache: bool = False, +) -> None: + """ + Installs the specified MPSD release and toolchains to the specified directory using Spack. + + Args: + mpsd_release: A string representing the MPSD release version. + toolchains: A list of strings representing the toolchains to install (e.g., "foss2021a-mpi", "global_generic", "ALL"). + script_dir: A Path object representing the path to the directory where the release and toolchains will be installed. + force_reinstall: A boolean indicating whether to force a reinstallation even if the release and toolchains already exist. Defaults to False. + enable_build_cache: A boolean indicating whether to build the build cache when installing toolchains. Defaults to False. + + Raises: + Exception: If a requested toolchain is not available in the specified release. + + Returns: + None + """ + print( + f"Installing release {mpsd_release} with toolchains {toolchains} to {script_dir}" + ) + + # Set required variables + release_base_dir = script_dir / mpsd_release + os.environ.get("MPSD_OS", "UNKNOWN_OS") + mpsd_microarch = os.environ.get("MPSD_MICROARCH", "UNKNOWN_MICROARCH") + toolchain_dir = release_base_dir / mpsd_microarch + toolchain_dir.mkdir(parents=True, exist_ok=True) + spack_setup_script = release_base_dir / "spack-environments" / "spack_setup.sh" + install_flags = [] + if not enable_build_cache: + install_flags.append("-b") + + # run the prepare_environment function + available_toolchains = prepare_environment(mpsd_release, script_dir) + # Ensure that the requested toolchains are available in the release + if toolchains == "ALL": + toolchains = available_toolchains + elif toolchains == "NONE": + # No toolchains requested, so we only create the env and print the list of available toolchains + print( + f"No toolchains requested. Available toolchains for release {mpsd_release} are: \n {available_toolchains}" + ) + return + + for toolchain in toolchains: + if toolchain not in available_toolchains: + raise Exception( + f"Toolchain '{toolchain}' is not available in release {mpsd_release}." + ) + + # Install the toolchains + with os_chdir(toolchain_dir): + # run spack_setup_script with the toolchains as arguments + # if the log folder doesn't exist, create it + if not os.path.exists("logs"): + os.mkdir("logs") + for toolchain in toolchains: + # Set the install log file name to config_vars["install_log_file"] + # and replace _toolchains_ with the toolchain name and _mpsd_spack_ver_ with mpsd_release + + print(f"Installing toolchain {toolchain} to {toolchain_dir}") + install_log_file = ( + config_vars["build_log_file"] + .replace("mpsd_spack_ver_", f"{mpsd_release}_") + .replace("_toolchains_", f"_{toolchain}_") + ) + # log the command + setup_log_cmd( + mpsd_release, + script_dir, + msg=f"installing {toolchain} and logging at {install_log_file}", + ) + setup_log_cmd( + mpsd_release, + script_dir, + msg=f"CMD: bash {spack_setup_script} {' '.join(install_flags)} {toolchain}", + ) + subprocess.run( + f"bash {spack_setup_script} {' '.join(install_flags)} {toolchain} 2>&1 | tee -a {install_log_file} ", + shell=True, + ) + + +def remove_environment(release, toolchains, target_dir): + print(f"Removing release {release} with toolchains {toolchains} from {target_dir}") + + +def start_new_environment(release, from_release, target_dir): + print(f"Starting new release {release} from {from_release} to {target_dir}") + + +def main(): + parser = argparse.ArgumentParser(description=about_tool) + subparsers = parser.add_subparsers( + dest="action", title="actions", description="valid actions", required=True + ) + subparsers.required = True + list_of_cmds = [ + ("prepare", "Prepare the environment for installation on the disk"), + ("install", "Install a software environment"), + ("reinstall", "Reinstall a software environment"), + ("remove", "Remove a software environment or toolchains from an environment"), + ("start-new", "Start a new software environment version"), + ] + for cmd, help_text in list_of_cmds: + subp = subparsers.add_parser(cmd, help=help_text) + + if cmd == "start-new": + subp.add_argument( + "--from-release", + dest="from_release", + type=str, + required=True, + help="Release version to start from", + ) + subp.add_argument( + "--to-release", + dest="to_release", + type=str, + required=True, + help="Release version to create", + ) + + else: + subp.add_argument( + "release", type=str, help="Release version to install or remove" + ) + if cmd in ["install", "reinstall"]: + subp.add_argument( + "--toolchains", # first option defines attribute + # name `args.toolchains` in `args = parser_args()` + "--toolchain", # allow singular as alternative + # (-> creates attribute `args.toolchains` if used) + type=str, + dest="toolchains", + nargs="+", + default="NONE", + help="List of toolchains to install (use '--toolchains ALL' to install all toolchains). If nothing is specified, list of available toolchains for the release would be shown after creating the environment.", + ) + subp.add_argument( + "--enable-build-cache", + action="store_true", + help="Enable Spack build cache. Useful for reinstallation but consumes time and disk space", + ) + # Carry out the action + args = parser.parse_args() + + # target dir is the place where this script exists. the release `dev` in script_dir/dev-23a + script_dir = Path(os.path.dirname(os.path.realpath(__file__))) + + # Check the command and run related function + if args.action == "remove": + remove_environment(args.release, args.toolchains, script_dir) + elif args.action == "start-new": + start_new_environment(args.from_release, args.to_release, script_dir) + elif args.action == "install": + install_environment( + args.release, args.toolchains, script_dir, False, args.enable_build_cache + ) + elif args.action == "prepare": + prepare_environment(args.release, script_dir) + + +if __name__ == "__main__": + main() diff --git a/tests.py b/tests.py new file mode 100644 index 0000000000000000000000000000000000000000..31d578fed2b66416f112a0a6fe0b59985e027b35 --- /dev/null +++ b/tests.py @@ -0,0 +1,213 @@ +import os +import pytest +import importlib +import subprocess +import shutil +from pathlib import Path + +mod = importlib.import_module("mpsd-software-environment") + + +def test_os_chdir(tmp_path): + # create a temporary directory for testing + temp_dir = tmp_path / "test_os_chdir" + temp_dir.mkdir() + + # initial current working directory + initial_cwd = os.getcwd() + + # change to the temporary directory using os_chdir + with mod.os_chdir(str(temp_dir)): + assert os.getcwd() == str(temp_dir) + + # current working directory should be back to initial directory + assert os.getcwd() == initial_cwd + + +def test_prepare_environment(tmp_path): + # simulate running ./install-software-environment.py --release dev-23a --target-directory /tmp/test_prepare_env + # prepare_env is run when cmd is not specified, we can test cmd='prepare' and cmd=None to check both cases + + script_dir = tmp_path / "test_prepare_env" + spack_environments = "spack-environments" + mpsd_release_to_test = "dev-23a" + release_base_dir = script_dir / mpsd_release_to_test + # check that the test directory does not exist + assert not script_dir.exists() + + result = mod.prepare_environment( + mpsd_release=mpsd_release_to_test, script_dir=(script_dir) + ) + # wait for 20 seconds for the git clone to finish + # time.sleep(20) + # check if the directory now is created + assert release_base_dir.exists() + # check for spack-environments directory + assert spack_environments in os.listdir(release_base_dir) + # check if the git branch is correctly checked out + assert ( + subprocess.run( + f"cd {str(release_base_dir/spack_environments)} && git branch", + shell=True, + capture_output=True, + ) + .stdout.decode("utf-8") + .split("\n")[0] + == f"* {mpsd_release_to_test}" + ) + # check that result is a list and contains atleast ['global','foss2021a-mpi'] + assert isinstance(result, list) + assert "global" in result + assert "foss2021a-mpi" in result + + # Expect an Exception when wrong mpsd_release is provided + with pytest.raises(Exception): + result = mod.prepare_environment( + mpsd_release="wrong-mpsd-release", script_dir=(script_dir) + ) + + +def test_setup_log_cmd(tmp_path): + # check that logs/install-software-environment.log is updated when the module is run + log_file = "install.log" + + script_dir = tmp_path / "test_prepare_env" + mpsd_release_to_test = "dev-23a" + release_base_dir = script_dir / mpsd_release_to_test + if os.path.exists(release_base_dir / log_file): + initial_bytes = os.path.getsize(log_file) + else: + initial_bytes = 0 + + # run the prepare_env functionality + mod.prepare_environment(mpsd_release=mpsd_release_to_test, script_dir=(script_dir)) + + # check that logs/install-software-environment.log is updated + assert os.path.exists(release_base_dir / log_file) + assert os.path.getsize(release_base_dir / log_file) > initial_bytes + + # Check that the log file has "Spack environments branch: dev-23a " in the last line + with open(release_base_dir / log_file, "r") as f: + last_line = f.readlines()[-1] + assert "Spack environments branch: dev-23a " in last_line + + +def test_install_environment(tmp_path): + # Test the installation part + # This is a long test, its handy to test this with print statements printed to stdout, use: + # pytest -s + # Expect an Exception when wrong toolchains are provided + with pytest.raises(Exception): + mod.install_environment( + mpsd_release="dev-23a", + toolchains=["wrong-toolchain"], + script_dir=(tmp_path), + ) + # Expect an Exception when wrong mpsd_release is provided ( part of prepare_environment) + with pytest.raises(Exception): + mod.install_environment( + mpsd_release="wrong-mpsd-release", + toolchains=["foss2021a-mpi"], + script_dir=(tmp_path), + ) + # prepare a test of global generic with only zlib to test the installation + # prepare dev-23a release + # script_dir = tmp_path / "test_global_generic" + # for actaual installation avoid tmp_path as the lenght of the path is too long and spack complains + script_dir = Path("/tmp/test_global_generic") + if script_dir.exists(): + shutil.rmtree(script_dir) + script_dir.mkdir(exist_ok=True, parents=True) + mpsd_release_to_test = "dev-23a" + toolchain_to_test = "global_generic" + mpsd_microarch = os.getenv("MPSD_MICROARCH", "UNKNOWN_MICROARCH") + release_base_dir = script_dir / mpsd_release_to_test + mod.prepare_environment(mpsd_release=mpsd_release_to_test, script_dir=(script_dir)) + # Patch the spack environments to create a fake global_generic + # create a test toolchain + toolchain_src_dir = release_base_dir / "spack-environments" / "toolchains" + # with mod.os_chdir(toolchain_src_dir): + # subprocess.run( + # "cp -r foss2021a-mpi fuss1999a", shell=True, capture_output=True + # ) + # add zlib as a spec to global_generic + with open(toolchain_src_dir / "global_generic" / "global_packages.list", "w") as f: + f.write("zlib@1.2.13 \n") + + # add zlib to whitelist of module creation file by replacing anaconda3%gcc@10.2.1 with zlib@1.2.13 + # in release_base_dir / "spack-environments/spack_overlay/etc/spack/modules.yaml" + module_file = ( + release_base_dir / "spack-environments/spack_overlay/etc/spack/modules.yaml" + ) + with open(module_file, "r") as f: + lines = f.read().replace("anaconda3%gcc@10.2.1", "zlib@1.2.13") + with open(module_file, "w") as f: + f.write(lines) + + # Replace gcc@10.2.1 with gcc#13.1.1 or available system gcc for testing on laptop + gcc_ver = ( + subprocess.run(["gcc -dumpfullversion"], shell=True, capture_output=True) + .stdout.decode("utf-8") + .strip() + ) + setup_file = release_base_dir / "spack-environments/spack_setup.sh" + with open(setup_file, "r") as f: + lines = f.read().replace( + 'system_compiler="gcc@10.2.1"', f'system_compiler="gcc@{gcc_ver}"' + ) + with open(setup_file, "w") as f: + f.write(lines) + # install global_generic toolchain + mod.install_environment( + mpsd_release=mpsd_release_to_test, + toolchains=[toolchain_to_test], + script_dir=script_dir, + enable_build_cache=False, + ) + # test that the build log is created correctly + # check that a file with glob build_globale_generic_dev-23a*.log exists at release_base_dir/mpsd_microarch + # print("Debug here ") + # time.sleep(10) + build_log = list( + (release_base_dir / mpsd_microarch / "logs").glob( + f"{mpsd_release_to_test}_{toolchain_to_test}_*.log" + ) + ) + assert len(build_log) > 0 + # take the most recent build log + build_log = sorted(build_log)[0] + # check that the build log contains statement ##### Installation finished + with open(build_log, "r") as f: + lines = f.read() + assert "##### Installation finished" in lines + build_log_file_name = os.path.basename(build_log) + + # assert that install log files exists + assert os.path.exists(release_base_dir / "install.log") + + # assert that the build log is written to the install log file + os.path.basename(build_log) + with open(release_base_dir / "install.log", "r") as f: + lines = f.read() + assert ( + f"installing {toolchain_to_test} and logging at logs/{build_log_file_name}" + in lines + ) + # assert that the module files are created correctly + assert os.path.exists(release_base_dir / mpsd_microarch) + assert os.path.exists(release_base_dir / mpsd_microarch / "lmod") + # assert that lmod/module-index.yaml contains zlib + with open( + release_base_dir / mpsd_microarch / "lmod" / "module-index.yaml", "r" + ) as f: + lines = f.read() + assert "zlib" in lines + + +def test_interface(tmp_path): + pass + # ensure that installing without toolchains only passes the available toolchains + # check that the script branch and hash are correct when running the script + # check that the help message is printed when no arguments are provided + # check that the help message is printed when -h is provided + # check that the error messages are also logged to the log file