diff --git a/development.rst b/development.rst index f034c980cf4e54eb7b229063b89c4b24950fae5b..0c733bf1134a6be6d20f022406f6a4fa9ccf54dc 100644 --- a/development.rst +++ b/development.rst @@ -33,4 +33,6 @@ Here is a list of exit codes and what they mean: | 20 | Requested package set is not available | Use 'available' command to see list of available package_sets | | 30 | Current directory is already initialised | Check if you are in the right directory | | 40 | Current directory is not initialised | Check if you are in the right directory, if so use 'init' command to initialise | +| 50 | No package set is selected | Please specify package_sets to remove, or 'ALL' to remove all package_sets | +| 60 | User didnt confirm removing the release | Please input 'y' if you are sure about removing the entire release | +-----------+------------------------------------------+----------------------------------------------------------------------------------+ diff --git a/src/mpsd_software_manager/mpsd_software.py b/src/mpsd_software_manager/mpsd_software.py index 73b4f4d6d151eb531f866ffe424928a8aa0a53e4..b26344a4b570773dd2e997dba95dd06e70ca3847 100755 --- a/src/mpsd_software_manager/mpsd_software.py +++ b/src/mpsd_software_manager/mpsd_software.py @@ -5,18 +5,17 @@ import argparse import datetime +import importlib.metadata import logging import os +import re import subprocess import sys import tempfile import time +from functools import cache from pathlib import Path from typing import List, Tuple, Union -import re -import shutil -from functools import cache -import importlib.metadata __version__ = importlib.metadata.version(__package__ or __name__) @@ -1080,39 +1079,177 @@ def install_environment( def remove_environment(mpsd_release, root_dir, package_sets="NONE", force_remove=False): - """Remove release from installation.""" + """Remove release from installation. + + Handle 3 situations : + 1. remove does not specify what to remove + -> warn and exit + 2. remove all package_sets from release + -> remove release folder except logs + 3. remove specific package_sets from release + -> remove spack environments via spack commands + + Parameters + ---------- + mpsd_release : str + A string representing the MPSD release version. + root_dir : pathlib.Path + A Path object representing the path to the directory where + the release and package_sets will be installed. + package_sets : list of str + A list of strings representing the package_sets to remove + (e.g., "foss2021a-mpi", "global_generic", "ALL"). + force_remove : bool, optional + A boolean indicating whether to force remove the release. + If False, the user will be prompted to confirm the removal. + Defaults to False. + + Raises + ------ + ValueError + + """ msg = ( f"Removing release {mpsd_release}" f" with package_sets {package_sets} from {root_dir}" ) logging.warning(msg) + if package_sets == "NONE": logging.warning( - "Please specify package_sets to remove, or 'ALL' to remove all toolchains" + "Please specify package_sets to remove, or 'ALL' to remove all package_sets" ) - sys.exit(1) + sys.exit(50) + # 2nd case: remove the entire release for microarchitecture + dir_to_remove = root_dir / mpsd_release / get_native_microarchitecture() if "ALL" in package_sets: # we need to remove the entire release folder logging.warning( - f"Removing release {mpsd_release} from {root_dir}" - "do you want to continue? [y/n]" + f"Removing release {mpsd_release}" + f"from {root_dir} for {get_native_microarchitecture()}" ) - if force_remove or input().lower() == "y": - folders_to_remove = os.listdir(root_dir / mpsd_release) - # skip logs folder - if "logs" in folders_to_remove: - folders_to_remove.remove("logs") - for folder in folders_to_remove: - shutil.rmtree(root_dir / mpsd_release / folder) - sys.exit(0) + if not force_remove: + logging.warning("do you want to continue? [y/n]") + if input().lower() != "y": + sys.exit(60) + + # Set the remove log file name from create_log_file_names + build_log_path = get_log_file_path(mpsd_release, "remove", root_dir, "ALL") + + logging.info(f"> Logging removal of {mpsd_release} at {build_log_path}") + folders_to_remove = os.listdir(dir_to_remove) + for folder in folders_to_remove: + # shutil.rmtree(dir_to_remove / folder) #dosent delete file + run( + f"rm -rf {dir_to_remove / folder} 2>&1 | tee -a {build_log_path}", + shell=True, + check=True, + ) + logging.warning(f"Removed release {mpsd_release} from {root_dir}") + return + # 3rd case: remove specific package_sets from release for package_set in package_sets: # we load the spack environment and remove the package_set - spack_env = "" - commands_to_execute = [ - f"source {spack_env}", - f"spack env remove -y {package_set}", - ] - run(" && ".join(commands_to_execute), shell=True, check=True) + build_log_path = get_log_file_path( + mpsd_release, "remove", root_dir, package_set + ) + logging.info(f"> Logging removal of {package_set} at {build_log_path}") + if package_set not in ["global_generic", "global"]: + remove_spack_environment( + dir_to_remove / "spack", package_set, build_log_path + ) + else: + # list all specs from the global_packages.list + spe_folder = root_dir / mpsd_release / "spack-environments" + package_list_file = ( + spe_folder / "toolchains" / package_set / "global_packages.list" + ) + with open(package_list_file, "r") as f: + package_dump = f.read() + + # remove all content from # to the end of the line + package_dump = re.sub(r"#.*\n", "\n", package_dump) + # replace \\n with "" to remove line breaks + package_list = package_dump.replace("\\\n", "").split("\n") + # remove all empty lines + package_list = [line for line in package_list if line != ""] + + # remove all packages in package_list + for package in package_list: + logging.info(f"Removing package {package} from installation") + remove_spack_package(dir_to_remove / "spack", package, build_log_path) + + +def remove_spack_environment(spack_dir, environment_name, build_log_path=None): + """Remove spack environment including packages exclusive to it. + + First activate the environment, + then uninstall all packages exclusive to the environment, + then deactivate the environment, + remove the environment, + and finally remove the environment lua file. + + Parameters + ---------- + spack_dir : pathlib.Path + A Path object representing the path to the spack directory. + environment_name : str + A string representing the name of the spack environment to remove. + build_log_path : pathlib.Path, optional + A Path object representing the path to where the logs will be teed + """ + logging.warning(f"Removing spack environment {environment_name}") + spack_env = spack_dir / "share" / "spack" / "setup-env.sh" + commands_to_execute = [ + f"export SPACK_ROOT={spack_dir}", # need to set SPACK_ROOT in dash and sh + f". {spack_env}", + f"spack env activate {environment_name}", + f"for spec in $(spack -e {environment_name} find" # this line continues + r' --format "{name}@{version}%{compiler.name}@{compiler.version}");do' + " spack uninstall -y $spec; done", + "spack env deactivate", + f"spack env remove -y {environment_name}", + ] + build_log_path = build_log_path or "/dev/null" + run( + "(" + " && ".join(commands_to_execute) + f") 2>&1 |tee -a {build_log_path}", + shell=True, + check=True, + ) + # remove the environment lua file + lua_file = ( + spack_dir / ".." / "lmod" / "Core" / "toolchains" / f"{environment_name}.lua" + ) + run(f"rm {lua_file}", shell=True, check=True) + + +def remove_spack_package(spack_dir, package, build_log_path=None): + """Remove spack package. + + Used to remove global packages. + + Parameters + ---------- + spack_dir : pathlib.Path + A Path object representing the path to the spack directory. + package : str + A string representing the name of the spack package to remove. + build_log_path : pathlib.Path, optional + A Path object representing the path to where the logs will be teed + + """ + logging.info(f"Removing spack package {package}") + spack_env = spack_dir / "share" / "spack" / "setup-env.sh" + commands_to_execute = [ + f"export SPACK_ROOT={spack_dir}", # need to set SPACK_ROOT in dash and sh + f". {spack_env}", + f"spack uninstall -y {package}", + ] + run( + "(" + " && ".join(commands_to_execute) + f") 2>&1 |tee -a {build_log_path}", + shell=True, + check=True, + ) def start_new_environment(release, from_release, target_dir): @@ -1315,7 +1452,7 @@ def main(): ("available", "What is available for installation?"), ("install", "Install a software environment"), # ("reinstall", "Reinstall a package_set"), - # ("remove", "Remove a package set"), + ("remove", "Remove a package set"), # ("start-new", "Start a new MPSD software release version"), ("status", "Show status: what is installed?"), ("prepare", "Prepare installation of MPSD-release (dev only)"), diff --git a/tests/test_mpsd_software.py b/tests/test_mpsd_software.py index a65d5d84a7b7c1de4b55509c3692d8f6406a90b4..0a503315a89740ec09b1d98dab7b272e5c25d27b 100644 --- a/tests/test_mpsd_software.py +++ b/tests/test_mpsd_software.py @@ -1,15 +1,17 @@ """Tests for mpsd-software-environment.py.""" +import copy +import datetime import importlib +import logging import os import shutil import subprocess -from pathlib import Path -import logging -import datetime import sys +from pathlib import Path import pytest +import yaml mod = importlib.import_module("mpsd_software_manager.mpsd_software") @@ -256,7 +258,7 @@ def test_install_environment_zlib(): with open( package_set_src_dir / "global_generic" / "global_packages.list", "w" ) as f: - f.write("zlib@1.2.13 \n") + f.write("zlib@1.2.13 \nzstd@1.5.2\n") # add zlib to whitelist of module creation file by replacing anaconda3%gcc@10.2.1 # with zlib@1.2.13 @@ -487,8 +489,11 @@ def create_fake_environment(tmp_path, mpsd_release, expected_toolchain_map=None) toolchain_lmod_folder.mkdir(parents=True, exist_ok=True) spack_folder = tmp_path / mpsd_release / microarch / "spack" spack_folder.mkdir(parents=True, exist_ok=True) - logs_folder = tmp_path / mpsd_release / microarch / "logs" + logs_folder = tmp_path / mpsd_release / "logs" logs_folder.mkdir(parents=True, exist_ok=True) + # Simulate the creation of APEX.log + # (which is created by the main function) + (logs_folder / "APEX.log").touch() for toolchain in expected_toolchain_map[microarch]: toolchain_lua_file = toolchain_lmod_folder / f"{toolchain}.lua" toolchain_lua_file.touch() @@ -509,33 +514,6 @@ def test_environment_status(tmp_path): assert set(toolchain_map[microarch]) == set(expected_toolchain_map[microarch]) -@pytest.mark.skip(reason="not implemented yet") -def test_remove_environment(tmp_path): - """Test that the remove_environment works as expected.""" - mpsd_release = "dev-23a" - # create a fake environment - create_fake_environment(tmp_path, mpsd_release) - # check that the environment status is correct - toolchain_map = mod.environment_status(mpsd_release, tmp_path) - assert toolchain_map is not None - - # test removal without arguments (should sys.exit(1)) - create_fake_environment(tmp_path, mpsd_release) - with pytest.raises(SystemExit): - mod.remove_environment(mpsd_release, tmp_path, force_remove=True) - - # test removal of the complete environment - mod.remove_environment(mpsd_release, tmp_path, ["ALL"], force_remove=True) - toolchain_map = mod.environment_status(mpsd_release, tmp_path) - assert toolchain_map is None - # ensure that logs folder remains - logs_folder = tmp_path / mpsd_release / "logs" - assert logs_folder.exists() - - # test removal of a single toolchain - # done in test_install_environment_zlib - - def test_initialise_environment(tmp_path): """Test that init_file is created as expected.""" # test that the init file is created as expected @@ -675,6 +653,227 @@ def test_argument_parsing_logic(mocker): ### Copy from 'install' when the time has come.) +def test_remove_environment(tmp_path, mocker): + """Test that the remove_environment function works as expected.""" + + release_to_test = "dev-23a" + + # check exit 50 when no package_sets are provided + with pytest.raises(SystemExit) as pytest_wrapped_e: + mod.remove_environment("dev-23a", tmp_path) + assert pytest_wrapped_e.type == SystemExit + assert pytest_wrapped_e.value.code == 50 + + # Test case2 - remove entire release + ## exit with 60 when force is not set + ### patch the input function to return 'n' + mocker.patch("builtins.input", return_value="n") + with pytest.raises(SystemExit) as pytest_wrapped_e: + mod.remove_environment(release_to_test, tmp_path, "ALL") + assert pytest_wrapped_e.type == SystemExit + assert pytest_wrapped_e.value.code == 60 + ### patch the input function to return 'y' + mocker.patch("builtins.input", return_value="y") + # check that the release directory is removed and logs are kept + # create a release directory + create_fake_environment(tmp_path, release_to_test) + release_dir = tmp_path / release_to_test / mod.get_native_microarchitecture() + logs_dir = tmp_path / release_to_test / "logs" + + toolchain_map = mod.environment_status(release_to_test, tmp_path) + assert toolchain_map is not None + mod.remove_environment(release_to_test, tmp_path, "ALL") + + toolchain_map = mod.environment_status(release_to_test, tmp_path) + # check that no toolchain remains + assert toolchain_map is None + # check that the release directory is empty + assert len(list(release_dir.iterdir())) == 0 + # check that the logs directory is non-empty + list_of_logs = list(logs_dir.iterdir()) + assert len(list_of_logs) == 2 # APEX + remove_build_log + # check that one of the log has 'remove.log' as part of the name + assert "BUILD_ALL_remove.log" in ",".join([str(x) for x in list_of_logs]) + + # Test case3 - remove specific package_sets + # defined in test_remove_package_sets + + +@pytest.fixture +def simple_toolchain(): + """returns a dict for a simple toolchain""" + dict = { + "spack": { + "specs": ["zlib@1.2.13"], + "view": True, + "concretizer": {"reuse": False, "unify": True}, + } + } + return dict + + +def install_test_release(tmp_path, simple_toolchain): + """Install a test release + + with toolchain1 and toolchain2 to use for testing. + """ + + release_to_test = "dev-23a" + # prepare a release directory + mod.prepare_environment(mpsd_release=release_to_test, root_dir=tmp_path) + + tmp_path / release_to_test / mod.get_native_microarchitecture() + spe_dir = tmp_path / release_to_test / "spack-environments" + + # create sample toolchains + simple_toolchain_string = yaml.dump(simple_toolchain) + simple_toolchain_2 = copy.deepcopy(simple_toolchain) + simple_toolchain_2["spack"]["specs"] = ["zlib@1.2.13", "zstd@1.5.2"] + simple_toolchain2_string = yaml.dump(simple_toolchain_2) + toolchain1_dir = spe_dir / "toolchains" / "toolchain1" + toolchain2_dir = spe_dir / "toolchains" / "toolchain2" + toolchain1_dir.mkdir(parents=True) + toolchain2_dir.mkdir(parents=True) + (toolchain1_dir / "spack.yaml").write_text(simple_toolchain_string) + (toolchain2_dir / "spack.yaml").write_text(simple_toolchain2_string) + + # install the release + mod.install_environment( + mpsd_release=release_to_test, + package_sets=["toolchain1", "toolchain2"], + root_dir=tmp_path, + ) + + +def test_remove_package_sets(tmp_path, simple_toolchain): + """Test removal of package_sets via spack.""" + + release_to_test = "dev-23a" + + # Case1 - remove global / global_generic package_sets + + # Case2 - remove specific package_sets (toolchains) + # Create a test install + install_test_release(tmp_path, simple_toolchain) + # check that the installation went through + release_dir = tmp_path / release_to_test / mod.get_native_microarchitecture() + assert len(list(release_dir.iterdir())) == 2 # spack and lmod + # check that the two toolchains are installed + environments_dir = release_dir / "spack" / "var" / "spack" / "environments" + set([environment.name for environment in environments_dir.iterdir()]) == set( + ["toolchain1", "toolchain2"] + ) + # check that the two toolchains have the "handmade" module files + toolchains_list = list(mod.environment_status(release_to_test, tmp_path).values())[ + 0 + ] + assert set(toolchains_list) == set(["toolchain1", "toolchain2"]) + + # remove toolchain2 + # toolchain1 contains - zlib@1.2 + # toolchain2 contains - zlib@1.2 and zstd@1.5 + # we check that removing toolchain2 removes zstd@1.5 but NOT zlib@1.2 + # and the environment toolchain2 is also removed + + mod.remove_environment( + mpsd_release=release_to_test, + root_dir=tmp_path, + package_sets=["toolchain2"], + force_remove=True, + ) + # now check that only "toolchain1" is installed in environments_dir + assert set([environment.name for environment in environments_dir.iterdir()]) == set( + ["toolchain1"] + ) + + # check that the only one toolchains has the "handmade" module files + toolchains_list = list(mod.environment_status(release_to_test, tmp_path).values())[ + 0 + ] + assert set(toolchains_list) == set(["toolchain1"]) + + # check that zlib@1.2 is still installed + # spack location -i <package> exit 0 if installed and 1 if not installed + source_spack = ( + f"export SPACK_ROOT={release_dir} &&" + f'. {release_dir / "spack" / "share" / "spack" / "setup-env.sh"}' + ) + mod.run(f"{source_spack} && spack location -i zlib", shell=True, check=True) + # check that zstd@1.5 is not installed + # we are here flipping the exit code to check that it is not installed + mod.run( + f"{source_spack} && (spack location -i zstd && exit 1 || exit 0 )", + shell=True, + check=True, + ) + # check that the logs directory contains a build log for remove cmd + # dev-23a_zen3_2023-08-11T15-55-54_BUILD_toolchain2_remove.log + logs_dir = tmp_path / release_to_test / "logs" + # remove_build_log is the last log file in the list + remove_build_log = sorted(list(logs_dir.iterdir()))[-1] + assert "toolchain2_remove.log" in remove_build_log.name + with open(remove_build_log, "r") as f: + logs = f.read() + assert "==> Will not uninstall zlib@" in logs + assert "==> Successfully removed environment 'toolchain2'" in logs + + +def test_remove_global_package_sets(): + """Test removal of global package_sets via spack.""" + root_dir = Path("/tmp/test_global_generic") + release_to_test = "dev-23a" + if not root_dir.exists(): + # we need the sample spack instance with global_generic + # this is already done in test_install_environment_zlib + # so we just need to call it + test_install_environment_zlib() + # check that zlib and zstd are installed + spack_dir = ( + root_dir / release_to_test / mod.get_native_microarchitecture() / "spack" + ) + source_spack = ( + f"export SPACK_ROOT={spack_dir} &&" + f'. {spack_dir / "share" / "spack" / "setup-env.sh"}' + ) + # check that zlib is installed + # location commands exits with non zero if not installed thus + # breaking failing test + mod.run(f"{source_spack} && spack location -i zlib", shell=True, check=True) + # check that zstd is installed + mod.run(f"{source_spack} && spack location -i zstd", shell=True, check=True) + + # remove global_generic + mod.remove_environment( + mpsd_release=release_to_test, + root_dir=root_dir, + package_sets=["global_generic"], + force_remove=True, + ) + # check that zstd@1.5 is not installed + # we are here flipping the exit code to check that it is not installed + mod.run( + f"{source_spack} && (spack location -i zstd && exit 1 || exit 0 )", + shell=True, + check=True, + ) + # check that zlib is not installed + mod.run( + f"{source_spack} && (spack location -i zlib && exit 1 || exit 0 )", + shell=True, + check=True, + ) + # check that the logs directory contains a build log for remove cmd + # dev-23a_zen3_2023-08-11T15-55-54_BUILD_toolchain2_remove.log + logs_dir = root_dir / release_to_test / "logs" + # remove_build_log is the last log file in the list + remove_build_log = sorted(list(logs_dir.iterdir()))[-1] + assert "global_generic_remove.log" in remove_build_log.name + with open(remove_build_log, "r") as f: + logs = f.read() + assert "==> Successfully uninstalled zstd" in logs + assert "==> Successfully uninstalled zlib" in logs + + def test_interface(tmp_path): """Test other things (not implemented yet).""" pass