From 464f9abd8c149e6029397bf0008af335add2ec0a Mon Sep 17 00:00:00 2001 From: adrianciu <adrian.ciu@codemart.ro> Date: Mon, 27 Jan 2025 12:51:28 +0200 Subject: [PATCH 01/30] esd: added logger class and fixed bug --- dedal/logger/logger_config.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 dedal/logger/logger_config.py diff --git a/dedal/logger/logger_config.py b/dedal/logger/logger_config.py new file mode 100644 index 00000000..3ca3b000 --- /dev/null +++ b/dedal/logger/logger_config.py @@ -0,0 +1,33 @@ +import logging + + +class LoggerConfig: + """ + This class sets up logging with a file handler + and a stream handler, ensuring consistent + and formatted log messages. + """ + def __init__(self, log_file): + self.log_file = log_file + self._configure_logger() + + def _configure_logger(self): + formatter = logging.Formatter( + fmt='%(asctime)s - %(levelname)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' + ) + + file_handler = logging.FileHandler(self.log_file) + file_handler.setFormatter(formatter) + + stream_handler = logging.StreamHandler() + stream_handler.setFormatter(formatter) + + self.logger = logging.getLogger(__name__) + self.logger.setLevel(logging.DEBUG) + + self.logger.addHandler(file_handler) + self.logger.addHandler(stream_handler) + + def get_logger(self): + return self.logger -- GitLab From 3c9425117675a42f06f614c563fb6177092fd0a4 Mon Sep 17 00:00:00 2001 From: adrianciu <adrian.ciu@codemart.ro> Date: Wed, 5 Feb 2025 11:25:05 +0200 Subject: [PATCH 02/30] esd-spack-installation: setup; tests; custom exceptions --- .gitlab-ci.yml | 18 +- dedal/build_cache/BuildCacheManager.py | 6 +- dedal/specfile_storage_path_source.py | 6 +- dedal/tests/spack_from_scratch_test.py | 72 +++++++ dedal/tests/spack_install_test.py | 21 ++ dedal/utils/bootstrap.sh | 6 + dedal/utils/utils.py | 33 +++ esd/error_handling/exceptions.py | 18 ++ esd/model/SpackModel.py | 12 ++ esd/model/__init__.py | 0 esd/spack_manager/SpackManager.py | 193 ++++++++++++++++++ esd/spack_manager/__init__.py | 0 esd/spack_manager/enums/SpackManagerEnum.py | 6 + esd/spack_manager/enums/__init__.py | 0 .../factory/SpackManagerBuildCache.py | 19 ++ .../factory/SpackManagerCreator.py | 14 ++ .../factory/SpackManagerScratch.py | 15 ++ esd/spack_manager/factory/__init__.py | 0 pyproject.toml | 5 +- 19 files changed, 430 insertions(+), 14 deletions(-) create mode 100644 dedal/tests/spack_from_scratch_test.py create mode 100644 dedal/tests/spack_install_test.py create mode 100644 dedal/utils/bootstrap.sh create mode 100644 esd/error_handling/exceptions.py create mode 100644 esd/model/SpackModel.py create mode 100644 esd/model/__init__.py create mode 100644 esd/spack_manager/SpackManager.py create mode 100644 esd/spack_manager/__init__.py create mode 100644 esd/spack_manager/enums/SpackManagerEnum.py create mode 100644 esd/spack_manager/enums/__init__.py create mode 100644 esd/spack_manager/factory/SpackManagerBuildCache.py create mode 100644 esd/spack_manager/factory/SpackManagerCreator.py create mode 100644 esd/spack_manager/factory/SpackManagerScratch.py create mode 100644 esd/spack_manager/factory/__init__.py diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 7cdc2157..4f15b9ab 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,10 +1,11 @@ stages: - - build - test + - build variables: BUILD_ENV_DOCKER_IMAGE: docker-registry.ebrains.eu/esd/tmp:latest + build-wheel: stage: build tags: @@ -21,18 +22,23 @@ build-wheel: expire_in: 1 week -testing: +testing-pytest: stage: test tags: - docker-runner - image: python:latest + image: ubuntu:22.04 script: - - pip install -e . - - pytest ./dedal/tests/ --junitxml=test-results.xml + - chmod +x dedal/utils/bootstrap.sh + - ./dedal/utils/bootstrap.sh + - pip install . + - pytest ./dedal/tests/ -s --junitxml=test-results.xml artifacts: when: always reports: junit: test-results.xml paths: - test-results.xml - expire_in: 1 week \ No newline at end of file + - .dedal.log + - .generate_cache.log + expire_in: 1 week + diff --git a/dedal/build_cache/BuildCacheManager.py b/dedal/build_cache/BuildCacheManager.py index 2da39e25..dbd50bf9 100644 --- a/dedal/build_cache/BuildCacheManager.py +++ b/dedal/build_cache/BuildCacheManager.py @@ -40,8 +40,8 @@ class BuildCacheManager(BuildCacheManagerInterface): for sub_path in build_cache_path.rglob("*"): if sub_path.is_file(): - rel_path = str(sub_path.relative_to(build_cache_path)).replace(str(sub_path.name), "") - target = f"{self.registry_host}/{self.registry_project}/cache:{str(sub_path.name)}" + rel_path = str(sub_path.relative_to(build_cache_path)).replace(str(sub_path.env_name), "") + target = f"{self.registry_host}/{self.registry_project}/cache:{str(sub_path.env_name)}" try: self.logger.info(f"Pushing folder '{sub_path}' to ORAS target '{target}' ...") self.client.push( @@ -51,7 +51,7 @@ class BuildCacheManager(BuildCacheManagerInterface): manifest_annotations={"path": rel_path}, disable_path_validation=True, ) - self.logger.info(f"Successfully pushed {sub_path.name}") + self.logger.info(f"Successfully pushed {sub_path.env_name}") except Exception as e: self.logger.error( f"An error occurred while pushing: {e}") diff --git a/dedal/specfile_storage_path_source.py b/dedal/specfile_storage_path_source.py index 6e8a8889..4d2ff658 100644 --- a/dedal/specfile_storage_path_source.py +++ b/dedal/specfile_storage_path_source.py @@ -49,14 +49,14 @@ for rspec in data: format_string = "{name}-{version}" pretty_name = pkg.spec.format_path(format_string) - cosmetic_path = os.path.join(pkg.name, pretty_name) + cosmetic_path = os.path.join(pkg.env_name, pretty_name) to_be_fetched.add(str(spack.mirror.mirror_archive_paths(pkg.fetcher, cosmetic_path).storage_path)) for resource in pkg._get_needed_resources(): - pretty_resource_name = fsys.polite_filename(f"{resource.name}-{pkg.version}") + pretty_resource_name = fsys.polite_filename(f"{resource.env_name}-{pkg.version}") to_be_fetched.add(str(spack.mirror.mirror_archive_paths(resource.fetcher, pretty_resource_name).storage_path)) for patch in ss.patches: if isinstance(patch, spack.patch.UrlPatch): - to_be_fetched.add(str(spack.mirror.mirror_archive_paths(patch.stage.fetcher, patch.stage.name).storage_path)) + to_be_fetched.add(str(spack.mirror.mirror_archive_paths(patch.stage.fetcher, patch.stage.env_name).storage_path)) for elem in to_be_fetched: print(elem) diff --git a/dedal/tests/spack_from_scratch_test.py b/dedal/tests/spack_from_scratch_test.py new file mode 100644 index 00000000..cca0d2e9 --- /dev/null +++ b/dedal/tests/spack_from_scratch_test.py @@ -0,0 +1,72 @@ +from pathlib import Path + +import pytest + +from esd.error_handling.exceptions import BashCommandException, NoSpackEnvironmentException +from esd.model.SpackModel import SpackModel +from esd.spack_manager.factory.SpackManagerScratch import SpackManagerScratch + + +def test_spack_repo_exists_1(): + spack_manager = SpackManagerScratch() + assert spack_manager.spack_repo_exists('ebrains-spack-builds') == False + + +def test_spack_repo_exists_2(): + install_dir = Path('./install').resolve() + env = SpackModel('ebrains-spack-builds', install_dir) + spack_manager = SpackManagerScratch(env=env) + with pytest.raises(NoSpackEnvironmentException): + spack_manager.spack_repo_exists(env.env_name) + + +# def test_spack_repo_exists_3(): +# install_dir = Path('./install').resolve() +# env = SpackModel('ebrains-spack-builds', install_dir) +# spack_manager = SpackManagerScratch(env=env) +# spack_manager.setup_spack_env() +# assert spack_manager.spack_repo_exists(env.env_name) == False + + +def test_spack_from_scratch_setup_1(): + install_dir = Path('./install').resolve() + env = SpackModel('ebrains-spack-builds', install_dir, + 'https://gitlab.ebrains.eu/ri/tech-hub/platform/esd/ebrains-spack-builds.git', ) + spack_manager = SpackManagerScratch(env=env, repos=[env], system_name='ebrainslab') + spack_manager.setup_spack_env() + assert spack_manager.spack_repo_exists(env.env_name) == True + + +def test_spack_from_scratch_setup_2(): + install_dir = Path('./install').resolve() + env = SpackModel('ebrains-spack-builds', install_dir, + 'https://gitlab.ebrains.eu/ri/tech-hub/platform/esd/ebrains-spack-builds.git', ) + repo = env + spack_manager = SpackManagerScratch(env=env, repos=[repo, repo], system_name='ebrainslab') + spack_manager.setup_spack_env() + assert spack_manager.spack_repo_exists(env.env_name) == True + + +def test_spack_from_scratch_setup_3(): + install_dir = Path('./install').resolve() + env = SpackModel('new_env1', install_dir) + repo = env + spack_manager = SpackManagerScratch(env=env, repos=[repo, repo], system_name='ebrainslab') + with pytest.raises(BashCommandException): + spack_manager.setup_spack_env() + + +def test_spack_from_scratch_setup_4(): + install_dir = Path('./install').resolve() + env = SpackModel('new_env2', install_dir) + spack_manager = SpackManagerScratch(env=env) + spack_manager.setup_spack_env() + assert spack_manager.spack_env_exists() == True + + +def test_spack_not_a_valid_repo(): + env = SpackModel('ebrains-spack-builds', Path(), None) + repo = env + spack_manager = SpackManagerScratch(env=env, repos=[repo], system_name='ebrainslab') + with pytest.raises(NoSpackEnvironmentException): + spack_manager.add_spack_repo(repo.path, repo.env_name) diff --git a/dedal/tests/spack_install_test.py b/dedal/tests/spack_install_test.py new file mode 100644 index 00000000..34f68323 --- /dev/null +++ b/dedal/tests/spack_install_test.py @@ -0,0 +1,21 @@ +import pytest + +from esd.spack_manager.factory.SpackManagerBuildCache import SpackManagerBuildCache +from esd.spack_manager.factory.SpackManagerScratch import SpackManagerScratch + + +# we need this test to run first so that spack is installed only once +@pytest.mark.run(order=1) +def test_spack_install_scratch(): + spack_manager = SpackManagerScratch() + spack_manager.install_spack(spack_version="v0.21.1") + installed_spack_version = spack_manager.get_spack_installed_version() + required_version = "0.21.1" + assert required_version == installed_spack_version + + +def test_spack_install_buildcache(): + spack_manager = SpackManagerBuildCache() + installed_spack_version = spack_manager.get_spack_installed_version() + required_version = "0.21.1" + assert required_version == installed_spack_version diff --git a/dedal/utils/bootstrap.sh b/dedal/utils/bootstrap.sh new file mode 100644 index 00000000..9b7d0131 --- /dev/null +++ b/dedal/utils/bootstrap.sh @@ -0,0 +1,6 @@ +# Minimal prerequisites for installing the esd_library +# pip must be installed on the OS +echo "Bootstrapping..." +apt update +apt install -y bzip2 ca-certificates g++ gcc gfortran git gzip lsb-release patch python3 python3-pip tar unzip xz-utils zstd +python3 -m pip install --upgrade pip setuptools wheel diff --git a/dedal/utils/utils.py b/dedal/utils/utils.py index 811d258e..48c500c3 100644 --- a/dedal/utils/utils.py +++ b/dedal/utils/utils.py @@ -1,6 +1,10 @@ +import logging import shutil +import subprocess from pathlib import Path +from esd.error_handling.exceptions import BashCommandException + def clean_up(dirs: list[str], logging, ignore_errors=True): """ @@ -18,3 +22,32 @@ def clean_up(dirs: list[str], logging, ignore_errors=True): raise e else: logging.info(f"{cleanup_dir} does not exist") + + +def run_command(*args, logger=logging.getLogger(__name__), debug_msg: str = '', exception_msg: str = None, + exception=None, **kwargs): + try: + logger.debug(f'{debug_msg}: args: {args}') + return subprocess.run(args, **kwargs) + except subprocess.CalledProcessError as e: + if exception_msg is not None: + logger.error(f"{exception_msg}: {e}") + if exception is not None: + raise exception(f'{exception_msg} : {e}') + else: + return None + + +def git_clone_repo(repo_name: str, dir: Path, git_path: str, logger: logging = logging.getLogger(__name__)): + if not dir.exists(): + run_command( + "git", "clone", "--depth", "1", + "-c", "advice.detachedHead=false", + "-c", "feature.manyFiles=true", + git_path, dir + , check=True, logger=logger, + debug_msg=f'Cloned repository {repo_name}', + exception_msg=f'Failed to clone repository: {repo_name}', + exception=BashCommandException) + else: + logger.debug(f'Repository {repo_name} already cloned.') diff --git a/esd/error_handling/exceptions.py b/esd/error_handling/exceptions.py new file mode 100644 index 00000000..79f8051f --- /dev/null +++ b/esd/error_handling/exceptions.py @@ -0,0 +1,18 @@ +class SpackException(Exception): + + def __init__(self, message): + super().__init__(message) + self.message = str(message) + + def __str__(self): + return self.message + +class BashCommandException(SpackException): + """ + To be thrown when an invalid input is received. + """ + +class NoSpackEnvironmentException(SpackException): + """ + To be thrown when an invalid input is received. + """ \ No newline at end of file diff --git a/esd/model/SpackModel.py b/esd/model/SpackModel.py new file mode 100644 index 00000000..4b065dba --- /dev/null +++ b/esd/model/SpackModel.py @@ -0,0 +1,12 @@ +from pathlib import Path + + +class SpackModel: + """" + Provides details about the spack environment + """ + + def __init__(self, env_name: str, path: Path, git_path: str = None): + self.env_name = env_name + self.path = path + self.git_path = git_path diff --git a/esd/model/__init__.py b/esd/model/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/esd/spack_manager/SpackManager.py b/esd/spack_manager/SpackManager.py new file mode 100644 index 00000000..340b1b95 --- /dev/null +++ b/esd/spack_manager/SpackManager.py @@ -0,0 +1,193 @@ +import os +from abc import ABC, abstractmethod +from pathlib import Path + +from esd.error_handling.exceptions import BashCommandException, NoSpackEnvironmentException +from esd.logger.logger_builder import get_logger +from esd.model.SpackModel import SpackModel +from esd.utils.utils import run_command, git_clone_repo + + +class SpackManager(ABC): + """ + This class should implement the methods necessary for installing spack, set up an environment, concretize and install packages. + Factory design pattern is used because there are 2 cases: creating an environment from scratch or creating an environment from the buildcache. + + Attributes: + ----------- + env : SpackModel + spack environment details + repos : list[SpackModel] + upstream_instance : str + path to Spack instance to use as upstream (optional) + """ + + def __init__(self, env: SpackModel = None, repos=None, + upstream_instance=None, system_name: str = None, logger=get_logger(__name__)): + if repos is None: + repos = [] + self.repos = repos + self.env = env + self.install_dir = Path(os.environ.get("INSTALLATION_ROOT") or os.getcwd()).resolve() + self.install_dir.mkdir(parents=True, exist_ok=True) + self.env_path = None + if self.env and self.env.path: + self.env.path = self.env.path.resolve() + self.env.path.mkdir(parents=True, exist_ok=True) + self.env_path = self.env.path / self.env.env_name + self.upstream_instance = upstream_instance + self.spack_dir = self.install_dir / "spack" + self.spack_setup_script = self.spack_dir / "share" / "spack" / "setup-env.sh" + self.logger = logger + self.system_name = system_name + + @abstractmethod + def concretize_spack_env(self, force=True): + pass + + @abstractmethod + def install_spack_packages(self, jobs: 3, verbose=False, debug=False): + pass + + def create_fetch_spack_environment(self): + + if self.env.git_path: + git_clone_repo(self.env.env_name, self.env.path / self.env.env_name, self.env.git_path, logger=self.logger) + else: + os.makedirs(self.env.path / self.env.env_name, exist_ok=True) + run_command("bash", "-c", + f'source {self.spack_setup_script} && spack env create -d {self.env_path}', + check=True, logger=self.logger, + debug_msg=f"Created {self.env.env_name} spack environment", + exception_msg=f"Failed to create {self.env.env_name} spack environment", + exception=BashCommandException) + + def setup_spack_env(self): + """ + This method prepares a spack environment by fetching/creating the spack environment and adding the necessary repos + """ + bashrc_path = os.path.expanduser("~/.bashrc") + if self.system_name: + with open(bashrc_path, "a") as bashrc: + bashrc.write(f'export SYSTEMNAME="{self.system_name}"\n') + os.environ['SYSTEMNAME'] = self.system_name + if self.spack_dir.exists() and self.spack_dir.is_dir(): + with open(bashrc_path, "a") as bashrc: + bashrc.write(f'export SPACK_USER_CACHE_PATH="{str(self.spack_dir / ".spack")}"\n') + bashrc.write(f'export SPACK_USER_CONFIG_PATH="{str(self.spack_dir / ".spack")}"\n') + self.logger.debug('Added env variables SPACK_USER_CACHE_PATH and SPACK_USER_CONFIG_PATH') + else: + self.logger.error(f'Invalid installation path: {self.spack_dir}') + # Restart the bash after adding environment variables + self.create_fetch_spack_environment() + if self.install_dir.exists(): + for repo in self.repos: + repo_dir = self.install_dir / repo.path / repo.env_name + git_clone_repo(repo.env_name, repo_dir, repo.git_path, logger=self.logger) + if not self.spack_repo_exists(repo.env_name): + self.add_spack_repo(repo.path, repo.env_name) + self.logger.debug(f'Added spack repository {repo.env_name}') + else: + self.logger.debug(f'Spack repository {repo.env_name} already added') + + def spack_repo_exists(self, repo_name: str) -> bool | None: + """Check if the given Spack repository exists.""" + if self.env is None: + result = run_command("bash", "-c", + f'source {self.spack_setup_script} && spack repo list', + check=True, + capture_output=True, text=True, logger=self.logger, + debug_msg=f'Checking if {repo_name} exists') + if result is None: + return False + else: + if self.spack_env_exists(): + result = run_command("bash", "-c", + f'source {self.spack_setup_script} && spack env activate -p {self.env_path} && spack repo list', + check=True, + capture_output=True, text=True, logger=self.logger, + debug_msg=f'Checking if repository {repo_name} was added') + else: + self.logger.debug('No spack environment defined') + raise NoSpackEnvironmentException('No spack environment defined') + if result is None: + return False + return any(line.strip().endswith(repo_name) for line in result.stdout.splitlines()) + + def spack_env_exists(self): + result = run_command("bash", "-c", + f'source {self.spack_setup_script} && spack env activate -p {self.env_path}', + check=True, + capture_output=True, text=True, logger=self.logger, + debug_msg=f'Checking if environment {self.env.env_name} exists') + if result is None: + return False + return True + + def add_spack_repo(self, repo_path: Path, repo_name: str): + """Add the Spack repository if it does not exist.""" + if self.spack_env_exists(): + run_command("bash", "-c", + f'source {self.spack_setup_script} && spack env activate -p {self.env_path} && spack repo add {repo_path}/{repo_name}', + check=True, logger=self.logger, + debug_msg=f"Added {repo_name} to spack environment {self.env.env_name}", + exception_msg=f"Failed to add {repo_name} to spack environment {self.env.env_name}", + exception=BashCommandException) + else: + self.logger.debug('No spack environment defined') + raise NoSpackEnvironmentException('No spack environment defined') + + def get_spack_installed_version(self): + spack_version = run_command("bash", "-c", f'source {self.spack_setup_script} && spack --version', + capture_output=True, text=True, check=True, + logger=self.logger, + debug_msg=f"Getting spack version", + exception_msg=f"Error retrieving Spack version") + if spack_version: + return spack_version.stdout.strip().split()[0] + return None + + def install_spack(self, spack_version="v0.21.1", spack_repo='https://github.com/spack/spack'): + try: + user = os.getlogin() + except OSError: + user = None + + self.logger.info(f"Starting to install Spack into {self.spack_dir} from branch {spack_version}") + if not self.spack_dir.exists(): + run_command( + "git", "clone", "--depth", "1", + "-c", "advice.detachedHead=false", + "-c", "feature.manyFiles=true", + "--branch", spack_version, spack_repo, self.spack_dir + , check=True, logger=self.logger) + self.logger.debug("Cloned spack") + else: + self.logger.debug("Spack already cloned.") + + bashrc_path = os.path.expanduser("~/.bashrc") + # ensure the file exists before opening it + if not os.path.exists(bashrc_path): + open(bashrc_path, "w").close() + # add spack setup commands to .bashrc + with open(bashrc_path, "a") as bashrc: + bashrc.write(f'export PATH="{self.spack_dir}/bin:$PATH"\n') + bashrc.write(f"source {self.spack_setup_script}\n") + self.logger.info("Added Spack PATH to .bashrc") + if user: + run_command("chown", "-R", f"{user}:{user}", self.spack_dir, check=True, logger=self.logger, + debug_msg='Adding permissions to the logged in user') + run_command("bash", "-c", f"source {bashrc_path}", check=True, logger=self.logger, debug_msg='Restart bash') + self.logger.info("Spack install completed") + # Restart Bash after the installation ends + os.system("exec bash") + + # Configure upstream Spack instance if specified + if self.upstream_instance: + upstreams_yaml_path = os.path.join(self.spack_dir, "etc/spack/defaults/upstreams.yaml") + with open(upstreams_yaml_path, "w") as file: + file.write(f"""upstreams: + upstream-spack-instance: + install_tree: {self.upstream_instance}/spack/opt/spack + """) + self.logger.info("Added upstream spack instance") diff --git a/esd/spack_manager/__init__.py b/esd/spack_manager/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/esd/spack_manager/enums/SpackManagerEnum.py b/esd/spack_manager/enums/SpackManagerEnum.py new file mode 100644 index 00000000..a2435839 --- /dev/null +++ b/esd/spack_manager/enums/SpackManagerEnum.py @@ -0,0 +1,6 @@ +from enum import Enum + + +class SpackManagerEnum(Enum): + FROM_SCRATCH = "from_scratch", + FROM_BUILDCACHE = "from_buildcache", diff --git a/esd/spack_manager/enums/__init__.py b/esd/spack_manager/enums/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/esd/spack_manager/factory/SpackManagerBuildCache.py b/esd/spack_manager/factory/SpackManagerBuildCache.py new file mode 100644 index 00000000..38151c6d --- /dev/null +++ b/esd/spack_manager/factory/SpackManagerBuildCache.py @@ -0,0 +1,19 @@ +from esd.model.SpackModel import SpackModel +from esd.spack_manager.SpackManager import SpackManager +from esd.logger.logger_builder import get_logger + + +class SpackManagerBuildCache(SpackManager): + def __init__(self, env: SpackModel = None, repos=None, + upstream_instance=None, system_name: str = None): + super().__init__(env, repos, upstream_instance, system_name, logger=get_logger(__name__)) + + def setup_spack_env(self): + super().setup_spack_env() + # todo add buildcache to the spack environment + + def concretize_spack_env(self, force=True): + pass + + def install_spack_packages(self, jobs: 3, verbose=False, debug=False): + pass diff --git a/esd/spack_manager/factory/SpackManagerCreator.py b/esd/spack_manager/factory/SpackManagerCreator.py new file mode 100644 index 00000000..9728467f --- /dev/null +++ b/esd/spack_manager/factory/SpackManagerCreator.py @@ -0,0 +1,14 @@ +from esd.spack_manager.enums.SpackManagerEnum import SpackManagerEnum +from esd.spack_manager.factory.SpackManagerBuildCache import SpackManagerBuildCache +from esd.spack_manager.factory.SpackManagerScratch import SpackManagerScratch + + +class SpackManagerCreator: + @staticmethod + def get_spack_manger(spack_manager_type: SpackManagerEnum, env_name: str, repo: str, repo_name: str, + upstream_instance: str): + if spack_manager_type == SpackManagerEnum.FROM_SCRATCH: + return SpackManagerScratch(env_name, repo, repo_name, upstream_instance) + elif spack_manager_type == SpackManagerEnum.FROM_BUILDCACHE: + return SpackManagerBuildCache(env_name, repo, repo_name, upstream_instance) + diff --git a/esd/spack_manager/factory/SpackManagerScratch.py b/esd/spack_manager/factory/SpackManagerScratch.py new file mode 100644 index 00000000..5a79797c --- /dev/null +++ b/esd/spack_manager/factory/SpackManagerScratch.py @@ -0,0 +1,15 @@ +from esd.model.SpackModel import SpackModel +from esd.spack_manager.SpackManager import SpackManager +from esd.logger.logger_builder import get_logger + + +class SpackManagerScratch(SpackManager): + def __init__(self, env: SpackModel = None, repos=None, + upstream_instance=None, system_name: str = None): + super().__init__(env, repos, upstream_instance, system_name, logger=get_logger(__name__)) + + def concretize_spack_env(self, force=True): + pass + + def install_spack_packages(self, jobs: 3, verbose=False, debug=False): + pass diff --git a/esd/spack_manager/factory/__init__.py b/esd/spack_manager/factory/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pyproject.toml b/pyproject.toml index 757f370c..b6679ca1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,15 +1,15 @@ [build-system] -requires = ["setuptools", "setuptools-scm"] +requires = ["setuptools>=64", "wheel"] build-backend = "setuptools.build_meta" [project] name = "dedal" +version = "0.1.0" authors = [ {name = "Eric Müller", email = "mueller@kip.uni-heidelberg.de"}, {name = "Adrian Ciu", email = "adrian.ciu@codemart.ro"}, ] description = "This package provides all the necessary tools to create an Ebrains Software Distribution environment" -version = "0.1.0" readme = "README.md" requires-python = ">=3.10" dependencies = [ @@ -18,6 +18,7 @@ dependencies = [ "ruamel.yaml", "pytest", "pytest-mock", + "pytest-ordering", ] [tool.setuptools.data-files] -- GitLab From 93d07dbfff3742fcd5fe8f8a7949e288c3a4056c Mon Sep 17 00:00:00 2001 From: adrianciu <adrian.ciu@codemart.ro> Date: Wed, 5 Feb 2025 21:32:38 +0200 Subject: [PATCH 03/30] esd-spack-installation: added concretization step and tests --- README.md | 1 + dedal/tests/spack_from_scratch_test.py | 97 ++++++++++++++----- dedal/tests/spack_install_test.py | 12 +-- dedal/tests/utils_test.py | 18 ++++ dedal/utils/utils.py | 4 + esd/error_handling/exceptions.py | 14 ++- .../factory/SpackManagerCreator.py | 10 +- .../factory/SpackManagerScratch.py | 15 ++- 8 files changed, 132 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 62e00c68..86ab9c2f 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,4 @@ # ~~Yashchiki~~Koutakia For now, this repository provides helpers for the EBRAINS container image build flow. +The lowest spack version which should be used with this library is v0.22.0 diff --git a/dedal/tests/spack_from_scratch_test.py b/dedal/tests/spack_from_scratch_test.py index cca0d2e9..d1aca83c 100644 --- a/dedal/tests/spack_from_scratch_test.py +++ b/dedal/tests/spack_from_scratch_test.py @@ -4,62 +4,70 @@ import pytest from esd.error_handling.exceptions import BashCommandException, NoSpackEnvironmentException from esd.model.SpackModel import SpackModel +from esd.spack_manager.enums.SpackManagerEnum import SpackManagerEnum +from esd.spack_manager.factory.SpackManagerCreator import SpackManagerCreator from esd.spack_manager.factory.SpackManagerScratch import SpackManagerScratch +from esd.utils.utils import file_exists_and_not_empty def test_spack_repo_exists_1(): - spack_manager = SpackManagerScratch() + spack_manager = SpackManagerCreator.get_spack_manger(SpackManagerEnum.FROM_SCRATCH) assert spack_manager.spack_repo_exists('ebrains-spack-builds') == False -def test_spack_repo_exists_2(): - install_dir = Path('./install').resolve() +def test_spack_repo_exists_2(tmp_path): + install_dir = tmp_path env = SpackModel('ebrains-spack-builds', install_dir) - spack_manager = SpackManagerScratch(env=env) + spack_manager = SpackManagerCreator.get_spack_manger(SpackManagerEnum.FROM_SCRATCH, env=env) with pytest.raises(NoSpackEnvironmentException): spack_manager.spack_repo_exists(env.env_name) -# def test_spack_repo_exists_3(): -# install_dir = Path('./install').resolve() -# env = SpackModel('ebrains-spack-builds', install_dir) -# spack_manager = SpackManagerScratch(env=env) -# spack_manager.setup_spack_env() -# assert spack_manager.spack_repo_exists(env.env_name) == False +def test_spack_repo_exists_3(tmp_path): + install_dir = tmp_path + env = SpackModel('ebrains-spack-builds', install_dir) + spack_manager = SpackManagerCreator.get_spack_manger(SpackManagerEnum.FROM_SCRATCH, env=env) + spack_manager.setup_spack_env() + assert spack_manager.spack_repo_exists(env.env_name) == False -def test_spack_from_scratch_setup_1(): - install_dir = Path('./install').resolve() +def test_spack_from_scratch_setup_1(tmp_path): + install_dir = tmp_path env = SpackModel('ebrains-spack-builds', install_dir, 'https://gitlab.ebrains.eu/ri/tech-hub/platform/esd/ebrains-spack-builds.git', ) - spack_manager = SpackManagerScratch(env=env, repos=[env], system_name='ebrainslab') + spack_manager = SpackManagerCreator.get_spack_manger(SpackManagerEnum.FROM_SCRATCH, env=env, + system_name='ebrainslab') spack_manager.setup_spack_env() - assert spack_manager.spack_repo_exists(env.env_name) == True + assert spack_manager.spack_repo_exists(env.env_name) == False -def test_spack_from_scratch_setup_2(): - install_dir = Path('./install').resolve() +def test_spack_from_scratch_setup_2(tmp_path): + install_dir = tmp_path env = SpackModel('ebrains-spack-builds', install_dir, 'https://gitlab.ebrains.eu/ri/tech-hub/platform/esd/ebrains-spack-builds.git', ) repo = env - spack_manager = SpackManagerScratch(env=env, repos=[repo, repo], system_name='ebrainslab') + spack_manager = SpackManagerCreator.get_spack_manger(SpackManagerEnum.FROM_SCRATCH, env=env, + repos=[repo, repo], + system_name='ebrainslab') spack_manager.setup_spack_env() assert spack_manager.spack_repo_exists(env.env_name) == True -def test_spack_from_scratch_setup_3(): - install_dir = Path('./install').resolve() +def test_spack_from_scratch_setup_3(tmp_path): + install_dir = tmp_path env = SpackModel('new_env1', install_dir) repo = env - spack_manager = SpackManagerScratch(env=env, repos=[repo, repo], system_name='ebrainslab') + spack_manager = SpackManagerCreator.get_spack_manger(SpackManagerEnum.FROM_SCRATCH, env=env, + repos=[repo, repo], + system_name='ebrainslab') with pytest.raises(BashCommandException): spack_manager.setup_spack_env() -def test_spack_from_scratch_setup_4(): - install_dir = Path('./install').resolve() +def test_spack_from_scratch_setup_4(tmp_path): + install_dir = tmp_path env = SpackModel('new_env2', install_dir) - spack_manager = SpackManagerScratch(env=env) + spack_manager = SpackManagerCreator.get_spack_manger(SpackManagerEnum.FROM_SCRATCH, env=env) spack_manager.setup_spack_env() assert spack_manager.spack_env_exists() == True @@ -67,6 +75,47 @@ def test_spack_from_scratch_setup_4(): def test_spack_not_a_valid_repo(): env = SpackModel('ebrains-spack-builds', Path(), None) repo = env - spack_manager = SpackManagerScratch(env=env, repos=[repo], system_name='ebrainslab') + spack_manager = SpackManagerCreator.get_spack_manger(SpackManagerEnum.FROM_SCRATCH, env=env, + repos=[repo], + system_name='ebrainslab') with pytest.raises(NoSpackEnvironmentException): spack_manager.add_spack_repo(repo.path, repo.env_name) + + +def test_spack_from_scratch_concretize_1(tmp_path): + install_dir = tmp_path + env = SpackModel('ebrains-spack-builds', install_dir, + 'https://gitlab.ebrains.eu/ri/tech-hub/platform/esd/ebrains-spack-builds.git', ) + repo = env + spack_manager = SpackManagerCreator.get_spack_manger(SpackManagerEnum.FROM_SCRATCH, env=env, repos=[repo, repo], + system_name='ebrainslab') + spack_manager.setup_spack_env() + spack_manager.concretize_spack_env(force=True) + concretization_file_path = spack_manager.env_path / 'spack.lock' + assert file_exists_and_not_empty(concretization_file_path) == True + + +def test_spack_from_scratch_concretize_2(tmp_path): + install_dir = tmp_path + env = SpackModel('ebrains-spack-builds', install_dir, + 'https://gitlab.ebrains.eu/ri/tech-hub/platform/esd/ebrains-spack-builds.git', ) + repo = env + spack_manager = SpackManagerCreator.get_spack_manger(SpackManagerEnum.FROM_SCRATCH, env=env, repos=[repo, repo], + system_name='ebrainslab') + spack_manager.setup_spack_env() + spack_manager.concretize_spack_env(force=False) + concretization_file_path = spack_manager.env_path / 'spack.lock' + assert file_exists_and_not_empty(concretization_file_path) == True + + +def test_spack_from_scratch_concretize_3(tmp_path): + install_dir = tmp_path + env = SpackModel('ebrains-spack-builds', install_dir, + 'https://gitlab.ebrains.eu/ri/tech-hub/platform/esd/ebrains-spack-builds.git', ) + repo = env + spack_manager = SpackManagerCreator.get_spack_manger(SpackManagerEnum.FROM_SCRATCH, env=env, + repos=[repo, repo], + system_name='ebrainslab') + spack_manager.setup_spack_env() + concretization_file_path = spack_manager.env_path / 'spack.lock' + assert file_exists_and_not_empty(concretization_file_path) == False diff --git a/dedal/tests/spack_install_test.py b/dedal/tests/spack_install_test.py index 34f68323..9a32d5c7 100644 --- a/dedal/tests/spack_install_test.py +++ b/dedal/tests/spack_install_test.py @@ -4,18 +4,18 @@ from esd.spack_manager.factory.SpackManagerBuildCache import SpackManagerBuildCa from esd.spack_manager.factory.SpackManagerScratch import SpackManagerScratch -# we need this test to run first so that spack is installed only once +SPACK_VERSION = "0.22.0" + +# we need this test to run first so that spack is installed only once for all the tests @pytest.mark.run(order=1) def test_spack_install_scratch(): spack_manager = SpackManagerScratch() - spack_manager.install_spack(spack_version="v0.21.1") + spack_manager.install_spack(spack_version=f'v{SPACK_VERSION}') installed_spack_version = spack_manager.get_spack_installed_version() - required_version = "0.21.1" - assert required_version == installed_spack_version + assert SPACK_VERSION == installed_spack_version def test_spack_install_buildcache(): spack_manager = SpackManagerBuildCache() installed_spack_version = spack_manager.get_spack_installed_version() - required_version = "0.21.1" - assert required_version == installed_spack_version + assert SPACK_VERSION == installed_spack_version diff --git a/dedal/tests/utils_test.py b/dedal/tests/utils_test.py index 14795726..256b8218 100644 --- a/dedal/tests/utils_test.py +++ b/dedal/tests/utils_test.py @@ -61,3 +61,21 @@ def test_clean_up_nonexistent_dirs(mocker): for dir_path in nonexistent_dirs: mock_logger.info.assert_any_call(f"{Path(dir_path).resolve()} does not exist") + + +def test_file_does_not_exist(tmp_path: Path): + non_existent_file = tmp_path / "non_existent.txt" + assert not file_exists_and_not_empty(non_existent_file) + + +def test_file_exists_but_empty(tmp_path: Path): + empty_file = tmp_path / "empty.txt" + # Create an empty file + empty_file.touch() + assert not file_exists_and_not_empty(empty_file) + + +def test_file_exists_and_not_empty(tmp_path: Path): + non_empty_file = tmp_path / "non_empty.txt" + non_empty_file.write_text("Some content") + assert file_exists_and_not_empty(non_empty_file) diff --git a/dedal/utils/utils.py b/dedal/utils/utils.py index 48c500c3..173347d0 100644 --- a/dedal/utils/utils.py +++ b/dedal/utils/utils.py @@ -51,3 +51,7 @@ def git_clone_repo(repo_name: str, dir: Path, git_path: str, logger: logging = l exception=BashCommandException) else: logger.debug(f'Repository {repo_name} already cloned.') + + +def file_exists_and_not_empty(file: Path) -> bool: + return file.is_file() and file.stat().st_size > 0 diff --git a/esd/error_handling/exceptions.py b/esd/error_handling/exceptions.py index 79f8051f..d6de666b 100644 --- a/esd/error_handling/exceptions.py +++ b/esd/error_handling/exceptions.py @@ -7,12 +7,20 @@ class SpackException(Exception): def __str__(self): return self.message + class BashCommandException(SpackException): """ - To be thrown when an invalid input is received. + To be thrown when a bash command has failed """ + class NoSpackEnvironmentException(SpackException): """ - To be thrown when an invalid input is received. - """ \ No newline at end of file + To be thrown when an operation on a spack environment is executed without the environment being activated or existent + """ + + +class SpackConcertizeException(SpackException): + """ + To be thrown when the spack concretization step fails + """ diff --git a/esd/spack_manager/factory/SpackManagerCreator.py b/esd/spack_manager/factory/SpackManagerCreator.py index 9728467f..6eb26a04 100644 --- a/esd/spack_manager/factory/SpackManagerCreator.py +++ b/esd/spack_manager/factory/SpackManagerCreator.py @@ -1,3 +1,4 @@ +from esd.model.SpackModel import SpackModel from esd.spack_manager.enums.SpackManagerEnum import SpackManagerEnum from esd.spack_manager.factory.SpackManagerBuildCache import SpackManagerBuildCache from esd.spack_manager.factory.SpackManagerScratch import SpackManagerScratch @@ -5,10 +6,9 @@ from esd.spack_manager.factory.SpackManagerScratch import SpackManagerScratch class SpackManagerCreator: @staticmethod - def get_spack_manger(spack_manager_type: SpackManagerEnum, env_name: str, repo: str, repo_name: str, - upstream_instance: str): + def get_spack_manger(spack_manager_type: SpackManagerEnum, env: SpackModel = None, repos=None, + upstream_instance=None, system_name: str = None): if spack_manager_type == SpackManagerEnum.FROM_SCRATCH: - return SpackManagerScratch(env_name, repo, repo_name, upstream_instance) + return SpackManagerScratch(env, repos, upstream_instance, system_name) elif spack_manager_type == SpackManagerEnum.FROM_BUILDCACHE: - return SpackManagerBuildCache(env_name, repo, repo_name, upstream_instance) - + return SpackManagerBuildCache(env, repos, upstream_instance, system_name) diff --git a/esd/spack_manager/factory/SpackManagerScratch.py b/esd/spack_manager/factory/SpackManagerScratch.py index 5a79797c..2ec22705 100644 --- a/esd/spack_manager/factory/SpackManagerScratch.py +++ b/esd/spack_manager/factory/SpackManagerScratch.py @@ -1,6 +1,8 @@ +from esd.error_handling.exceptions import SpackConcertizeException, NoSpackEnvironmentException from esd.model.SpackModel import SpackModel from esd.spack_manager.SpackManager import SpackManager from esd.logger.logger_builder import get_logger +from esd.utils.utils import run_command class SpackManagerScratch(SpackManager): @@ -9,7 +11,18 @@ class SpackManagerScratch(SpackManager): super().__init__(env, repos, upstream_instance, system_name, logger=get_logger(__name__)) def concretize_spack_env(self, force=True): - pass + force = '--force' if force else '' + if self.spack_env_exists(): + run_command("bash", "-c", + f'source {self.spack_setup_script} && spack env activate -p {self.env_path} && spack concretize {force}', + check=True, + capture_output=True, text=True, logger=self.logger, + debug_msg=f'Concertization step for {self.env.env_name}', + exception_msg=f'Failed the concertization step for {self.env.env_name}', + exception=SpackConcertizeException) + else: + self.logger.debug('No spack environment defined') + raise NoSpackEnvironmentException('No spack environment defined') def install_spack_packages(self, jobs: 3, verbose=False, debug=False): pass -- GitLab From d0a57d8fffcb8286c5f4eb220a9590b55002f8b5 Mon Sep 17 00:00:00 2001 From: adrianciu <adrianciu25@gmail.com> Date: Thu, 6 Feb 2025 10:10:33 +0200 Subject: [PATCH 04/30] esd-spack-installation: additional methods --- .gitlab-ci.yml | 1 + dedal/tests/spack_from_scratch_test.py | 83 +++++++++++++++++----- esd/error_handling/exceptions.py | 9 ++- esd/spack_manager/SpackManager.py | 51 +++++++++---- esd/spack_manager/wrapper/__init__.py | 0 esd/spack_manager/wrapper/spack_wrapper.py | 15 ++++ 6 files changed, 124 insertions(+), 35 deletions(-) create mode 100644 esd/spack_manager/wrapper/__init__.py create mode 100644 esd/spack_manager/wrapper/spack_wrapper.py diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 4f15b9ab..bd5669bd 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -6,6 +6,7 @@ variables: BUILD_ENV_DOCKER_IMAGE: docker-registry.ebrains.eu/esd/tmp:latest + build-wheel: stage: build tags: diff --git a/dedal/tests/spack_from_scratch_test.py b/dedal/tests/spack_from_scratch_test.py index d1aca83c..2131e7df 100644 --- a/dedal/tests/spack_from_scratch_test.py +++ b/dedal/tests/spack_from_scratch_test.py @@ -1,14 +1,13 @@ +import os from pathlib import Path - import pytest - from esd.error_handling.exceptions import BashCommandException, NoSpackEnvironmentException from esd.model.SpackModel import SpackModel from esd.spack_manager.enums.SpackManagerEnum import SpackManagerEnum from esd.spack_manager.factory.SpackManagerCreator import SpackManagerCreator -from esd.spack_manager.factory.SpackManagerScratch import SpackManagerScratch from esd.utils.utils import file_exists_and_not_empty +SPACK_ENV_ACCESS_TOKEN = os.getenv("SPACK_ENV_ACCESS_TOKEN") def test_spack_repo_exists_1(): spack_manager = SpackManagerCreator.get_spack_manger(SpackManagerEnum.FROM_SCRATCH) @@ -34,9 +33,9 @@ def test_spack_repo_exists_3(tmp_path): def test_spack_from_scratch_setup_1(tmp_path): install_dir = tmp_path env = SpackModel('ebrains-spack-builds', install_dir, - 'https://gitlab.ebrains.eu/ri/tech-hub/platform/esd/ebrains-spack-builds.git', ) + 'https://gitlab.ebrains.eu/ri/tech-hub/platform/esd/ebrains-spack-builds.git') spack_manager = SpackManagerCreator.get_spack_manger(SpackManagerEnum.FROM_SCRATCH, env=env, - system_name='ebrainslab') + system_name='ebrainslab') spack_manager.setup_spack_env() assert spack_manager.spack_repo_exists(env.env_name) == False @@ -44,11 +43,11 @@ def test_spack_from_scratch_setup_1(tmp_path): def test_spack_from_scratch_setup_2(tmp_path): install_dir = tmp_path env = SpackModel('ebrains-spack-builds', install_dir, - 'https://gitlab.ebrains.eu/ri/tech-hub/platform/esd/ebrains-spack-builds.git', ) + 'https://gitlab.ebrains.eu/ri/tech-hub/platform/esd/ebrains-spack-builds.git') repo = env spack_manager = SpackManagerCreator.get_spack_manger(SpackManagerEnum.FROM_SCRATCH, env=env, - repos=[repo, repo], - system_name='ebrainslab') + repos=[repo, repo], + system_name='ebrainslab') spack_manager.setup_spack_env() assert spack_manager.spack_repo_exists(env.env_name) == True @@ -58,8 +57,8 @@ def test_spack_from_scratch_setup_3(tmp_path): env = SpackModel('new_env1', install_dir) repo = env spack_manager = SpackManagerCreator.get_spack_manger(SpackManagerEnum.FROM_SCRATCH, env=env, - repos=[repo, repo], - system_name='ebrainslab') + repos=[repo, repo], + system_name='ebrainslab') with pytest.raises(BashCommandException): spack_manager.setup_spack_env() @@ -76,8 +75,8 @@ def test_spack_not_a_valid_repo(): env = SpackModel('ebrains-spack-builds', Path(), None) repo = env spack_manager = SpackManagerCreator.get_spack_manger(SpackManagerEnum.FROM_SCRATCH, env=env, - repos=[repo], - system_name='ebrainslab') + repos=[repo], + system_name='ebrainslab') with pytest.raises(NoSpackEnvironmentException): spack_manager.add_spack_repo(repo.path, repo.env_name) @@ -85,7 +84,7 @@ def test_spack_not_a_valid_repo(): def test_spack_from_scratch_concretize_1(tmp_path): install_dir = tmp_path env = SpackModel('ebrains-spack-builds', install_dir, - 'https://gitlab.ebrains.eu/ri/tech-hub/platform/esd/ebrains-spack-builds.git', ) + 'https://gitlab.ebrains.eu/ri/tech-hub/platform/esd/ebrains-spack-builds.git') repo = env spack_manager = SpackManagerCreator.get_spack_manger(SpackManagerEnum.FROM_SCRATCH, env=env, repos=[repo, repo], system_name='ebrainslab') @@ -98,7 +97,7 @@ def test_spack_from_scratch_concretize_1(tmp_path): def test_spack_from_scratch_concretize_2(tmp_path): install_dir = tmp_path env = SpackModel('ebrains-spack-builds', install_dir, - 'https://gitlab.ebrains.eu/ri/tech-hub/platform/esd/ebrains-spack-builds.git', ) + 'https://gitlab.ebrains.eu/ri/tech-hub/platform/esd/ebrains-spack-builds.git') repo = env spack_manager = SpackManagerCreator.get_spack_manger(SpackManagerEnum.FROM_SCRATCH, env=env, repos=[repo, repo], system_name='ebrainslab') @@ -111,11 +110,59 @@ def test_spack_from_scratch_concretize_2(tmp_path): def test_spack_from_scratch_concretize_3(tmp_path): install_dir = tmp_path env = SpackModel('ebrains-spack-builds', install_dir, - 'https://gitlab.ebrains.eu/ri/tech-hub/platform/esd/ebrains-spack-builds.git', ) + 'https://gitlab.ebrains.eu/ri/tech-hub/platform/esd/ebrains-spack-builds.git') repo = env - spack_manager = SpackManagerCreator.get_spack_manger(SpackManagerEnum.FROM_SCRATCH, env=env, - repos=[repo, repo], - system_name='ebrainslab') + spack_manager = SpackManagerCreator.get_spack_manger(SpackManagerEnum.FROM_SCRATCH, env=env, + repos=[repo, repo], + system_name='ebrainslab') spack_manager.setup_spack_env() concretization_file_path = spack_manager.env_path / 'spack.lock' assert file_exists_and_not_empty(concretization_file_path) == False + + +def test_spack_from_scratch_concretize_4(tmp_path): + install_dir = tmp_path + env = SpackModel('test-spack-env', install_dir, + f'https://oauth2:{SPACK_ENV_ACCESS_TOKEN}@gitlab.ebrains.eu/ri/projects-and-initiatives/virtualbraintwin/test-spack-env.git') + spack_manager = SpackManagerCreator.get_spack_manger(SpackManagerEnum.FROM_SCRATCH, env=env) + spack_manager.setup_spack_env() + spack_manager.concretize_spack_env(force=False) + concretization_file_path = spack_manager.env_path / 'spack.lock' + assert file_exists_and_not_empty(concretization_file_path) == True + + +def test_spack_from_scratch_concretize_5(tmp_path): + install_dir = tmp_path + env = SpackModel('test-spack-env', install_dir, + f'https://oauth2:{SPACK_ENV_ACCESS_TOKEN}@gitlab.ebrains.eu/ri/projects-and-initiatives/virtualbraintwin/test-spack-env.git') + spack_manager = SpackManagerCreator.get_spack_manger(SpackManagerEnum.FROM_SCRATCH, env=env) + spack_manager.setup_spack_env() + spack_manager.concretize_spack_env(force=True) + concretization_file_path = spack_manager.env_path / 'spack.lock' + assert file_exists_and_not_empty(concretization_file_path) == True + + +def test_spack_from_scratch_concretize_6(tmp_path): + install_dir = tmp_path + env = SpackModel('test-spack-env', install_dir, + f'https://oauth2:{SPACK_ENV_ACCESS_TOKEN}@gitlab.ebrains.eu/ri/projects-and-initiatives/virtualbraintwin/test-spack-env.git') + repo = SpackModel('ebrains-spack-builds', install_dir, + 'https://gitlab.ebrains.eu/ri/tech-hub/platform/esd/ebrains-spack-builds.git') + spack_manager = SpackManagerCreator.get_spack_manger(SpackManagerEnum.FROM_SCRATCH, env=env, repos=[repo]) + spack_manager.setup_spack_env() + spack_manager.concretize_spack_env(force=False) + concretization_file_path = spack_manager.env_path / 'spack.lock' + assert file_exists_and_not_empty(concretization_file_path) == True + + +def test_spack_from_scratch_concretize_7(tmp_path): + install_dir = tmp_path + env = SpackModel('test-spack-env', install_dir, + 'https://gitlab.ebrains.eu/ri/projects-and-initiatives/virtualbraintwin/test-spack-env.git') + repo = SpackModel('ebrains-spack-builds', install_dir, + 'https://gitlab.ebrains.eu/ri/tech-hub/platform/esd/ebrains-spack-builds.git') + spack_manager = SpackManagerCreator.get_spack_manger(SpackManagerEnum.FROM_SCRATCH, env=env, repos=[repo]) + spack_manager.setup_spack_env() + spack_manager.concretize_spack_env(force=True) + concretization_file_path = spack_manager.env_path / 'spack.lock' + assert file_exists_and_not_empty(concretization_file_path) == True diff --git a/esd/error_handling/exceptions.py b/esd/error_handling/exceptions.py index d6de666b..9d11b5fa 100644 --- a/esd/error_handling/exceptions.py +++ b/esd/error_handling/exceptions.py @@ -14,13 +14,18 @@ class BashCommandException(SpackException): """ -class NoSpackEnvironmentException(SpackException): +class NoSpackEnvironmentException(BashCommandException): """ To be thrown when an operation on a spack environment is executed without the environment being activated or existent """ -class SpackConcertizeException(SpackException): +class SpackConcertizeException(BashCommandException): """ To be thrown when the spack concretization step fails """ + +class SpackInstallPackagesException(BashCommandException): + """ + To be thrown when the spack fails to install spack packages + """ diff --git a/esd/spack_manager/SpackManager.py b/esd/spack_manager/SpackManager.py index 340b1b95..a7f46c27 100644 --- a/esd/spack_manager/SpackManager.py +++ b/esd/spack_manager/SpackManager.py @@ -1,10 +1,13 @@ import os +import re from abc import ABC, abstractmethod from pathlib import Path -from esd.error_handling.exceptions import BashCommandException, NoSpackEnvironmentException +from esd.error_handling.exceptions import BashCommandException, NoSpackEnvironmentException, \ + SpackInstallPackagesException from esd.logger.logger_builder import get_logger from esd.model.SpackModel import SpackModel +from esd.spack_manager.wrapper.spack_wrapper import no_spack_env from esd.utils.utils import run_command, git_clone_repo @@ -103,10 +106,10 @@ class SpackManager(ABC): else: if self.spack_env_exists(): result = run_command("bash", "-c", - f'source {self.spack_setup_script} && spack env activate -p {self.env_path} && spack repo list', - check=True, - capture_output=True, text=True, logger=self.logger, - debug_msg=f'Checking if repository {repo_name} was added') + f'source {self.spack_setup_script} && spack env activate -p {self.env_path} && spack repo list', + check=True, + capture_output=True, text=True, logger=self.logger, + debug_msg=f'Checking if repository {repo_name} was added') else: self.logger.debug('No spack environment defined') raise NoSpackEnvironmentException('No spack environment defined') @@ -124,18 +127,35 @@ class SpackManager(ABC): return False return True + @no_spack_env def add_spack_repo(self, repo_path: Path, repo_name: str): """Add the Spack repository if it does not exist.""" - if self.spack_env_exists(): - run_command("bash", "-c", - f'source {self.spack_setup_script} && spack env activate -p {self.env_path} && spack repo add {repo_path}/{repo_name}', - check=True, logger=self.logger, - debug_msg=f"Added {repo_name} to spack environment {self.env.env_name}", - exception_msg=f"Failed to add {repo_name} to spack environment {self.env.env_name}", - exception=BashCommandException) - else: - self.logger.debug('No spack environment defined') - raise NoSpackEnvironmentException('No spack environment defined') + run_command("bash", "-c", + f'source {self.spack_setup_script} && spack env activate -p {self.env_path} && spack repo add {repo_path}/{repo_name}', + check=True, logger=self.logger, + debug_msg=f"Added {repo_name} to spack environment {self.env.env_name}", + exception_msg=f"Failed to add {repo_name} to spack environment {self.env.env_name}", + exception=BashCommandException) + + @no_spack_env + def get_compiler_version(self): + result = run_command("bash", "-c", + f'source {self.spack_setup_script} && spack env activate -p {self.env_path} && spack compiler list', + check=True, logger=self.logger, + capture_output=True, text=True, + debug_msg=f"Checking spack environment compiler version for {self.env.env_name}", + exception_msg=f"Failed to checking spack environment compiler version for {self.env.env_name}", + exception=BashCommandException) + # todo add error handling and tests + if result.stdout is None: + self.logger.debug('No gcc found for {self.env.env_name}') + return None + + # Find the first occurrence of a GCC compiler using regex + match = re.search(r"gcc@([\d\.]+)", result.stdout) + gcc_version = match.group(1) + self.logger.debug(f'Found gcc for {self.env.env_name}: {gcc_version}') + return gcc_version def get_spack_installed_version(self): spack_version = run_command("bash", "-c", f'source {self.spack_setup_script} && spack --version', @@ -147,6 +167,7 @@ class SpackManager(ABC): return spack_version.stdout.strip().split()[0] return None + def install_spack(self, spack_version="v0.21.1", spack_repo='https://github.com/spack/spack'): try: user = os.getlogin() diff --git a/esd/spack_manager/wrapper/__init__.py b/esd/spack_manager/wrapper/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/esd/spack_manager/wrapper/spack_wrapper.py b/esd/spack_manager/wrapper/spack_wrapper.py new file mode 100644 index 00000000..d075a317 --- /dev/null +++ b/esd/spack_manager/wrapper/spack_wrapper.py @@ -0,0 +1,15 @@ +import functools + +from esd.error_handling.exceptions import NoSpackEnvironmentException + + +def no_spack_env(method): + @functools.wraps(method) + def wrapper(self, *args, **kwargs): + if self.spack_env_exists(): + return method(self, *args, **kwargs) # Call the method with 'self' + else: + self.logger.debug('No spack environment defined') + raise NoSpackEnvironmentException('No spack environment defined') + + return wrapper -- GitLab From eee415e3245e7232e71f3ac11630b407f01d5390 Mon Sep 17 00:00:00 2001 From: adrianciu <adrian.ciu@codemart.ro> Date: Thu, 6 Feb 2025 18:14:49 +0200 Subject: [PATCH 05/30] esd-spack-installation: fixed passing access token to tests; added log file for spack install step --- dedal/tests/spack_from_scratch_test.py | 37 +++++++++------------- esd/spack_manager/SpackManager.py | 25 +++++++++++---- esd/spack_manager/wrapper/spack_wrapper.py | 2 +- 3 files changed, 35 insertions(+), 29 deletions(-) diff --git a/dedal/tests/spack_from_scratch_test.py b/dedal/tests/spack_from_scratch_test.py index 2131e7df..e5627409 100644 --- a/dedal/tests/spack_from_scratch_test.py +++ b/dedal/tests/spack_from_scratch_test.py @@ -8,6 +8,10 @@ from esd.spack_manager.factory.SpackManagerCreator import SpackManagerCreator from esd.utils.utils import file_exists_and_not_empty SPACK_ENV_ACCESS_TOKEN = os.getenv("SPACK_ENV_ACCESS_TOKEN") +test_spack_env_git = f'https://oauth2:{SPACK_ENV_ACCESS_TOKEN}@gitlab.ebrains.eu/ri/projects-and-initiatives/virtualbraintwin/tools/test-spack-env.git' +ebrains_spack_builds_git = 'https://gitlab.ebrains.eu/ri/tech-hub/platform/esd/ebrains-spack-builds.git' + + def test_spack_repo_exists_1(): spack_manager = SpackManagerCreator.get_spack_manger(SpackManagerEnum.FROM_SCRATCH) @@ -32,8 +36,7 @@ def test_spack_repo_exists_3(tmp_path): def test_spack_from_scratch_setup_1(tmp_path): install_dir = tmp_path - env = SpackModel('ebrains-spack-builds', install_dir, - 'https://gitlab.ebrains.eu/ri/tech-hub/platform/esd/ebrains-spack-builds.git') + env = SpackModel('ebrains-spack-builds', install_dir, ebrains_spack_builds_git) spack_manager = SpackManagerCreator.get_spack_manger(SpackManagerEnum.FROM_SCRATCH, env=env, system_name='ebrainslab') spack_manager.setup_spack_env() @@ -42,8 +45,7 @@ def test_spack_from_scratch_setup_1(tmp_path): def test_spack_from_scratch_setup_2(tmp_path): install_dir = tmp_path - env = SpackModel('ebrains-spack-builds', install_dir, - 'https://gitlab.ebrains.eu/ri/tech-hub/platform/esd/ebrains-spack-builds.git') + env = SpackModel('ebrains-spack-builds', install_dir, ebrains_spack_builds_git) repo = env spack_manager = SpackManagerCreator.get_spack_manger(SpackManagerEnum.FROM_SCRATCH, env=env, repos=[repo, repo], @@ -83,8 +85,7 @@ def test_spack_not_a_valid_repo(): def test_spack_from_scratch_concretize_1(tmp_path): install_dir = tmp_path - env = SpackModel('ebrains-spack-builds', install_dir, - 'https://gitlab.ebrains.eu/ri/tech-hub/platform/esd/ebrains-spack-builds.git') + env = SpackModel('ebrains-spack-builds', install_dir, ebrains_spack_builds_git) repo = env spack_manager = SpackManagerCreator.get_spack_manger(SpackManagerEnum.FROM_SCRATCH, env=env, repos=[repo, repo], system_name='ebrainslab') @@ -96,8 +97,7 @@ def test_spack_from_scratch_concretize_1(tmp_path): def test_spack_from_scratch_concretize_2(tmp_path): install_dir = tmp_path - env = SpackModel('ebrains-spack-builds', install_dir, - 'https://gitlab.ebrains.eu/ri/tech-hub/platform/esd/ebrains-spack-builds.git') + env = SpackModel('ebrains-spack-builds', install_dir, ebrains_spack_builds_git) repo = env spack_manager = SpackManagerCreator.get_spack_manger(SpackManagerEnum.FROM_SCRATCH, env=env, repos=[repo, repo], system_name='ebrainslab') @@ -109,8 +109,7 @@ def test_spack_from_scratch_concretize_2(tmp_path): def test_spack_from_scratch_concretize_3(tmp_path): install_dir = tmp_path - env = SpackModel('ebrains-spack-builds', install_dir, - 'https://gitlab.ebrains.eu/ri/tech-hub/platform/esd/ebrains-spack-builds.git') + env = SpackModel('ebrains-spack-builds', install_dir, ebrains_spack_builds_git) repo = env spack_manager = SpackManagerCreator.get_spack_manger(SpackManagerEnum.FROM_SCRATCH, env=env, repos=[repo, repo], @@ -122,8 +121,7 @@ def test_spack_from_scratch_concretize_3(tmp_path): def test_spack_from_scratch_concretize_4(tmp_path): install_dir = tmp_path - env = SpackModel('test-spack-env', install_dir, - f'https://oauth2:{SPACK_ENV_ACCESS_TOKEN}@gitlab.ebrains.eu/ri/projects-and-initiatives/virtualbraintwin/test-spack-env.git') + env = SpackModel('test-spack-env', install_dir, test_spack_env_git) spack_manager = SpackManagerCreator.get_spack_manger(SpackManagerEnum.FROM_SCRATCH, env=env) spack_manager.setup_spack_env() spack_manager.concretize_spack_env(force=False) @@ -133,8 +131,7 @@ def test_spack_from_scratch_concretize_4(tmp_path): def test_spack_from_scratch_concretize_5(tmp_path): install_dir = tmp_path - env = SpackModel('test-spack-env', install_dir, - f'https://oauth2:{SPACK_ENV_ACCESS_TOKEN}@gitlab.ebrains.eu/ri/projects-and-initiatives/virtualbraintwin/test-spack-env.git') + env = SpackModel('test-spack-env', install_dir, test_spack_env_git) spack_manager = SpackManagerCreator.get_spack_manger(SpackManagerEnum.FROM_SCRATCH, env=env) spack_manager.setup_spack_env() spack_manager.concretize_spack_env(force=True) @@ -144,10 +141,8 @@ def test_spack_from_scratch_concretize_5(tmp_path): def test_spack_from_scratch_concretize_6(tmp_path): install_dir = tmp_path - env = SpackModel('test-spack-env', install_dir, - f'https://oauth2:{SPACK_ENV_ACCESS_TOKEN}@gitlab.ebrains.eu/ri/projects-and-initiatives/virtualbraintwin/test-spack-env.git') - repo = SpackModel('ebrains-spack-builds', install_dir, - 'https://gitlab.ebrains.eu/ri/tech-hub/platform/esd/ebrains-spack-builds.git') + env = SpackModel('test-spack-env', install_dir, test_spack_env_git) + repo = SpackModel('ebrains-spack-builds', install_dir, ebrains_spack_builds_git) spack_manager = SpackManagerCreator.get_spack_manger(SpackManagerEnum.FROM_SCRATCH, env=env, repos=[repo]) spack_manager.setup_spack_env() spack_manager.concretize_spack_env(force=False) @@ -157,10 +152,8 @@ def test_spack_from_scratch_concretize_6(tmp_path): def test_spack_from_scratch_concretize_7(tmp_path): install_dir = tmp_path - env = SpackModel('test-spack-env', install_dir, - 'https://gitlab.ebrains.eu/ri/projects-and-initiatives/virtualbraintwin/test-spack-env.git') - repo = SpackModel('ebrains-spack-builds', install_dir, - 'https://gitlab.ebrains.eu/ri/tech-hub/platform/esd/ebrains-spack-builds.git') + env = SpackModel('test-spack-env', install_dir, test_spack_env_git) + repo = SpackModel('ebrains-spack-builds', install_dir, ebrains_spack_builds_git) spack_manager = SpackManagerCreator.get_spack_manger(SpackManagerEnum.FROM_SCRATCH, env=env, repos=[repo]) spack_manager.setup_spack_env() spack_manager.concretize_spack_env(force=True) diff --git a/esd/spack_manager/SpackManager.py b/esd/spack_manager/SpackManager.py index a7f46c27..d534b30a 100644 --- a/esd/spack_manager/SpackManager.py +++ b/esd/spack_manager/SpackManager.py @@ -2,12 +2,13 @@ import os import re from abc import ABC, abstractmethod from pathlib import Path +from tabnanny import check from esd.error_handling.exceptions import BashCommandException, NoSpackEnvironmentException, \ SpackInstallPackagesException from esd.logger.logger_builder import get_logger from esd.model.SpackModel import SpackModel -from esd.spack_manager.wrapper.spack_wrapper import no_spack_env +from esd.spack_manager.wrapper.spack_wrapper import check_spack_env from esd.utils.utils import run_command, git_clone_repo @@ -53,7 +54,6 @@ class SpackManager(ABC): pass def create_fetch_spack_environment(self): - if self.env.git_path: git_clone_repo(self.env.env_name, self.env.path / self.env.env_name, self.env.git_path, logger=self.logger) else: @@ -127,7 +127,7 @@ class SpackManager(ABC): return False return True - @no_spack_env + @check_spack_env def add_spack_repo(self, repo_path: Path, repo_name: str): """Add the Spack repository if it does not exist.""" run_command("bash", "-c", @@ -137,7 +137,7 @@ class SpackManager(ABC): exception_msg=f"Failed to add {repo_name} to spack environment {self.env.env_name}", exception=BashCommandException) - @no_spack_env + @check_spack_env def get_compiler_version(self): result = run_command("bash", "-c", f'source {self.spack_setup_script} && spack env activate -p {self.env_path} && spack compiler list', @@ -167,8 +167,21 @@ class SpackManager(ABC): return spack_version.stdout.strip().split()[0] return None - - def install_spack(self, spack_version="v0.21.1", spack_repo='https://github.com/spack/spack'): + @check_spack_env + def install_packages(self, jobs: int, signed=True, fresh=False): + signed = '' if signed else '--no-check-signature' + fresh = '--fresh' if fresh else '' + with open(str(Path(os.getcwd()).resolve() / ".generate_cache.log"), "w") as log_file: + run_command("bash", "-c", + f'source {self.spack_setup_script} && spack install --env {self.env.env_name} -v {signed} --j {jobs} {fresh}', + stdout=log_file, + capture_output=True, text=True, check=True, + logger=self.logger, + debug_msg=f"Installing spack packages for {self.env.env_name}", + exception_msg=f"Error installing spack packages for {self.env.env_name}", + exception=SpackInstallPackagesException) + + def install_spack(self, spack_version="v0.22.0", spack_repo='https://github.com/spack/spack'): try: user = os.getlogin() except OSError: diff --git a/esd/spack_manager/wrapper/spack_wrapper.py b/esd/spack_manager/wrapper/spack_wrapper.py index d075a317..c2f9c116 100644 --- a/esd/spack_manager/wrapper/spack_wrapper.py +++ b/esd/spack_manager/wrapper/spack_wrapper.py @@ -3,7 +3,7 @@ import functools from esd.error_handling.exceptions import NoSpackEnvironmentException -def no_spack_env(method): +def check_spack_env(method): @functools.wraps(method) def wrapper(self, *args, **kwargs): if self.spack_env_exists(): -- GitLab From 8b4b484ae4ac77a7a8f8a01bd8826e5d00759979 Mon Sep 17 00:00:00 2001 From: adrianciu <adrian.ciu@codemart.ro> Date: Fri, 7 Feb 2025 18:50:52 +0200 Subject: [PATCH 06/30] esd-spack-installation: spack install method; major refactoring; fixed bug on updating env vars in .bashrc --- dedal/build_cache/BuildCacheManager.py | 62 ++--- .../__init__.py => dedal/cli/SpackManager.py | 0 dedal/tests/spack_from_scratch_test.py | 211 +++++++++++------- dedal/tests/spack_install_test.py | 21 +- dedal/tests/testing_variables.py | 6 + dedal/utils/utils.py | 47 +++- esd/configuration/SpackConfig.py | 25 +++ .../enums => configuration}/__init__.py | 0 .../factory => error_handling}/__init__.py | 0 .../{SpackModel.py => SpackDescriptor.py} | 5 +- .../SpackOperation.py} | 153 ++++++------- esd/spack_factory/SpackOperationCreator.py | 14 ++ esd/spack_factory/SpackOperationUseCache.py | 19 ++ .../wrapper => spack_factory}/__init__.py | 0 esd/spack_manager/enums/SpackManagerEnum.py | 6 - .../factory/SpackManagerBuildCache.py | 19 -- .../factory/SpackManagerCreator.py | 14 -- .../factory/SpackManagerScratch.py | 28 --- esd/wrapper/__init__.py | 0 .../wrapper/spack_wrapper.py | 0 20 files changed, 350 insertions(+), 280 deletions(-) rename esd/spack_manager/__init__.py => dedal/cli/SpackManager.py (100%) create mode 100644 dedal/tests/testing_variables.py create mode 100644 esd/configuration/SpackConfig.py rename esd/{spack_manager/enums => configuration}/__init__.py (100%) rename esd/{spack_manager/factory => error_handling}/__init__.py (100%) rename esd/model/{SpackModel.py => SpackDescriptor.py} (57%) rename esd/{spack_manager/SpackManager.py => spack_factory/SpackOperation.py} (56%) create mode 100644 esd/spack_factory/SpackOperationCreator.py create mode 100644 esd/spack_factory/SpackOperationUseCache.py rename esd/{spack_manager/wrapper => spack_factory}/__init__.py (100%) delete mode 100644 esd/spack_manager/enums/SpackManagerEnum.py delete mode 100644 esd/spack_manager/factory/SpackManagerBuildCache.py delete mode 100644 esd/spack_manager/factory/SpackManagerCreator.py delete mode 100644 esd/spack_manager/factory/SpackManagerScratch.py create mode 100644 esd/wrapper/__init__.py rename esd/{spack_manager => }/wrapper/spack_wrapper.py (100%) diff --git a/dedal/build_cache/BuildCacheManager.py b/dedal/build_cache/BuildCacheManager.py index dbd50bf9..55fa10cb 100644 --- a/dedal/build_cache/BuildCacheManager.py +++ b/dedal/build_cache/BuildCacheManager.py @@ -12,48 +12,51 @@ class BuildCacheManager(BuildCacheManagerInterface): This class aims to manage the push/pull/delete of build cache files """ - def __init__(self, auth_backend='basic', insecure=False): - self.logger = get_logger(__name__, BuildCacheManager.__name__) - self.home_path = Path(os.environ.get("HOME_PATH", os.getcwd())) - self.registry_project = os.environ.get("REGISTRY_PROJECT") + def __init__(self, registry_host, registry_project, registry_username, registry_password, cache_version='cache', + auth_backend='basic', + insecure=False): + self._logger = get_logger(__name__, BuildCacheManager.__name__) + self._registry_project = registry_project - self._registry_username = str(os.environ.get("REGISTRY_USERNAME")) - self._registry_password = str(os.environ.get("REGISTRY_PASSWORD")) + self._registry_username = registry_username + self._registry_password = registry_password - self.registry_host = str(os.environ.get("REGISTRY_HOST")) + self._registry_host = registry_host # Initialize an OrasClient instance. # This method utilizes the OCI Registry for container image and artifact management. # Refer to the official OCI Registry documentation for detailed information on the available authentication methods. # Supported authentication types may include basic authentication (username/password), token-based authentication, - self.client = oras.client.OrasClient(hostname=self.registry_host, auth_backend=auth_backend, insecure=insecure) - self.client.login(username=self._registry_username, password=self._registry_password) - self.oci_registry_path = f'{self.registry_host}/{self.registry_project}/cache' + self._client = oras.client.OrasClient(hostname=self._registry_host, auth_backend=auth_backend, + insecure=insecure) + self._client.login(username=self._registry_username, password=self._registry_password) + self.cache_version = cache_version + self._oci_registry_path = f'{self._registry_host}/{self._registry_project}/{self.cache_version}' def upload(self, out_dir: Path): """ This method pushed all the files from the build cache folder into the OCI Registry """ - build_cache_path = self.home_path / out_dir + build_cache_path = out_dir.resolve() # build cache folder must exist before pushing all the artifacts if not build_cache_path.exists(): - self.logger.error(f"Path {build_cache_path} not found.") + self._logger.error(f"Path {build_cache_path} not found.") for sub_path in build_cache_path.rglob("*"): if sub_path.is_file(): - rel_path = str(sub_path.relative_to(build_cache_path)).replace(str(sub_path.env_name), "") - target = f"{self.registry_host}/{self.registry_project}/cache:{str(sub_path.env_name)}" + rel_path = str(sub_path.relative_to(build_cache_path)).replace(str(sub_path.name), "") + target = f"{self._registry_host}/{self._registry_project}/{self.cache_version}:{str(sub_path.name)}" try: - self.logger.info(f"Pushing folder '{sub_path}' to ORAS target '{target}' ...") - self.client.push( + self._logger.info(f"Pushing folder '{sub_path}' to ORAS target '{target}' ...") + self._client.push( files=[str(sub_path)], target=target, # save in manifest the relative path for reconstruction manifest_annotations={"path": rel_path}, disable_path_validation=True, ) - self.logger.info(f"Successfully pushed {sub_path.env_name}") + self._logger.info(f"Successfully pushed {sub_path.name}") except Exception as e: - self.logger.error( + self._logger.error( f"An error occurred while pushing: {e}") # todo to be discussed hot to delete the build cache after being pushed to the OCI Registry # clean_up([str(build_cache_path)], self.logger) @@ -63,37 +66,38 @@ class BuildCacheManager(BuildCacheManagerInterface): This method retrieves all tags from an OCI Registry """ try: - return self.client.get_tags(self.oci_registry_path) + return self._client.get_tags(self._oci_registry_path) except Exception as e: - self.logger.error(f"Failed to list tags: {e}") + self._logger.error(f"Failed to list tags: {e}") return None def download(self, in_dir: Path): """ This method pulls all the files from the OCI Registry into the build cache folder """ - build_cache_path = self.home_path / in_dir + build_cache_path = in_dir.resolve() # create the buildcache dir if it does not exist os.makedirs(build_cache_path, exist_ok=True) tags = self.list_tags() if tags is not None: for tag in tags: - ref = f"{self.registry_host}/{self.registry_project}/cache:{tag}" + ref = f"{self._registry_host}/{self._registry_project}/{self.cache_version}:{tag}" # reconstruct the relative path of each artifact by getting it from the manifest cache_path = \ - self.client.get_manifest(f'{self.registry_host}/{self.registry_project}/cache:{tag}')[ + self._client.get_manifest( + f'{self._registry_host}/{self._registry_project}/{self.cache_version}:{tag}')[ 'annotations'][ 'path'] try: - self.client.pull( + self._client.pull( ref, # missing dirs to output dir are created automatically by OrasClient pull method outdir=str(build_cache_path / cache_path), overwrite=True ) - self.logger.info(f"Successfully pulled artifact {tag}.") + self._logger.info(f"Successfully pulled artifact {tag}.") except Exception as e: - self.logger.error( + self._logger.error( f"Failed to pull artifact {tag} : {e}") def delete(self): @@ -106,8 +110,8 @@ class BuildCacheManager(BuildCacheManagerInterface): tags = self.list_tags() if tags is not None: try: - self.client.delete_tags(self.oci_registry_path, tags) - self.logger.info(f"Successfully deleted all artifacts form OCI registry.") + self._client.delete_tags(self._oci_registry_path, tags) + self._logger.info(f"Successfully deleted all artifacts form OCI registry.") except RuntimeError as e: - self.logger.error( + self._logger.error( f"Failed to delete artifacts: {e}") diff --git a/esd/spack_manager/__init__.py b/dedal/cli/SpackManager.py similarity index 100% rename from esd/spack_manager/__init__.py rename to dedal/cli/SpackManager.py diff --git a/dedal/tests/spack_from_scratch_test.py b/dedal/tests/spack_from_scratch_test.py index e5627409..cdc405e7 100644 --- a/dedal/tests/spack_from_scratch_test.py +++ b/dedal/tests/spack_from_scratch_test.py @@ -1,161 +1,204 @@ -import os from pathlib import Path import pytest +from esd.configuration.SpackConfig import SpackConfig from esd.error_handling.exceptions import BashCommandException, NoSpackEnvironmentException -from esd.model.SpackModel import SpackModel -from esd.spack_manager.enums.SpackManagerEnum import SpackManagerEnum -from esd.spack_manager.factory.SpackManagerCreator import SpackManagerCreator +from esd.spack_factory.SpackOperationCreator import SpackOperationCreator +from esd.model.SpackDescriptor import SpackDescriptor +from esd.tests.testing_variables import test_spack_env_git, ebrains_spack_builds_git from esd.utils.utils import file_exists_and_not_empty -SPACK_ENV_ACCESS_TOKEN = os.getenv("SPACK_ENV_ACCESS_TOKEN") -test_spack_env_git = f'https://oauth2:{SPACK_ENV_ACCESS_TOKEN}@gitlab.ebrains.eu/ri/projects-and-initiatives/virtualbraintwin/tools/test-spack-env.git' -ebrains_spack_builds_git = 'https://gitlab.ebrains.eu/ri/tech-hub/platform/esd/ebrains-spack-builds.git' - - def test_spack_repo_exists_1(): - spack_manager = SpackManagerCreator.get_spack_manger(SpackManagerEnum.FROM_SCRATCH) - assert spack_manager.spack_repo_exists('ebrains-spack-builds') == False + spack_operation = SpackOperationCreator.get_spack_operator() + spack_operation.install_spack() + assert spack_operation.spack_repo_exists('ebrains-spack-builds') == False def test_spack_repo_exists_2(tmp_path): install_dir = tmp_path - env = SpackModel('ebrains-spack-builds', install_dir) - spack_manager = SpackManagerCreator.get_spack_manger(SpackManagerEnum.FROM_SCRATCH, env=env) + env = SpackDescriptor('ebrains-spack-builds', install_dir) + config = SpackConfig(env=env, install_dir=install_dir) + spack_operation = SpackOperationCreator.get_spack_operator(config) + spack_operation.install_spack() with pytest.raises(NoSpackEnvironmentException): - spack_manager.spack_repo_exists(env.env_name) + spack_operation.spack_repo_exists(env.env_name) def test_spack_repo_exists_3(tmp_path): install_dir = tmp_path - env = SpackModel('ebrains-spack-builds', install_dir) - spack_manager = SpackManagerCreator.get_spack_manger(SpackManagerEnum.FROM_SCRATCH, env=env) - spack_manager.setup_spack_env() - assert spack_manager.spack_repo_exists(env.env_name) == False + env = SpackDescriptor('ebrains-spack-builds', install_dir) + config = SpackConfig(env=env, install_dir=install_dir) + spack_operation = SpackOperationCreator.get_spack_operator(config) + spack_operation.install_spack() + print(spack_operation.get_spack_installed_version()) + spack_operation.setup_spack_env() + assert spack_operation.spack_repo_exists(env.env_name) == False def test_spack_from_scratch_setup_1(tmp_path): install_dir = tmp_path - env = SpackModel('ebrains-spack-builds', install_dir, ebrains_spack_builds_git) - spack_manager = SpackManagerCreator.get_spack_manger(SpackManagerEnum.FROM_SCRATCH, env=env, - system_name='ebrainslab') - spack_manager.setup_spack_env() - assert spack_manager.spack_repo_exists(env.env_name) == False + env = SpackDescriptor('ebrains-spack-builds', install_dir, ebrains_spack_builds_git) + config = SpackConfig(env=env, system_name='ebrainslab', install_dir=install_dir) + spack_operation = SpackOperationCreator.get_spack_operator(config) + spack_operation.install_spack() + spack_operation.setup_spack_env() + assert spack_operation.spack_repo_exists(env.env_name) == False def test_spack_from_scratch_setup_2(tmp_path): install_dir = tmp_path - env = SpackModel('ebrains-spack-builds', install_dir, ebrains_spack_builds_git) + env = SpackDescriptor('ebrains-spack-builds', install_dir, ebrains_spack_builds_git) repo = env - spack_manager = SpackManagerCreator.get_spack_manger(SpackManagerEnum.FROM_SCRATCH, env=env, - repos=[repo, repo], - system_name='ebrainslab') - spack_manager.setup_spack_env() - assert spack_manager.spack_repo_exists(env.env_name) == True + config = SpackConfig(env=env, system_name='ebrainslab', install_dir=install_dir) + config.add_repo(repo) + config.add_repo(repo) + spack_operation = SpackOperationCreator.get_spack_operator(config) + spack_operation.install_spack() + spack_operation.setup_spack_env() + assert spack_operation.spack_repo_exists(env.env_name) == True def test_spack_from_scratch_setup_3(tmp_path): install_dir = tmp_path - env = SpackModel('new_env1', install_dir) + env = SpackDescriptor('new_env1', install_dir) repo = env - spack_manager = SpackManagerCreator.get_spack_manger(SpackManagerEnum.FROM_SCRATCH, env=env, - repos=[repo, repo], - system_name='ebrainslab') + config = SpackConfig(env=env, system_name='ebrainslab', install_dir=install_dir) + config.add_repo(repo) + config.add_repo(repo) + spack_operation = SpackOperationCreator.get_spack_operator(config) + spack_operation.install_spack() with pytest.raises(BashCommandException): - spack_manager.setup_spack_env() + spack_operation.setup_spack_env() def test_spack_from_scratch_setup_4(tmp_path): install_dir = tmp_path - env = SpackModel('new_env2', install_dir) - spack_manager = SpackManagerCreator.get_spack_manger(SpackManagerEnum.FROM_SCRATCH, env=env) - spack_manager.setup_spack_env() - assert spack_manager.spack_env_exists() == True + env = SpackDescriptor('new_env2', install_dir) + config = SpackConfig(env=env, install_dir=install_dir) + spack_operation = SpackOperationCreator.get_spack_operator(config) + spack_operation.install_spack() + spack_operation.setup_spack_env() + assert spack_operation.spack_env_exists() == True def test_spack_not_a_valid_repo(): - env = SpackModel('ebrains-spack-builds', Path(), None) + env = SpackDescriptor('ebrains-spack-builds', Path(), None) repo = env - spack_manager = SpackManagerCreator.get_spack_manger(SpackManagerEnum.FROM_SCRATCH, env=env, - repos=[repo], - system_name='ebrainslab') - with pytest.raises(NoSpackEnvironmentException): - spack_manager.add_spack_repo(repo.path, repo.env_name) + config = SpackConfig(env=env, system_name='ebrainslab') + config.add_repo(repo) + spack_operation = SpackOperationCreator.get_spack_operator(config) + with pytest.raises(BashCommandException): + spack_operation.add_spack_repo(repo.path, repo.env_name) def test_spack_from_scratch_concretize_1(tmp_path): install_dir = tmp_path - env = SpackModel('ebrains-spack-builds', install_dir, ebrains_spack_builds_git) + env = SpackDescriptor('ebrains-spack-builds', install_dir, ebrains_spack_builds_git) repo = env - spack_manager = SpackManagerCreator.get_spack_manger(SpackManagerEnum.FROM_SCRATCH, env=env, repos=[repo, repo], - system_name='ebrainslab') - spack_manager.setup_spack_env() - spack_manager.concretize_spack_env(force=True) - concretization_file_path = spack_manager.env_path / 'spack.lock' + config = SpackConfig(env=env, system_name='ebrainslab', install_dir=install_dir) + config.add_repo(repo) + config.add_repo(repo) + spack_operation = SpackOperationCreator.get_spack_operator(config) + spack_operation.install_spack() + spack_operation.install_spack() + spack_operation.setup_spack_env() + spack_operation.concretize_spack_env(force=True) + concretization_file_path = spack_operation.env_path / 'spack.lock' assert file_exists_and_not_empty(concretization_file_path) == True def test_spack_from_scratch_concretize_2(tmp_path): install_dir = tmp_path - env = SpackModel('ebrains-spack-builds', install_dir, ebrains_spack_builds_git) + env = SpackDescriptor('ebrains-spack-builds', install_dir, ebrains_spack_builds_git) repo = env - spack_manager = SpackManagerCreator.get_spack_manger(SpackManagerEnum.FROM_SCRATCH, env=env, repos=[repo, repo], - system_name='ebrainslab') - spack_manager.setup_spack_env() - spack_manager.concretize_spack_env(force=False) - concretization_file_path = spack_manager.env_path / 'spack.lock' + config = SpackConfig(env=env, system_name='ebrainslab', install_dir=install_dir) + config.add_repo(repo) + config.add_repo(repo) + spack_operation = SpackOperationCreator.get_spack_operator(config) + spack_operation.install_spack() + spack_operation.setup_spack_env() + spack_operation.concretize_spack_env(force=False) + concretization_file_path = spack_operation.env_path / 'spack.lock' assert file_exists_and_not_empty(concretization_file_path) == True def test_spack_from_scratch_concretize_3(tmp_path): install_dir = tmp_path - env = SpackModel('ebrains-spack-builds', install_dir, ebrains_spack_builds_git) + env = SpackDescriptor('ebrains-spack-builds', install_dir, ebrains_spack_builds_git) repo = env - spack_manager = SpackManagerCreator.get_spack_manger(SpackManagerEnum.FROM_SCRATCH, env=env, - repos=[repo, repo], - system_name='ebrainslab') - spack_manager.setup_spack_env() - concretization_file_path = spack_manager.env_path / 'spack.lock' + config = SpackConfig(env=env, system_name='ebrainslab', install_dir=install_dir) + config.add_repo(repo) + config.add_repo(repo) + spack_operation = SpackOperationCreator.get_spack_operator(config) + spack_operation.install_spack() + spack_operation.setup_spack_env() + concretization_file_path = spack_operation.env_path / 'spack.lock' assert file_exists_and_not_empty(concretization_file_path) == False def test_spack_from_scratch_concretize_4(tmp_path): install_dir = tmp_path - env = SpackModel('test-spack-env', install_dir, test_spack_env_git) - spack_manager = SpackManagerCreator.get_spack_manger(SpackManagerEnum.FROM_SCRATCH, env=env) - spack_manager.setup_spack_env() - spack_manager.concretize_spack_env(force=False) - concretization_file_path = spack_manager.env_path / 'spack.lock' + env = SpackDescriptor('test-spack-env', install_dir, test_spack_env_git) + config = SpackConfig(env=env, install_dir=install_dir) + spack_operation = SpackOperationCreator.get_spack_operator(config) + spack_operation.install_spack() + spack_operation.setup_spack_env() + spack_operation.concretize_spack_env(force=False) + concretization_file_path = spack_operation.env_path / 'spack.lock' assert file_exists_and_not_empty(concretization_file_path) == True def test_spack_from_scratch_concretize_5(tmp_path): install_dir = tmp_path - env = SpackModel('test-spack-env', install_dir, test_spack_env_git) - spack_manager = SpackManagerCreator.get_spack_manger(SpackManagerEnum.FROM_SCRATCH, env=env) - spack_manager.setup_spack_env() - spack_manager.concretize_spack_env(force=True) - concretization_file_path = spack_manager.env_path / 'spack.lock' + env = SpackDescriptor('test-spack-env', install_dir, test_spack_env_git) + config = SpackConfig(env=env, install_dir=install_dir) + spack_operation = SpackOperationCreator.get_spack_operator(config) + spack_operation.install_spack() + spack_operation.setup_spack_env() + spack_operation.concretize_spack_env(force=True) + concretization_file_path = spack_operation.env_path / 'spack.lock' assert file_exists_and_not_empty(concretization_file_path) == True def test_spack_from_scratch_concretize_6(tmp_path): install_dir = tmp_path - env = SpackModel('test-spack-env', install_dir, test_spack_env_git) - repo = SpackModel('ebrains-spack-builds', install_dir, ebrains_spack_builds_git) - spack_manager = SpackManagerCreator.get_spack_manger(SpackManagerEnum.FROM_SCRATCH, env=env, repos=[repo]) - spack_manager.setup_spack_env() - spack_manager.concretize_spack_env(force=False) - concretization_file_path = spack_manager.env_path / 'spack.lock' + env = SpackDescriptor('test-spack-env', install_dir, test_spack_env_git) + repo = SpackDescriptor('ebrains-spack-builds', install_dir, ebrains_spack_builds_git) + config = SpackConfig(env=env, install_dir=install_dir) + config.add_repo(repo) + spack_operation = SpackOperationCreator.get_spack_operator(config) + spack_operation.install_spack() + spack_operation.setup_spack_env() + spack_operation.concretize_spack_env(force=False) + concretization_file_path = spack_operation.env_path / 'spack.lock' assert file_exists_and_not_empty(concretization_file_path) == True def test_spack_from_scratch_concretize_7(tmp_path): install_dir = tmp_path - env = SpackModel('test-spack-env', install_dir, test_spack_env_git) - repo = SpackModel('ebrains-spack-builds', install_dir, ebrains_spack_builds_git) - spack_manager = SpackManagerCreator.get_spack_manger(SpackManagerEnum.FROM_SCRATCH, env=env, repos=[repo]) - spack_manager.setup_spack_env() - spack_manager.concretize_spack_env(force=True) - concretization_file_path = spack_manager.env_path / 'spack.lock' + env = SpackDescriptor('test-spack-env', install_dir, test_spack_env_git) + repo = SpackDescriptor('ebrains-spack-builds', install_dir, ebrains_spack_builds_git) + config = SpackConfig(env=env) + config.add_repo(repo) + spack_operation = SpackOperationCreator.get_spack_operator(config) + spack_operation.install_spack() + spack_operation.setup_spack_env() + spack_operation.concretize_spack_env(force=True) + concretization_file_path = spack_operation.env_path / 'spack.lock' + assert file_exists_and_not_empty(concretization_file_path) == True + + +def test_spack_from_scratch_install(tmp_path): + install_dir = tmp_path + env = SpackDescriptor('test-spack-env', install_dir, test_spack_env_git) + repo = SpackDescriptor('ebrains-spack-builds', install_dir, ebrains_spack_builds_git) + config = SpackConfig(env=env) + config.add_repo(repo) + spack_operation = SpackOperationCreator.get_spack_operator(config) + spack_operation.install_spack() + spack_operation.setup_spack_env() + spack_operation.concretize_spack_env(force=True) + concretization_file_path = spack_operation.env_path / 'spack.lock' assert file_exists_and_not_empty(concretization_file_path) == True + install_result = spack_operation.install_packages(jobs=2, signed=False, fresh=True, debug=False) + assert install_result.returncode == 0 diff --git a/dedal/tests/spack_install_test.py b/dedal/tests/spack_install_test.py index 9a32d5c7..28f8268e 100644 --- a/dedal/tests/spack_install_test.py +++ b/dedal/tests/spack_install_test.py @@ -1,21 +1,12 @@ import pytest +from esd.spack_factory.SpackOperation import SpackOperation +from esd.tests.testing_variables import SPACK_VERSION -from esd.spack_manager.factory.SpackManagerBuildCache import SpackManagerBuildCache -from esd.spack_manager.factory.SpackManagerScratch import SpackManagerScratch - -SPACK_VERSION = "0.22.0" - -# we need this test to run first so that spack is installed only once for all the tests +# run this test first so that spack is installed only once for all the tests @pytest.mark.run(order=1) def test_spack_install_scratch(): - spack_manager = SpackManagerScratch() - spack_manager.install_spack(spack_version=f'v{SPACK_VERSION}') - installed_spack_version = spack_manager.get_spack_installed_version() - assert SPACK_VERSION == installed_spack_version - - -def test_spack_install_buildcache(): - spack_manager = SpackManagerBuildCache() - installed_spack_version = spack_manager.get_spack_installed_version() + spack_operation = SpackOperation() + spack_operation.install_spack(spack_version=f'v{SPACK_VERSION}') + installed_spack_version = spack_operation.get_spack_installed_version() assert SPACK_VERSION == installed_spack_version diff --git a/dedal/tests/testing_variables.py b/dedal/tests/testing_variables.py new file mode 100644 index 00000000..ab95bfa1 --- /dev/null +++ b/dedal/tests/testing_variables.py @@ -0,0 +1,6 @@ +import os + +ebrains_spack_builds_git = 'https://gitlab.ebrains.eu/ri/tech-hub/platform/esd/ebrains-spack-builds.git' +SPACK_VERSION = "0.22.0" +SPACK_ENV_ACCESS_TOKEN = os.getenv("SPACK_ENV_ACCESS_TOKEN") +test_spack_env_git = f'https://oauth2:{SPACK_ENV_ACCESS_TOKEN}@gitlab.ebrains.eu/ri/projects-and-initiatives/virtualbraintwin/tools/test-spack-env.git' diff --git a/dedal/utils/utils.py b/dedal/utils/utils.py index 173347d0..033cbc54 100644 --- a/dedal/utils/utils.py +++ b/dedal/utils/utils.py @@ -1,33 +1,35 @@ import logging +import os import shutil import subprocess from pathlib import Path from esd.error_handling.exceptions import BashCommandException +import re -def clean_up(dirs: list[str], logging, ignore_errors=True): +def clean_up(dirs: list[str], logger: logging = logging.getLogger(__name__), ignore_errors=True): """ All the folders from the list dirs are removed with all the content in them """ for cleanup_dir in dirs: cleanup_dir = Path(cleanup_dir).resolve() if cleanup_dir.exists(): - logging.info(f"Removing {cleanup_dir}") + logger.info(f"Removing {cleanup_dir}") try: shutil.rmtree(Path(cleanup_dir)) except OSError as e: - logging.error(f"Failed to remove {cleanup_dir}: {e}") + logger.error(f"Failed to remove {cleanup_dir}: {e}") if not ignore_errors: raise e else: - logging.info(f"{cleanup_dir} does not exist") + logger.info(f"{cleanup_dir} does not exist") -def run_command(*args, logger=logging.getLogger(__name__), debug_msg: str = '', exception_msg: str = None, +def run_command(*args, logger=logging.getLogger(__name__), info_msg: str = '', exception_msg: str = None, exception=None, **kwargs): try: - logger.debug(f'{debug_msg}: args: {args}') + logger.info(f'{info_msg}: args: {args}') return subprocess.run(args, **kwargs) except subprocess.CalledProcessError as e: if exception_msg is not None: @@ -46,12 +48,41 @@ def git_clone_repo(repo_name: str, dir: Path, git_path: str, logger: logging = l "-c", "feature.manyFiles=true", git_path, dir , check=True, logger=logger, - debug_msg=f'Cloned repository {repo_name}', + info_msg=f'Cloned repository {repo_name}', exception_msg=f'Failed to clone repository: {repo_name}', exception=BashCommandException) else: - logger.debug(f'Repository {repo_name} already cloned.') + logger.info(f'Repository {repo_name} already cloned.') def file_exists_and_not_empty(file: Path) -> bool: return file.is_file() and file.stat().st_size > 0 + + +def log_command(results, log_file: str): + with open(log_file, "w") as log_file: + log_file.write(results.stdout) + log_file.write("\n--- STDERR ---\n") + log_file.write(results.stderr) + + +def set_bashrc_variable(var_name: str, value: str, bashrc_path: str = os.path.expanduser("~/.bashrc"), + logger: logging = logging.getLogger(__name__)): + """Update or add an environment variable in ~/.bashrc.""" + with open(bashrc_path, "r") as file: + lines = file.readlines() + pattern = re.compile(rf'^\s*export\s+{var_name}=.*$') + updated = False + # Modify the existing variable if found + for i, line in enumerate(lines): + if pattern.match(line): + lines[i] = f'export {var_name}={value}\n' + updated = True + break + if not updated: + lines.append(f'\nexport {var_name}={value}\n') + logger.info(f"Added in {bashrc_path} with: export {var_name}={value}") + else: + logger.info(f"Updated {bashrc_path} with: export {var_name}={value}") + with open(bashrc_path, "w") as file: + file.writelines(lines) diff --git a/esd/configuration/SpackConfig.py b/esd/configuration/SpackConfig.py new file mode 100644 index 00000000..93a2e874 --- /dev/null +++ b/esd/configuration/SpackConfig.py @@ -0,0 +1,25 @@ +import os +from pathlib import Path +from esd.model import SpackDescriptor + + +class SpackConfig: + def __init__(self, env: SpackDescriptor = None, repos: list[SpackDescriptor] = None, + install_dir=Path(os.getcwd()).resolve(), upstream_instance=None, system_name=None, + concretization_dir: Path = None, buildcache_dir: Path = None): + self.env = env + if repos is None: + self.repos = [] + else: + self.repos = repos + self.install_dir = install_dir + self.upstream_instance = upstream_instance + self.system_name = system_name + self.concretization_dir = concretization_dir + self.buildcache_dir = buildcache_dir + + def add_repo(self, repo: SpackDescriptor): + if self.repos is None: + self.repos = [] + else: + self.repos.append(repo) diff --git a/esd/spack_manager/enums/__init__.py b/esd/configuration/__init__.py similarity index 100% rename from esd/spack_manager/enums/__init__.py rename to esd/configuration/__init__.py diff --git a/esd/spack_manager/factory/__init__.py b/esd/error_handling/__init__.py similarity index 100% rename from esd/spack_manager/factory/__init__.py rename to esd/error_handling/__init__.py diff --git a/esd/model/SpackModel.py b/esd/model/SpackDescriptor.py similarity index 57% rename from esd/model/SpackModel.py rename to esd/model/SpackDescriptor.py index 4b065dba..70e484fb 100644 --- a/esd/model/SpackModel.py +++ b/esd/model/SpackDescriptor.py @@ -1,12 +1,13 @@ +import os from pathlib import Path -class SpackModel: +class SpackDescriptor: """" Provides details about the spack environment """ - def __init__(self, env_name: str, path: Path, git_path: str = None): + def __init__(self, env_name: str, path: Path = Path(os.getcwd()).resolve(), git_path: str = None): self.env_name = env_name self.path = path self.git_path = git_path diff --git a/esd/spack_manager/SpackManager.py b/esd/spack_factory/SpackOperation.py similarity index 56% rename from esd/spack_manager/SpackManager.py rename to esd/spack_factory/SpackOperation.py index d534b30a..29f44f49 100644 --- a/esd/spack_manager/SpackManager.py +++ b/esd/spack_factory/SpackOperation.py @@ -1,68 +1,59 @@ import os import re +import subprocess from abc import ABC, abstractmethod from pathlib import Path -from tabnanny import check - from esd.error_handling.exceptions import BashCommandException, NoSpackEnvironmentException, \ - SpackInstallPackagesException + SpackInstallPackagesException, SpackConcertizeException from esd.logger.logger_builder import get_logger -from esd.model.SpackModel import SpackModel -from esd.spack_manager.wrapper.spack_wrapper import check_spack_env -from esd.utils.utils import run_command, git_clone_repo +from esd.configuration.SpackConfig import SpackConfig +from esd.tests.testing_variables import SPACK_VERSION +from esd.wrapper.spack_wrapper import check_spack_env +from esd.utils.utils import run_command, git_clone_repo, log_command, set_bashrc_variable -class SpackManager(ABC): +class SpackOperation(ABC): """ This class should implement the methods necessary for installing spack, set up an environment, concretize and install packages. Factory design pattern is used because there are 2 cases: creating an environment from scratch or creating an environment from the buildcache. Attributes: ----------- - env : SpackModel + env : SpackDescriptor spack environment details - repos : list[SpackModel] + repos : list[SpackDescriptor] upstream_instance : str path to Spack instance to use as upstream (optional) """ - def __init__(self, env: SpackModel = None, repos=None, - upstream_instance=None, system_name: str = None, logger=get_logger(__name__)): - if repos is None: - repos = [] - self.repos = repos - self.env = env - self.install_dir = Path(os.environ.get("INSTALLATION_ROOT") or os.getcwd()).resolve() - self.install_dir.mkdir(parents=True, exist_ok=True) - self.env_path = None - if self.env and self.env.path: - self.env.path = self.env.path.resolve() - self.env.path.mkdir(parents=True, exist_ok=True) - self.env_path = self.env.path / self.env.env_name - self.upstream_instance = upstream_instance - self.spack_dir = self.install_dir / "spack" - self.spack_setup_script = self.spack_dir / "share" / "spack" / "setup-env.sh" + def __init__(self, spack_config: SpackConfig = SpackConfig(), logger=get_logger(__name__)): + self.spack_config = spack_config + self.spack_config.install_dir.mkdir(parents=True, exist_ok=True) + self.spack_dir = self.spack_config.install_dir / 'spack' + self.spack_setup_script = self.spack_dir / 'share' / 'spack' / 'setup-env.sh' self.logger = logger - self.system_name = system_name + if self.spack_config.env and spack_config.env.path: + self.spack_config.env.path = spack_config.env.path.resolve() + self.spack_config.env.path.mkdir(parents=True, exist_ok=True) + self.env_path = spack_config.env.path / spack_config.env.env_name + self.spack_command_on_env = f'source {self.spack_setup_script} && spack env activate -p {self.env_path}' @abstractmethod def concretize_spack_env(self, force=True): pass - @abstractmethod - def install_spack_packages(self, jobs: 3, verbose=False, debug=False): - pass - def create_fetch_spack_environment(self): - if self.env.git_path: - git_clone_repo(self.env.env_name, self.env.path / self.env.env_name, self.env.git_path, logger=self.logger) + if self.spack_config.env.git_path: + git_clone_repo(self.spack_config.env.env_name, self.spack_config.env.path / self.spack_config.env.env_name, + self.spack_config.env.git_path, + logger=self.logger) else: - os.makedirs(self.env.path / self.env.env_name, exist_ok=True) + os.makedirs(self.spack_config.env.path / self.spack_config.env.env_name, exist_ok=True) run_command("bash", "-c", f'source {self.spack_setup_script} && spack env create -d {self.env_path}', check=True, logger=self.logger, - debug_msg=f"Created {self.env.env_name} spack environment", - exception_msg=f"Failed to create {self.env.env_name} spack environment", + info_msg=f"Created {self.spack_config.env.env_name} spack environment", + exception_msg=f"Failed to create {self.spack_config.env.env_name} spack environment", exception=BashCommandException) def setup_spack_env(self): @@ -70,22 +61,20 @@ class SpackManager(ABC): This method prepares a spack environment by fetching/creating the spack environment and adding the necessary repos """ bashrc_path = os.path.expanduser("~/.bashrc") - if self.system_name: - with open(bashrc_path, "a") as bashrc: - bashrc.write(f'export SYSTEMNAME="{self.system_name}"\n') - os.environ['SYSTEMNAME'] = self.system_name + if self.spack_config.system_name: + set_bashrc_variable('SYSTEMNAME', self.spack_config.system_name, bashrc_path, logger=self.logger) + os.environ['SYSTEMNAME'] = self.spack_config.system_name if self.spack_dir.exists() and self.spack_dir.is_dir(): - with open(bashrc_path, "a") as bashrc: - bashrc.write(f'export SPACK_USER_CACHE_PATH="{str(self.spack_dir / ".spack")}"\n') - bashrc.write(f'export SPACK_USER_CONFIG_PATH="{str(self.spack_dir / ".spack")}"\n') + set_bashrc_variable('SPACK_USER_CACHE_PATH', str(self.spack_dir / ".spack"), bashrc_path, logger=self.logger) + set_bashrc_variable('SPACK_USER_CONFIG_PATH', str(self.spack_dir / ".spack"), bashrc_path, logger=self.logger) self.logger.debug('Added env variables SPACK_USER_CACHE_PATH and SPACK_USER_CONFIG_PATH') else: self.logger.error(f'Invalid installation path: {self.spack_dir}') # Restart the bash after adding environment variables self.create_fetch_spack_environment() - if self.install_dir.exists(): - for repo in self.repos: - repo_dir = self.install_dir / repo.path / repo.env_name + if self.spack_config.install_dir.exists(): + for repo in self.spack_config.repos: + repo_dir = self.spack_config.install_dir / repo.path / repo.env_name git_clone_repo(repo.env_name, repo_dir, repo.git_path, logger=self.logger) if not self.spack_repo_exists(repo.env_name): self.add_spack_repo(repo.path, repo.env_name) @@ -95,21 +84,21 @@ class SpackManager(ABC): def spack_repo_exists(self, repo_name: str) -> bool | None: """Check if the given Spack repository exists.""" - if self.env is None: + if self.spack_config.env is None: result = run_command("bash", "-c", f'source {self.spack_setup_script} && spack repo list', check=True, capture_output=True, text=True, logger=self.logger, - debug_msg=f'Checking if {repo_name} exists') + info_msg=f'Checking if {repo_name} exists') if result is None: return False else: if self.spack_env_exists(): result = run_command("bash", "-c", - f'source {self.spack_setup_script} && spack env activate -p {self.env_path} && spack repo list', + f'{self.spack_command_on_env} && spack repo list', check=True, capture_output=True, text=True, logger=self.logger, - debug_msg=f'Checking if repository {repo_name} was added') + info_msg=f'Checking if repository {repo_name} was added') else: self.logger.debug('No spack environment defined') raise NoSpackEnvironmentException('No spack environment defined') @@ -119,10 +108,10 @@ class SpackManager(ABC): def spack_env_exists(self): result = run_command("bash", "-c", - f'source {self.spack_setup_script} && spack env activate -p {self.env_path}', + self.spack_command_on_env, check=True, capture_output=True, text=True, logger=self.logger, - debug_msg=f'Checking if environment {self.env.env_name} exists') + info_msg=f'Checking if environment {self.spack_config.env.env_name} exists') if result is None: return False return True @@ -131,20 +120,20 @@ class SpackManager(ABC): def add_spack_repo(self, repo_path: Path, repo_name: str): """Add the Spack repository if it does not exist.""" run_command("bash", "-c", - f'source {self.spack_setup_script} && spack env activate -p {self.env_path} && spack repo add {repo_path}/{repo_name}', + f'{self.spack_command_on_env} && spack repo add {repo_path}/{repo_name}', check=True, logger=self.logger, - debug_msg=f"Added {repo_name} to spack environment {self.env.env_name}", - exception_msg=f"Failed to add {repo_name} to spack environment {self.env.env_name}", + info_msg=f"Added {repo_name} to spack environment {self.spack_config.env.env_name}", + exception_msg=f"Failed to add {repo_name} to spack environment {self.spack_config.env.env_name}", exception=BashCommandException) @check_spack_env def get_compiler_version(self): result = run_command("bash", "-c", - f'source {self.spack_setup_script} && spack env activate -p {self.env_path} && spack compiler list', + f'{self.spack_command_on_env} && spack compiler list', check=True, logger=self.logger, capture_output=True, text=True, - debug_msg=f"Checking spack environment compiler version for {self.env.env_name}", - exception_msg=f"Failed to checking spack environment compiler version for {self.env.env_name}", + info_msg=f"Checking spack environment compiler version for {self.spack_config.env.env_name}", + exception_msg=f"Failed to checking spack environment compiler version for {self.spack_config.env.env_name}", exception=BashCommandException) # todo add error handling and tests if result.stdout is None: @@ -154,34 +143,48 @@ class SpackManager(ABC): # Find the first occurrence of a GCC compiler using regex match = re.search(r"gcc@([\d\.]+)", result.stdout) gcc_version = match.group(1) - self.logger.debug(f'Found gcc for {self.env.env_name}: {gcc_version}') + self.logger.debug(f'Found gcc for {self.spack_config.env.env_name}: {gcc_version}') return gcc_version def get_spack_installed_version(self): spack_version = run_command("bash", "-c", f'source {self.spack_setup_script} && spack --version', capture_output=True, text=True, check=True, logger=self.logger, - debug_msg=f"Getting spack version", + info_msg=f"Getting spack version", exception_msg=f"Error retrieving Spack version") if spack_version: return spack_version.stdout.strip().split()[0] return None @check_spack_env - def install_packages(self, jobs: int, signed=True, fresh=False): + def concretize_spack_env(self, force=True): + force = '--force' if force else '' + run_command("bash", "-c", + f'source {self.spack_setup_script} && spack env activate -p {self.env_path} && spack concretize {force}', + check=True, + capture_output=True, text=True, logger=self.logger, + info_msg=f'Concertization step for {self.spack_config.env.env_name}', + exception_msg=f'Failed the concertization step for {self.spack_config.env.env_name}', + exception=SpackConcertizeException) + + @check_spack_env + def install_packages(self, jobs: int, signed=True, fresh=False, debug=False): signed = '' if signed else '--no-check-signature' fresh = '--fresh' if fresh else '' - with open(str(Path(os.getcwd()).resolve() / ".generate_cache.log"), "w") as log_file: - run_command("bash", "-c", - f'source {self.spack_setup_script} && spack install --env {self.env.env_name} -v {signed} --j {jobs} {fresh}', - stdout=log_file, - capture_output=True, text=True, check=True, - logger=self.logger, - debug_msg=f"Installing spack packages for {self.env.env_name}", - exception_msg=f"Error installing spack packages for {self.env.env_name}", - exception=SpackInstallPackagesException) - - def install_spack(self, spack_version="v0.22.0", spack_repo='https://github.com/spack/spack'): + debug = '--debug' if debug else '' + install_result = run_command("bash", "-c", + f'{self.spack_command_on_env} && spack {debug} install -v {signed} --j {jobs} {fresh}', + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + logger=self.logger, + info_msg=f"Installing spack packages for {self.spack_config.env.env_name}", + exception_msg=f"Error installing spack packages for {self.spack_config.env.env_name}", + exception=SpackInstallPackagesException) + log_command(install_result, str(Path(os.getcwd()).resolve() / ".generate_cache.log")) + return install_result + + def install_spack(self, spack_version=f'v{SPACK_VERSION}', spack_repo='https://github.com/spack/spack'): try: user = os.getlogin() except OSError: @@ -210,18 +213,18 @@ class SpackManager(ABC): self.logger.info("Added Spack PATH to .bashrc") if user: run_command("chown", "-R", f"{user}:{user}", self.spack_dir, check=True, logger=self.logger, - debug_msg='Adding permissions to the logged in user') - run_command("bash", "-c", f"source {bashrc_path}", check=True, logger=self.logger, debug_msg='Restart bash') + info_msg='Adding permissions to the logged in user') + run_command("bash", "-c", f"source {bashrc_path}", check=True, logger=self.logger, info_msg='Restart bash') self.logger.info("Spack install completed") # Restart Bash after the installation ends os.system("exec bash") # Configure upstream Spack instance if specified - if self.upstream_instance: + if self.spack_config.upstream_instance: upstreams_yaml_path = os.path.join(self.spack_dir, "etc/spack/defaults/upstreams.yaml") with open(upstreams_yaml_path, "w") as file: file.write(f"""upstreams: upstream-spack-instance: - install_tree: {self.upstream_instance}/spack/opt/spack + install_tree: {self.spack_config.upstream_instance}/spack/opt/spack """) self.logger.info("Added upstream spack instance") diff --git a/esd/spack_factory/SpackOperationCreator.py b/esd/spack_factory/SpackOperationCreator.py new file mode 100644 index 00000000..8369c5ca --- /dev/null +++ b/esd/spack_factory/SpackOperationCreator.py @@ -0,0 +1,14 @@ +from esd.configuration.SpackConfig import SpackConfig +from esd.spack_factory.SpackOperation import SpackOperation +from esd.spack_factory.SpackOperationUseCache import SpackOperationUseCache + + +class SpackOperationCreator: + @staticmethod + def get_spack_operator(spack_config: SpackConfig = None): + if spack_config is None: + return SpackOperation(SpackConfig()) + elif spack_config.concretization_dir is None and spack_config.buildcache_dir is None: + return SpackOperation(spack_config) + else: + return SpackOperationUseCache(spack_config) diff --git a/esd/spack_factory/SpackOperationUseCache.py b/esd/spack_factory/SpackOperationUseCache.py new file mode 100644 index 00000000..15a3822f --- /dev/null +++ b/esd/spack_factory/SpackOperationUseCache.py @@ -0,0 +1,19 @@ +from esd.logger.logger_builder import get_logger +from esd.spack_factory.SpackOperation import SpackOperation +from esd.configuration.SpackConfig import SpackConfig + + +class SpackOperationUseCache(SpackOperation): + """ + This class uses caching for the concretization step and for the installation step. + """ + + def __init__(self, spack_config: SpackConfig = SpackConfig()): + super().__init__(spack_config, logger=get_logger(__name__)) + + def setup_spack_env(self): + super().setup_spack_env() + # todo add buildcache to the spack environment + + def concretize_spack_env(self, force=True): + pass diff --git a/esd/spack_manager/wrapper/__init__.py b/esd/spack_factory/__init__.py similarity index 100% rename from esd/spack_manager/wrapper/__init__.py rename to esd/spack_factory/__init__.py diff --git a/esd/spack_manager/enums/SpackManagerEnum.py b/esd/spack_manager/enums/SpackManagerEnum.py deleted file mode 100644 index a2435839..00000000 --- a/esd/spack_manager/enums/SpackManagerEnum.py +++ /dev/null @@ -1,6 +0,0 @@ -from enum import Enum - - -class SpackManagerEnum(Enum): - FROM_SCRATCH = "from_scratch", - FROM_BUILDCACHE = "from_buildcache", diff --git a/esd/spack_manager/factory/SpackManagerBuildCache.py b/esd/spack_manager/factory/SpackManagerBuildCache.py deleted file mode 100644 index 38151c6d..00000000 --- a/esd/spack_manager/factory/SpackManagerBuildCache.py +++ /dev/null @@ -1,19 +0,0 @@ -from esd.model.SpackModel import SpackModel -from esd.spack_manager.SpackManager import SpackManager -from esd.logger.logger_builder import get_logger - - -class SpackManagerBuildCache(SpackManager): - def __init__(self, env: SpackModel = None, repos=None, - upstream_instance=None, system_name: str = None): - super().__init__(env, repos, upstream_instance, system_name, logger=get_logger(__name__)) - - def setup_spack_env(self): - super().setup_spack_env() - # todo add buildcache to the spack environment - - def concretize_spack_env(self, force=True): - pass - - def install_spack_packages(self, jobs: 3, verbose=False, debug=False): - pass diff --git a/esd/spack_manager/factory/SpackManagerCreator.py b/esd/spack_manager/factory/SpackManagerCreator.py deleted file mode 100644 index 6eb26a04..00000000 --- a/esd/spack_manager/factory/SpackManagerCreator.py +++ /dev/null @@ -1,14 +0,0 @@ -from esd.model.SpackModel import SpackModel -from esd.spack_manager.enums.SpackManagerEnum import SpackManagerEnum -from esd.spack_manager.factory.SpackManagerBuildCache import SpackManagerBuildCache -from esd.spack_manager.factory.SpackManagerScratch import SpackManagerScratch - - -class SpackManagerCreator: - @staticmethod - def get_spack_manger(spack_manager_type: SpackManagerEnum, env: SpackModel = None, repos=None, - upstream_instance=None, system_name: str = None): - if spack_manager_type == SpackManagerEnum.FROM_SCRATCH: - return SpackManagerScratch(env, repos, upstream_instance, system_name) - elif spack_manager_type == SpackManagerEnum.FROM_BUILDCACHE: - return SpackManagerBuildCache(env, repos, upstream_instance, system_name) diff --git a/esd/spack_manager/factory/SpackManagerScratch.py b/esd/spack_manager/factory/SpackManagerScratch.py deleted file mode 100644 index 2ec22705..00000000 --- a/esd/spack_manager/factory/SpackManagerScratch.py +++ /dev/null @@ -1,28 +0,0 @@ -from esd.error_handling.exceptions import SpackConcertizeException, NoSpackEnvironmentException -from esd.model.SpackModel import SpackModel -from esd.spack_manager.SpackManager import SpackManager -from esd.logger.logger_builder import get_logger -from esd.utils.utils import run_command - - -class SpackManagerScratch(SpackManager): - def __init__(self, env: SpackModel = None, repos=None, - upstream_instance=None, system_name: str = None): - super().__init__(env, repos, upstream_instance, system_name, logger=get_logger(__name__)) - - def concretize_spack_env(self, force=True): - force = '--force' if force else '' - if self.spack_env_exists(): - run_command("bash", "-c", - f'source {self.spack_setup_script} && spack env activate -p {self.env_path} && spack concretize {force}', - check=True, - capture_output=True, text=True, logger=self.logger, - debug_msg=f'Concertization step for {self.env.env_name}', - exception_msg=f'Failed the concertization step for {self.env.env_name}', - exception=SpackConcertizeException) - else: - self.logger.debug('No spack environment defined') - raise NoSpackEnvironmentException('No spack environment defined') - - def install_spack_packages(self, jobs: 3, verbose=False, debug=False): - pass diff --git a/esd/wrapper/__init__.py b/esd/wrapper/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/esd/spack_manager/wrapper/spack_wrapper.py b/esd/wrapper/spack_wrapper.py similarity index 100% rename from esd/spack_manager/wrapper/spack_wrapper.py rename to esd/wrapper/spack_wrapper.py -- GitLab From ebda7beeef6e7460c6eb129a37c6eb0e503edaf4 Mon Sep 17 00:00:00 2001 From: adrianciu <adrian.ciu@codemart.ro> Date: Fri, 14 Feb 2025 11:28:46 +0200 Subject: [PATCH 07/30] esd-spack-installation: added additional spack commands --- esd/configuration/GpgConfig.py | 7 +++ esd/configuration/SpackConfig.py | 11 +++- esd/error_handling/exceptions.py | 10 ++++ esd/spack_factory/SpackOperation.py | 61 +++++++++++++++++---- esd/spack_factory/SpackOperationUseCache.py | 15 ++++- 5 files changed, 91 insertions(+), 13 deletions(-) create mode 100644 esd/configuration/GpgConfig.py diff --git a/esd/configuration/GpgConfig.py b/esd/configuration/GpgConfig.py new file mode 100644 index 00000000..a8f0c2d3 --- /dev/null +++ b/esd/configuration/GpgConfig.py @@ -0,0 +1,7 @@ +class GpgConfig: + """ + Configuration for gpg key used by spack + """ + def __init__(self, gpg_name='example', gpg_mail='example@example.com'): + self.name = gpg_name + self.mail = gpg_mail diff --git a/esd/configuration/SpackConfig.py b/esd/configuration/SpackConfig.py index 93a2e874..b6178760 100644 --- a/esd/configuration/SpackConfig.py +++ b/esd/configuration/SpackConfig.py @@ -1,22 +1,31 @@ import os from pathlib import Path + +from esd.configuration.GpgConfig import GpgConfig from esd.model import SpackDescriptor class SpackConfig: def __init__(self, env: SpackDescriptor = None, repos: list[SpackDescriptor] = None, install_dir=Path(os.getcwd()).resolve(), upstream_instance=None, system_name=None, - concretization_dir: Path = None, buildcache_dir: Path = None): + concretization_dir: Path = None, buildcache_dir: Path = None, gpg: GpgConfig = None): self.env = env if repos is None: self.repos = [] else: self.repos = repos self.install_dir = install_dir + if self.install_dir: + os.makedirs(self.install_dir, exist_ok=True) self.upstream_instance = upstream_instance self.system_name = system_name self.concretization_dir = concretization_dir + if self.concretization_dir: + os.makedirs(self.concretization_dir, exist_ok=True) self.buildcache_dir = buildcache_dir + if self.buildcache_dir: + os.makedirs(self.buildcache_dir, exist_ok=True) + self.gpg = gpg def add_repo(self, repo: SpackDescriptor): if self.repos is None: diff --git a/esd/error_handling/exceptions.py b/esd/error_handling/exceptions.py index 9d11b5fa..0256f886 100644 --- a/esd/error_handling/exceptions.py +++ b/esd/error_handling/exceptions.py @@ -29,3 +29,13 @@ class SpackInstallPackagesException(BashCommandException): """ To be thrown when the spack fails to install spack packages """ + +class SpackMirrorException(BashCommandException): + """ + To be thrown when the spack add mirror command fails + """ + +class SpackGpgException(BashCommandException): + """ + To be thrown when the spack fails to create gpg keys + """ diff --git a/esd/spack_factory/SpackOperation.py b/esd/spack_factory/SpackOperation.py index 29f44f49..a0b21a1c 100644 --- a/esd/spack_factory/SpackOperation.py +++ b/esd/spack_factory/SpackOperation.py @@ -1,10 +1,9 @@ import os import re import subprocess -from abc import ABC, abstractmethod from pathlib import Path from esd.error_handling.exceptions import BashCommandException, NoSpackEnvironmentException, \ - SpackInstallPackagesException, SpackConcertizeException + SpackInstallPackagesException, SpackConcertizeException, SpackMirrorException, SpackGpgException from esd.logger.logger_builder import get_logger from esd.configuration.SpackConfig import SpackConfig from esd.tests.testing_variables import SPACK_VERSION @@ -12,7 +11,7 @@ from esd.wrapper.spack_wrapper import check_spack_env from esd.utils.utils import run_command, git_clone_repo, log_command, set_bashrc_variable -class SpackOperation(ABC): +class SpackOperation: """ This class should implement the methods necessary for installing spack, set up an environment, concretize and install packages. Factory design pattern is used because there are 2 cases: creating an environment from scratch or creating an environment from the buildcache. @@ -38,10 +37,6 @@ class SpackOperation(ABC): self.env_path = spack_config.env.path / spack_config.env.env_name self.spack_command_on_env = f'source {self.spack_setup_script} && spack env activate -p {self.env_path}' - @abstractmethod - def concretize_spack_env(self, force=True): - pass - def create_fetch_spack_environment(self): if self.spack_config.env.git_path: git_clone_repo(self.spack_config.env.env_name, self.spack_config.env.path / self.spack_config.env.env_name, @@ -65,8 +60,10 @@ class SpackOperation(ABC): set_bashrc_variable('SYSTEMNAME', self.spack_config.system_name, bashrc_path, logger=self.logger) os.environ['SYSTEMNAME'] = self.spack_config.system_name if self.spack_dir.exists() and self.spack_dir.is_dir(): - set_bashrc_variable('SPACK_USER_CACHE_PATH', str(self.spack_dir / ".spack"), bashrc_path, logger=self.logger) - set_bashrc_variable('SPACK_USER_CONFIG_PATH', str(self.spack_dir / ".spack"), bashrc_path, logger=self.logger) + set_bashrc_variable('SPACK_USER_CACHE_PATH', str(self.spack_dir / ".spack"), bashrc_path, + logger=self.logger) + set_bashrc_variable('SPACK_USER_CONFIG_PATH', str(self.spack_dir / ".spack"), bashrc_path, + logger=self.logger) self.logger.debug('Added env variables SPACK_USER_CACHE_PATH and SPACK_USER_CONFIG_PATH') else: self.logger.error(f'Invalid installation path: {self.spack_dir}') @@ -160,13 +157,55 @@ class SpackOperation(ABC): def concretize_spack_env(self, force=True): force = '--force' if force else '' run_command("bash", "-c", - f'source {self.spack_setup_script} && spack env activate -p {self.env_path} && spack concretize {force}', + f'{self.spack_command_on_env} && spack concretize {force}', check=True, - capture_output=True, text=True, logger=self.logger, + logger=self.logger, info_msg=f'Concertization step for {self.spack_config.env.env_name}', exception_msg=f'Failed the concertization step for {self.spack_config.env.env_name}', exception=SpackConcertizeException) + def create_gpg_keys(self): + if self.spack_config.gpg: + run_command("bash", "-c", + f'source {self.spack_setup_script} && spack gpg init && spack gpg create {self.spack_config.gpg.name} {self.spack_config.gpg.mail}', + check=True, + logger=self.logger, + info_msg=f'Created pgp keys for {self.spack_config.env.env_name}', + exception_msg=f'Failed to create pgp keys mirror {self.spack_config.env.env_name}', + exception=SpackGpgException) + else: + raise SpackGpgException('No GPG configuration was defined is spack configuration') + + def add_mirror(self, mirror_name: str, mirror_path: Path, signed=False, autopush=False, global_mirror=False): + autopush = '--autopush' if autopush else '' + signed = '--signed' if signed else '' + if global_mirror: + run_command("bash", "-c", + f'source {self.spack_setup_script} && spack mirror add {autopush} {signed} {mirror_name} {mirror_path}', + check=True, + logger=self.logger, + info_msg=f'Added mirror {mirror_name}', + exception_msg=f'Failed to add mirror {mirror_name}', + exception=SpackMirrorException) + else: + check_spack_env( + run_command("bash", "-c", + f'{self.spack_command_on_env} && spack mirror add {autopush} {signed} {mirror_name} {mirror_path}', + check=True, + logger=self.logger, + info_msg=f'Added mirror {mirror_name}', + exception_msg=f'Failed to add mirror {mirror_name}', + exception=SpackMirrorException)) + + def remove_mirror(self, mirror_name: str): + run_command("bash", "-c", + f'source {self.spack_setup_script} && spack mirror rm {mirror_name}', + check=True, + logger=self.logger, + info_msg=f'Removing mirror {mirror_name}', + exception_msg=f'Failed to remove mirror {mirror_name}', + exception=SpackMirrorException) + @check_spack_env def install_packages(self, jobs: int, signed=True, fresh=False, debug=False): signed = '' if signed else '--no-check-signature' diff --git a/esd/spack_factory/SpackOperationUseCache.py b/esd/spack_factory/SpackOperationUseCache.py index 15a3822f..313522d2 100644 --- a/esd/spack_factory/SpackOperationUseCache.py +++ b/esd/spack_factory/SpackOperationUseCache.py @@ -1,3 +1,5 @@ +import os +from esd.build_cache.BuildCacheManager import BuildCacheManager from esd.logger.logger_builder import get_logger from esd.spack_factory.SpackOperation import SpackOperation from esd.configuration.SpackConfig import SpackConfig @@ -8,8 +10,19 @@ class SpackOperationUseCache(SpackOperation): This class uses caching for the concretization step and for the installation step. """ - def __init__(self, spack_config: SpackConfig = SpackConfig()): + def __init__(self, spack_config: SpackConfig = SpackConfig(), cache_version_concretize='cache', + cache_version_build='cache'): super().__init__(spack_config, logger=get_logger(__name__)) + self.cache_dependency = BuildCacheManager(os.environ.get('CONCRETIZE_OCI_HOST'), + os.environ.get('CONCRETIZE_OCI_PROJECT'), + os.environ.get('CONCRETIZE_OCI_USERNAME'), + os.environ.get('CONCRETIZE_OCI_PASSWORD'), + cache_version=cache_version_concretize) + self.build_cache = BuildCacheManager(os.environ.get('BUILDCACHE_OCI_HOST'), + os.environ.get('BUILDCACHE_OCI_PROJECT'), + os.environ.get('BUILDCACHE_OCI_USERNAME'), + os.environ.get('BUILDCACHE_OCI_PASSWORD'), + cache_version=cache_version_build) def setup_spack_env(self): super().setup_spack_env() -- GitLab From 59ab786c33be27d4f003c87ea1baae518496c20c Mon Sep 17 00:00:00 2001 From: adrianciu <adrian.ciu@codemart.ro> Date: Tue, 18 Feb 2025 13:45:13 +0200 Subject: [PATCH 08/30] esd-spack-installation: package renaming to dedal --- .gitlab-ci.yml | 2 - {esd => dedal}/configuration/GpgConfig.py | 0 {esd => dedal}/configuration/SpackConfig.py | 4 +- {esd => dedal}/configuration/__init__.py | 0 {esd => dedal}/error_handling/__init__.py | 0 {esd => dedal}/error_handling/exceptions.py | 0 {esd => dedal}/model/SpackDescriptor.py | 0 {esd => dedal}/model/__init__.py | 0 .../spack_factory/SpackOperation.py | 12 +-- .../spack_factory/SpackOperationCreator.py | 6 +- .../spack_factory/SpackOperationUseCache.py | 8 +- {esd => dedal}/spack_factory/__init__.py | 0 dedal/tests/spack_from_scratch_test.py | 12 +-- dedal/tests/spack_install_test.py | 4 +- dedal/tests/utils_test.py | 73 ++++++++++++++++++- dedal/utils/utils.py | 14 +++- {esd => dedal}/wrapper/__init__.py | 0 {esd => dedal}/wrapper/spack_wrapper.py | 2 +- 18 files changed, 108 insertions(+), 29 deletions(-) rename {esd => dedal}/configuration/GpgConfig.py (100%) rename {esd => dedal}/configuration/SpackConfig.py (92%) rename {esd => dedal}/configuration/__init__.py (100%) rename {esd => dedal}/error_handling/__init__.py (100%) rename {esd => dedal}/error_handling/exceptions.py (100%) rename {esd => dedal}/model/SpackDescriptor.py (100%) rename {esd => dedal}/model/__init__.py (100%) rename {esd => dedal}/spack_factory/SpackOperation.py (97%) rename {esd => dedal}/spack_factory/SpackOperationCreator.py (67%) rename {esd => dedal}/spack_factory/SpackOperationUseCache.py (86%) rename {esd => dedal}/spack_factory/__init__.py (100%) rename {esd => dedal}/wrapper/__init__.py (100%) rename {esd => dedal}/wrapper/spack_wrapper.py (85%) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index bd5669bd..2b497048 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -6,7 +6,6 @@ variables: BUILD_ENV_DOCKER_IMAGE: docker-registry.ebrains.eu/esd/tmp:latest - build-wheel: stage: build tags: @@ -40,6 +39,5 @@ testing-pytest: paths: - test-results.xml - .dedal.log - - .generate_cache.log expire_in: 1 week diff --git a/esd/configuration/GpgConfig.py b/dedal/configuration/GpgConfig.py similarity index 100% rename from esd/configuration/GpgConfig.py rename to dedal/configuration/GpgConfig.py diff --git a/esd/configuration/SpackConfig.py b/dedal/configuration/SpackConfig.py similarity index 92% rename from esd/configuration/SpackConfig.py rename to dedal/configuration/SpackConfig.py index b6178760..0d470679 100644 --- a/esd/configuration/SpackConfig.py +++ b/dedal/configuration/SpackConfig.py @@ -1,8 +1,8 @@ import os from pathlib import Path -from esd.configuration.GpgConfig import GpgConfig -from esd.model import SpackDescriptor +from dedal.configuration.GpgConfig import GpgConfig +from dedal.model import SpackDescriptor class SpackConfig: diff --git a/esd/configuration/__init__.py b/dedal/configuration/__init__.py similarity index 100% rename from esd/configuration/__init__.py rename to dedal/configuration/__init__.py diff --git a/esd/error_handling/__init__.py b/dedal/error_handling/__init__.py similarity index 100% rename from esd/error_handling/__init__.py rename to dedal/error_handling/__init__.py diff --git a/esd/error_handling/exceptions.py b/dedal/error_handling/exceptions.py similarity index 100% rename from esd/error_handling/exceptions.py rename to dedal/error_handling/exceptions.py diff --git a/esd/model/SpackDescriptor.py b/dedal/model/SpackDescriptor.py similarity index 100% rename from esd/model/SpackDescriptor.py rename to dedal/model/SpackDescriptor.py diff --git a/esd/model/__init__.py b/dedal/model/__init__.py similarity index 100% rename from esd/model/__init__.py rename to dedal/model/__init__.py diff --git a/esd/spack_factory/SpackOperation.py b/dedal/spack_factory/SpackOperation.py similarity index 97% rename from esd/spack_factory/SpackOperation.py rename to dedal/spack_factory/SpackOperation.py index a0b21a1c..ecfbb8e5 100644 --- a/esd/spack_factory/SpackOperation.py +++ b/dedal/spack_factory/SpackOperation.py @@ -2,13 +2,13 @@ import os import re import subprocess from pathlib import Path -from esd.error_handling.exceptions import BashCommandException, NoSpackEnvironmentException, \ +from dedal.error_handling.exceptions import BashCommandException, NoSpackEnvironmentException, \ SpackInstallPackagesException, SpackConcertizeException, SpackMirrorException, SpackGpgException -from esd.logger.logger_builder import get_logger -from esd.configuration.SpackConfig import SpackConfig -from esd.tests.testing_variables import SPACK_VERSION -from esd.wrapper.spack_wrapper import check_spack_env -from esd.utils.utils import run_command, git_clone_repo, log_command, set_bashrc_variable +from dedal.logger.logger_builder import get_logger +from dedal.configuration.SpackConfig import SpackConfig +from dedal.tests.testing_variables import SPACK_VERSION +from dedal.wrapper.spack_wrapper import check_spack_env +from dedal.utils.utils import run_command, git_clone_repo, log_command, set_bashrc_variable class SpackOperation: diff --git a/esd/spack_factory/SpackOperationCreator.py b/dedal/spack_factory/SpackOperationCreator.py similarity index 67% rename from esd/spack_factory/SpackOperationCreator.py rename to dedal/spack_factory/SpackOperationCreator.py index 8369c5ca..54517a84 100644 --- a/esd/spack_factory/SpackOperationCreator.py +++ b/dedal/spack_factory/SpackOperationCreator.py @@ -1,6 +1,6 @@ -from esd.configuration.SpackConfig import SpackConfig -from esd.spack_factory.SpackOperation import SpackOperation -from esd.spack_factory.SpackOperationUseCache import SpackOperationUseCache +from dedal.configuration.SpackConfig import SpackConfig +from dedal.spack_factory.SpackOperation import SpackOperation +from dedal.spack_factory.SpackOperationUseCache import SpackOperationUseCache class SpackOperationCreator: diff --git a/esd/spack_factory/SpackOperationUseCache.py b/dedal/spack_factory/SpackOperationUseCache.py similarity index 86% rename from esd/spack_factory/SpackOperationUseCache.py rename to dedal/spack_factory/SpackOperationUseCache.py index 313522d2..efb9af76 100644 --- a/esd/spack_factory/SpackOperationUseCache.py +++ b/dedal/spack_factory/SpackOperationUseCache.py @@ -1,8 +1,8 @@ import os -from esd.build_cache.BuildCacheManager import BuildCacheManager -from esd.logger.logger_builder import get_logger -from esd.spack_factory.SpackOperation import SpackOperation -from esd.configuration.SpackConfig import SpackConfig +from dedal.build_cache.BuildCacheManager import BuildCacheManager +from dedal.logger.logger_builder import get_logger +from dedal.spack_factory.SpackOperation import SpackOperation +from dedal.configuration.SpackConfig import SpackConfig class SpackOperationUseCache(SpackOperation): diff --git a/esd/spack_factory/__init__.py b/dedal/spack_factory/__init__.py similarity index 100% rename from esd/spack_factory/__init__.py rename to dedal/spack_factory/__init__.py diff --git a/dedal/tests/spack_from_scratch_test.py b/dedal/tests/spack_from_scratch_test.py index cdc405e7..2fec80f7 100644 --- a/dedal/tests/spack_from_scratch_test.py +++ b/dedal/tests/spack_from_scratch_test.py @@ -1,11 +1,11 @@ from pathlib import Path import pytest -from esd.configuration.SpackConfig import SpackConfig -from esd.error_handling.exceptions import BashCommandException, NoSpackEnvironmentException -from esd.spack_factory.SpackOperationCreator import SpackOperationCreator -from esd.model.SpackDescriptor import SpackDescriptor -from esd.tests.testing_variables import test_spack_env_git, ebrains_spack_builds_git -from esd.utils.utils import file_exists_and_not_empty +from dedal.configuration.SpackConfig import SpackConfig +from dedal.error_handling.exceptions import BashCommandException, NoSpackEnvironmentException +from dedal.spack_factory.SpackOperationCreator import SpackOperationCreator +from dedal.model.SpackDescriptor import SpackDescriptor +from dedal.tests.testing_variables import test_spack_env_git, ebrains_spack_builds_git +from dedal.utils.utils import file_exists_and_not_empty def test_spack_repo_exists_1(): diff --git a/dedal/tests/spack_install_test.py b/dedal/tests/spack_install_test.py index 28f8268e..564d5c6a 100644 --- a/dedal/tests/spack_install_test.py +++ b/dedal/tests/spack_install_test.py @@ -1,6 +1,6 @@ import pytest -from esd.spack_factory.SpackOperation import SpackOperation -from esd.tests.testing_variables import SPACK_VERSION +from dedal.spack_factory.SpackOperation import SpackOperation +from dedal.tests.testing_variables import SPACK_VERSION # run this test first so that spack is installed only once for all the tests diff --git a/dedal/tests/utils_test.py b/dedal/tests/utils_test.py index 256b8218..cd478606 100644 --- a/dedal/tests/utils_test.py +++ b/dedal/tests/utils_test.py @@ -1,7 +1,9 @@ +import subprocess + import pytest from pathlib import Path - -from dedal.utils.utils import clean_up +from unittest.mock import mock_open, patch, MagicMock +from dedal.utils.utils import clean_up, file_exists_and_not_empty, log_command, run_command @pytest.fixture @@ -79,3 +81,70 @@ def test_file_exists_and_not_empty(tmp_path: Path): non_empty_file = tmp_path / "non_empty.txt" non_empty_file.write_text("Some content") assert file_exists_and_not_empty(non_empty_file) + + +def test_log_command(): + results = MagicMock() + results.stdout = "Test output" + results.stderr = "Test error" + mock_file = mock_open() + + with patch("builtins.open", mock_file): + log_command(results, "logfile.log") + + mock_file.assert_called_once_with("logfile.log", "w") + handle = mock_file() + handle.write.assert_any_call("Test output") + handle.write.assert_any_call("\n--- STDERR ---\n") + handle.write.assert_any_call("Test error") + + +def test_run_command_success(mocker): + mock_subprocess = mocker.patch("subprocess.run", return_value=MagicMock(returncode=0)) + mock_logger = MagicMock() + result = run_command('bash', '-c', 'echo hello', logger=mock_logger, info_msg="Running echo") + mock_logger.info.assert_called_with("Running echo: args: ('bash', '-c', 'echo hello')") + mock_subprocess.assert_called_once_with(('bash', '-c', 'echo hello')) + assert result.returncode == 0 + + +def test_run_command_not_found(mocker): + mocker.patch("subprocess.run", side_effect=FileNotFoundError) + mock_logger = MagicMock() + run_command("invalid_command", logger=mock_logger) + mock_logger.error.assert_called_with("Command not found. Please check the command syntax.") + + +def test_run_command_permission_error(mocker): + mocker.patch("subprocess.run", side_effect=PermissionError) + mock_logger = MagicMock() + run_command("restricted_command", logger=mock_logger) + mock_logger.error.assert_called_with("Permission denied. Try running with appropriate permissions.") + + +def test_run_command_timeout(mocker): + mocker.patch("subprocess.run", side_effect=subprocess.TimeoutExpired(cmd="test", timeout=5)) + mock_logger = MagicMock() + run_command("test", logger=mock_logger) + mock_logger.error.assert_called_with("Command timed out. Try increasing the timeout duration.") + + +def test_run_command_os_error(mocker): + mocker.patch("subprocess.run", side_effect=OSError("OS Error")) + mock_logger = MagicMock() + run_command("test", logger=mock_logger) + mock_logger.error.assert_called_with("OS error occurred: OS Error") + + +def test_run_command_unexpected_exception(mocker): + mocker.patch("subprocess.run", side_effect=Exception("Unexpected Error")) + mock_logger = MagicMock() + run_command("test", logger=mock_logger) + mock_logger.error.assert_called_with("An unexpected error occurred: Unexpected Error") + + +def test_run_command_called_process_error(mocker): + mocker.patch("subprocess.run", side_effect=subprocess.CalledProcessError(1, "test")) + mock_logger = MagicMock() + run_command("test", logger=mock_logger, exception_msg="Process failed") + mock_logger.error.assert_called_with("Process failed: Command 'test' returned non-zero exit status 1.") diff --git a/dedal/utils/utils.py b/dedal/utils/utils.py index 033cbc54..9fc82ad5 100644 --- a/dedal/utils/utils.py +++ b/dedal/utils/utils.py @@ -4,7 +4,7 @@ import shutil import subprocess from pathlib import Path -from esd.error_handling.exceptions import BashCommandException +from dedal.error_handling.exceptions import BashCommandException import re @@ -38,6 +38,18 @@ def run_command(*args, logger=logging.getLogger(__name__), info_msg: str = '', e raise exception(f'{exception_msg} : {e}') else: return None + except FileNotFoundError: + logger.error(f"Command not found. Please check the command syntax.") + except PermissionError: + logger.error(f"Permission denied. Try running with appropriate permissions.") + except subprocess.TimeoutExpired: + logger.error(f"Command timed out. Try increasing the timeout duration.") + except ValueError: + logger.error(f"Invalid argument passed to subprocess. Check function parameters.") + except OSError as e: + logger.error(f"OS error occurred: {e}") + except Exception as e: + logger.error(f"An unexpected error occurred: {e}") def git_clone_repo(repo_name: str, dir: Path, git_path: str, logger: logging = logging.getLogger(__name__)): diff --git a/esd/wrapper/__init__.py b/dedal/wrapper/__init__.py similarity index 100% rename from esd/wrapper/__init__.py rename to dedal/wrapper/__init__.py diff --git a/esd/wrapper/spack_wrapper.py b/dedal/wrapper/spack_wrapper.py similarity index 85% rename from esd/wrapper/spack_wrapper.py rename to dedal/wrapper/spack_wrapper.py index c2f9c116..018cad48 100644 --- a/esd/wrapper/spack_wrapper.py +++ b/dedal/wrapper/spack_wrapper.py @@ -1,6 +1,6 @@ import functools -from esd.error_handling.exceptions import NoSpackEnvironmentException +from dedal.error_handling.exceptions import NoSpackEnvironmentException def check_spack_env(method): -- GitLab From ccf28f621364c9b33b7e0237e83b20011d8ce007 Mon Sep 17 00:00:00 2001 From: adrianciu <adrianciu25@gmail.com> Date: Thu, 20 Feb 2025 13:00:31 +0200 Subject: [PATCH 09/30] esd-spack-install: test package restructure --- dedal/tests/integration_tests/__init__.py | 0 dedal/tests/{ => integration_tests}/spack_from_scratch_test.py | 0 dedal/tests/{ => integration_tests}/spack_install_test.py | 0 dedal/tests/unit_tests/__init__.py | 0 dedal/tests/{ => unit_tests}/utils_test.py | 0 5 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 dedal/tests/integration_tests/__init__.py rename dedal/tests/{ => integration_tests}/spack_from_scratch_test.py (100%) rename dedal/tests/{ => integration_tests}/spack_install_test.py (100%) create mode 100644 dedal/tests/unit_tests/__init__.py rename dedal/tests/{ => unit_tests}/utils_test.py (100%) diff --git a/dedal/tests/integration_tests/__init__.py b/dedal/tests/integration_tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/dedal/tests/spack_from_scratch_test.py b/dedal/tests/integration_tests/spack_from_scratch_test.py similarity index 100% rename from dedal/tests/spack_from_scratch_test.py rename to dedal/tests/integration_tests/spack_from_scratch_test.py diff --git a/dedal/tests/spack_install_test.py b/dedal/tests/integration_tests/spack_install_test.py similarity index 100% rename from dedal/tests/spack_install_test.py rename to dedal/tests/integration_tests/spack_install_test.py diff --git a/dedal/tests/unit_tests/__init__.py b/dedal/tests/unit_tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/dedal/tests/utils_test.py b/dedal/tests/unit_tests/utils_test.py similarity index 100% rename from dedal/tests/utils_test.py rename to dedal/tests/unit_tests/utils_test.py -- GitLab From 2b1be1143535c27c88be9fc906e1676625a00d74 Mon Sep 17 00:00:00 2001 From: adrianciu <adrianciu25@gmail.com> Date: Fri, 21 Feb 2025 10:49:01 +0200 Subject: [PATCH 10/30] esd-spack-installation: README --- .env | 9 +++ README.md | 55 ++++++++++++++++++- dedal/spack_factory/SpackOperationUseCache.py | 4 +- dedal/tests/testing_variables.py | 2 +- 4 files changed, 64 insertions(+), 6 deletions(-) create mode 100644 .env diff --git a/.env b/.env new file mode 100644 index 00000000..93e62653 --- /dev/null +++ b/.env @@ -0,0 +1,9 @@ +BUILDCACHE_OCI_HOST="" +BUILDCACHE_OCI_PASSWORD="" +BUILDCACHE_OCI_PROJECT="" +BUILDCACHE_OCI_USERNAME="" + +CONCRETIZE_OCI_HOST="" +CONCRETIZE_OCI_PASSWORD="" +CONCRETIZE_OCI_PROJECT="" +CONCRETIZE_OCI_USERNAME"" diff --git a/README.md b/README.md index 86ab9c2f..dd68dcfa 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,53 @@ -# ~~Yashchiki~~Koutakia +# Dedal -For now, this repository provides helpers for the EBRAINS container image build flow. -The lowest spack version which should be used with this library is v0.22.0 +This repository provides functionalities to easily ```managed spack environments``` and ```helpers for the container image build flow```. + +This library runs only on different kinds of linux distribution operating systems. + +The lowest ```spack version``` compatible with this library is ```v0.23.0```. + + + This repository also provied CLI interface. For more informations, after installing this library, call dedal --help. + + +**Setting up the needed environment variables** + The ````<checkout path>\dedal\.env```` file contains the environment variables required for OCI registry used for caching. + Ensure that you edit the ````<checkout path>\dedal\.env```` file to match your environment. + The following provides an explanation of the various environment variables: + + + # OCI Registry Configuration Sample for concretization caches + # ============================= + # The following variables configure the Harbor docker OCI registry (EBRAINS) used for caching. + + # The hostname of the OCI registry. e.g. docker-registry.ebrains.eu + CONCRETIZE__OCI_HOST="docker-registry.ebrains.eu" + + # The project name in the Docker registry. + CONCRETIZE__OCI_PROJECT="concretize_caches" + + # The username used for authentication with the Docker registry. + CONCRETIZE__OCI_USERNAME="robot$concretize-cache-test+user" + + # The password used for authentication with the Docker registry. + CONCRETIZE__OCI_HOST="###ACCESS_TOKEN###" + + + # OCI Registry Configuration Sample for binary caches + # ============================= + # The following variables configure the Harbor docker OCI registry (EBRAINS) used for caching. + + # The hostname of the OCI registry. e.g. docker-registry.ebrains.eu + BUILDCACHE_OCI_HOST="docker-registry.ebrains.eu" + + # The project name in the Docker registry. + BUILDCACHE_OCI_PROJECT="binary-cache-test" + + # The username used for authentication with the Docker registry. + BUILDCACHE_OCI_USERNAME="robot$binary-cache-test+user" + + # The password used for authentication with the Docker registry. + BUILDCACHE_OCI_HOST="###ACCESS_TOKEN###" + +For both concretization and binary caches, the cache version can be changed via the attributes ```cache_version_concretize``` and ```cache_version_build```. +The default values are ```v1```. diff --git a/dedal/spack_factory/SpackOperationUseCache.py b/dedal/spack_factory/SpackOperationUseCache.py index efb9af76..41a9094c 100644 --- a/dedal/spack_factory/SpackOperationUseCache.py +++ b/dedal/spack_factory/SpackOperationUseCache.py @@ -10,8 +10,8 @@ class SpackOperationUseCache(SpackOperation): This class uses caching for the concretization step and for the installation step. """ - def __init__(self, spack_config: SpackConfig = SpackConfig(), cache_version_concretize='cache', - cache_version_build='cache'): + def __init__(self, spack_config: SpackConfig = SpackConfig(), cache_version_concretize='v1', + cache_version_build='v1'): super().__init__(spack_config, logger=get_logger(__name__)) self.cache_dependency = BuildCacheManager(os.environ.get('CONCRETIZE_OCI_HOST'), os.environ.get('CONCRETIZE_OCI_PROJECT'), diff --git a/dedal/tests/testing_variables.py b/dedal/tests/testing_variables.py index ab95bfa1..e441a286 100644 --- a/dedal/tests/testing_variables.py +++ b/dedal/tests/testing_variables.py @@ -1,6 +1,6 @@ import os ebrains_spack_builds_git = 'https://gitlab.ebrains.eu/ri/tech-hub/platform/esd/ebrains-spack-builds.git' -SPACK_VERSION = "0.22.0" +SPACK_VERSION = "0.23.0" SPACK_ENV_ACCESS_TOKEN = os.getenv("SPACK_ENV_ACCESS_TOKEN") test_spack_env_git = f'https://oauth2:{SPACK_ENV_ACCESS_TOKEN}@gitlab.ebrains.eu/ri/projects-and-initiatives/virtualbraintwin/tools/test-spack-env.git' -- GitLab From 66d4453c7ffd647c3e1783936b0f33bb2182f8d6 Mon Sep 17 00:00:00 2001 From: adrianciu <adrian.ciu@codemart.ro> Date: Wed, 26 Feb 2025 16:06:29 +0200 Subject: [PATCH 11/30] esd-spack-installation: fixing tests --- .../integration_tests/spack_from_scratch_test.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/dedal/tests/integration_tests/spack_from_scratch_test.py b/dedal/tests/integration_tests/spack_from_scratch_test.py index 2fec80f7..d080bc7b 100644 --- a/dedal/tests/integration_tests/spack_from_scratch_test.py +++ b/dedal/tests/integration_tests/spack_from_scratch_test.py @@ -8,13 +8,7 @@ from dedal.tests.testing_variables import test_spack_env_git, ebrains_spack_buil from dedal.utils.utils import file_exists_and_not_empty -def test_spack_repo_exists_1(): - spack_operation = SpackOperationCreator.get_spack_operator() - spack_operation.install_spack() - assert spack_operation.spack_repo_exists('ebrains-spack-builds') == False - - -def test_spack_repo_exists_2(tmp_path): +def test_spack_repo_exists_1(tmp_path): install_dir = tmp_path env = SpackDescriptor('ebrains-spack-builds', install_dir) config = SpackConfig(env=env, install_dir=install_dir) @@ -24,7 +18,7 @@ def test_spack_repo_exists_2(tmp_path): spack_operation.spack_repo_exists(env.env_name) -def test_spack_repo_exists_3(tmp_path): +def test_spack_repo_exists_2(tmp_path): install_dir = tmp_path env = SpackDescriptor('ebrains-spack-builds', install_dir) config = SpackConfig(env=env, install_dir=install_dir) @@ -91,6 +85,8 @@ def test_spack_not_a_valid_repo(): spack_operation.add_spack_repo(repo.path, repo.env_name) +@pytest.mark.skip( + reason="Skipping the concretization step because it may freeze when numerous Spack packages are added to the environment.") def test_spack_from_scratch_concretize_1(tmp_path): install_dir = tmp_path env = SpackDescriptor('ebrains-spack-builds', install_dir, ebrains_spack_builds_git) @@ -107,6 +103,8 @@ def test_spack_from_scratch_concretize_1(tmp_path): assert file_exists_and_not_empty(concretization_file_path) == True +@pytest.mark.skip( + reason="Skipping the concretization step because it may freeze when numerous Spack packages are added to the environment.") def test_spack_from_scratch_concretize_2(tmp_path): install_dir = tmp_path env = SpackDescriptor('ebrains-spack-builds', install_dir, ebrains_spack_builds_git) -- GitLab From 0fbcce75a2b3a08d8790cbd12c0b50a20f41f4c1 Mon Sep 17 00:00:00 2001 From: adrianciu <adrian.ciu@codemart.ro> Date: Wed, 5 Feb 2025 11:25:05 +0200 Subject: [PATCH 12/30] esd-spack-installation: setup and tests for a spack env --- .gitlab-ci.yml | 9 +- dedal/build_cache/BuildCacheManager.py | 68 ++++--- dedal/logger/logger_config.py | 33 ---- .../spack_from_scratch_test.py | 14 +- .../integration_tests/spack_install_test.py | 23 ++- esd/error_handling/exceptions.py | 13 ++ esd/model/SpackModel.py | 12 ++ esd/model/__init__.py | 0 esd/spack_manager/SpackManager.py | 180 ++++++++++++++++++ esd/spack_manager/__init__.py | 0 esd/spack_manager/enums/SpackManagerEnum.py | 6 + esd/spack_manager/enums/__init__.py | 0 .../factory/SpackManagerBuildCache.py | 19 ++ .../factory/SpackManagerCreator.py | 14 ++ .../factory/SpackManagerScratch.py | 15 ++ esd/spack_manager/factory/__init__.py | 0 esd/utils/bootstrap.sh | 6 + esd/utils/utils.py | 42 ++++ pyproject.toml | 4 +- 19 files changed, 367 insertions(+), 91 deletions(-) delete mode 100644 dedal/logger/logger_config.py create mode 100644 esd/error_handling/exceptions.py create mode 100644 esd/model/SpackModel.py create mode 100644 esd/model/__init__.py create mode 100644 esd/spack_manager/SpackManager.py create mode 100644 esd/spack_manager/__init__.py create mode 100644 esd/spack_manager/enums/SpackManagerEnum.py create mode 100644 esd/spack_manager/enums/__init__.py create mode 100644 esd/spack_manager/factory/SpackManagerBuildCache.py create mode 100644 esd/spack_manager/factory/SpackManagerCreator.py create mode 100644 esd/spack_manager/factory/SpackManagerScratch.py create mode 100644 esd/spack_manager/factory/__init__.py create mode 100644 esd/utils/bootstrap.sh create mode 100644 esd/utils/utils.py diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 2b497048..0ce02907 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -5,7 +5,6 @@ stages: variables: BUILD_ENV_DOCKER_IMAGE: docker-registry.ebrains.eu/esd/tmp:latest - build-wheel: stage: build tags: @@ -28,16 +27,16 @@ testing-pytest: - docker-runner image: ubuntu:22.04 script: - - chmod +x dedal/utils/bootstrap.sh - - ./dedal/utils/bootstrap.sh + - chmod +x esd/utils/bootstrap.sh + - ./esd/utils/bootstrap.sh - pip install . - - pytest ./dedal/tests/ -s --junitxml=test-results.xml + - pytest ./esd/tests/ -s --junitxml=test-results.xml artifacts: when: always reports: junit: test-results.xml paths: - test-results.xml - - .dedal.log + - .esd.log expire_in: 1 week diff --git a/dedal/build_cache/BuildCacheManager.py b/dedal/build_cache/BuildCacheManager.py index 55fa10cb..e1bd6824 100644 --- a/dedal/build_cache/BuildCacheManager.py +++ b/dedal/build_cache/BuildCacheManager.py @@ -2,9 +2,9 @@ import os import oras.client from pathlib import Path -from dedal.build_cache.BuildCacheManagerInterface import BuildCacheManagerInterface -from dedal.logger.logger_builder import get_logger -from dedal.utils.utils import clean_up +from esd.build_cache.BuildCacheManagerInterface import BuildCacheManagerInterface +from esd.logger.logger_builder import get_logger +from esd.utils.utils import clean_up class BuildCacheManager(BuildCacheManagerInterface): @@ -12,51 +12,48 @@ class BuildCacheManager(BuildCacheManagerInterface): This class aims to manage the push/pull/delete of build cache files """ - def __init__(self, registry_host, registry_project, registry_username, registry_password, cache_version='cache', - auth_backend='basic', - insecure=False): - self._logger = get_logger(__name__, BuildCacheManager.__name__) - self._registry_project = registry_project + def __init__(self, auth_backend='basic', insecure=False): + self.logger = get_logger(__name__, BuildCacheManager.__name__) + self.home_path = Path(os.environ.get("HOME_PATH", os.getcwd())) + self.registry_project = os.environ.get("REGISTRY_PROJECT") - self._registry_username = registry_username - self._registry_password = registry_password + self._registry_username = str(os.environ.get("REGISTRY_USERNAME")) + self._registry_password = str(os.environ.get("REGISTRY_PASSWORD")) - self._registry_host = registry_host + self.registry_host = str(os.environ.get("REGISTRY_HOST")) # Initialize an OrasClient instance. # This method utilizes the OCI Registry for container image and artifact management. # Refer to the official OCI Registry documentation for detailed information on the available authentication methods. # Supported authentication types may include basic authentication (username/password), token-based authentication, - self._client = oras.client.OrasClient(hostname=self._registry_host, auth_backend=auth_backend, - insecure=insecure) - self._client.login(username=self._registry_username, password=self._registry_password) - self.cache_version = cache_version - self._oci_registry_path = f'{self._registry_host}/{self._registry_project}/{self.cache_version}' + self.client = oras.client.OrasClient(hostname=self.registry_host, auth_backend=auth_backend, insecure=insecure) + self.client.login(username=self._registry_username, password=self._registry_password) + self.oci_registry_path = f'{self.registry_host}/{self.registry_project}/cache' def upload(self, out_dir: Path): """ This method pushed all the files from the build cache folder into the OCI Registry """ - build_cache_path = out_dir.resolve() + build_cache_path = self.home_path / out_dir # build cache folder must exist before pushing all the artifacts if not build_cache_path.exists(): - self._logger.error(f"Path {build_cache_path} not found.") + self.logger.error(f"Path {build_cache_path} not found.") for sub_path in build_cache_path.rglob("*"): if sub_path.is_file(): - rel_path = str(sub_path.relative_to(build_cache_path)).replace(str(sub_path.name), "") - target = f"{self._registry_host}/{self._registry_project}/{self.cache_version}:{str(sub_path.name)}" + rel_path = str(sub_path.relative_to(build_cache_path)).replace(str(sub_path.env_name), "") + target = f"{self.registry_host}/{self.registry_project}/cache:{str(sub_path.env_name)}" try: - self._logger.info(f"Pushing folder '{sub_path}' to ORAS target '{target}' ...") - self._client.push( + self.logger.info(f"Pushing folder '{sub_path}' to ORAS target '{target}' ...") + self.client.push( files=[str(sub_path)], target=target, # save in manifest the relative path for reconstruction manifest_annotations={"path": rel_path}, disable_path_validation=True, ) - self._logger.info(f"Successfully pushed {sub_path.name}") + self.logger.info(f"Successfully pushed {sub_path.env_name}") except Exception as e: - self._logger.error( + self.logger.error( f"An error occurred while pushing: {e}") # todo to be discussed hot to delete the build cache after being pushed to the OCI Registry # clean_up([str(build_cache_path)], self.logger) @@ -66,38 +63,37 @@ class BuildCacheManager(BuildCacheManagerInterface): This method retrieves all tags from an OCI Registry """ try: - return self._client.get_tags(self._oci_registry_path) + return self.client.get_tags(self.oci_registry_path) except Exception as e: - self._logger.error(f"Failed to list tags: {e}") + self.logger.error(f"Failed to list tags: {e}") return None def download(self, in_dir: Path): """ This method pulls all the files from the OCI Registry into the build cache folder """ - build_cache_path = in_dir.resolve() + build_cache_path = self.home_path / in_dir # create the buildcache dir if it does not exist os.makedirs(build_cache_path, exist_ok=True) tags = self.list_tags() if tags is not None: for tag in tags: - ref = f"{self._registry_host}/{self._registry_project}/{self.cache_version}:{tag}" + ref = f"{self.registry_host}/{self.registry_project}/cache:{tag}" # reconstruct the relative path of each artifact by getting it from the manifest cache_path = \ - self._client.get_manifest( - f'{self._registry_host}/{self._registry_project}/{self.cache_version}:{tag}')[ + self.client.get_manifest(f'{self.registry_host}/{self.registry_project}/cache:{tag}')[ 'annotations'][ 'path'] try: - self._client.pull( + self.client.pull( ref, # missing dirs to output dir are created automatically by OrasClient pull method outdir=str(build_cache_path / cache_path), overwrite=True ) - self._logger.info(f"Successfully pulled artifact {tag}.") + self.logger.info(f"Successfully pulled artifact {tag}.") except Exception as e: - self._logger.error( + self.logger.error( f"Failed to pull artifact {tag} : {e}") def delete(self): @@ -110,8 +106,8 @@ class BuildCacheManager(BuildCacheManagerInterface): tags = self.list_tags() if tags is not None: try: - self._client.delete_tags(self._oci_registry_path, tags) - self._logger.info(f"Successfully deleted all artifacts form OCI registry.") + self.client.delete_tags(self.oci_registry_path, tags) + self.logger.info(f"Successfully deleted all artifacts form OCI registry.") except RuntimeError as e: - self._logger.error( + self.logger.error( f"Failed to delete artifacts: {e}") diff --git a/dedal/logger/logger_config.py b/dedal/logger/logger_config.py deleted file mode 100644 index 3ca3b000..00000000 --- a/dedal/logger/logger_config.py +++ /dev/null @@ -1,33 +0,0 @@ -import logging - - -class LoggerConfig: - """ - This class sets up logging with a file handler - and a stream handler, ensuring consistent - and formatted log messages. - """ - def __init__(self, log_file): - self.log_file = log_file - self._configure_logger() - - def _configure_logger(self): - formatter = logging.Formatter( - fmt='%(asctime)s - %(levelname)s - %(message)s', - datefmt='%Y-%m-%d %H:%M:%S' - ) - - file_handler = logging.FileHandler(self.log_file) - file_handler.setFormatter(formatter) - - stream_handler = logging.StreamHandler() - stream_handler.setFormatter(formatter) - - self.logger = logging.getLogger(__name__) - self.logger.setLevel(logging.DEBUG) - - self.logger.addHandler(file_handler) - self.logger.addHandler(stream_handler) - - def get_logger(self): - return self.logger diff --git a/dedal/tests/integration_tests/spack_from_scratch_test.py b/dedal/tests/integration_tests/spack_from_scratch_test.py index d080bc7b..fa862301 100644 --- a/dedal/tests/integration_tests/spack_from_scratch_test.py +++ b/dedal/tests/integration_tests/spack_from_scratch_test.py @@ -65,14 +65,12 @@ def test_spack_from_scratch_setup_3(tmp_path): spack_operation.setup_spack_env() -def test_spack_from_scratch_setup_4(tmp_path): - install_dir = tmp_path - env = SpackDescriptor('new_env2', install_dir) - config = SpackConfig(env=env, install_dir=install_dir) - spack_operation = SpackOperationCreator.get_spack_operator(config) - spack_operation.install_spack() - spack_operation.setup_spack_env() - assert spack_operation.spack_env_exists() == True +def test_spack_from_scratch_setup_4(): + install_dir = Path('./install').resolve() + env = SpackModel('new_environment', install_dir, ) + spack_manager = SpackManagerScratch(env, system_name='ebrainslab') + spack_manager.setup_spack_env() + assert spack_manager.spack_repo_exists(env.env_name) == True def test_spack_not_a_valid_repo(): diff --git a/dedal/tests/integration_tests/spack_install_test.py b/dedal/tests/integration_tests/spack_install_test.py index 564d5c6a..34f68323 100644 --- a/dedal/tests/integration_tests/spack_install_test.py +++ b/dedal/tests/integration_tests/spack_install_test.py @@ -1,12 +1,21 @@ import pytest -from dedal.spack_factory.SpackOperation import SpackOperation -from dedal.tests.testing_variables import SPACK_VERSION +from esd.spack_manager.factory.SpackManagerBuildCache import SpackManagerBuildCache +from esd.spack_manager.factory.SpackManagerScratch import SpackManagerScratch -# run this test first so that spack is installed only once for all the tests + +# we need this test to run first so that spack is installed only once @pytest.mark.run(order=1) def test_spack_install_scratch(): - spack_operation = SpackOperation() - spack_operation.install_spack(spack_version=f'v{SPACK_VERSION}') - installed_spack_version = spack_operation.get_spack_installed_version() - assert SPACK_VERSION == installed_spack_version + spack_manager = SpackManagerScratch() + spack_manager.install_spack(spack_version="v0.21.1") + installed_spack_version = spack_manager.get_spack_installed_version() + required_version = "0.21.1" + assert required_version == installed_spack_version + + +def test_spack_install_buildcache(): + spack_manager = SpackManagerBuildCache() + installed_spack_version = spack_manager.get_spack_installed_version() + required_version = "0.21.1" + assert required_version == installed_spack_version diff --git a/esd/error_handling/exceptions.py b/esd/error_handling/exceptions.py new file mode 100644 index 00000000..2acca54e --- /dev/null +++ b/esd/error_handling/exceptions.py @@ -0,0 +1,13 @@ +class SpackException(Exception): + + def __init__(self, message): + super().__init__(message) + self.message = str(message) + + def __str__(self): + return self.message + +class BashCommandException(SpackException): + """ + To be thrown when an invalid input is received. + """ diff --git a/esd/model/SpackModel.py b/esd/model/SpackModel.py new file mode 100644 index 00000000..4b065dba --- /dev/null +++ b/esd/model/SpackModel.py @@ -0,0 +1,12 @@ +from pathlib import Path + + +class SpackModel: + """" + Provides details about the spack environment + """ + + def __init__(self, env_name: str, path: Path, git_path: str = None): + self.env_name = env_name + self.path = path + self.git_path = git_path diff --git a/esd/model/__init__.py b/esd/model/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/esd/spack_manager/SpackManager.py b/esd/spack_manager/SpackManager.py new file mode 100644 index 00000000..cdf4a1d3 --- /dev/null +++ b/esd/spack_manager/SpackManager.py @@ -0,0 +1,180 @@ +import os +import subprocess +from abc import ABC, abstractmethod +from pathlib import Path + +from esd.error_handling.exceptions import BashCommandException +from esd.logger.logger_builder import get_logger +from esd.model.SpackModel import SpackModel +from esd.utils.utils import run_command, git_clone_repo + + +class SpackManager(ABC): + """ + This class should implement the methods necessary for installing spack, set up an environment, concretize and install packages. + Factory design pattern is used because there are 2 cases: creating an environment from scratch or creating an environment from the buildcache. + + Attributes: + ----------- + env : SpackModel + spack environment details + repos : list[SpackModel] + upstream_instance : str + path to Spack instance to use as upstream (optional) + """ + + def __init__(self, env: SpackModel = None, repos=None, + upstream_instance=None, system_name: str = None, logger=get_logger(__name__)): + if repos is None: + self.repos = list() + self.env = env + self.upstream_instance = upstream_instance + self.install_dir = Path(os.environ.get("INSTALLATION_ROOT") or os.getcwd()) + self.install_dir.mkdir(parents=True, exist_ok=True) + self.spack_dir = self.install_dir / "spack" + self.spack_setup_script = self.spack_dir / "share" / "spack" / "setup-env.sh" + self.logger = logger + self.system_name = system_name + + @abstractmethod + def concretize_spack_env(self, force=True): + pass + + @abstractmethod + def install_spack_packages(self, jobs: 3, verbose=False, debug=False): + pass + + def create_fetch_spack_environment(self): + env_dir = self.install_dir / self.env.path / self.env.env_name + if self.env.git_path: + try: + git_clone_repo(self.env.env_name, env_dir, self.env.git_path, logger=self.logger) + except subprocess.CalledProcessError as e: + self.logger.exception(f'Failed to clone repository: {self.env.env_name}: {e}') + raise BashCommandException(f'Failed to clone repository: {self.env.env_name}: {e}') + else: + try: + os.makedirs(self.env.path / self.env.env_name, exist_ok=True) + run_command("bash", "-c", + f'source {self.spack_setup_script} && spack env create -d {self.env.path}/{self.env.env_name}', + check=True, logger=self.logger) + self.logger.debug(f"Created {self.env.env_name} spack environment") + except subprocess.CalledProcessError as e: + self.logger.error(f"Failed to create {self.env.env_name} spack environment") + raise BashCommandException(f"Failed to create {self.env.env_name} spack environment") + + def setup_spack_env(self): + """ + This method prepares a spack environment by fetching/creating the spack environment and adding the necessary repos + """ + bashrc_path = os.path.expanduser("~/.bashrc") + if self.system_name: + with open(bashrc_path, "a") as bashrc: + bashrc.write(f'export SYSTEMNAME="{self.system_name}"\n') + os.environ['SYSTEMNAME'] = self.system_name + if self.spack_dir.exists() and self.spack_dir.is_dir(): + with open(bashrc_path, "a") as bashrc: + bashrc.write(f'export SPACK_USER_CACHE_PATH="{str(self.spack_dir / ".spack")}"\n') + bashrc.write(f'export SPACK_USER_CONFIG_PATH="{str(self.spack_dir / ".spack")}"\n') + self.logger.debug('Added env variables SPACK_USER_CACHE_PATH and SPACK_USER_CONFIG_PATH') + else: + self.logger.error(f'Invalid installation path: {self.spack_dir}') + # Restart the bash after adding environment variables + self.create_fetch_spack_environment() + if self.install_dir.exists(): + for repo in self.repos: + repo_dir = self.install_dir / repo.path / repo.env_name + git_clone_repo(repo.env_name, repo_dir, repo.git_path, logger=self.logger) + if not self.spack_repo_exists(repo.env_name): + self.add_spack_repo(repo.path, repo.env_name) + self.logger.debug(f'Added spack repository {repo.env_name}') + else: + self.logger.debug(f'Spack repository {repo.env_name} already added') + + def spack_repo_exists(self, repo_name: str) -> bool: + """Check if the given Spack repository exists.""" + if self.env is None: + try: + result = run_command("bash", "-c", + f'source {self.spack_setup_script} && spack repo list', + check=True, + capture_output=True, text=True, logger=self.logger) + except subprocess.CalledProcessError: + return False + else: + try: + result = run_command("bash", "-c", + f'source {self.spack_setup_script} && spack env activate -p {self.env.path}/{self.env.env_name} && spack repo list', + check=True, + capture_output=True, text=True, logger=self.logger) + except subprocess.CalledProcessError: + return False + return any(line.strip().endswith(repo_name) for line in result.stdout.splitlines()) + + def add_spack_repo(self, repo_path: Path, repo_name: str): + """Add the Spack repository if it does not exist.""" + repo_path = repo_path.resolve().as_posix() + try: + run_command("bash", "-c", + f'source {self.spack_setup_script} && spack env activate -p {self.env.path}/{self.env.env_name} && spack repo add {repo_path}/{repo_name}', + check=True, logger=self.logger) + self.logger.debug(f"Added {repo_name} to spack environment {self.env.env_name}") + except subprocess.CalledProcessError as e: + self.logger.error(f"Failed to add {repo_name} to spack environment {self.env.env_name}") + raise BashCommandException(f"Failed to add {repo_name} to spack environment {self.env.env_name}: {e}") + + def get_spack_installed_version(self): + try: + spack_version = run_command("bash", "-c", f'source {self.spack_setup_script} && spack --version', + capture_output=True, text=True, check=True, + logger=self.logger) + spack_version = spack_version.stdout.strip().split()[0] + self.logger.debug(f"Getting spack version: {spack_version}") + return spack_version + except subprocess.SubprocessError as e: + self.logger.error(f"Error retrieving Spack version: {e}") + return None + + def install_spack(self, spack_version="v0.21.1", spack_repo='https://github.com/spack/spack'): + try: + user = os.getlogin() + except OSError: + user = None + + self.logger.info(f"Starting to install Spack into {self.spack_dir} from branch {spack_version}") + if not self.spack_dir.exists(): + run_command( + "git", "clone", "--depth", "1", + "-c", "advice.detachedHead=false", + "-c", "feature.manyFiles=true", + "--branch", spack_version, spack_repo, self.spack_dir + , check=True, logger=self.logger) + self.logger.debug("Cloned spack") + else: + self.logger.debug("Spack already cloned.") + + bashrc_path = os.path.expanduser("~/.bashrc") + # ensure the file exists before opening it + if not os.path.exists(bashrc_path): + open(bashrc_path, "w").close() + # add spack setup commands to .bashrc + with open(bashrc_path, "a") as bashrc: + bashrc.write(f'export PATH="{self.spack_dir}/bin:$PATH"\n') + bashrc.write(f"source {self.spack_setup_script}\n") + self.logger.info("Added Spack PATH to .bashrc") + if user: + run_command("chown", "-R", f"{user}:{user}", self.spack_dir, check=True, logger=self.logger) + run_command("bash", "-c", f"source {bashrc_path}", check=True, logger=self.logger) + self.logger.info("Spack install completed") + # Restart Bash after the installation ends + os.system("exec bash") + + # Configure upstream Spack instance if specified + if self.upstream_instance: + upstreams_yaml_path = os.path.join(self.spack_dir, "etc/spack/defaults/upstreams.yaml") + with open(upstreams_yaml_path, "w") as file: + file.write(f"""upstreams: + upstream-spack-instance: + install_tree: {self.upstream_instance}/spack/opt/spack + """) + self.logger.info("Added upstream spack instance") diff --git a/esd/spack_manager/__init__.py b/esd/spack_manager/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/esd/spack_manager/enums/SpackManagerEnum.py b/esd/spack_manager/enums/SpackManagerEnum.py new file mode 100644 index 00000000..a2435839 --- /dev/null +++ b/esd/spack_manager/enums/SpackManagerEnum.py @@ -0,0 +1,6 @@ +from enum import Enum + + +class SpackManagerEnum(Enum): + FROM_SCRATCH = "from_scratch", + FROM_BUILDCACHE = "from_buildcache", diff --git a/esd/spack_manager/enums/__init__.py b/esd/spack_manager/enums/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/esd/spack_manager/factory/SpackManagerBuildCache.py b/esd/spack_manager/factory/SpackManagerBuildCache.py new file mode 100644 index 00000000..38151c6d --- /dev/null +++ b/esd/spack_manager/factory/SpackManagerBuildCache.py @@ -0,0 +1,19 @@ +from esd.model.SpackModel import SpackModel +from esd.spack_manager.SpackManager import SpackManager +from esd.logger.logger_builder import get_logger + + +class SpackManagerBuildCache(SpackManager): + def __init__(self, env: SpackModel = None, repos=None, + upstream_instance=None, system_name: str = None): + super().__init__(env, repos, upstream_instance, system_name, logger=get_logger(__name__)) + + def setup_spack_env(self): + super().setup_spack_env() + # todo add buildcache to the spack environment + + def concretize_spack_env(self, force=True): + pass + + def install_spack_packages(self, jobs: 3, verbose=False, debug=False): + pass diff --git a/esd/spack_manager/factory/SpackManagerCreator.py b/esd/spack_manager/factory/SpackManagerCreator.py new file mode 100644 index 00000000..9728467f --- /dev/null +++ b/esd/spack_manager/factory/SpackManagerCreator.py @@ -0,0 +1,14 @@ +from esd.spack_manager.enums.SpackManagerEnum import SpackManagerEnum +from esd.spack_manager.factory.SpackManagerBuildCache import SpackManagerBuildCache +from esd.spack_manager.factory.SpackManagerScratch import SpackManagerScratch + + +class SpackManagerCreator: + @staticmethod + def get_spack_manger(spack_manager_type: SpackManagerEnum, env_name: str, repo: str, repo_name: str, + upstream_instance: str): + if spack_manager_type == SpackManagerEnum.FROM_SCRATCH: + return SpackManagerScratch(env_name, repo, repo_name, upstream_instance) + elif spack_manager_type == SpackManagerEnum.FROM_BUILDCACHE: + return SpackManagerBuildCache(env_name, repo, repo_name, upstream_instance) + diff --git a/esd/spack_manager/factory/SpackManagerScratch.py b/esd/spack_manager/factory/SpackManagerScratch.py new file mode 100644 index 00000000..5a79797c --- /dev/null +++ b/esd/spack_manager/factory/SpackManagerScratch.py @@ -0,0 +1,15 @@ +from esd.model.SpackModel import SpackModel +from esd.spack_manager.SpackManager import SpackManager +from esd.logger.logger_builder import get_logger + + +class SpackManagerScratch(SpackManager): + def __init__(self, env: SpackModel = None, repos=None, + upstream_instance=None, system_name: str = None): + super().__init__(env, repos, upstream_instance, system_name, logger=get_logger(__name__)) + + def concretize_spack_env(self, force=True): + pass + + def install_spack_packages(self, jobs: 3, verbose=False, debug=False): + pass diff --git a/esd/spack_manager/factory/__init__.py b/esd/spack_manager/factory/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/esd/utils/bootstrap.sh b/esd/utils/bootstrap.sh new file mode 100644 index 00000000..9b7d0131 --- /dev/null +++ b/esd/utils/bootstrap.sh @@ -0,0 +1,6 @@ +# Minimal prerequisites for installing the esd_library +# pip must be installed on the OS +echo "Bootstrapping..." +apt update +apt install -y bzip2 ca-certificates g++ gcc gfortran git gzip lsb-release patch python3 python3-pip tar unzip xz-utils zstd +python3 -m pip install --upgrade pip setuptools wheel diff --git a/esd/utils/utils.py b/esd/utils/utils.py new file mode 100644 index 00000000..d229e345 --- /dev/null +++ b/esd/utils/utils.py @@ -0,0 +1,42 @@ +import logging +import shutil +import subprocess +from pathlib import Path + + +def clean_up(dirs: list[str], logging, ignore_errors=True): + """ + All the folders from the list dirs are removed with all the content in them + """ + for cleanup_dir in dirs: + cleanup_dir = Path(cleanup_dir).resolve() + if cleanup_dir.exists(): + logging.info(f"Removing {cleanup_dir}") + try: + shutil.rmtree(Path(cleanup_dir)) + except OSError as e: + logging.error(f"Failed to remove {cleanup_dir}: {e}") + if not ignore_errors: + raise e + else: + logging.info(f"{cleanup_dir} does not exist") + + +def run_command(*args, logger: None, **kwargs): + if logger is None: + logger = logging.getLogger(__name__) + logger.debug(f'{args}') + return subprocess.run(args, **kwargs) + + +def git_clone_repo(repo_name: str, dir: Path, git_path: str, logger: logging): + if not dir.exists(): + run_command( + "git", "clone", "--depth", "1", + "-c", "advice.detachedHead=false", + "-c", "feature.manyFiles=true", + git_path, dir + , check=True, logger=logger) + logger.debug(f'Cloned repository {repo_name}') + else: + logger.debug(f'Repository {repo_name} already cloned.') diff --git a/pyproject.toml b/pyproject.toml index b6679ca1..abcbe05d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = ["setuptools>=64", "wheel"] build-backend = "setuptools.build_meta" [project] -name = "dedal" +name = "esd-tools" version = "0.1.0" authors = [ {name = "Eric Müller", email = "mueller@kip.uni-heidelberg.de"}, @@ -22,4 +22,4 @@ dependencies = [ ] [tool.setuptools.data-files] -"dedal" = ["dedal/logger/logging.conf"] \ No newline at end of file +"esd-tools" = ["esd/logger/logging.conf"] \ No newline at end of file -- GitLab From 49f82dfb268ffbc608e4a209221ccb386ab06ff7 Mon Sep 17 00:00:00 2001 From: adrianciu <adrian.ciu@codemart.ro> Date: Wed, 5 Feb 2025 11:33:28 +0200 Subject: [PATCH 13/30] esd-spack-installation: fixing tests --- esd/spack_manager/SpackManager.py | 109 ++++++++++++++++-------------- esd/utils/utils.py | 27 +++++--- 2 files changed, 76 insertions(+), 60 deletions(-) diff --git a/esd/spack_manager/SpackManager.py b/esd/spack_manager/SpackManager.py index cdf4a1d3..f7bb00b4 100644 --- a/esd/spack_manager/SpackManager.py +++ b/esd/spack_manager/SpackManager.py @@ -1,5 +1,4 @@ import os -import subprocess from abc import ABC, abstractmethod from pathlib import Path @@ -26,11 +25,17 @@ class SpackManager(ABC): def __init__(self, env: SpackModel = None, repos=None, upstream_instance=None, system_name: str = None, logger=get_logger(__name__)): if repos is None: - self.repos = list() + repos = [] + self.repos = repos self.env = env - self.upstream_instance = upstream_instance - self.install_dir = Path(os.environ.get("INSTALLATION_ROOT") or os.getcwd()) + self.install_dir = Path(os.environ.get("INSTALLATION_ROOT") or os.getcwd()).resolve() self.install_dir.mkdir(parents=True, exist_ok=True) + self.env_path = None + if self.env and self.env.path: + self.env.path = self.env.path.resolve() + self.env.path.mkdir(parents=True, exist_ok=True) + self.env_path = self.env.path / self.env.env_name + self.upstream_instance = upstream_instance self.spack_dir = self.install_dir / "spack" self.spack_setup_script = self.spack_dir / "share" / "spack" / "setup-env.sh" self.logger = logger @@ -45,23 +50,17 @@ class SpackManager(ABC): pass def create_fetch_spack_environment(self): - env_dir = self.install_dir / self.env.path / self.env.env_name + if self.env.git_path: - try: - git_clone_repo(self.env.env_name, env_dir, self.env.git_path, logger=self.logger) - except subprocess.CalledProcessError as e: - self.logger.exception(f'Failed to clone repository: {self.env.env_name}: {e}') - raise BashCommandException(f'Failed to clone repository: {self.env.env_name}: {e}') + git_clone_repo(self.env.env_name, self.env.path / self.env.env_name, self.env.git_path, logger=self.logger) else: - try: - os.makedirs(self.env.path / self.env.env_name, exist_ok=True) - run_command("bash", "-c", - f'source {self.spack_setup_script} && spack env create -d {self.env.path}/{self.env.env_name}', - check=True, logger=self.logger) - self.logger.debug(f"Created {self.env.env_name} spack environment") - except subprocess.CalledProcessError as e: - self.logger.error(f"Failed to create {self.env.env_name} spack environment") - raise BashCommandException(f"Failed to create {self.env.env_name} spack environment") + os.makedirs(self.env.path / self.env.env_name, exist_ok=True) + run_command("bash", "-c", + f'source {self.spack_setup_script} && spack env create -d {self.env_path}', + check=True, logger=self.logger, + debug_msg=f"Created {self.env.env_name} spack environment", + exception_msg=f"Failed to create {self.env.env_name} spack environment", + exception=BashCommandException) def setup_spack_env(self): """ @@ -94,46 +93,51 @@ class SpackManager(ABC): def spack_repo_exists(self, repo_name: str) -> bool: """Check if the given Spack repository exists.""" if self.env is None: - try: - result = run_command("bash", "-c", - f'source {self.spack_setup_script} && spack repo list', - check=True, - capture_output=True, text=True, logger=self.logger) - except subprocess.CalledProcessError: + result = run_command("bash", "-c", + f'source {self.spack_setup_script} && spack repo list', + check=True, + capture_output=True, text=True, logger=self.logger, + debug_msg=f'Checking if {repo_name} exists') + if result is None: return False else: - try: - result = run_command("bash", "-c", - f'source {self.spack_setup_script} && spack env activate -p {self.env.path}/{self.env.env_name} && spack repo list', - check=True, - capture_output=True, text=True, logger=self.logger) - except subprocess.CalledProcessError: + result = run_command("bash", "-c", + f'source {self.spack_setup_script} && spack env activate -p {self.env_path} && spack repo list', + check=True, + capture_output=True, text=True, logger=self.logger, + debug_msg=f'Checking if repository {repo_name} was added') + if result is None: return False return any(line.strip().endswith(repo_name) for line in result.stdout.splitlines()) + def spack_env_exists(self): + result = run_command("bash", "-c", + f'source {self.spack_setup_script} && spack env activate -p {self.env_path}', + check=True, + capture_output=True, text=True, logger=self.logger, + debug_msg=f'Checking if environment {self.env.env_name} exists') + if result is None: + return False + return True + def add_spack_repo(self, repo_path: Path, repo_name: str): """Add the Spack repository if it does not exist.""" - repo_path = repo_path.resolve().as_posix() - try: - run_command("bash", "-c", - f'source {self.spack_setup_script} && spack env activate -p {self.env.path}/{self.env.env_name} && spack repo add {repo_path}/{repo_name}', - check=True, logger=self.logger) - self.logger.debug(f"Added {repo_name} to spack environment {self.env.env_name}") - except subprocess.CalledProcessError as e: - self.logger.error(f"Failed to add {repo_name} to spack environment {self.env.env_name}") - raise BashCommandException(f"Failed to add {repo_name} to spack environment {self.env.env_name}: {e}") + run_command("bash", "-c", + f'source {self.spack_setup_script} && spack env activate -p {self.env_path} && spack repo add {repo_path}/{repo_name}', + check=True, logger=self.logger, + debug_msg=f"Added {repo_name} to spack environment {self.env.env_name}", + exception_msg=f"Failed to add {repo_name} to spack environment {self.env.env_name}", + exception=BashCommandException) def get_spack_installed_version(self): - try: - spack_version = run_command("bash", "-c", f'source {self.spack_setup_script} && spack --version', - capture_output=True, text=True, check=True, - logger=self.logger) - spack_version = spack_version.stdout.strip().split()[0] - self.logger.debug(f"Getting spack version: {spack_version}") - return spack_version - except subprocess.SubprocessError as e: - self.logger.error(f"Error retrieving Spack version: {e}") - return None + spack_version = run_command("bash", "-c", f'source {self.spack_setup_script} && spack --version', + capture_output=True, text=True, check=True, + logger=self.logger, + debug_msg=f"Getting spack version", + exception_msg=f"Error retrieving Spack version") + if spack_version: + return spack_version.stdout.strip().split()[0] + return None def install_spack(self, spack_version="v0.21.1", spack_repo='https://github.com/spack/spack'): try: @@ -163,8 +167,9 @@ class SpackManager(ABC): bashrc.write(f"source {self.spack_setup_script}\n") self.logger.info("Added Spack PATH to .bashrc") if user: - run_command("chown", "-R", f"{user}:{user}", self.spack_dir, check=True, logger=self.logger) - run_command("bash", "-c", f"source {bashrc_path}", check=True, logger=self.logger) + run_command("chown", "-R", f"{user}:{user}", self.spack_dir, check=True, logger=self.logger, + debug_msg='Adding permissions to the logged in user') + run_command("bash", "-c", f"source {bashrc_path}", check=True, logger=self.logger, debug_msg='Restart bash') self.logger.info("Spack install completed") # Restart Bash after the installation ends os.system("exec bash") diff --git a/esd/utils/utils.py b/esd/utils/utils.py index d229e345..48c500c3 100644 --- a/esd/utils/utils.py +++ b/esd/utils/utils.py @@ -3,6 +3,8 @@ import shutil import subprocess from pathlib import Path +from esd.error_handling.exceptions import BashCommandException + def clean_up(dirs: list[str], logging, ignore_errors=True): """ @@ -22,21 +24,30 @@ def clean_up(dirs: list[str], logging, ignore_errors=True): logging.info(f"{cleanup_dir} does not exist") -def run_command(*args, logger: None, **kwargs): - if logger is None: - logger = logging.getLogger(__name__) - logger.debug(f'{args}') - return subprocess.run(args, **kwargs) +def run_command(*args, logger=logging.getLogger(__name__), debug_msg: str = '', exception_msg: str = None, + exception=None, **kwargs): + try: + logger.debug(f'{debug_msg}: args: {args}') + return subprocess.run(args, **kwargs) + except subprocess.CalledProcessError as e: + if exception_msg is not None: + logger.error(f"{exception_msg}: {e}") + if exception is not None: + raise exception(f'{exception_msg} : {e}') + else: + return None -def git_clone_repo(repo_name: str, dir: Path, git_path: str, logger: logging): +def git_clone_repo(repo_name: str, dir: Path, git_path: str, logger: logging = logging.getLogger(__name__)): if not dir.exists(): run_command( "git", "clone", "--depth", "1", "-c", "advice.detachedHead=false", "-c", "feature.manyFiles=true", git_path, dir - , check=True, logger=logger) - logger.debug(f'Cloned repository {repo_name}') + , check=True, logger=logger, + debug_msg=f'Cloned repository {repo_name}', + exception_msg=f'Failed to clone repository: {repo_name}', + exception=BashCommandException) else: logger.debug(f'Repository {repo_name} already cloned.') -- GitLab From 53ddcc4184eb5929b8115a97315aa4c111feb9fc Mon Sep 17 00:00:00 2001 From: adrianciu <adrian.ciu@codemart.ro> Date: Wed, 5 Feb 2025 15:18:03 +0200 Subject: [PATCH 14/30] esd-spack-installation: added spack env exceptions --- esd/error_handling/exceptions.py | 5 +++++ esd/spack_manager/SpackManager.py | 26 +++++++++++++++++--------- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/esd/error_handling/exceptions.py b/esd/error_handling/exceptions.py index 2acca54e..79f8051f 100644 --- a/esd/error_handling/exceptions.py +++ b/esd/error_handling/exceptions.py @@ -11,3 +11,8 @@ class BashCommandException(SpackException): """ To be thrown when an invalid input is received. """ + +class NoSpackEnvironmentException(SpackException): + """ + To be thrown when an invalid input is received. + """ \ No newline at end of file diff --git a/esd/spack_manager/SpackManager.py b/esd/spack_manager/SpackManager.py index f7bb00b4..340b1b95 100644 --- a/esd/spack_manager/SpackManager.py +++ b/esd/spack_manager/SpackManager.py @@ -2,7 +2,7 @@ import os from abc import ABC, abstractmethod from pathlib import Path -from esd.error_handling.exceptions import BashCommandException +from esd.error_handling.exceptions import BashCommandException, NoSpackEnvironmentException from esd.logger.logger_builder import get_logger from esd.model.SpackModel import SpackModel from esd.utils.utils import run_command, git_clone_repo @@ -90,7 +90,7 @@ class SpackManager(ABC): else: self.logger.debug(f'Spack repository {repo.env_name} already added') - def spack_repo_exists(self, repo_name: str) -> bool: + def spack_repo_exists(self, repo_name: str) -> bool | None: """Check if the given Spack repository exists.""" if self.env is None: result = run_command("bash", "-c", @@ -101,11 +101,15 @@ class SpackManager(ABC): if result is None: return False else: - result = run_command("bash", "-c", + if self.spack_env_exists(): + result = run_command("bash", "-c", f'source {self.spack_setup_script} && spack env activate -p {self.env_path} && spack repo list', check=True, capture_output=True, text=True, logger=self.logger, debug_msg=f'Checking if repository {repo_name} was added') + else: + self.logger.debug('No spack environment defined') + raise NoSpackEnvironmentException('No spack environment defined') if result is None: return False return any(line.strip().endswith(repo_name) for line in result.stdout.splitlines()) @@ -122,12 +126,16 @@ class SpackManager(ABC): def add_spack_repo(self, repo_path: Path, repo_name: str): """Add the Spack repository if it does not exist.""" - run_command("bash", "-c", - f'source {self.spack_setup_script} && spack env activate -p {self.env_path} && spack repo add {repo_path}/{repo_name}', - check=True, logger=self.logger, - debug_msg=f"Added {repo_name} to spack environment {self.env.env_name}", - exception_msg=f"Failed to add {repo_name} to spack environment {self.env.env_name}", - exception=BashCommandException) + if self.spack_env_exists(): + run_command("bash", "-c", + f'source {self.spack_setup_script} && spack env activate -p {self.env_path} && spack repo add {repo_path}/{repo_name}', + check=True, logger=self.logger, + debug_msg=f"Added {repo_name} to spack environment {self.env.env_name}", + exception_msg=f"Failed to add {repo_name} to spack environment {self.env.env_name}", + exception=BashCommandException) + else: + self.logger.debug('No spack environment defined') + raise NoSpackEnvironmentException('No spack environment defined') def get_spack_installed_version(self): spack_version = run_command("bash", "-c", f'source {self.spack_setup_script} && spack --version', -- GitLab From dfa83ee626956f80711961f0bb4ffb3787caf65a Mon Sep 17 00:00:00 2001 From: adrianciu <adrian.ciu@codemart.ro> Date: Wed, 5 Feb 2025 21:32:38 +0200 Subject: [PATCH 15/30] esd-spack-installation: added clean up after each test --- dedal/tests/integration_tests/spack_install_test.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/dedal/tests/integration_tests/spack_install_test.py b/dedal/tests/integration_tests/spack_install_test.py index 34f68323..50e94044 100644 --- a/dedal/tests/integration_tests/spack_install_test.py +++ b/dedal/tests/integration_tests/spack_install_test.py @@ -4,18 +4,18 @@ from esd.spack_manager.factory.SpackManagerBuildCache import SpackManagerBuildCa from esd.spack_manager.factory.SpackManagerScratch import SpackManagerScratch +SPACK_VERSION = "0.22.0" + # we need this test to run first so that spack is installed only once @pytest.mark.run(order=1) def test_spack_install_scratch(): spack_manager = SpackManagerScratch() - spack_manager.install_spack(spack_version="v0.21.1") + spack_manager.install_spack(spack_version=f'v{SPACK_VERSION}') installed_spack_version = spack_manager.get_spack_installed_version() - required_version = "0.21.1" - assert required_version == installed_spack_version + assert SPACK_VERSION == installed_spack_version def test_spack_install_buildcache(): spack_manager = SpackManagerBuildCache() installed_spack_version = spack_manager.get_spack_installed_version() - required_version = "0.21.1" - assert required_version == installed_spack_version + assert SPACK_VERSION == installed_spack_version -- GitLab From 4bb169ac555ec575b338efefec2826a41c47387a Mon Sep 17 00:00:00 2001 From: adrianciu <adrian.ciu@codemart.ro> Date: Wed, 5 Feb 2025 23:09:24 +0200 Subject: [PATCH 16/30] esd-spack-installation: added concretization step and tests --- .../tests/integration_tests/spack_install_test.py | 2 +- esd/error_handling/exceptions.py | 14 +++++++++++--- esd/spack_manager/factory/SpackManagerCreator.py | 10 +++++----- esd/spack_manager/factory/SpackManagerScratch.py | 15 ++++++++++++++- esd/utils/utils.py | 4 ++++ 5 files changed, 35 insertions(+), 10 deletions(-) diff --git a/dedal/tests/integration_tests/spack_install_test.py b/dedal/tests/integration_tests/spack_install_test.py index 50e94044..9a32d5c7 100644 --- a/dedal/tests/integration_tests/spack_install_test.py +++ b/dedal/tests/integration_tests/spack_install_test.py @@ -6,7 +6,7 @@ from esd.spack_manager.factory.SpackManagerScratch import SpackManagerScratch SPACK_VERSION = "0.22.0" -# we need this test to run first so that spack is installed only once +# we need this test to run first so that spack is installed only once for all the tests @pytest.mark.run(order=1) def test_spack_install_scratch(): spack_manager = SpackManagerScratch() diff --git a/esd/error_handling/exceptions.py b/esd/error_handling/exceptions.py index 79f8051f..d6de666b 100644 --- a/esd/error_handling/exceptions.py +++ b/esd/error_handling/exceptions.py @@ -7,12 +7,20 @@ class SpackException(Exception): def __str__(self): return self.message + class BashCommandException(SpackException): """ - To be thrown when an invalid input is received. + To be thrown when a bash command has failed """ + class NoSpackEnvironmentException(SpackException): """ - To be thrown when an invalid input is received. - """ \ No newline at end of file + To be thrown when an operation on a spack environment is executed without the environment being activated or existent + """ + + +class SpackConcertizeException(SpackException): + """ + To be thrown when the spack concretization step fails + """ diff --git a/esd/spack_manager/factory/SpackManagerCreator.py b/esd/spack_manager/factory/SpackManagerCreator.py index 9728467f..6eb26a04 100644 --- a/esd/spack_manager/factory/SpackManagerCreator.py +++ b/esd/spack_manager/factory/SpackManagerCreator.py @@ -1,3 +1,4 @@ +from esd.model.SpackModel import SpackModel from esd.spack_manager.enums.SpackManagerEnum import SpackManagerEnum from esd.spack_manager.factory.SpackManagerBuildCache import SpackManagerBuildCache from esd.spack_manager.factory.SpackManagerScratch import SpackManagerScratch @@ -5,10 +6,9 @@ from esd.spack_manager.factory.SpackManagerScratch import SpackManagerScratch class SpackManagerCreator: @staticmethod - def get_spack_manger(spack_manager_type: SpackManagerEnum, env_name: str, repo: str, repo_name: str, - upstream_instance: str): + def get_spack_manger(spack_manager_type: SpackManagerEnum, env: SpackModel = None, repos=None, + upstream_instance=None, system_name: str = None): if spack_manager_type == SpackManagerEnum.FROM_SCRATCH: - return SpackManagerScratch(env_name, repo, repo_name, upstream_instance) + return SpackManagerScratch(env, repos, upstream_instance, system_name) elif spack_manager_type == SpackManagerEnum.FROM_BUILDCACHE: - return SpackManagerBuildCache(env_name, repo, repo_name, upstream_instance) - + return SpackManagerBuildCache(env, repos, upstream_instance, system_name) diff --git a/esd/spack_manager/factory/SpackManagerScratch.py b/esd/spack_manager/factory/SpackManagerScratch.py index 5a79797c..6380d123 100644 --- a/esd/spack_manager/factory/SpackManagerScratch.py +++ b/esd/spack_manager/factory/SpackManagerScratch.py @@ -1,6 +1,8 @@ +from esd.error_handling.exceptions import SpackConcertizeException, NoSpackEnvironmentException from esd.model.SpackModel import SpackModel from esd.spack_manager.SpackManager import SpackManager from esd.logger.logger_builder import get_logger +from esd.utils.utils import run_command class SpackManagerScratch(SpackManager): @@ -9,7 +11,18 @@ class SpackManagerScratch(SpackManager): super().__init__(env, repos, upstream_instance, system_name, logger=get_logger(__name__)) def concretize_spack_env(self, force=True): - pass + force = '--force' if force else '' + if self.spack_env_exists(): + run_command("bash", "-c", + f'source {self.spack_setup_script} && spack env activate -p {self.env_path} $$ spack concretize {force}', + check=True, + capture_output=True, text=True, logger=self.logger, + debug_msg=f'Concertization step for {self.env.env_name}', + exception_msg=f'Failed the concertization step for {self.env.env_name}', + exception=SpackConcertizeException) + else: + self.logger.debug('No spack environment defined') + raise NoSpackEnvironmentException('No spack environment defined') def install_spack_packages(self, jobs: 3, verbose=False, debug=False): pass diff --git a/esd/utils/utils.py b/esd/utils/utils.py index 48c500c3..173347d0 100644 --- a/esd/utils/utils.py +++ b/esd/utils/utils.py @@ -51,3 +51,7 @@ def git_clone_repo(repo_name: str, dir: Path, git_path: str, logger: logging = l exception=BashCommandException) else: logger.debug(f'Repository {repo_name} already cloned.') + + +def file_exists_and_not_empty(file: Path) -> bool: + return file.is_file() and file.stat().st_size > 0 -- GitLab From 36a42d69240ddbd038f935bc072c844c062f0be8 Mon Sep 17 00:00:00 2001 From: adrianciu <adrianciu25@gmail.com> Date: Thu, 6 Feb 2025 09:56:09 +0200 Subject: [PATCH 17/30] esd-spack-installation: fixed concretization step --- esd/spack_manager/factory/SpackManagerScratch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esd/spack_manager/factory/SpackManagerScratch.py b/esd/spack_manager/factory/SpackManagerScratch.py index 6380d123..2ec22705 100644 --- a/esd/spack_manager/factory/SpackManagerScratch.py +++ b/esd/spack_manager/factory/SpackManagerScratch.py @@ -14,7 +14,7 @@ class SpackManagerScratch(SpackManager): force = '--force' if force else '' if self.spack_env_exists(): run_command("bash", "-c", - f'source {self.spack_setup_script} && spack env activate -p {self.env_path} $$ spack concretize {force}', + f'source {self.spack_setup_script} && spack env activate -p {self.env_path} && spack concretize {force}', check=True, capture_output=True, text=True, logger=self.logger, debug_msg=f'Concertization step for {self.env.env_name}', -- GitLab From 9abbf5f4ed004b1246cc23251611b43385bc0bcc Mon Sep 17 00:00:00 2001 From: adrianciu <adrianciu25@gmail.com> Date: Thu, 6 Feb 2025 10:10:33 +0200 Subject: [PATCH 18/30] esd-spack-installation: additional methods --- .gitlab-ci.yml | 1 + esd/error_handling/exceptions.py | 9 +++- esd/spack_manager/SpackManager.py | 51 +++++++++++++++------- esd/spack_manager/wrapper/__init__.py | 0 esd/spack_manager/wrapper/spack_wrapper.py | 15 +++++++ 5 files changed, 59 insertions(+), 17 deletions(-) create mode 100644 esd/spack_manager/wrapper/__init__.py create mode 100644 esd/spack_manager/wrapper/spack_wrapper.py diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 0ce02907..7af0dd30 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -5,6 +5,7 @@ stages: variables: BUILD_ENV_DOCKER_IMAGE: docker-registry.ebrains.eu/esd/tmp:latest + build-wheel: stage: build tags: diff --git a/esd/error_handling/exceptions.py b/esd/error_handling/exceptions.py index d6de666b..9d11b5fa 100644 --- a/esd/error_handling/exceptions.py +++ b/esd/error_handling/exceptions.py @@ -14,13 +14,18 @@ class BashCommandException(SpackException): """ -class NoSpackEnvironmentException(SpackException): +class NoSpackEnvironmentException(BashCommandException): """ To be thrown when an operation on a spack environment is executed without the environment being activated or existent """ -class SpackConcertizeException(SpackException): +class SpackConcertizeException(BashCommandException): """ To be thrown when the spack concretization step fails """ + +class SpackInstallPackagesException(BashCommandException): + """ + To be thrown when the spack fails to install spack packages + """ diff --git a/esd/spack_manager/SpackManager.py b/esd/spack_manager/SpackManager.py index 340b1b95..a7f46c27 100644 --- a/esd/spack_manager/SpackManager.py +++ b/esd/spack_manager/SpackManager.py @@ -1,10 +1,13 @@ import os +import re from abc import ABC, abstractmethod from pathlib import Path -from esd.error_handling.exceptions import BashCommandException, NoSpackEnvironmentException +from esd.error_handling.exceptions import BashCommandException, NoSpackEnvironmentException, \ + SpackInstallPackagesException from esd.logger.logger_builder import get_logger from esd.model.SpackModel import SpackModel +from esd.spack_manager.wrapper.spack_wrapper import no_spack_env from esd.utils.utils import run_command, git_clone_repo @@ -103,10 +106,10 @@ class SpackManager(ABC): else: if self.spack_env_exists(): result = run_command("bash", "-c", - f'source {self.spack_setup_script} && spack env activate -p {self.env_path} && spack repo list', - check=True, - capture_output=True, text=True, logger=self.logger, - debug_msg=f'Checking if repository {repo_name} was added') + f'source {self.spack_setup_script} && spack env activate -p {self.env_path} && spack repo list', + check=True, + capture_output=True, text=True, logger=self.logger, + debug_msg=f'Checking if repository {repo_name} was added') else: self.logger.debug('No spack environment defined') raise NoSpackEnvironmentException('No spack environment defined') @@ -124,18 +127,35 @@ class SpackManager(ABC): return False return True + @no_spack_env def add_spack_repo(self, repo_path: Path, repo_name: str): """Add the Spack repository if it does not exist.""" - if self.spack_env_exists(): - run_command("bash", "-c", - f'source {self.spack_setup_script} && spack env activate -p {self.env_path} && spack repo add {repo_path}/{repo_name}', - check=True, logger=self.logger, - debug_msg=f"Added {repo_name} to spack environment {self.env.env_name}", - exception_msg=f"Failed to add {repo_name} to spack environment {self.env.env_name}", - exception=BashCommandException) - else: - self.logger.debug('No spack environment defined') - raise NoSpackEnvironmentException('No spack environment defined') + run_command("bash", "-c", + f'source {self.spack_setup_script} && spack env activate -p {self.env_path} && spack repo add {repo_path}/{repo_name}', + check=True, logger=self.logger, + debug_msg=f"Added {repo_name} to spack environment {self.env.env_name}", + exception_msg=f"Failed to add {repo_name} to spack environment {self.env.env_name}", + exception=BashCommandException) + + @no_spack_env + def get_compiler_version(self): + result = run_command("bash", "-c", + f'source {self.spack_setup_script} && spack env activate -p {self.env_path} && spack compiler list', + check=True, logger=self.logger, + capture_output=True, text=True, + debug_msg=f"Checking spack environment compiler version for {self.env.env_name}", + exception_msg=f"Failed to checking spack environment compiler version for {self.env.env_name}", + exception=BashCommandException) + # todo add error handling and tests + if result.stdout is None: + self.logger.debug('No gcc found for {self.env.env_name}') + return None + + # Find the first occurrence of a GCC compiler using regex + match = re.search(r"gcc@([\d\.]+)", result.stdout) + gcc_version = match.group(1) + self.logger.debug(f'Found gcc for {self.env.env_name}: {gcc_version}') + return gcc_version def get_spack_installed_version(self): spack_version = run_command("bash", "-c", f'source {self.spack_setup_script} && spack --version', @@ -147,6 +167,7 @@ class SpackManager(ABC): return spack_version.stdout.strip().split()[0] return None + def install_spack(self, spack_version="v0.21.1", spack_repo='https://github.com/spack/spack'): try: user = os.getlogin() diff --git a/esd/spack_manager/wrapper/__init__.py b/esd/spack_manager/wrapper/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/esd/spack_manager/wrapper/spack_wrapper.py b/esd/spack_manager/wrapper/spack_wrapper.py new file mode 100644 index 00000000..d075a317 --- /dev/null +++ b/esd/spack_manager/wrapper/spack_wrapper.py @@ -0,0 +1,15 @@ +import functools + +from esd.error_handling.exceptions import NoSpackEnvironmentException + + +def no_spack_env(method): + @functools.wraps(method) + def wrapper(self, *args, **kwargs): + if self.spack_env_exists(): + return method(self, *args, **kwargs) # Call the method with 'self' + else: + self.logger.debug('No spack environment defined') + raise NoSpackEnvironmentException('No spack environment defined') + + return wrapper -- GitLab From c708d57a58db5333e07542ee773bae48f2e6431e Mon Sep 17 00:00:00 2001 From: adrianciu <adrian.ciu@codemart.ro> Date: Thu, 6 Feb 2025 18:14:49 +0200 Subject: [PATCH 19/30] esd-spack-installation: fixed passing access token to tests; added log file for spack install step --- .gitlab-ci.yml | 1 + esd/spack_manager/SpackManager.py | 25 ++++++++++++++++------ esd/spack_manager/wrapper/spack_wrapper.py | 2 +- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 7af0dd30..d1b09e6a 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -39,5 +39,6 @@ testing-pytest: paths: - test-results.xml - .esd.log + - .generate_cache.log expire_in: 1 week diff --git a/esd/spack_manager/SpackManager.py b/esd/spack_manager/SpackManager.py index a7f46c27..d534b30a 100644 --- a/esd/spack_manager/SpackManager.py +++ b/esd/spack_manager/SpackManager.py @@ -2,12 +2,13 @@ import os import re from abc import ABC, abstractmethod from pathlib import Path +from tabnanny import check from esd.error_handling.exceptions import BashCommandException, NoSpackEnvironmentException, \ SpackInstallPackagesException from esd.logger.logger_builder import get_logger from esd.model.SpackModel import SpackModel -from esd.spack_manager.wrapper.spack_wrapper import no_spack_env +from esd.spack_manager.wrapper.spack_wrapper import check_spack_env from esd.utils.utils import run_command, git_clone_repo @@ -53,7 +54,6 @@ class SpackManager(ABC): pass def create_fetch_spack_environment(self): - if self.env.git_path: git_clone_repo(self.env.env_name, self.env.path / self.env.env_name, self.env.git_path, logger=self.logger) else: @@ -127,7 +127,7 @@ class SpackManager(ABC): return False return True - @no_spack_env + @check_spack_env def add_spack_repo(self, repo_path: Path, repo_name: str): """Add the Spack repository if it does not exist.""" run_command("bash", "-c", @@ -137,7 +137,7 @@ class SpackManager(ABC): exception_msg=f"Failed to add {repo_name} to spack environment {self.env.env_name}", exception=BashCommandException) - @no_spack_env + @check_spack_env def get_compiler_version(self): result = run_command("bash", "-c", f'source {self.spack_setup_script} && spack env activate -p {self.env_path} && spack compiler list', @@ -167,8 +167,21 @@ class SpackManager(ABC): return spack_version.stdout.strip().split()[0] return None - - def install_spack(self, spack_version="v0.21.1", spack_repo='https://github.com/spack/spack'): + @check_spack_env + def install_packages(self, jobs: int, signed=True, fresh=False): + signed = '' if signed else '--no-check-signature' + fresh = '--fresh' if fresh else '' + with open(str(Path(os.getcwd()).resolve() / ".generate_cache.log"), "w") as log_file: + run_command("bash", "-c", + f'source {self.spack_setup_script} && spack install --env {self.env.env_name} -v {signed} --j {jobs} {fresh}', + stdout=log_file, + capture_output=True, text=True, check=True, + logger=self.logger, + debug_msg=f"Installing spack packages for {self.env.env_name}", + exception_msg=f"Error installing spack packages for {self.env.env_name}", + exception=SpackInstallPackagesException) + + def install_spack(self, spack_version="v0.22.0", spack_repo='https://github.com/spack/spack'): try: user = os.getlogin() except OSError: diff --git a/esd/spack_manager/wrapper/spack_wrapper.py b/esd/spack_manager/wrapper/spack_wrapper.py index d075a317..c2f9c116 100644 --- a/esd/spack_manager/wrapper/spack_wrapper.py +++ b/esd/spack_manager/wrapper/spack_wrapper.py @@ -3,7 +3,7 @@ import functools from esd.error_handling.exceptions import NoSpackEnvironmentException -def no_spack_env(method): +def check_spack_env(method): @functools.wraps(method) def wrapper(self, *args, **kwargs): if self.spack_env_exists(): -- GitLab From a9253a23dc392c8786a5a23cb8e1eaa5b6bbf3de Mon Sep 17 00:00:00 2001 From: adrianciu <adrian.ciu@codemart.ro> Date: Fri, 7 Feb 2025 18:50:52 +0200 Subject: [PATCH 20/30] esd-spack-installation: spack install method and additional tests --- esd/spack_manager/SpackManager.py | 29 ++++++++++--------- .../factory/SpackManagerBuildCache.py | 3 -- .../factory/SpackManagerScratch.py | 3 -- 3 files changed, 16 insertions(+), 19 deletions(-) diff --git a/esd/spack_manager/SpackManager.py b/esd/spack_manager/SpackManager.py index d534b30a..65a21d6e 100644 --- a/esd/spack_manager/SpackManager.py +++ b/esd/spack_manager/SpackManager.py @@ -1,5 +1,6 @@ import os import re +import subprocess from abc import ABC, abstractmethod from pathlib import Path from tabnanny import check @@ -49,10 +50,6 @@ class SpackManager(ABC): def concretize_spack_env(self, force=True): pass - @abstractmethod - def install_spack_packages(self, jobs: 3, verbose=False, debug=False): - pass - def create_fetch_spack_environment(self): if self.env.git_path: git_clone_repo(self.env.env_name, self.env.path / self.env.env_name, self.env.git_path, logger=self.logger) @@ -168,18 +165,24 @@ class SpackManager(ABC): return None @check_spack_env - def install_packages(self, jobs: int, signed=True, fresh=False): + def install_packages(self, jobs: int, signed=True, fresh=False, debug=False): signed = '' if signed else '--no-check-signature' fresh = '--fresh' if fresh else '' + debug = '--debug' if debug else '' + install_result = run_command("bash", "-c", + f'source {self.spack_setup_script} && spack env activate -p {self.env_path} && spack {debug} install -v {signed} --j {jobs} {fresh}', + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + logger=self.logger, + debug_msg=f"Installing spack packages for {self.env.env_name}", + exception_msg=f"Error installing spack packages for {self.env.env_name}", + exception=SpackInstallPackagesException) with open(str(Path(os.getcwd()).resolve() / ".generate_cache.log"), "w") as log_file: - run_command("bash", "-c", - f'source {self.spack_setup_script} && spack install --env {self.env.env_name} -v {signed} --j {jobs} {fresh}', - stdout=log_file, - capture_output=True, text=True, check=True, - logger=self.logger, - debug_msg=f"Installing spack packages for {self.env.env_name}", - exception_msg=f"Error installing spack packages for {self.env.env_name}", - exception=SpackInstallPackagesException) + log_file.write(install_result.stdout) + log_file.write("\n--- STDERR ---\n") + log_file.write(install_result.stderr) + return install_result def install_spack(self, spack_version="v0.22.0", spack_repo='https://github.com/spack/spack'): try: diff --git a/esd/spack_manager/factory/SpackManagerBuildCache.py b/esd/spack_manager/factory/SpackManagerBuildCache.py index 38151c6d..5b66f5c5 100644 --- a/esd/spack_manager/factory/SpackManagerBuildCache.py +++ b/esd/spack_manager/factory/SpackManagerBuildCache.py @@ -14,6 +14,3 @@ class SpackManagerBuildCache(SpackManager): def concretize_spack_env(self, force=True): pass - - def install_spack_packages(self, jobs: 3, verbose=False, debug=False): - pass diff --git a/esd/spack_manager/factory/SpackManagerScratch.py b/esd/spack_manager/factory/SpackManagerScratch.py index 2ec22705..3dbc25f6 100644 --- a/esd/spack_manager/factory/SpackManagerScratch.py +++ b/esd/spack_manager/factory/SpackManagerScratch.py @@ -23,6 +23,3 @@ class SpackManagerScratch(SpackManager): else: self.logger.debug('No spack environment defined') raise NoSpackEnvironmentException('No spack environment defined') - - def install_spack_packages(self, jobs: 3, verbose=False, debug=False): - pass -- GitLab From a55bf1aecbe962d0ba6f65615f7eb690a9688c62 Mon Sep 17 00:00:00 2001 From: adrianciu <adrian.ciu@codemart.ro> Date: Mon, 10 Feb 2025 18:21:51 +0200 Subject: [PATCH 21/30] esd-spack-installation: refactoring --- dedal/build_cache/BuildCacheManager.py | 50 +++++++++++++------------- esd/spack_manager/SpackManager.py | 7 ++-- esd/utils/utils.py | 8 +++++ 3 files changed, 35 insertions(+), 30 deletions(-) diff --git a/dedal/build_cache/BuildCacheManager.py b/dedal/build_cache/BuildCacheManager.py index e1bd6824..839ac524 100644 --- a/dedal/build_cache/BuildCacheManager.py +++ b/dedal/build_cache/BuildCacheManager.py @@ -13,47 +13,47 @@ class BuildCacheManager(BuildCacheManagerInterface): """ def __init__(self, auth_backend='basic', insecure=False): - self.logger = get_logger(__name__, BuildCacheManager.__name__) - self.home_path = Path(os.environ.get("HOME_PATH", os.getcwd())) - self.registry_project = os.environ.get("REGISTRY_PROJECT") + self._logger = get_logger(__name__, BuildCacheManager.__name__) + self._home_path = Path(os.environ.get("HOME_PATH", os.getcwd())) + self._registry_project = os.environ.get("REGISTRY_PROJECT") self._registry_username = str(os.environ.get("REGISTRY_USERNAME")) self._registry_password = str(os.environ.get("REGISTRY_PASSWORD")) - self.registry_host = str(os.environ.get("REGISTRY_HOST")) + self._registry_host = str(os.environ.get("REGISTRY_HOST")) # Initialize an OrasClient instance. # This method utilizes the OCI Registry for container image and artifact management. # Refer to the official OCI Registry documentation for detailed information on the available authentication methods. # Supported authentication types may include basic authentication (username/password), token-based authentication, - self.client = oras.client.OrasClient(hostname=self.registry_host, auth_backend=auth_backend, insecure=insecure) - self.client.login(username=self._registry_username, password=self._registry_password) - self.oci_registry_path = f'{self.registry_host}/{self.registry_project}/cache' + self._client = oras.client.OrasClient(hostname=self._registry_host, auth_backend=auth_backend, insecure=insecure) + self._client.login(username=self._registry_username, password=self._registry_password) + self._oci_registry_path = f'{self._registry_host}/{self._registry_project}/cache' def upload(self, out_dir: Path): """ This method pushed all the files from the build cache folder into the OCI Registry """ - build_cache_path = self.home_path / out_dir + build_cache_path = self._home_path / out_dir # build cache folder must exist before pushing all the artifacts if not build_cache_path.exists(): - self.logger.error(f"Path {build_cache_path} not found.") + self._logger.error(f"Path {build_cache_path} not found.") for sub_path in build_cache_path.rglob("*"): if sub_path.is_file(): rel_path = str(sub_path.relative_to(build_cache_path)).replace(str(sub_path.env_name), "") - target = f"{self.registry_host}/{self.registry_project}/cache:{str(sub_path.env_name)}" + target = f"{self._registry_host}/{self._registry_project}/cache:{str(sub_path.env_name)}" try: - self.logger.info(f"Pushing folder '{sub_path}' to ORAS target '{target}' ...") - self.client.push( + self._logger.info(f"Pushing folder '{sub_path}' to ORAS target '{target}' ...") + self._client.push( files=[str(sub_path)], target=target, # save in manifest the relative path for reconstruction manifest_annotations={"path": rel_path}, disable_path_validation=True, ) - self.logger.info(f"Successfully pushed {sub_path.env_name}") + self._logger.info(f"Successfully pushed {sub_path.env_name}") except Exception as e: - self.logger.error( + self._logger.error( f"An error occurred while pushing: {e}") # todo to be discussed hot to delete the build cache after being pushed to the OCI Registry # clean_up([str(build_cache_path)], self.logger) @@ -63,37 +63,37 @@ class BuildCacheManager(BuildCacheManagerInterface): This method retrieves all tags from an OCI Registry """ try: - return self.client.get_tags(self.oci_registry_path) + return self._client.get_tags(self._oci_registry_path) except Exception as e: - self.logger.error(f"Failed to list tags: {e}") + self._logger.error(f"Failed to list tags: {e}") return None def download(self, in_dir: Path): """ This method pulls all the files from the OCI Registry into the build cache folder """ - build_cache_path = self.home_path / in_dir + build_cache_path = self._home_path / in_dir # create the buildcache dir if it does not exist os.makedirs(build_cache_path, exist_ok=True) tags = self.list_tags() if tags is not None: for tag in tags: - ref = f"{self.registry_host}/{self.registry_project}/cache:{tag}" + ref = f"{self._registry_host}/{self._registry_project}/cache:{tag}" # reconstruct the relative path of each artifact by getting it from the manifest cache_path = \ - self.client.get_manifest(f'{self.registry_host}/{self.registry_project}/cache:{tag}')[ + self._client.get_manifest(f'{self._registry_host}/{self._registry_project}/cache:{tag}')[ 'annotations'][ 'path'] try: - self.client.pull( + self._client.pull( ref, # missing dirs to output dir are created automatically by OrasClient pull method outdir=str(build_cache_path / cache_path), overwrite=True ) - self.logger.info(f"Successfully pulled artifact {tag}.") + self._logger.info(f"Successfully pulled artifact {tag}.") except Exception as e: - self.logger.error( + self._logger.error( f"Failed to pull artifact {tag} : {e}") def delete(self): @@ -106,8 +106,8 @@ class BuildCacheManager(BuildCacheManagerInterface): tags = self.list_tags() if tags is not None: try: - self.client.delete_tags(self.oci_registry_path, tags) - self.logger.info(f"Successfully deleted all artifacts form OCI registry.") + self._client.delete_tags(self._oci_registry_path, tags) + self._logger.info(f"Successfully deleted all artifacts form OCI registry.") except RuntimeError as e: - self.logger.error( + self._logger.error( f"Failed to delete artifacts: {e}") diff --git a/esd/spack_manager/SpackManager.py b/esd/spack_manager/SpackManager.py index 65a21d6e..bf381c39 100644 --- a/esd/spack_manager/SpackManager.py +++ b/esd/spack_manager/SpackManager.py @@ -10,7 +10,7 @@ from esd.error_handling.exceptions import BashCommandException, NoSpackEnvironme from esd.logger.logger_builder import get_logger from esd.model.SpackModel import SpackModel from esd.spack_manager.wrapper.spack_wrapper import check_spack_env -from esd.utils.utils import run_command, git_clone_repo +from esd.utils.utils import run_command, git_clone_repo, log_command class SpackManager(ABC): @@ -178,10 +178,7 @@ class SpackManager(ABC): debug_msg=f"Installing spack packages for {self.env.env_name}", exception_msg=f"Error installing spack packages for {self.env.env_name}", exception=SpackInstallPackagesException) - with open(str(Path(os.getcwd()).resolve() / ".generate_cache.log"), "w") as log_file: - log_file.write(install_result.stdout) - log_file.write("\n--- STDERR ---\n") - log_file.write(install_result.stderr) + log_command(install_result, str(Path(os.getcwd()).resolve() / ".generate_cache.log")) return install_result def install_spack(self, spack_version="v0.22.0", spack_repo='https://github.com/spack/spack'): diff --git a/esd/utils/utils.py b/esd/utils/utils.py index 173347d0..3ce70f25 100644 --- a/esd/utils/utils.py +++ b/esd/utils/utils.py @@ -1,4 +1,5 @@ import logging +import os import shutil import subprocess from pathlib import Path @@ -55,3 +56,10 @@ def git_clone_repo(repo_name: str, dir: Path, git_path: str, logger: logging = l def file_exists_and_not_empty(file: Path) -> bool: return file.is_file() and file.stat().st_size > 0 + + +def log_command(results, log_file: str): + with open(log_file, "w") as log_file: + log_file.write(results.stdout) + log_file.write("\n--- STDERR ---\n") + log_file.write(results.stderr) -- GitLab From 4374fe5c81e16712da831b1a5ede941120ce245e Mon Sep 17 00:00:00 2001 From: adrianciu <adrian.ciu@codemart.ro> Date: Tue, 11 Feb 2025 19:23:33 +0200 Subject: [PATCH 22/30] esd-spack-installation: major refactoring; fixed bug on updating env vars in .bashrc --- dedal/build_cache/BuildCacheManager.py | 34 +++-- .../integration_tests/spack_install_test.py | 21 +-- esd/configuration/SpackConfig.py | 25 ++++ .../__init__.py | 0 .../enums => error_handling}/__init__.py | 0 .../{SpackModel.py => SpackDescriptor.py} | 5 +- .../SpackOperation.py} | 129 +++++++++--------- esd/spack_factory/SpackOperationCreator.py | 14 ++ esd/spack_factory/SpackOperationUseCache.py | 19 +++ .../factory => spack_factory}/__init__.py | 0 esd/spack_manager/enums/SpackManagerEnum.py | 6 - .../factory/SpackManagerBuildCache.py | 16 --- .../factory/SpackManagerCreator.py | 14 -- .../factory/SpackManagerScratch.py | 25 ---- esd/utils/utils.py | 39 ++++-- esd/{spack_manager => }/wrapper/__init__.py | 0 .../wrapper/spack_wrapper.py | 0 17 files changed, 183 insertions(+), 164 deletions(-) create mode 100644 esd/configuration/SpackConfig.py rename esd/{spack_manager => configuration}/__init__.py (100%) rename esd/{spack_manager/enums => error_handling}/__init__.py (100%) rename esd/model/{SpackModel.py => SpackDescriptor.py} (57%) rename esd/{spack_manager/SpackManager.py => spack_factory/SpackOperation.py} (62%) create mode 100644 esd/spack_factory/SpackOperationCreator.py create mode 100644 esd/spack_factory/SpackOperationUseCache.py rename esd/{spack_manager/factory => spack_factory}/__init__.py (100%) delete mode 100644 esd/spack_manager/enums/SpackManagerEnum.py delete mode 100644 esd/spack_manager/factory/SpackManagerBuildCache.py delete mode 100644 esd/spack_manager/factory/SpackManagerCreator.py delete mode 100644 esd/spack_manager/factory/SpackManagerScratch.py rename esd/{spack_manager => }/wrapper/__init__.py (100%) rename esd/{spack_manager => }/wrapper/spack_wrapper.py (100%) diff --git a/dedal/build_cache/BuildCacheManager.py b/dedal/build_cache/BuildCacheManager.py index 839ac524..cccb5846 100644 --- a/dedal/build_cache/BuildCacheManager.py +++ b/dedal/build_cache/BuildCacheManager.py @@ -12,36 +12,39 @@ class BuildCacheManager(BuildCacheManagerInterface): This class aims to manage the push/pull/delete of build cache files """ - def __init__(self, auth_backend='basic', insecure=False): + def __init__(self, registry_host, registry_project, registry_username, registry_password, cache_version='cache', + auth_backend='basic', + insecure=False): self._logger = get_logger(__name__, BuildCacheManager.__name__) - self._home_path = Path(os.environ.get("HOME_PATH", os.getcwd())) - self._registry_project = os.environ.get("REGISTRY_PROJECT") + self._registry_project = registry_project - self._registry_username = str(os.environ.get("REGISTRY_USERNAME")) - self._registry_password = str(os.environ.get("REGISTRY_PASSWORD")) + self._registry_username = registry_username + self._registry_password = registry_password - self._registry_host = str(os.environ.get("REGISTRY_HOST")) + self._registry_host = registry_host # Initialize an OrasClient instance. # This method utilizes the OCI Registry for container image and artifact management. # Refer to the official OCI Registry documentation for detailed information on the available authentication methods. # Supported authentication types may include basic authentication (username/password), token-based authentication, - self._client = oras.client.OrasClient(hostname=self._registry_host, auth_backend=auth_backend, insecure=insecure) + self._client = oras.client.OrasClient(hostname=self._registry_host, auth_backend=auth_backend, + insecure=insecure) self._client.login(username=self._registry_username, password=self._registry_password) - self._oci_registry_path = f'{self._registry_host}/{self._registry_project}/cache' + self.cache_version = cache_version + self._oci_registry_path = f'{self._registry_host}/{self._registry_project}/{self.cache_version}' def upload(self, out_dir: Path): """ This method pushed all the files from the build cache folder into the OCI Registry """ - build_cache_path = self._home_path / out_dir + build_cache_path = out_dir.resolve() # build cache folder must exist before pushing all the artifacts if not build_cache_path.exists(): self._logger.error(f"Path {build_cache_path} not found.") for sub_path in build_cache_path.rglob("*"): if sub_path.is_file(): - rel_path = str(sub_path.relative_to(build_cache_path)).replace(str(sub_path.env_name), "") - target = f"{self._registry_host}/{self._registry_project}/cache:{str(sub_path.env_name)}" + rel_path = str(sub_path.relative_to(build_cache_path)).replace(str(sub_path.name), "") + target = f"{self._registry_host}/{self._registry_project}/{self.cache_version}:{str(sub_path.name)}" try: self._logger.info(f"Pushing folder '{sub_path}' to ORAS target '{target}' ...") self._client.push( @@ -51,7 +54,7 @@ class BuildCacheManager(BuildCacheManagerInterface): manifest_annotations={"path": rel_path}, disable_path_validation=True, ) - self._logger.info(f"Successfully pushed {sub_path.env_name}") + self._logger.info(f"Successfully pushed {sub_path.name}") except Exception as e: self._logger.error( f"An error occurred while pushing: {e}") @@ -72,16 +75,17 @@ class BuildCacheManager(BuildCacheManagerInterface): """ This method pulls all the files from the OCI Registry into the build cache folder """ - build_cache_path = self._home_path / in_dir + build_cache_path = in_dir.resolve() # create the buildcache dir if it does not exist os.makedirs(build_cache_path, exist_ok=True) tags = self.list_tags() if tags is not None: for tag in tags: - ref = f"{self._registry_host}/{self._registry_project}/cache:{tag}" + ref = f"{self._registry_host}/{self._registry_project}/{self.cache_version}:{tag}" # reconstruct the relative path of each artifact by getting it from the manifest cache_path = \ - self._client.get_manifest(f'{self._registry_host}/{self._registry_project}/cache:{tag}')[ + self._client.get_manifest( + f'{self._registry_host}/{self._registry_project}/{self.cache_version}:{tag}')[ 'annotations'][ 'path'] try: diff --git a/dedal/tests/integration_tests/spack_install_test.py b/dedal/tests/integration_tests/spack_install_test.py index 9a32d5c7..28f8268e 100644 --- a/dedal/tests/integration_tests/spack_install_test.py +++ b/dedal/tests/integration_tests/spack_install_test.py @@ -1,21 +1,12 @@ import pytest +from esd.spack_factory.SpackOperation import SpackOperation +from esd.tests.testing_variables import SPACK_VERSION -from esd.spack_manager.factory.SpackManagerBuildCache import SpackManagerBuildCache -from esd.spack_manager.factory.SpackManagerScratch import SpackManagerScratch - -SPACK_VERSION = "0.22.0" - -# we need this test to run first so that spack is installed only once for all the tests +# run this test first so that spack is installed only once for all the tests @pytest.mark.run(order=1) def test_spack_install_scratch(): - spack_manager = SpackManagerScratch() - spack_manager.install_spack(spack_version=f'v{SPACK_VERSION}') - installed_spack_version = spack_manager.get_spack_installed_version() - assert SPACK_VERSION == installed_spack_version - - -def test_spack_install_buildcache(): - spack_manager = SpackManagerBuildCache() - installed_spack_version = spack_manager.get_spack_installed_version() + spack_operation = SpackOperation() + spack_operation.install_spack(spack_version=f'v{SPACK_VERSION}') + installed_spack_version = spack_operation.get_spack_installed_version() assert SPACK_VERSION == installed_spack_version diff --git a/esd/configuration/SpackConfig.py b/esd/configuration/SpackConfig.py new file mode 100644 index 00000000..93a2e874 --- /dev/null +++ b/esd/configuration/SpackConfig.py @@ -0,0 +1,25 @@ +import os +from pathlib import Path +from esd.model import SpackDescriptor + + +class SpackConfig: + def __init__(self, env: SpackDescriptor = None, repos: list[SpackDescriptor] = None, + install_dir=Path(os.getcwd()).resolve(), upstream_instance=None, system_name=None, + concretization_dir: Path = None, buildcache_dir: Path = None): + self.env = env + if repos is None: + self.repos = [] + else: + self.repos = repos + self.install_dir = install_dir + self.upstream_instance = upstream_instance + self.system_name = system_name + self.concretization_dir = concretization_dir + self.buildcache_dir = buildcache_dir + + def add_repo(self, repo: SpackDescriptor): + if self.repos is None: + self.repos = [] + else: + self.repos.append(repo) diff --git a/esd/spack_manager/__init__.py b/esd/configuration/__init__.py similarity index 100% rename from esd/spack_manager/__init__.py rename to esd/configuration/__init__.py diff --git a/esd/spack_manager/enums/__init__.py b/esd/error_handling/__init__.py similarity index 100% rename from esd/spack_manager/enums/__init__.py rename to esd/error_handling/__init__.py diff --git a/esd/model/SpackModel.py b/esd/model/SpackDescriptor.py similarity index 57% rename from esd/model/SpackModel.py rename to esd/model/SpackDescriptor.py index 4b065dba..70e484fb 100644 --- a/esd/model/SpackModel.py +++ b/esd/model/SpackDescriptor.py @@ -1,12 +1,13 @@ +import os from pathlib import Path -class SpackModel: +class SpackDescriptor: """" Provides details about the spack environment """ - def __init__(self, env_name: str, path: Path, git_path: str = None): + def __init__(self, env_name: str, path: Path = Path(os.getcwd()).resolve(), git_path: str = None): self.env_name = env_name self.path = path self.git_path = git_path diff --git a/esd/spack_manager/SpackManager.py b/esd/spack_factory/SpackOperation.py similarity index 62% rename from esd/spack_manager/SpackManager.py rename to esd/spack_factory/SpackOperation.py index bf381c39..29f44f49 100644 --- a/esd/spack_manager/SpackManager.py +++ b/esd/spack_factory/SpackOperation.py @@ -3,63 +3,57 @@ import re import subprocess from abc import ABC, abstractmethod from pathlib import Path -from tabnanny import check - from esd.error_handling.exceptions import BashCommandException, NoSpackEnvironmentException, \ - SpackInstallPackagesException + SpackInstallPackagesException, SpackConcertizeException from esd.logger.logger_builder import get_logger -from esd.model.SpackModel import SpackModel -from esd.spack_manager.wrapper.spack_wrapper import check_spack_env -from esd.utils.utils import run_command, git_clone_repo, log_command +from esd.configuration.SpackConfig import SpackConfig +from esd.tests.testing_variables import SPACK_VERSION +from esd.wrapper.spack_wrapper import check_spack_env +from esd.utils.utils import run_command, git_clone_repo, log_command, set_bashrc_variable -class SpackManager(ABC): +class SpackOperation(ABC): """ This class should implement the methods necessary for installing spack, set up an environment, concretize and install packages. Factory design pattern is used because there are 2 cases: creating an environment from scratch or creating an environment from the buildcache. Attributes: ----------- - env : SpackModel + env : SpackDescriptor spack environment details - repos : list[SpackModel] + repos : list[SpackDescriptor] upstream_instance : str path to Spack instance to use as upstream (optional) """ - def __init__(self, env: SpackModel = None, repos=None, - upstream_instance=None, system_name: str = None, logger=get_logger(__name__)): - if repos is None: - repos = [] - self.repos = repos - self.env = env - self.install_dir = Path(os.environ.get("INSTALLATION_ROOT") or os.getcwd()).resolve() - self.install_dir.mkdir(parents=True, exist_ok=True) - self.env_path = None - if self.env and self.env.path: - self.env.path = self.env.path.resolve() - self.env.path.mkdir(parents=True, exist_ok=True) - self.env_path = self.env.path / self.env.env_name - self.upstream_instance = upstream_instance - self.spack_dir = self.install_dir / "spack" - self.spack_setup_script = self.spack_dir / "share" / "spack" / "setup-env.sh" + def __init__(self, spack_config: SpackConfig = SpackConfig(), logger=get_logger(__name__)): + self.spack_config = spack_config + self.spack_config.install_dir.mkdir(parents=True, exist_ok=True) + self.spack_dir = self.spack_config.install_dir / 'spack' + self.spack_setup_script = self.spack_dir / 'share' / 'spack' / 'setup-env.sh' self.logger = logger - self.system_name = system_name + if self.spack_config.env and spack_config.env.path: + self.spack_config.env.path = spack_config.env.path.resolve() + self.spack_config.env.path.mkdir(parents=True, exist_ok=True) + self.env_path = spack_config.env.path / spack_config.env.env_name + self.spack_command_on_env = f'source {self.spack_setup_script} && spack env activate -p {self.env_path}' @abstractmethod def concretize_spack_env(self, force=True): pass def create_fetch_spack_environment(self): - if self.env.git_path: - git_clone_repo(self.env.env_name, self.env.path / self.env.env_name, self.env.git_path, logger=self.logger) + if self.spack_config.env.git_path: + git_clone_repo(self.spack_config.env.env_name, self.spack_config.env.path / self.spack_config.env.env_name, + self.spack_config.env.git_path, + logger=self.logger) else: - os.makedirs(self.env.path / self.env.env_name, exist_ok=True) + os.makedirs(self.spack_config.env.path / self.spack_config.env.env_name, exist_ok=True) run_command("bash", "-c", f'source {self.spack_setup_script} && spack env create -d {self.env_path}', check=True, logger=self.logger, - debug_msg=f"Created {self.env.env_name} spack environment", - exception_msg=f"Failed to create {self.env.env_name} spack environment", + info_msg=f"Created {self.spack_config.env.env_name} spack environment", + exception_msg=f"Failed to create {self.spack_config.env.env_name} spack environment", exception=BashCommandException) def setup_spack_env(self): @@ -67,22 +61,20 @@ class SpackManager(ABC): This method prepares a spack environment by fetching/creating the spack environment and adding the necessary repos """ bashrc_path = os.path.expanduser("~/.bashrc") - if self.system_name: - with open(bashrc_path, "a") as bashrc: - bashrc.write(f'export SYSTEMNAME="{self.system_name}"\n') - os.environ['SYSTEMNAME'] = self.system_name + if self.spack_config.system_name: + set_bashrc_variable('SYSTEMNAME', self.spack_config.system_name, bashrc_path, logger=self.logger) + os.environ['SYSTEMNAME'] = self.spack_config.system_name if self.spack_dir.exists() and self.spack_dir.is_dir(): - with open(bashrc_path, "a") as bashrc: - bashrc.write(f'export SPACK_USER_CACHE_PATH="{str(self.spack_dir / ".spack")}"\n') - bashrc.write(f'export SPACK_USER_CONFIG_PATH="{str(self.spack_dir / ".spack")}"\n') + set_bashrc_variable('SPACK_USER_CACHE_PATH', str(self.spack_dir / ".spack"), bashrc_path, logger=self.logger) + set_bashrc_variable('SPACK_USER_CONFIG_PATH', str(self.spack_dir / ".spack"), bashrc_path, logger=self.logger) self.logger.debug('Added env variables SPACK_USER_CACHE_PATH and SPACK_USER_CONFIG_PATH') else: self.logger.error(f'Invalid installation path: {self.spack_dir}') # Restart the bash after adding environment variables self.create_fetch_spack_environment() - if self.install_dir.exists(): - for repo in self.repos: - repo_dir = self.install_dir / repo.path / repo.env_name + if self.spack_config.install_dir.exists(): + for repo in self.spack_config.repos: + repo_dir = self.spack_config.install_dir / repo.path / repo.env_name git_clone_repo(repo.env_name, repo_dir, repo.git_path, logger=self.logger) if not self.spack_repo_exists(repo.env_name): self.add_spack_repo(repo.path, repo.env_name) @@ -92,21 +84,21 @@ class SpackManager(ABC): def spack_repo_exists(self, repo_name: str) -> bool | None: """Check if the given Spack repository exists.""" - if self.env is None: + if self.spack_config.env is None: result = run_command("bash", "-c", f'source {self.spack_setup_script} && spack repo list', check=True, capture_output=True, text=True, logger=self.logger, - debug_msg=f'Checking if {repo_name} exists') + info_msg=f'Checking if {repo_name} exists') if result is None: return False else: if self.spack_env_exists(): result = run_command("bash", "-c", - f'source {self.spack_setup_script} && spack env activate -p {self.env_path} && spack repo list', + f'{self.spack_command_on_env} && spack repo list', check=True, capture_output=True, text=True, logger=self.logger, - debug_msg=f'Checking if repository {repo_name} was added') + info_msg=f'Checking if repository {repo_name} was added') else: self.logger.debug('No spack environment defined') raise NoSpackEnvironmentException('No spack environment defined') @@ -116,10 +108,10 @@ class SpackManager(ABC): def spack_env_exists(self): result = run_command("bash", "-c", - f'source {self.spack_setup_script} && spack env activate -p {self.env_path}', + self.spack_command_on_env, check=True, capture_output=True, text=True, logger=self.logger, - debug_msg=f'Checking if environment {self.env.env_name} exists') + info_msg=f'Checking if environment {self.spack_config.env.env_name} exists') if result is None: return False return True @@ -128,20 +120,20 @@ class SpackManager(ABC): def add_spack_repo(self, repo_path: Path, repo_name: str): """Add the Spack repository if it does not exist.""" run_command("bash", "-c", - f'source {self.spack_setup_script} && spack env activate -p {self.env_path} && spack repo add {repo_path}/{repo_name}', + f'{self.spack_command_on_env} && spack repo add {repo_path}/{repo_name}', check=True, logger=self.logger, - debug_msg=f"Added {repo_name} to spack environment {self.env.env_name}", - exception_msg=f"Failed to add {repo_name} to spack environment {self.env.env_name}", + info_msg=f"Added {repo_name} to spack environment {self.spack_config.env.env_name}", + exception_msg=f"Failed to add {repo_name} to spack environment {self.spack_config.env.env_name}", exception=BashCommandException) @check_spack_env def get_compiler_version(self): result = run_command("bash", "-c", - f'source {self.spack_setup_script} && spack env activate -p {self.env_path} && spack compiler list', + f'{self.spack_command_on_env} && spack compiler list', check=True, logger=self.logger, capture_output=True, text=True, - debug_msg=f"Checking spack environment compiler version for {self.env.env_name}", - exception_msg=f"Failed to checking spack environment compiler version for {self.env.env_name}", + info_msg=f"Checking spack environment compiler version for {self.spack_config.env.env_name}", + exception_msg=f"Failed to checking spack environment compiler version for {self.spack_config.env.env_name}", exception=BashCommandException) # todo add error handling and tests if result.stdout is None: @@ -151,37 +143,48 @@ class SpackManager(ABC): # Find the first occurrence of a GCC compiler using regex match = re.search(r"gcc@([\d\.]+)", result.stdout) gcc_version = match.group(1) - self.logger.debug(f'Found gcc for {self.env.env_name}: {gcc_version}') + self.logger.debug(f'Found gcc for {self.spack_config.env.env_name}: {gcc_version}') return gcc_version def get_spack_installed_version(self): spack_version = run_command("bash", "-c", f'source {self.spack_setup_script} && spack --version', capture_output=True, text=True, check=True, logger=self.logger, - debug_msg=f"Getting spack version", + info_msg=f"Getting spack version", exception_msg=f"Error retrieving Spack version") if spack_version: return spack_version.stdout.strip().split()[0] return None + @check_spack_env + def concretize_spack_env(self, force=True): + force = '--force' if force else '' + run_command("bash", "-c", + f'source {self.spack_setup_script} && spack env activate -p {self.env_path} && spack concretize {force}', + check=True, + capture_output=True, text=True, logger=self.logger, + info_msg=f'Concertization step for {self.spack_config.env.env_name}', + exception_msg=f'Failed the concertization step for {self.spack_config.env.env_name}', + exception=SpackConcertizeException) + @check_spack_env def install_packages(self, jobs: int, signed=True, fresh=False, debug=False): signed = '' if signed else '--no-check-signature' fresh = '--fresh' if fresh else '' debug = '--debug' if debug else '' install_result = run_command("bash", "-c", - f'source {self.spack_setup_script} && spack env activate -p {self.env_path} && spack {debug} install -v {signed} --j {jobs} {fresh}', + f'{self.spack_command_on_env} && spack {debug} install -v {signed} --j {jobs} {fresh}', stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, logger=self.logger, - debug_msg=f"Installing spack packages for {self.env.env_name}", - exception_msg=f"Error installing spack packages for {self.env.env_name}", + info_msg=f"Installing spack packages for {self.spack_config.env.env_name}", + exception_msg=f"Error installing spack packages for {self.spack_config.env.env_name}", exception=SpackInstallPackagesException) log_command(install_result, str(Path(os.getcwd()).resolve() / ".generate_cache.log")) return install_result - def install_spack(self, spack_version="v0.22.0", spack_repo='https://github.com/spack/spack'): + def install_spack(self, spack_version=f'v{SPACK_VERSION}', spack_repo='https://github.com/spack/spack'): try: user = os.getlogin() except OSError: @@ -210,18 +213,18 @@ class SpackManager(ABC): self.logger.info("Added Spack PATH to .bashrc") if user: run_command("chown", "-R", f"{user}:{user}", self.spack_dir, check=True, logger=self.logger, - debug_msg='Adding permissions to the logged in user') - run_command("bash", "-c", f"source {bashrc_path}", check=True, logger=self.logger, debug_msg='Restart bash') + info_msg='Adding permissions to the logged in user') + run_command("bash", "-c", f"source {bashrc_path}", check=True, logger=self.logger, info_msg='Restart bash') self.logger.info("Spack install completed") # Restart Bash after the installation ends os.system("exec bash") # Configure upstream Spack instance if specified - if self.upstream_instance: + if self.spack_config.upstream_instance: upstreams_yaml_path = os.path.join(self.spack_dir, "etc/spack/defaults/upstreams.yaml") with open(upstreams_yaml_path, "w") as file: file.write(f"""upstreams: upstream-spack-instance: - install_tree: {self.upstream_instance}/spack/opt/spack + install_tree: {self.spack_config.upstream_instance}/spack/opt/spack """) self.logger.info("Added upstream spack instance") diff --git a/esd/spack_factory/SpackOperationCreator.py b/esd/spack_factory/SpackOperationCreator.py new file mode 100644 index 00000000..8369c5ca --- /dev/null +++ b/esd/spack_factory/SpackOperationCreator.py @@ -0,0 +1,14 @@ +from esd.configuration.SpackConfig import SpackConfig +from esd.spack_factory.SpackOperation import SpackOperation +from esd.spack_factory.SpackOperationUseCache import SpackOperationUseCache + + +class SpackOperationCreator: + @staticmethod + def get_spack_operator(spack_config: SpackConfig = None): + if spack_config is None: + return SpackOperation(SpackConfig()) + elif spack_config.concretization_dir is None and spack_config.buildcache_dir is None: + return SpackOperation(spack_config) + else: + return SpackOperationUseCache(spack_config) diff --git a/esd/spack_factory/SpackOperationUseCache.py b/esd/spack_factory/SpackOperationUseCache.py new file mode 100644 index 00000000..15a3822f --- /dev/null +++ b/esd/spack_factory/SpackOperationUseCache.py @@ -0,0 +1,19 @@ +from esd.logger.logger_builder import get_logger +from esd.spack_factory.SpackOperation import SpackOperation +from esd.configuration.SpackConfig import SpackConfig + + +class SpackOperationUseCache(SpackOperation): + """ + This class uses caching for the concretization step and for the installation step. + """ + + def __init__(self, spack_config: SpackConfig = SpackConfig()): + super().__init__(spack_config, logger=get_logger(__name__)) + + def setup_spack_env(self): + super().setup_spack_env() + # todo add buildcache to the spack environment + + def concretize_spack_env(self, force=True): + pass diff --git a/esd/spack_manager/factory/__init__.py b/esd/spack_factory/__init__.py similarity index 100% rename from esd/spack_manager/factory/__init__.py rename to esd/spack_factory/__init__.py diff --git a/esd/spack_manager/enums/SpackManagerEnum.py b/esd/spack_manager/enums/SpackManagerEnum.py deleted file mode 100644 index a2435839..00000000 --- a/esd/spack_manager/enums/SpackManagerEnum.py +++ /dev/null @@ -1,6 +0,0 @@ -from enum import Enum - - -class SpackManagerEnum(Enum): - FROM_SCRATCH = "from_scratch", - FROM_BUILDCACHE = "from_buildcache", diff --git a/esd/spack_manager/factory/SpackManagerBuildCache.py b/esd/spack_manager/factory/SpackManagerBuildCache.py deleted file mode 100644 index 5b66f5c5..00000000 --- a/esd/spack_manager/factory/SpackManagerBuildCache.py +++ /dev/null @@ -1,16 +0,0 @@ -from esd.model.SpackModel import SpackModel -from esd.spack_manager.SpackManager import SpackManager -from esd.logger.logger_builder import get_logger - - -class SpackManagerBuildCache(SpackManager): - def __init__(self, env: SpackModel = None, repos=None, - upstream_instance=None, system_name: str = None): - super().__init__(env, repos, upstream_instance, system_name, logger=get_logger(__name__)) - - def setup_spack_env(self): - super().setup_spack_env() - # todo add buildcache to the spack environment - - def concretize_spack_env(self, force=True): - pass diff --git a/esd/spack_manager/factory/SpackManagerCreator.py b/esd/spack_manager/factory/SpackManagerCreator.py deleted file mode 100644 index 6eb26a04..00000000 --- a/esd/spack_manager/factory/SpackManagerCreator.py +++ /dev/null @@ -1,14 +0,0 @@ -from esd.model.SpackModel import SpackModel -from esd.spack_manager.enums.SpackManagerEnum import SpackManagerEnum -from esd.spack_manager.factory.SpackManagerBuildCache import SpackManagerBuildCache -from esd.spack_manager.factory.SpackManagerScratch import SpackManagerScratch - - -class SpackManagerCreator: - @staticmethod - def get_spack_manger(spack_manager_type: SpackManagerEnum, env: SpackModel = None, repos=None, - upstream_instance=None, system_name: str = None): - if spack_manager_type == SpackManagerEnum.FROM_SCRATCH: - return SpackManagerScratch(env, repos, upstream_instance, system_name) - elif spack_manager_type == SpackManagerEnum.FROM_BUILDCACHE: - return SpackManagerBuildCache(env, repos, upstream_instance, system_name) diff --git a/esd/spack_manager/factory/SpackManagerScratch.py b/esd/spack_manager/factory/SpackManagerScratch.py deleted file mode 100644 index 3dbc25f6..00000000 --- a/esd/spack_manager/factory/SpackManagerScratch.py +++ /dev/null @@ -1,25 +0,0 @@ -from esd.error_handling.exceptions import SpackConcertizeException, NoSpackEnvironmentException -from esd.model.SpackModel import SpackModel -from esd.spack_manager.SpackManager import SpackManager -from esd.logger.logger_builder import get_logger -from esd.utils.utils import run_command - - -class SpackManagerScratch(SpackManager): - def __init__(self, env: SpackModel = None, repos=None, - upstream_instance=None, system_name: str = None): - super().__init__(env, repos, upstream_instance, system_name, logger=get_logger(__name__)) - - def concretize_spack_env(self, force=True): - force = '--force' if force else '' - if self.spack_env_exists(): - run_command("bash", "-c", - f'source {self.spack_setup_script} && spack env activate -p {self.env_path} && spack concretize {force}', - check=True, - capture_output=True, text=True, logger=self.logger, - debug_msg=f'Concertization step for {self.env.env_name}', - exception_msg=f'Failed the concertization step for {self.env.env_name}', - exception=SpackConcertizeException) - else: - self.logger.debug('No spack environment defined') - raise NoSpackEnvironmentException('No spack environment defined') diff --git a/esd/utils/utils.py b/esd/utils/utils.py index 3ce70f25..033cbc54 100644 --- a/esd/utils/utils.py +++ b/esd/utils/utils.py @@ -5,30 +5,31 @@ import subprocess from pathlib import Path from esd.error_handling.exceptions import BashCommandException +import re -def clean_up(dirs: list[str], logging, ignore_errors=True): +def clean_up(dirs: list[str], logger: logging = logging.getLogger(__name__), ignore_errors=True): """ All the folders from the list dirs are removed with all the content in them """ for cleanup_dir in dirs: cleanup_dir = Path(cleanup_dir).resolve() if cleanup_dir.exists(): - logging.info(f"Removing {cleanup_dir}") + logger.info(f"Removing {cleanup_dir}") try: shutil.rmtree(Path(cleanup_dir)) except OSError as e: - logging.error(f"Failed to remove {cleanup_dir}: {e}") + logger.error(f"Failed to remove {cleanup_dir}: {e}") if not ignore_errors: raise e else: - logging.info(f"{cleanup_dir} does not exist") + logger.info(f"{cleanup_dir} does not exist") -def run_command(*args, logger=logging.getLogger(__name__), debug_msg: str = '', exception_msg: str = None, +def run_command(*args, logger=logging.getLogger(__name__), info_msg: str = '', exception_msg: str = None, exception=None, **kwargs): try: - logger.debug(f'{debug_msg}: args: {args}') + logger.info(f'{info_msg}: args: {args}') return subprocess.run(args, **kwargs) except subprocess.CalledProcessError as e: if exception_msg is not None: @@ -47,11 +48,11 @@ def git_clone_repo(repo_name: str, dir: Path, git_path: str, logger: logging = l "-c", "feature.manyFiles=true", git_path, dir , check=True, logger=logger, - debug_msg=f'Cloned repository {repo_name}', + info_msg=f'Cloned repository {repo_name}', exception_msg=f'Failed to clone repository: {repo_name}', exception=BashCommandException) else: - logger.debug(f'Repository {repo_name} already cloned.') + logger.info(f'Repository {repo_name} already cloned.') def file_exists_and_not_empty(file: Path) -> bool: @@ -63,3 +64,25 @@ def log_command(results, log_file: str): log_file.write(results.stdout) log_file.write("\n--- STDERR ---\n") log_file.write(results.stderr) + + +def set_bashrc_variable(var_name: str, value: str, bashrc_path: str = os.path.expanduser("~/.bashrc"), + logger: logging = logging.getLogger(__name__)): + """Update or add an environment variable in ~/.bashrc.""" + with open(bashrc_path, "r") as file: + lines = file.readlines() + pattern = re.compile(rf'^\s*export\s+{var_name}=.*$') + updated = False + # Modify the existing variable if found + for i, line in enumerate(lines): + if pattern.match(line): + lines[i] = f'export {var_name}={value}\n' + updated = True + break + if not updated: + lines.append(f'\nexport {var_name}={value}\n') + logger.info(f"Added in {bashrc_path} with: export {var_name}={value}") + else: + logger.info(f"Updated {bashrc_path} with: export {var_name}={value}") + with open(bashrc_path, "w") as file: + file.writelines(lines) diff --git a/esd/spack_manager/wrapper/__init__.py b/esd/wrapper/__init__.py similarity index 100% rename from esd/spack_manager/wrapper/__init__.py rename to esd/wrapper/__init__.py diff --git a/esd/spack_manager/wrapper/spack_wrapper.py b/esd/wrapper/spack_wrapper.py similarity index 100% rename from esd/spack_manager/wrapper/spack_wrapper.py rename to esd/wrapper/spack_wrapper.py -- GitLab From fdadeab5abeefd42b028cfd780d46961c4255ee0 Mon Sep 17 00:00:00 2001 From: adrianciu <adrian.ciu@codemart.ro> Date: Fri, 14 Feb 2025 11:28:46 +0200 Subject: [PATCH 23/30] esd-spack-installation: added additional spack commands --- esd/spack_factory/SpackOperation.py | 1 + 1 file changed, 1 insertion(+) diff --git a/esd/spack_factory/SpackOperation.py b/esd/spack_factory/SpackOperation.py index 29f44f49..cbf7b5be 100644 --- a/esd/spack_factory/SpackOperation.py +++ b/esd/spack_factory/SpackOperation.py @@ -112,6 +112,7 @@ class SpackOperation(ABC): check=True, capture_output=True, text=True, logger=self.logger, info_msg=f'Checking if environment {self.spack_config.env.env_name} exists') + print(result) if result is None: return False return True -- GitLab From 1d1d633b63905af34bd0d85d314c927af6a1ae95 Mon Sep 17 00:00:00 2001 From: adrianciu <adrian.ciu@codemart.ro> Date: Thu, 27 Feb 2025 13:26:33 +0200 Subject: [PATCH 24/30] dedal: spack operations for creating the caches and tests --- .env | 3 +- .gitlab-ci.yml | 8 +- README.md | 16 +- {esd/configuration => dedal/bll}/__init__.py | 0 dedal/build_cache/BuildCacheManager.py | 6 +- dedal/configuration/SpackConfig.py | 21 +- dedal/error_handling/exceptions.py | 5 + dedal/model/SpackDescriptor.py | 2 +- dedal/spack_factory/SpackOperation.py | 54 ++-- .../SpackOperationCreateCache.py | 51 ++++ dedal/spack_factory/SpackOperationCreator.py | 11 +- dedal/spack_factory/SpackOperationUseCache.py | 47 +++- .../spack_create_cache_test.py | 58 +++++ .../spack_from_cache_test.py | 42 ++++ .../spack_from_scratch_test.py | 30 ++- .../integration_tests/spack_install_test.py | 14 +- dedal/utils/utils.py | 56 +++++ dedal/utils/variables.py | 5 + esd/configuration/SpackConfig.py | 25 -- esd/error_handling/__init__.py | 0 esd/error_handling/exceptions.py | 31 --- esd/model/SpackDescriptor.py | 13 - esd/model/__init__.py | 0 esd/spack_factory/SpackOperation.py | 231 ------------------ esd/spack_factory/SpackOperationCreator.py | 14 -- esd/spack_factory/SpackOperationUseCache.py | 19 -- esd/spack_factory/__init__.py | 0 esd/utils/bootstrap.sh | 6 - esd/utils/utils.py | 88 ------- esd/wrapper/__init__.py | 0 esd/wrapper/spack_wrapper.py | 15 -- pyproject.toml | 9 +- 32 files changed, 362 insertions(+), 518 deletions(-) rename {esd/configuration => dedal/bll}/__init__.py (100%) create mode 100644 dedal/spack_factory/SpackOperationCreateCache.py create mode 100644 dedal/tests/integration_tests/spack_create_cache_test.py create mode 100644 dedal/tests/integration_tests/spack_from_cache_test.py create mode 100644 dedal/utils/variables.py delete mode 100644 esd/configuration/SpackConfig.py delete mode 100644 esd/error_handling/__init__.py delete mode 100644 esd/error_handling/exceptions.py delete mode 100644 esd/model/SpackDescriptor.py delete mode 100644 esd/model/__init__.py delete mode 100644 esd/spack_factory/SpackOperation.py delete mode 100644 esd/spack_factory/SpackOperationCreator.py delete mode 100644 esd/spack_factory/SpackOperationUseCache.py delete mode 100644 esd/spack_factory/__init__.py delete mode 100644 esd/utils/bootstrap.sh delete mode 100644 esd/utils/utils.py delete mode 100644 esd/wrapper/__init__.py delete mode 100644 esd/wrapper/spack_wrapper.py diff --git a/.env b/.env index 93e62653..786f93e2 100644 --- a/.env +++ b/.env @@ -2,8 +2,7 @@ BUILDCACHE_OCI_HOST="" BUILDCACHE_OCI_PASSWORD="" BUILDCACHE_OCI_PROJECT="" BUILDCACHE_OCI_USERNAME="" - CONCRETIZE_OCI_HOST="" CONCRETIZE_OCI_PASSWORD="" CONCRETIZE_OCI_PROJECT="" -CONCRETIZE_OCI_USERNAME"" +CONCRETIZE_OCI_USERNAME="" diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index d1b09e6a..4f15b9ab 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -28,17 +28,17 @@ testing-pytest: - docker-runner image: ubuntu:22.04 script: - - chmod +x esd/utils/bootstrap.sh - - ./esd/utils/bootstrap.sh + - chmod +x dedal/utils/bootstrap.sh + - ./dedal/utils/bootstrap.sh - pip install . - - pytest ./esd/tests/ -s --junitxml=test-results.xml + - pytest ./dedal/tests/ -s --junitxml=test-results.xml artifacts: when: always reports: junit: test-results.xml paths: - test-results.xml - - .esd.log + - .dedal.log - .generate_cache.log expire_in: 1 week diff --git a/README.md b/README.md index dd68dcfa..5dc3fcc1 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,6 @@ This repository provides functionalities to easily ```managed spack environments``` and ```helpers for the container image build flow```. -This library runs only on different kinds of linux distribution operating systems. - -The lowest ```spack version``` compatible with this library is ```v0.23.0```. - - - This repository also provied CLI interface. For more informations, after installing this library, call dedal --help. - - **Setting up the needed environment variables** The ````<checkout path>\dedal\.env```` file contains the environment variables required for OCI registry used for caching. Ensure that you edit the ````<checkout path>\dedal\.env```` file to match your environment. @@ -51,3 +43,11 @@ The lowest ```spack version``` compatible with this library is ```v0.23.0```. For both concretization and binary caches, the cache version can be changed via the attributes ```cache_version_concretize``` and ```cache_version_build```. The default values are ```v1```. + +Before using this library, the following tool must be installed on Linux distribution: +```` + apt install -y bzip2 ca-certificates g++ gcc gfortran git gzip lsb-release patch python3 python3-pip tar unzip xz-utils zstd +```` +```` + python3 -m pip install --upgrade pip setuptools wheel +```` diff --git a/esd/configuration/__init__.py b/dedal/bll/__init__.py similarity index 100% rename from esd/configuration/__init__.py rename to dedal/bll/__init__.py diff --git a/dedal/build_cache/BuildCacheManager.py b/dedal/build_cache/BuildCacheManager.py index cccb5846..55fa10cb 100644 --- a/dedal/build_cache/BuildCacheManager.py +++ b/dedal/build_cache/BuildCacheManager.py @@ -2,9 +2,9 @@ import os import oras.client from pathlib import Path -from esd.build_cache.BuildCacheManagerInterface import BuildCacheManagerInterface -from esd.logger.logger_builder import get_logger -from esd.utils.utils import clean_up +from dedal.build_cache.BuildCacheManagerInterface import BuildCacheManagerInterface +from dedal.logger.logger_builder import get_logger +from dedal.utils.utils import clean_up class BuildCacheManager(BuildCacheManagerInterface): diff --git a/dedal/configuration/SpackConfig.py b/dedal/configuration/SpackConfig.py index 0d470679..d76783ec 100644 --- a/dedal/configuration/SpackConfig.py +++ b/dedal/configuration/SpackConfig.py @@ -1,31 +1,30 @@ import os from pathlib import Path - from dedal.configuration.GpgConfig import GpgConfig from dedal.model import SpackDescriptor +from dedal.utils.utils import resolve_path class SpackConfig: def __init__(self, env: SpackDescriptor = None, repos: list[SpackDescriptor] = None, install_dir=Path(os.getcwd()).resolve(), upstream_instance=None, system_name=None, - concretization_dir: Path = None, buildcache_dir: Path = None, gpg: GpgConfig = None): + concretization_dir: Path = None, buildcache_dir: Path = None, gpg: GpgConfig = None, + use_spack_global=False, cache_version_concretize='v1', + cache_version_build='v1'): self.env = env if repos is None: self.repos = [] else: self.repos = repos - self.install_dir = install_dir - if self.install_dir: - os.makedirs(self.install_dir, exist_ok=True) self.upstream_instance = upstream_instance self.system_name = system_name - self.concretization_dir = concretization_dir - if self.concretization_dir: - os.makedirs(self.concretization_dir, exist_ok=True) - self.buildcache_dir = buildcache_dir - if self.buildcache_dir: - os.makedirs(self.buildcache_dir, exist_ok=True) + self.concretization_dir = concretization_dir if concretization_dir is None else resolve_path(concretization_dir) + self.buildcache_dir = buildcache_dir if buildcache_dir is None else resolve_path(buildcache_dir) + self.install_dir = resolve_path(install_dir) self.gpg = gpg + self.use_spack_global = use_spack_global + self.cache_version_concretize = cache_version_concretize + self.cache_version_build = cache_version_build def add_repo(self, repo: SpackDescriptor): if self.repos is None: diff --git a/dedal/error_handling/exceptions.py b/dedal/error_handling/exceptions.py index 0256f886..4653e72f 100644 --- a/dedal/error_handling/exceptions.py +++ b/dedal/error_handling/exceptions.py @@ -39,3 +39,8 @@ class SpackGpgException(BashCommandException): """ To be thrown when the spack fails to create gpg keys """ + +class SpackRepoException(BashCommandException): + """ + To be thrown when the spack fails to add a repo + """ \ No newline at end of file diff --git a/dedal/model/SpackDescriptor.py b/dedal/model/SpackDescriptor.py index 70e484fb..421c4824 100644 --- a/dedal/model/SpackDescriptor.py +++ b/dedal/model/SpackDescriptor.py @@ -9,5 +9,5 @@ class SpackDescriptor: def __init__(self, env_name: str, path: Path = Path(os.getcwd()).resolve(), git_path: str = None): self.env_name = env_name - self.path = path + self.path = path if isinstance(path,Path) else Path(path) self.git_path = git_path diff --git a/dedal/spack_factory/SpackOperation.py b/dedal/spack_factory/SpackOperation.py index ecfbb8e5..58dcad8b 100644 --- a/dedal/spack_factory/SpackOperation.py +++ b/dedal/spack_factory/SpackOperation.py @@ -3,7 +3,7 @@ import re import subprocess from pathlib import Path from dedal.error_handling.exceptions import BashCommandException, NoSpackEnvironmentException, \ - SpackInstallPackagesException, SpackConcertizeException, SpackMirrorException, SpackGpgException + SpackInstallPackagesException, SpackConcertizeException, SpackMirrorException, SpackGpgException, SpackRepoException from dedal.logger.logger_builder import get_logger from dedal.configuration.SpackConfig import SpackConfig from dedal.tests.testing_variables import SPACK_VERSION @@ -27,15 +27,23 @@ class SpackOperation: def __init__(self, spack_config: SpackConfig = SpackConfig(), logger=get_logger(__name__)): self.spack_config = spack_config - self.spack_config.install_dir.mkdir(parents=True, exist_ok=True) + self.spack_config.install_dir = spack_config.install_dir + os.makedirs(self.spack_config.install_dir, exist_ok=True) self.spack_dir = self.spack_config.install_dir / 'spack' - self.spack_setup_script = self.spack_dir / 'share' / 'spack' / 'setup-env.sh' + + self.spack_setup_script = "" if self.spack_config.use_spack_global else f"source {self.spack_dir / 'share' / 'spack' / 'setup-env.sh'} &&" self.logger = logger + self.spack_config.concretization_dir = spack_config.concretization_dir + if self.spack_config.concretization_dir: + os.makedirs(self.spack_config.concretization_dir, exist_ok=True) + self.spack_config.buildcache_dir = spack_config.buildcache_dir + if self.spack_config.buildcache_dir: + os.makedirs(self.spack_config.buildcache_dir, exist_ok=True) if self.spack_config.env and spack_config.env.path: - self.spack_config.env.path = spack_config.env.path.resolve() + self.spack_config.env.path = spack_config.env.path self.spack_config.env.path.mkdir(parents=True, exist_ok=True) - self.env_path = spack_config.env.path / spack_config.env.env_name - self.spack_command_on_env = f'source {self.spack_setup_script} && spack env activate -p {self.env_path}' + self.env_path: Path = spack_config.env.path / spack_config.env.env_name + self.spack_command_on_env = f'{self.spack_setup_script} spack env activate -p {self.env_path}' def create_fetch_spack_environment(self): if self.spack_config.env.git_path: @@ -45,7 +53,7 @@ class SpackOperation: else: os.makedirs(self.spack_config.env.path / self.spack_config.env.env_name, exist_ok=True) run_command("bash", "-c", - f'source {self.spack_setup_script} && spack env create -d {self.env_path}', + f'{self.spack_setup_script} spack env create -d {self.env_path}', check=True, logger=self.logger, info_msg=f"Created {self.spack_config.env.env_name} spack environment", exception_msg=f"Failed to create {self.spack_config.env.env_name} spack environment", @@ -83,7 +91,7 @@ class SpackOperation: """Check if the given Spack repository exists.""" if self.spack_config.env is None: result = run_command("bash", "-c", - f'source {self.spack_setup_script} && spack repo list', + f'{self.spack_setup_script} spack repo list', check=True, capture_output=True, text=True, logger=self.logger, info_msg=f'Checking if {repo_name} exists') @@ -121,7 +129,7 @@ class SpackOperation: check=True, logger=self.logger, info_msg=f"Added {repo_name} to spack environment {self.spack_config.env.env_name}", exception_msg=f"Failed to add {repo_name} to spack environment {self.spack_config.env.env_name}", - exception=BashCommandException) + exception=SpackRepoException) @check_spack_env def get_compiler_version(self): @@ -144,7 +152,7 @@ class SpackOperation: return gcc_version def get_spack_installed_version(self): - spack_version = run_command("bash", "-c", f'source {self.spack_setup_script} && spack --version', + spack_version = run_command("bash", "-c", f'{self.spack_setup_script} spack --version', capture_output=True, text=True, check=True, logger=self.logger, info_msg=f"Getting spack version", @@ -159,7 +167,7 @@ class SpackOperation: run_command("bash", "-c", f'{self.spack_command_on_env} && spack concretize {force}', check=True, - logger=self.logger, + logger=self.logger, info_msg=f'Concertization step for {self.spack_config.env.env_name}', exception_msg=f'Failed the concertization step for {self.spack_config.env.env_name}', exception=SpackConcertizeException) @@ -167,7 +175,7 @@ class SpackOperation: def create_gpg_keys(self): if self.spack_config.gpg: run_command("bash", "-c", - f'source {self.spack_setup_script} && spack gpg init && spack gpg create {self.spack_config.gpg.name} {self.spack_config.gpg.mail}', + f'{self.spack_setup_script} spack gpg init && spack gpg create {self.spack_config.gpg.name} {self.spack_config.gpg.mail}', check=True, logger=self.logger, info_msg=f'Created pgp keys for {self.spack_config.env.env_name}', @@ -181,7 +189,7 @@ class SpackOperation: signed = '--signed' if signed else '' if global_mirror: run_command("bash", "-c", - f'source {self.spack_setup_script} && spack mirror add {autopush} {signed} {mirror_name} {mirror_path}', + f'{self.spack_setup_script} spack mirror add {autopush} {signed} {mirror_name} {mirror_path}', check=True, logger=self.logger, info_msg=f'Added mirror {mirror_name}', @@ -199,7 +207,7 @@ class SpackOperation: def remove_mirror(self, mirror_name: str): run_command("bash", "-c", - f'source {self.spack_setup_script} && spack mirror rm {mirror_name}', + f'{self.spack_setup_script} spack mirror rm {mirror_name}', check=True, logger=self.logger, info_msg=f'Removing mirror {mirror_name}', @@ -212,7 +220,7 @@ class SpackOperation: fresh = '--fresh' if fresh else '' debug = '--debug' if debug else '' install_result = run_command("bash", "-c", - f'{self.spack_command_on_env} && spack {debug} install -v {signed} --j {jobs} {fresh}', + f'{self.spack_command_on_env} && spack {debug} install -v {signed} -j {jobs} {fresh}', stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, @@ -223,7 +231,8 @@ class SpackOperation: log_command(install_result, str(Path(os.getcwd()).resolve() / ".generate_cache.log")) return install_result - def install_spack(self, spack_version=f'v{SPACK_VERSION}', spack_repo='https://github.com/spack/spack'): + def install_spack(self, spack_version=f'v{SPACK_VERSION}', spack_repo='https://github.com/spack/spack', + bashrc_path=os.path.expanduser("~/.bashrc")): try: user = os.getlogin() except OSError: @@ -241,23 +250,24 @@ class SpackOperation: else: self.logger.debug("Spack already cloned.") - bashrc_path = os.path.expanduser("~/.bashrc") # ensure the file exists before opening it if not os.path.exists(bashrc_path): open(bashrc_path, "w").close() # add spack setup commands to .bashrc with open(bashrc_path, "a") as bashrc: bashrc.write(f'export PATH="{self.spack_dir}/bin:$PATH"\n') - bashrc.write(f"source {self.spack_setup_script}\n") + spack_setup_script = f"source {self.spack_dir / 'share' / 'spack' / 'setup-env.sh'}" + bashrc.write(f"{spack_setup_script}\n") self.logger.info("Added Spack PATH to .bashrc") if user: run_command("chown", "-R", f"{user}:{user}", self.spack_dir, check=True, logger=self.logger, info_msg='Adding permissions to the logged in user') - run_command("bash", "-c", f"source {bashrc_path}", check=True, logger=self.logger, info_msg='Restart bash') self.logger.info("Spack install completed") - # Restart Bash after the installation ends - os.system("exec bash") - + if self.spack_config.use_spack_global is True: + # Restart the bash only of the spack is used globally + self.logger.info('Restarting bash') + run_command("bash", "-c", f"source {bashrc_path}", check=True, logger=self.logger, info_msg='Restart bash') + os.system("exec bash") # Configure upstream Spack instance if specified if self.spack_config.upstream_instance: upstreams_yaml_path = os.path.join(self.spack_dir, "etc/spack/defaults/upstreams.yaml") diff --git a/dedal/spack_factory/SpackOperationCreateCache.py b/dedal/spack_factory/SpackOperationCreateCache.py new file mode 100644 index 00000000..f04eae3a --- /dev/null +++ b/dedal/spack_factory/SpackOperationCreateCache.py @@ -0,0 +1,51 @@ +import os + +from dedal.error_handling.exceptions import NoSpackEnvironmentException + +from dedal.utils.utils import copy_to_tmp, copy_file +from dedal.wrapper.spack_wrapper import check_spack_env +from dedal.build_cache.BuildCacheManager import BuildCacheManager +from dedal.configuration.SpackConfig import SpackConfig +from dedal.logger.logger_builder import get_logger +from dedal.spack_factory.SpackOperation import SpackOperation + + +class SpackOperationCreateCache(SpackOperation): + """ + This class creates caching for the concretization step and for the installation step. + """ + + def __init__(self, spack_config: SpackConfig = SpackConfig()): + super().__init__(spack_config, logger=get_logger(__name__)) + self.cache_dependency = BuildCacheManager(os.environ.get('CONCRETIZE_OCI_HOST'), + os.environ.get('CONCRETIZE_OCI_PROJECT'), + os.environ.get('CONCRETIZE_OCI_USERNAME'), + os.environ.get('CONCRETIZE_OCI_PASSWORD'), + cache_version=spack_config.cache_version_concretize) + self.build_cache = BuildCacheManager(os.environ.get('BUILDCACHE_OCI_HOST'), + os.environ.get('BUILDCACHE_OCI_PROJECT'), + os.environ.get('BUILDCACHE_OCI_USERNAME'), + os.environ.get('BUILDCACHE_OCI_PASSWORD'), + cache_version=spack_config.cache_version_build) + + @check_spack_env + def concretize_spack_env(self): + super().concretize_spack_env(force=True) + dependency_path = self.spack_config.env.path / self.spack_config.env.env_name / 'spack.lock' + copy_file(dependency_path, self.spack_config.concretization_dir, logger=self.logger) + self.cache_dependency.upload(self.spack_config.concretization_dir) + self.logger.info(f'Created new spack concretization for create cache: {self.spack_config.env.env_name}') + + @check_spack_env + def install_packages(self, jobs: int = 2, debug=False): + signed = False + if self.spack_config.gpg: + signed = True + self.create_gpg_keys() + self.add_mirror('local_cache', self.spack_config.buildcache_dir, signed=signed, autopush=signed, + global_mirror=False) + self.logger.info(f'Added mirror for {self.spack_config.env.env_name}') + super().install_packages(jobs=jobs, signed=signed, debug=debug, fresh=True) + self.logger.info(f'Installed spack packages for {self.spack_config.env.env_name}') + self.build_cache.upload(self.spack_config.buildcache_dir) + self.logger.info(f'Pushed spack packages for {self.spack_config.env.env_name}') diff --git a/dedal/spack_factory/SpackOperationCreator.py b/dedal/spack_factory/SpackOperationCreator.py index 54517a84..fdc929d3 100644 --- a/dedal/spack_factory/SpackOperationCreator.py +++ b/dedal/spack_factory/SpackOperationCreator.py @@ -1,14 +1,19 @@ from dedal.configuration.SpackConfig import SpackConfig from dedal.spack_factory.SpackOperation import SpackOperation +from dedal.spack_factory.SpackOperationCreateCache import SpackOperationCreateCache from dedal.spack_factory.SpackOperationUseCache import SpackOperationUseCache class SpackOperationCreator: @staticmethod - def get_spack_operator(spack_config: SpackConfig = None): + def get_spack_operator(spack_config: SpackConfig = None, use_cache: bool = False) -> SpackOperation: if spack_config is None: - return SpackOperation(SpackConfig()) + return SpackOperation() elif spack_config.concretization_dir is None and spack_config.buildcache_dir is None: return SpackOperation(spack_config) - else: + elif (spack_config.concretization_dir and spack_config.buildcache_dir) and not use_cache: + return SpackOperationCreateCache(spack_config) + elif (spack_config.concretization_dir and spack_config.buildcache_dir) and use_cache: return SpackOperationUseCache(spack_config) + else: + return SpackOperation(SpackConfig()) diff --git a/dedal/spack_factory/SpackOperationUseCache.py b/dedal/spack_factory/SpackOperationUseCache.py index 41a9094c..cb2b3ac8 100644 --- a/dedal/spack_factory/SpackOperationUseCache.py +++ b/dedal/spack_factory/SpackOperationUseCache.py @@ -1,8 +1,14 @@ import os +import subprocess +from pathlib import Path + from dedal.build_cache.BuildCacheManager import BuildCacheManager +from dedal.error_handling.exceptions import SpackInstallPackagesException from dedal.logger.logger_builder import get_logger from dedal.spack_factory.SpackOperation import SpackOperation from dedal.configuration.SpackConfig import SpackConfig +from dedal.utils.utils import file_exists_and_not_empty, run_command, log_command, copy_to_tmp, copy_file +from dedal.wrapper.spack_wrapper import check_spack_env class SpackOperationUseCache(SpackOperation): @@ -10,23 +16,52 @@ class SpackOperationUseCache(SpackOperation): This class uses caching for the concretization step and for the installation step. """ - def __init__(self, spack_config: SpackConfig = SpackConfig(), cache_version_concretize='v1', - cache_version_build='v1'): + def __init__(self, spack_config: SpackConfig = SpackConfig()): super().__init__(spack_config, logger=get_logger(__name__)) self.cache_dependency = BuildCacheManager(os.environ.get('CONCRETIZE_OCI_HOST'), os.environ.get('CONCRETIZE_OCI_PROJECT'), os.environ.get('CONCRETIZE_OCI_USERNAME'), os.environ.get('CONCRETIZE_OCI_PASSWORD'), - cache_version=cache_version_concretize) + cache_version=spack_config.cache_version_concretize) self.build_cache = BuildCacheManager(os.environ.get('BUILDCACHE_OCI_HOST'), os.environ.get('BUILDCACHE_OCI_PROJECT'), os.environ.get('BUILDCACHE_OCI_USERNAME'), os.environ.get('BUILDCACHE_OCI_PASSWORD'), - cache_version=cache_version_build) + cache_version=spack_config.cache_version_build) def setup_spack_env(self): super().setup_spack_env() # todo add buildcache to the spack environment - def concretize_spack_env(self, force=True): - pass + @check_spack_env + def concretize_spack_env(self): + concretization_redo = False + self.cache_dependency.download(self.spack_config.concretization_dir) + if file_exists_and_not_empty(self.spack_config.concretization_dir / 'spack.lock'): + concretization_file_path = self.env_path / 'spack.lock' + copy_file(self.spack_config.concretization_dir / 'spack.lock', self.env_path) + # redo the concretization step if spack.lock file was not downloaded from the cache + if not file_exists_and_not_empty(concretization_file_path): + super().concretize_spack_env(force=True) + concretization_redo = True + else: + # redo the concretization step if spack.lock file was not downloaded from the cache + super().concretize_spack_env(force=True) + concretization_redo = True + return concretization_redo + + @check_spack_env + def install_packages(self, jobs: int, signed=True, debug=False): + signed = '' if signed else '--no-check-signature' + debug = '--debug' if debug else '' + install_result = run_command("bash", "-c", + f'{self.spack_command_on_env} && spack {debug} install -v --reuse {signed} -j {jobs}', + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + logger=self.logger, + info_msg=f"Installing spack packages for {self.spack_config.env.env_name}", + exception_msg=f"Error installing spack packages for {self.spack_config.env.env_name}", + exception=SpackInstallPackagesException) + log_command(install_result, str(Path(os.getcwd()).resolve() / ".generate_cache.log")) + return install_result diff --git a/dedal/tests/integration_tests/spack_create_cache_test.py b/dedal/tests/integration_tests/spack_create_cache_test.py new file mode 100644 index 00000000..fcef47a8 --- /dev/null +++ b/dedal/tests/integration_tests/spack_create_cache_test.py @@ -0,0 +1,58 @@ +from pathlib import Path + +import pytest + +from dedal.configuration.GpgConfig import GpgConfig +from dedal.configuration.SpackConfig import SpackConfig + +from dedal.model.SpackDescriptor import SpackDescriptor +from dedal.spack_factory.SpackOperationCreateCache import SpackOperationCreateCache +from dedal.spack_factory.SpackOperationCreator import SpackOperationCreator +from dedal.tests.testing_variables import test_spack_env_git, ebrains_spack_builds_git + +""" +Before running those tests, the repositories where the caching is stored must be cleared after each run. +Ebrains Harbour does not support deletion via API, so the clean up must be done manually +""" + + +@pytest.mark.skip( + reason="Skipping until an OCI registry which supports via API deletion; Clean up for OCI registry repo must be added before this test.") +def test_spack_create_cache_concretization(tmp_path): + install_dir = tmp_path + concretization_dir = install_dir / 'concretization' + buildcache_dir = install_dir / 'buildcache' + env = SpackDescriptor('test-spack-env', install_dir, test_spack_env_git) + repo = SpackDescriptor('ebrains-spack-builds', install_dir, ebrains_spack_builds_git) + gpg = GpgConfig(gpg_name='test-gpg', gpg_mail='test@test.com') + config = SpackConfig(env=env, install_dir=install_dir, concretization_dir=concretization_dir, + buildcache_dir=buildcache_dir, gpg=gpg) + config.add_repo(repo) + spack_operation = SpackOperationCreator.get_spack_operator(config) + assert isinstance(spack_operation, SpackOperationCreateCache) + spack_operation.install_spack() + spack_operation.setup_spack_env() + spack_operation.concretize_spack_env() + assert len(spack_operation.cache_dependency.list_tags()) > 0 + + +@pytest.mark.skip( + reason="Skipping until an OCI registry which supports via API deletion; Clean up for OCI registry repo must be added before this test.") +def test_spack_create_cache_installation(tmp_path): + install_dir = tmp_path + concretization_dir = install_dir / 'concretization' + buildcache_dir = install_dir / 'buildcache' + env = SpackDescriptor('test-spack-env', install_dir, test_spack_env_git) + repo = SpackDescriptor('ebrains-spack-builds', install_dir, ebrains_spack_builds_git) + gpg = GpgConfig(gpg_name='test-gpg', gpg_mail='test@test.com') + config = SpackConfig(env=env, install_dir=install_dir, concretization_dir=concretization_dir, + buildcache_dir=buildcache_dir, gpg=gpg) + config.add_repo(repo) + spack_operation = SpackOperationCreator.get_spack_operator(config) + assert isinstance(spack_operation, SpackOperationCreateCache) + spack_operation.install_spack() + spack_operation.setup_spack_env() + spack_operation.concretize_spack_env() + assert len(spack_operation.cache_dependency.list_tags()) > 0 + spack_operation.install_packages() + assert len(spack_operation.build_cache.list_tags()) > 0 diff --git a/dedal/tests/integration_tests/spack_from_cache_test.py b/dedal/tests/integration_tests/spack_from_cache_test.py new file mode 100644 index 00000000..33f44833 --- /dev/null +++ b/dedal/tests/integration_tests/spack_from_cache_test.py @@ -0,0 +1,42 @@ +import pytest +from dedal.configuration.SpackConfig import SpackConfig +from dedal.model.SpackDescriptor import SpackDescriptor +from dedal.spack_factory.SpackOperationCreator import SpackOperationCreator +from dedal.spack_factory.SpackOperationUseCache import SpackOperationUseCache +from dedal.utils.utils import file_exists_and_not_empty +from dedal.utils.variables import test_spack_env_git, ebrains_spack_builds_git + + +def test_spack_from_cache_concretize(tmp_path): + install_dir = tmp_path + env = SpackDescriptor('test-spack-env', install_dir, test_spack_env_git) + repo = SpackDescriptor('ebrains-spack-builds', install_dir, ebrains_spack_builds_git) + spack_config = SpackConfig(env, install_dir=install_dir, concretization_dir=install_dir / 'concretize', + buildcache_dir=install_dir / 'buildcache') + spack_config.add_repo(repo) + spack_operation = SpackOperationCreator.get_spack_operator(spack_config, use_cache=True) + assert isinstance(spack_operation, SpackOperationUseCache) + spack_operation.install_spack() + spack_operation.setup_spack_env() + assert spack_operation.concretize_spack_env() == False + concretization_file_path = spack_operation.env_path / 'spack.lock' + assert file_exists_and_not_empty(concretization_file_path) == True + + +@pytest.mark.skip(reason="Skipping test::test_spack_from_cache_install until all the functionalities in SpackOperationUseCache") +def test_spack_from_cache_install(tmp_path): + install_dir = tmp_path + env = SpackDescriptor('test-spack-env', install_dir, test_spack_env_git) + repo = SpackDescriptor('ebrains-spack-builds', install_dir, ebrains_spack_builds_git) + spack_config = SpackConfig(env, install_dir=install_dir, concretization_dir=install_dir / 'concretize', + buildcache_dir=install_dir / 'buildcache') + spack_config.add_repo(repo) + spack_operation = SpackOperationCreator.get_spack_operator(spack_config, use_cache=True) + assert isinstance(spack_operation, SpackOperationUseCache) + spack_operation.install_spack() + spack_operation.setup_spack_env() + assert spack_operation.concretize_spack_env() == False + concretization_file_path = spack_operation.env_path / 'spack.lock' + assert file_exists_and_not_empty(concretization_file_path) == True + install_result = spack_operation.install_packages(jobs=2, signed=True, debug=False) + assert install_result.returncode == 0 diff --git a/dedal/tests/integration_tests/spack_from_scratch_test.py b/dedal/tests/integration_tests/spack_from_scratch_test.py index fa862301..794caef1 100644 --- a/dedal/tests/integration_tests/spack_from_scratch_test.py +++ b/dedal/tests/integration_tests/spack_from_scratch_test.py @@ -6,6 +6,7 @@ from dedal.spack_factory.SpackOperationCreator import SpackOperationCreator from dedal.model.SpackDescriptor import SpackDescriptor from dedal.tests.testing_variables import test_spack_env_git, ebrains_spack_builds_git from dedal.utils.utils import file_exists_and_not_empty +from dedal.spack_factory.SpackOperation import SpackOperation def test_spack_repo_exists_1(tmp_path): @@ -23,8 +24,8 @@ def test_spack_repo_exists_2(tmp_path): env = SpackDescriptor('ebrains-spack-builds', install_dir) config = SpackConfig(env=env, install_dir=install_dir) spack_operation = SpackOperationCreator.get_spack_operator(config) + assert isinstance(spack_operation, SpackOperation) spack_operation.install_spack() - print(spack_operation.get_spack_installed_version()) spack_operation.setup_spack_env() assert spack_operation.spack_repo_exists(env.env_name) == False @@ -34,6 +35,7 @@ def test_spack_from_scratch_setup_1(tmp_path): env = SpackDescriptor('ebrains-spack-builds', install_dir, ebrains_spack_builds_git) config = SpackConfig(env=env, system_name='ebrainslab', install_dir=install_dir) spack_operation = SpackOperationCreator.get_spack_operator(config) + assert isinstance(spack_operation, SpackOperation) spack_operation.install_spack() spack_operation.setup_spack_env() assert spack_operation.spack_repo_exists(env.env_name) == False @@ -47,6 +49,7 @@ def test_spack_from_scratch_setup_2(tmp_path): config.add_repo(repo) config.add_repo(repo) spack_operation = SpackOperationCreator.get_spack_operator(config) + assert isinstance(spack_operation, SpackOperation) spack_operation.install_spack() spack_operation.setup_spack_env() assert spack_operation.spack_repo_exists(env.env_name) == True @@ -60,17 +63,21 @@ def test_spack_from_scratch_setup_3(tmp_path): config.add_repo(repo) config.add_repo(repo) spack_operation = SpackOperationCreator.get_spack_operator(config) + assert isinstance(spack_operation, SpackOperation) spack_operation.install_spack() with pytest.raises(BashCommandException): spack_operation.setup_spack_env() -def test_spack_from_scratch_setup_4(): - install_dir = Path('./install').resolve() - env = SpackModel('new_environment', install_dir, ) - spack_manager = SpackManagerScratch(env, system_name='ebrainslab') - spack_manager.setup_spack_env() - assert spack_manager.spack_repo_exists(env.env_name) == True +def test_spack_from_scratch_setup_4(tmp_path): + install_dir = tmp_path + env = SpackDescriptor('new_env2', install_dir) + config = SpackConfig(env=env, install_dir=install_dir) + spack_operation = SpackOperationCreator.get_spack_operator(config) + assert isinstance(spack_operation, SpackOperation) + spack_operation.install_spack() + spack_operation.setup_spack_env() + assert spack_operation.spack_env_exists() == True def test_spack_not_a_valid_repo(): @@ -79,6 +86,7 @@ def test_spack_not_a_valid_repo(): config = SpackConfig(env=env, system_name='ebrainslab') config.add_repo(repo) spack_operation = SpackOperationCreator.get_spack_operator(config) + assert isinstance(spack_operation, SpackOperation) with pytest.raises(BashCommandException): spack_operation.add_spack_repo(repo.path, repo.env_name) @@ -93,6 +101,7 @@ def test_spack_from_scratch_concretize_1(tmp_path): config.add_repo(repo) config.add_repo(repo) spack_operation = SpackOperationCreator.get_spack_operator(config) + assert isinstance(spack_operation, SpackOperation) spack_operation.install_spack() spack_operation.install_spack() spack_operation.setup_spack_env() @@ -111,6 +120,7 @@ def test_spack_from_scratch_concretize_2(tmp_path): config.add_repo(repo) config.add_repo(repo) spack_operation = SpackOperationCreator.get_spack_operator(config) + assert isinstance(spack_operation, SpackOperation) spack_operation.install_spack() spack_operation.setup_spack_env() spack_operation.concretize_spack_env(force=False) @@ -126,6 +136,7 @@ def test_spack_from_scratch_concretize_3(tmp_path): config.add_repo(repo) config.add_repo(repo) spack_operation = SpackOperationCreator.get_spack_operator(config) + assert isinstance(spack_operation, SpackOperation) spack_operation.install_spack() spack_operation.setup_spack_env() concretization_file_path = spack_operation.env_path / 'spack.lock' @@ -137,6 +148,7 @@ def test_spack_from_scratch_concretize_4(tmp_path): env = SpackDescriptor('test-spack-env', install_dir, test_spack_env_git) config = SpackConfig(env=env, install_dir=install_dir) spack_operation = SpackOperationCreator.get_spack_operator(config) + assert isinstance(spack_operation, SpackOperation) spack_operation.install_spack() spack_operation.setup_spack_env() spack_operation.concretize_spack_env(force=False) @@ -149,6 +161,7 @@ def test_spack_from_scratch_concretize_5(tmp_path): env = SpackDescriptor('test-spack-env', install_dir, test_spack_env_git) config = SpackConfig(env=env, install_dir=install_dir) spack_operation = SpackOperationCreator.get_spack_operator(config) + assert isinstance(spack_operation, SpackOperation) spack_operation.install_spack() spack_operation.setup_spack_env() spack_operation.concretize_spack_env(force=True) @@ -163,6 +176,7 @@ def test_spack_from_scratch_concretize_6(tmp_path): config = SpackConfig(env=env, install_dir=install_dir) config.add_repo(repo) spack_operation = SpackOperationCreator.get_spack_operator(config) + assert isinstance(spack_operation, SpackOperation) spack_operation.install_spack() spack_operation.setup_spack_env() spack_operation.concretize_spack_env(force=False) @@ -177,6 +191,7 @@ def test_spack_from_scratch_concretize_7(tmp_path): config = SpackConfig(env=env) config.add_repo(repo) spack_operation = SpackOperationCreator.get_spack_operator(config) + assert isinstance(spack_operation, SpackOperation) spack_operation.install_spack() spack_operation.setup_spack_env() spack_operation.concretize_spack_env(force=True) @@ -191,6 +206,7 @@ def test_spack_from_scratch_install(tmp_path): config = SpackConfig(env=env) config.add_repo(repo) spack_operation = SpackOperationCreator.get_spack_operator(config) + assert isinstance(spack_operation, SpackOperation) spack_operation.install_spack() spack_operation.setup_spack_env() spack_operation.concretize_spack_env(force=True) diff --git a/dedal/tests/integration_tests/spack_install_test.py b/dedal/tests/integration_tests/spack_install_test.py index 28f8268e..0c6cf127 100644 --- a/dedal/tests/integration_tests/spack_install_test.py +++ b/dedal/tests/integration_tests/spack_install_test.py @@ -1,12 +1,12 @@ -import pytest -from esd.spack_factory.SpackOperation import SpackOperation -from esd.tests.testing_variables import SPACK_VERSION +from dedal.configuration.SpackConfig import SpackConfig +from dedal.spack_factory.SpackOperation import SpackOperation +from dedal.tests.testing_variables import SPACK_VERSION -# run this test first so that spack is installed only once for all the tests -@pytest.mark.run(order=1) -def test_spack_install_scratch(): - spack_operation = SpackOperation() +def test_spack_install_scratch(tmp_path): + install_dir = tmp_path + spack_config = SpackConfig(install_dir=install_dir) + spack_operation = SpackOperation(spack_config) spack_operation.install_spack(spack_version=f'v{SPACK_VERSION}') installed_spack_version = spack_operation.get_spack_installed_version() assert SPACK_VERSION == installed_spack_version diff --git a/dedal/utils/utils.py b/dedal/utils/utils.py index 9fc82ad5..2f7c4847 100644 --- a/dedal/utils/utils.py +++ b/dedal/utils/utils.py @@ -2,6 +2,7 @@ import logging import os import shutil import subprocess +import tempfile from pathlib import Path from dedal.error_handling.exceptions import BashCommandException @@ -78,6 +79,21 @@ def log_command(results, log_file: str): log_file.write(results.stderr) +def copy_to_tmp(file_path: Path) -> Path: + """ + Creates a temporary directory and copies the given file into it. + + :param file_path: Path to the file that needs to be copied. + :return: Path to the copied file inside the temporary directory. + """ + if not file_path.is_file(): + raise FileNotFoundError(f"File not found: {file_path}") + tmp_dir = Path(tempfile.mkdtemp()) + tmp_file_path = tmp_dir / file_path.name + shutil.copy(file_path, tmp_file_path) + return tmp_file_path + + def set_bashrc_variable(var_name: str, value: str, bashrc_path: str = os.path.expanduser("~/.bashrc"), logger: logging = logging.getLogger(__name__)): """Update or add an environment variable in ~/.bashrc.""" @@ -98,3 +114,43 @@ def set_bashrc_variable(var_name: str, value: str, bashrc_path: str = os.path.ex logger.info(f"Updated {bashrc_path} with: export {var_name}={value}") with open(bashrc_path, "w") as file: file.writelines(lines) + + +def copy_file(src: Path, dst: Path, logger: logging = logging.getLogger(__name__)) -> None: + """ + Copy a file from src to dest. + """ + if not os.path.exists(src): + raise FileNotFoundError(f"Source file '{src}' does not exist.") + src.resolve().as_posix() + dst.resolve().as_posix() + os.makedirs(os.path.dirname(dst), exist_ok=True) + shutil.copy2(src, dst) + logger.debug(f"File copied from '{src}' to '{dst}'") + + +def delete_file(file_path: str, logger: logging = logging.getLogger(__name__)) -> bool: + """ + Deletes a file at the given path. Returns True if successful, False if the file doesn't exist. + """ + try: + os.remove(file_path) + logger.debug(f"File '{file_path}' deleted.") + return True + except FileNotFoundError: + logger.error(f"File not found: {file_path}") + return False + except PermissionError: + logger.error(f"Permission denied: {file_path}") + return False + except Exception as e: + logger.error(f"Error deleting file {file_path}: {e}") + return False + + +def resolve_path(path: str): + if path is None: + path = Path(os.getcwd()).resolve() + else: + path = Path(path).resolve() + return path diff --git a/dedal/utils/variables.py b/dedal/utils/variables.py new file mode 100644 index 00000000..553ccf97 --- /dev/null +++ b/dedal/utils/variables.py @@ -0,0 +1,5 @@ +import os + +SPACK_ENV_ACCESS_TOKEN = os.getenv("SPACK_ENV_ACCESS_TOKEN") +test_spack_env_git = f'https://oauth2:{SPACK_ENV_ACCESS_TOKEN}@gitlab.ebrains.eu/ri/projects-and-initiatives/virtualbraintwin/tools/test-spack-env.git' +ebrains_spack_builds_git = 'https://gitlab.ebrains.eu/ri/tech-hub/platform/esd/ebrains-spack-builds.git' diff --git a/esd/configuration/SpackConfig.py b/esd/configuration/SpackConfig.py deleted file mode 100644 index 93a2e874..00000000 --- a/esd/configuration/SpackConfig.py +++ /dev/null @@ -1,25 +0,0 @@ -import os -from pathlib import Path -from esd.model import SpackDescriptor - - -class SpackConfig: - def __init__(self, env: SpackDescriptor = None, repos: list[SpackDescriptor] = None, - install_dir=Path(os.getcwd()).resolve(), upstream_instance=None, system_name=None, - concretization_dir: Path = None, buildcache_dir: Path = None): - self.env = env - if repos is None: - self.repos = [] - else: - self.repos = repos - self.install_dir = install_dir - self.upstream_instance = upstream_instance - self.system_name = system_name - self.concretization_dir = concretization_dir - self.buildcache_dir = buildcache_dir - - def add_repo(self, repo: SpackDescriptor): - if self.repos is None: - self.repos = [] - else: - self.repos.append(repo) diff --git a/esd/error_handling/__init__.py b/esd/error_handling/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/esd/error_handling/exceptions.py b/esd/error_handling/exceptions.py deleted file mode 100644 index 9d11b5fa..00000000 --- a/esd/error_handling/exceptions.py +++ /dev/null @@ -1,31 +0,0 @@ -class SpackException(Exception): - - def __init__(self, message): - super().__init__(message) - self.message = str(message) - - def __str__(self): - return self.message - - -class BashCommandException(SpackException): - """ - To be thrown when a bash command has failed - """ - - -class NoSpackEnvironmentException(BashCommandException): - """ - To be thrown when an operation on a spack environment is executed without the environment being activated or existent - """ - - -class SpackConcertizeException(BashCommandException): - """ - To be thrown when the spack concretization step fails - """ - -class SpackInstallPackagesException(BashCommandException): - """ - To be thrown when the spack fails to install spack packages - """ diff --git a/esd/model/SpackDescriptor.py b/esd/model/SpackDescriptor.py deleted file mode 100644 index 70e484fb..00000000 --- a/esd/model/SpackDescriptor.py +++ /dev/null @@ -1,13 +0,0 @@ -import os -from pathlib import Path - - -class SpackDescriptor: - """" - Provides details about the spack environment - """ - - def __init__(self, env_name: str, path: Path = Path(os.getcwd()).resolve(), git_path: str = None): - self.env_name = env_name - self.path = path - self.git_path = git_path diff --git a/esd/model/__init__.py b/esd/model/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/esd/spack_factory/SpackOperation.py b/esd/spack_factory/SpackOperation.py deleted file mode 100644 index cbf7b5be..00000000 --- a/esd/spack_factory/SpackOperation.py +++ /dev/null @@ -1,231 +0,0 @@ -import os -import re -import subprocess -from abc import ABC, abstractmethod -from pathlib import Path -from esd.error_handling.exceptions import BashCommandException, NoSpackEnvironmentException, \ - SpackInstallPackagesException, SpackConcertizeException -from esd.logger.logger_builder import get_logger -from esd.configuration.SpackConfig import SpackConfig -from esd.tests.testing_variables import SPACK_VERSION -from esd.wrapper.spack_wrapper import check_spack_env -from esd.utils.utils import run_command, git_clone_repo, log_command, set_bashrc_variable - - -class SpackOperation(ABC): - """ - This class should implement the methods necessary for installing spack, set up an environment, concretize and install packages. - Factory design pattern is used because there are 2 cases: creating an environment from scratch or creating an environment from the buildcache. - - Attributes: - ----------- - env : SpackDescriptor - spack environment details - repos : list[SpackDescriptor] - upstream_instance : str - path to Spack instance to use as upstream (optional) - """ - - def __init__(self, spack_config: SpackConfig = SpackConfig(), logger=get_logger(__name__)): - self.spack_config = spack_config - self.spack_config.install_dir.mkdir(parents=True, exist_ok=True) - self.spack_dir = self.spack_config.install_dir / 'spack' - self.spack_setup_script = self.spack_dir / 'share' / 'spack' / 'setup-env.sh' - self.logger = logger - if self.spack_config.env and spack_config.env.path: - self.spack_config.env.path = spack_config.env.path.resolve() - self.spack_config.env.path.mkdir(parents=True, exist_ok=True) - self.env_path = spack_config.env.path / spack_config.env.env_name - self.spack_command_on_env = f'source {self.spack_setup_script} && spack env activate -p {self.env_path}' - - @abstractmethod - def concretize_spack_env(self, force=True): - pass - - def create_fetch_spack_environment(self): - if self.spack_config.env.git_path: - git_clone_repo(self.spack_config.env.env_name, self.spack_config.env.path / self.spack_config.env.env_name, - self.spack_config.env.git_path, - logger=self.logger) - else: - os.makedirs(self.spack_config.env.path / self.spack_config.env.env_name, exist_ok=True) - run_command("bash", "-c", - f'source {self.spack_setup_script} && spack env create -d {self.env_path}', - check=True, logger=self.logger, - info_msg=f"Created {self.spack_config.env.env_name} spack environment", - exception_msg=f"Failed to create {self.spack_config.env.env_name} spack environment", - exception=BashCommandException) - - def setup_spack_env(self): - """ - This method prepares a spack environment by fetching/creating the spack environment and adding the necessary repos - """ - bashrc_path = os.path.expanduser("~/.bashrc") - if self.spack_config.system_name: - set_bashrc_variable('SYSTEMNAME', self.spack_config.system_name, bashrc_path, logger=self.logger) - os.environ['SYSTEMNAME'] = self.spack_config.system_name - if self.spack_dir.exists() and self.spack_dir.is_dir(): - set_bashrc_variable('SPACK_USER_CACHE_PATH', str(self.spack_dir / ".spack"), bashrc_path, logger=self.logger) - set_bashrc_variable('SPACK_USER_CONFIG_PATH', str(self.spack_dir / ".spack"), bashrc_path, logger=self.logger) - self.logger.debug('Added env variables SPACK_USER_CACHE_PATH and SPACK_USER_CONFIG_PATH') - else: - self.logger.error(f'Invalid installation path: {self.spack_dir}') - # Restart the bash after adding environment variables - self.create_fetch_spack_environment() - if self.spack_config.install_dir.exists(): - for repo in self.spack_config.repos: - repo_dir = self.spack_config.install_dir / repo.path / repo.env_name - git_clone_repo(repo.env_name, repo_dir, repo.git_path, logger=self.logger) - if not self.spack_repo_exists(repo.env_name): - self.add_spack_repo(repo.path, repo.env_name) - self.logger.debug(f'Added spack repository {repo.env_name}') - else: - self.logger.debug(f'Spack repository {repo.env_name} already added') - - def spack_repo_exists(self, repo_name: str) -> bool | None: - """Check if the given Spack repository exists.""" - if self.spack_config.env is None: - result = run_command("bash", "-c", - f'source {self.spack_setup_script} && spack repo list', - check=True, - capture_output=True, text=True, logger=self.logger, - info_msg=f'Checking if {repo_name} exists') - if result is None: - return False - else: - if self.spack_env_exists(): - result = run_command("bash", "-c", - f'{self.spack_command_on_env} && spack repo list', - check=True, - capture_output=True, text=True, logger=self.logger, - info_msg=f'Checking if repository {repo_name} was added') - else: - self.logger.debug('No spack environment defined') - raise NoSpackEnvironmentException('No spack environment defined') - if result is None: - return False - return any(line.strip().endswith(repo_name) for line in result.stdout.splitlines()) - - def spack_env_exists(self): - result = run_command("bash", "-c", - self.spack_command_on_env, - check=True, - capture_output=True, text=True, logger=self.logger, - info_msg=f'Checking if environment {self.spack_config.env.env_name} exists') - print(result) - if result is None: - return False - return True - - @check_spack_env - def add_spack_repo(self, repo_path: Path, repo_name: str): - """Add the Spack repository if it does not exist.""" - run_command("bash", "-c", - f'{self.spack_command_on_env} && spack repo add {repo_path}/{repo_name}', - check=True, logger=self.logger, - info_msg=f"Added {repo_name} to spack environment {self.spack_config.env.env_name}", - exception_msg=f"Failed to add {repo_name} to spack environment {self.spack_config.env.env_name}", - exception=BashCommandException) - - @check_spack_env - def get_compiler_version(self): - result = run_command("bash", "-c", - f'{self.spack_command_on_env} && spack compiler list', - check=True, logger=self.logger, - capture_output=True, text=True, - info_msg=f"Checking spack environment compiler version for {self.spack_config.env.env_name}", - exception_msg=f"Failed to checking spack environment compiler version for {self.spack_config.env.env_name}", - exception=BashCommandException) - # todo add error handling and tests - if result.stdout is None: - self.logger.debug('No gcc found for {self.env.env_name}') - return None - - # Find the first occurrence of a GCC compiler using regex - match = re.search(r"gcc@([\d\.]+)", result.stdout) - gcc_version = match.group(1) - self.logger.debug(f'Found gcc for {self.spack_config.env.env_name}: {gcc_version}') - return gcc_version - - def get_spack_installed_version(self): - spack_version = run_command("bash", "-c", f'source {self.spack_setup_script} && spack --version', - capture_output=True, text=True, check=True, - logger=self.logger, - info_msg=f"Getting spack version", - exception_msg=f"Error retrieving Spack version") - if spack_version: - return spack_version.stdout.strip().split()[0] - return None - - @check_spack_env - def concretize_spack_env(self, force=True): - force = '--force' if force else '' - run_command("bash", "-c", - f'source {self.spack_setup_script} && spack env activate -p {self.env_path} && spack concretize {force}', - check=True, - capture_output=True, text=True, logger=self.logger, - info_msg=f'Concertization step for {self.spack_config.env.env_name}', - exception_msg=f'Failed the concertization step for {self.spack_config.env.env_name}', - exception=SpackConcertizeException) - - @check_spack_env - def install_packages(self, jobs: int, signed=True, fresh=False, debug=False): - signed = '' if signed else '--no-check-signature' - fresh = '--fresh' if fresh else '' - debug = '--debug' if debug else '' - install_result = run_command("bash", "-c", - f'{self.spack_command_on_env} && spack {debug} install -v {signed} --j {jobs} {fresh}', - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - logger=self.logger, - info_msg=f"Installing spack packages for {self.spack_config.env.env_name}", - exception_msg=f"Error installing spack packages for {self.spack_config.env.env_name}", - exception=SpackInstallPackagesException) - log_command(install_result, str(Path(os.getcwd()).resolve() / ".generate_cache.log")) - return install_result - - def install_spack(self, spack_version=f'v{SPACK_VERSION}', spack_repo='https://github.com/spack/spack'): - try: - user = os.getlogin() - except OSError: - user = None - - self.logger.info(f"Starting to install Spack into {self.spack_dir} from branch {spack_version}") - if not self.spack_dir.exists(): - run_command( - "git", "clone", "--depth", "1", - "-c", "advice.detachedHead=false", - "-c", "feature.manyFiles=true", - "--branch", spack_version, spack_repo, self.spack_dir - , check=True, logger=self.logger) - self.logger.debug("Cloned spack") - else: - self.logger.debug("Spack already cloned.") - - bashrc_path = os.path.expanduser("~/.bashrc") - # ensure the file exists before opening it - if not os.path.exists(bashrc_path): - open(bashrc_path, "w").close() - # add spack setup commands to .bashrc - with open(bashrc_path, "a") as bashrc: - bashrc.write(f'export PATH="{self.spack_dir}/bin:$PATH"\n') - bashrc.write(f"source {self.spack_setup_script}\n") - self.logger.info("Added Spack PATH to .bashrc") - if user: - run_command("chown", "-R", f"{user}:{user}", self.spack_dir, check=True, logger=self.logger, - info_msg='Adding permissions to the logged in user') - run_command("bash", "-c", f"source {bashrc_path}", check=True, logger=self.logger, info_msg='Restart bash') - self.logger.info("Spack install completed") - # Restart Bash after the installation ends - os.system("exec bash") - - # Configure upstream Spack instance if specified - if self.spack_config.upstream_instance: - upstreams_yaml_path = os.path.join(self.spack_dir, "etc/spack/defaults/upstreams.yaml") - with open(upstreams_yaml_path, "w") as file: - file.write(f"""upstreams: - upstream-spack-instance: - install_tree: {self.spack_config.upstream_instance}/spack/opt/spack - """) - self.logger.info("Added upstream spack instance") diff --git a/esd/spack_factory/SpackOperationCreator.py b/esd/spack_factory/SpackOperationCreator.py deleted file mode 100644 index 8369c5ca..00000000 --- a/esd/spack_factory/SpackOperationCreator.py +++ /dev/null @@ -1,14 +0,0 @@ -from esd.configuration.SpackConfig import SpackConfig -from esd.spack_factory.SpackOperation import SpackOperation -from esd.spack_factory.SpackOperationUseCache import SpackOperationUseCache - - -class SpackOperationCreator: - @staticmethod - def get_spack_operator(spack_config: SpackConfig = None): - if spack_config is None: - return SpackOperation(SpackConfig()) - elif spack_config.concretization_dir is None and spack_config.buildcache_dir is None: - return SpackOperation(spack_config) - else: - return SpackOperationUseCache(spack_config) diff --git a/esd/spack_factory/SpackOperationUseCache.py b/esd/spack_factory/SpackOperationUseCache.py deleted file mode 100644 index 15a3822f..00000000 --- a/esd/spack_factory/SpackOperationUseCache.py +++ /dev/null @@ -1,19 +0,0 @@ -from esd.logger.logger_builder import get_logger -from esd.spack_factory.SpackOperation import SpackOperation -from esd.configuration.SpackConfig import SpackConfig - - -class SpackOperationUseCache(SpackOperation): - """ - This class uses caching for the concretization step and for the installation step. - """ - - def __init__(self, spack_config: SpackConfig = SpackConfig()): - super().__init__(spack_config, logger=get_logger(__name__)) - - def setup_spack_env(self): - super().setup_spack_env() - # todo add buildcache to the spack environment - - def concretize_spack_env(self, force=True): - pass diff --git a/esd/spack_factory/__init__.py b/esd/spack_factory/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/esd/utils/bootstrap.sh b/esd/utils/bootstrap.sh deleted file mode 100644 index 9b7d0131..00000000 --- a/esd/utils/bootstrap.sh +++ /dev/null @@ -1,6 +0,0 @@ -# Minimal prerequisites for installing the esd_library -# pip must be installed on the OS -echo "Bootstrapping..." -apt update -apt install -y bzip2 ca-certificates g++ gcc gfortran git gzip lsb-release patch python3 python3-pip tar unzip xz-utils zstd -python3 -m pip install --upgrade pip setuptools wheel diff --git a/esd/utils/utils.py b/esd/utils/utils.py deleted file mode 100644 index 033cbc54..00000000 --- a/esd/utils/utils.py +++ /dev/null @@ -1,88 +0,0 @@ -import logging -import os -import shutil -import subprocess -from pathlib import Path - -from esd.error_handling.exceptions import BashCommandException -import re - - -def clean_up(dirs: list[str], logger: logging = logging.getLogger(__name__), ignore_errors=True): - """ - All the folders from the list dirs are removed with all the content in them - """ - for cleanup_dir in dirs: - cleanup_dir = Path(cleanup_dir).resolve() - if cleanup_dir.exists(): - logger.info(f"Removing {cleanup_dir}") - try: - shutil.rmtree(Path(cleanup_dir)) - except OSError as e: - logger.error(f"Failed to remove {cleanup_dir}: {e}") - if not ignore_errors: - raise e - else: - logger.info(f"{cleanup_dir} does not exist") - - -def run_command(*args, logger=logging.getLogger(__name__), info_msg: str = '', exception_msg: str = None, - exception=None, **kwargs): - try: - logger.info(f'{info_msg}: args: {args}') - return subprocess.run(args, **kwargs) - except subprocess.CalledProcessError as e: - if exception_msg is not None: - logger.error(f"{exception_msg}: {e}") - if exception is not None: - raise exception(f'{exception_msg} : {e}') - else: - return None - - -def git_clone_repo(repo_name: str, dir: Path, git_path: str, logger: logging = logging.getLogger(__name__)): - if not dir.exists(): - run_command( - "git", "clone", "--depth", "1", - "-c", "advice.detachedHead=false", - "-c", "feature.manyFiles=true", - git_path, dir - , check=True, logger=logger, - info_msg=f'Cloned repository {repo_name}', - exception_msg=f'Failed to clone repository: {repo_name}', - exception=BashCommandException) - else: - logger.info(f'Repository {repo_name} already cloned.') - - -def file_exists_and_not_empty(file: Path) -> bool: - return file.is_file() and file.stat().st_size > 0 - - -def log_command(results, log_file: str): - with open(log_file, "w") as log_file: - log_file.write(results.stdout) - log_file.write("\n--- STDERR ---\n") - log_file.write(results.stderr) - - -def set_bashrc_variable(var_name: str, value: str, bashrc_path: str = os.path.expanduser("~/.bashrc"), - logger: logging = logging.getLogger(__name__)): - """Update or add an environment variable in ~/.bashrc.""" - with open(bashrc_path, "r") as file: - lines = file.readlines() - pattern = re.compile(rf'^\s*export\s+{var_name}=.*$') - updated = False - # Modify the existing variable if found - for i, line in enumerate(lines): - if pattern.match(line): - lines[i] = f'export {var_name}={value}\n' - updated = True - break - if not updated: - lines.append(f'\nexport {var_name}={value}\n') - logger.info(f"Added in {bashrc_path} with: export {var_name}={value}") - else: - logger.info(f"Updated {bashrc_path} with: export {var_name}={value}") - with open(bashrc_path, "w") as file: - file.writelines(lines) diff --git a/esd/wrapper/__init__.py b/esd/wrapper/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/esd/wrapper/spack_wrapper.py b/esd/wrapper/spack_wrapper.py deleted file mode 100644 index c2f9c116..00000000 --- a/esd/wrapper/spack_wrapper.py +++ /dev/null @@ -1,15 +0,0 @@ -import functools - -from esd.error_handling.exceptions import NoSpackEnvironmentException - - -def check_spack_env(method): - @functools.wraps(method) - def wrapper(self, *args, **kwargs): - if self.spack_env_exists(): - return method(self, *args, **kwargs) # Call the method with 'self' - else: - self.logger.debug('No spack environment defined') - raise NoSpackEnvironmentException('No spack environment defined') - - return wrapper diff --git a/pyproject.toml b/pyproject.toml index abcbe05d..aad18fa5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = ["setuptools>=64", "wheel"] build-backend = "setuptools.build_meta" [project] -name = "esd-tools" +name = "dedal" version = "0.1.0" authors = [ {name = "Eric Müller", email = "mueller@kip.uni-heidelberg.de"}, @@ -19,7 +19,12 @@ dependencies = [ "pytest", "pytest-mock", "pytest-ordering", + "click", + "jsonpickle", ] +[project.scripts] +dedal = "dedal.cli.spack_manager_api:cli" + [tool.setuptools.data-files] -"esd-tools" = ["esd/logger/logging.conf"] \ No newline at end of file +"dedal" = ["dedal/logger/logging.conf"] \ No newline at end of file -- GitLab From 5bee8f1f734a0f9547301de93e43357ccb09c956 Mon Sep 17 00:00:00 2001 From: adrianciu <adrian.ciu@codemart.ro> Date: Thu, 27 Feb 2025 13:26:45 +0200 Subject: [PATCH 25/30] dedal: CLI; tests --- dedal/bll/SpackManager.py | 35 ++++ dedal/bll/cli_utils.py | 23 +++ dedal/cli/SpackManager.py | 0 dedal/cli/spack_manager_api.py | 153 +++++++++++++++ dedal/model/SpackDescriptor.py | 6 +- dedal/spack_factory/SpackOperation.py | 53 ++--- .../spack_from_scratch_test.py | 10 +- .../spack_operation_creator_test.py | 50 +++++ .../unit_tests/spack_manager_api_test.py | 183 ++++++++++++++++++ 9 files changed, 480 insertions(+), 33 deletions(-) create mode 100644 dedal/bll/SpackManager.py create mode 100644 dedal/bll/cli_utils.py delete mode 100644 dedal/cli/SpackManager.py create mode 100644 dedal/cli/spack_manager_api.py create mode 100644 dedal/tests/integration_tests/spack_operation_creator_test.py create mode 100644 dedal/tests/unit_tests/spack_manager_api_test.py diff --git a/dedal/bll/SpackManager.py b/dedal/bll/SpackManager.py new file mode 100644 index 00000000..e5fae221 --- /dev/null +++ b/dedal/bll/SpackManager.py @@ -0,0 +1,35 @@ +import os +from dedal.model.SpackDescriptor import SpackDescriptor +from dedal.spack_factory.SpackOperationCreator import SpackOperationCreator +from dedal.configuration.SpackConfig import SpackConfig + + +class SpackManager: + """ + This class defines the logic used by the CLI + """ + + def __init__(self, spack_config: SpackConfig = None, use_cache=False): + self._spack_config = spack_config + self._use_cache = use_cache + + def _get_spack_operation(self): + return SpackOperationCreator.get_spack_operator(self._spack_config, self._use_cache) + + def install_spack(self, version: str, bashrc_path=os.path.expanduser("~/.bashrc")): + self._get_spack_operation().install_spack(spack_version=f'v{version}', bashrc_path=bashrc_path) + + def add_spack_repo(self, repo: SpackDescriptor): + """ + After additional repo was added, setup_spack_env must be invoked + """ + self._spack_config.add_repo(repo) + + def setup_spack_env(self): + self._get_spack_operation().setup_spack_env() + + def concretize_spack_env(self): + self._get_spack_operation().concretize_spack_env() + + def install_packages(self, jobs: int): + self._get_spack_operation().install_packages(jobs=jobs) diff --git a/dedal/bll/cli_utils.py b/dedal/bll/cli_utils.py new file mode 100644 index 00000000..bfc74ed0 --- /dev/null +++ b/dedal/bll/cli_utils.py @@ -0,0 +1,23 @@ +import jsonpickle +import os + + +def save_config(spack_config_data, config_path: str): + """Save config to JSON file.""" + with open(config_path, "w") as data_file: + data_file.write(jsonpickle.encode(spack_config_data)) + + +def load_config(config_path: str): + """Load config from JSON file.""" + if os.path.exists(config_path): + with open(config_path, "r") as data_file: + data = jsonpickle.decode(data_file.read()) + return data + return {} + + +def clear_config(config_path: str): + """Delete the JSON config file.""" + if os.path.exists(config_path): + os.remove(config_path) diff --git a/dedal/cli/SpackManager.py b/dedal/cli/SpackManager.py deleted file mode 100644 index e69de29b..00000000 diff --git a/dedal/cli/spack_manager_api.py b/dedal/cli/spack_manager_api.py new file mode 100644 index 00000000..78918849 --- /dev/null +++ b/dedal/cli/spack_manager_api.py @@ -0,0 +1,153 @@ +import os +from pathlib import Path +import click +import jsonpickle + +from dedal.bll.SpackManager import SpackManager +from dedal.bll.cli_utils import save_config, load_config +from dedal.configuration.GpgConfig import GpgConfig +from dedal.configuration.SpackConfig import SpackConfig +from dedal.model.SpackDescriptor import SpackDescriptor +from dedal.utils.utils import resolve_path + +SESSION_CONFIG_PATH = os.path.expanduser(f'~/tmp/dedal/dedal_session.json') +os.makedirs(os.path.dirname(SESSION_CONFIG_PATH), exist_ok=True) + + +@click.group() +@click.pass_context +def cli(ctx: click.Context): + config = load_config(SESSION_CONFIG_PATH) + if ctx.invoked_subcommand not in ['set-config', 'install-spack'] and not config: + click.echo('No configuration set. Use `set-config` first.') + ctx.exit(1) + if config: + config['env_path'] = resolve_path(config['env_path']) + env = SpackDescriptor(config['env_name'], config['env_path'], config['env_git_path']) + gpg = GpgConfig(config['gpg_name'], config['gpg_mail']) if config['gpg_name'] and config['gpg_mail'] else None + spack_config = SpackConfig(env=env, repos=None, install_dir=config['install_dir'], + upstream_instance=config['upstream_instance'], + concretization_dir=config['concretization_dir'], + buildcache_dir=config['buildcache_dir'], + system_name=config['system_name'], gpg=gpg, + use_spack_global=config['use_spack_global']) + ctx.obj = SpackManager(spack_config, use_cache=config['use_cache']) + + +@cli.command() +@click.option('--use_cache', is_flag=True, default=False, help='Enables cashing') +@click.option('--use_spack_global', is_flag=True, default=False, help='Uses spack installed globally on the os') +@click.option('--env_name', type=str, default=None, help='Environment name') +@click.option('--env_path', type=str, default=None, help='Environment path to download locally') +@click.option('--env_git_path', type=str, default=None, help='Git path to download the environment') +@click.option('--install_dir', type=str, + help='Install directory for installing spack; spack environments and repositories are stored here') +@click.option('--upstream_instance', type=str, default=None, help='Upstream instance for spack environment') +@click.option('--system_name', type=str, default=None, help='System name; it is used inside the spack environment') +@click.option('--concretization_dir', type=str, default=None, + help='Directory where the concretization caching (spack.lock) will be downloaded') +@click.option('--buildcache_dir', type=str, default=None, + help='Directory where the binary caching is downloaded for the spack packages') +@click.option('--gpg_name', type=str, default=None, help='Gpg name') +@click.option('--gpg_mail', type=str, default=None, help='Gpg mail contact address') +@click.option('--cache_version_concretize', type=str, default='v1', help='Cache version for concretizaion data') +@click.option('--cache_version_build', type=str, default='v1', help='Cache version for binary caches data') +def set_config(use_cache, env_name, env_path, env_git_path, install_dir, upstream_instance, system_name, + concretization_dir, + buildcache_dir, gpg_name, gpg_mail, use_spack_global, cache_version_concretize, cache_version_build): + """Set configuration parameters for tahe session.""" + spack_config_data = { + 'use_cache': use_cache, + 'env_name': env_name, + 'env_path': env_path, + 'env_git_path': env_git_path, + 'install_dir': install_dir, + 'upstream_instance': upstream_instance, + 'system_name': system_name, + 'concretization_dir': Path(concretization_dir) if concretization_dir else None, + 'buildcache_dir': Path(buildcache_dir) if buildcache_dir else None, + 'gpg_name': gpg_name, + 'gpg_mail': gpg_mail, + 'use_spack_global': use_spack_global, + 'repos': [], + 'cache_version_concretize': cache_version_concretize, + 'cache_version_build': cache_version_build, + } + save_config(spack_config_data, SESSION_CONFIG_PATH) + click.echo('Configuration saved.') + + +@click.command() +def show_config(): + """Show the current configuration.""" + config = load_config(SESSION_CONFIG_PATH) + if config: + click.echo(jsonpickle.encode(config, indent=2)) + else: + click.echo('No configuration set. Use `set-config` first.') + + +@cli.command() +@click.option('--spack_version', type=str, default='0.23.0', help='Spack version') +@click.option('--bashrc_path', type=str, default="~/.bashrc", help='Path to .bashrc') +@click.pass_context +def install_spack(ctx: click.Context, spack_version: str, bashrc_path: str): + """Install spack in the install_dir folder""" + bashrc_path = os.path.expanduser(bashrc_path) + if ctx.obj is None: + SpackManager().install_spack(spack_version, bashrc_path) + else: + ctx.obj.install_spack(spack_version, bashrc_path) + + +@cli.command() +@click.option('--repo_name', type=str, required=True, default=None, help='Repository name') +@click.option('--path', type=str, required=True, default=None, help='Repository path to download locally') +@click.option('--git_path', type=str, required=True, default=None, help='Git path to download the repository') +def add_spack_repo(repo_name: str, path: str, git_path: str = None): + """Adds a spack repository to the spack environments. The setup command must be rerun.""" + path = resolve_path(path) + repo = SpackDescriptor(repo_name, path, git_path) + config = load_config(SESSION_CONFIG_PATH) + config['repos'].append(repo) + save_config(config, SESSION_CONFIG_PATH) + click.echo('dedal setup_spack_env must be reran after each repo is added for the environment.') + + +@cli.command() +@click.pass_context +def setup_spack_env(ctx: click.Context): + """Setups a spack environment according to the given configuration.""" + ctx.obj.setup_spack_env() + + +@cli.command() +@click.pass_context +def concretize(ctx: click.Context): + """Spack concretization step""" + ctx.obj.concretize_spack_env() + + +@cli.command() +@click.option('--jobs', type=int, default=2, help='Number of parallel jobs for spack installation') +@click.pass_context +def install_packages(ctx: click.Context, jobs): + """Installs spack packages present in the spack environment defined in configuration""" + ctx.obj.install_packages(jobs=jobs) + + +@click.command() +def clear_config(): + """Clear stored configuration""" + if os.path.exists(SESSION_CONFIG_PATH): + os.remove(SESSION_CONFIG_PATH) + click.echo('Configuration cleared!') + else: + click.echo('No configuration to clear.') + + +cli.add_command(show_config) +cli.add_command(clear_config) + +if __name__ == '__main__': + cli() diff --git a/dedal/model/SpackDescriptor.py b/dedal/model/SpackDescriptor.py index 421c4824..939164a0 100644 --- a/dedal/model/SpackDescriptor.py +++ b/dedal/model/SpackDescriptor.py @@ -7,7 +7,7 @@ class SpackDescriptor: Provides details about the spack environment """ - def __init__(self, env_name: str, path: Path = Path(os.getcwd()).resolve(), git_path: str = None): - self.env_name = env_name - self.path = path if isinstance(path,Path) else Path(path) + def __init__(self, name: str, path: Path = Path(os.getcwd()).resolve(), git_path: str = None): + self.name = name + self.path = path.resolve() if isinstance(path, Path) else Path(path).resolve() self.git_path = git_path diff --git a/dedal/spack_factory/SpackOperation.py b/dedal/spack_factory/SpackOperation.py index 58dcad8b..aebabc0b 100644 --- a/dedal/spack_factory/SpackOperation.py +++ b/dedal/spack_factory/SpackOperation.py @@ -39,24 +39,27 @@ class SpackOperation: self.spack_config.buildcache_dir = spack_config.buildcache_dir if self.spack_config.buildcache_dir: os.makedirs(self.spack_config.buildcache_dir, exist_ok=True) + if self.spack_config.env and spack_config.env.name: + self.env_path: Path = spack_config.env.path / spack_config.env.name + self.spack_command_on_env = f'{self.spack_setup_script} spack env activate -p {self.env_path}' + else: + self.spack_command_on_env = self.spack_setup_script if self.spack_config.env and spack_config.env.path: self.spack_config.env.path = spack_config.env.path self.spack_config.env.path.mkdir(parents=True, exist_ok=True) - self.env_path: Path = spack_config.env.path / spack_config.env.env_name - self.spack_command_on_env = f'{self.spack_setup_script} spack env activate -p {self.env_path}' def create_fetch_spack_environment(self): if self.spack_config.env.git_path: - git_clone_repo(self.spack_config.env.env_name, self.spack_config.env.path / self.spack_config.env.env_name, + git_clone_repo(self.spack_config.env.name, self.spack_config.env.path / self.spack_config.env.name, self.spack_config.env.git_path, logger=self.logger) else: - os.makedirs(self.spack_config.env.path / self.spack_config.env.env_name, exist_ok=True) + os.makedirs(self.spack_config.env.path / self.spack_config.env.name, exist_ok=True) run_command("bash", "-c", f'{self.spack_setup_script} spack env create -d {self.env_path}', check=True, logger=self.logger, - info_msg=f"Created {self.spack_config.env.env_name} spack environment", - exception_msg=f"Failed to create {self.spack_config.env.env_name} spack environment", + info_msg=f"Created {self.spack_config.env.name} spack environment", + exception_msg=f"Failed to create {self.spack_config.env.name} spack environment", exception=BashCommandException) def setup_spack_env(self): @@ -79,13 +82,13 @@ class SpackOperation: self.create_fetch_spack_environment() if self.spack_config.install_dir.exists(): for repo in self.spack_config.repos: - repo_dir = self.spack_config.install_dir / repo.path / repo.env_name - git_clone_repo(repo.env_name, repo_dir, repo.git_path, logger=self.logger) - if not self.spack_repo_exists(repo.env_name): - self.add_spack_repo(repo.path, repo.env_name) - self.logger.debug(f'Added spack repository {repo.env_name}') + repo_dir = self.spack_config.install_dir / repo.path / repo.name + git_clone_repo(repo.name, repo_dir, repo.git_path, logger=self.logger) + if not self.spack_repo_exists(repo.name): + self.add_spack_repo(repo.path, repo.name) + self.logger.debug(f'Added spack repository {repo.name}') else: - self.logger.debug(f'Spack repository {repo.env_name} already added') + self.logger.debug(f'Spack repository {repo.name} already added') def spack_repo_exists(self, repo_name: str) -> bool | None: """Check if the given Spack repository exists.""" @@ -116,7 +119,7 @@ class SpackOperation: self.spack_command_on_env, check=True, capture_output=True, text=True, logger=self.logger, - info_msg=f'Checking if environment {self.spack_config.env.env_name} exists') + info_msg=f'Checking if environment {self.spack_config.env.name} exists') if result is None: return False return True @@ -127,8 +130,8 @@ class SpackOperation: run_command("bash", "-c", f'{self.spack_command_on_env} && spack repo add {repo_path}/{repo_name}', check=True, logger=self.logger, - info_msg=f"Added {repo_name} to spack environment {self.spack_config.env.env_name}", - exception_msg=f"Failed to add {repo_name} to spack environment {self.spack_config.env.env_name}", + info_msg=f"Added {repo_name} to spack environment {self.spack_config.env.name}", + exception_msg=f"Failed to add {repo_name} to spack environment {self.spack_config.env.name}", exception=SpackRepoException) @check_spack_env @@ -137,18 +140,18 @@ class SpackOperation: f'{self.spack_command_on_env} && spack compiler list', check=True, logger=self.logger, capture_output=True, text=True, - info_msg=f"Checking spack environment compiler version for {self.spack_config.env.env_name}", - exception_msg=f"Failed to checking spack environment compiler version for {self.spack_config.env.env_name}", + info_msg=f"Checking spack environment compiler version for {self.spack_config.env.name}", + exception_msg=f"Failed to checking spack environment compiler version for {self.spack_config.env.name}", exception=BashCommandException) # todo add error handling and tests if result.stdout is None: - self.logger.debug('No gcc found for {self.env.env_name}') + self.logger.debug(f'No gcc found for {self.spack_config.env.name}') return None # Find the first occurrence of a GCC compiler using regex match = re.search(r"gcc@([\d\.]+)", result.stdout) gcc_version = match.group(1) - self.logger.debug(f'Found gcc for {self.spack_config.env.env_name}: {gcc_version}') + self.logger.debug(f'Found gcc for {self.spack_config.env.name}: {gcc_version}') return gcc_version def get_spack_installed_version(self): @@ -168,8 +171,8 @@ class SpackOperation: f'{self.spack_command_on_env} && spack concretize {force}', check=True, logger=self.logger, - info_msg=f'Concertization step for {self.spack_config.env.env_name}', - exception_msg=f'Failed the concertization step for {self.spack_config.env.env_name}', + info_msg=f'Concertization step for {self.spack_config.env.name}', + exception_msg=f'Failed the concertization step for {self.spack_config.env.name}', exception=SpackConcertizeException) def create_gpg_keys(self): @@ -178,8 +181,8 @@ class SpackOperation: f'{self.spack_setup_script} spack gpg init && spack gpg create {self.spack_config.gpg.name} {self.spack_config.gpg.mail}', check=True, logger=self.logger, - info_msg=f'Created pgp keys for {self.spack_config.env.env_name}', - exception_msg=f'Failed to create pgp keys mirror {self.spack_config.env.env_name}', + info_msg=f'Created pgp keys for {self.spack_config.env.name}', + exception_msg=f'Failed to create pgp keys mirror {self.spack_config.env.name}', exception=SpackGpgException) else: raise SpackGpgException('No GPG configuration was defined is spack configuration') @@ -225,8 +228,8 @@ class SpackOperation: stderr=subprocess.PIPE, text=True, logger=self.logger, - info_msg=f"Installing spack packages for {self.spack_config.env.env_name}", - exception_msg=f"Error installing spack packages for {self.spack_config.env.env_name}", + info_msg=f"Installing spack packages for {self.spack_config.env.name}", + exception_msg=f"Error installing spack packages for {self.spack_config.env.name}", exception=SpackInstallPackagesException) log_command(install_result, str(Path(os.getcwd()).resolve() / ".generate_cache.log")) return install_result diff --git a/dedal/tests/integration_tests/spack_from_scratch_test.py b/dedal/tests/integration_tests/spack_from_scratch_test.py index 794caef1..7e8d900c 100644 --- a/dedal/tests/integration_tests/spack_from_scratch_test.py +++ b/dedal/tests/integration_tests/spack_from_scratch_test.py @@ -16,7 +16,7 @@ def test_spack_repo_exists_1(tmp_path): spack_operation = SpackOperationCreator.get_spack_operator(config) spack_operation.install_spack() with pytest.raises(NoSpackEnvironmentException): - spack_operation.spack_repo_exists(env.env_name) + spack_operation.spack_repo_exists(env.name) def test_spack_repo_exists_2(tmp_path): @@ -27,7 +27,7 @@ def test_spack_repo_exists_2(tmp_path): assert isinstance(spack_operation, SpackOperation) spack_operation.install_spack() spack_operation.setup_spack_env() - assert spack_operation.spack_repo_exists(env.env_name) == False + assert spack_operation.spack_repo_exists(env.name) == False def test_spack_from_scratch_setup_1(tmp_path): @@ -38,7 +38,7 @@ def test_spack_from_scratch_setup_1(tmp_path): assert isinstance(spack_operation, SpackOperation) spack_operation.install_spack() spack_operation.setup_spack_env() - assert spack_operation.spack_repo_exists(env.env_name) == False + assert spack_operation.spack_repo_exists(env.name) == False def test_spack_from_scratch_setup_2(tmp_path): @@ -52,7 +52,7 @@ def test_spack_from_scratch_setup_2(tmp_path): assert isinstance(spack_operation, SpackOperation) spack_operation.install_spack() spack_operation.setup_spack_env() - assert spack_operation.spack_repo_exists(env.env_name) == True + assert spack_operation.spack_repo_exists(env.name) == True def test_spack_from_scratch_setup_3(tmp_path): @@ -88,7 +88,7 @@ def test_spack_not_a_valid_repo(): spack_operation = SpackOperationCreator.get_spack_operator(config) assert isinstance(spack_operation, SpackOperation) with pytest.raises(BashCommandException): - spack_operation.add_spack_repo(repo.path, repo.env_name) + spack_operation.add_spack_repo(repo.path, repo.name) @pytest.mark.skip( diff --git a/dedal/tests/integration_tests/spack_operation_creator_test.py b/dedal/tests/integration_tests/spack_operation_creator_test.py new file mode 100644 index 00000000..226184b0 --- /dev/null +++ b/dedal/tests/integration_tests/spack_operation_creator_test.py @@ -0,0 +1,50 @@ +from dedal.spack_factory.SpackOperationCreateCache import SpackOperationCreateCache + +from dedal.configuration.SpackConfig import SpackConfig +from dedal.model.SpackDescriptor import SpackDescriptor +from dedal.spack_factory.SpackOperation import SpackOperation +from dedal.spack_factory.SpackOperationCreator import SpackOperationCreator +from dedal.spack_factory.SpackOperationUseCache import SpackOperationUseCache +from dedal.tests.testing_variables import ebrains_spack_builds_git, test_spack_env_git + + +def test_spack_creator_scratch_1(tmp_path): + install_dir = tmp_path + env = SpackDescriptor('test-spack-env', install_dir, test_spack_env_git) + repo = SpackDescriptor('ebrains-spack-builds', install_dir, ebrains_spack_builds_git) + spack_config = SpackConfig(env, install_dir=install_dir) + spack_config.add_repo(repo) + spack_operation = SpackOperationCreator.get_spack_operator(spack_config) + assert isinstance(spack_operation, SpackOperation) + + +def test_spack_creator_scratch_2(tmp_path): + spack_config = None + spack_operation = SpackOperationCreator.get_spack_operator(spack_config) + assert isinstance(spack_operation, SpackOperation) + + +def test_spack_creator_scratch_3(): + spack_config = SpackConfig() + spack_operation = SpackOperationCreator.get_spack_operator(spack_config) + assert isinstance(spack_operation, SpackOperation) + + +def test_spack_creator_create_cache(tmp_path): + install_dir = tmp_path + env = SpackDescriptor('test-spack-env', install_dir, test_spack_env_git) + repo = SpackDescriptor('ebrains-spack-builds', install_dir, ebrains_spack_builds_git) + spack_config = SpackConfig(env, install_dir=install_dir, concretization_dir=install_dir, buildcache_dir=install_dir) + spack_config.add_repo(repo) + spack_operation = SpackOperationCreator.get_spack_operator(spack_config) + assert isinstance(spack_operation, SpackOperationCreateCache) + + +def test_spack_creator_use_cache(tmp_path): + install_dir = tmp_path + env = SpackDescriptor('test-spack-env', install_dir, test_spack_env_git) + repo = SpackDescriptor('ebrains-spack-builds', install_dir, ebrains_spack_builds_git) + spack_config = SpackConfig(env, install_dir=install_dir, concretization_dir=install_dir, buildcache_dir=install_dir) + spack_config.add_repo(repo) + spack_operation = SpackOperationCreator.get_spack_operator(spack_config, use_cache=True) + assert isinstance(spack_operation, SpackOperationUseCache) diff --git a/dedal/tests/unit_tests/spack_manager_api_test.py b/dedal/tests/unit_tests/spack_manager_api_test.py new file mode 100644 index 00000000..5d32a56d --- /dev/null +++ b/dedal/tests/unit_tests/spack_manager_api_test.py @@ -0,0 +1,183 @@ +import os + +import pytest +from unittest.mock import patch, MagicMock +from click.testing import CliRunner +from dedal.cli.spack_manager_api import show_config, clear_config, install_spack, add_spack_repo, install_packages, \ + setup_spack_env, concretize, set_config +from dedal.model.SpackDescriptor import SpackDescriptor + + +@pytest.fixture +def runner(): + return CliRunner() + + +@pytest.fixture +def mocked_session_path(): + return '/mocked/tmp/session.json' + + +@pytest.fixture +def mock_spack_manager(): + mock_spack_manager = MagicMock() + mock_spack_manager.install_spack = MagicMock() + mock_spack_manager.add_spack_repo = MagicMock() + mock_spack_manager.setup_spack_env = MagicMock() + mock_spack_manager.concretize_spack_env = MagicMock() + mock_spack_manager.install_packages = MagicMock() + return mock_spack_manager + + +@pytest.fixture +def mock_load_config(): + with patch('dedal.cli.spack_manager_api.load_config') as mock_load: + yield mock_load + + +@pytest.fixture +def mock_save_config(): + with patch('dedal.cli.spack_manager_api.save_config') as mock_save: + yield mock_save + + +@pytest.fixture +def mock_clear_config(): + with patch('dedal.cli.spack_manager_api.clear_config') as mock_clear: + yield mock_clear + + +def test_show_config_no_config(runner, mock_load_config): + mock_load_config.return_value = None + result = runner.invoke(show_config) + assert 'No configuration set. Use `set-config` first.' in result.output + + +def test_show_config_with_config(runner, mock_load_config): + """Test the show_config command when config is present.""" + mock_load_config.return_value = {"key": "value"} + result = runner.invoke(show_config) + assert result.exit_code == 0 + assert '"key": "value"' in result.output + + +def test_clear_config(runner, mock_clear_config): + """Test the clear_config command.""" + with patch('os.path.exists', return_value=True), patch('os.remove') as mock_remove: + result = runner.invoke(clear_config) + assert 'Configuration cleared!' in result.output + mock_remove.assert_called_once() + + +def test_install_spack_no_context_1(runner, mock_spack_manager): + """Test install_spack with no context, using SpackManager.""" + with patch('dedal.cli.spack_manager_api.SpackManager', return_value=mock_spack_manager): + result = runner.invoke(install_spack, ['--spack_version', '0.24.0']) + mock_spack_manager.install_spack.assert_called_once_with('0.24.0', os.path.expanduser("~/.bashrc")) + assert result.exit_code == 0 + + +def test_install_spack_no_context_2(runner, mock_spack_manager): + """Test install_spack with no context, using SpackManager and the default value for spack_version.""" + with patch('dedal.cli.spack_manager_api.SpackManager', return_value=mock_spack_manager): + result = runner.invoke(install_spack) + mock_spack_manager.install_spack.assert_called_once_with('0.23.0', os.path.expanduser("~/.bashrc")) + assert result.exit_code == 0 + + +def test_install_spack_with_mocked_context_1(runner, mock_spack_manager): + """Test install_spack with a mocked context, using ctx.obj as SpackManager.""" + result = runner.invoke(install_spack, ['--spack_version', '0.24.0', '--bashrc_path', '/home/.bahsrc'], obj=mock_spack_manager) + mock_spack_manager.install_spack.assert_called_once_with('0.24.0', '/home/.bahsrc') + assert result.exit_code == 0 + + +def test_install_spack_with_mocked_context_2(runner, mock_spack_manager): + """Test install_spack with a mocked context, using ctx.obj as SpackManager and the default value for spack_version.""" + result = runner.invoke(install_spack, obj=mock_spack_manager) + mock_spack_manager.install_spack.assert_called_once_with('0.23.0', os.path.expanduser("~/.bashrc")) + assert result.exit_code == 0 + + +def test_setup_spack_env(runner, mock_spack_manager): + """Test setup_spack_env with a mocked context, using ctx.obj as SpackManager.""" + result = runner.invoke(setup_spack_env, obj=mock_spack_manager) + mock_spack_manager.setup_spack_env.assert_called_once_with() + assert result.exit_code == 0 + + +def test_concretize(runner, mock_spack_manager): + """Test install_spack with a mocked context, using ctx.obj as SpackManager.""" + result = runner.invoke(concretize, obj=mock_spack_manager) + mock_spack_manager.concretize_spack_env.assert_called_once_with() + assert result.exit_code == 0 + + +def test_install_packages_1(runner, mock_spack_manager): + """Test install_packages with a mocked context, using ctx.obj as SpackManager.""" + result = runner.invoke(install_packages, obj=mock_spack_manager) + mock_spack_manager.install_packages.assert_called_once_with(jobs=2) + assert result.exit_code == 0 + + +def test_install_packages(runner, mock_spack_manager): + """Test install_packages with a mocked context, using ctx.obj as SpackManager.""" + result = runner.invoke(install_packages, ['--jobs', 3], obj=mock_spack_manager) + mock_spack_manager.install_packages.assert_called_once_with(jobs=3) + assert result.exit_code == 0 + + +@patch('dedal.cli.spack_manager_api.resolve_path') +@patch('dedal.cli.spack_manager_api.SpackDescriptor') +def test_add_spack_repo(mock_spack_descriptor, mock_resolve_path, mock_load_config, mock_save_config, + mocked_session_path, runner): + """Test adding a spack repository with mocks.""" + expected_config = {'repos': [SpackDescriptor(name='test-repo')]} + repo_name = 'test-repo' + path = '/path' + git_path = 'https://example.com/repo.git' + mock_resolve_path.return_value = '/resolved/path' + mock_load_config.return_value = expected_config + mock_repo_instance = MagicMock() + mock_spack_descriptor.return_value = mock_repo_instance + + with patch('dedal.cli.spack_manager_api.SESSION_CONFIG_PATH', mocked_session_path): + result = runner.invoke(add_spack_repo, ['--repo_name', repo_name, '--path', path, '--git_path', git_path]) + + assert result.exit_code == 0 + assert 'dedal setup_spack_env must be reran after each repo is added' in result.output + mock_resolve_path.assert_called_once_with(path) + mock_spack_descriptor.assert_called_once_with(repo_name, '/resolved/path', git_path) + assert mock_repo_instance in expected_config['repos'] + mock_save_config.assert_called_once_with(expected_config, mocked_session_path) + + +def test_set_config(runner, mock_save_config, mocked_session_path): + """Test set_config.""" + with patch('dedal.cli.spack_manager_api.SESSION_CONFIG_PATH', mocked_session_path): + result = runner.invoke(set_config, ['--env_name', 'test', '--system_name', 'sys']) + + expected_config = { + 'use_cache': False, + 'env_name': 'test', + 'env_path': None, + 'env_git_path': None, + 'install_dir': None, + 'upstream_instance': None, + 'system_name': 'sys', + 'concretization_dir': None, + 'buildcache_dir': None, + 'gpg_name': None, + 'gpg_mail': None, + 'use_spack_global': False, + 'repos': [], + 'cache_version_concretize': 'v1', + 'cache_version_build': 'v1', + } + + mock_save_config.assert_called_once() + saved_config, saved_path = mock_save_config.call_args[0] + assert saved_path == mocked_session_path + assert saved_config == expected_config + assert result.exit_code == 0 + assert 'Configuration saved.' in result.output -- GitLab From 05ac60e3f82033b8fcd0ff412db082aa17a300d3 Mon Sep 17 00:00:00 2001 From: adrianciu <adrian.ciu@codemart.ro> Date: Mon, 3 Mar 2025 12:04:32 +0200 Subject: [PATCH 26/30] dev: bootstrap script and README update --- README.md | 101 ++++++++++++++++-- dedal/build_cache/BuildCacheManager.py | 4 +- dedal/cli/spack_manager_api.py | 12 +-- dedal/docs/resources/dedal_UML.png | Bin 0 -> 77393 bytes .../SpackOperationCreateCache.py | 10 +- dedal/spack_factory/SpackOperationUseCache.py | 4 +- dedal/utils/bootstrap.sh | 9 +- dedal/utils/utils.py | 1 + 8 files changed, 119 insertions(+), 22 deletions(-) create mode 100644 dedal/docs/resources/dedal_UML.png diff --git a/README.md b/README.md index 5dc3fcc1..733d8ff6 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,13 @@ # Dedal -This repository provides functionalities to easily ```managed spack environments``` and ```helpers for the container image build flow```. +This repository provides functionalities to easily ```managed spack environments``` and +```helpers for the container image build flow```. **Setting up the needed environment variables** - The ````<checkout path>\dedal\.env```` file contains the environment variables required for OCI registry used for caching. - Ensure that you edit the ````<checkout path>\dedal\.env```` file to match your environment. - The following provides an explanation of the various environment variables: - +The ````<checkout path>\dedal\.env```` file contains the environment variables required for OCI registry used for +caching. +Ensure that you edit the ````<checkout path>\dedal\.env```` file to match your environment. +The following provides an explanation of the various environment variables: # OCI Registry Configuration Sample for concretization caches # ============================= @@ -41,13 +42,101 @@ This repository provides functionalities to easily ```managed spack environments # The password used for authentication with the Docker registry. BUILDCACHE_OCI_HOST="###ACCESS_TOKEN###" -For both concretization and binary caches, the cache version can be changed via the attributes ```cache_version_concretize``` and ```cache_version_build```. +For both concretization and binary caches, the cache version can be changed via the attributes +```cache_version_concretize``` and ```cache_version_build```. The default values are ```v1```. Before using this library, the following tool must be installed on Linux distribution: + ```` apt install -y bzip2 ca-certificates g++ gcc gfortran git gzip lsb-release patch python3 python3-pip tar unzip xz-utils zstd ```` + ```` python3 -m pip install --upgrade pip setuptools wheel ```` + +# Dedal library installation + +```sh + pip install dedal +``` + +# Dedal CLI Commands + +The following commands are available in this CLI tool. You can view detailed explanations by using the `--help` option +with any command. + +### 1. `dedal install-spack` + +Install spack in the install_dir folder. + +**Options:** + +- `--spack_version <TEXT>` : Specifies the Spack version to be installed (default: v0.23.0). +- `--bashrc_path <TEXT>` : Defines the path to .bashrc. + +### 2. `dedal set-config` + +Sets configuration parameters for the session. + +**Options:** + +- `--use_cache` Enables cashing +- `--use_spack_global` Uses spack installed globally on the os +- `--env_name <TEXT>` Environment name +- `--env_path <TEXT>` Environment path to download locally +- `--env_git_path <TEXT>` Git path to download the environment +- `--install_dir <TEXT>` Install directory for installing spack; + spack environments and repositories are + stored here +- `--upstream_instance <TEXT>` Upstream instance for spack environment +- `--system_name <TEXT>` System name; it is used inside the spack + environment +- `--concretization_dir <TEXT>` Directory where the concretization caching + (spack.lock) will be downloaded +- `--buildcache_dir <TEXT>` Directory where the binary caching is + downloaded for the spack packages +- `--gpg_name <TEXT>` Gpg name +- `--gpg_mail <TEXT>` Gpg mail contact address +- `--cache_version_concretize <TEXT>` + Cache version for concretizaion data +- `--cache_version_build <TEXT>` Cache version for binary caches data + +### 3. `dedal show-config` + +Show the current configuration. + +### 4. `dedal clear-config` + +Clears stored configuration + +### 5. `dedal add-spack-repo` + +Adds a spack repository to the spack environments. + +**Options:** + +- `--repo_name <TEXT>` Repository name [required] +- `--path <TEXT>` Repository path to download locally [required] +- `--git_path <TEXT>` Git path to download the repository [required] + +### 6. `dedal setup-spack-env` + +Setups a spack environment according to the given configuration. + +### 7. `dedal concretize` + +Spack concretization step. + +### 9. `dedal install-packages` + +Installs spack packages present in the spack environment defined in configuration. + +**Options:** + +- `--jobs <INTEGER>` Number of parallel jobs for spack installation + +# Dedal's UML diagram + + \ No newline at end of file diff --git a/dedal/build_cache/BuildCacheManager.py b/dedal/build_cache/BuildCacheManager.py index 55fa10cb..ba1f62a3 100644 --- a/dedal/build_cache/BuildCacheManager.py +++ b/dedal/build_cache/BuildCacheManager.py @@ -1,4 +1,6 @@ import os +import time + import oras.client from pathlib import Path @@ -46,7 +48,7 @@ class BuildCacheManager(BuildCacheManagerInterface): rel_path = str(sub_path.relative_to(build_cache_path)).replace(str(sub_path.name), "") target = f"{self._registry_host}/{self._registry_project}/{self.cache_version}:{str(sub_path.name)}" try: - self._logger.info(f"Pushing folder '{sub_path}' to ORAS target '{target}' ...") + self._logger.info(f"Pushing file '{sub_path}' to ORAS target '{target}' ...") self._client.push( files=[str(sub_path)], target=target, diff --git a/dedal/cli/spack_manager_api.py b/dedal/cli/spack_manager_api.py index 78918849..497bce91 100644 --- a/dedal/cli/spack_manager_api.py +++ b/dedal/cli/spack_manager_api.py @@ -55,7 +55,7 @@ def cli(ctx: click.Context): def set_config(use_cache, env_name, env_path, env_git_path, install_dir, upstream_instance, system_name, concretization_dir, buildcache_dir, gpg_name, gpg_mail, use_spack_global, cache_version_concretize, cache_version_build): - """Set configuration parameters for tahe session.""" + """Sets configuration parameters for the session.""" spack_config_data = { 'use_cache': use_cache, 'env_name': env_name, @@ -88,8 +88,8 @@ def show_config(): @cli.command() -@click.option('--spack_version', type=str, default='0.23.0', help='Spack version') -@click.option('--bashrc_path', type=str, default="~/.bashrc", help='Path to .bashrc') +@click.option('--spack_version', type=str, default='0.23.0', help='Specifies the Spack version to be installed (default: v0.23.0).') +@click.option('--bashrc_path', type=str, default="~/.bashrc", help='Defines the path to .bashrc.') @click.pass_context def install_spack(ctx: click.Context, spack_version: str, bashrc_path: str): """Install spack in the install_dir folder""" @@ -124,7 +124,7 @@ def setup_spack_env(ctx: click.Context): @cli.command() @click.pass_context def concretize(ctx: click.Context): - """Spack concretization step""" + """Spack concretization step.""" ctx.obj.concretize_spack_env() @@ -132,13 +132,13 @@ def concretize(ctx: click.Context): @click.option('--jobs', type=int, default=2, help='Number of parallel jobs for spack installation') @click.pass_context def install_packages(ctx: click.Context, jobs): - """Installs spack packages present in the spack environment defined in configuration""" + """Installs spack packages present in the spack environment defined in configuration.""" ctx.obj.install_packages(jobs=jobs) @click.command() def clear_config(): - """Clear stored configuration""" + """Clears stored configuration.""" if os.path.exists(SESSION_CONFIG_PATH): os.remove(SESSION_CONFIG_PATH) click.echo('Configuration cleared!') diff --git a/dedal/docs/resources/dedal_UML.png b/dedal/docs/resources/dedal_UML.png new file mode 100644 index 0000000000000000000000000000000000000000..430554abd5420474a5f2c3681871d491faf5976d GIT binary patch literal 77393 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 O00K`}KbLh*2~7Za<_?<x literal 0 HcmV?d00001 diff --git a/dedal/spack_factory/SpackOperationCreateCache.py b/dedal/spack_factory/SpackOperationCreateCache.py index f04eae3a..41cfc845 100644 --- a/dedal/spack_factory/SpackOperationCreateCache.py +++ b/dedal/spack_factory/SpackOperationCreateCache.py @@ -31,10 +31,10 @@ class SpackOperationCreateCache(SpackOperation): @check_spack_env def concretize_spack_env(self): super().concretize_spack_env(force=True) - dependency_path = self.spack_config.env.path / self.spack_config.env.env_name / 'spack.lock' + dependency_path = self.spack_config.env.path / self.spack_config.env.name / 'spack.lock' copy_file(dependency_path, self.spack_config.concretization_dir, logger=self.logger) self.cache_dependency.upload(self.spack_config.concretization_dir) - self.logger.info(f'Created new spack concretization for create cache: {self.spack_config.env.env_name}') + self.logger.info(f'Created new spack concretization for create cache: {self.spack_config.env.name}') @check_spack_env def install_packages(self, jobs: int = 2, debug=False): @@ -44,8 +44,8 @@ class SpackOperationCreateCache(SpackOperation): self.create_gpg_keys() self.add_mirror('local_cache', self.spack_config.buildcache_dir, signed=signed, autopush=signed, global_mirror=False) - self.logger.info(f'Added mirror for {self.spack_config.env.env_name}') + self.logger.info(f'Added mirror for {self.spack_config.env.name}') super().install_packages(jobs=jobs, signed=signed, debug=debug, fresh=True) - self.logger.info(f'Installed spack packages for {self.spack_config.env.env_name}') + self.logger.info(f'Installed spack packages for {self.spack_config.env.name}') self.build_cache.upload(self.spack_config.buildcache_dir) - self.logger.info(f'Pushed spack packages for {self.spack_config.env.env_name}') + self.logger.info(f'Pushed spack packages for {self.spack_config.env.name}') diff --git a/dedal/spack_factory/SpackOperationUseCache.py b/dedal/spack_factory/SpackOperationUseCache.py index cb2b3ac8..2bb6f76a 100644 --- a/dedal/spack_factory/SpackOperationUseCache.py +++ b/dedal/spack_factory/SpackOperationUseCache.py @@ -60,8 +60,8 @@ class SpackOperationUseCache(SpackOperation): stderr=subprocess.PIPE, text=True, logger=self.logger, - info_msg=f"Installing spack packages for {self.spack_config.env.env_name}", - exception_msg=f"Error installing spack packages for {self.spack_config.env.env_name}", + info_msg=f"Installing spack packages for {self.spack_config.env.name}", + exception_msg=f"Error installing spack packages for {self.spack_config.env.name}", exception=SpackInstallPackagesException) log_command(install_result, str(Path(os.getcwd()).resolve() / ".generate_cache.log")) return install_result diff --git a/dedal/utils/bootstrap.sh b/dedal/utils/bootstrap.sh index 9b7d0131..d103e440 100644 --- a/dedal/utils/bootstrap.sh +++ b/dedal/utils/bootstrap.sh @@ -1,6 +1,11 @@ -# Minimal prerequisites for installing the esd_library +# Minimal prerequisites for installing the dedal library # pip must be installed on the OS echo "Bootstrapping..." +set -euo pipefail +shopt -s inherit_errexit 2>/dev/null +export DEBIAN_FRONTEND=noninteractive apt update -apt install -y bzip2 ca-certificates g++ gcc gfortran git gzip lsb-release patch python3 python3-pip tar unzip xz-utils zstd +apt install -o DPkg::Options::=--force-confold -y -q --reinstall \ + bzip2 ca-certificates g++ gcc make gfortran git gzip lsb-release \ + patch python3 python3-pip tar unzip xz-utils zstd gnupg2 vim curl rsync python3 -m pip install --upgrade pip setuptools wheel diff --git a/dedal/utils/utils.py b/dedal/utils/utils.py index 2f7c4847..f7fe6620 100644 --- a/dedal/utils/utils.py +++ b/dedal/utils/utils.py @@ -97,6 +97,7 @@ def copy_to_tmp(file_path: Path) -> Path: def set_bashrc_variable(var_name: str, value: str, bashrc_path: str = os.path.expanduser("~/.bashrc"), logger: logging = logging.getLogger(__name__)): """Update or add an environment variable in ~/.bashrc.""" + value = value.replace("$", r"\$") with open(bashrc_path, "r") as file: lines = file.readlines() pattern = re.compile(rf'^\s*export\s+{var_name}=.*$') -- GitLab From 11e0dae36c16cfc1d8d58822a7a03cf43c69d933 Mon Sep 17 00:00:00 2001 From: Jithu Murugan <j.murugan@fz-juelich.de> Date: Mon, 10 Mar 2025 13:18:27 +0100 Subject: [PATCH 27/30] feat(spack_operation): implement setup_spack_env functionality --- .gitlab-ci.yml | 55 +++- MANIFEST.ini | 3 + README.md | 4 +- dedal/build_cache/BuildCacheManager.py | 50 +++- dedal/commands/__init__.py | 8 + dedal/commands/bash_command_executor.py | 100 ++++++++ dedal/commands/command.py | 29 +++ dedal/commands/command_enum.py | 37 +++ dedal/commands/command_registry.py | 59 +++++ dedal/commands/command_runner.py | 207 +++++++++++++++ dedal/commands/command_sequence.py | 54 ++++ dedal/commands/command_sequence_builder.py | 71 ++++++ dedal/commands/command_sequence_factory.py | 46 ++++ dedal/commands/generic_shell_command.py | 47 ++++ dedal/commands/preconfigured_command_enum.py | 28 +++ dedal/commands/shell_command_factory.py | 33 +++ .../spack_command_sequence_factory.py | 211 ++++++++++++++++ dedal/spack_factory/SpackOperation.py | 130 ++++++++-- .../SpackOperationCreateCache.py | 5 +- dedal/spack_factory/SpackOperationUseCache.py | 33 ++- dedal/tests/spack_from_scratch_test.py | 204 +++++++++++++++ dedal/tests/spack_install_test.py | 12 + .../unit_tests/test_bash_command_executor.py | 236 ++++++++++++++++++ .../unit_tests/test_build_cache_manager.py | 161 ++++++++++++ dedal/tests/unit_tests/test_command.py | 45 ++++ dedal/tests/unit_tests/test_command_enum.py | 38 +++ dedal/tests/unit_tests/test_command_runner.py | 125 ++++++++++ .../tests/unit_tests/test_command_sequence.py | 108 ++++++++ .../test_command_sequence_builder.py | 95 +++++++ .../test_command_sequence_factory.py | 49 ++++ .../unit_tests/test_generic_shell_command.py | 63 +++++ .../test_preconfigured_command_enum.py | 37 +++ .../unit_tests/test_shell_command_factory.py | 61 +++++ .../test_spack_command_sequence_factory.py | 159 ++++++++++++ .../tests/unit_tests/test_spack_operation.py | 209 ++++++++++++++++ .../test_spack_operation_use_cache.py | 89 +++++++ dedal/utils/bootstrap.sh | 2 +- pyproject.toml | 16 +- 38 files changed, 2871 insertions(+), 48 deletions(-) create mode 100644 MANIFEST.ini create mode 100644 dedal/commands/__init__.py create mode 100644 dedal/commands/bash_command_executor.py create mode 100644 dedal/commands/command.py create mode 100644 dedal/commands/command_enum.py create mode 100644 dedal/commands/command_registry.py create mode 100644 dedal/commands/command_runner.py create mode 100644 dedal/commands/command_sequence.py create mode 100644 dedal/commands/command_sequence_builder.py create mode 100644 dedal/commands/command_sequence_factory.py create mode 100644 dedal/commands/generic_shell_command.py create mode 100644 dedal/commands/preconfigured_command_enum.py create mode 100644 dedal/commands/shell_command_factory.py create mode 100644 dedal/commands/spack_command_sequence_factory.py create mode 100644 dedal/tests/spack_from_scratch_test.py create mode 100644 dedal/tests/spack_install_test.py create mode 100644 dedal/tests/unit_tests/test_bash_command_executor.py create mode 100644 dedal/tests/unit_tests/test_build_cache_manager.py create mode 100644 dedal/tests/unit_tests/test_command.py create mode 100644 dedal/tests/unit_tests/test_command_enum.py create mode 100644 dedal/tests/unit_tests/test_command_runner.py create mode 100644 dedal/tests/unit_tests/test_command_sequence.py create mode 100644 dedal/tests/unit_tests/test_command_sequence_builder.py create mode 100644 dedal/tests/unit_tests/test_command_sequence_factory.py create mode 100644 dedal/tests/unit_tests/test_generic_shell_command.py create mode 100644 dedal/tests/unit_tests/test_preconfigured_command_enum.py create mode 100644 dedal/tests/unit_tests/test_shell_command_factory.py create mode 100644 dedal/tests/unit_tests/test_spack_command_sequence_factory.py create mode 100644 dedal/tests/unit_tests/test_spack_operation.py create mode 100644 dedal/tests/unit_tests/test_spack_operation_use_cache.py diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 4f15b9ab..ab31bc6b 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,10 +1,16 @@ stages: - test - build + - coverage_report variables: BUILD_ENV_DOCKER_IMAGE: docker-registry.ebrains.eu/esd/tmp:latest +default: + before_script: + - chmod +x dedal/utils/bootstrap.sh + - ./dedal/utils/bootstrap.sh + - pip install -e .[test] build-wheel: stage: build @@ -21,17 +27,34 @@ build-wheel: - dist/*.tar.gz expire_in: 1 week +unit_tests: + stage: test + tags: + - docker-runner + image: ubuntu:22.04 + script: + - coverage run -m pytest -s --tb=short --junitxml=test-results.xml ./dedal/tests/unit_tests + - mv .coverage .coverage.unit # Rename to avoid overwriting + artifacts: + when: always + reports: + junit: test-results.xml + paths: + - test-results.xml + - .dedal.log + - .generate_cache.log + - .coverage.unit + expire_in: 1 week -testing-pytest: +integration_tests: stage: test tags: - docker-runner image: ubuntu:22.04 script: - - chmod +x dedal/utils/bootstrap.sh - - ./dedal/utils/bootstrap.sh - - pip install . - - pytest ./dedal/tests/ -s --junitxml=test-results.xml + - coverage run -m pytest -s --tb=short --junitxml=test-results.xml ./dedal/tests/integration_tests + - mv .coverage .coverage.integration # Rename to avoid overwriting + needs: ["unit_tests"] artifacts: when: always reports: @@ -40,5 +63,27 @@ testing-pytest: - test-results.xml - .dedal.log - .generate_cache.log + - .coverage.integration + expire_in: 1 week + +merge_coverage: + stage: coverage_report + tags: + - docker-runner + image: ubuntu:22.04 + script: + - coverage combine .coverage.unit .coverage.integration + - coverage report + - coverage xml -o coverage.xml + - coverage html -d coverage_html + artifacts: + reports: + coverage_report: + coverage_format: cobertura + path: coverage.xml + paths: + - coverage.xml + - coverage_html expire_in: 1 week + coverage: '/TOTAL.*?(\d+\%)$/' diff --git a/MANIFEST.ini b/MANIFEST.ini new file mode 100644 index 00000000..e62be467 --- /dev/null +++ b/MANIFEST.ini @@ -0,0 +1,3 @@ + +include README.md +recursive-include yashchiki/dedal *.* \ No newline at end of file diff --git a/README.md b/README.md index 733d8ff6..55080aab 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ # Dedal + This repository provides functionalities to easily ```managed spack environments``` and ```helpers for the container image build flow```. @@ -139,4 +140,5 @@ Installs spack packages present in the spack environment defined in configuratio # Dedal's UML diagram - \ No newline at end of file + + diff --git a/dedal/build_cache/BuildCacheManager.py b/dedal/build_cache/BuildCacheManager.py index ba1f62a3..62cb9af1 100644 --- a/dedal/build_cache/BuildCacheManager.py +++ b/dedal/build_cache/BuildCacheManager.py @@ -1,12 +1,12 @@ +import glob import os -import time +from os.path import join +from pathlib import Path import oras.client -from pathlib import Path from dedal.build_cache.BuildCacheManagerInterface import BuildCacheManagerInterface from dedal.logger.logger_builder import get_logger -from dedal.utils.utils import clean_up class BuildCacheManager(BuildCacheManagerInterface): @@ -113,7 +113,49 @@ class BuildCacheManager(BuildCacheManagerInterface): if tags is not None: try: self._client.delete_tags(self._oci_registry_path, tags) - self._logger.info(f"Successfully deleted all artifacts form OCI registry.") + self._logger.info("Successfully deleted all artifacts form OCI registry.") except RuntimeError as e: self._logger.error( f"Failed to delete artifacts: {e}") + + def __log_warning_if_needed(self, warn_message: str, items: list[str]) -> None: + """Logs a warning message if the number of items is greater than 1. (Private function) + + This method logs a warning message using the provided message and items if the list of items has more than one element. + + Args: + warn_message (str): The warning message to log. + items (list[str]): The list of items to include in the log message. + """ + if len(items) > 1: + self._logger.warning(warn_message, items, items[0]) + + def get_public_key_from_cache(self, build_cache_dir: str | None) -> str | None: + """Retrieves the public key from the build cache. + + This method searches for the public key within the specified build cache directory. + + Args: + build_cache_dir (str | None): The path to the build cache directory. + + Returns: + str | None: The path to the public key file if found, otherwise None. + """ + + if not build_cache_dir or not os.path.exists(build_cache_dir): + self._logger.warning("Build cache directory does not exist!") + return None + pgp_folders = glob.glob(f"{build_cache_dir}/**/_pgp", recursive=True) + if not pgp_folders: + self._logger.warning("No _pgp folder found in the build cache!") + return None + self.__log_warning_if_needed( + "More than one PGP folders found in the build cache: %s, using the first one in the list: %s", pgp_folders) + pgp_folder = pgp_folders[0] + key_files = glob.glob(join(pgp_folder, "**")) + if not key_files: + self._logger.warning("No PGP key files found in the build cache!") + return None + self.__log_warning_if_needed( + "More than one PGP key files found in the build cache: %s, using the first one in the list: %s", key_files) + return key_files[0] diff --git a/dedal/commands/__init__.py b/dedal/commands/__init__.py new file mode 100644 index 00000000..ea9c384e --- /dev/null +++ b/dedal/commands/__init__.py @@ -0,0 +1,8 @@ +# Copyright (c) 2025 +# License Information: To be decided! +# +# File: __init__.py +# Description: Brief description of the file. +# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> +# Created on: 2025-02-17 + diff --git a/dedal/commands/bash_command_executor.py b/dedal/commands/bash_command_executor.py new file mode 100644 index 00000000..aef9c576 --- /dev/null +++ b/dedal/commands/bash_command_executor.py @@ -0,0 +1,100 @@ +""" Bash Command Executor module. """ +# Copyright (c) 2025 +# License Information: To be decided! +# +# File: bash_command_executor.py +# Description: Brief description of the file. +# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> +# Created on: 2025-02-17 +import logging +import subprocess +from logging import Logger +from os import name as os_name + +from dedal.commands.command import Command +from dedal.commands.command_sequence import CommandSequence + + +class BashCommandExecutor: + """Executes commands in a Bash shell. + + Manages a sequence of commands and executes them in a single Bash session, + handling potential errors during execution. + """ + + def __init__(self) -> None: + self.logger: Logger = logging.getLogger(__name__) + self.sequence: CommandSequence = CommandSequence() + command_map: dict[str, list[str]] = { + "nt": ["wsl", "bash", "-c"], # Works only if wsl is installed on windows + "posix": ["bash", "-c"], + } + self.bash_command: list[str] = command_map.get(os_name, ["undefined"]) + + def add_command(self, command: Command) -> None: + """Adds a command to the sequence. + + Appends the given command to the internal sequence of commands to be executed. + + Args: + command (Command): The command to add. + + Raises: + ValueError: If the command is not an instance of the `Command` class. + """ + if not isinstance(command, Command): + raise ValueError("Invalid command type. Use Command.") + self.logger.info("Adding command to the sequence: %s", command) + self.sequence.add_command(command) + + def execute(self) -> tuple[str | None, str | None]: + """Executes all commands in a single Bash session. + + Runs the accumulated commands in a Bash shell and returns the output. + Handles various potential errors during execution. The execution is time + + Returns (tuple[str | None, str | None]): + A tuple containing the output and error message (if any). + output will be None if an error occurred, and the error message will + contain details about the error. + + Raises: + ValueError: If no commands have been added to the sequence. + """ + if not self.sequence.commands: + raise ValueError("No commands to execute.") + try: + result = subprocess.run( + [*self.bash_command, self.sequence.execute()], + capture_output=True, + text=True, + check=True, + timeout=172800 # Given a default timeout of 48 hours + ) + self.logger.info("Successfully executed command sequence, output: %s", result.stdout) + return result.stdout, None + except FileNotFoundError as e: + error = f"Error: Bash Command: {self.bash_command} not found: {e}" + except subprocess.CalledProcessError as e: + error = (f"Error: Command failed with exit code " + f"{e.returncode}, Error Output: {e.stderr}") + except PermissionError as e: + error = f"Error: Permission denied: {e}" + except OSError as e: + error = f"Error: OS error occurred: {e}" + except ValueError as e: + error = f"Error: Invalid argument passed: {e}" + except TypeError as e: + error = f"Error: Invalid type for arguments: {e}" + except subprocess.TimeoutExpired as e: + error = f"Error: Command timed out after {e.timeout} seconds" + except subprocess.SubprocessError as e: + error = f"Subprocess error occurred: {e}" + return None, error + + def reset(self) -> None: + """Resets the command executor. + + Clears the internal command sequence, preparing the executor for a new set of commands. + """ + self.sequence.clear() diff --git a/dedal/commands/command.py b/dedal/commands/command.py new file mode 100644 index 00000000..07e9837c --- /dev/null +++ b/dedal/commands/command.py @@ -0,0 +1,29 @@ +""" Command module. """ +# Copyright (c) 2025 +# License Information: To be decided! +# +# File: command.py +# Description: Abstract base class for executable commands +# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> +# Created on: 2025-02-17 + +from abc import ABC, abstractmethod + + +class Command(ABC): + """Abstract base class for executable commands. + + Provides a common interface for defining and executing commands. + Subclasses must implement the `execute` method. + """ + + @abstractmethod + def execute(self) -> str: + """Executes the command. + + This method must be implemented by subclasses to define the specific + behavior of the command. + + Returns: + str: The result of the command execution. + """ diff --git a/dedal/commands/command_enum.py b/dedal/commands/command_enum.py new file mode 100644 index 00000000..7ef184cb --- /dev/null +++ b/dedal/commands/command_enum.py @@ -0,0 +1,37 @@ +""" Command Enum module. """ +# Copyright (c) 2025 +# License Information: To be decided! +# +# File: command_enum.py +# Description: Enumeration of supported commands in command registry +# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> +# Created on: 2025-02-17 + +from enum import Enum + + +class CommandEnum(Enum): + """Enumeration of supported commands. + + Provides a standardized way to refer to different command types, + including Linux commands and Spack commands. + """ + # Linux commands_ + + SOURCE = "source" + LIST_FILES = "list_files" + SHOW_DIRECTORY = "show_directory" + FIND_IN_FILE = "find_in_file" + ECHO_MESSAGE = "echo_message" + CHANGE_DIRECTORY = "change_directory" + + # Spack commands_ + SPACK_COMPILER_FIND = "spack_compiler_find" + SPACK_COMPILERS = "spack_compilers" + SPACK_COMPILER_INFO = "spack_compiler_info" + SPACK_COMPILER_LIST = "spack_compiler_list" + SPACK_ENVIRONMENT_ACTIVATE = "spack_environment_activate" + SPACK_FIND = "spack_find" + SPACK_INSTALL = "spack_install" + SPACK_MIRROR_ADD = "spack_mirror_add" + SPACK_GPG_TRUST = "spack_gpg_trust" diff --git a/dedal/commands/command_registry.py b/dedal/commands/command_registry.py new file mode 100644 index 00000000..adaa3d6e --- /dev/null +++ b/dedal/commands/command_registry.py @@ -0,0 +1,59 @@ +""" Command Registry module. """ +# Copyright (c) 2025 +# License Information: To be decided! +# +# File: command_registry.py +# Description: Registry for storing and retrieving shell commands with placeholders +# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> +# Created on: 2025-02-17 + +from dedal.commands.command_enum import CommandEnum + + +class CommandRegistry: + """Registry for storing and retrieving commands. + + Holds a dictionary of commands, keyed by `CommandEnum` members, + allowing easy access to command definitions. The generic shell command factory uses this registry for constructing the shell commands. + + The commands are stored in a dictionary where the keys or command names are `CommandEnum` members, and the values corresponding shell commands stored in a list. + The placeholders `{}` in the commands are replaced with the actual values when the command is executed. + + Extend this registry if you want to add more commands and do the necessary mapping in command_runner module. + """ + + COMMANDS: dict[CommandEnum, list[str]] = { + # Linux commands_ + CommandEnum.LIST_FILES: ["ls", "-l", "{folder_location}"], + CommandEnum.SHOW_DIRECTORY: ["pwd"], + CommandEnum.FIND_IN_FILE: ["grep", "-i", "{search_term}", "{file_path}"], + CommandEnum.ECHO_MESSAGE: ["echo", "{message}"], + CommandEnum.SOURCE: ["source", "{file_path}"], + CommandEnum.CHANGE_DIRECTORY: ["cd", "{folder_location}"], + + # Spack commands_ + CommandEnum.SPACK_COMPILER_FIND: ["spack", "compiler", "find", "{compiler_name}"], + CommandEnum.SPACK_COMPILERS: ["spack", "compilers"], + CommandEnum.SPACK_COMPILER_LIST: ["spack", "compiler", "list"], + CommandEnum.SPACK_COMPILER_INFO: ["spack", "compiler", "info", "{compiler_name_with_version}"], + CommandEnum.SPACK_ENVIRONMENT_ACTIVATE: ["spack", "env", "activate", "-p", "{env_path}"], + CommandEnum.SPACK_FIND: ["spack", "find", "{package_name_with_version}"], + CommandEnum.SPACK_INSTALL: ["spack", "install", "{package_name_with_version}"], + CommandEnum.SPACK_MIRROR_ADD: ["spack", "mirror", "add", "{autopush}", "{signed}", "{mirror_name}", + "{mirror_path}"], + CommandEnum.SPACK_GPG_TRUST: ["spack", "gpg", "trust", "{public_key_path}"] + } + + @classmethod + def get_command(cls, command_name: CommandEnum) -> list[str] | None: + """Retrieve a command from the registry. + + Returns the command definition associated with the given `CommandEnum` member. + + Args: + command_name (CommandEnum): The name of the command to retrieve. + + Returns (list[str]): + The shell command in a list format, or None if the command is not found. + """ + return cls.COMMANDS.get(command_name) diff --git a/dedal/commands/command_runner.py b/dedal/commands/command_runner.py new file mode 100644 index 00000000..88ee46a8 --- /dev/null +++ b/dedal/commands/command_runner.py @@ -0,0 +1,207 @@ +""" Command Runner module. """ +# Copyright (c) 2025 +# License Information: To be decided! +# +# File: command_runner.py +# Description: Manages creation, execution, and result handling of command sequences. +# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> +# Created on: 2025-02-17 + +from __future__ import annotations + +import logging +from logging import Logger +from typing import Callable, Any + +from dedal.commands.bash_command_executor import BashCommandExecutor +from dedal.commands.command_enum import CommandEnum +from dedal.commands.command_sequence import CommandSequence +from dedal.commands.command_sequence_factory import CommandSequenceFactory +from dedal.commands.preconfigured_command_enum import PreconfiguredCommandEnum +from dedal.commands.spack_command_sequence_factory import SpackCommandSequenceFactory + + +class CommandRunner: + """This module provides a unified interface for executing both predefined and custom command sequences. + + Functionality + + The module offers two primary methods for running command sequences: + + 1. Run Preconfigured Command Sequence: This method allows users to execute preconfigured command sequences using the 'run_preconfigured_command_sequence' method. + 2. Run Custom Command Sequence: This method enables users to execute dynamically generated custom command sequences using the 'run_custom_command_sequence' method. + + Preconfigured Command Sequences + To run a preconfigured command sequence, users can simply call the run_preconfigured_command_sequence method. The module will execute the corresponding command sequence. + To create and run a preconfigured command sequence, users must: + 1. Define a new preconfigured command in the PreconfiguredCommandEnum. + 2. Add a new entry in CommandRunner.commands_map that maps the preconfigured command to its corresponding function defined in the SpackCommandSequenceFactory class. + 4. Call the run_preconfigured_command_sequence method to execute the preconfigured command sequence. + + Custom Command Sequences + To create and execute a custom command sequence, users must: + 1. Define the commands as a dictionary, where the key represents the command already registered in the command_registry, and the value contains the corresponding placeholder values expected by the command. + 2. Invoke the run_custom_command_sequence using generated dictionary to execute the custom command sequence. + + Command Pattern Diagram + ------------------------ + + The Command pattern is used to encapsulate the request as an object, allowing for more flexibility and extensibility in handling requests. + + ``` + +---------------+ + | Client | + +---------------+ + | + | + v + +---------------+ + | Invoker | + | (CommandRunner)| + +---------------+ + | + | + v + +---------------+ + | Command | + | (PreconfiguredCommandEnum)| + +---------------+ + | + | + v + +---------------+ + | Receiver | + | (SpackCommandSequenceFactory)| + +---------------+ + ``` + + In this diagram: + + * The Client is the user of the CommandRunner. + * The Invoker is the CommandRunner itself, which receives the Command object and invokes the corresponding action. + * The Command is the PreconfiguredCommandEnum, which represents the request. + * The Receiver is the SpackCommandSequenceFactory, which performs the action requested by the Command object. + + Benefits + This module provides a flexible and unified interface for executing both predefined and custom command sequences, allowing users to easily manage and execute complex command workflows. + + + Attributes + ---------- + logger : Logger + The logger instance used for logging purposes. + executor : BashCommandExecutor + The BashCommandExecutor instance used for executing commands. + commands_map : dict[PreconfiguredCommandEnum, Callable[..., CommandSequence]] + A dictionary mapping preconfigured commands to their corresponding functions. + + Methods + ------- + run_preconfigured_command_sequence(preconfigured_command: PreconfiguredCommandEnum) + Executes a preconfigured command sequence. + run_custom_command_sequence(command_sequence: dict) + Executes a custom command sequence. + """ + + def __init__(self) -> None: + """Initializes the CommandRunner.""" + self.logger: Logger = logging.getLogger(__name__) + self.executor: BashCommandExecutor = BashCommandExecutor() + self.commands_map: dict[PreconfiguredCommandEnum, Callable[..., CommandSequence]] = { + PreconfiguredCommandEnum.SPACK_COMPILER_FIND: + SpackCommandSequenceFactory.create_spack_compilers_command_sequence, + PreconfiguredCommandEnum.SPACK_COMPILER_LIST: + SpackCommandSequenceFactory.create_spack_compiler_list_command_sequence, + PreconfiguredCommandEnum.SPACK_COMPILERS: + SpackCommandSequenceFactory.create_spack_compiler_list_command_sequence, + PreconfiguredCommandEnum.SPACK_COMPILER_INFO: + SpackCommandSequenceFactory.create_spack_compiler_info_command_sequence, + PreconfiguredCommandEnum.SPACK_FIND: + SpackCommandSequenceFactory.create_spack_post_install_find_command_sequence, + PreconfiguredCommandEnum.SPACK_INSTALL: + SpackCommandSequenceFactory.create_spack_install_package_command_sequence, + PreconfiguredCommandEnum.SPACK_MIRROR_ADD: + SpackCommandSequenceFactory.create_spack_mirror_add_command_sequence, + PreconfiguredCommandEnum.SPACK_GPG_TRUST: + SpackCommandSequenceFactory.create_spack_gpg_trust_command_sequence, + } + + def execute_command(self, + command_sequence: CommandSequence) -> dict[str, str | bool | None]: + """Executes a given command sequence. + + Adds the command sequence to the executor and runs it, returning the result as a dictionary. + + Args: + command_sequence (CommandSequence): The command sequence to execute. + + Returns (dict[str, str | bool | None]): + A dictionary containing the execution result. + The dictionary has the following keys: + - success (bool): True if the command executed successfully, False otherwise. + - output (str | None): The output of the command if successful, None otherwise. + - error (str | None): The error message if the command failed, None otherwise. + """ + self.executor.add_command(command_sequence) + output, error = self.executor.execute() + self.executor.reset() + return { + "success": error is None, + "output": output.strip() if output else None, + "error": error + } + + def run_preconfigured_command_sequence(self, + command_name: PreconfiguredCommandEnum, + *args: Any) -> dict[str, str | bool | None]: + """Runs a predefined command sequence. + + Creates and executes a predefined command based on the given name and arguments. + + For example `run_preconfigured_command_sequence(PreconfiguredCommandEnum.SPACK_COMPILER_FIND, 'gcc', '11')` + will execute the command `source spack_setup_path && spack find gcc@11` + + Args: + command_name (PreconfiguredCommandEnum): The name of the predefined command sequence. + args (tuple(Any, ...)): Arguments to pass to the command constructor. The arguments should correspond to the flags, options, and placeholders of the command. + + Returns: + A dictionary containing the execution result. + The dictionary has the following keys: + - success (bool): True if the command executed successfully, False otherwise. + - output (str | None): The output of the command if successful, None otherwise. + - error (str | None): The error message if the command failed, None otherwise. + If command_type is invalid, returns a dictionary with "success" as False and an error message. + """ + if command_name not in self.commands_map: + return {"success": False, "error": f"Invalid command name: {command_name}"} + + command_sequence = self.commands_map[command_name](*args) + return self.execute_command(command_sequence) + + def run_custom_command_sequence(self, + command_placeholders_map: dict[CommandEnum, dict[str, str]]) \ + -> dict[str, str | bool | None]: + """Runs a custom command sequence. + + Creates and executes a custom command sequence from a map of command names to placeholder values. + + Args: + command_placeholders_map (dict[CommandEnum, dict[str, str]]): A dictionary mapping command name enums to + placeholder values. The key is the command name in `CommandEnum` and the value is a dictionary + mapping placeholder names to their actual values. + + For example, if the key command is `CommandEnum.FIND_IN_FILE`, + this corresponds to the command `grep -i {search_term} {file_path}` in CommandRegistry.COMMANDS. + Hence the placeholder_map_value should be `{search_term: 'python', file_path: '/path/to/file.txt'}`. + + Returns (dict[str, str | bool | None]): + A dictionary containing the execution result. + The dictionary has the following keys: + - success (bool): True if the command executed successfully, False otherwise. + - output (str | None): The output of the command if successful, None otherwise. + - error (str | None): The error message if the command failed, None otherwise. + """ + command_sequence = (CommandSequenceFactory + .create_custom_command_sequence(command_placeholders_map)) + return self.execute_command(command_sequence) diff --git a/dedal/commands/command_sequence.py b/dedal/commands/command_sequence.py new file mode 100644 index 00000000..6115215a --- /dev/null +++ b/dedal/commands/command_sequence.py @@ -0,0 +1,54 @@ +""" Command Sequence module. """ +# Copyright (c) 2025 +# License Information: To be decided! +# +# File: command_sequence.py +# Description: Command Sequence abstraction +# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> +# Created on: 2025-02-17 + +from dedal.commands.command import Command + + +class CommandSequence(Command): + """Represents a sequence of executable commands. + + Allows adding commands to a sequence and executing them in order, + combining/chaining their execution strings with "&&". + """ + + def __init__(self) -> None: + """Initializes an empty command sequence.""" + self.commands: list[Command] = [] + + def add_command(self, command: Command) -> None: + """Adds a command to the sequence. + + Appends the given command to the list of commands. + + Args: + command (Command): The command to add. + + Raises: + ValueError: If the provided command is not an instance of `Command`. + """ + if not isinstance(command, Command): + raise ValueError("Command must be an instance of Command") + self.commands.append(command) + + def execute(self) -> str: + """Executes the command sequence. + + Executes each command in the sequence and joins their results with "&&". + + Returns: + The combined execution string of all commands in the sequence. + """ + return " && ".join(cmd.execute().strip() for cmd in self.commands).strip() + + def clear(self) -> None: + """Clears the command sequence. + + Removes all commands from the sequence, making it empty. + """ + self.commands.clear() diff --git a/dedal/commands/command_sequence_builder.py b/dedal/commands/command_sequence_builder.py new file mode 100644 index 00000000..bd355057 --- /dev/null +++ b/dedal/commands/command_sequence_builder.py @@ -0,0 +1,71 @@ +""" Command Sequence Builder module. """ +# Copyright (c) 2025 +# License Information: To be decided! +# +# File: command_sequence_builder.py +# Description: Command sequence builder module for creating command sequences +# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> +# Created on: 2025-02-19 +from __future__ import annotations + +from dedal.commands.command_enum import CommandEnum +from dedal.commands.command_registry import CommandRegistry +from dedal.commands.command_sequence import CommandSequence +from dedal.commands.shell_command_factory import ShellCommandFactory + + +class CommandSequenceBuilder: + """Builds a sequence of commands using the builder pattern. + + Facilitates the creation of CommandSequence objects by adding commands incrementally + and then building the final sequence. + """ + + def __init__(self) -> None: + """Initializes a new CommandSequenceBuilder with an empty sequence.""" + self.sequence: CommandSequence = CommandSequence() + + def add_generic_command(self, + command_name: CommandEnum, + placeholders: dict[str, str]) -> CommandSequenceBuilder: + """Adds a generic command to the sequence. + + Retrieves the command definition from the CommandRegistry, replaces placeholders with + provided values, creates a ShellCommand, and adds it to the sequence. + + Args: + command_name (CommandEnum): The enum representing the command name to add. + placeholders (dict[str, str]): A dictionary of placeholder values to substitute in the command. + + Returns: + The CommandSequenceBuilder instance (self) for method chaining. + + Raises: + ValueError: If the command type is invalid or if the command is unknown. + """ + if not isinstance(command_name, CommandEnum): + raise ValueError("Invalid command type. Use CommandEnum.") + command = CommandRegistry.get_command(command_name) + + if command is None: + raise ValueError(f"Unknown command: {command_name}") + full_command = command[:] # Copy the command list to avoid mutation + # Replace placeholders with actual values + if placeholders: + full_command = [placeholders.get(arg.strip("{}"), arg) for arg in full_command] + full_command = list(filter(None, full_command)) + shell_command = ShellCommandFactory.create_command(*full_command) + self.sequence.add_command(shell_command) + return self + + def build(self) -> CommandSequence: + """Builds and returns the CommandSequence. + + Returns the constructed CommandSequence and resets the builder for creating new sequences. + + Returns: + The built CommandSequence. + """ + sequence = self.sequence + self.sequence = CommandSequence() # Reset for next build + return sequence diff --git a/dedal/commands/command_sequence_factory.py b/dedal/commands/command_sequence_factory.py new file mode 100644 index 00000000..1c187245 --- /dev/null +++ b/dedal/commands/command_sequence_factory.py @@ -0,0 +1,46 @@ +""" Command Sequence Factory module. """ +# Copyright (c) 2025 +# License Information: To be decided! +# +# File: command_sequence_factory.py +# Description: Brief description of the file. +# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> +# Created on: 2025-02-17 + +from dedal.commands.command_enum import CommandEnum +from dedal.commands.command_sequence import CommandSequence +from dedal.commands.command_sequence_builder import CommandSequenceBuilder + + +class CommandSequenceFactory: + """Factory for creating CommandSequence objects.""" + + @staticmethod + def create_custom_command_sequence( + command_placeholders_map: dict[CommandEnum, dict[str, str]]) -> CommandSequence: + """Creates a custom CommandSequence. + + Builds a CommandSequence from a dictionary mapping CommandEnums to placeholder values. + + For example if the key command is `CommandEnum.FIND_IN_FILE` and the value is `{search_term: 'python', file_path: '/path/to/file.txt'}`, + this corresponds to the command `grep -i {search_term} {file_path}` in CommandRegistry.COMMANDS. So the user can create a sequence of such commands. + + e.g. command_placeholders_map = { + CommandEnum.FIND_IN_FILE: { + "search_term": "python", + "file_path": "/path/to/file.txt" + }, + CommandEnum.SHOW_DIRECTORY: {}, + CommandEnum.LIST_FILES: {"folder_location": "/tmp"}, + CommandEnum.ECHO_MESSAGE: {"message": "Hello, world!"} + } + + Args: + command_placeholders_map: A dictionary mapping CommandEnum members to dictionaries of placeholder values. + Returns: + A CommandSequence object representing the custom command sequence. + """ + builder = CommandSequenceBuilder() + for command_type, placeholders in command_placeholders_map.items(): + builder.add_generic_command(command_type, placeholders) + return builder.build() diff --git a/dedal/commands/generic_shell_command.py b/dedal/commands/generic_shell_command.py new file mode 100644 index 00000000..0a02b095 --- /dev/null +++ b/dedal/commands/generic_shell_command.py @@ -0,0 +1,47 @@ +""" Generic Shell Command module. """ +# Copyright (c) 2025 +# License Information: To be decided! +# +# File: generic_shell_command.py +# Description: Generic shell command implementation +# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> +# Created on: 2025-02-17 + +from dedal.commands.command import Command + + +class GenericShellCommand(Command): + """Represents a generic shell command. + + Encapsulates a shell command with its name and arguments, providing + a way to execute it. + """ + + def __init__(self, command_name: str, *args: str) -> None: + """Initializes a new GenericShellCommand. + + Args: + command_name (str): The name of the command. + *args (str): The arguments for the command. + + Raises: + ValueError: If the command name is empty, not a string, or if any of the arguments are not strings. + """ + if not command_name: + raise ValueError("Command name is required!") + if not isinstance(command_name, str): + raise ValueError("Command name must be a string!") + if not all(isinstance(arg, str) for arg in args): + raise ValueError("All arguments must be strings!") + self.args: tuple[str, ...] = tuple(map(str.strip, args)) + self.command_name: str = command_name.strip() + + def execute(self) -> str: + """Executes the command. + + Constructs and returns the full command string, including the command name and arguments. + + Returns: + The full command string. + """ + return f"{self.command_name} {' '.join(self.args)}" if self.args else self.command_name diff --git a/dedal/commands/preconfigured_command_enum.py b/dedal/commands/preconfigured_command_enum.py new file mode 100644 index 00000000..14b747ad --- /dev/null +++ b/dedal/commands/preconfigured_command_enum.py @@ -0,0 +1,28 @@ +""" Preconfigured Command Enum module. """ +# Copyright (c) 2025 +# License Information: To be decided! +# +# File: command_enum.py +# Description: Enumeration of supported predefined commands used in command_runner module: +# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> +# Created on: 2025-02-17 + +from enum import Enum + + +class PreconfiguredCommandEnum(Enum): + """Enumeration of preconfigured composite commands. + + Provides a predefined set of command identifiers for commonly used operations. + + The command_runner module uses these identifiers to construct command sequences and execute them via 'run_preconfigured_command_sequence' method. + """ + SPACK_COMPILER_FIND = "spack_compiler_find" + SPACK_COMPILERS = "spack_compilers" + SPACK_COMPILER_INFO = "spack_compiler_info" + SPACK_COMPILER_LIST = "spack_compiler_list" + SPACK_ENVIRONMENT_ACTIVATE = "spack_environment_activate" + SPACK_FIND = "spack_find" + SPACK_INSTALL = "spack_install" + SPACK_MIRROR_ADD = "spack_mirror_add" + SPACK_GPG_TRUST = "spack_gpg_trust" diff --git a/dedal/commands/shell_command_factory.py b/dedal/commands/shell_command_factory.py new file mode 100644 index 00000000..e63a456e --- /dev/null +++ b/dedal/commands/shell_command_factory.py @@ -0,0 +1,33 @@ +""" Shell Command Factory module. """ +# Copyright (c) 2025 +# License Information: To be decided! +# +# File: shell_command_factory.py +# Description: Shell command factory to create shell command instances +# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> +# Created on: 2025-02-17 + +from dedal.commands.command import Command +from dedal.commands.generic_shell_command import GenericShellCommand + + +class ShellCommandFactory: + """Factory for creating shell command instances. + + Provides a static method for creating GenericShellCommand objects. + """ + + @staticmethod + def create_command(command_name: str, *args: str) -> Command: + """Creates a generic shell command. + + Instantiates and returns a GenericShellCommand object with the given command name and arguments. + + Args: + command_name (str): The name of the command. + *args (str): The arguments for the command. + + Returns: + A GenericShellCommand object. + """ + return GenericShellCommand(command_name, *args) diff --git a/dedal/commands/spack_command_sequence_factory.py b/dedal/commands/spack_command_sequence_factory.py new file mode 100644 index 00000000..ce7afac3 --- /dev/null +++ b/dedal/commands/spack_command_sequence_factory.py @@ -0,0 +1,211 @@ +"""Factory for generating predefined spack related command sequences.""" +# Copyright (c) 2025 +# License Information: To be decided! +# +# File: spack_command_sequence_factory.py +# Description: Factory for generating predefined spack related command sequences +# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> +# Created on: 2025-02-17 + +from dedal.commands.command_enum import CommandEnum +from dedal.commands.command_sequence import CommandSequence +from dedal.commands.command_sequence_builder import CommandSequenceBuilder + + +def get_name_version(name: str | None, version: str | None) -> str: + """Formats a name and version string. + + Returns a string combining the name and version with "@" if both are provided, + otherwise returns the name or an empty string. + + Args: + name (str): The name. + version (str): The version. + + Returns: + The formatted name and version string. + """ + return f"{name}@{version}" if name and version else name or "" + + +class SpackCommandSequenceFactory: + """Factory for creating Spack command sequences. + + Provides methods for building CommandSequence objects for various Spack operations. + """ + + @staticmethod + def create_spack_enabled_command_sequence_builder(spack_setup_script: str) -> CommandSequenceBuilder: + """Creates a CommandSequenceBuilder with Spack setup. + + Initializes a builder with the 'source' command for the given Spack setup script. + + Args: + spack_setup_script (str): Path to the Spack setup script. + + Returns: + A CommandSequenceBuilder pre-configured with the Spack setup command. + """ + return (CommandSequenceBuilder() + .add_generic_command(CommandEnum.SOURCE, {"file_path": spack_setup_script})) + + @staticmethod + def create_spack_compilers_command_sequence(spack_setup_script: str) -> CommandSequence: + """Creates a command sequence for listing Spack compilers. + + Builds a sequence that sources the Spack setup script and then lists available compilers. + + Args: + spack_setup_script (str): Path to the Spack setup script. + + Returns: + A CommandSequence for listing Spack compilers. + """ + return (SpackCommandSequenceFactory.create_spack_enabled_command_sequence_builder(spack_setup_script) + .add_generic_command(CommandEnum.SPACK_COMPILERS, {}) + .build()) + + @staticmethod + def create_spack_compiler_list_command_sequence(spack_setup_script: str) -> CommandSequence: + """Creates a command sequence for listing Spack compilers. + + Builds a sequence that sources the Spack setup script and then lists available compilers. + + Args: + spack_setup_script (str): Path to the Spack setup script. + + Returns: + A CommandSequence for listing Spack compilers. + """ + return (SpackCommandSequenceFactory.create_spack_enabled_command_sequence_builder(spack_setup_script) + .add_generic_command(CommandEnum.SPACK_COMPILER_LIST, {}) + .build()) + + @staticmethod + def create_spack_install_package_command_sequence(spack_setup_script: str, + package_name: str | None, + version: str | None) -> CommandSequence: + """Creates a command sequence for installing a Spack package. + + Builds a sequence that sources the Spack setup script and then installs the specified package. + + Args: + spack_setup_script (str): Path to the Spack setup script. + package_name (str | None): The name of the package to install. + version (str | None): The version of the package to install. + + Returns: + A CommandSequence for installing the Spack package. + """ + return (SpackCommandSequenceFactory.create_spack_enabled_command_sequence_builder(spack_setup_script) + .add_generic_command(CommandEnum.SPACK_INSTALL, { + "package_name_with_version": get_name_version(package_name, version) + }).build()) + + @staticmethod + def create_spack_mirror_add_command_sequence(spack_setup_script: str, + env_name: str, + mirror_name: str, + mirror_path: str, + autopush: bool = False, + signed: bool = False) -> CommandSequence: + """Creates a command sequence for adding a Spack mirror. + + Builds a sequence that sources the Spack setup script, activates an environment (if specified), + and adds the given mirror. + + Args: + spack_setup_script (str): Path to the Spack setup script. + env_name (str): The name of the environment to activate. + mirror_name (str): The name of the mirror. + mirror_path (str): The URL or path of the mirror. + autopush (bool): Whether to enable autopush for the mirror. + signed (bool): Whether to require signed packages from the mirror. + + Returns: + A CommandSequence for adding the Spack mirror. + """ + builder = SpackCommandSequenceFactory.create_spack_enabled_command_sequence_builder(spack_setup_script) + if env_name: + builder = builder.add_generic_command(CommandEnum.SPACK_ENVIRONMENT_ACTIVATE, + {"env_path": env_name}) + place_holders = { + "mirror_name": mirror_name, + "mirror_path": mirror_path, + "autopush": "--autopush" if autopush else "", + "signed": "--signed" if signed else "" + } + builder = builder.add_generic_command(CommandEnum.SPACK_MIRROR_ADD, place_holders) + return builder.build() + + @staticmethod + def create_spack_compiler_info_command_sequence(spack_setup_script: str, + compiler_name: str, + compiler_version: str) -> CommandSequence: + """Creates a command sequence for getting Spack compiler information. + + Builds a sequence that sources the Spack setup script and retrieves information about the specified compiler. + + + Args: + spack_setup_script (str): Path to the Spack setup script. + compiler_name (str): The name of the compiler. + compiler_version (str): The version of the compiler. + + Returns: + A CommandSequence for getting compiler information. + """ + return (SpackCommandSequenceFactory.create_spack_enabled_command_sequence_builder(spack_setup_script) + .add_generic_command(CommandEnum.SPACK_COMPILER_INFO, + { + "compiler_name_with_version": + get_name_version(compiler_name, compiler_version) + }) + .build()) + + @staticmethod + def create_spack_post_install_find_command_sequence(spack_setup_script: str, + env_path: str, + package_name: str | None, + version: str | None) -> CommandSequence: + """Creates a command sequence for finding installed Spack packages after installation. + + Builds a sequence that sources the Spack setup script, activates the specified environment, + and then searches for the given package. + + Args: + spack_setup_script (str): Path to the Spack setup script. + env_path (str): The path to the Spack environment. + package_name (str | None): The name of the package to find. + version (str | None): The version of the package to find. + + Returns: + A CommandSequence for finding installed Spack packages. + """ + return (SpackCommandSequenceFactory.create_spack_enabled_command_sequence_builder(spack_setup_script) + .add_generic_command(CommandEnum.SPACK_ENVIRONMENT_ACTIVATE, {"env_path": env_path}) + .add_generic_command(CommandEnum.SPACK_FIND, + { + "package_name_with_version": + get_name_version(package_name, version) + }).build()) + + @staticmethod + def create_spack_gpg_trust_command_sequence(spack_setup_script: str, + public_key_path: str) -> CommandSequence: + """Creates a command sequence for trusting a GPG key in Spack. + + Builds a sequence that sources the Spack setup script and then trusts the given GPG key. + + Args: + spack_setup_script (str): Path to the Spack setup script. + public_key_path (str): Path to the public key file. + + Returns: + A CommandSequence for trusting the GPG key. + """ + return (SpackCommandSequenceFactory.create_spack_enabled_command_sequence_builder(spack_setup_script) + .add_generic_command(CommandEnum.SPACK_GPG_TRUST, + { + "public_key_path": public_key_path + }).build()) diff --git a/dedal/spack_factory/SpackOperation.py b/dedal/spack_factory/SpackOperation.py index aebabc0b..ad4f2389 100644 --- a/dedal/spack_factory/SpackOperation.py +++ b/dedal/spack_factory/SpackOperation.py @@ -2,13 +2,16 @@ import os import re import subprocess from pathlib import Path + +from dedal.commands.command_runner import CommandRunner +from dedal.commands.preconfigured_command_enum import PreconfiguredCommandEnum +from dedal.configuration.SpackConfig import SpackConfig from dedal.error_handling.exceptions import BashCommandException, NoSpackEnvironmentException, \ SpackInstallPackagesException, SpackConcertizeException, SpackMirrorException, SpackGpgException, SpackRepoException from dedal.logger.logger_builder import get_logger -from dedal.configuration.SpackConfig import SpackConfig from dedal.tests.testing_variables import SPACK_VERSION -from dedal.wrapper.spack_wrapper import check_spack_env from dedal.utils.utils import run_command, git_clone_repo, log_command, set_bashrc_variable +from dedal.wrapper.spack_wrapper import check_spack_env class SpackOperation: @@ -47,6 +50,7 @@ class SpackOperation: if self.spack_config.env and spack_config.env.path: self.spack_config.env.path = spack_config.env.path self.spack_config.env.path.mkdir(parents=True, exist_ok=True) + self.command_runner = CommandRunner() def create_fetch_spack_environment(self): if self.spack_config.env.git_path: @@ -120,9 +124,7 @@ class SpackOperation: check=True, capture_output=True, text=True, logger=self.logger, info_msg=f'Checking if environment {self.spack_config.env.name} exists') - if result is None: - return False - return True + return result is not None @check_spack_env def add_spack_repo(self, repo_path: Path, repo_name: str): @@ -187,26 +189,104 @@ class SpackOperation: else: raise SpackGpgException('No GPG configuration was defined is spack configuration') - def add_mirror(self, mirror_name: str, mirror_path: Path, signed=False, autopush=False, global_mirror=False): - autopush = '--autopush' if autopush else '' - signed = '--signed' if signed else '' - if global_mirror: - run_command("bash", "-c", - f'{self.spack_setup_script} spack mirror add {autopush} {signed} {mirror_name} {mirror_path}', - check=True, - logger=self.logger, - info_msg=f'Added mirror {mirror_name}', - exception_msg=f'Failed to add mirror {mirror_name}', - exception=SpackMirrorException) - else: - check_spack_env( - run_command("bash", "-c", - f'{self.spack_command_on_env} && spack mirror add {autopush} {signed} {mirror_name} {mirror_path}', - check=True, - logger=self.logger, - info_msg=f'Added mirror {mirror_name}', - exception_msg=f'Failed to add mirror {mirror_name}', - exception=SpackMirrorException)) + def add_mirror(self, + mirror_name: str, + mirror_path: str, + signed=False, + autopush=False, + global_mirror=False) -> bool: + """Adds a Spack mirror. + + Adds a new mirror to the Spack configuration, either globally or to a specific environment. + + Args: + mirror_name (str): The name of the mirror. + mirror_path (str): The path or URL of the mirror. + signed (bool): Whether to require signed packages from the mirror. + autopush (bool): Whether to enable autopush for the mirror. + global_mirror (bool): Whether to add the mirror globally (True) or to the current environment (False). + + Returns: + True if the mirror was added successfully, False otherwise. + + Raises: + ValueError: If mirror_name or mirror_path are empty. + NoSpackEnvironmentException: If global_mirror is False and no environment is defined. + """ + if not mirror_name or not mirror_path: + raise ValueError("mirror_name and mirror_path are required") + if not global_mirror and not self.env_path: + raise NoSpackEnvironmentException('No spack environment defined') + result = self.command_runner.run_preconfigured_command_sequence( + PreconfiguredCommandEnum.SPACK_MIRROR_ADD, + self.spack_setup_script, + "" if global_mirror else str(self.env_path), + mirror_name, + mirror_path, + autopush, + signed) + return self.handle_result( + result, + "Failed to add mirror %s, reason: %s, output: %s", + (mirror_name,), + "Added mirror %s", + (mirror_name,) + ) + + def trust_gpg_key(self, public_key_path: str): + """Adds a GPG public key to the trusted keyring. + + This method attempts to add the provided GPG public key to the + Spack trusted keyring. + + Args: + public_key_path (str): Path to the GPG public key file. + + Returns: + bool: True if the key was added successfully, False otherwise. + + Raises: + ValueError: If public_key_path is empty. + """ + if not public_key_path: + raise ValueError("public_key_path is required") + result = self.command_runner.run_preconfigured_command_sequence( + PreconfiguredCommandEnum.SPACK_GPG_TRUST, + self.spack_setup_script, + public_key_path) + return self.handle_result( + result, + "Failed to add public key %s as trusted, reason: %s, output: %s", + (public_key_path,), + "Added public key %s as trusted", + (public_key_path,), + ) + + def handle_result(self, + result: dict[str, str | bool | None], + error_msg: str, + error_msg_args: tuple[str, ...], + info_msg: str, + info_msg_args: tuple[str, ...]): + """Handles the result of a command execution. + + Checks the success status of the result and logs either an error or an info message accordingly. + + Args: + result (dict[str, str | bool | None]): A dictionary containing the result of the command execution. + error_msg (str): The error message to log if the command failed. + error_msg_args (tuple[str, ...]): Arguments to format the error message. + info_msg (str): The info message to log if the command succeeded. + info_msg_args (tuple[str, ...]): Arguments to format the info message. + + Returns: + bool: True if the command succeeded, False otherwise. + """ + if not result["success"]: + self.logger.error(error_msg, *error_msg_args, result['error'], result['output']) + return False + self.logger.info(info_msg, *info_msg_args) + return True def remove_mirror(self, mirror_name: str): run_command("bash", "-c", diff --git a/dedal/spack_factory/SpackOperationCreateCache.py b/dedal/spack_factory/SpackOperationCreateCache.py index 41cfc845..8d6125fb 100644 --- a/dedal/spack_factory/SpackOperationCreateCache.py +++ b/dedal/spack_factory/SpackOperationCreateCache.py @@ -42,7 +42,10 @@ class SpackOperationCreateCache(SpackOperation): if self.spack_config.gpg: signed = True self.create_gpg_keys() - self.add_mirror('local_cache', self.spack_config.buildcache_dir, signed=signed, autopush=signed, + self.add_mirror('local_cache', + str(self.spack_config.buildcache_dir), + signed=signed, + autopush=signed, global_mirror=False) self.logger.info(f'Added mirror for {self.spack_config.env.name}') super().install_packages(jobs=jobs, signed=signed, debug=debug, fresh=True) diff --git a/dedal/spack_factory/SpackOperationUseCache.py b/dedal/spack_factory/SpackOperationUseCache.py index 2bb6f76a..16312e7f 100644 --- a/dedal/spack_factory/SpackOperationUseCache.py +++ b/dedal/spack_factory/SpackOperationUseCache.py @@ -3,11 +3,12 @@ import subprocess from pathlib import Path from dedal.build_cache.BuildCacheManager import BuildCacheManager +from dedal.configuration.SpackConfig import SpackConfig +from dedal.error_handling.exceptions import NoSpackEnvironmentException from dedal.error_handling.exceptions import SpackInstallPackagesException from dedal.logger.logger_builder import get_logger from dedal.spack_factory.SpackOperation import SpackOperation -from dedal.configuration.SpackConfig import SpackConfig -from dedal.utils.utils import file_exists_and_not_empty, run_command, log_command, copy_to_tmp, copy_file +from dedal.utils.utils import file_exists_and_not_empty, run_command, log_command, copy_file from dedal.wrapper.spack_wrapper import check_spack_env @@ -29,9 +30,33 @@ class SpackOperationUseCache(SpackOperation): os.environ.get('BUILDCACHE_OCI_PASSWORD'), cache_version=spack_config.cache_version_build) - def setup_spack_env(self): + def setup_spack_env(self) -> None: + """Set up the spack environment for using the cache. + + Downloads the build cache, adds the public key to trusted keys, + and adds the build cache mirror. + + Raises: + ValueError: If there is an issue with the build cache setup (mirror_name/mirror_path are empty). + NoSpackEnvironmentException: If the spack environment is not set up. + """ super().setup_spack_env() - # todo add buildcache to the spack environment + try: + # Download build cache from OCI Registry and add public key to trusted keys + self.build_cache.download(self.spack_config.buildcache_dir) + cached_public_key = self.build_cache.get_public_key_from_cache(str(self.spack_config.buildcache_dir)) + signed = cached_public_key is not None and self.trust_gpg_key(cached_public_key) + if not signed: + self.logger.warning("Public key not found in cache or failed to trust pgp keys!") + # Add build cache mirror + self.add_mirror('local_cache', + str(self.spack_config.buildcache_dir), + signed=signed, + autopush=True, + global_mirror=False) + except (ValueError, NoSpackEnvironmentException) as e: + self.logger.error("Error adding buildcache mirror: %s", e) + raise @check_spack_env def concretize_spack_env(self): diff --git a/dedal/tests/spack_from_scratch_test.py b/dedal/tests/spack_from_scratch_test.py new file mode 100644 index 00000000..2fec80f7 --- /dev/null +++ b/dedal/tests/spack_from_scratch_test.py @@ -0,0 +1,204 @@ +from pathlib import Path +import pytest +from dedal.configuration.SpackConfig import SpackConfig +from dedal.error_handling.exceptions import BashCommandException, NoSpackEnvironmentException +from dedal.spack_factory.SpackOperationCreator import SpackOperationCreator +from dedal.model.SpackDescriptor import SpackDescriptor +from dedal.tests.testing_variables import test_spack_env_git, ebrains_spack_builds_git +from dedal.utils.utils import file_exists_and_not_empty + + +def test_spack_repo_exists_1(): + spack_operation = SpackOperationCreator.get_spack_operator() + spack_operation.install_spack() + assert spack_operation.spack_repo_exists('ebrains-spack-builds') == False + + +def test_spack_repo_exists_2(tmp_path): + install_dir = tmp_path + env = SpackDescriptor('ebrains-spack-builds', install_dir) + config = SpackConfig(env=env, install_dir=install_dir) + spack_operation = SpackOperationCreator.get_spack_operator(config) + spack_operation.install_spack() + with pytest.raises(NoSpackEnvironmentException): + spack_operation.spack_repo_exists(env.env_name) + + +def test_spack_repo_exists_3(tmp_path): + install_dir = tmp_path + env = SpackDescriptor('ebrains-spack-builds', install_dir) + config = SpackConfig(env=env, install_dir=install_dir) + spack_operation = SpackOperationCreator.get_spack_operator(config) + spack_operation.install_spack() + print(spack_operation.get_spack_installed_version()) + spack_operation.setup_spack_env() + assert spack_operation.spack_repo_exists(env.env_name) == False + + +def test_spack_from_scratch_setup_1(tmp_path): + install_dir = tmp_path + env = SpackDescriptor('ebrains-spack-builds', install_dir, ebrains_spack_builds_git) + config = SpackConfig(env=env, system_name='ebrainslab', install_dir=install_dir) + spack_operation = SpackOperationCreator.get_spack_operator(config) + spack_operation.install_spack() + spack_operation.setup_spack_env() + assert spack_operation.spack_repo_exists(env.env_name) == False + + +def test_spack_from_scratch_setup_2(tmp_path): + install_dir = tmp_path + env = SpackDescriptor('ebrains-spack-builds', install_dir, ebrains_spack_builds_git) + repo = env + config = SpackConfig(env=env, system_name='ebrainslab', install_dir=install_dir) + config.add_repo(repo) + config.add_repo(repo) + spack_operation = SpackOperationCreator.get_spack_operator(config) + spack_operation.install_spack() + spack_operation.setup_spack_env() + assert spack_operation.spack_repo_exists(env.env_name) == True + + +def test_spack_from_scratch_setup_3(tmp_path): + install_dir = tmp_path + env = SpackDescriptor('new_env1', install_dir) + repo = env + config = SpackConfig(env=env, system_name='ebrainslab', install_dir=install_dir) + config.add_repo(repo) + config.add_repo(repo) + spack_operation = SpackOperationCreator.get_spack_operator(config) + spack_operation.install_spack() + with pytest.raises(BashCommandException): + spack_operation.setup_spack_env() + + +def test_spack_from_scratch_setup_4(tmp_path): + install_dir = tmp_path + env = SpackDescriptor('new_env2', install_dir) + config = SpackConfig(env=env, install_dir=install_dir) + spack_operation = SpackOperationCreator.get_spack_operator(config) + spack_operation.install_spack() + spack_operation.setup_spack_env() + assert spack_operation.spack_env_exists() == True + + +def test_spack_not_a_valid_repo(): + env = SpackDescriptor('ebrains-spack-builds', Path(), None) + repo = env + config = SpackConfig(env=env, system_name='ebrainslab') + config.add_repo(repo) + spack_operation = SpackOperationCreator.get_spack_operator(config) + with pytest.raises(BashCommandException): + spack_operation.add_spack_repo(repo.path, repo.env_name) + + +def test_spack_from_scratch_concretize_1(tmp_path): + install_dir = tmp_path + env = SpackDescriptor('ebrains-spack-builds', install_dir, ebrains_spack_builds_git) + repo = env + config = SpackConfig(env=env, system_name='ebrainslab', install_dir=install_dir) + config.add_repo(repo) + config.add_repo(repo) + spack_operation = SpackOperationCreator.get_spack_operator(config) + spack_operation.install_spack() + spack_operation.install_spack() + spack_operation.setup_spack_env() + spack_operation.concretize_spack_env(force=True) + concretization_file_path = spack_operation.env_path / 'spack.lock' + assert file_exists_and_not_empty(concretization_file_path) == True + + +def test_spack_from_scratch_concretize_2(tmp_path): + install_dir = tmp_path + env = SpackDescriptor('ebrains-spack-builds', install_dir, ebrains_spack_builds_git) + repo = env + config = SpackConfig(env=env, system_name='ebrainslab', install_dir=install_dir) + config.add_repo(repo) + config.add_repo(repo) + spack_operation = SpackOperationCreator.get_spack_operator(config) + spack_operation.install_spack() + spack_operation.setup_spack_env() + spack_operation.concretize_spack_env(force=False) + concretization_file_path = spack_operation.env_path / 'spack.lock' + assert file_exists_and_not_empty(concretization_file_path) == True + + +def test_spack_from_scratch_concretize_3(tmp_path): + install_dir = tmp_path + env = SpackDescriptor('ebrains-spack-builds', install_dir, ebrains_spack_builds_git) + repo = env + config = SpackConfig(env=env, system_name='ebrainslab', install_dir=install_dir) + config.add_repo(repo) + config.add_repo(repo) + spack_operation = SpackOperationCreator.get_spack_operator(config) + spack_operation.install_spack() + spack_operation.setup_spack_env() + concretization_file_path = spack_operation.env_path / 'spack.lock' + assert file_exists_and_not_empty(concretization_file_path) == False + + +def test_spack_from_scratch_concretize_4(tmp_path): + install_dir = tmp_path + env = SpackDescriptor('test-spack-env', install_dir, test_spack_env_git) + config = SpackConfig(env=env, install_dir=install_dir) + spack_operation = SpackOperationCreator.get_spack_operator(config) + spack_operation.install_spack() + spack_operation.setup_spack_env() + spack_operation.concretize_spack_env(force=False) + concretization_file_path = spack_operation.env_path / 'spack.lock' + assert file_exists_and_not_empty(concretization_file_path) == True + + +def test_spack_from_scratch_concretize_5(tmp_path): + install_dir = tmp_path + env = SpackDescriptor('test-spack-env', install_dir, test_spack_env_git) + config = SpackConfig(env=env, install_dir=install_dir) + spack_operation = SpackOperationCreator.get_spack_operator(config) + spack_operation.install_spack() + spack_operation.setup_spack_env() + spack_operation.concretize_spack_env(force=True) + concretization_file_path = spack_operation.env_path / 'spack.lock' + assert file_exists_and_not_empty(concretization_file_path) == True + + +def test_spack_from_scratch_concretize_6(tmp_path): + install_dir = tmp_path + env = SpackDescriptor('test-spack-env', install_dir, test_spack_env_git) + repo = SpackDescriptor('ebrains-spack-builds', install_dir, ebrains_spack_builds_git) + config = SpackConfig(env=env, install_dir=install_dir) + config.add_repo(repo) + spack_operation = SpackOperationCreator.get_spack_operator(config) + spack_operation.install_spack() + spack_operation.setup_spack_env() + spack_operation.concretize_spack_env(force=False) + concretization_file_path = spack_operation.env_path / 'spack.lock' + assert file_exists_and_not_empty(concretization_file_path) == True + + +def test_spack_from_scratch_concretize_7(tmp_path): + install_dir = tmp_path + env = SpackDescriptor('test-spack-env', install_dir, test_spack_env_git) + repo = SpackDescriptor('ebrains-spack-builds', install_dir, ebrains_spack_builds_git) + config = SpackConfig(env=env) + config.add_repo(repo) + spack_operation = SpackOperationCreator.get_spack_operator(config) + spack_operation.install_spack() + spack_operation.setup_spack_env() + spack_operation.concretize_spack_env(force=True) + concretization_file_path = spack_operation.env_path / 'spack.lock' + assert file_exists_and_not_empty(concretization_file_path) == True + + +def test_spack_from_scratch_install(tmp_path): + install_dir = tmp_path + env = SpackDescriptor('test-spack-env', install_dir, test_spack_env_git) + repo = SpackDescriptor('ebrains-spack-builds', install_dir, ebrains_spack_builds_git) + config = SpackConfig(env=env) + config.add_repo(repo) + spack_operation = SpackOperationCreator.get_spack_operator(config) + spack_operation.install_spack() + spack_operation.setup_spack_env() + spack_operation.concretize_spack_env(force=True) + concretization_file_path = spack_operation.env_path / 'spack.lock' + assert file_exists_and_not_empty(concretization_file_path) == True + install_result = spack_operation.install_packages(jobs=2, signed=False, fresh=True, debug=False) + assert install_result.returncode == 0 diff --git a/dedal/tests/spack_install_test.py b/dedal/tests/spack_install_test.py new file mode 100644 index 00000000..564d5c6a --- /dev/null +++ b/dedal/tests/spack_install_test.py @@ -0,0 +1,12 @@ +import pytest +from dedal.spack_factory.SpackOperation import SpackOperation +from dedal.tests.testing_variables import SPACK_VERSION + + +# run this test first so that spack is installed only once for all the tests +@pytest.mark.run(order=1) +def test_spack_install_scratch(): + spack_operation = SpackOperation() + spack_operation.install_spack(spack_version=f'v{SPACK_VERSION}') + installed_spack_version = spack_operation.get_spack_installed_version() + assert SPACK_VERSION == installed_spack_version diff --git a/dedal/tests/unit_tests/test_bash_command_executor.py b/dedal/tests/unit_tests/test_bash_command_executor.py new file mode 100644 index 00000000..e216cc0f --- /dev/null +++ b/dedal/tests/unit_tests/test_bash_command_executor.py @@ -0,0 +1,236 @@ +# Copyright (c) 2025 +# License Information: To be decided! +# +# File: test_bash_command_executor.py +# Description: Brief description of the file. +# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> +# Created on: 2025-02-18 +import os +import subprocess +from unittest.mock import patch + +import pytest + +from dedal.commands.bash_command_executor import BashCommandExecutor +from dedal.commands.command import Command + + +class MockCommand(Command): + def __init__(self, cmd: str): + self._cmd = cmd + + def execute(self) -> str: + return self._cmd + + +class TestBashCommandExecutor: + @pytest.mark.parametrize( + "test_id, os_name, expected_bash_command", + [ + ("posix_system", "posix", ["bash", "-c"]), + ("nt_system", "nt", ["wsl", "bash", "-c"]), + ], + ) + def test_init_success_path(self, mocker, test_id, os_name, expected_bash_command): + # Arrange + mock_get_logger = mocker.patch("dedal.commands.bash_command_executor.logging.getLogger") + mocker.patch("dedal.commands.bash_command_executor.os_name", os_name) + + # Act + executor = BashCommandExecutor() + + # Assert + assert executor.bash_command == expected_bash_command + mock_get_logger.assert_called_once_with('dedal.commands.bash_command_executor') + + @pytest.mark.parametrize( + "test_id, num_commands", [(1, 1), (5, 5), (0, 0)] + ) + def test_add_command_success_path(self, mocker, test_id: int, num_commands: int): + # Arrange + executor = BashCommandExecutor() + executor.logger = mocker.MagicMock() + commands = [MockCommand("some_command") for _ in range(num_commands)] + + # Act + for command in commands: + executor.add_command(command) + + # Assert + assert len(executor.sequence.commands) == num_commands + executor.logger.info.assert_has_calls( + [mocker.call("Adding command to the sequence: %s", command) for command in commands]) + + def test_add_command_invalid_type(self): + # Arrange + executor = BashCommandExecutor() + invalid_command = "not a command object" # type: ignore + + # Act + with pytest.raises(ValueError) as except_info: + executor.add_command(invalid_command) + + # Assert + assert str(except_info.value) == "Invalid command type. Use Command." + + @patch("dedal.commands.bash_command_executor.os_name", "unknown") + def test_init_unknown_os(self): + + # Act + executor = BashCommandExecutor() + + # Assert + assert executor.bash_command == ["undefined"] + + @pytest.mark.parametrize( + "test_id, commands, expected_output", + [ + ("single_command", [MockCommand("echo hello")], "hello\n"), + ("multiple_commands", [MockCommand("echo hello"), MockCommand("echo world")], "hello\nworld\n"), + ("command_with_pipe", [MockCommand("echo hello | grep hello")], "hello\n"), + + ], + ) + @patch("dedal.commands.bash_command_executor.subprocess.run") + def test_execute_success_path_posix(self, mock_subprocess_run, test_id, commands, expected_output, mocker): + # Arrange + executor = BashCommandExecutor() + executor.logger = mocker.MagicMock() + for cmd in commands: + executor.add_command(cmd) + mock_subprocess_run.return_value.stdout = expected_output + + # Act + stdout, err = executor.execute() + + # Assert + assert stdout == expected_output + assert err is None + executor.logger.info.assert_has_calls( + [mocker.call('Adding command to the sequence: %s', cmd) for cmd in commands] + + [mocker.call("Successfully executed command sequence, output: %s", + mock_subprocess_run.return_value.stdout)]) + + @patch("dedal.commands.bash_command_executor.subprocess.run", + side_effect=FileNotFoundError("Mock file not found error")) + def test_execute_file_not_found_error(self, mock_subprocess_run): + # Arrange + executor = BashCommandExecutor() + executor.bash_command = ["bash", "-c"] + executor.add_command(MockCommand("some_command")) + + # Act + stdout, err = executor.execute() + + # Assert + assert stdout is None + assert err == "Error: Bash Command: ['bash', '-c'] not found: Mock file not found error" + mock_subprocess_run.assert_called_once_with(['bash', '-c', 'some_command'], capture_output=True, text=True, + check=True, timeout=172800) + + @patch("dedal.commands.bash_command_executor.subprocess.run", + side_effect=subprocess.CalledProcessError(1, "some_command", stderr="Mock stderr")) + def test_execute_called_process_error(self, mock_subprocess_run, mocker): + # Arrange + mocker.patch("dedal.commands.bash_command_executor.os_name", "nt") + executor = BashCommandExecutor() + executor.add_command(MockCommand("failing_command")) + + # Act + stdout, err = executor.execute() + + # Assert + assert stdout is None + assert err == "Error: Command failed with exit code 1, Error Output: Mock stderr" + mock_subprocess_run.assert_called_once_with(['wsl', 'bash', '-c', 'failing_command'], capture_output=True, + text=True, check=True, timeout=172800) + + @pytest.mark.parametrize( + "test_id, exception, expected_error_message", + [ + ("permission_error", PermissionError("Mock permission denied"), + "Error: Permission denied: Mock permission denied"), + ("os_error", OSError("Mock OS error"), "Error: OS error occurred: Mock OS error"), + ("value_error", ValueError("Mock invalid argument"), + "Error: Invalid argument passed: Mock invalid argument"), + ("type_error", TypeError("Mock invalid type"), "Error: Invalid type for arguments: Mock invalid type"), + ("timeout_expired", subprocess.TimeoutExpired("some_command", 10), + "Error: Command timed out after 10 seconds"), + ("subprocess_error", subprocess.SubprocessError("Mock subprocess error"), + "Subprocess error occurred: Mock subprocess error"), + ], + ) + def test_execute_other_errors(self, test_id, exception, expected_error_message, mocker): + # Arrange + mocker.patch("dedal.commands.bash_command_executor.os_name", "nt") + with patch("dedal.commands.bash_command_executor.subprocess.run", side_effect=exception) as mock_subprocess_run: + executor = BashCommandExecutor() + executor.add_command(MockCommand("some_command")) + + # Act + stdout, err = executor.execute() + + # Assert + assert stdout is None + assert err == expected_error_message + mock_subprocess_run.assert_called_once_with(['wsl', 'bash', '-c', 'some_command'], capture_output=True, + text=True, check=True, timeout=172800) + + def test_execute_no_commands(self): + # Arrange + executor = BashCommandExecutor() + + # Act + with pytest.raises(ValueError) as except_info: + executor.execute() + + # Assert + assert str(except_info.value) == "No commands to execute." + + @patch("dedal.commands.bash_command_executor.subprocess.run") + def test_execute_happy_path_nt(self, mock_subprocess_run, mocker): + # Arrange + mocker.patch("dedal.commands.bash_command_executor.os_name", "nt") + executor = BashCommandExecutor() + executor.add_command(MockCommand("echo hello")) + mock_subprocess_run.return_value.stdout = "hello\n" + + # Act + stdout, err = executor.execute() + + # Assert + assert stdout == "hello\n" + assert err is None + assert executor.bash_command == ['wsl', 'bash', '-c'] + mock_subprocess_run.assert_called_once_with(['wsl', 'bash', '-c', 'echo hello'], capture_output=True, text=True, + check=True, timeout=172800) + + def test_execute_unknown_os(self, mocker): + # Arrange + errors = { + "posix": "Error: Bash Command: ['undefined'] not found: [Errno 2] No such file or directory: 'undefined'", + "nt": "Error: Bash Command: ['undefined'] not found: [WinError 2] The system cannot find the file " + 'specified' + } + original_os = os.name + expected_error = errors.get(original_os, "Error: Unknown OS") + mocker.patch("dedal.commands.bash_command_executor.os_name") + executor = BashCommandExecutor() + executor.add_command(MockCommand("echo hello")) + + # Act + assert executor.execute() == (None, expected_error) + + def test_reset(self, mocker): + # Test ID: reset + + # Arrange + executor = BashCommandExecutor() + mock_sequence = mocker.MagicMock() + executor.sequence = mock_sequence + + # Act + executor.reset() + + # Assert + mock_sequence.clear.assert_called_once() diff --git a/dedal/tests/unit_tests/test_build_cache_manager.py b/dedal/tests/unit_tests/test_build_cache_manager.py new file mode 100644 index 00000000..af5690eb --- /dev/null +++ b/dedal/tests/unit_tests/test_build_cache_manager.py @@ -0,0 +1,161 @@ +# Copyright (c) 2025 +# License Information: To be decided! +# +# File: test_build_cache_manager.py +# Description: Brief description of the file. +# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> +# Created on: 2025-02-20 +import pytest +from _pytest.fixtures import fixture + +from dedal.build_cache.BuildCacheManager import BuildCacheManager + + +class TestBuildCacheManager: + + @fixture(scope="function") + def mock_build_cache_manager(self, mocker): + mocker.patch("dedal.build_cache.BuildCacheManager.get_logger") + return BuildCacheManager("TEST_HOST", "TEST_PROJECT", "TEST_USERNAME", "TEST_PASSWORD", "TEST_VERSION") + + def test_get_public_key_from_cache_success_path(self, mock_build_cache_manager, tmp_path): + + # Arrange + build_cache_dir = tmp_path / "build_cache" + pgp_folder = build_cache_dir / "project" / "_pgp" + pgp_folder.mkdir(parents=True) + key_file = pgp_folder / "key.pub" + key_file.write_text("public key content") + + # Act + result = mock_build_cache_manager.get_public_key_from_cache(str(build_cache_dir)) + + # Assert + assert result == str(key_file) + + @pytest.mark.parametrize("test_id, num_pgp_folders, num_key_files, expected_log_message", [ + ("more_than_one_gpg_folder", 2, 1, + "More than one PGP folders found in the build cache: %s, using the first one in the list: %s"), + ("more_than_one_key_file", 1, 2, + "More than one PGP key files found in the build cache: %s, using the first one in the list: %s"), + ]) + def test_get_public_key_from_cache_multiple_files_or_folders(self, mock_build_cache_manager, test_id, + tmp_path, num_pgp_folders, + num_key_files, expected_log_message): + + # Arrange + pgp_folders = [] + key_files = [] + build_cache_dir = tmp_path / "build_cache" + for i in range(num_pgp_folders): + pgp_folder = build_cache_dir / f"project{i}" / "_pgp" + pgp_folders.append(str(pgp_folder)) + pgp_folder.mkdir(parents=True) + for j in range(num_key_files): + key_file = pgp_folder / f"key{j}.pub" + key_files.append(str(key_file)) + key_file.write_text(f"public key {j} content") + + # Act + result = mock_build_cache_manager.get_public_key_from_cache(str(build_cache_dir)) + + # Assert + # Cannot assure the order in which the OS returns the files, + # hence check if the result is in the expected list + assert result in [str(build_cache_dir / "project0" / "_pgp" / "key0.pub"), + str(build_cache_dir / "project0" / "_pgp" / "key1.pub"), + str(build_cache_dir / "project1" / "_pgp" / "key0.pub")] + assert mock_build_cache_manager._logger.warning.call_args[0][0] == expected_log_message + assert set(mock_build_cache_manager._logger.warning.call_args[0][1]) == set( + pgp_folders) if test_id == "more_than_one_gpg_folder" else set(key_files) + assert mock_build_cache_manager._logger.warning.call_args[0][ + 2] in pgp_folders if test_id == "more_than_one_gpg_folder" else key_files + + @pytest.mark.parametrize("build_cache_dir, expected_log_message", [ + (None, 'Build cache directory does not exist!'), + ("non_existent_dir", 'Build cache directory does not exist!'), + ]) + def test_get_public_key_from_cache_no_build_cache(self, mock_build_cache_manager, build_cache_dir, + expected_log_message, tmp_path): + + # Arrange + build_cache_dir = str(tmp_path / build_cache_dir) if build_cache_dir else None + + # Act + result = mock_build_cache_manager.get_public_key_from_cache(build_cache_dir) + + # Assert + assert result is None + mock_build_cache_manager._logger.warning.assert_called_once_with(expected_log_message) + + # Assert + assert result is None + mock_build_cache_manager._logger.warning.assert_called_once_with(expected_log_message) + + @pytest.mark.parametrize("build_cache_dir, expected_log_message", [ + ("non_existent_dir", "No _pgp folder found in the build cache!"), + ]) + def test_get_public_key_from_cache_no_pgp_folder(self, mock_build_cache_manager, build_cache_dir, + expected_log_message, tmp_path): + + # Arrange + if build_cache_dir == "non_existent_dir": + build_cache_dir = tmp_path / build_cache_dir + build_cache_dir.mkdir(parents=True) + + # Act + result = mock_build_cache_manager.get_public_key_from_cache(build_cache_dir) + + # Assert + assert result is None + mock_build_cache_manager._logger.warning.assert_called_once_with(expected_log_message) + + # Assert + assert result is None + mock_build_cache_manager._logger.warning.assert_called_once_with(expected_log_message) + + def test_get_public_key_from_cache_empty_pgp_folder(self, mock_build_cache_manager, tmp_path): + + # Arrange + build_cache_dir = tmp_path / "build_cache" + pgp_folder = build_cache_dir / "project" / "_pgp" + pgp_folder.mkdir(parents=True) + + # Act + result = mock_build_cache_manager.get_public_key_from_cache(str(build_cache_dir)) + + # Assert + assert result is None + mock_build_cache_manager._logger.warning.assert_called_once_with("No PGP key files found in the build cache!") + + @pytest.mark.parametrize("items, expected_log_message", [ + (["item1", "item2"], "test message item1 item2 item1"), + (["item1", "item2", "item3"], "test message item1 item2 item3 item1"), + ]) + def test_log_warning_if_needed_multiple_items(self, mock_build_cache_manager, items, expected_log_message): + # Test ID: multiple_items + + # Arrange + warn_message = "test message" + + # Act + mock_build_cache_manager._BuildCacheManager__log_warning_if_needed(warn_message, items) + + # Assert + mock_build_cache_manager._logger.warning.assert_called_once_with(warn_message, items, items[0]) + + @pytest.mark.parametrize("items", [ + [], + ["item1"], + ]) + def test_log_warning_if_needed_no_warning(self, mock_build_cache_manager, items): + # Test ID: no_warning + + # Arrange + warn_message = "test message" + + # Act + mock_build_cache_manager._BuildCacheManager__log_warning_if_needed(warn_message, items) + + # Assert + mock_build_cache_manager._logger.warning.assert_not_called() diff --git a/dedal/tests/unit_tests/test_command.py b/dedal/tests/unit_tests/test_command.py new file mode 100644 index 00000000..3c864041 --- /dev/null +++ b/dedal/tests/unit_tests/test_command.py @@ -0,0 +1,45 @@ +# Copyright (c) 2025 +# License Information: To be decided! +# +# File: test_command.py +# Description: Brief description of the file. +# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> +# Created on: 2025-02-18 + +import pytest + +from dedal.commands.command import Command + +class ConcreteCommand(Command): + def __init__(self, return_value: str): + self._return_value = return_value + + def execute(self) -> str: + return self._return_value + + +class TestCommand: + def test_execute_abstract_method(self): + # Act + with pytest.raises(TypeError): + Command() # type: ignore + + + @pytest.mark.parametrize( + "test_id, return_value", + [ + ("empty_string", ""), + ("non_empty_string", "some_command"), + ("string_with_spaces", "command with spaces"), + ("non_ascii_chars", "αβγδ"), + ], + ) + def test_execute_concrete_command(self, test_id, return_value): + # Arrange + command = ConcreteCommand(return_value) + + # Act + result = command.execute() + + # Assert + assert result == return_value diff --git a/dedal/tests/unit_tests/test_command_enum.py b/dedal/tests/unit_tests/test_command_enum.py new file mode 100644 index 00000000..f29e2b4c --- /dev/null +++ b/dedal/tests/unit_tests/test_command_enum.py @@ -0,0 +1,38 @@ +# Copyright (c) 2025 +# License Information: To be decided! +# +# File: test_command_enum.py +# Description: Brief description of the file. +# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> +# Created on: 2025-02-18 + +import pytest + +from dedal.commands.command_enum import CommandEnum + +class TestCommandEnum: + + @pytest.mark.parametrize( + "test_id, command_name, expected_value", + [ + ("source", "SOURCE", "source"), + ("list_files", "LIST_FILES", "list_files"), + ("spack_install", "SPACK_INSTALL", "spack_install"), + ], + ) + def test_command_enum_values(self, test_id, command_name, expected_value): + + # Act + command = CommandEnum[command_name] + + # Assert + assert command.value == expected_value + assert command.name == command_name + + + def test_command_enum_invalid_name(self): + + # Act + with pytest.raises(KeyError): + CommandEnum["INVALID_COMMAND"] # type: ignore + diff --git a/dedal/tests/unit_tests/test_command_runner.py b/dedal/tests/unit_tests/test_command_runner.py new file mode 100644 index 00000000..ac30fa08 --- /dev/null +++ b/dedal/tests/unit_tests/test_command_runner.py @@ -0,0 +1,125 @@ +# Copyright (c) 2025 +# License Information: To be decided! +# +# File: test_command_runner.py +# Description: Brief description of the file. +# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> +# Created on: 2025-02-18 + +import pytest + +from dedal.commands.command_enum import CommandEnum +from dedal.commands.command_runner import CommandRunner +from dedal.commands.command_sequence import CommandSequence +from dedal.commands.preconfigured_command_enum import PreconfiguredCommandEnum + + +class MockCommandSequence(CommandSequence): + def __init__(self, cmd_str: str): + self._cmd_str = cmd_str + + def execute(self) -> str: + return self._cmd_str + + +class TestCommandRunner: + @pytest.fixture(scope="function") + def mock_command_runner(self, mocker): + mocker.patch("dedal.commands.command_runner.BashCommandExecutor") + mocker.patch("dedal.commands.command_runner.logging.getLogger") + return CommandRunner() + + @pytest.mark.parametrize( + "test_id, command_str, mock_output, expected_output", + [ + ("simple_command", "echo hello", "hello\n", {"success": True, "output": "hello", "error": None}), + ("complex_command", "ls -l /tmp", "mock_output\n", + {"success": True, "output": "mock_output", "error": None}), + ("empty_output", "ls -l /tmp", "", {"success": True, "output": None, "error": None}), + ("command_with_error", "failing_command", "", {"success": False, "output": None, "error": "mock_error"}), + + ], + ) + def test_execute_command(self, mock_command_runner, test_id, command_str, mock_output, expected_output): + # Arrange + mock_command_runner.executor.execute.return_value = ( + mock_output, "mock_error" if "failing" in command_str else None) + command_sequence = MockCommandSequence(command_str) + + # Act + result = mock_command_runner.execute_command(command_sequence) + + # Assert + assert result == expected_output + mock_command_runner.executor.execute.assert_called_once() + mock_command_runner.executor.reset.assert_called_once() + + @pytest.mark.parametrize( + "test_id, command_type, args, expected_result", + [ + ( + "valid_command", + PreconfiguredCommandEnum.SPACK_COMPILER_FIND, + ["gcc"], + {"success": True, "output": "mock_output", "error": None}, + ), + ], + ) + def test_run_predefined_command_success_path(self, mock_command_runner, test_id, command_type, args, + expected_result): + # Arrange + mock_command_runner.executor.execute.return_value = ("mock_output\n", None) + + # Act + result = mock_command_runner.run_preconfigured_command_sequence(command_type, *args) + + # Assert + assert result == expected_result + + def test_run_predefined_command_invalid_type(self, mock_command_runner): + # Arrange + invalid_command_type = "INVALID_COMMAND" # type: ignore + + # Act + result = mock_command_runner.run_preconfigured_command_sequence(invalid_command_type) + + # Assert + assert result == {"success": False, "error": "Invalid command name: INVALID_COMMAND"} + + @pytest.mark.parametrize( + "test_id, command_placeholders_map, expected_result", + [ + ( + "single_command", + {CommandEnum.LIST_FILES: {"folder_location": "/tmp"}}, + {"success": True, "output": "mock_output", "error": None}, + ), + ( + "multiple_commands", + { + CommandEnum.LIST_FILES: {"folder_location": "/tmp"}, + CommandEnum.SHOW_DIRECTORY: {}, + }, + {"success": True, "output": "mock_output", "error": None}, + ), + ( + "empty_placeholders", + {CommandEnum.SHOW_DIRECTORY: {}}, + {"success": True, "output": "mock_output", "error": None}, + ), + ], + ) + def test_run_custom_command(self, mocker, mock_command_runner, test_id, command_placeholders_map, expected_result): + # Arrange + mock_command_runner.execute_command = mocker.MagicMock(return_value=expected_result) + mock_create_custom_command_sequence = mocker.patch( + "dedal.commands.command_runner.CommandSequenceFactory.create_custom_command_sequence") + mock_create_custom_command_sequence.return_value = MockCommandSequence("mock_command") + + # Act + result = mock_command_runner.run_custom_command_sequence(command_placeholders_map) + + # Assert + assert result == expected_result + mock_create_custom_command_sequence.assert_called_once_with(command_placeholders_map) + mock_command_runner.execute_command.assert_called_once_with(mock_create_custom_command_sequence.return_value) diff --git a/dedal/tests/unit_tests/test_command_sequence.py b/dedal/tests/unit_tests/test_command_sequence.py new file mode 100644 index 00000000..e663c06b --- /dev/null +++ b/dedal/tests/unit_tests/test_command_sequence.py @@ -0,0 +1,108 @@ +# Copyright (c) 2025 +# License Information: To be decided! +# +# File: test_command_sequence.py +# Description: Brief description of the file. +# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> +# Created on: 2025-02-19 +from unittest.mock import Mock + +import pytest + +from dedal.commands.command import Command +from dedal.commands.command_sequence import CommandSequence + + +class MockCommand(Command): + def __init__(self, cmd_str: str): + self._cmd_str = cmd_str + + def execute(self) -> str: + return self._cmd_str + + +class TestCommandSequence: + + @pytest.mark.parametrize( + "test_id, commands, expected_output", + [ + ("single_command", ["echo hello"], "echo hello"), + ("single_command_with_spaces at_beginning", [" pwd"], "pwd"), + ("single_command_with_spaces at end", [" pwd "], "pwd"), + ("multiple_commands_with_spaces", [" echo hello", " ls -l /tmp "], "echo hello && ls -l /tmp"), + ("multiple_commands", ["echo hello", "ls -l /tmp"], "echo hello && ls -l /tmp"), + ("multiple_commands_with_spaces_in_between_and_end", ["echo hello ", "ls -l /tmp "], + "echo hello && ls -l /tmp"), + ("empty_command", [""], ""), + ("commands_with_spaces", ["command with spaces", "another command"], + "command with spaces && another command"), + ], + ) + def test_execute_success_path(self, test_id, commands, expected_output): + # Arrange + sequence = CommandSequence() + for cmd in commands: + sequence.add_command(MockCommand(cmd)) + + # Act + result = sequence.execute() + + # Assert + assert result == expected_output + + def test_execute_no_commands(self): + # Arrange + sequence = CommandSequence() + + # Act + result = sequence.execute() + + # Assert + assert result == "" + + @pytest.mark.parametrize( + "test_id, num_commands", + [ + ("one_command", 1), + ("multiple_commands", 5), + ("no_commands", 0), + ], + ) + def test_add_command_success_path(self, test_id, num_commands): + # Arrange + sequence = CommandSequence() + commands = [MockCommand(f"command_{i}") for i in range(num_commands)] + + # Act + for command in commands: + sequence.add_command(command) + + # Assert + assert len(sequence.commands) == num_commands + + def test_add_command_invalid_type(self): + # Arrange + sequence = CommandSequence() + invalid_command = "not a command object" # type: ignore + + # Act + with pytest.raises(ValueError) as except_info: + sequence.add_command(invalid_command) + + # Assert + assert str(except_info.value) == "Command must be an instance of Command" + + @pytest.mark.parametrize("initial_commands", [[], [Mock(spec=Command), Mock(spec=Command)]]) + def test_clear(self, mocker, initial_commands): + # Test ID: clear + + # Arrange + sequence = CommandSequence() + for command in initial_commands: + sequence.add_command(command) + + # Act + sequence.clear() + + # Assert + assert len(sequence.commands) == 0 diff --git a/dedal/tests/unit_tests/test_command_sequence_builder.py b/dedal/tests/unit_tests/test_command_sequence_builder.py new file mode 100644 index 00000000..e4004255 --- /dev/null +++ b/dedal/tests/unit_tests/test_command_sequence_builder.py @@ -0,0 +1,95 @@ +# Copyright (c) 2025 +# License Information: To be decided! +# +# File: test_command_sequence_builder.py +# Description: Brief description of the file. +# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> +# Created on: 2025-02-19 + +from unittest.mock import Mock, patch + +import pytest + +from dedal.commands.command import Command +from dedal.commands.command_enum import CommandEnum +from dedal.commands.command_registry import CommandRegistry +from dedal.commands.command_sequence import CommandSequence +from dedal.commands.command_sequence_builder import CommandSequenceBuilder +from dedal.commands.shell_command_factory import ShellCommandFactory + + +class TestCommandSequenceBuilder: + + @pytest.mark.parametrize( + "test_id, command_name, placeholders, expected_command", + [ + ("no_placeholders", CommandEnum.SHOW_DIRECTORY, {}, ["pwd"]), + ("with_placeholders", CommandEnum.LIST_FILES, {"folder_location": "/tmp "}, ["ls", "-l", "/tmp"]), + ("missing_placeholders", CommandEnum.FIND_IN_FILE, {"search_term": "error"}, + ["grep", "-i", "error", "{file_path}"]), + ("missing_placeholders_values_1", CommandEnum.SPACK_MIRROR_ADD, {"autopush": ""}, + ["spack", "mirror", "add", "{signed}", "{mirror_name}","{mirror_path}"]), + ("missing_placeholders_values_2", CommandEnum.SPACK_MIRROR_ADD, {"autopush": "", "signed": ""}, + ["spack", "mirror", "add", "{mirror_name}", "{mirror_path}"]), + ("missing_placeholders_values_3", CommandEnum.SPACK_MIRROR_ADD, {"autopush": "", "signed": "", "mirror_name": "test", "mirror_path": "test_path"}, + ["spack", "mirror", "add", "test", "test_path"]), + ("extra_placeholders", CommandEnum.ECHO_MESSAGE, {"message": "hello", "extra": "world"}, ["echo", "hello"]), + ], + ) + def test_add_generic_command_success_path(self, mocker, test_id, command_name, + placeholders, expected_command): + # Arrange + mock_get_command = mocker.patch.object(CommandRegistry, "get_command") + mock_create_command = mocker.patch.object(ShellCommandFactory, "create_command") + builder = CommandSequenceBuilder() + mock_get_command.return_value = expected_command + mock_create_command.return_value = Mock(spec=Command, + execute=lambda: " ".join(expected_command) if expected_command else "") + + # Act + builder.add_generic_command(command_name, placeholders) + + # Assert + mock_get_command.assert_called_once_with(command_name) + mock_create_command.assert_called_once_with(*expected_command) + assert len(builder.sequence.commands) == 1 + assert builder.sequence.commands[0].execute() == " ".join(expected_command) if expected_command else "" + + def test_add_generic_command_invalid_command_type(self): + # Arrange + builder = CommandSequenceBuilder() + invalid_command_name = "invalid" # type: ignore + + # Act + with pytest.raises(ValueError) as except_info: + builder.add_generic_command(invalid_command_name, {}) + + # Assert + assert str(except_info.value) == "Invalid command type. Use CommandEnum." + + def test_add_generic_command_unknown_command(self, mocker): + # Arrange + mock_get_command = mocker.patch.object(CommandRegistry, "get_command") + builder = CommandSequenceBuilder() + mock_get_command.return_value = None + + # Act + with pytest.raises(ValueError) as except_info: + builder.add_generic_command(CommandEnum.LIST_FILES, {}) + + # Assert + assert str(except_info.value) == "Unknown command: CommandEnum.LIST_FILES" + + def test_build(self): + # Arrange + builder = CommandSequenceBuilder() + builder.add_generic_command(CommandEnum.SHOW_DIRECTORY, {}) + + # Act + sequence = builder.build() + + # Assert + assert isinstance(sequence, CommandSequence) + assert sequence.execute() == "pwd" + assert len(sequence.commands) == 1 + assert len(builder.sequence.commands) == 0 # Check if the builder's sequence is reset diff --git a/dedal/tests/unit_tests/test_command_sequence_factory.py b/dedal/tests/unit_tests/test_command_sequence_factory.py new file mode 100644 index 00000000..7048690a --- /dev/null +++ b/dedal/tests/unit_tests/test_command_sequence_factory.py @@ -0,0 +1,49 @@ +# Copyright (c) 2025 +# License Information: To be decided! +# +# File: test_command_sequence_factory.py +# Description: Brief description of the file. +# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> +# Created on: 2025-02-19 + +from unittest.mock import Mock, patch + +import pytest + +from dedal.commands.command_enum import CommandEnum +from dedal.commands.command_sequence import CommandSequence +from dedal.commands.command_sequence_builder import CommandSequenceBuilder +from dedal.commands.command_sequence_factory import CommandSequenceFactory + + +class TestCommandSequenceFactory: + + @pytest.mark.parametrize( + "test_id, command_placeholders_map, expected_calls", + [ + ("single_command", {CommandEnum.SHOW_DIRECTORY: {}}, [(CommandEnum.SHOW_DIRECTORY, {})]), + ( + "multiple_commands", + {CommandEnum.LIST_FILES: {"folder_location": "/tmp"}, CommandEnum.SHOW_DIRECTORY: {}}, + [(CommandEnum.LIST_FILES, {"folder_location": "/tmp"}), (CommandEnum.SHOW_DIRECTORY, {})], + ), + ("no_commands", {}, []), + ], + ) + def test_create_custom_command_sequence(self, mocker, test_id, command_placeholders_map, expected_calls): + # Arrange + mock_add_generic_command = mocker.patch.object(CommandSequenceBuilder, "add_generic_command") + mock_builder = Mock(spec=CommandSequenceBuilder, build=Mock(return_value=CommandSequence())) + mock_builder.add_generic_command = mock_add_generic_command + mock_add_generic_command.return_value = mock_builder + + with patch("dedal.commands.command_sequence_factory.CommandSequenceBuilder", return_value=mock_builder): + # Act + CommandSequenceFactory.create_custom_command_sequence(command_placeholders_map) + + # Assert + assert mock_add_generic_command.call_count == len(expected_calls) + mock_add_generic_command.assert_has_calls( + [mocker.call(args, kwargs) for args, kwargs in expected_calls], any_order=True + ) + mock_builder.build.assert_called_once() diff --git a/dedal/tests/unit_tests/test_generic_shell_command.py b/dedal/tests/unit_tests/test_generic_shell_command.py new file mode 100644 index 00000000..7ed779ae --- /dev/null +++ b/dedal/tests/unit_tests/test_generic_shell_command.py @@ -0,0 +1,63 @@ +# Copyright (c) 2025 +# License Information: To be decided! +# +# File: test_generic_shell_command.py +# Description: Brief description of the file. +# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> +# Created on: 2025-02-19 + +import pytest + +from dedal.commands.command import Command +from dedal.commands.generic_shell_command import GenericShellCommand + +class TestGenericShellCommand: + + @pytest.mark.parametrize( + "test_id, command_name, args, expected_output", + [ + ("no_args", "ls", [], "ls"), + ("single_arg", "echo", ["hello"], "echo hello"), + ("multiple_args", "grep", ["-i", "error", "file.txt"], "grep -i error file.txt"), + ("command_with_spaces", " ls ", ["-l", "/tmp"], "ls -l /tmp"), + ("args_with_spaces", "ls", [" -l ", " /tmp "], "ls -l /tmp"), + ("command_and_args_with_spaces", " ls ", [" -l ", " /tmp "], "ls -l /tmp"), + ("empty_args", "ls", [""], "ls "), # Empty arguments are preserved, but stripped + ], + ) + def test_execute_success_path(self, test_id, command_name, args, expected_output): + + # Act + command = GenericShellCommand(command_name, *args) + result = command.execute() + + # Assert + assert result == expected_output + + + @pytest.mark.parametrize( + "test_id, command_name, args, expected_error", + [ + ("empty_command_name", "", [], ValueError), + ("none_command_name", None, [], ValueError), # type: ignore + ("int_command_name", 123, [], ValueError), # type: ignore + ("invalid_arg_type", "ls", [123], ValueError), # type: ignore + ("mixed_arg_types", "ls", ["valid", 456], ValueError), # type: ignore + ], + ) + def test_init_error_cases(self, test_id, command_name, args, expected_error): + + # Act + with pytest.raises(expected_error) as except_info: + GenericShellCommand(command_name, *args) + + # Assert + if expected_error is ValueError: + if not command_name: + assert str(except_info.value) == "Command name is required!" + elif not isinstance(command_name, str): + assert str(except_info.value) == "Command name must be a string!" + else: + assert str(except_info.value) == "All arguments must be strings!" + + diff --git a/dedal/tests/unit_tests/test_preconfigured_command_enum.py b/dedal/tests/unit_tests/test_preconfigured_command_enum.py new file mode 100644 index 00000000..820c4d41 --- /dev/null +++ b/dedal/tests/unit_tests/test_preconfigured_command_enum.py @@ -0,0 +1,37 @@ +# Copyright (c) 2025 +# License Information: To be decided! +# +# File: test_preconfigured_command_enum.py +# Description: Brief description of the file. +# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> +# Created on: 2025-02-19 + +import pytest + +from dedal.commands.preconfigured_command_enum import PreconfiguredCommandEnum + +class TestPreconfiguredCommandEnum: + + @pytest.mark.parametrize( + "test_id, command_name, expected_value", + [ + ("spack_compiler_find", "SPACK_COMPILER_FIND", "spack_compiler_find"), + ("spack_compilers", "SPACK_COMPILERS", "spack_compilers"), + ("spack_install", "SPACK_INSTALL", "spack_install"), + ], + ) + def test_preconfigured_command_enum_values(self, test_id, command_name, expected_value): + + # Act + command = PreconfiguredCommandEnum[command_name] + + # Assert + assert command.value == expected_value + assert command.name == command_name + + + def test_preconfigured_command_enum_invalid_name(self): + + # Act + with pytest.raises(KeyError): + PreconfiguredCommandEnum["INVALID_COMMAND"] # type: ignore diff --git a/dedal/tests/unit_tests/test_shell_command_factory.py b/dedal/tests/unit_tests/test_shell_command_factory.py new file mode 100644 index 00000000..bfd2b2db --- /dev/null +++ b/dedal/tests/unit_tests/test_shell_command_factory.py @@ -0,0 +1,61 @@ +# Copyright (c) 2025 +# License Information: To be decided! +# +# File: test_shell_command_factory.py +# Description: Brief description of the file. +# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> +# Created on: 2025-02-19 + +import pytest + +from dedal.commands.generic_shell_command import GenericShellCommand +from dedal.commands.shell_command_factory import ShellCommandFactory + + +class TestShellCommandFactory: + + @pytest.mark.parametrize( + "test_id, command_name, args", + [ + ("no_args", "ls", []), + ("single_arg", "echo", ["hello"]), + ("multiple_args", "grep", ["-i", "error", "file.txt"]), + ("command_with_spaces", " ls ", ["-l", "/tmp"]), + ("args_with_spaces", "ls", [" -l ", " /tmp "]), + ("command_and_args_with_spaces", " ls ", [" -l ", " /tmp "]), + ], + ) + def test_create_command_happy_path(self, test_id, command_name, args): + + # Act + command = ShellCommandFactory.create_command(command_name, *args) + + # Assert + assert isinstance(command, GenericShellCommand) + assert command.command_name == command_name.strip() + assert command.args == tuple(map(str.strip, args)) + + @pytest.mark.parametrize( + "test_id, command_name, args, expected_error", + [ + ("empty_command_name", "", [], ValueError), + ("none_command_name", None, [], ValueError), # type: ignore + ("int_command_name", 123, [], ValueError), # type: ignore + ("invalid_arg_type", "ls", [123], ValueError), # type: ignore + ("mixed_arg_types", "ls", ["valid", 456], ValueError), # type: ignore + ], + ) + def test_create_command_error_cases(self, test_id, command_name, args, expected_error): + + # Act + with pytest.raises(expected_error) as except_info: + ShellCommandFactory.create_command(command_name, *args) + + # Assert + if expected_error is ValueError: + if not command_name: + assert str(except_info.value) == "Command name is required!" + elif not isinstance(command_name, str): + assert str(except_info.value) == "Command name must be a string!" + else: + assert str(except_info.value) == "All arguments must be strings!" diff --git a/dedal/tests/unit_tests/test_spack_command_sequence_factory.py b/dedal/tests/unit_tests/test_spack_command_sequence_factory.py new file mode 100644 index 00000000..0ffebcf3 --- /dev/null +++ b/dedal/tests/unit_tests/test_spack_command_sequence_factory.py @@ -0,0 +1,159 @@ +# Copyright (c) 2025 +# License Information: To be decided! +# +# File: test_spack_command_sequence_factory.py +# Description: Brief description of the file. +# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> +# Created on: 2025-02-19 + +import pytest + +from dedal.commands.command_enum import CommandEnum +from dedal.commands.command_sequence import CommandSequence +from dedal.commands.command_sequence_builder import CommandSequenceBuilder +from dedal.commands.spack_command_sequence_factory import SpackCommandSequenceFactory + + +class TestSpackCommandSequenceFactory: + + @pytest.mark.parametrize( + "test_id, spack_setup_script", + [ + ("with_setup_script", "/path/to/setup.sh"), + ("empty_setup_script", "")], + ) + def test_create_spack_enabled_command_sequence_builder(self, test_id, spack_setup_script): + # Act + builder = SpackCommandSequenceFactory.create_spack_enabled_command_sequence_builder(spack_setup_script) + + # Assert + assert isinstance(builder, CommandSequenceBuilder) + assert len(builder.sequence.commands) == 1 + assert builder.sequence.commands[0].command_name == "source" + if spack_setup_script: + assert builder.sequence.commands[0].args == ("/path/to/setup.sh",) + else: + assert len(builder.sequence.commands[0].args) == 0 + + @pytest.mark.parametrize( + "test_id, method_name, args, kwargs, expected_command, expected_args", + [ + ( + "spack_compilers", "create_spack_compilers_command_sequence", ["/path/to/setup.sh"], {}, "spack", + ["compilers"] + ), + ( + "spack_compiler_list", "create_spack_compiler_list_command_sequence", ["/path/to/setup.sh"], {}, + "spack", ["compiler", "list"] + ), + ( + "spack_install_with_version", "create_spack_install_package_command_sequence", + ["/path/to/setup.sh", "package", "1.0"], {}, "spack", ["install", "package@1.0"] + ), + ( + "spack_install_no_version", "create_spack_install_package_command_sequence", + ["/path/to/setup.sh", "package", None], {}, "spack", ["install", "package"] + ), + ( + "spack_install_no_package", "create_spack_install_package_command_sequence", + ["/path/to/setup.sh", None, "1.0"], {}, "spack", ["install"] + ), + ( + "spack_compiler_info", "create_spack_compiler_info_command_sequence", + ["/path/to/setup.sh", "gcc", "10.2"], {}, "spack", ["compiler", "info", "gcc@10.2"] + ), + ], + ) + def test_create_command_sequence(self, test_id, method_name, args, kwargs, expected_command, expected_args): + # Arrange + method = getattr(SpackCommandSequenceFactory, method_name) + + # Act + sequence = method(*args, **kwargs) + + # Assert + assert isinstance(sequence, CommandSequence) + assert len(sequence.commands) == 2 + assert sequence.commands[1].command_name == expected_command + assert sequence.commands[1].args == tuple(expected_args) + + @pytest.mark.parametrize( + "test_id, env_name, mirror_name, mirror_path, autopush, signed, expected_length, expected_autopush, expected_signed, expected_output", + [ + ("no_env", "", "mymirror", "/path/to/mirror", False, False, 2, "", "", + ("mirror", "add", "mymirror", "/path/to/mirror")), + ("with_env", "myenv", "mymirror", "/path/to/mirror", True, True, 3, "--autopush", "--signed", + ("mirror", "add", "--autopush", "--signed", "mymirror", "/path/to/mirror")), + ], + ) + def test_create_spack_mirror_add_command_sequence(self, test_id, env_name, mirror_name, mirror_path, autopush, + signed, expected_length, expected_autopush, expected_signed, + expected_output): + # Arrange + spack_setup_script = "/path/to/setup.sh" + + # Act + sequence = SpackCommandSequenceFactory.create_spack_mirror_add_command_sequence( + spack_setup_script, env_name, mirror_name, mirror_path, autopush, signed + ) + + # Assert + assert isinstance(sequence, CommandSequence) + assert len(sequence.commands) == expected_length + assert sequence.commands[-1].command_name == "spack" + assert sequence.commands[-1].args == expected_output + + @pytest.mark.parametrize( + "test_id, package_name, version, expected_package_arg", + [ + ("with_package_and_version", "mypackage", "1.2.3", "mypackage@1.2.3"), + ("only_package_name", "mypackage", None, "mypackage"), + ("no_package", None, "1.2.3", ""), + ], + ) + def test_create_spack_post_install_find_command_sequence(self, test_id, package_name, version, + expected_package_arg): + # Arrange + spack_setup_script = "/path/to/setup.sh" + env_path = "/path/to/env" + + # Act + sequence = SpackCommandSequenceFactory.create_spack_post_install_find_command_sequence( + spack_setup_script, env_path, package_name, version + ) + + # Assert + assert isinstance(sequence, CommandSequence) + assert len(sequence.commands) == 3 + assert sequence.commands[1].command_name == "spack" + assert sequence.commands[1].args == ("env", "activate", "-p", env_path) + assert sequence.commands[2].command_name == "spack" + assert sequence.commands[2].args == ("find", expected_package_arg) if expected_package_arg else ("find",) + + @pytest.mark.parametrize("spack_setup_script, public_key_path", [ + ("/path/to/setup-env.sh", "key.gpg"), + ("./setup-env.sh", "path/to/key.gpg"), + ]) + def test_create_spack_gpg_trust_command_sequence(self, mocker, spack_setup_script, public_key_path): + # Test ID: create_spack_gpg_trust_command_sequence + + # Arrange + mock_command_sequence_builder = mocker.MagicMock() + mocker.patch.object(SpackCommandSequenceFactory, "create_spack_enabled_command_sequence_builder", + return_value=mock_command_sequence_builder) + + mock_command_sequence = mocker.MagicMock() + mock_command_sequence_builder.add_generic_command.return_value = mock_command_sequence_builder + mock_command_sequence_builder.build.return_value = mock_command_sequence + + # Act + result = SpackCommandSequenceFactory.create_spack_gpg_trust_command_sequence(spack_setup_script, + public_key_path) + + # Assert + assert result == mock_command_sequence + SpackCommandSequenceFactory.create_spack_enabled_command_sequence_builder.assert_called_once_with( + spack_setup_script) + mock_command_sequence_builder.add_generic_command.assert_called_once_with(CommandEnum.SPACK_GPG_TRUST, + {"public_key_path": public_key_path}) + mock_command_sequence_builder.build.assert_called_once() diff --git a/dedal/tests/unit_tests/test_spack_operation.py b/dedal/tests/unit_tests/test_spack_operation.py new file mode 100644 index 00000000..f053459c --- /dev/null +++ b/dedal/tests/unit_tests/test_spack_operation.py @@ -0,0 +1,209 @@ +# Copyright (c) 2025 +# License Information: To be decided! +# +# File: test_spack_operation.py +# Description: Brief description of the file. +# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> +# Created on: 2025-02-19 + +import pytest +from _pytest.fixtures import fixture + +from dedal.build_cache.BuildCacheManager import BuildCacheManager +from dedal.commands.preconfigured_command_enum import PreconfiguredCommandEnum +from dedal.error_handling.exceptions import NoSpackEnvironmentException +from dedal.spack_factory.SpackOperation import SpackOperation + + +class TestSpackOperationAddMirrorWithComposite: + + @fixture + def mock_spack_operation(self, mocker): + mocker.resetall() + mocker.patch("dedal.spack_factory.SpackOperation.CommandRunner") + mocker.patch("dedal.spack_factory.SpackOperation.get_logger") + mock_object = SpackOperation() + mock_object.logger = mocker.MagicMock() + return mock_object + + @pytest.mark.parametrize( + "test_id, global_mirror, env_path, mirror_name, mirror_path, autopush, signed, expected_result, expected_env_arg", + [ + ("global_mirror", True, None, "global_mirror", "/path/to/global/mirror", False, False, True, ""), + ("local_mirror", False, "/path/to/env", "local_mirror", "/path/to/local/mirror", True, True, True, + "/path/to/env"), + ], + ) + def test_add_mirror_with_composite_success_path(self, mock_spack_operation, test_id, global_mirror, env_path, + mirror_name, mirror_path, autopush, signed, expected_result, + expected_env_arg): + # Arrange + mock_spack_operation.spack_setup_script = "setup.sh" + mock_spack_operation.env_path = env_path + mock_spack_operation.command_runner.run_preconfigured_command_sequence.return_value = {"success": True, + "error": None, + "output": None} + + # Act + result = mock_spack_operation.add_mirror(mirror_name, mirror_path, signed, autopush, + global_mirror) + + # Assert + assert result == expected_result + mock_spack_operation.command_runner.run_preconfigured_command_sequence.assert_called_once_with( + PreconfiguredCommandEnum.SPACK_MIRROR_ADD, + "setup.sh", + expected_env_arg, + mirror_name, + mirror_path, + autopush, + signed, + ) + mock_spack_operation.logger.info.assert_called_once_with("Added mirror %s", mirror_name) + + @pytest.mark.parametrize( + "test_id, mirror_name, mirror_path, expected_error", + [ + ("missing_mirror_name", "", "/path/to/mirror", ValueError), + ("missing_mirror_path", "mirror_name", "", ValueError), + ("missing_both", "", "", ValueError), + ], + ) + def test_add_mirror_with_composite_missing_args(self, mock_spack_operation, test_id, mirror_name, mirror_path, + expected_error): + # Arrange + mock_spack_operation.spack_setup_script = "setup.sh" + + # Act + with pytest.raises(expected_error) as except_info: + mock_spack_operation.add_mirror(mirror_name, mirror_path) + + # Assert + assert str(except_info.value) == "mirror_name and mirror_path are required" + + def test_add_mirror_with_composite_no_env(self, mock_spack_operation): + # Arrange + mock_spack_operation.spack_setup_script = "setup.sh" + mock_spack_operation.env_path = None + + # Act + with pytest.raises(NoSpackEnvironmentException) as except_info: + mock_spack_operation.add_mirror("mirror_name", "/path/to/mirror") + + # Assert + assert str(except_info.value) == "No spack environment defined" + + def test_add_mirror_with_composite_command_failure(self, mock_spack_operation): + # Arrange + mock_spack_operation.spack_setup_script = "setup.sh" + mock_spack_operation.env_path = "/path/to/env" + mock_spack_operation.command_runner.run_preconfigured_command_sequence.return_value = {"success": False, + "error": "Error: Command failed with exit code 1, Error Output: ==> Error: Mirror with name mirror_name already exists.", + "output": None} + + # Act + result = mock_spack_operation.add_mirror("mirror_name", "/path/to/mirror") + + # Assert + assert result is False + mock_spack_operation.command_runner.run_preconfigured_command_sequence.assert_called_once_with( + PreconfiguredCommandEnum.SPACK_MIRROR_ADD, + "setup.sh", + "/path/to/env", + "mirror_name", + "/path/to/mirror", + False, + False + ) + mock_spack_operation.logger.error.assert_called_once_with('Failed to add mirror %s, reason: %s, output: %s', + 'mirror_name', + 'Error: Command failed with exit code 1, Error Output: ==> Error: Mirror with ' + 'name mirror_name already exists.', + None) + + @pytest.mark.parametrize("result, expected_log_message, expected_return_value", [ + ({"success": True, "output": "test output"}, "test info message", True), + ({"success": True, "error": "test error", "output": "test output"}, "test info message", True), + ]) + def test_handle_result_success(self, mock_spack_operation, result, expected_log_message, expected_return_value): + # Test ID: success + + # Arrange + error_msg = "test error message" + error_msg_args = ("arg1", "arg2") + info_msg = "test info message" + info_msg_args = ("arg3", "arg4") + + # Act + return_value = mock_spack_operation.handle_result(result, error_msg, error_msg_args, info_msg, info_msg_args) + + # Assert + assert return_value == expected_return_value + mock_spack_operation.logger.info.assert_called_once_with(info_msg, *info_msg_args) + mock_spack_operation.logger.error.assert_not_called() + + @pytest.mark.parametrize("result, expected_log_message", [ + ({"success": False, "error": "test error", "output": "test output"}, + "test error message arg1 arg2 test error test output"), + ({"success": False, "error": None, "output": None}, "test error message arg1 arg2 None None"), + ]) + def test_handle_result_failure(self, mock_spack_operation, result, expected_log_message): + # Test ID: failure + + # Arrange + error_msg = "test error message" + error_msg_args = ("arg1", "arg2") + info_msg = "test info message" + info_msg_args = ("arg3", "arg4") + + # Act + return_value = mock_spack_operation.handle_result(result, error_msg, error_msg_args, info_msg, info_msg_args) + + # Assert + assert return_value is False + mock_spack_operation.logger.error.assert_called_once_with(error_msg, *error_msg_args, result["error"], + result["output"]) + mock_spack_operation.logger.info.assert_not_called() + + @pytest.mark.parametrize("public_key_path, result_success, expected_log_message", [ + ("test_key.gpg", True, ('Added public key %s as trusted', 'test_key.gpg')), + ("test_key.gpg", False, + ('Failed to add public key %s as trusted, reason: %s, output: %s', + 'test_key.gpg', + 'test_error', + 'test_output')), + ]) + def test_trust_gpg_key(self, mock_spack_operation, public_key_path, result_success, expected_log_message): + # Test ID: trust_gpg_key + + # Arrange + mock_result = {"success": result_success, "error": "test_error", "output": "test_output"} + mock_spack_operation.command_runner.run_preconfigured_command_sequence.return_value = mock_result + + # Act + if result_success: + result = mock_spack_operation.trust_gpg_key(public_key_path) + else: + result = mock_spack_operation.trust_gpg_key(public_key_path) + + # Assert + if result_success: + assert result is True + mock_spack_operation.logger.info.assert_called_once_with(*expected_log_message) + else: + assert result is False + mock_spack_operation.logger.error.assert_called_once_with(*expected_log_message) + + mock_spack_operation.command_runner.run_preconfigured_command_sequence.assert_called_once_with( + PreconfiguredCommandEnum.SPACK_GPG_TRUST, + mock_spack_operation.spack_setup_script, + public_key_path + ) + + def test_trust_gpg_key_empty_path(self, mock_spack_operation): + # Test ID: empty_path + + # Act & Assert + with pytest.raises(ValueError) as except_info: + mock_spack_operation.trust_gpg_key("") + assert str(except_info.value) == "public_key_path is required" diff --git a/dedal/tests/unit_tests/test_spack_operation_use_cache.py b/dedal/tests/unit_tests/test_spack_operation_use_cache.py new file mode 100644 index 00000000..fe5d9da3 --- /dev/null +++ b/dedal/tests/unit_tests/test_spack_operation_use_cache.py @@ -0,0 +1,89 @@ +# Copyright (c) 2025 +# License Information: To be decided! +# +# File: test_spack_operation_use_cache.py +# Description: Brief description of the file. +# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> +# Created on: 2025-02-20 +from pathlib import Path + +import pytest + +from dedal.error_handling.exceptions import NoSpackEnvironmentException +from dedal.spack_factory.SpackOperationUseCache import SpackOperationUseCache + + +@pytest.fixture +def spack_operation_use_cache_mock(mocker): + super_mock = mocker.patch("dedal.spack_factory.SpackOperationUseCache.super") + super_mock.return_value.setup_spack_env = mocker.MagicMock() + mocker.patch("dedal.spack_factory.SpackOperationUseCache.BuildCacheManager") + mock_spack_operation_use_cache = SpackOperationUseCache() + mock_spack_operation_use_cache.build_cache = mocker.MagicMock() + mock_spack_operation_use_cache.spack_config = mocker.MagicMock() + mock_spack_operation_use_cache.spack_config.buildcache_dir = Path("path/to/buildcache") + mock_spack_operation_use_cache.logger = mocker.MagicMock() + return mock_spack_operation_use_cache + + +class TestSpackOperationUseCache: + + @pytest.mark.parametrize("test_id, signed, key_path", [ + ("key_path_exists", True, "path/to/key.gpg"), + ("key_path_does_not_exist", False, None)]) + def test_setup_spack_env(self, mocker, spack_operation_use_cache_mock, test_id, signed, key_path): + # Test ID: setup_spack_env_success + super_mock = mocker.patch("dedal.spack_factory.SpackOperationUseCache.super") + spack_operation_use_cache_mock.trust_gpg_key = mocker.MagicMock() + spack_operation_use_cache_mock.add_mirror = mocker.MagicMock() + + # Arrange + spack_operation_use_cache_mock.build_cache.get_public_key_from_cache.return_value = key_path + spack_operation_use_cache_mock.trust_gpg_key.return_value = signed + spack_operation_use_cache_mock.add_mirror.return_value = None + + # Act + spack_operation_use_cache_mock.setup_spack_env() + + # Assert + spack_operation_use_cache_mock.build_cache.download.assert_called_once_with( + spack_operation_use_cache_mock.spack_config.buildcache_dir) + spack_operation_use_cache_mock.build_cache.get_public_key_from_cache.assert_called_once_with( + str(spack_operation_use_cache_mock.spack_config.buildcache_dir)) + + if key_path: + spack_operation_use_cache_mock.trust_gpg_key.assert_called_once_with(key_path) + else: + spack_operation_use_cache_mock.trust_gpg_key.assert_not_called() + + if not signed: + spack_operation_use_cache_mock.logger.warning.assert_called_once_with( + "Public key not found in cache or failed to trust pgp keys!") + + spack_operation_use_cache_mock.add_mirror.assert_called_once_with( + 'local_cache', + str(spack_operation_use_cache_mock.spack_config.buildcache_dir), + signed=signed, + autopush=True, + global_mirror=False + ) + super_mock.return_value.setup_spack_env.assert_called_once() # call original method + + @pytest.mark.parametrize("exception_type", [ValueError, NoSpackEnvironmentException]) + def test_setup_spack_env_exceptions(self, mocker, spack_operation_use_cache_mock, exception_type): + # Test ID: setup_spack_env_exceptions + spack_operation_use_cache_mock.trust_gpg_key = mocker.MagicMock() + spack_operation_use_cache_mock.add_mirror = mocker.MagicMock() + + # Arrange + spack_operation_use_cache_mock.build_cache.get_public_key_from_cache.return_value = "path/to/key.gpg" + spack_operation_use_cache_mock.trust_gpg_key.return_value = True + exception = exception_type("test exception") + spack_operation_use_cache_mock.add_mirror.side_effect = exception + + # Act & Assert + with pytest.raises(exception_type): + spack_operation_use_cache_mock.setup_spack_env() + + spack_operation_use_cache_mock.logger.error.assert_called_once_with("Error adding buildcache mirror: %s", + exception) diff --git a/dedal/utils/bootstrap.sh b/dedal/utils/bootstrap.sh index d103e440..9cd2e1e1 100644 --- a/dedal/utils/bootstrap.sh +++ b/dedal/utils/bootstrap.sh @@ -1,4 +1,4 @@ -# Minimal prerequisites for installing the dedal library +# Minimal prerequisites for installing the esd_library # pip must be installed on the OS echo "Bootstrapping..." set -euo pipefail diff --git a/pyproject.toml b/pyproject.toml index aad18fa5..c7ea2762 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,19 +6,17 @@ build-backend = "setuptools.build_meta" name = "dedal" version = "0.1.0" authors = [ - {name = "Eric Müller", email = "mueller@kip.uni-heidelberg.de"}, - {name = "Adrian Ciu", email = "adrian.ciu@codemart.ro"}, + { name = "Eric Müller", email = "mueller@kip.uni-heidelberg.de" }, + { name = "Adrian Ciu", email = "adrian.ciu@codemart.ro" }, + { name = "Jithu Murugan", email = "j.murugan@fz-juelich.de" } ] -description = "This package provides all the necessary tools to create an Ebrains Software Distribution environment" +description = "This package includes all the essential tools required to set up an EBRAINS Software Distribution environment." readme = "README.md" requires-python = ">=3.10" dependencies = [ "oras", "spack", "ruamel.yaml", - "pytest", - "pytest-mock", - "pytest-ordering", "click", "jsonpickle", ] @@ -27,4 +25,8 @@ dependencies = [ dedal = "dedal.cli.spack_manager_api:cli" [tool.setuptools.data-files] -"dedal" = ["dedal/logger/logging.conf"] \ No newline at end of file +"dedal" = ["dedal/logger/logging.conf"] + +[project.optional-dependencies] +test = ["pytest", "pytest-mock", "pytest-ordering", "coverage"] +dev = ["mypy", "pylint", "black", "flake8"] \ No newline at end of file -- GitLab From 7d2d3614902417ca69107542ebb8f627a0dfd625 Mon Sep 17 00:00:00 2001 From: adrianciu <adrian.ciu@codemart.ro> Date: Wed, 12 Mar 2025 12:20:25 +0200 Subject: [PATCH 28/30] dev: preparing for release --- dedal/build_cache/BuildCacheManager.py | 8 +- dedal/commands/__init__.py | 8 - dedal/commands/bash_command_executor.py | 100 -------- dedal/commands/command.py | 29 --- dedal/commands/command_enum.py | 37 --- dedal/commands/command_registry.py | 59 ----- dedal/commands/command_sequence.py | 54 ---- dedal/commands/command_sequence_builder.py | 71 ------ dedal/commands/command_sequence_factory.py | 46 ---- dedal/commands/generic_shell_command.py | 47 ---- dedal/commands/shell_command_factory.py | 33 --- .../spack_command_sequence_factory.py | 211 ---------------- dedal/spack_factory/SpackOperation.py | 117 ++++----- dedal/spack_factory/SpackOperationUseCache.py | 12 + .../spack_from_cache_test.py | 23 +- dedal/tests/spack_from_scratch_test.py | 204 --------------- dedal/tests/spack_install_test.py | 12 - .../unit_tests/test_bash_command_executor.py | 236 ------------------ .../unit_tests/test_build_cache_manager.py | 7 - dedal/tests/unit_tests/test_command.py | 45 ---- dedal/tests/unit_tests/test_command_enum.py | 38 --- dedal/tests/unit_tests/test_command_runner.py | 125 ---------- .../tests/unit_tests/test_command_sequence.py | 108 -------- .../test_command_sequence_builder.py | 95 ------- .../test_command_sequence_factory.py | 49 ---- .../unit_tests/test_generic_shell_command.py | 63 ----- .../test_preconfigured_command_enum.py | 37 --- .../unit_tests/test_shell_command_factory.py | 61 ----- .../test_spack_command_sequence_factory.py | 159 ------------ .../tests/unit_tests/test_spack_operation.py | 8 - .../test_spack_operation_use_cache.py | 7 - dedal/utils/utils.py | 6 + pyproject.toml | 3 +- 33 files changed, 87 insertions(+), 2031 deletions(-) delete mode 100644 dedal/commands/__init__.py delete mode 100644 dedal/commands/bash_command_executor.py delete mode 100644 dedal/commands/command.py delete mode 100644 dedal/commands/command_enum.py delete mode 100644 dedal/commands/command_registry.py delete mode 100644 dedal/commands/command_sequence.py delete mode 100644 dedal/commands/command_sequence_builder.py delete mode 100644 dedal/commands/command_sequence_factory.py delete mode 100644 dedal/commands/generic_shell_command.py delete mode 100644 dedal/commands/shell_command_factory.py delete mode 100644 dedal/commands/spack_command_sequence_factory.py delete mode 100644 dedal/tests/spack_from_scratch_test.py delete mode 100644 dedal/tests/spack_install_test.py delete mode 100644 dedal/tests/unit_tests/test_bash_command_executor.py delete mode 100644 dedal/tests/unit_tests/test_command.py delete mode 100644 dedal/tests/unit_tests/test_command_enum.py delete mode 100644 dedal/tests/unit_tests/test_command_runner.py delete mode 100644 dedal/tests/unit_tests/test_command_sequence.py delete mode 100644 dedal/tests/unit_tests/test_command_sequence_builder.py delete mode 100644 dedal/tests/unit_tests/test_command_sequence_factory.py delete mode 100644 dedal/tests/unit_tests/test_generic_shell_command.py delete mode 100644 dedal/tests/unit_tests/test_preconfigured_command_enum.py delete mode 100644 dedal/tests/unit_tests/test_shell_command_factory.py delete mode 100644 dedal/tests/unit_tests/test_spack_command_sequence_factory.py diff --git a/dedal/build_cache/BuildCacheManager.py b/dedal/build_cache/BuildCacheManager.py index 62cb9af1..3b96cd09 100644 --- a/dedal/build_cache/BuildCacheManager.py +++ b/dedal/build_cache/BuildCacheManager.py @@ -34,11 +34,11 @@ class BuildCacheManager(BuildCacheManagerInterface): self.cache_version = cache_version self._oci_registry_path = f'{self._registry_host}/{self._registry_project}/{self.cache_version}' - def upload(self, out_dir: Path): + def upload(self, upload_dir: Path): """ This method pushed all the files from the build cache folder into the OCI Registry """ - build_cache_path = out_dir.resolve() + build_cache_path = upload_dir.resolve() # build cache folder must exist before pushing all the artifacts if not build_cache_path.exists(): self._logger.error(f"Path {build_cache_path} not found.") @@ -73,11 +73,11 @@ class BuildCacheManager(BuildCacheManagerInterface): self._logger.error(f"Failed to list tags: {e}") return None - def download(self, in_dir: Path): + def download(self, download_dir: Path): """ This method pulls all the files from the OCI Registry into the build cache folder """ - build_cache_path = in_dir.resolve() + build_cache_path = download_dir.resolve() # create the buildcache dir if it does not exist os.makedirs(build_cache_path, exist_ok=True) tags = self.list_tags() diff --git a/dedal/commands/__init__.py b/dedal/commands/__init__.py deleted file mode 100644 index ea9c384e..00000000 --- a/dedal/commands/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -# Copyright (c) 2025 -# License Information: To be decided! -# -# File: __init__.py -# Description: Brief description of the file. -# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> -# Created on: 2025-02-17 - diff --git a/dedal/commands/bash_command_executor.py b/dedal/commands/bash_command_executor.py deleted file mode 100644 index aef9c576..00000000 --- a/dedal/commands/bash_command_executor.py +++ /dev/null @@ -1,100 +0,0 @@ -""" Bash Command Executor module. """ -# Copyright (c) 2025 -# License Information: To be decided! -# -# File: bash_command_executor.py -# Description: Brief description of the file. -# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> -# Created on: 2025-02-17 -import logging -import subprocess -from logging import Logger -from os import name as os_name - -from dedal.commands.command import Command -from dedal.commands.command_sequence import CommandSequence - - -class BashCommandExecutor: - """Executes commands in a Bash shell. - - Manages a sequence of commands and executes them in a single Bash session, - handling potential errors during execution. - """ - - def __init__(self) -> None: - self.logger: Logger = logging.getLogger(__name__) - self.sequence: CommandSequence = CommandSequence() - command_map: dict[str, list[str]] = { - "nt": ["wsl", "bash", "-c"], # Works only if wsl is installed on windows - "posix": ["bash", "-c"], - } - self.bash_command: list[str] = command_map.get(os_name, ["undefined"]) - - def add_command(self, command: Command) -> None: - """Adds a command to the sequence. - - Appends the given command to the internal sequence of commands to be executed. - - Args: - command (Command): The command to add. - - Raises: - ValueError: If the command is not an instance of the `Command` class. - """ - if not isinstance(command, Command): - raise ValueError("Invalid command type. Use Command.") - self.logger.info("Adding command to the sequence: %s", command) - self.sequence.add_command(command) - - def execute(self) -> tuple[str | None, str | None]: - """Executes all commands in a single Bash session. - - Runs the accumulated commands in a Bash shell and returns the output. - Handles various potential errors during execution. The execution is time - - Returns (tuple[str | None, str | None]): - A tuple containing the output and error message (if any). - output will be None if an error occurred, and the error message will - contain details about the error. - - Raises: - ValueError: If no commands have been added to the sequence. - """ - if not self.sequence.commands: - raise ValueError("No commands to execute.") - try: - result = subprocess.run( - [*self.bash_command, self.sequence.execute()], - capture_output=True, - text=True, - check=True, - timeout=172800 # Given a default timeout of 48 hours - ) - self.logger.info("Successfully executed command sequence, output: %s", result.stdout) - return result.stdout, None - except FileNotFoundError as e: - error = f"Error: Bash Command: {self.bash_command} not found: {e}" - except subprocess.CalledProcessError as e: - error = (f"Error: Command failed with exit code " - f"{e.returncode}, Error Output: {e.stderr}") - except PermissionError as e: - error = f"Error: Permission denied: {e}" - except OSError as e: - error = f"Error: OS error occurred: {e}" - except ValueError as e: - error = f"Error: Invalid argument passed: {e}" - except TypeError as e: - error = f"Error: Invalid type for arguments: {e}" - except subprocess.TimeoutExpired as e: - error = f"Error: Command timed out after {e.timeout} seconds" - except subprocess.SubprocessError as e: - error = f"Subprocess error occurred: {e}" - return None, error - - def reset(self) -> None: - """Resets the command executor. - - Clears the internal command sequence, preparing the executor for a new set of commands. - """ - self.sequence.clear() diff --git a/dedal/commands/command.py b/dedal/commands/command.py deleted file mode 100644 index 07e9837c..00000000 --- a/dedal/commands/command.py +++ /dev/null @@ -1,29 +0,0 @@ -""" Command module. """ -# Copyright (c) 2025 -# License Information: To be decided! -# -# File: command.py -# Description: Abstract base class for executable commands -# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> -# Created on: 2025-02-17 - -from abc import ABC, abstractmethod - - -class Command(ABC): - """Abstract base class for executable commands. - - Provides a common interface for defining and executing commands. - Subclasses must implement the `execute` method. - """ - - @abstractmethod - def execute(self) -> str: - """Executes the command. - - This method must be implemented by subclasses to define the specific - behavior of the command. - - Returns: - str: The result of the command execution. - """ diff --git a/dedal/commands/command_enum.py b/dedal/commands/command_enum.py deleted file mode 100644 index 7ef184cb..00000000 --- a/dedal/commands/command_enum.py +++ /dev/null @@ -1,37 +0,0 @@ -""" Command Enum module. """ -# Copyright (c) 2025 -# License Information: To be decided! -# -# File: command_enum.py -# Description: Enumeration of supported commands in command registry -# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> -# Created on: 2025-02-17 - -from enum import Enum - - -class CommandEnum(Enum): - """Enumeration of supported commands. - - Provides a standardized way to refer to different command types, - including Linux commands and Spack commands. - """ - # Linux commands_ - - SOURCE = "source" - LIST_FILES = "list_files" - SHOW_DIRECTORY = "show_directory" - FIND_IN_FILE = "find_in_file" - ECHO_MESSAGE = "echo_message" - CHANGE_DIRECTORY = "change_directory" - - # Spack commands_ - SPACK_COMPILER_FIND = "spack_compiler_find" - SPACK_COMPILERS = "spack_compilers" - SPACK_COMPILER_INFO = "spack_compiler_info" - SPACK_COMPILER_LIST = "spack_compiler_list" - SPACK_ENVIRONMENT_ACTIVATE = "spack_environment_activate" - SPACK_FIND = "spack_find" - SPACK_INSTALL = "spack_install" - SPACK_MIRROR_ADD = "spack_mirror_add" - SPACK_GPG_TRUST = "spack_gpg_trust" diff --git a/dedal/commands/command_registry.py b/dedal/commands/command_registry.py deleted file mode 100644 index adaa3d6e..00000000 --- a/dedal/commands/command_registry.py +++ /dev/null @@ -1,59 +0,0 @@ -""" Command Registry module. """ -# Copyright (c) 2025 -# License Information: To be decided! -# -# File: command_registry.py -# Description: Registry for storing and retrieving shell commands with placeholders -# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> -# Created on: 2025-02-17 - -from dedal.commands.command_enum import CommandEnum - - -class CommandRegistry: - """Registry for storing and retrieving commands. - - Holds a dictionary of commands, keyed by `CommandEnum` members, - allowing easy access to command definitions. The generic shell command factory uses this registry for constructing the shell commands. - - The commands are stored in a dictionary where the keys or command names are `CommandEnum` members, and the values corresponding shell commands stored in a list. - The placeholders `{}` in the commands are replaced with the actual values when the command is executed. - - Extend this registry if you want to add more commands and do the necessary mapping in command_runner module. - """ - - COMMANDS: dict[CommandEnum, list[str]] = { - # Linux commands_ - CommandEnum.LIST_FILES: ["ls", "-l", "{folder_location}"], - CommandEnum.SHOW_DIRECTORY: ["pwd"], - CommandEnum.FIND_IN_FILE: ["grep", "-i", "{search_term}", "{file_path}"], - CommandEnum.ECHO_MESSAGE: ["echo", "{message}"], - CommandEnum.SOURCE: ["source", "{file_path}"], - CommandEnum.CHANGE_DIRECTORY: ["cd", "{folder_location}"], - - # Spack commands_ - CommandEnum.SPACK_COMPILER_FIND: ["spack", "compiler", "find", "{compiler_name}"], - CommandEnum.SPACK_COMPILERS: ["spack", "compilers"], - CommandEnum.SPACK_COMPILER_LIST: ["spack", "compiler", "list"], - CommandEnum.SPACK_COMPILER_INFO: ["spack", "compiler", "info", "{compiler_name_with_version}"], - CommandEnum.SPACK_ENVIRONMENT_ACTIVATE: ["spack", "env", "activate", "-p", "{env_path}"], - CommandEnum.SPACK_FIND: ["spack", "find", "{package_name_with_version}"], - CommandEnum.SPACK_INSTALL: ["spack", "install", "{package_name_with_version}"], - CommandEnum.SPACK_MIRROR_ADD: ["spack", "mirror", "add", "{autopush}", "{signed}", "{mirror_name}", - "{mirror_path}"], - CommandEnum.SPACK_GPG_TRUST: ["spack", "gpg", "trust", "{public_key_path}"] - } - - @classmethod - def get_command(cls, command_name: CommandEnum) -> list[str] | None: - """Retrieve a command from the registry. - - Returns the command definition associated with the given `CommandEnum` member. - - Args: - command_name (CommandEnum): The name of the command to retrieve. - - Returns (list[str]): - The shell command in a list format, or None if the command is not found. - """ - return cls.COMMANDS.get(command_name) diff --git a/dedal/commands/command_sequence.py b/dedal/commands/command_sequence.py deleted file mode 100644 index 6115215a..00000000 --- a/dedal/commands/command_sequence.py +++ /dev/null @@ -1,54 +0,0 @@ -""" Command Sequence module. """ -# Copyright (c) 2025 -# License Information: To be decided! -# -# File: command_sequence.py -# Description: Command Sequence abstraction -# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> -# Created on: 2025-02-17 - -from dedal.commands.command import Command - - -class CommandSequence(Command): - """Represents a sequence of executable commands. - - Allows adding commands to a sequence and executing them in order, - combining/chaining their execution strings with "&&". - """ - - def __init__(self) -> None: - """Initializes an empty command sequence.""" - self.commands: list[Command] = [] - - def add_command(self, command: Command) -> None: - """Adds a command to the sequence. - - Appends the given command to the list of commands. - - Args: - command (Command): The command to add. - - Raises: - ValueError: If the provided command is not an instance of `Command`. - """ - if not isinstance(command, Command): - raise ValueError("Command must be an instance of Command") - self.commands.append(command) - - def execute(self) -> str: - """Executes the command sequence. - - Executes each command in the sequence and joins their results with "&&". - - Returns: - The combined execution string of all commands in the sequence. - """ - return " && ".join(cmd.execute().strip() for cmd in self.commands).strip() - - def clear(self) -> None: - """Clears the command sequence. - - Removes all commands from the sequence, making it empty. - """ - self.commands.clear() diff --git a/dedal/commands/command_sequence_builder.py b/dedal/commands/command_sequence_builder.py deleted file mode 100644 index bd355057..00000000 --- a/dedal/commands/command_sequence_builder.py +++ /dev/null @@ -1,71 +0,0 @@ -""" Command Sequence Builder module. """ -# Copyright (c) 2025 -# License Information: To be decided! -# -# File: command_sequence_builder.py -# Description: Command sequence builder module for creating command sequences -# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> -# Created on: 2025-02-19 -from __future__ import annotations - -from dedal.commands.command_enum import CommandEnum -from dedal.commands.command_registry import CommandRegistry -from dedal.commands.command_sequence import CommandSequence -from dedal.commands.shell_command_factory import ShellCommandFactory - - -class CommandSequenceBuilder: - """Builds a sequence of commands using the builder pattern. - - Facilitates the creation of CommandSequence objects by adding commands incrementally - and then building the final sequence. - """ - - def __init__(self) -> None: - """Initializes a new CommandSequenceBuilder with an empty sequence.""" - self.sequence: CommandSequence = CommandSequence() - - def add_generic_command(self, - command_name: CommandEnum, - placeholders: dict[str, str]) -> CommandSequenceBuilder: - """Adds a generic command to the sequence. - - Retrieves the command definition from the CommandRegistry, replaces placeholders with - provided values, creates a ShellCommand, and adds it to the sequence. - - Args: - command_name (CommandEnum): The enum representing the command name to add. - placeholders (dict[str, str]): A dictionary of placeholder values to substitute in the command. - - Returns: - The CommandSequenceBuilder instance (self) for method chaining. - - Raises: - ValueError: If the command type is invalid or if the command is unknown. - """ - if not isinstance(command_name, CommandEnum): - raise ValueError("Invalid command type. Use CommandEnum.") - command = CommandRegistry.get_command(command_name) - - if command is None: - raise ValueError(f"Unknown command: {command_name}") - full_command = command[:] # Copy the command list to avoid mutation - # Replace placeholders with actual values - if placeholders: - full_command = [placeholders.get(arg.strip("{}"), arg) for arg in full_command] - full_command = list(filter(None, full_command)) - shell_command = ShellCommandFactory.create_command(*full_command) - self.sequence.add_command(shell_command) - return self - - def build(self) -> CommandSequence: - """Builds and returns the CommandSequence. - - Returns the constructed CommandSequence and resets the builder for creating new sequences. - - Returns: - The built CommandSequence. - """ - sequence = self.sequence - self.sequence = CommandSequence() # Reset for next build - return sequence diff --git a/dedal/commands/command_sequence_factory.py b/dedal/commands/command_sequence_factory.py deleted file mode 100644 index 1c187245..00000000 --- a/dedal/commands/command_sequence_factory.py +++ /dev/null @@ -1,46 +0,0 @@ -""" Command Sequence Factory module. """ -# Copyright (c) 2025 -# License Information: To be decided! -# -# File: command_sequence_factory.py -# Description: Brief description of the file. -# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> -# Created on: 2025-02-17 - -from dedal.commands.command_enum import CommandEnum -from dedal.commands.command_sequence import CommandSequence -from dedal.commands.command_sequence_builder import CommandSequenceBuilder - - -class CommandSequenceFactory: - """Factory for creating CommandSequence objects.""" - - @staticmethod - def create_custom_command_sequence( - command_placeholders_map: dict[CommandEnum, dict[str, str]]) -> CommandSequence: - """Creates a custom CommandSequence. - - Builds a CommandSequence from a dictionary mapping CommandEnums to placeholder values. - - For example if the key command is `CommandEnum.FIND_IN_FILE` and the value is `{search_term: 'python', file_path: '/path/to/file.txt'}`, - this corresponds to the command `grep -i {search_term} {file_path}` in CommandRegistry.COMMANDS. So the user can create a sequence of such commands. - - e.g. command_placeholders_map = { - CommandEnum.FIND_IN_FILE: { - "search_term": "python", - "file_path": "/path/to/file.txt" - }, - CommandEnum.SHOW_DIRECTORY: {}, - CommandEnum.LIST_FILES: {"folder_location": "/tmp"}, - CommandEnum.ECHO_MESSAGE: {"message": "Hello, world!"} - } - - Args: - command_placeholders_map: A dictionary mapping CommandEnum members to dictionaries of placeholder values. - Returns: - A CommandSequence object representing the custom command sequence. - """ - builder = CommandSequenceBuilder() - for command_type, placeholders in command_placeholders_map.items(): - builder.add_generic_command(command_type, placeholders) - return builder.build() diff --git a/dedal/commands/generic_shell_command.py b/dedal/commands/generic_shell_command.py deleted file mode 100644 index 0a02b095..00000000 --- a/dedal/commands/generic_shell_command.py +++ /dev/null @@ -1,47 +0,0 @@ -""" Generic Shell Command module. """ -# Copyright (c) 2025 -# License Information: To be decided! -# -# File: generic_shell_command.py -# Description: Generic shell command implementation -# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> -# Created on: 2025-02-17 - -from dedal.commands.command import Command - - -class GenericShellCommand(Command): - """Represents a generic shell command. - - Encapsulates a shell command with its name and arguments, providing - a way to execute it. - """ - - def __init__(self, command_name: str, *args: str) -> None: - """Initializes a new GenericShellCommand. - - Args: - command_name (str): The name of the command. - *args (str): The arguments for the command. - - Raises: - ValueError: If the command name is empty, not a string, or if any of the arguments are not strings. - """ - if not command_name: - raise ValueError("Command name is required!") - if not isinstance(command_name, str): - raise ValueError("Command name must be a string!") - if not all(isinstance(arg, str) for arg in args): - raise ValueError("All arguments must be strings!") - self.args: tuple[str, ...] = tuple(map(str.strip, args)) - self.command_name: str = command_name.strip() - - def execute(self) -> str: - """Executes the command. - - Constructs and returns the full command string, including the command name and arguments. - - Returns: - The full command string. - """ - return f"{self.command_name} {' '.join(self.args)}" if self.args else self.command_name diff --git a/dedal/commands/shell_command_factory.py b/dedal/commands/shell_command_factory.py deleted file mode 100644 index e63a456e..00000000 --- a/dedal/commands/shell_command_factory.py +++ /dev/null @@ -1,33 +0,0 @@ -""" Shell Command Factory module. """ -# Copyright (c) 2025 -# License Information: To be decided! -# -# File: shell_command_factory.py -# Description: Shell command factory to create shell command instances -# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> -# Created on: 2025-02-17 - -from dedal.commands.command import Command -from dedal.commands.generic_shell_command import GenericShellCommand - - -class ShellCommandFactory: - """Factory for creating shell command instances. - - Provides a static method for creating GenericShellCommand objects. - """ - - @staticmethod - def create_command(command_name: str, *args: str) -> Command: - """Creates a generic shell command. - - Instantiates and returns a GenericShellCommand object with the given command name and arguments. - - Args: - command_name (str): The name of the command. - *args (str): The arguments for the command. - - Returns: - A GenericShellCommand object. - """ - return GenericShellCommand(command_name, *args) diff --git a/dedal/commands/spack_command_sequence_factory.py b/dedal/commands/spack_command_sequence_factory.py deleted file mode 100644 index ce7afac3..00000000 --- a/dedal/commands/spack_command_sequence_factory.py +++ /dev/null @@ -1,211 +0,0 @@ -"""Factory for generating predefined spack related command sequences.""" -# Copyright (c) 2025 -# License Information: To be decided! -# -# File: spack_command_sequence_factory.py -# Description: Factory for generating predefined spack related command sequences -# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> -# Created on: 2025-02-17 - -from dedal.commands.command_enum import CommandEnum -from dedal.commands.command_sequence import CommandSequence -from dedal.commands.command_sequence_builder import CommandSequenceBuilder - - -def get_name_version(name: str | None, version: str | None) -> str: - """Formats a name and version string. - - Returns a string combining the name and version with "@" if both are provided, - otherwise returns the name or an empty string. - - Args: - name (str): The name. - version (str): The version. - - Returns: - The formatted name and version string. - """ - return f"{name}@{version}" if name and version else name or "" - - -class SpackCommandSequenceFactory: - """Factory for creating Spack command sequences. - - Provides methods for building CommandSequence objects for various Spack operations. - """ - - @staticmethod - def create_spack_enabled_command_sequence_builder(spack_setup_script: str) -> CommandSequenceBuilder: - """Creates a CommandSequenceBuilder with Spack setup. - - Initializes a builder with the 'source' command for the given Spack setup script. - - Args: - spack_setup_script (str): Path to the Spack setup script. - - Returns: - A CommandSequenceBuilder pre-configured with the Spack setup command. - """ - return (CommandSequenceBuilder() - .add_generic_command(CommandEnum.SOURCE, {"file_path": spack_setup_script})) - - @staticmethod - def create_spack_compilers_command_sequence(spack_setup_script: str) -> CommandSequence: - """Creates a command sequence for listing Spack compilers. - - Builds a sequence that sources the Spack setup script and then lists available compilers. - - Args: - spack_setup_script (str): Path to the Spack setup script. - - Returns: - A CommandSequence for listing Spack compilers. - """ - return (SpackCommandSequenceFactory.create_spack_enabled_command_sequence_builder(spack_setup_script) - .add_generic_command(CommandEnum.SPACK_COMPILERS, {}) - .build()) - - @staticmethod - def create_spack_compiler_list_command_sequence(spack_setup_script: str) -> CommandSequence: - """Creates a command sequence for listing Spack compilers. - - Builds a sequence that sources the Spack setup script and then lists available compilers. - - Args: - spack_setup_script (str): Path to the Spack setup script. - - Returns: - A CommandSequence for listing Spack compilers. - """ - return (SpackCommandSequenceFactory.create_spack_enabled_command_sequence_builder(spack_setup_script) - .add_generic_command(CommandEnum.SPACK_COMPILER_LIST, {}) - .build()) - - @staticmethod - def create_spack_install_package_command_sequence(spack_setup_script: str, - package_name: str | None, - version: str | None) -> CommandSequence: - """Creates a command sequence for installing a Spack package. - - Builds a sequence that sources the Spack setup script and then installs the specified package. - - Args: - spack_setup_script (str): Path to the Spack setup script. - package_name (str | None): The name of the package to install. - version (str | None): The version of the package to install. - - Returns: - A CommandSequence for installing the Spack package. - """ - return (SpackCommandSequenceFactory.create_spack_enabled_command_sequence_builder(spack_setup_script) - .add_generic_command(CommandEnum.SPACK_INSTALL, { - "package_name_with_version": get_name_version(package_name, version) - }).build()) - - @staticmethod - def create_spack_mirror_add_command_sequence(spack_setup_script: str, - env_name: str, - mirror_name: str, - mirror_path: str, - autopush: bool = False, - signed: bool = False) -> CommandSequence: - """Creates a command sequence for adding a Spack mirror. - - Builds a sequence that sources the Spack setup script, activates an environment (if specified), - and adds the given mirror. - - Args: - spack_setup_script (str): Path to the Spack setup script. - env_name (str): The name of the environment to activate. - mirror_name (str): The name of the mirror. - mirror_path (str): The URL or path of the mirror. - autopush (bool): Whether to enable autopush for the mirror. - signed (bool): Whether to require signed packages from the mirror. - - Returns: - A CommandSequence for adding the Spack mirror. - """ - builder = SpackCommandSequenceFactory.create_spack_enabled_command_sequence_builder(spack_setup_script) - if env_name: - builder = builder.add_generic_command(CommandEnum.SPACK_ENVIRONMENT_ACTIVATE, - {"env_path": env_name}) - place_holders = { - "mirror_name": mirror_name, - "mirror_path": mirror_path, - "autopush": "--autopush" if autopush else "", - "signed": "--signed" if signed else "" - } - builder = builder.add_generic_command(CommandEnum.SPACK_MIRROR_ADD, place_holders) - return builder.build() - - @staticmethod - def create_spack_compiler_info_command_sequence(spack_setup_script: str, - compiler_name: str, - compiler_version: str) -> CommandSequence: - """Creates a command sequence for getting Spack compiler information. - - Builds a sequence that sources the Spack setup script and retrieves information about the specified compiler. - - - Args: - spack_setup_script (str): Path to the Spack setup script. - compiler_name (str): The name of the compiler. - compiler_version (str): The version of the compiler. - - Returns: - A CommandSequence for getting compiler information. - """ - return (SpackCommandSequenceFactory.create_spack_enabled_command_sequence_builder(spack_setup_script) - .add_generic_command(CommandEnum.SPACK_COMPILER_INFO, - { - "compiler_name_with_version": - get_name_version(compiler_name, compiler_version) - }) - .build()) - - @staticmethod - def create_spack_post_install_find_command_sequence(spack_setup_script: str, - env_path: str, - package_name: str | None, - version: str | None) -> CommandSequence: - """Creates a command sequence for finding installed Spack packages after installation. - - Builds a sequence that sources the Spack setup script, activates the specified environment, - and then searches for the given package. - - Args: - spack_setup_script (str): Path to the Spack setup script. - env_path (str): The path to the Spack environment. - package_name (str | None): The name of the package to find. - version (str | None): The version of the package to find. - - Returns: - A CommandSequence for finding installed Spack packages. - """ - return (SpackCommandSequenceFactory.create_spack_enabled_command_sequence_builder(spack_setup_script) - .add_generic_command(CommandEnum.SPACK_ENVIRONMENT_ACTIVATE, {"env_path": env_path}) - .add_generic_command(CommandEnum.SPACK_FIND, - { - "package_name_with_version": - get_name_version(package_name, version) - }).build()) - - @staticmethod - def create_spack_gpg_trust_command_sequence(spack_setup_script: str, - public_key_path: str) -> CommandSequence: - """Creates a command sequence for trusting a GPG key in Spack. - - Builds a sequence that sources the Spack setup script and then trusts the given GPG key. - - Args: - spack_setup_script (str): Path to the Spack setup script. - public_key_path (str): Path to the public key file. - - Returns: - A CommandSequence for trusting the GPG key. - """ - return (SpackCommandSequenceFactory.create_spack_enabled_command_sequence_builder(spack_setup_script) - .add_generic_command(CommandEnum.SPACK_GPG_TRUST, - { - "public_key_path": public_key_path - }).build()) diff --git a/dedal/spack_factory/SpackOperation.py b/dedal/spack_factory/SpackOperation.py index ad4f2389..b9fd6e79 100644 --- a/dedal/spack_factory/SpackOperation.py +++ b/dedal/spack_factory/SpackOperation.py @@ -2,12 +2,10 @@ import os import re import subprocess from pathlib import Path - -from dedal.commands.command_runner import CommandRunner -from dedal.commands.preconfigured_command_enum import PreconfiguredCommandEnum from dedal.configuration.SpackConfig import SpackConfig from dedal.error_handling.exceptions import BashCommandException, NoSpackEnvironmentException, \ - SpackInstallPackagesException, SpackConcertizeException, SpackMirrorException, SpackGpgException, SpackRepoException + SpackInstallPackagesException, SpackConcertizeException, SpackMirrorException, SpackGpgException, \ + SpackRepoException from dedal.logger.logger_builder import get_logger from dedal.tests.testing_variables import SPACK_VERSION from dedal.utils.utils import run_command, git_clone_repo, log_command, set_bashrc_variable @@ -50,7 +48,6 @@ class SpackOperation: if self.spack_config.env and spack_config.env.path: self.spack_config.env.path = spack_config.env.path self.spack_config.env.path.mkdir(parents=True, exist_ok=True) - self.command_runner = CommandRunner() def create_fetch_spack_environment(self): if self.spack_config.env.git_path: @@ -189,106 +186,80 @@ class SpackOperation: else: raise SpackGpgException('No GPG configuration was defined is spack configuration') - def add_mirror(self, - mirror_name: str, - mirror_path: str, - signed=False, - autopush=False, - global_mirror=False) -> bool: + def add_mirror(self, mirror_name: str, mirror_path: Path, signed=False, autopush=False, global_mirror=False): """Adds a Spack mirror. - Adds a new mirror to the Spack configuration, either globally or to a specific environment. - Args: mirror_name (str): The name of the mirror. mirror_path (str): The path or URL of the mirror. signed (bool): Whether to require signed packages from the mirror. autopush (bool): Whether to enable autopush for the mirror. global_mirror (bool): Whether to add the mirror globally (True) or to the current environment (False). - - Returns: - True if the mirror was added successfully, False otherwise. - Raises: ValueError: If mirror_name or mirror_path are empty. NoSpackEnvironmentException: If global_mirror is False and no environment is defined. """ if not mirror_name or not mirror_path: raise ValueError("mirror_name and mirror_path are required") - if not global_mirror and not self.env_path: - raise NoSpackEnvironmentException('No spack environment defined') - result = self.command_runner.run_preconfigured_command_sequence( - PreconfiguredCommandEnum.SPACK_MIRROR_ADD, - self.spack_setup_script, - "" if global_mirror else str(self.env_path), - mirror_name, - mirror_path, - autopush, - signed) - return self.handle_result( - result, - "Failed to add mirror %s, reason: %s, output: %s", - (mirror_name,), - "Added mirror %s", - (mirror_name,) - ) + autopush = '--autopush' if autopush else '' + signed = '--signed' if signed else '' + spack_add_mirror = f'spack mirror add {autopush} {signed} {mirror_name} {mirror_path}' + if global_mirror: + run_command("bash", "-c", + f'{self.spack_setup_script} {spack_add_mirror}', + check=True, + logger=self.logger, + info_msg=f'Added mirror {mirror_name}', + exception_msg=f'Failed to add mirror {mirror_name}', + exception=SpackMirrorException) + else: + check_spack_env( + run_command("bash", "-c", + f'{self.spack_command_on_env} && {spack_add_mirror}', + check=True, + logger=self.logger, + info_msg=f'Added mirror {mirror_name}', + exception_msg=f'Failed to add mirror {mirror_name}', + exception=SpackMirrorException)) def trust_gpg_key(self, public_key_path: str): """Adds a GPG public key to the trusted keyring. - This method attempts to add the provided GPG public key to the Spack trusted keyring. - Args: public_key_path (str): Path to the GPG public key file. - Returns: bool: True if the key was added successfully, False otherwise. - Raises: ValueError: If public_key_path is empty. """ if not public_key_path: raise ValueError("public_key_path is required") - result = self.command_runner.run_preconfigured_command_sequence( - PreconfiguredCommandEnum.SPACK_GPG_TRUST, - self.spack_setup_script, - public_key_path) - return self.handle_result( - result, - "Failed to add public key %s as trusted, reason: %s, output: %s", - (public_key_path,), - "Added public key %s as trusted", - (public_key_path,), - ) - - def handle_result(self, - result: dict[str, str | bool | None], - error_msg: str, - error_msg_args: tuple[str, ...], - info_msg: str, - info_msg_args: tuple[str, ...]): - """Handles the result of a command execution. - - Checks the success status of the result and logs either an error or an info message accordingly. - Args: - result (dict[str, str | bool | None]): A dictionary containing the result of the command execution. - error_msg (str): The error message to log if the command failed. - error_msg_args (tuple[str, ...]): Arguments to format the error message. - info_msg (str): The info message to log if the command succeeded. - info_msg_args (tuple[str, ...]): Arguments to format the info message. + run_command("bash", "-c", + f'{self.spack_command_on_env} && spack gpg trust {public_key_path}', + check=True, + logger=self.logger, + info_msg=f'Trusted GPG key for {self.spack_config.env.name}', + exception_msg=f'Failed to trust GPG key for {self.spack_config.env.name}', + exception=SpackGpgException) - Returns: - bool: True if the command succeeded, False otherwise. - """ - if not result["success"]: - self.logger.error(error_msg, *error_msg_args, result['error'], result['output']) - return False - self.logger.info(info_msg, *info_msg_args) - return True + def list_mirrors(self): + mirrors = run_command("bash", "-c", + f'{self.spack_setup_script} spack mirror list', + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + logger=self.logger, + info_msg=f'Listing mirrors', + exception_msg=f'Failed list mirrors', + exception=SpackMirrorException) + return list(mirrors.stdout.strip().splitlines()) def remove_mirror(self, mirror_name: str): + if not mirror_name: + raise ValueError("mirror_name is required") run_command("bash", "-c", f'{self.spack_setup_script} spack mirror rm {mirror_name}', check=True, diff --git a/dedal/spack_factory/SpackOperationUseCache.py b/dedal/spack_factory/SpackOperationUseCache.py index 16312e7f..f1feb046 100644 --- a/dedal/spack_factory/SpackOperationUseCache.py +++ b/dedal/spack_factory/SpackOperationUseCache.py @@ -60,6 +60,13 @@ class SpackOperationUseCache(SpackOperation): @check_spack_env def concretize_spack_env(self): + """Concretization step for spack environment for using the concretization cache (spack.lock file). + + Downloads the concretization cache and moves it to the spack environment's folder + + Raises: + NoSpackEnvironmentException: If the spack environment is not set up. + """ concretization_redo = False self.cache_dependency.download(self.spack_config.concretization_dir) if file_exists_and_not_empty(self.spack_config.concretization_dir / 'spack.lock'): @@ -77,6 +84,11 @@ class SpackOperationUseCache(SpackOperation): @check_spack_env def install_packages(self, jobs: int, signed=True, debug=False): + """Installation step for spack environment for using the binary caches. + + Raises: + NoSpackEnvironmentException: If the spack environment is not set up. + """ signed = '' if signed else '--no-check-signature' debug = '--debug' if debug else '' install_result = run_command("bash", "-c", diff --git a/dedal/tests/integration_tests/spack_from_cache_test.py b/dedal/tests/integration_tests/spack_from_cache_test.py index 33f44833..d0e390de 100644 --- a/dedal/tests/integration_tests/spack_from_cache_test.py +++ b/dedal/tests/integration_tests/spack_from_cache_test.py @@ -1,12 +1,30 @@ -import pytest from dedal.configuration.SpackConfig import SpackConfig from dedal.model.SpackDescriptor import SpackDescriptor from dedal.spack_factory.SpackOperationCreator import SpackOperationCreator from dedal.spack_factory.SpackOperationUseCache import SpackOperationUseCache -from dedal.utils.utils import file_exists_and_not_empty +from dedal.utils.utils import file_exists_and_not_empty, count_files_in_folder from dedal.utils.variables import test_spack_env_git, ebrains_spack_builds_git +def test_spack_from_cache_setup(tmp_path): + install_dir = tmp_path + env = SpackDescriptor('test-spack-env', install_dir, test_spack_env_git) + repo = SpackDescriptor('ebrains-spack-builds', install_dir, ebrains_spack_builds_git) + concretization_dir = install_dir / 'concretize' + buildcache_dir = install_dir / 'buildcache' + spack_config = SpackConfig(env, install_dir=install_dir, concretization_dir=concretization_dir, + buildcache_dir=buildcache_dir) + spack_config.add_repo(repo) + spack_operation = SpackOperationCreator.get_spack_operator(spack_config, use_cache=True) + assert isinstance(spack_operation, SpackOperationUseCache) + spack_operation.install_spack() + spack_operation.setup_spack_env() + num_tags = len(spack_operation.build_cache.list_tags()) + assert file_exists_and_not_empty(concretization_dir) == True + assert count_files_in_folder(buildcache_dir) == num_tags + + + def test_spack_from_cache_concretize(tmp_path): install_dir = tmp_path env = SpackDescriptor('test-spack-env', install_dir, test_spack_env_git) @@ -23,7 +41,6 @@ def test_spack_from_cache_concretize(tmp_path): assert file_exists_and_not_empty(concretization_file_path) == True -@pytest.mark.skip(reason="Skipping test::test_spack_from_cache_install until all the functionalities in SpackOperationUseCache") def test_spack_from_cache_install(tmp_path): install_dir = tmp_path env = SpackDescriptor('test-spack-env', install_dir, test_spack_env_git) diff --git a/dedal/tests/spack_from_scratch_test.py b/dedal/tests/spack_from_scratch_test.py deleted file mode 100644 index 2fec80f7..00000000 --- a/dedal/tests/spack_from_scratch_test.py +++ /dev/null @@ -1,204 +0,0 @@ -from pathlib import Path -import pytest -from dedal.configuration.SpackConfig import SpackConfig -from dedal.error_handling.exceptions import BashCommandException, NoSpackEnvironmentException -from dedal.spack_factory.SpackOperationCreator import SpackOperationCreator -from dedal.model.SpackDescriptor import SpackDescriptor -from dedal.tests.testing_variables import test_spack_env_git, ebrains_spack_builds_git -from dedal.utils.utils import file_exists_and_not_empty - - -def test_spack_repo_exists_1(): - spack_operation = SpackOperationCreator.get_spack_operator() - spack_operation.install_spack() - assert spack_operation.spack_repo_exists('ebrains-spack-builds') == False - - -def test_spack_repo_exists_2(tmp_path): - install_dir = tmp_path - env = SpackDescriptor('ebrains-spack-builds', install_dir) - config = SpackConfig(env=env, install_dir=install_dir) - spack_operation = SpackOperationCreator.get_spack_operator(config) - spack_operation.install_spack() - with pytest.raises(NoSpackEnvironmentException): - spack_operation.spack_repo_exists(env.env_name) - - -def test_spack_repo_exists_3(tmp_path): - install_dir = tmp_path - env = SpackDescriptor('ebrains-spack-builds', install_dir) - config = SpackConfig(env=env, install_dir=install_dir) - spack_operation = SpackOperationCreator.get_spack_operator(config) - spack_operation.install_spack() - print(spack_operation.get_spack_installed_version()) - spack_operation.setup_spack_env() - assert spack_operation.spack_repo_exists(env.env_name) == False - - -def test_spack_from_scratch_setup_1(tmp_path): - install_dir = tmp_path - env = SpackDescriptor('ebrains-spack-builds', install_dir, ebrains_spack_builds_git) - config = SpackConfig(env=env, system_name='ebrainslab', install_dir=install_dir) - spack_operation = SpackOperationCreator.get_spack_operator(config) - spack_operation.install_spack() - spack_operation.setup_spack_env() - assert spack_operation.spack_repo_exists(env.env_name) == False - - -def test_spack_from_scratch_setup_2(tmp_path): - install_dir = tmp_path - env = SpackDescriptor('ebrains-spack-builds', install_dir, ebrains_spack_builds_git) - repo = env - config = SpackConfig(env=env, system_name='ebrainslab', install_dir=install_dir) - config.add_repo(repo) - config.add_repo(repo) - spack_operation = SpackOperationCreator.get_spack_operator(config) - spack_operation.install_spack() - spack_operation.setup_spack_env() - assert spack_operation.spack_repo_exists(env.env_name) == True - - -def test_spack_from_scratch_setup_3(tmp_path): - install_dir = tmp_path - env = SpackDescriptor('new_env1', install_dir) - repo = env - config = SpackConfig(env=env, system_name='ebrainslab', install_dir=install_dir) - config.add_repo(repo) - config.add_repo(repo) - spack_operation = SpackOperationCreator.get_spack_operator(config) - spack_operation.install_spack() - with pytest.raises(BashCommandException): - spack_operation.setup_spack_env() - - -def test_spack_from_scratch_setup_4(tmp_path): - install_dir = tmp_path - env = SpackDescriptor('new_env2', install_dir) - config = SpackConfig(env=env, install_dir=install_dir) - spack_operation = SpackOperationCreator.get_spack_operator(config) - spack_operation.install_spack() - spack_operation.setup_spack_env() - assert spack_operation.spack_env_exists() == True - - -def test_spack_not_a_valid_repo(): - env = SpackDescriptor('ebrains-spack-builds', Path(), None) - repo = env - config = SpackConfig(env=env, system_name='ebrainslab') - config.add_repo(repo) - spack_operation = SpackOperationCreator.get_spack_operator(config) - with pytest.raises(BashCommandException): - spack_operation.add_spack_repo(repo.path, repo.env_name) - - -def test_spack_from_scratch_concretize_1(tmp_path): - install_dir = tmp_path - env = SpackDescriptor('ebrains-spack-builds', install_dir, ebrains_spack_builds_git) - repo = env - config = SpackConfig(env=env, system_name='ebrainslab', install_dir=install_dir) - config.add_repo(repo) - config.add_repo(repo) - spack_operation = SpackOperationCreator.get_spack_operator(config) - spack_operation.install_spack() - spack_operation.install_spack() - spack_operation.setup_spack_env() - spack_operation.concretize_spack_env(force=True) - concretization_file_path = spack_operation.env_path / 'spack.lock' - assert file_exists_and_not_empty(concretization_file_path) == True - - -def test_spack_from_scratch_concretize_2(tmp_path): - install_dir = tmp_path - env = SpackDescriptor('ebrains-spack-builds', install_dir, ebrains_spack_builds_git) - repo = env - config = SpackConfig(env=env, system_name='ebrainslab', install_dir=install_dir) - config.add_repo(repo) - config.add_repo(repo) - spack_operation = SpackOperationCreator.get_spack_operator(config) - spack_operation.install_spack() - spack_operation.setup_spack_env() - spack_operation.concretize_spack_env(force=False) - concretization_file_path = spack_operation.env_path / 'spack.lock' - assert file_exists_and_not_empty(concretization_file_path) == True - - -def test_spack_from_scratch_concretize_3(tmp_path): - install_dir = tmp_path - env = SpackDescriptor('ebrains-spack-builds', install_dir, ebrains_spack_builds_git) - repo = env - config = SpackConfig(env=env, system_name='ebrainslab', install_dir=install_dir) - config.add_repo(repo) - config.add_repo(repo) - spack_operation = SpackOperationCreator.get_spack_operator(config) - spack_operation.install_spack() - spack_operation.setup_spack_env() - concretization_file_path = spack_operation.env_path / 'spack.lock' - assert file_exists_and_not_empty(concretization_file_path) == False - - -def test_spack_from_scratch_concretize_4(tmp_path): - install_dir = tmp_path - env = SpackDescriptor('test-spack-env', install_dir, test_spack_env_git) - config = SpackConfig(env=env, install_dir=install_dir) - spack_operation = SpackOperationCreator.get_spack_operator(config) - spack_operation.install_spack() - spack_operation.setup_spack_env() - spack_operation.concretize_spack_env(force=False) - concretization_file_path = spack_operation.env_path / 'spack.lock' - assert file_exists_and_not_empty(concretization_file_path) == True - - -def test_spack_from_scratch_concretize_5(tmp_path): - install_dir = tmp_path - env = SpackDescriptor('test-spack-env', install_dir, test_spack_env_git) - config = SpackConfig(env=env, install_dir=install_dir) - spack_operation = SpackOperationCreator.get_spack_operator(config) - spack_operation.install_spack() - spack_operation.setup_spack_env() - spack_operation.concretize_spack_env(force=True) - concretization_file_path = spack_operation.env_path / 'spack.lock' - assert file_exists_and_not_empty(concretization_file_path) == True - - -def test_spack_from_scratch_concretize_6(tmp_path): - install_dir = tmp_path - env = SpackDescriptor('test-spack-env', install_dir, test_spack_env_git) - repo = SpackDescriptor('ebrains-spack-builds', install_dir, ebrains_spack_builds_git) - config = SpackConfig(env=env, install_dir=install_dir) - config.add_repo(repo) - spack_operation = SpackOperationCreator.get_spack_operator(config) - spack_operation.install_spack() - spack_operation.setup_spack_env() - spack_operation.concretize_spack_env(force=False) - concretization_file_path = spack_operation.env_path / 'spack.lock' - assert file_exists_and_not_empty(concretization_file_path) == True - - -def test_spack_from_scratch_concretize_7(tmp_path): - install_dir = tmp_path - env = SpackDescriptor('test-spack-env', install_dir, test_spack_env_git) - repo = SpackDescriptor('ebrains-spack-builds', install_dir, ebrains_spack_builds_git) - config = SpackConfig(env=env) - config.add_repo(repo) - spack_operation = SpackOperationCreator.get_spack_operator(config) - spack_operation.install_spack() - spack_operation.setup_spack_env() - spack_operation.concretize_spack_env(force=True) - concretization_file_path = spack_operation.env_path / 'spack.lock' - assert file_exists_and_not_empty(concretization_file_path) == True - - -def test_spack_from_scratch_install(tmp_path): - install_dir = tmp_path - env = SpackDescriptor('test-spack-env', install_dir, test_spack_env_git) - repo = SpackDescriptor('ebrains-spack-builds', install_dir, ebrains_spack_builds_git) - config = SpackConfig(env=env) - config.add_repo(repo) - spack_operation = SpackOperationCreator.get_spack_operator(config) - spack_operation.install_spack() - spack_operation.setup_spack_env() - spack_operation.concretize_spack_env(force=True) - concretization_file_path = spack_operation.env_path / 'spack.lock' - assert file_exists_and_not_empty(concretization_file_path) == True - install_result = spack_operation.install_packages(jobs=2, signed=False, fresh=True, debug=False) - assert install_result.returncode == 0 diff --git a/dedal/tests/spack_install_test.py b/dedal/tests/spack_install_test.py deleted file mode 100644 index 564d5c6a..00000000 --- a/dedal/tests/spack_install_test.py +++ /dev/null @@ -1,12 +0,0 @@ -import pytest -from dedal.spack_factory.SpackOperation import SpackOperation -from dedal.tests.testing_variables import SPACK_VERSION - - -# run this test first so that spack is installed only once for all the tests -@pytest.mark.run(order=1) -def test_spack_install_scratch(): - spack_operation = SpackOperation() - spack_operation.install_spack(spack_version=f'v{SPACK_VERSION}') - installed_spack_version = spack_operation.get_spack_installed_version() - assert SPACK_VERSION == installed_spack_version diff --git a/dedal/tests/unit_tests/test_bash_command_executor.py b/dedal/tests/unit_tests/test_bash_command_executor.py deleted file mode 100644 index e216cc0f..00000000 --- a/dedal/tests/unit_tests/test_bash_command_executor.py +++ /dev/null @@ -1,236 +0,0 @@ -# Copyright (c) 2025 -# License Information: To be decided! -# -# File: test_bash_command_executor.py -# Description: Brief description of the file. -# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> -# Created on: 2025-02-18 -import os -import subprocess -from unittest.mock import patch - -import pytest - -from dedal.commands.bash_command_executor import BashCommandExecutor -from dedal.commands.command import Command - - -class MockCommand(Command): - def __init__(self, cmd: str): - self._cmd = cmd - - def execute(self) -> str: - return self._cmd - - -class TestBashCommandExecutor: - @pytest.mark.parametrize( - "test_id, os_name, expected_bash_command", - [ - ("posix_system", "posix", ["bash", "-c"]), - ("nt_system", "nt", ["wsl", "bash", "-c"]), - ], - ) - def test_init_success_path(self, mocker, test_id, os_name, expected_bash_command): - # Arrange - mock_get_logger = mocker.patch("dedal.commands.bash_command_executor.logging.getLogger") - mocker.patch("dedal.commands.bash_command_executor.os_name", os_name) - - # Act - executor = BashCommandExecutor() - - # Assert - assert executor.bash_command == expected_bash_command - mock_get_logger.assert_called_once_with('dedal.commands.bash_command_executor') - - @pytest.mark.parametrize( - "test_id, num_commands", [(1, 1), (5, 5), (0, 0)] - ) - def test_add_command_success_path(self, mocker, test_id: int, num_commands: int): - # Arrange - executor = BashCommandExecutor() - executor.logger = mocker.MagicMock() - commands = [MockCommand("some_command") for _ in range(num_commands)] - - # Act - for command in commands: - executor.add_command(command) - - # Assert - assert len(executor.sequence.commands) == num_commands - executor.logger.info.assert_has_calls( - [mocker.call("Adding command to the sequence: %s", command) for command in commands]) - - def test_add_command_invalid_type(self): - # Arrange - executor = BashCommandExecutor() - invalid_command = "not a command object" # type: ignore - - # Act - with pytest.raises(ValueError) as except_info: - executor.add_command(invalid_command) - - # Assert - assert str(except_info.value) == "Invalid command type. Use Command." - - @patch("dedal.commands.bash_command_executor.os_name", "unknown") - def test_init_unknown_os(self): - - # Act - executor = BashCommandExecutor() - - # Assert - assert executor.bash_command == ["undefined"] - - @pytest.mark.parametrize( - "test_id, commands, expected_output", - [ - ("single_command", [MockCommand("echo hello")], "hello\n"), - ("multiple_commands", [MockCommand("echo hello"), MockCommand("echo world")], "hello\nworld\n"), - ("command_with_pipe", [MockCommand("echo hello | grep hello")], "hello\n"), - - ], - ) - @patch("dedal.commands.bash_command_executor.subprocess.run") - def test_execute_success_path_posix(self, mock_subprocess_run, test_id, commands, expected_output, mocker): - # Arrange - executor = BashCommandExecutor() - executor.logger = mocker.MagicMock() - for cmd in commands: - executor.add_command(cmd) - mock_subprocess_run.return_value.stdout = expected_output - - # Act - stdout, err = executor.execute() - - # Assert - assert stdout == expected_output - assert err is None - executor.logger.info.assert_has_calls( - [mocker.call('Adding command to the sequence: %s', cmd) for cmd in commands] + - [mocker.call("Successfully executed command sequence, output: %s", - mock_subprocess_run.return_value.stdout)]) - - @patch("dedal.commands.bash_command_executor.subprocess.run", - side_effect=FileNotFoundError("Mock file not found error")) - def test_execute_file_not_found_error(self, mock_subprocess_run): - # Arrange - executor = BashCommandExecutor() - executor.bash_command = ["bash", "-c"] - executor.add_command(MockCommand("some_command")) - - # Act - stdout, err = executor.execute() - - # Assert - assert stdout is None - assert err == "Error: Bash Command: ['bash', '-c'] not found: Mock file not found error" - mock_subprocess_run.assert_called_once_with(['bash', '-c', 'some_command'], capture_output=True, text=True, - check=True, timeout=172800) - - @patch("dedal.commands.bash_command_executor.subprocess.run", - side_effect=subprocess.CalledProcessError(1, "some_command", stderr="Mock stderr")) - def test_execute_called_process_error(self, mock_subprocess_run, mocker): - # Arrange - mocker.patch("dedal.commands.bash_command_executor.os_name", "nt") - executor = BashCommandExecutor() - executor.add_command(MockCommand("failing_command")) - - # Act - stdout, err = executor.execute() - - # Assert - assert stdout is None - assert err == "Error: Command failed with exit code 1, Error Output: Mock stderr" - mock_subprocess_run.assert_called_once_with(['wsl', 'bash', '-c', 'failing_command'], capture_output=True, - text=True, check=True, timeout=172800) - - @pytest.mark.parametrize( - "test_id, exception, expected_error_message", - [ - ("permission_error", PermissionError("Mock permission denied"), - "Error: Permission denied: Mock permission denied"), - ("os_error", OSError("Mock OS error"), "Error: OS error occurred: Mock OS error"), - ("value_error", ValueError("Mock invalid argument"), - "Error: Invalid argument passed: Mock invalid argument"), - ("type_error", TypeError("Mock invalid type"), "Error: Invalid type for arguments: Mock invalid type"), - ("timeout_expired", subprocess.TimeoutExpired("some_command", 10), - "Error: Command timed out after 10 seconds"), - ("subprocess_error", subprocess.SubprocessError("Mock subprocess error"), - "Subprocess error occurred: Mock subprocess error"), - ], - ) - def test_execute_other_errors(self, test_id, exception, expected_error_message, mocker): - # Arrange - mocker.patch("dedal.commands.bash_command_executor.os_name", "nt") - with patch("dedal.commands.bash_command_executor.subprocess.run", side_effect=exception) as mock_subprocess_run: - executor = BashCommandExecutor() - executor.add_command(MockCommand("some_command")) - - # Act - stdout, err = executor.execute() - - # Assert - assert stdout is None - assert err == expected_error_message - mock_subprocess_run.assert_called_once_with(['wsl', 'bash', '-c', 'some_command'], capture_output=True, - text=True, check=True, timeout=172800) - - def test_execute_no_commands(self): - # Arrange - executor = BashCommandExecutor() - - # Act - with pytest.raises(ValueError) as except_info: - executor.execute() - - # Assert - assert str(except_info.value) == "No commands to execute." - - @patch("dedal.commands.bash_command_executor.subprocess.run") - def test_execute_happy_path_nt(self, mock_subprocess_run, mocker): - # Arrange - mocker.patch("dedal.commands.bash_command_executor.os_name", "nt") - executor = BashCommandExecutor() - executor.add_command(MockCommand("echo hello")) - mock_subprocess_run.return_value.stdout = "hello\n" - - # Act - stdout, err = executor.execute() - - # Assert - assert stdout == "hello\n" - assert err is None - assert executor.bash_command == ['wsl', 'bash', '-c'] - mock_subprocess_run.assert_called_once_with(['wsl', 'bash', '-c', 'echo hello'], capture_output=True, text=True, - check=True, timeout=172800) - - def test_execute_unknown_os(self, mocker): - # Arrange - errors = { - "posix": "Error: Bash Command: ['undefined'] not found: [Errno 2] No such file or directory: 'undefined'", - "nt": "Error: Bash Command: ['undefined'] not found: [WinError 2] The system cannot find the file " - 'specified' - } - original_os = os.name - expected_error = errors.get(original_os, "Error: Unknown OS") - mocker.patch("dedal.commands.bash_command_executor.os_name") - executor = BashCommandExecutor() - executor.add_command(MockCommand("echo hello")) - - # Act - assert executor.execute() == (None, expected_error) - - def test_reset(self, mocker): - # Test ID: reset - - # Arrange - executor = BashCommandExecutor() - mock_sequence = mocker.MagicMock() - executor.sequence = mock_sequence - - # Act - executor.reset() - - # Assert - mock_sequence.clear.assert_called_once() diff --git a/dedal/tests/unit_tests/test_build_cache_manager.py b/dedal/tests/unit_tests/test_build_cache_manager.py index af5690eb..6bce0948 100644 --- a/dedal/tests/unit_tests/test_build_cache_manager.py +++ b/dedal/tests/unit_tests/test_build_cache_manager.py @@ -1,10 +1,3 @@ -# Copyright (c) 2025 -# License Information: To be decided! -# -# File: test_build_cache_manager.py -# Description: Brief description of the file. -# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> -# Created on: 2025-02-20 import pytest from _pytest.fixtures import fixture diff --git a/dedal/tests/unit_tests/test_command.py b/dedal/tests/unit_tests/test_command.py deleted file mode 100644 index 3c864041..00000000 --- a/dedal/tests/unit_tests/test_command.py +++ /dev/null @@ -1,45 +0,0 @@ -# Copyright (c) 2025 -# License Information: To be decided! -# -# File: test_command.py -# Description: Brief description of the file. -# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> -# Created on: 2025-02-18 - -import pytest - -from dedal.commands.command import Command - -class ConcreteCommand(Command): - def __init__(self, return_value: str): - self._return_value = return_value - - def execute(self) -> str: - return self._return_value - - -class TestCommand: - def test_execute_abstract_method(self): - # Act - with pytest.raises(TypeError): - Command() # type: ignore - - - @pytest.mark.parametrize( - "test_id, return_value", - [ - ("empty_string", ""), - ("non_empty_string", "some_command"), - ("string_with_spaces", "command with spaces"), - ("non_ascii_chars", "αβγδ"), - ], - ) - def test_execute_concrete_command(self, test_id, return_value): - # Arrange - command = ConcreteCommand(return_value) - - # Act - result = command.execute() - - # Assert - assert result == return_value diff --git a/dedal/tests/unit_tests/test_command_enum.py b/dedal/tests/unit_tests/test_command_enum.py deleted file mode 100644 index f29e2b4c..00000000 --- a/dedal/tests/unit_tests/test_command_enum.py +++ /dev/null @@ -1,38 +0,0 @@ -# Copyright (c) 2025 -# License Information: To be decided! -# -# File: test_command_enum.py -# Description: Brief description of the file. -# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> -# Created on: 2025-02-18 - -import pytest - -from dedal.commands.command_enum import CommandEnum - -class TestCommandEnum: - - @pytest.mark.parametrize( - "test_id, command_name, expected_value", - [ - ("source", "SOURCE", "source"), - ("list_files", "LIST_FILES", "list_files"), - ("spack_install", "SPACK_INSTALL", "spack_install"), - ], - ) - def test_command_enum_values(self, test_id, command_name, expected_value): - - # Act - command = CommandEnum[command_name] - - # Assert - assert command.value == expected_value - assert command.name == command_name - - - def test_command_enum_invalid_name(self): - - # Act - with pytest.raises(KeyError): - CommandEnum["INVALID_COMMAND"] # type: ignore - diff --git a/dedal/tests/unit_tests/test_command_runner.py b/dedal/tests/unit_tests/test_command_runner.py deleted file mode 100644 index ac30fa08..00000000 --- a/dedal/tests/unit_tests/test_command_runner.py +++ /dev/null @@ -1,125 +0,0 @@ -# Copyright (c) 2025 -# License Information: To be decided! -# -# File: test_command_runner.py -# Description: Brief description of the file. -# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> -# Created on: 2025-02-18 - -import pytest - -from dedal.commands.command_enum import CommandEnum -from dedal.commands.command_runner import CommandRunner -from dedal.commands.command_sequence import CommandSequence -from dedal.commands.preconfigured_command_enum import PreconfiguredCommandEnum - - -class MockCommandSequence(CommandSequence): - def __init__(self, cmd_str: str): - self._cmd_str = cmd_str - - def execute(self) -> str: - return self._cmd_str - - -class TestCommandRunner: - @pytest.fixture(scope="function") - def mock_command_runner(self, mocker): - mocker.patch("dedal.commands.command_runner.BashCommandExecutor") - mocker.patch("dedal.commands.command_runner.logging.getLogger") - return CommandRunner() - - @pytest.mark.parametrize( - "test_id, command_str, mock_output, expected_output", - [ - ("simple_command", "echo hello", "hello\n", {"success": True, "output": "hello", "error": None}), - ("complex_command", "ls -l /tmp", "mock_output\n", - {"success": True, "output": "mock_output", "error": None}), - ("empty_output", "ls -l /tmp", "", {"success": True, "output": None, "error": None}), - ("command_with_error", "failing_command", "", {"success": False, "output": None, "error": "mock_error"}), - - ], - ) - def test_execute_command(self, mock_command_runner, test_id, command_str, mock_output, expected_output): - # Arrange - mock_command_runner.executor.execute.return_value = ( - mock_output, "mock_error" if "failing" in command_str else None) - command_sequence = MockCommandSequence(command_str) - - # Act - result = mock_command_runner.execute_command(command_sequence) - - # Assert - assert result == expected_output - mock_command_runner.executor.execute.assert_called_once() - mock_command_runner.executor.reset.assert_called_once() - - @pytest.mark.parametrize( - "test_id, command_type, args, expected_result", - [ - ( - "valid_command", - PreconfiguredCommandEnum.SPACK_COMPILER_FIND, - ["gcc"], - {"success": True, "output": "mock_output", "error": None}, - ), - ], - ) - def test_run_predefined_command_success_path(self, mock_command_runner, test_id, command_type, args, - expected_result): - # Arrange - mock_command_runner.executor.execute.return_value = ("mock_output\n", None) - - # Act - result = mock_command_runner.run_preconfigured_command_sequence(command_type, *args) - - # Assert - assert result == expected_result - - def test_run_predefined_command_invalid_type(self, mock_command_runner): - # Arrange - invalid_command_type = "INVALID_COMMAND" # type: ignore - - # Act - result = mock_command_runner.run_preconfigured_command_sequence(invalid_command_type) - - # Assert - assert result == {"success": False, "error": "Invalid command name: INVALID_COMMAND"} - - @pytest.mark.parametrize( - "test_id, command_placeholders_map, expected_result", - [ - ( - "single_command", - {CommandEnum.LIST_FILES: {"folder_location": "/tmp"}}, - {"success": True, "output": "mock_output", "error": None}, - ), - ( - "multiple_commands", - { - CommandEnum.LIST_FILES: {"folder_location": "/tmp"}, - CommandEnum.SHOW_DIRECTORY: {}, - }, - {"success": True, "output": "mock_output", "error": None}, - ), - ( - "empty_placeholders", - {CommandEnum.SHOW_DIRECTORY: {}}, - {"success": True, "output": "mock_output", "error": None}, - ), - ], - ) - def test_run_custom_command(self, mocker, mock_command_runner, test_id, command_placeholders_map, expected_result): - # Arrange - mock_command_runner.execute_command = mocker.MagicMock(return_value=expected_result) - mock_create_custom_command_sequence = mocker.patch( - "dedal.commands.command_runner.CommandSequenceFactory.create_custom_command_sequence") - mock_create_custom_command_sequence.return_value = MockCommandSequence("mock_command") - - # Act - result = mock_command_runner.run_custom_command_sequence(command_placeholders_map) - - # Assert - assert result == expected_result - mock_create_custom_command_sequence.assert_called_once_with(command_placeholders_map) - mock_command_runner.execute_command.assert_called_once_with(mock_create_custom_command_sequence.return_value) diff --git a/dedal/tests/unit_tests/test_command_sequence.py b/dedal/tests/unit_tests/test_command_sequence.py deleted file mode 100644 index e663c06b..00000000 --- a/dedal/tests/unit_tests/test_command_sequence.py +++ /dev/null @@ -1,108 +0,0 @@ -# Copyright (c) 2025 -# License Information: To be decided! -# -# File: test_command_sequence.py -# Description: Brief description of the file. -# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> -# Created on: 2025-02-19 -from unittest.mock import Mock - -import pytest - -from dedal.commands.command import Command -from dedal.commands.command_sequence import CommandSequence - - -class MockCommand(Command): - def __init__(self, cmd_str: str): - self._cmd_str = cmd_str - - def execute(self) -> str: - return self._cmd_str - - -class TestCommandSequence: - - @pytest.mark.parametrize( - "test_id, commands, expected_output", - [ - ("single_command", ["echo hello"], "echo hello"), - ("single_command_with_spaces at_beginning", [" pwd"], "pwd"), - ("single_command_with_spaces at end", [" pwd "], "pwd"), - ("multiple_commands_with_spaces", [" echo hello", " ls -l /tmp "], "echo hello && ls -l /tmp"), - ("multiple_commands", ["echo hello", "ls -l /tmp"], "echo hello && ls -l /tmp"), - ("multiple_commands_with_spaces_in_between_and_end", ["echo hello ", "ls -l /tmp "], - "echo hello && ls -l /tmp"), - ("empty_command", [""], ""), - ("commands_with_spaces", ["command with spaces", "another command"], - "command with spaces && another command"), - ], - ) - def test_execute_success_path(self, test_id, commands, expected_output): - # Arrange - sequence = CommandSequence() - for cmd in commands: - sequence.add_command(MockCommand(cmd)) - - # Act - result = sequence.execute() - - # Assert - assert result == expected_output - - def test_execute_no_commands(self): - # Arrange - sequence = CommandSequence() - - # Act - result = sequence.execute() - - # Assert - assert result == "" - - @pytest.mark.parametrize( - "test_id, num_commands", - [ - ("one_command", 1), - ("multiple_commands", 5), - ("no_commands", 0), - ], - ) - def test_add_command_success_path(self, test_id, num_commands): - # Arrange - sequence = CommandSequence() - commands = [MockCommand(f"command_{i}") for i in range(num_commands)] - - # Act - for command in commands: - sequence.add_command(command) - - # Assert - assert len(sequence.commands) == num_commands - - def test_add_command_invalid_type(self): - # Arrange - sequence = CommandSequence() - invalid_command = "not a command object" # type: ignore - - # Act - with pytest.raises(ValueError) as except_info: - sequence.add_command(invalid_command) - - # Assert - assert str(except_info.value) == "Command must be an instance of Command" - - @pytest.mark.parametrize("initial_commands", [[], [Mock(spec=Command), Mock(spec=Command)]]) - def test_clear(self, mocker, initial_commands): - # Test ID: clear - - # Arrange - sequence = CommandSequence() - for command in initial_commands: - sequence.add_command(command) - - # Act - sequence.clear() - - # Assert - assert len(sequence.commands) == 0 diff --git a/dedal/tests/unit_tests/test_command_sequence_builder.py b/dedal/tests/unit_tests/test_command_sequence_builder.py deleted file mode 100644 index e4004255..00000000 --- a/dedal/tests/unit_tests/test_command_sequence_builder.py +++ /dev/null @@ -1,95 +0,0 @@ -# Copyright (c) 2025 -# License Information: To be decided! -# -# File: test_command_sequence_builder.py -# Description: Brief description of the file. -# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> -# Created on: 2025-02-19 - -from unittest.mock import Mock, patch - -import pytest - -from dedal.commands.command import Command -from dedal.commands.command_enum import CommandEnum -from dedal.commands.command_registry import CommandRegistry -from dedal.commands.command_sequence import CommandSequence -from dedal.commands.command_sequence_builder import CommandSequenceBuilder -from dedal.commands.shell_command_factory import ShellCommandFactory - - -class TestCommandSequenceBuilder: - - @pytest.mark.parametrize( - "test_id, command_name, placeholders, expected_command", - [ - ("no_placeholders", CommandEnum.SHOW_DIRECTORY, {}, ["pwd"]), - ("with_placeholders", CommandEnum.LIST_FILES, {"folder_location": "/tmp "}, ["ls", "-l", "/tmp"]), - ("missing_placeholders", CommandEnum.FIND_IN_FILE, {"search_term": "error"}, - ["grep", "-i", "error", "{file_path}"]), - ("missing_placeholders_values_1", CommandEnum.SPACK_MIRROR_ADD, {"autopush": ""}, - ["spack", "mirror", "add", "{signed}", "{mirror_name}","{mirror_path}"]), - ("missing_placeholders_values_2", CommandEnum.SPACK_MIRROR_ADD, {"autopush": "", "signed": ""}, - ["spack", "mirror", "add", "{mirror_name}", "{mirror_path}"]), - ("missing_placeholders_values_3", CommandEnum.SPACK_MIRROR_ADD, {"autopush": "", "signed": "", "mirror_name": "test", "mirror_path": "test_path"}, - ["spack", "mirror", "add", "test", "test_path"]), - ("extra_placeholders", CommandEnum.ECHO_MESSAGE, {"message": "hello", "extra": "world"}, ["echo", "hello"]), - ], - ) - def test_add_generic_command_success_path(self, mocker, test_id, command_name, - placeholders, expected_command): - # Arrange - mock_get_command = mocker.patch.object(CommandRegistry, "get_command") - mock_create_command = mocker.patch.object(ShellCommandFactory, "create_command") - builder = CommandSequenceBuilder() - mock_get_command.return_value = expected_command - mock_create_command.return_value = Mock(spec=Command, - execute=lambda: " ".join(expected_command) if expected_command else "") - - # Act - builder.add_generic_command(command_name, placeholders) - - # Assert - mock_get_command.assert_called_once_with(command_name) - mock_create_command.assert_called_once_with(*expected_command) - assert len(builder.sequence.commands) == 1 - assert builder.sequence.commands[0].execute() == " ".join(expected_command) if expected_command else "" - - def test_add_generic_command_invalid_command_type(self): - # Arrange - builder = CommandSequenceBuilder() - invalid_command_name = "invalid" # type: ignore - - # Act - with pytest.raises(ValueError) as except_info: - builder.add_generic_command(invalid_command_name, {}) - - # Assert - assert str(except_info.value) == "Invalid command type. Use CommandEnum." - - def test_add_generic_command_unknown_command(self, mocker): - # Arrange - mock_get_command = mocker.patch.object(CommandRegistry, "get_command") - builder = CommandSequenceBuilder() - mock_get_command.return_value = None - - # Act - with pytest.raises(ValueError) as except_info: - builder.add_generic_command(CommandEnum.LIST_FILES, {}) - - # Assert - assert str(except_info.value) == "Unknown command: CommandEnum.LIST_FILES" - - def test_build(self): - # Arrange - builder = CommandSequenceBuilder() - builder.add_generic_command(CommandEnum.SHOW_DIRECTORY, {}) - - # Act - sequence = builder.build() - - # Assert - assert isinstance(sequence, CommandSequence) - assert sequence.execute() == "pwd" - assert len(sequence.commands) == 1 - assert len(builder.sequence.commands) == 0 # Check if the builder's sequence is reset diff --git a/dedal/tests/unit_tests/test_command_sequence_factory.py b/dedal/tests/unit_tests/test_command_sequence_factory.py deleted file mode 100644 index 7048690a..00000000 --- a/dedal/tests/unit_tests/test_command_sequence_factory.py +++ /dev/null @@ -1,49 +0,0 @@ -# Copyright (c) 2025 -# License Information: To be decided! -# -# File: test_command_sequence_factory.py -# Description: Brief description of the file. -# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> -# Created on: 2025-02-19 - -from unittest.mock import Mock, patch - -import pytest - -from dedal.commands.command_enum import CommandEnum -from dedal.commands.command_sequence import CommandSequence -from dedal.commands.command_sequence_builder import CommandSequenceBuilder -from dedal.commands.command_sequence_factory import CommandSequenceFactory - - -class TestCommandSequenceFactory: - - @pytest.mark.parametrize( - "test_id, command_placeholders_map, expected_calls", - [ - ("single_command", {CommandEnum.SHOW_DIRECTORY: {}}, [(CommandEnum.SHOW_DIRECTORY, {})]), - ( - "multiple_commands", - {CommandEnum.LIST_FILES: {"folder_location": "/tmp"}, CommandEnum.SHOW_DIRECTORY: {}}, - [(CommandEnum.LIST_FILES, {"folder_location": "/tmp"}), (CommandEnum.SHOW_DIRECTORY, {})], - ), - ("no_commands", {}, []), - ], - ) - def test_create_custom_command_sequence(self, mocker, test_id, command_placeholders_map, expected_calls): - # Arrange - mock_add_generic_command = mocker.patch.object(CommandSequenceBuilder, "add_generic_command") - mock_builder = Mock(spec=CommandSequenceBuilder, build=Mock(return_value=CommandSequence())) - mock_builder.add_generic_command = mock_add_generic_command - mock_add_generic_command.return_value = mock_builder - - with patch("dedal.commands.command_sequence_factory.CommandSequenceBuilder", return_value=mock_builder): - # Act - CommandSequenceFactory.create_custom_command_sequence(command_placeholders_map) - - # Assert - assert mock_add_generic_command.call_count == len(expected_calls) - mock_add_generic_command.assert_has_calls( - [mocker.call(args, kwargs) for args, kwargs in expected_calls], any_order=True - ) - mock_builder.build.assert_called_once() diff --git a/dedal/tests/unit_tests/test_generic_shell_command.py b/dedal/tests/unit_tests/test_generic_shell_command.py deleted file mode 100644 index 7ed779ae..00000000 --- a/dedal/tests/unit_tests/test_generic_shell_command.py +++ /dev/null @@ -1,63 +0,0 @@ -# Copyright (c) 2025 -# License Information: To be decided! -# -# File: test_generic_shell_command.py -# Description: Brief description of the file. -# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> -# Created on: 2025-02-19 - -import pytest - -from dedal.commands.command import Command -from dedal.commands.generic_shell_command import GenericShellCommand - -class TestGenericShellCommand: - - @pytest.mark.parametrize( - "test_id, command_name, args, expected_output", - [ - ("no_args", "ls", [], "ls"), - ("single_arg", "echo", ["hello"], "echo hello"), - ("multiple_args", "grep", ["-i", "error", "file.txt"], "grep -i error file.txt"), - ("command_with_spaces", " ls ", ["-l", "/tmp"], "ls -l /tmp"), - ("args_with_spaces", "ls", [" -l ", " /tmp "], "ls -l /tmp"), - ("command_and_args_with_spaces", " ls ", [" -l ", " /tmp "], "ls -l /tmp"), - ("empty_args", "ls", [""], "ls "), # Empty arguments are preserved, but stripped - ], - ) - def test_execute_success_path(self, test_id, command_name, args, expected_output): - - # Act - command = GenericShellCommand(command_name, *args) - result = command.execute() - - # Assert - assert result == expected_output - - - @pytest.mark.parametrize( - "test_id, command_name, args, expected_error", - [ - ("empty_command_name", "", [], ValueError), - ("none_command_name", None, [], ValueError), # type: ignore - ("int_command_name", 123, [], ValueError), # type: ignore - ("invalid_arg_type", "ls", [123], ValueError), # type: ignore - ("mixed_arg_types", "ls", ["valid", 456], ValueError), # type: ignore - ], - ) - def test_init_error_cases(self, test_id, command_name, args, expected_error): - - # Act - with pytest.raises(expected_error) as except_info: - GenericShellCommand(command_name, *args) - - # Assert - if expected_error is ValueError: - if not command_name: - assert str(except_info.value) == "Command name is required!" - elif not isinstance(command_name, str): - assert str(except_info.value) == "Command name must be a string!" - else: - assert str(except_info.value) == "All arguments must be strings!" - - diff --git a/dedal/tests/unit_tests/test_preconfigured_command_enum.py b/dedal/tests/unit_tests/test_preconfigured_command_enum.py deleted file mode 100644 index 820c4d41..00000000 --- a/dedal/tests/unit_tests/test_preconfigured_command_enum.py +++ /dev/null @@ -1,37 +0,0 @@ -# Copyright (c) 2025 -# License Information: To be decided! -# -# File: test_preconfigured_command_enum.py -# Description: Brief description of the file. -# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> -# Created on: 2025-02-19 - -import pytest - -from dedal.commands.preconfigured_command_enum import PreconfiguredCommandEnum - -class TestPreconfiguredCommandEnum: - - @pytest.mark.parametrize( - "test_id, command_name, expected_value", - [ - ("spack_compiler_find", "SPACK_COMPILER_FIND", "spack_compiler_find"), - ("spack_compilers", "SPACK_COMPILERS", "spack_compilers"), - ("spack_install", "SPACK_INSTALL", "spack_install"), - ], - ) - def test_preconfigured_command_enum_values(self, test_id, command_name, expected_value): - - # Act - command = PreconfiguredCommandEnum[command_name] - - # Assert - assert command.value == expected_value - assert command.name == command_name - - - def test_preconfigured_command_enum_invalid_name(self): - - # Act - with pytest.raises(KeyError): - PreconfiguredCommandEnum["INVALID_COMMAND"] # type: ignore diff --git a/dedal/tests/unit_tests/test_shell_command_factory.py b/dedal/tests/unit_tests/test_shell_command_factory.py deleted file mode 100644 index bfd2b2db..00000000 --- a/dedal/tests/unit_tests/test_shell_command_factory.py +++ /dev/null @@ -1,61 +0,0 @@ -# Copyright (c) 2025 -# License Information: To be decided! -# -# File: test_shell_command_factory.py -# Description: Brief description of the file. -# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> -# Created on: 2025-02-19 - -import pytest - -from dedal.commands.generic_shell_command import GenericShellCommand -from dedal.commands.shell_command_factory import ShellCommandFactory - - -class TestShellCommandFactory: - - @pytest.mark.parametrize( - "test_id, command_name, args", - [ - ("no_args", "ls", []), - ("single_arg", "echo", ["hello"]), - ("multiple_args", "grep", ["-i", "error", "file.txt"]), - ("command_with_spaces", " ls ", ["-l", "/tmp"]), - ("args_with_spaces", "ls", [" -l ", " /tmp "]), - ("command_and_args_with_spaces", " ls ", [" -l ", " /tmp "]), - ], - ) - def test_create_command_happy_path(self, test_id, command_name, args): - - # Act - command = ShellCommandFactory.create_command(command_name, *args) - - # Assert - assert isinstance(command, GenericShellCommand) - assert command.command_name == command_name.strip() - assert command.args == tuple(map(str.strip, args)) - - @pytest.mark.parametrize( - "test_id, command_name, args, expected_error", - [ - ("empty_command_name", "", [], ValueError), - ("none_command_name", None, [], ValueError), # type: ignore - ("int_command_name", 123, [], ValueError), # type: ignore - ("invalid_arg_type", "ls", [123], ValueError), # type: ignore - ("mixed_arg_types", "ls", ["valid", 456], ValueError), # type: ignore - ], - ) - def test_create_command_error_cases(self, test_id, command_name, args, expected_error): - - # Act - with pytest.raises(expected_error) as except_info: - ShellCommandFactory.create_command(command_name, *args) - - # Assert - if expected_error is ValueError: - if not command_name: - assert str(except_info.value) == "Command name is required!" - elif not isinstance(command_name, str): - assert str(except_info.value) == "Command name must be a string!" - else: - assert str(except_info.value) == "All arguments must be strings!" diff --git a/dedal/tests/unit_tests/test_spack_command_sequence_factory.py b/dedal/tests/unit_tests/test_spack_command_sequence_factory.py deleted file mode 100644 index 0ffebcf3..00000000 --- a/dedal/tests/unit_tests/test_spack_command_sequence_factory.py +++ /dev/null @@ -1,159 +0,0 @@ -# Copyright (c) 2025 -# License Information: To be decided! -# -# File: test_spack_command_sequence_factory.py -# Description: Brief description of the file. -# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> -# Created on: 2025-02-19 - -import pytest - -from dedal.commands.command_enum import CommandEnum -from dedal.commands.command_sequence import CommandSequence -from dedal.commands.command_sequence_builder import CommandSequenceBuilder -from dedal.commands.spack_command_sequence_factory import SpackCommandSequenceFactory - - -class TestSpackCommandSequenceFactory: - - @pytest.mark.parametrize( - "test_id, spack_setup_script", - [ - ("with_setup_script", "/path/to/setup.sh"), - ("empty_setup_script", "")], - ) - def test_create_spack_enabled_command_sequence_builder(self, test_id, spack_setup_script): - # Act - builder = SpackCommandSequenceFactory.create_spack_enabled_command_sequence_builder(spack_setup_script) - - # Assert - assert isinstance(builder, CommandSequenceBuilder) - assert len(builder.sequence.commands) == 1 - assert builder.sequence.commands[0].command_name == "source" - if spack_setup_script: - assert builder.sequence.commands[0].args == ("/path/to/setup.sh",) - else: - assert len(builder.sequence.commands[0].args) == 0 - - @pytest.mark.parametrize( - "test_id, method_name, args, kwargs, expected_command, expected_args", - [ - ( - "spack_compilers", "create_spack_compilers_command_sequence", ["/path/to/setup.sh"], {}, "spack", - ["compilers"] - ), - ( - "spack_compiler_list", "create_spack_compiler_list_command_sequence", ["/path/to/setup.sh"], {}, - "spack", ["compiler", "list"] - ), - ( - "spack_install_with_version", "create_spack_install_package_command_sequence", - ["/path/to/setup.sh", "package", "1.0"], {}, "spack", ["install", "package@1.0"] - ), - ( - "spack_install_no_version", "create_spack_install_package_command_sequence", - ["/path/to/setup.sh", "package", None], {}, "spack", ["install", "package"] - ), - ( - "spack_install_no_package", "create_spack_install_package_command_sequence", - ["/path/to/setup.sh", None, "1.0"], {}, "spack", ["install"] - ), - ( - "spack_compiler_info", "create_spack_compiler_info_command_sequence", - ["/path/to/setup.sh", "gcc", "10.2"], {}, "spack", ["compiler", "info", "gcc@10.2"] - ), - ], - ) - def test_create_command_sequence(self, test_id, method_name, args, kwargs, expected_command, expected_args): - # Arrange - method = getattr(SpackCommandSequenceFactory, method_name) - - # Act - sequence = method(*args, **kwargs) - - # Assert - assert isinstance(sequence, CommandSequence) - assert len(sequence.commands) == 2 - assert sequence.commands[1].command_name == expected_command - assert sequence.commands[1].args == tuple(expected_args) - - @pytest.mark.parametrize( - "test_id, env_name, mirror_name, mirror_path, autopush, signed, expected_length, expected_autopush, expected_signed, expected_output", - [ - ("no_env", "", "mymirror", "/path/to/mirror", False, False, 2, "", "", - ("mirror", "add", "mymirror", "/path/to/mirror")), - ("with_env", "myenv", "mymirror", "/path/to/mirror", True, True, 3, "--autopush", "--signed", - ("mirror", "add", "--autopush", "--signed", "mymirror", "/path/to/mirror")), - ], - ) - def test_create_spack_mirror_add_command_sequence(self, test_id, env_name, mirror_name, mirror_path, autopush, - signed, expected_length, expected_autopush, expected_signed, - expected_output): - # Arrange - spack_setup_script = "/path/to/setup.sh" - - # Act - sequence = SpackCommandSequenceFactory.create_spack_mirror_add_command_sequence( - spack_setup_script, env_name, mirror_name, mirror_path, autopush, signed - ) - - # Assert - assert isinstance(sequence, CommandSequence) - assert len(sequence.commands) == expected_length - assert sequence.commands[-1].command_name == "spack" - assert sequence.commands[-1].args == expected_output - - @pytest.mark.parametrize( - "test_id, package_name, version, expected_package_arg", - [ - ("with_package_and_version", "mypackage", "1.2.3", "mypackage@1.2.3"), - ("only_package_name", "mypackage", None, "mypackage"), - ("no_package", None, "1.2.3", ""), - ], - ) - def test_create_spack_post_install_find_command_sequence(self, test_id, package_name, version, - expected_package_arg): - # Arrange - spack_setup_script = "/path/to/setup.sh" - env_path = "/path/to/env" - - # Act - sequence = SpackCommandSequenceFactory.create_spack_post_install_find_command_sequence( - spack_setup_script, env_path, package_name, version - ) - - # Assert - assert isinstance(sequence, CommandSequence) - assert len(sequence.commands) == 3 - assert sequence.commands[1].command_name == "spack" - assert sequence.commands[1].args == ("env", "activate", "-p", env_path) - assert sequence.commands[2].command_name == "spack" - assert sequence.commands[2].args == ("find", expected_package_arg) if expected_package_arg else ("find",) - - @pytest.mark.parametrize("spack_setup_script, public_key_path", [ - ("/path/to/setup-env.sh", "key.gpg"), - ("./setup-env.sh", "path/to/key.gpg"), - ]) - def test_create_spack_gpg_trust_command_sequence(self, mocker, spack_setup_script, public_key_path): - # Test ID: create_spack_gpg_trust_command_sequence - - # Arrange - mock_command_sequence_builder = mocker.MagicMock() - mocker.patch.object(SpackCommandSequenceFactory, "create_spack_enabled_command_sequence_builder", - return_value=mock_command_sequence_builder) - - mock_command_sequence = mocker.MagicMock() - mock_command_sequence_builder.add_generic_command.return_value = mock_command_sequence_builder - mock_command_sequence_builder.build.return_value = mock_command_sequence - - # Act - result = SpackCommandSequenceFactory.create_spack_gpg_trust_command_sequence(spack_setup_script, - public_key_path) - - # Assert - assert result == mock_command_sequence - SpackCommandSequenceFactory.create_spack_enabled_command_sequence_builder.assert_called_once_with( - spack_setup_script) - mock_command_sequence_builder.add_generic_command.assert_called_once_with(CommandEnum.SPACK_GPG_TRUST, - {"public_key_path": public_key_path}) - mock_command_sequence_builder.build.assert_called_once() diff --git a/dedal/tests/unit_tests/test_spack_operation.py b/dedal/tests/unit_tests/test_spack_operation.py index f053459c..fa322c7b 100644 --- a/dedal/tests/unit_tests/test_spack_operation.py +++ b/dedal/tests/unit_tests/test_spack_operation.py @@ -1,11 +1,3 @@ -# Copyright (c) 2025 -# License Information: To be decided! -# -# File: test_spack_operation.py -# Description: Brief description of the file. -# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> -# Created on: 2025-02-19 - import pytest from _pytest.fixtures import fixture diff --git a/dedal/tests/unit_tests/test_spack_operation_use_cache.py b/dedal/tests/unit_tests/test_spack_operation_use_cache.py index fe5d9da3..f0eaf796 100644 --- a/dedal/tests/unit_tests/test_spack_operation_use_cache.py +++ b/dedal/tests/unit_tests/test_spack_operation_use_cache.py @@ -1,10 +1,3 @@ -# Copyright (c) 2025 -# License Information: To be decided! -# -# File: test_spack_operation_use_cache.py -# Description: Brief description of the file. -# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> -# Created on: 2025-02-20 from pathlib import Path import pytest diff --git a/dedal/utils/utils.py b/dedal/utils/utils.py index f7fe6620..e37bce84 100644 --- a/dedal/utils/utils.py +++ b/dedal/utils/utils.py @@ -155,3 +155,9 @@ def resolve_path(path: str): else: path = Path(path).resolve() return path + + +def count_files_in_folder(folder_path: str) -> int: + if not os.path.isdir(folder_path): + raise ValueError(f"{folder_path} is not a valid directory") + return sum(1 for file in os.listdir(folder_path) if os.path.isfile(os.path.join(folder_path, file))) diff --git a/pyproject.toml b/pyproject.toml index c7ea2762..7b829097 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,5 +28,4 @@ dedal = "dedal.cli.spack_manager_api:cli" "dedal" = ["dedal/logger/logging.conf"] [project.optional-dependencies] -test = ["pytest", "pytest-mock", "pytest-ordering", "coverage"] -dev = ["mypy", "pylint", "black", "flake8"] \ No newline at end of file +test = ["pytest", "pytest-mock", "pytest-ordering", "coverage"] \ No newline at end of file -- GitLab From e45531fb5fc13863c7d68c54bfe442925e3631bb Mon Sep 17 00:00:00 2001 From: adrianciu <adrianciu25@gmail.com> Date: Wed, 12 Mar 2025 17:07:02 +0200 Subject: [PATCH 29/30] VT-95: refactored the use cache functionality. implemented tests for use cache. added additional spack methods; Added missing documentation for methods. --- dedal/build_cache/BuildCacheManager.py | 6 +- dedal/cli/spack_manager_api.py | 2 +- dedal/commands/command_runner.py | 207 ------------------ dedal/commands/preconfigured_command_enum.py | 28 --- dedal/spack_factory/SpackOperation.py | 89 +++++--- .../SpackOperationCreateCache.py | 8 + dedal/spack_factory/SpackOperationUseCache.py | 35 ++- dedal/specfile_storage_path_source.py | 6 +- .../spack_create_cache_test.py | 19 +- .../spack_from_cache_test.py | 36 +-- .../spack_from_scratch_test.py | 67 ++++-- ...manager.py => build_cache_manager_test.py} | 0 ...e.py => spack_operation_use_cache_test.py} | 11 +- .../tests/unit_tests/test_spack_operation.py | 201 ----------------- dedal/tests/unit_tests/utils_test.py | 139 +++++++++++- dedal/utils/utils.py | 10 +- 16 files changed, 298 insertions(+), 566 deletions(-) delete mode 100644 dedal/commands/command_runner.py delete mode 100644 dedal/commands/preconfigured_command_enum.py rename dedal/tests/unit_tests/{test_build_cache_manager.py => build_cache_manager_test.py} (100%) rename dedal/tests/unit_tests/{test_spack_operation_use_cache.py => spack_operation_use_cache_test.py} (86%) delete mode 100644 dedal/tests/unit_tests/test_spack_operation.py diff --git a/dedal/build_cache/BuildCacheManager.py b/dedal/build_cache/BuildCacheManager.py index 3b96cd09..e4c8284c 100644 --- a/dedal/build_cache/BuildCacheManager.py +++ b/dedal/build_cache/BuildCacheManager.py @@ -120,8 +120,7 @@ class BuildCacheManager(BuildCacheManagerInterface): def __log_warning_if_needed(self, warn_message: str, items: list[str]) -> None: """Logs a warning message if the number of items is greater than 1. (Private function) - - This method logs a warning message using the provided message and items if the list of items has more than one element. + This method logs a warning message using the provided message and items if the list of items has more than one element. Args: warn_message (str): The warning message to log. @@ -132,12 +131,9 @@ class BuildCacheManager(BuildCacheManagerInterface): def get_public_key_from_cache(self, build_cache_dir: str | None) -> str | None: """Retrieves the public key from the build cache. - This method searches for the public key within the specified build cache directory. - Args: build_cache_dir (str | None): The path to the build cache directory. - Returns: str | None: The path to the public key file if found, otherwise None. """ diff --git a/dedal/cli/spack_manager_api.py b/dedal/cli/spack_manager_api.py index 497bce91..16d435fa 100644 --- a/dedal/cli/spack_manager_api.py +++ b/dedal/cli/spack_manager_api.py @@ -10,7 +10,7 @@ from dedal.configuration.SpackConfig import SpackConfig from dedal.model.SpackDescriptor import SpackDescriptor from dedal.utils.utils import resolve_path -SESSION_CONFIG_PATH = os.path.expanduser(f'~/tmp/dedal/dedal_session.json') +SESSION_CONFIG_PATH = os.path.expanduser('/tmp/dedal/dedal_session.json') os.makedirs(os.path.dirname(SESSION_CONFIG_PATH), exist_ok=True) diff --git a/dedal/commands/command_runner.py b/dedal/commands/command_runner.py deleted file mode 100644 index 88ee46a8..00000000 --- a/dedal/commands/command_runner.py +++ /dev/null @@ -1,207 +0,0 @@ -""" Command Runner module. """ -# Copyright (c) 2025 -# License Information: To be decided! -# -# File: command_runner.py -# Description: Manages creation, execution, and result handling of command sequences. -# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> -# Created on: 2025-02-17 - -from __future__ import annotations - -import logging -from logging import Logger -from typing import Callable, Any - -from dedal.commands.bash_command_executor import BashCommandExecutor -from dedal.commands.command_enum import CommandEnum -from dedal.commands.command_sequence import CommandSequence -from dedal.commands.command_sequence_factory import CommandSequenceFactory -from dedal.commands.preconfigured_command_enum import PreconfiguredCommandEnum -from dedal.commands.spack_command_sequence_factory import SpackCommandSequenceFactory - - -class CommandRunner: - """This module provides a unified interface for executing both predefined and custom command sequences. - - Functionality - - The module offers two primary methods for running command sequences: - - 1. Run Preconfigured Command Sequence: This method allows users to execute preconfigured command sequences using the 'run_preconfigured_command_sequence' method. - 2. Run Custom Command Sequence: This method enables users to execute dynamically generated custom command sequences using the 'run_custom_command_sequence' method. - - Preconfigured Command Sequences - To run a preconfigured command sequence, users can simply call the run_preconfigured_command_sequence method. The module will execute the corresponding command sequence. - To create and run a preconfigured command sequence, users must: - 1. Define a new preconfigured command in the PreconfiguredCommandEnum. - 2. Add a new entry in CommandRunner.commands_map that maps the preconfigured command to its corresponding function defined in the SpackCommandSequenceFactory class. - 4. Call the run_preconfigured_command_sequence method to execute the preconfigured command sequence. - - Custom Command Sequences - To create and execute a custom command sequence, users must: - 1. Define the commands as a dictionary, where the key represents the command already registered in the command_registry, and the value contains the corresponding placeholder values expected by the command. - 2. Invoke the run_custom_command_sequence using generated dictionary to execute the custom command sequence. - - Command Pattern Diagram - ------------------------ - - The Command pattern is used to encapsulate the request as an object, allowing for more flexibility and extensibility in handling requests. - - ``` - +---------------+ - | Client | - +---------------+ - | - | - v - +---------------+ - | Invoker | - | (CommandRunner)| - +---------------+ - | - | - v - +---------------+ - | Command | - | (PreconfiguredCommandEnum)| - +---------------+ - | - | - v - +---------------+ - | Receiver | - | (SpackCommandSequenceFactory)| - +---------------+ - ``` - - In this diagram: - - * The Client is the user of the CommandRunner. - * The Invoker is the CommandRunner itself, which receives the Command object and invokes the corresponding action. - * The Command is the PreconfiguredCommandEnum, which represents the request. - * The Receiver is the SpackCommandSequenceFactory, which performs the action requested by the Command object. - - Benefits - This module provides a flexible and unified interface for executing both predefined and custom command sequences, allowing users to easily manage and execute complex command workflows. - - - Attributes - ---------- - logger : Logger - The logger instance used for logging purposes. - executor : BashCommandExecutor - The BashCommandExecutor instance used for executing commands. - commands_map : dict[PreconfiguredCommandEnum, Callable[..., CommandSequence]] - A dictionary mapping preconfigured commands to their corresponding functions. - - Methods - ------- - run_preconfigured_command_sequence(preconfigured_command: PreconfiguredCommandEnum) - Executes a preconfigured command sequence. - run_custom_command_sequence(command_sequence: dict) - Executes a custom command sequence. - """ - - def __init__(self) -> None: - """Initializes the CommandRunner.""" - self.logger: Logger = logging.getLogger(__name__) - self.executor: BashCommandExecutor = BashCommandExecutor() - self.commands_map: dict[PreconfiguredCommandEnum, Callable[..., CommandSequence]] = { - PreconfiguredCommandEnum.SPACK_COMPILER_FIND: - SpackCommandSequenceFactory.create_spack_compilers_command_sequence, - PreconfiguredCommandEnum.SPACK_COMPILER_LIST: - SpackCommandSequenceFactory.create_spack_compiler_list_command_sequence, - PreconfiguredCommandEnum.SPACK_COMPILERS: - SpackCommandSequenceFactory.create_spack_compiler_list_command_sequence, - PreconfiguredCommandEnum.SPACK_COMPILER_INFO: - SpackCommandSequenceFactory.create_spack_compiler_info_command_sequence, - PreconfiguredCommandEnum.SPACK_FIND: - SpackCommandSequenceFactory.create_spack_post_install_find_command_sequence, - PreconfiguredCommandEnum.SPACK_INSTALL: - SpackCommandSequenceFactory.create_spack_install_package_command_sequence, - PreconfiguredCommandEnum.SPACK_MIRROR_ADD: - SpackCommandSequenceFactory.create_spack_mirror_add_command_sequence, - PreconfiguredCommandEnum.SPACK_GPG_TRUST: - SpackCommandSequenceFactory.create_spack_gpg_trust_command_sequence, - } - - def execute_command(self, - command_sequence: CommandSequence) -> dict[str, str | bool | None]: - """Executes a given command sequence. - - Adds the command sequence to the executor and runs it, returning the result as a dictionary. - - Args: - command_sequence (CommandSequence): The command sequence to execute. - - Returns (dict[str, str | bool | None]): - A dictionary containing the execution result. - The dictionary has the following keys: - - success (bool): True if the command executed successfully, False otherwise. - - output (str | None): The output of the command if successful, None otherwise. - - error (str | None): The error message if the command failed, None otherwise. - """ - self.executor.add_command(command_sequence) - output, error = self.executor.execute() - self.executor.reset() - return { - "success": error is None, - "output": output.strip() if output else None, - "error": error - } - - def run_preconfigured_command_sequence(self, - command_name: PreconfiguredCommandEnum, - *args: Any) -> dict[str, str | bool | None]: - """Runs a predefined command sequence. - - Creates and executes a predefined command based on the given name and arguments. - - For example `run_preconfigured_command_sequence(PreconfiguredCommandEnum.SPACK_COMPILER_FIND, 'gcc', '11')` - will execute the command `source spack_setup_path && spack find gcc@11` - - Args: - command_name (PreconfiguredCommandEnum): The name of the predefined command sequence. - args (tuple(Any, ...)): Arguments to pass to the command constructor. The arguments should correspond to the flags, options, and placeholders of the command. - - Returns: - A dictionary containing the execution result. - The dictionary has the following keys: - - success (bool): True if the command executed successfully, False otherwise. - - output (str | None): The output of the command if successful, None otherwise. - - error (str | None): The error message if the command failed, None otherwise. - If command_type is invalid, returns a dictionary with "success" as False and an error message. - """ - if command_name not in self.commands_map: - return {"success": False, "error": f"Invalid command name: {command_name}"} - - command_sequence = self.commands_map[command_name](*args) - return self.execute_command(command_sequence) - - def run_custom_command_sequence(self, - command_placeholders_map: dict[CommandEnum, dict[str, str]]) \ - -> dict[str, str | bool | None]: - """Runs a custom command sequence. - - Creates and executes a custom command sequence from a map of command names to placeholder values. - - Args: - command_placeholders_map (dict[CommandEnum, dict[str, str]]): A dictionary mapping command name enums to - placeholder values. The key is the command name in `CommandEnum` and the value is a dictionary - mapping placeholder names to their actual values. - - For example, if the key command is `CommandEnum.FIND_IN_FILE`, - this corresponds to the command `grep -i {search_term} {file_path}` in CommandRegistry.COMMANDS. - Hence the placeholder_map_value should be `{search_term: 'python', file_path: '/path/to/file.txt'}`. - - Returns (dict[str, str | bool | None]): - A dictionary containing the execution result. - The dictionary has the following keys: - - success (bool): True if the command executed successfully, False otherwise. - - output (str | None): The output of the command if successful, None otherwise. - - error (str | None): The error message if the command failed, None otherwise. - """ - command_sequence = (CommandSequenceFactory - .create_custom_command_sequence(command_placeholders_map)) - return self.execute_command(command_sequence) diff --git a/dedal/commands/preconfigured_command_enum.py b/dedal/commands/preconfigured_command_enum.py deleted file mode 100644 index 14b747ad..00000000 --- a/dedal/commands/preconfigured_command_enum.py +++ /dev/null @@ -1,28 +0,0 @@ -""" Preconfigured Command Enum module. """ -# Copyright (c) 2025 -# License Information: To be decided! -# -# File: command_enum.py -# Description: Enumeration of supported predefined commands used in command_runner module: -# Created by: Murugan, Jithu <j.murugan@fz-juelich.de> -# Created on: 2025-02-17 - -from enum import Enum - - -class PreconfiguredCommandEnum(Enum): - """Enumeration of preconfigured composite commands. - - Provides a predefined set of command identifiers for commonly used operations. - - The command_runner module uses these identifiers to construct command sequences and execute them via 'run_preconfigured_command_sequence' method. - """ - SPACK_COMPILER_FIND = "spack_compiler_find" - SPACK_COMPILERS = "spack_compilers" - SPACK_COMPILER_INFO = "spack_compiler_info" - SPACK_COMPILER_LIST = "spack_compiler_list" - SPACK_ENVIRONMENT_ACTIVATE = "spack_environment_activate" - SPACK_FIND = "spack_find" - SPACK_INSTALL = "spack_install" - SPACK_MIRROR_ADD = "spack_mirror_add" - SPACK_GPG_TRUST = "spack_gpg_trust" diff --git a/dedal/spack_factory/SpackOperation.py b/dedal/spack_factory/SpackOperation.py index b9fd6e79..1b2442fd 100644 --- a/dedal/spack_factory/SpackOperation.py +++ b/dedal/spack_factory/SpackOperation.py @@ -8,7 +8,7 @@ from dedal.error_handling.exceptions import BashCommandException, NoSpackEnviron SpackRepoException from dedal.logger.logger_builder import get_logger from dedal.tests.testing_variables import SPACK_VERSION -from dedal.utils.utils import run_command, git_clone_repo, log_command, set_bashrc_variable +from dedal.utils.utils import run_command, git_clone_repo, log_command, set_bashrc_variable, get_first_word from dedal.wrapper.spack_wrapper import check_spack_env @@ -32,7 +32,7 @@ class SpackOperation: os.makedirs(self.spack_config.install_dir, exist_ok=True) self.spack_dir = self.spack_config.install_dir / 'spack' - self.spack_setup_script = "" if self.spack_config.use_spack_global else f"source {self.spack_dir / 'share' / 'spack' / 'setup-env.sh'} &&" + self.spack_setup_script = "" if self.spack_config.use_spack_global else f"source {self.spack_dir / 'share' / 'spack' / 'setup-env.sh'}" self.logger = logger self.spack_config.concretization_dir = spack_config.concretization_dir if self.spack_config.concretization_dir: @@ -42,7 +42,7 @@ class SpackOperation: os.makedirs(self.spack_config.buildcache_dir, exist_ok=True) if self.spack_config.env and spack_config.env.name: self.env_path: Path = spack_config.env.path / spack_config.env.name - self.spack_command_on_env = f'{self.spack_setup_script} spack env activate -p {self.env_path}' + self.spack_command_on_env = f'{self.spack_setup_script} && spack env activate -p {self.env_path}' else: self.spack_command_on_env = self.spack_setup_script if self.spack_config.env and spack_config.env.path: @@ -50,14 +50,15 @@ class SpackOperation: self.spack_config.env.path.mkdir(parents=True, exist_ok=True) def create_fetch_spack_environment(self): - if self.spack_config.env.git_path: + """Fetches a spack environment if the git path is defined, otherwise creates it.""" + if self.spack_config.env and self.spack_config.env.git_path: git_clone_repo(self.spack_config.env.name, self.spack_config.env.path / self.spack_config.env.name, self.spack_config.env.git_path, logger=self.logger) else: os.makedirs(self.spack_config.env.path / self.spack_config.env.name, exist_ok=True) run_command("bash", "-c", - f'{self.spack_setup_script} spack env create -d {self.env_path}', + f'{self.spack_setup_script} && spack env create -d {self.env_path}', check=True, logger=self.logger, info_msg=f"Created {self.spack_config.env.name} spack environment", exception_msg=f"Failed to create {self.spack_config.env.name} spack environment", @@ -80,7 +81,8 @@ class SpackOperation: else: self.logger.error(f'Invalid installation path: {self.spack_dir}') # Restart the bash after adding environment variables - self.create_fetch_spack_environment() + if self.spack_config.env: + self.create_fetch_spack_environment() if self.spack_config.install_dir.exists(): for repo in self.spack_config.repos: repo_dir = self.spack_config.install_dir / repo.path / repo.name @@ -92,10 +94,13 @@ class SpackOperation: self.logger.debug(f'Spack repository {repo.name} already added') def spack_repo_exists(self, repo_name: str) -> bool | None: - """Check if the given Spack repository exists.""" + """Check if the given Spack repository exists. + Returns: + True if spack repository exists, False otherwise. + """ if self.spack_config.env is None: result = run_command("bash", "-c", - f'{self.spack_setup_script} spack repo list', + f'{self.spack_setup_script} && spack repo list', check=True, capture_output=True, text=True, logger=self.logger, info_msg=f'Checking if {repo_name} exists') @@ -107,15 +112,19 @@ class SpackOperation: f'{self.spack_command_on_env} && spack repo list', check=True, capture_output=True, text=True, logger=self.logger, - info_msg=f'Checking if repository {repo_name} was added') + info_msg=f'Checking if repository {repo_name} was added').stdout else: self.logger.debug('No spack environment defined') raise NoSpackEnvironmentException('No spack environment defined') if result is None: return False - return any(line.strip().endswith(repo_name) for line in result.stdout.splitlines()) + return any(line.strip().endswith(repo_name) for line in result.splitlines()) def spack_env_exists(self): + """Checks if a spack environments exists. + Returns: + True if spack environments exists, False otherwise. + """ result = run_command("bash", "-c", self.spack_command_on_env, check=True, @@ -123,7 +132,6 @@ class SpackOperation: info_msg=f'Checking if environment {self.spack_config.env.name} exists') return result is not None - @check_spack_env def add_spack_repo(self, repo_path: Path, repo_name: str): """Add the Spack repository if it does not exist.""" run_command("bash", "-c", @@ -135,6 +143,10 @@ class SpackOperation: @check_spack_env def get_compiler_version(self): + """Returns the compiler version + Raises: + NoSpackEnvironmentException: If the spack environment is not set up. + """ result = run_command("bash", "-c", f'{self.spack_command_on_env} && spack compiler list', check=True, logger=self.logger, @@ -142,7 +154,7 @@ class SpackOperation: info_msg=f"Checking spack environment compiler version for {self.spack_config.env.name}", exception_msg=f"Failed to checking spack environment compiler version for {self.spack_config.env.name}", exception=BashCommandException) - # todo add error handling and tests + if result.stdout is None: self.logger.debug(f'No gcc found for {self.spack_config.env.name}') return None @@ -154,7 +166,8 @@ class SpackOperation: return gcc_version def get_spack_installed_version(self): - spack_version = run_command("bash", "-c", f'{self.spack_setup_script} spack --version', + """Returns the spack installed version""" + spack_version = run_command("bash", "-c", f'{self.spack_setup_script} && spack --version', capture_output=True, text=True, check=True, logger=self.logger, info_msg=f"Getting spack version", @@ -165,6 +178,12 @@ class SpackOperation: @check_spack_env def concretize_spack_env(self, force=True): + """Concretization step for a spack environment + Args: + force (bool): TOverrides an existing concretization when set to True + Raises: + NoSpackEnvironmentException: If the spack environment is not set up. + """ force = '--force' if force else '' run_command("bash", "-c", f'{self.spack_command_on_env} && spack concretize {force}', @@ -175,9 +194,10 @@ class SpackOperation: exception=SpackConcertizeException) def create_gpg_keys(self): + """Creates GPG keys (which can be used when creating binary cashes) and adds it to the trusted keyring.""" if self.spack_config.gpg: run_command("bash", "-c", - f'{self.spack_setup_script} spack gpg init && spack gpg create {self.spack_config.gpg.name} {self.spack_config.gpg.mail}', + f'{self.spack_setup_script} && spack gpg init && spack gpg create {self.spack_config.gpg.name} {self.spack_config.gpg.mail}', check=True, logger=self.logger, info_msg=f'Created pgp keys for {self.spack_config.env.name}', @@ -206,7 +226,7 @@ class SpackOperation: spack_add_mirror = f'spack mirror add {autopush} {signed} {mirror_name} {mirror_path}' if global_mirror: run_command("bash", "-c", - f'{self.spack_setup_script} {spack_add_mirror}', + f'{self.spack_setup_script} && {spack_add_mirror}', check=True, logger=self.logger, info_msg=f'Added mirror {mirror_name}', @@ -222,6 +242,7 @@ class SpackOperation: exception_msg=f'Failed to add mirror {mirror_name}', exception=SpackMirrorException)) + @check_spack_env def trust_gpg_key(self, public_key_path: str): """Adds a GPG public key to the trusted keyring. This method attempts to add the provided GPG public key to the @@ -232,6 +253,7 @@ class SpackOperation: bool: True if the key was added successfully, False otherwise. Raises: ValueError: If public_key_path is empty. + NoSpackEnvironmentException: If the spack environment is not set up. """ if not public_key_path: raise ValueError("public_key_path is required") @@ -244,24 +266,27 @@ class SpackOperation: exception_msg=f'Failed to trust GPG key for {self.spack_config.env.name}', exception=SpackGpgException) - def list_mirrors(self): + def mirror_list(self): + """Returns of available mirrors. When an environment is activated it will return the mirrors associated with it, + otherwise the mirrors set globally""" mirrors = run_command("bash", "-c", - f'{self.spack_setup_script} spack mirror list', - check=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - logger=self.logger, - info_msg=f'Listing mirrors', - exception_msg=f'Failed list mirrors', - exception=SpackMirrorException) - return list(mirrors.stdout.strip().splitlines()) + f'{self.spack_command_on_env} && spack mirror list', + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + logger=self.logger, + info_msg=f'Listing mirrors', + exception_msg=f'Failed list mirrors', + exception=SpackMirrorException).stdout + return list(map(get_first_word, list(mirrors.strip().splitlines()))) def remove_mirror(self, mirror_name: str): + """Removes a mirror from an environment (if it is activated), otherwise removes the mirror globally.""" if not mirror_name: raise ValueError("mirror_name is required") run_command("bash", "-c", - f'{self.spack_setup_script} spack mirror rm {mirror_name}', + f'{self.spack_command_on_env} && spack mirror rm {mirror_name}', check=True, logger=self.logger, info_msg=f'Removing mirror {mirror_name}', @@ -270,6 +295,10 @@ class SpackOperation: @check_spack_env def install_packages(self, jobs: int, signed=True, fresh=False, debug=False): + """Installs all spack packages. + Raises: + NoSpackEnvironmentException: If the spack environment is not set up. + """ signed = '' if signed else '--no-check-signature' fresh = '--fresh' if fresh else '' debug = '--debug' if debug else '' @@ -287,6 +316,12 @@ class SpackOperation: def install_spack(self, spack_version=f'v{SPACK_VERSION}', spack_repo='https://github.com/spack/spack', bashrc_path=os.path.expanduser("~/.bashrc")): + """Install spack. + Args: + spack_version (str): spack version + spack_repo (str): Git path to the Spack repository. + bashrc_path (str): Path to the .bashrc file. + """ try: user = os.getlogin() except OSError: diff --git a/dedal/spack_factory/SpackOperationCreateCache.py b/dedal/spack_factory/SpackOperationCreateCache.py index 8d6125fb..2de1af93 100644 --- a/dedal/spack_factory/SpackOperationCreateCache.py +++ b/dedal/spack_factory/SpackOperationCreateCache.py @@ -30,6 +30,10 @@ class SpackOperationCreateCache(SpackOperation): @check_spack_env def concretize_spack_env(self): + """Concretization step for a spack environment. After the concretization step is complete, the concretization file is uploaded to the OCI casing. + Raises: + NoSpackEnvironmentException: If the spack environment is not set up. + """ super().concretize_spack_env(force=True) dependency_path = self.spack_config.env.path / self.spack_config.env.name / 'spack.lock' copy_file(dependency_path, self.spack_config.concretization_dir, logger=self.logger) @@ -38,6 +42,10 @@ class SpackOperationCreateCache(SpackOperation): @check_spack_env def install_packages(self, jobs: int = 2, debug=False): + """Installs all spack packages. After the installation is complete, all the binary cashes are pushed to the defined OCI registry + Raises: + NoSpackEnvironmentException: If the spack environment is not set up. + """ signed = False if self.spack_config.gpg: signed = True diff --git a/dedal/spack_factory/SpackOperationUseCache.py b/dedal/spack_factory/SpackOperationUseCache.py index f1feb046..6411f27b 100644 --- a/dedal/spack_factory/SpackOperationUseCache.py +++ b/dedal/spack_factory/SpackOperationUseCache.py @@ -32,38 +32,31 @@ class SpackOperationUseCache(SpackOperation): def setup_spack_env(self) -> None: """Set up the spack environment for using the cache. - Downloads the build cache, adds the public key to trusted keys, and adds the build cache mirror. - Raises: - ValueError: If there is an issue with the build cache setup (mirror_name/mirror_path are empty). NoSpackEnvironmentException: If the spack environment is not set up. """ super().setup_spack_env() - try: - # Download build cache from OCI Registry and add public key to trusted keys - self.build_cache.download(self.spack_config.buildcache_dir) - cached_public_key = self.build_cache.get_public_key_from_cache(str(self.spack_config.buildcache_dir)) - signed = cached_public_key is not None and self.trust_gpg_key(cached_public_key) - if not signed: - self.logger.warning("Public key not found in cache or failed to trust pgp keys!") - # Add build cache mirror - self.add_mirror('local_cache', - str(self.spack_config.buildcache_dir), - signed=signed, - autopush=True, - global_mirror=False) - except (ValueError, NoSpackEnvironmentException) as e: - self.logger.error("Error adding buildcache mirror: %s", e) - raise + # Download concretization cache from OCI Registry + self.cache_dependency.download(self.spack_config.concretization_dir) + # Download build cache from OCI Registry and add public key to trusted keys + self.build_cache.download(self.spack_config.buildcache_dir) + cached_public_key = self.build_cache.get_public_key_from_cache(str(self.spack_config.buildcache_dir)) + signed = cached_public_key is not None + if signed: + self.trust_gpg_key(cached_public_key) + # Add build cache mirror + self.add_mirror('local_cache', + str(self.spack_config.buildcache_dir), + signed=signed, + autopush=True, + global_mirror=False) @check_spack_env def concretize_spack_env(self): """Concretization step for spack environment for using the concretization cache (spack.lock file). - Downloads the concretization cache and moves it to the spack environment's folder - Raises: NoSpackEnvironmentException: If the spack environment is not set up. """ diff --git a/dedal/specfile_storage_path_source.py b/dedal/specfile_storage_path_source.py index 4d2ff658..6e8a8889 100644 --- a/dedal/specfile_storage_path_source.py +++ b/dedal/specfile_storage_path_source.py @@ -49,14 +49,14 @@ for rspec in data: format_string = "{name}-{version}" pretty_name = pkg.spec.format_path(format_string) - cosmetic_path = os.path.join(pkg.env_name, pretty_name) + cosmetic_path = os.path.join(pkg.name, pretty_name) to_be_fetched.add(str(spack.mirror.mirror_archive_paths(pkg.fetcher, cosmetic_path).storage_path)) for resource in pkg._get_needed_resources(): - pretty_resource_name = fsys.polite_filename(f"{resource.env_name}-{pkg.version}") + pretty_resource_name = fsys.polite_filename(f"{resource.name}-{pkg.version}") to_be_fetched.add(str(spack.mirror.mirror_archive_paths(resource.fetcher, pretty_resource_name).storage_path)) for patch in ss.patches: if isinstance(patch, spack.patch.UrlPatch): - to_be_fetched.add(str(spack.mirror.mirror_archive_paths(patch.stage.fetcher, patch.stage.env_name).storage_path)) + to_be_fetched.add(str(spack.mirror.mirror_archive_paths(patch.stage.fetcher, patch.stage.name).storage_path)) for elem in to_be_fetched: print(elem) diff --git a/dedal/tests/integration_tests/spack_create_cache_test.py b/dedal/tests/integration_tests/spack_create_cache_test.py index fcef47a8..2ae70502 100644 --- a/dedal/tests/integration_tests/spack_create_cache_test.py +++ b/dedal/tests/integration_tests/spack_create_cache_test.py @@ -30,29 +30,16 @@ def test_spack_create_cache_concretization(tmp_path): config.add_repo(repo) spack_operation = SpackOperationCreator.get_spack_operator(config) assert isinstance(spack_operation, SpackOperationCreateCache) - spack_operation.install_spack() + spack_operation.install_spack(bashrc_path=str(tmp_path / Path('.bashrc'))) spack_operation.setup_spack_env() spack_operation.concretize_spack_env() assert len(spack_operation.cache_dependency.list_tags()) > 0 + return spack_operation @pytest.mark.skip( reason="Skipping until an OCI registry which supports via API deletion; Clean up for OCI registry repo must be added before this test.") def test_spack_create_cache_installation(tmp_path): - install_dir = tmp_path - concretization_dir = install_dir / 'concretization' - buildcache_dir = install_dir / 'buildcache' - env = SpackDescriptor('test-spack-env', install_dir, test_spack_env_git) - repo = SpackDescriptor('ebrains-spack-builds', install_dir, ebrains_spack_builds_git) - gpg = GpgConfig(gpg_name='test-gpg', gpg_mail='test@test.com') - config = SpackConfig(env=env, install_dir=install_dir, concretization_dir=concretization_dir, - buildcache_dir=buildcache_dir, gpg=gpg) - config.add_repo(repo) - spack_operation = SpackOperationCreator.get_spack_operator(config) - assert isinstance(spack_operation, SpackOperationCreateCache) - spack_operation.install_spack() - spack_operation.setup_spack_env() - spack_operation.concretize_spack_env() - assert len(spack_operation.cache_dependency.list_tags()) > 0 + spack_operation = test_spack_create_cache_concretization(tmp_path) spack_operation.install_packages() assert len(spack_operation.build_cache.list_tags()) > 0 diff --git a/dedal/tests/integration_tests/spack_from_cache_test.py b/dedal/tests/integration_tests/spack_from_cache_test.py index d0e390de..0a6c47bb 100644 --- a/dedal/tests/integration_tests/spack_from_cache_test.py +++ b/dedal/tests/integration_tests/spack_from_cache_test.py @@ -1,3 +1,5 @@ +from pathlib import Path + from dedal.configuration.SpackConfig import SpackConfig from dedal.model.SpackDescriptor import SpackDescriptor from dedal.spack_factory.SpackOperationCreator import SpackOperationCreator @@ -17,43 +19,25 @@ def test_spack_from_cache_setup(tmp_path): spack_config.add_repo(repo) spack_operation = SpackOperationCreator.get_spack_operator(spack_config, use_cache=True) assert isinstance(spack_operation, SpackOperationUseCache) - spack_operation.install_spack() + spack_operation.install_spack(bashrc_path=str(tmp_path / Path('.bashrc'))) spack_operation.setup_spack_env() num_tags = len(spack_operation.build_cache.list_tags()) - assert file_exists_and_not_empty(concretization_dir) == True + concretization_download_file_path = concretization_dir/ 'spack.lock' + assert file_exists_and_not_empty(concretization_download_file_path) == True assert count_files_in_folder(buildcache_dir) == num_tags - + assert 'local_cache' in spack_operation.mirror_list() + return spack_operation def test_spack_from_cache_concretize(tmp_path): - install_dir = tmp_path - env = SpackDescriptor('test-spack-env', install_dir, test_spack_env_git) - repo = SpackDescriptor('ebrains-spack-builds', install_dir, ebrains_spack_builds_git) - spack_config = SpackConfig(env, install_dir=install_dir, concretization_dir=install_dir / 'concretize', - buildcache_dir=install_dir / 'buildcache') - spack_config.add_repo(repo) - spack_operation = SpackOperationCreator.get_spack_operator(spack_config, use_cache=True) - assert isinstance(spack_operation, SpackOperationUseCache) - spack_operation.install_spack() - spack_operation.setup_spack_env() + spack_operation = test_spack_from_cache_setup(tmp_path) assert spack_operation.concretize_spack_env() == False concretization_file_path = spack_operation.env_path / 'spack.lock' assert file_exists_and_not_empty(concretization_file_path) == True + return spack_operation def test_spack_from_cache_install(tmp_path): - install_dir = tmp_path - env = SpackDescriptor('test-spack-env', install_dir, test_spack_env_git) - repo = SpackDescriptor('ebrains-spack-builds', install_dir, ebrains_spack_builds_git) - spack_config = SpackConfig(env, install_dir=install_dir, concretization_dir=install_dir / 'concretize', - buildcache_dir=install_dir / 'buildcache') - spack_config.add_repo(repo) - spack_operation = SpackOperationCreator.get_spack_operator(spack_config, use_cache=True) - assert isinstance(spack_operation, SpackOperationUseCache) - spack_operation.install_spack() - spack_operation.setup_spack_env() - assert spack_operation.concretize_spack_env() == False - concretization_file_path = spack_operation.env_path / 'spack.lock' - assert file_exists_and_not_empty(concretization_file_path) == True + spack_operation = test_spack_from_cache_concretize(tmp_path) install_result = spack_operation.install_packages(jobs=2, signed=True, debug=False) assert install_result.returncode == 0 diff --git a/dedal/tests/integration_tests/spack_from_scratch_test.py b/dedal/tests/integration_tests/spack_from_scratch_test.py index 7e8d900c..0b0f77f2 100644 --- a/dedal/tests/integration_tests/spack_from_scratch_test.py +++ b/dedal/tests/integration_tests/spack_from_scratch_test.py @@ -14,7 +14,7 @@ def test_spack_repo_exists_1(tmp_path): env = SpackDescriptor('ebrains-spack-builds', install_dir) config = SpackConfig(env=env, install_dir=install_dir) spack_operation = SpackOperationCreator.get_spack_operator(config) - spack_operation.install_spack() + spack_operation.install_spack(bashrc_path=str(tmp_path / Path('.bashrc'))) with pytest.raises(NoSpackEnvironmentException): spack_operation.spack_repo_exists(env.name) @@ -25,7 +25,7 @@ def test_spack_repo_exists_2(tmp_path): config = SpackConfig(env=env, install_dir=install_dir) spack_operation = SpackOperationCreator.get_spack_operator(config) assert isinstance(spack_operation, SpackOperation) - spack_operation.install_spack() + spack_operation.install_spack(bashrc_path=str(tmp_path / Path('.bashrc'))) spack_operation.setup_spack_env() assert spack_operation.spack_repo_exists(env.name) == False @@ -36,7 +36,7 @@ def test_spack_from_scratch_setup_1(tmp_path): config = SpackConfig(env=env, system_name='ebrainslab', install_dir=install_dir) spack_operation = SpackOperationCreator.get_spack_operator(config) assert isinstance(spack_operation, SpackOperation) - spack_operation.install_spack() + spack_operation.install_spack(bashrc_path=str(tmp_path / Path('.bashrc'))) spack_operation.setup_spack_env() assert spack_operation.spack_repo_exists(env.name) == False @@ -50,7 +50,7 @@ def test_spack_from_scratch_setup_2(tmp_path): config.add_repo(repo) spack_operation = SpackOperationCreator.get_spack_operator(config) assert isinstance(spack_operation, SpackOperation) - spack_operation.install_spack() + spack_operation.install_spack(bashrc_path=str(tmp_path / Path('.bashrc'))) spack_operation.setup_spack_env() assert spack_operation.spack_repo_exists(env.name) == True @@ -64,7 +64,7 @@ def test_spack_from_scratch_setup_3(tmp_path): config.add_repo(repo) spack_operation = SpackOperationCreator.get_spack_operator(config) assert isinstance(spack_operation, SpackOperation) - spack_operation.install_spack() + spack_operation.install_spack(bashrc_path=str(tmp_path / Path('.bashrc'))) with pytest.raises(BashCommandException): spack_operation.setup_spack_env() @@ -75,7 +75,7 @@ def test_spack_from_scratch_setup_4(tmp_path): config = SpackConfig(env=env, install_dir=install_dir) spack_operation = SpackOperationCreator.get_spack_operator(config) assert isinstance(spack_operation, SpackOperation) - spack_operation.install_spack() + spack_operation.install_spack(bashrc_path=str(tmp_path / Path('.bashrc'))) spack_operation.setup_spack_env() assert spack_operation.spack_env_exists() == True @@ -102,8 +102,8 @@ def test_spack_from_scratch_concretize_1(tmp_path): config.add_repo(repo) spack_operation = SpackOperationCreator.get_spack_operator(config) assert isinstance(spack_operation, SpackOperation) - spack_operation.install_spack() - spack_operation.install_spack() + spack_operation.install_spack(bashrc_path=str(tmp_path / Path('.bashrc'))) + spack_operation.install_spack(bashrc_path=str(tmp_path / Path('.bashrc'))) spack_operation.setup_spack_env() spack_operation.concretize_spack_env(force=True) concretization_file_path = spack_operation.env_path / 'spack.lock' @@ -121,7 +121,7 @@ def test_spack_from_scratch_concretize_2(tmp_path): config.add_repo(repo) spack_operation = SpackOperationCreator.get_spack_operator(config) assert isinstance(spack_operation, SpackOperation) - spack_operation.install_spack() + spack_operation.install_spack(bashrc_path=str(tmp_path / Path('.bashrc'))) spack_operation.setup_spack_env() spack_operation.concretize_spack_env(force=False) concretization_file_path = spack_operation.env_path / 'spack.lock' @@ -137,7 +137,7 @@ def test_spack_from_scratch_concretize_3(tmp_path): config.add_repo(repo) spack_operation = SpackOperationCreator.get_spack_operator(config) assert isinstance(spack_operation, SpackOperation) - spack_operation.install_spack() + spack_operation.install_spack(bashrc_path=str(tmp_path / Path('.bashrc'))) spack_operation.setup_spack_env() concretization_file_path = spack_operation.env_path / 'spack.lock' assert file_exists_and_not_empty(concretization_file_path) == False @@ -149,7 +149,7 @@ def test_spack_from_scratch_concretize_4(tmp_path): config = SpackConfig(env=env, install_dir=install_dir) spack_operation = SpackOperationCreator.get_spack_operator(config) assert isinstance(spack_operation, SpackOperation) - spack_operation.install_spack() + spack_operation.install_spack(bashrc_path=str(tmp_path / Path('.bashrc'))) spack_operation.setup_spack_env() spack_operation.concretize_spack_env(force=False) concretization_file_path = spack_operation.env_path / 'spack.lock' @@ -162,7 +162,7 @@ def test_spack_from_scratch_concretize_5(tmp_path): config = SpackConfig(env=env, install_dir=install_dir) spack_operation = SpackOperationCreator.get_spack_operator(config) assert isinstance(spack_operation, SpackOperation) - spack_operation.install_spack() + spack_operation.install_spack(bashrc_path=str(tmp_path / Path('.bashrc'))) spack_operation.setup_spack_env() spack_operation.concretize_spack_env(force=True) concretization_file_path = spack_operation.env_path / 'spack.lock' @@ -177,7 +177,7 @@ def test_spack_from_scratch_concretize_6(tmp_path): config.add_repo(repo) spack_operation = SpackOperationCreator.get_spack_operator(config) assert isinstance(spack_operation, SpackOperation) - spack_operation.install_spack() + spack_operation.install_spack(bashrc_path=str(tmp_path / Path('.bashrc'))) spack_operation.setup_spack_env() spack_operation.concretize_spack_env(force=False) concretization_file_path = spack_operation.env_path / 'spack.lock' @@ -188,11 +188,11 @@ def test_spack_from_scratch_concretize_7(tmp_path): install_dir = tmp_path env = SpackDescriptor('test-spack-env', install_dir, test_spack_env_git) repo = SpackDescriptor('ebrains-spack-builds', install_dir, ebrains_spack_builds_git) - config = SpackConfig(env=env) + config = SpackConfig(env=env, install_dir=install_dir) config.add_repo(repo) spack_operation = SpackOperationCreator.get_spack_operator(config) assert isinstance(spack_operation, SpackOperation) - spack_operation.install_spack() + spack_operation.install_spack(bashrc_path=str(tmp_path / Path('.bashrc'))) spack_operation.setup_spack_env() spack_operation.concretize_spack_env(force=True) concretization_file_path = spack_operation.env_path / 'spack.lock' @@ -203,14 +203,47 @@ def test_spack_from_scratch_install(tmp_path): install_dir = tmp_path env = SpackDescriptor('test-spack-env', install_dir, test_spack_env_git) repo = SpackDescriptor('ebrains-spack-builds', install_dir, ebrains_spack_builds_git) - config = SpackConfig(env=env) + config = SpackConfig(env=env, install_dir=install_dir) config.add_repo(repo) spack_operation = SpackOperationCreator.get_spack_operator(config) assert isinstance(spack_operation, SpackOperation) - spack_operation.install_spack() + spack_operation.install_spack(bashrc_path=str(tmp_path / Path('.bashrc'))) spack_operation.setup_spack_env() spack_operation.concretize_spack_env(force=True) concretization_file_path = spack_operation.env_path / 'spack.lock' assert file_exists_and_not_empty(concretization_file_path) == True install_result = spack_operation.install_packages(jobs=2, signed=False, fresh=True, debug=False) assert install_result.returncode == 0 + + +def test_spack_mirror_env(tmp_path): + install_dir = tmp_path + env = SpackDescriptor('test-spack-env', install_dir, test_spack_env_git) + repo = SpackDescriptor('ebrains-spack-builds', install_dir, ebrains_spack_builds_git) + spack_config = SpackConfig(env, install_dir=install_dir) + spack_config.add_repo(repo) + spack_operation = SpackOperationCreator.get_spack_operator(spack_config) + assert isinstance(spack_operation, SpackOperation) + spack_operation.install_spack(bashrc_path=str(tmp_path / Path('.bashrc'))) + spack_operation.setup_spack_env() + mirror_dir = tmp_path / Path('./mirror_dir') + mirror_name = 'mirror_tests' + spack_operation.add_mirror(mirror_name=mirror_name, mirror_path=mirror_dir) + assert mirror_name in spack_operation.mirror_list() + spack_operation.remove_mirror(mirror_name=mirror_name) + assert mirror_name not in spack_operation.mirror_list() + + +def test_spack_mirror_global(tmp_path): + install_dir = tmp_path + spack_config = SpackConfig(install_dir=install_dir) + spack_operation = SpackOperationCreator.get_spack_operator(spack_config) + assert isinstance(spack_operation, SpackOperation) + spack_operation.install_spack(bashrc_path=str(tmp_path / Path('.bashrc'))) + spack_operation.setup_spack_env() + mirror_dir = tmp_path / Path('./mirror_dir') + mirror_name = 'mirror_test' + spack_operation.add_mirror(mirror_name=mirror_name, mirror_path=mirror_dir) + assert mirror_name in spack_operation.mirror_list() + spack_operation.remove_mirror(mirror_name=mirror_name) + assert mirror_name not in spack_operation.mirror_list() diff --git a/dedal/tests/unit_tests/test_build_cache_manager.py b/dedal/tests/unit_tests/build_cache_manager_test.py similarity index 100% rename from dedal/tests/unit_tests/test_build_cache_manager.py rename to dedal/tests/unit_tests/build_cache_manager_test.py diff --git a/dedal/tests/unit_tests/test_spack_operation_use_cache.py b/dedal/tests/unit_tests/spack_operation_use_cache_test.py similarity index 86% rename from dedal/tests/unit_tests/test_spack_operation_use_cache.py rename to dedal/tests/unit_tests/spack_operation_use_cache_test.py index f0eaf796..fad549bf 100644 --- a/dedal/tests/unit_tests/test_spack_operation_use_cache.py +++ b/dedal/tests/unit_tests/spack_operation_use_cache_test.py @@ -49,10 +49,6 @@ class TestSpackOperationUseCache: else: spack_operation_use_cache_mock.trust_gpg_key.assert_not_called() - if not signed: - spack_operation_use_cache_mock.logger.warning.assert_called_once_with( - "Public key not found in cache or failed to trust pgp keys!") - spack_operation_use_cache_mock.add_mirror.assert_called_once_with( 'local_cache', str(spack_operation_use_cache_mock.spack_config.buildcache_dir), @@ -62,7 +58,7 @@ class TestSpackOperationUseCache: ) super_mock.return_value.setup_spack_env.assert_called_once() # call original method - @pytest.mark.parametrize("exception_type", [ValueError, NoSpackEnvironmentException]) + @pytest.mark.parametrize("exception_type", [NoSpackEnvironmentException]) def test_setup_spack_env_exceptions(self, mocker, spack_operation_use_cache_mock, exception_type): # Test ID: setup_spack_env_exceptions spack_operation_use_cache_mock.trust_gpg_key = mocker.MagicMock() @@ -76,7 +72,4 @@ class TestSpackOperationUseCache: # Act & Assert with pytest.raises(exception_type): - spack_operation_use_cache_mock.setup_spack_env() - - spack_operation_use_cache_mock.logger.error.assert_called_once_with("Error adding buildcache mirror: %s", - exception) + spack_operation_use_cache_mock.setup_spack_env() \ No newline at end of file diff --git a/dedal/tests/unit_tests/test_spack_operation.py b/dedal/tests/unit_tests/test_spack_operation.py deleted file mode 100644 index fa322c7b..00000000 --- a/dedal/tests/unit_tests/test_spack_operation.py +++ /dev/null @@ -1,201 +0,0 @@ -import pytest -from _pytest.fixtures import fixture - -from dedal.build_cache.BuildCacheManager import BuildCacheManager -from dedal.commands.preconfigured_command_enum import PreconfiguredCommandEnum -from dedal.error_handling.exceptions import NoSpackEnvironmentException -from dedal.spack_factory.SpackOperation import SpackOperation - - -class TestSpackOperationAddMirrorWithComposite: - - @fixture - def mock_spack_operation(self, mocker): - mocker.resetall() - mocker.patch("dedal.spack_factory.SpackOperation.CommandRunner") - mocker.patch("dedal.spack_factory.SpackOperation.get_logger") - mock_object = SpackOperation() - mock_object.logger = mocker.MagicMock() - return mock_object - - @pytest.mark.parametrize( - "test_id, global_mirror, env_path, mirror_name, mirror_path, autopush, signed, expected_result, expected_env_arg", - [ - ("global_mirror", True, None, "global_mirror", "/path/to/global/mirror", False, False, True, ""), - ("local_mirror", False, "/path/to/env", "local_mirror", "/path/to/local/mirror", True, True, True, - "/path/to/env"), - ], - ) - def test_add_mirror_with_composite_success_path(self, mock_spack_operation, test_id, global_mirror, env_path, - mirror_name, mirror_path, autopush, signed, expected_result, - expected_env_arg): - # Arrange - mock_spack_operation.spack_setup_script = "setup.sh" - mock_spack_operation.env_path = env_path - mock_spack_operation.command_runner.run_preconfigured_command_sequence.return_value = {"success": True, - "error": None, - "output": None} - - # Act - result = mock_spack_operation.add_mirror(mirror_name, mirror_path, signed, autopush, - global_mirror) - - # Assert - assert result == expected_result - mock_spack_operation.command_runner.run_preconfigured_command_sequence.assert_called_once_with( - PreconfiguredCommandEnum.SPACK_MIRROR_ADD, - "setup.sh", - expected_env_arg, - mirror_name, - mirror_path, - autopush, - signed, - ) - mock_spack_operation.logger.info.assert_called_once_with("Added mirror %s", mirror_name) - - @pytest.mark.parametrize( - "test_id, mirror_name, mirror_path, expected_error", - [ - ("missing_mirror_name", "", "/path/to/mirror", ValueError), - ("missing_mirror_path", "mirror_name", "", ValueError), - ("missing_both", "", "", ValueError), - ], - ) - def test_add_mirror_with_composite_missing_args(self, mock_spack_operation, test_id, mirror_name, mirror_path, - expected_error): - # Arrange - mock_spack_operation.spack_setup_script = "setup.sh" - - # Act - with pytest.raises(expected_error) as except_info: - mock_spack_operation.add_mirror(mirror_name, mirror_path) - - # Assert - assert str(except_info.value) == "mirror_name and mirror_path are required" - - def test_add_mirror_with_composite_no_env(self, mock_spack_operation): - # Arrange - mock_spack_operation.spack_setup_script = "setup.sh" - mock_spack_operation.env_path = None - - # Act - with pytest.raises(NoSpackEnvironmentException) as except_info: - mock_spack_operation.add_mirror("mirror_name", "/path/to/mirror") - - # Assert - assert str(except_info.value) == "No spack environment defined" - - def test_add_mirror_with_composite_command_failure(self, mock_spack_operation): - # Arrange - mock_spack_operation.spack_setup_script = "setup.sh" - mock_spack_operation.env_path = "/path/to/env" - mock_spack_operation.command_runner.run_preconfigured_command_sequence.return_value = {"success": False, - "error": "Error: Command failed with exit code 1, Error Output: ==> Error: Mirror with name mirror_name already exists.", - "output": None} - - # Act - result = mock_spack_operation.add_mirror("mirror_name", "/path/to/mirror") - - # Assert - assert result is False - mock_spack_operation.command_runner.run_preconfigured_command_sequence.assert_called_once_with( - PreconfiguredCommandEnum.SPACK_MIRROR_ADD, - "setup.sh", - "/path/to/env", - "mirror_name", - "/path/to/mirror", - False, - False - ) - mock_spack_operation.logger.error.assert_called_once_with('Failed to add mirror %s, reason: %s, output: %s', - 'mirror_name', - 'Error: Command failed with exit code 1, Error Output: ==> Error: Mirror with ' - 'name mirror_name already exists.', - None) - - @pytest.mark.parametrize("result, expected_log_message, expected_return_value", [ - ({"success": True, "output": "test output"}, "test info message", True), - ({"success": True, "error": "test error", "output": "test output"}, "test info message", True), - ]) - def test_handle_result_success(self, mock_spack_operation, result, expected_log_message, expected_return_value): - # Test ID: success - - # Arrange - error_msg = "test error message" - error_msg_args = ("arg1", "arg2") - info_msg = "test info message" - info_msg_args = ("arg3", "arg4") - - # Act - return_value = mock_spack_operation.handle_result(result, error_msg, error_msg_args, info_msg, info_msg_args) - - # Assert - assert return_value == expected_return_value - mock_spack_operation.logger.info.assert_called_once_with(info_msg, *info_msg_args) - mock_spack_operation.logger.error.assert_not_called() - - @pytest.mark.parametrize("result, expected_log_message", [ - ({"success": False, "error": "test error", "output": "test output"}, - "test error message arg1 arg2 test error test output"), - ({"success": False, "error": None, "output": None}, "test error message arg1 arg2 None None"), - ]) - def test_handle_result_failure(self, mock_spack_operation, result, expected_log_message): - # Test ID: failure - - # Arrange - error_msg = "test error message" - error_msg_args = ("arg1", "arg2") - info_msg = "test info message" - info_msg_args = ("arg3", "arg4") - - # Act - return_value = mock_spack_operation.handle_result(result, error_msg, error_msg_args, info_msg, info_msg_args) - - # Assert - assert return_value is False - mock_spack_operation.logger.error.assert_called_once_with(error_msg, *error_msg_args, result["error"], - result["output"]) - mock_spack_operation.logger.info.assert_not_called() - - @pytest.mark.parametrize("public_key_path, result_success, expected_log_message", [ - ("test_key.gpg", True, ('Added public key %s as trusted', 'test_key.gpg')), - ("test_key.gpg", False, - ('Failed to add public key %s as trusted, reason: %s, output: %s', - 'test_key.gpg', - 'test_error', - 'test_output')), - ]) - def test_trust_gpg_key(self, mock_spack_operation, public_key_path, result_success, expected_log_message): - # Test ID: trust_gpg_key - - # Arrange - mock_result = {"success": result_success, "error": "test_error", "output": "test_output"} - mock_spack_operation.command_runner.run_preconfigured_command_sequence.return_value = mock_result - - # Act - if result_success: - result = mock_spack_operation.trust_gpg_key(public_key_path) - else: - result = mock_spack_operation.trust_gpg_key(public_key_path) - - # Assert - if result_success: - assert result is True - mock_spack_operation.logger.info.assert_called_once_with(*expected_log_message) - else: - assert result is False - mock_spack_operation.logger.error.assert_called_once_with(*expected_log_message) - - mock_spack_operation.command_runner.run_preconfigured_command_sequence.assert_called_once_with( - PreconfiguredCommandEnum.SPACK_GPG_TRUST, - mock_spack_operation.spack_setup_script, - public_key_path - ) - - def test_trust_gpg_key_empty_path(self, mock_spack_operation): - # Test ID: empty_path - - # Act & Assert - with pytest.raises(ValueError) as except_info: - mock_spack_operation.trust_gpg_key("") - assert str(except_info.value) == "public_key_path is required" diff --git a/dedal/tests/unit_tests/utils_test.py b/dedal/tests/unit_tests/utils_test.py index cd478606..0f2b2a82 100644 --- a/dedal/tests/unit_tests/utils_test.py +++ b/dedal/tests/unit_tests/utils_test.py @@ -1,9 +1,12 @@ +import logging +import os import subprocess import pytest from pathlib import Path from unittest.mock import mock_open, patch, MagicMock -from dedal.utils.utils import clean_up, file_exists_and_not_empty, log_command, run_command +from dedal.utils.utils import clean_up, file_exists_and_not_empty, log_command, run_command, get_first_word, \ + count_files_in_folder, resolve_path, delete_file @pytest.fixture @@ -102,7 +105,7 @@ def test_log_command(): def test_run_command_success(mocker): mock_subprocess = mocker.patch("subprocess.run", return_value=MagicMock(returncode=0)) mock_logger = MagicMock() - result = run_command('bash', '-c', 'echo hello', logger=mock_logger, info_msg="Running echo") + result = run_command('bash', '-c', 'echo hello', logger=mock_logger, info_msg="Running echo") mock_logger.info.assert_called_with("Running echo: args: ('bash', '-c', 'echo hello')") mock_subprocess.assert_called_once_with(('bash', '-c', 'echo hello')) assert result.returncode == 0 @@ -148,3 +151,135 @@ def test_run_command_called_process_error(mocker): mock_logger = MagicMock() run_command("test", logger=mock_logger, exception_msg="Process failed") mock_logger.error.assert_called_with("Process failed: Command 'test' returned non-zero exit status 1.") + + +def test_get_first_word_basic(): + assert get_first_word("Hello world") == "Hello" + + +def test_get_first_word_single_word(): + assert get_first_word("word") == "word" + + +def test_get_first_word_leading_whitespace(): + assert get_first_word(" leading spaces") == "leading" + + +def test_get_first_word_empty_string(): + assert get_first_word("") == "" + + +def test_get_first_word_whitespace_only(): + assert get_first_word(" \t ") == "" + + +def test_get_first_word_with_punctuation(): + assert get_first_word("Hello, world!") == "Hello," + + +def test_get_first_word_newline_delimiter(): + assert get_first_word("First line\nSecond line") == "First" + + +def test_count_files_in_folder_counts_files_only(tmp_path): + # create files and subdirectories + file1 = tmp_path / "a.txt" + file2 = tmp_path / "b.txt" + file3 = tmp_path / "c.txt" + subdir = tmp_path / "subfolder" + subdir_file = subdir / "d.txt" + file1.write_text("data1") + file2.write_text("data2") + file3.write_text("data3") + subdir.mkdir() + subdir_file.write_text("data4") + count = count_files_in_folder(tmp_path) + assert count == 4 + + +def test_count_files_in_folder_empty(tmp_path): + count = count_files_in_folder(tmp_path) + assert count == 0 + + +def test_count_files_in_folder_only_dirs(tmp_path): + (tmp_path / "dir1").mkdir() + (tmp_path / "dir2").mkdir() + count = count_files_in_folder(tmp_path) + assert count == 0 + + +def test_count_files_in_folder_path_is_file(tmp_path): + file_path = tmp_path / "single.txt" + file_path.write_text("content") + with pytest.raises(ValueError): + count_files_in_folder(file_path) + + +def test_delete_file_success(tmp_path, caplog): + # monkeypatch.chdir(tmp_path) + target = tmp_path / "temp.txt" + target.write_text("to be deleted") + logger = logging.getLogger("delete_success_test") + caplog.set_level(logging.DEBUG, logger=logger.name) + result = delete_file(str(target), logger) + assert result is True + assert not target.exists() + assert any(rec.levelno == logging.DEBUG for rec in caplog.records) + assert "deleted" in " ".join(rec.getMessage() for rec in caplog.records).lower() + + +def test_delete_file_not_found(tmp_path, caplog): + missing = tmp_path / "no_such_file.txt" + logger = logging.getLogger("delete_notfound_test") + caplog.set_level(logging.ERROR, logger=logger.name) + result = delete_file(str(missing), logger) + assert result is False + assert any(rec.levelno >= logging.WARNING for rec in caplog.records) + combined_logs = " ".join(rec.getMessage() for rec in caplog.records).lower() + assert "not found" in combined_logs or "no such file" in combined_logs + + +def test_delete_file_directory_input(tmp_path, caplog): + dir_path = tmp_path / "dir_to_delete" + dir_path.mkdir() + logger = logging.getLogger("delete_dir_test") + caplog.set_level(logging.ERROR, logger=logger.name) + result = delete_file(str(dir_path), logger) + assert result is False + assert any(rec.levelno == logging.ERROR for rec in caplog.records) + combined_logs = " ".join(rec.getMessage() for rec in caplog.records).lower() + assert "directory" in combined_logs or "is a directory" in combined_logs + + +def test_delete_file_empty_path(caplog): + logger = logging.getLogger("delete_empty_test") + caplog.set_level(logging.ERROR, logger=logger.name) + result = delete_file("", logger) + assert result is False + assert any(rec.levelno == logging.ERROR for rec in caplog.records) + combined_logs = " ".join(rec.getMessage() for rec in caplog.records).lower() + assert "invalid" in combined_logs or "no such file" in combined_logs or "not found" in combined_logs + + +def test_resolve_path_relative(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + relative_path = "subfolder/test.txt" + (tmp_path / "subfolder").mkdir() + result = resolve_path(relative_path) + expected_path = tmp_path / "subfolder" / "test.txt" + assert result == expected_path + + +def test_resolve_path_absolute_identity(tmp_path): + absolute = tmp_path / "file.txt" + result = resolve_path(str(absolute)) + assert isinstance(result, Path) + assert str(result) == str(absolute) + + +def test_resolve_path_nonexistent(): + fake_path = "/some/path/that/does/not/exist.txt" + result = resolve_path(fake_path) + assert isinstance(result, Path) + assert str(result) == fake_path or str(result) == os.path.abspath(fake_path) diff --git a/dedal/utils/utils.py b/dedal/utils/utils.py index e37bce84..4b322958 100644 --- a/dedal/utils/utils.py +++ b/dedal/utils/utils.py @@ -157,7 +157,11 @@ def resolve_path(path: str): return path -def count_files_in_folder(folder_path: str) -> int: - if not os.path.isdir(folder_path): +def count_files_in_folder(folder_path: Path) -> int: + if not folder_path.is_dir(): raise ValueError(f"{folder_path} is not a valid directory") - return sum(1 for file in os.listdir(folder_path) if os.path.isfile(os.path.join(folder_path, file))) + return sum(1 for sub_path in folder_path.rglob("*") if sub_path.is_file()) + + +def get_first_word(s: str) -> str: + return s.split()[0] if s.strip() else '' -- GitLab From 5cb0291ceabb21cc24fed481af5c6438fe4839c8 Mon Sep 17 00:00:00 2001 From: adrianciu <adrian.ciu@codemart.ro> Date: Fri, 14 Mar 2025 16:55:49 +0200 Subject: [PATCH 30/30] VT-82: added additional parameters for uploading to the oci registry and for spack operations; added view for spack env; additional spack commands --- README.md | 2 + dedal/build_cache/BuildCacheManager.py | 44 ++++++---- dedal/cli/spack_manager_api.py | 15 +++- dedal/configuration/SpackConfig.py | 5 +- dedal/docs/resources/dedal_UML.png | Bin 77393 -> 83862 bytes dedal/enum/SpackConfigCommand.py | 13 +++ dedal/enum/SpackViewEnum.py | 5 ++ dedal/enum/__init__.py | 0 dedal/error_handling/exceptions.py | 24 +++++- dedal/spack_factory/SpackOperation.py | 76 ++++++++++++++---- .../SpackOperationCreateCache.py | 16 ++-- dedal/spack_factory/SpackOperationUseCache.py | 12 +-- .../spack_from_cache_test.py | 10 ++- .../spack_from_scratch_test.py | 49 +++++++++++ dedal/tests/integration_tests/utils_test.py | 50 ++++++++++++ .../unit_tests/spack_manager_api_test.py | 3 + dedal/utils/utils.py | 11 +-- dedal/wrapper/spack_wrapper.py | 2 +- 18 files changed, 279 insertions(+), 58 deletions(-) create mode 100644 dedal/enum/SpackConfigCommand.py create mode 100644 dedal/enum/SpackViewEnum.py create mode 100644 dedal/enum/__init__.py create mode 100644 dedal/tests/integration_tests/utils_test.py diff --git a/README.md b/README.md index 55080aab..299377a1 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,8 @@ Sets configuration parameters for the session. - `--cache_version_concretize <TEXT>` Cache version for concretizaion data - `--cache_version_build <TEXT>` Cache version for binary caches data +- `--view <SpackViewEnum>` Spack environment view +- `--update_cache <bool>` Flag for overriding existing cache ### 3. `dedal show-config` diff --git a/dedal/build_cache/BuildCacheManager.py b/dedal/build_cache/BuildCacheManager.py index e4c8284c..98fd234d 100644 --- a/dedal/build_cache/BuildCacheManager.py +++ b/dedal/build_cache/BuildCacheManager.py @@ -34,32 +34,44 @@ class BuildCacheManager(BuildCacheManagerInterface): self.cache_version = cache_version self._oci_registry_path = f'{self._registry_host}/{self._registry_project}/{self.cache_version}' - def upload(self, upload_dir: Path): + def upload(self, upload_dir: Path, update_cache=True): """ This method pushed all the files from the build cache folder into the OCI Registry + Args: + upload_dir (Path): directory with the local binary caches + update_cache (bool): Updates the cache from the OCI Registry with the same tag """ build_cache_path = upload_dir.resolve() # build cache folder must exist before pushing all the artifacts if not build_cache_path.exists(): self._logger.error(f"Path {build_cache_path} not found.") + tags = self.list_tags() + for sub_path in build_cache_path.rglob("*"): if sub_path.is_file(): - rel_path = str(sub_path.relative_to(build_cache_path)).replace(str(sub_path.name), "") - target = f"{self._registry_host}/{self._registry_project}/{self.cache_version}:{str(sub_path.name)}" - try: - self._logger.info(f"Pushing file '{sub_path}' to ORAS target '{target}' ...") - self._client.push( - files=[str(sub_path)], - target=target, - # save in manifest the relative path for reconstruction - manifest_annotations={"path": rel_path}, - disable_path_validation=True, - ) - self._logger.info(f"Successfully pushed {sub_path.name}") - except Exception as e: - self._logger.error( - f"An error occurred while pushing: {e}") + tag = str(sub_path.name) + rel_path = str(sub_path.relative_to(build_cache_path)).replace(tag, "") + target = f"{self._registry_host}/{self._registry_project}/{self.cache_version}:{tag}" + upload_file = True + if update_cache is False and tag in tags: + upload_file = False + if upload_file: + try: + self._logger.info(f"Pushing file '{sub_path}' to ORAS target '{target}' ...") + self._client.push( + files=[str(sub_path)], + target=target, + # save in manifest the relative path for reconstruction + manifest_annotations={"path": rel_path}, + disable_path_validation=True, + ) + self._logger.info(f"Successfully pushed {tag}") + except Exception as e: + self._logger.error( + f"An error occurred while pushing: {e}") + else: + self._logger.info(f"File '{sub_path}' already uploaded ...") # todo to be discussed hot to delete the build cache after being pushed to the OCI Registry # clean_up([str(build_cache_path)], self.logger) diff --git a/dedal/cli/spack_manager_api.py b/dedal/cli/spack_manager_api.py index 16d435fa..d5f5a9f0 100644 --- a/dedal/cli/spack_manager_api.py +++ b/dedal/cli/spack_manager_api.py @@ -7,6 +7,7 @@ from dedal.bll.SpackManager import SpackManager from dedal.bll.cli_utils import save_config, load_config from dedal.configuration.GpgConfig import GpgConfig from dedal.configuration.SpackConfig import SpackConfig +from dedal.enum.SpackViewEnum import SpackViewEnum from dedal.model.SpackDescriptor import SpackDescriptor from dedal.utils.utils import resolve_path @@ -30,7 +31,9 @@ def cli(ctx: click.Context): concretization_dir=config['concretization_dir'], buildcache_dir=config['buildcache_dir'], system_name=config['system_name'], gpg=gpg, - use_spack_global=config['use_spack_global']) + use_spack_global=config['use_spack_global'], + view=config['view'], + update_cache=config['update_cache']) ctx.obj = SpackManager(spack_config, use_cache=config['use_cache']) @@ -52,9 +55,12 @@ def cli(ctx: click.Context): @click.option('--gpg_mail', type=str, default=None, help='Gpg mail contact address') @click.option('--cache_version_concretize', type=str, default='v1', help='Cache version for concretizaion data') @click.option('--cache_version_build', type=str, default='v1', help='Cache version for binary caches data') +@click.option('--view', type=SpackViewEnum, default=SpackViewEnum.VIEW, help='Spack environment view') +@click.option('--update_cache', is_flag=True, default=True, help='Flag for overriding existing cache') def set_config(use_cache, env_name, env_path, env_git_path, install_dir, upstream_instance, system_name, concretization_dir, - buildcache_dir, gpg_name, gpg_mail, use_spack_global, cache_version_concretize, cache_version_build): + buildcache_dir, gpg_name, gpg_mail, use_spack_global, cache_version_concretize, cache_version_build, + view, update_cache): """Sets configuration parameters for the session.""" spack_config_data = { 'use_cache': use_cache, @@ -72,6 +78,8 @@ def set_config(use_cache, env_name, env_path, env_git_path, install_dir, upstrea 'repos': [], 'cache_version_concretize': cache_version_concretize, 'cache_version_build': cache_version_build, + 'view': view, + 'update_cache': update_cache, } save_config(spack_config_data, SESSION_CONFIG_PATH) click.echo('Configuration saved.') @@ -88,7 +96,8 @@ def show_config(): @cli.command() -@click.option('--spack_version', type=str, default='0.23.0', help='Specifies the Spack version to be installed (default: v0.23.0).') +@click.option('--spack_version', type=str, default='0.23.0', + help='Specifies the Spack version to be installed (default: v0.23.0).') @click.option('--bashrc_path', type=str, default="~/.bashrc", help='Defines the path to .bashrc.') @click.pass_context def install_spack(ctx: click.Context, spack_version: str, bashrc_path: str): diff --git a/dedal/configuration/SpackConfig.py b/dedal/configuration/SpackConfig.py index d76783ec..7945e848 100644 --- a/dedal/configuration/SpackConfig.py +++ b/dedal/configuration/SpackConfig.py @@ -1,6 +1,7 @@ import os from pathlib import Path from dedal.configuration.GpgConfig import GpgConfig +from dedal.enum.SpackViewEnum import SpackViewEnum from dedal.model import SpackDescriptor from dedal.utils.utils import resolve_path @@ -10,7 +11,7 @@ class SpackConfig: install_dir=Path(os.getcwd()).resolve(), upstream_instance=None, system_name=None, concretization_dir: Path = None, buildcache_dir: Path = None, gpg: GpgConfig = None, use_spack_global=False, cache_version_concretize='v1', - cache_version_build='v1'): + cache_version_build='v1', view=SpackViewEnum.VIEW, update_cache=True): self.env = env if repos is None: self.repos = [] @@ -25,6 +26,8 @@ class SpackConfig: self.use_spack_global = use_spack_global self.cache_version_concretize = cache_version_concretize self.cache_version_build = cache_version_build + self.view = view + self.update_cache = update_cache def add_repo(self, repo: SpackDescriptor): if self.repos is None: diff --git a/dedal/docs/resources/dedal_UML.png b/dedal/docs/resources/dedal_UML.png index 430554abd5420474a5f2c3681871d491faf5976d..6e970d08690dc6f53f95c5088ad3933b9bae6f15 100644 GIT binary patch literal 83862 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�bLKQHzfyWRUZQ4H` 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 literal 77393 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 O00K`}KbLh*2~7Za<_?<x diff --git a/dedal/enum/SpackConfigCommand.py b/dedal/enum/SpackConfigCommand.py new file mode 100644 index 00000000..3e7b6929 --- /dev/null +++ b/dedal/enum/SpackConfigCommand.py @@ -0,0 +1,13 @@ +from enum import Enum + +class SpackConfigCommand(Enum): + GET = 'get' + BLAME = 'blame' + EDIT = 'edit' + LIST = 'list' + ADD = 'add' + CHANGE = 'change' + PREFER_UPSTREAM = 'prefer-upstream' + REMOVE = 'remove' + UPDATE = 'update' + REVERT = 'revert' \ No newline at end of file diff --git a/dedal/enum/SpackViewEnum.py b/dedal/enum/SpackViewEnum.py new file mode 100644 index 00000000..fc6c01c1 --- /dev/null +++ b/dedal/enum/SpackViewEnum.py @@ -0,0 +1,5 @@ +from enum import Enum + +class SpackViewEnum(Enum): + VIEW = '' + WITHOUT_VIEW = '--without-view' \ No newline at end of file diff --git a/dedal/enum/__init__.py b/dedal/enum/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/dedal/error_handling/exceptions.py b/dedal/error_handling/exceptions.py index 4653e72f..85398df0 100644 --- a/dedal/error_handling/exceptions.py +++ b/dedal/error_handling/exceptions.py @@ -25,22 +25,44 @@ class SpackConcertizeException(BashCommandException): To be thrown when the spack concretization step fails """ + class SpackInstallPackagesException(BashCommandException): """ To be thrown when the spack fails to install spack packages """ + class SpackMirrorException(BashCommandException): """ To be thrown when the spack add mirror command fails """ + class SpackGpgException(BashCommandException): """ To be thrown when the spack fails to create gpg keys """ + class SpackRepoException(BashCommandException): """ To be thrown when the spack fails to add a repo - """ \ No newline at end of file + """ + + +class SpackReindexException(BashCommandException): + """ + To be thrown when the spack reindex step fails + """ + + +class SpackSpecException(BashCommandException): + """ + To be thrown when the spack spec for a package fails + """ + + +class SpackConfigException(BashCommandException): + """ + To be thrown when the spack config command fails + """ diff --git a/dedal/spack_factory/SpackOperation.py b/dedal/spack_factory/SpackOperation.py index 1b2442fd..b0c0a78b 100644 --- a/dedal/spack_factory/SpackOperation.py +++ b/dedal/spack_factory/SpackOperation.py @@ -3,13 +3,15 @@ import re import subprocess from pathlib import Path from dedal.configuration.SpackConfig import SpackConfig +from dedal.enum.SpackConfigCommand import SpackConfigCommand from dedal.error_handling.exceptions import BashCommandException, NoSpackEnvironmentException, \ SpackInstallPackagesException, SpackConcertizeException, SpackMirrorException, SpackGpgException, \ - SpackRepoException + SpackRepoException, SpackReindexException, SpackSpecException, SpackConfigException from dedal.logger.logger_builder import get_logger from dedal.tests.testing_variables import SPACK_VERSION from dedal.utils.utils import run_command, git_clone_repo, log_command, set_bashrc_variable, get_first_word from dedal.wrapper.spack_wrapper import check_spack_env +import glob class SpackOperation: @@ -42,7 +44,7 @@ class SpackOperation: os.makedirs(self.spack_config.buildcache_dir, exist_ok=True) if self.spack_config.env and spack_config.env.name: self.env_path: Path = spack_config.env.path / spack_config.env.name - self.spack_command_on_env = f'{self.spack_setup_script} && spack env activate -p {self.env_path}' + self.spack_command_on_env = f'{self.spack_setup_script} && spack env activate -p {spack_config.view.value} {self.env_path}' else: self.spack_command_on_env = self.spack_setup_script if self.spack_config.env and spack_config.env.path: @@ -177,22 +179,61 @@ class SpackOperation: return None @check_spack_env - def concretize_spack_env(self, force=True): + def concretize_spack_env(self, force=True, test=None): """Concretization step for a spack environment Args: force (bool): TOverrides an existing concretization when set to True + test: which test dependencies should be included Raises: NoSpackEnvironmentException: If the spack environment is not set up. """ force = '--force' if force else '' + test = f'--test {test}' if test else '' run_command("bash", "-c", - f'{self.spack_command_on_env} && spack concretize {force}', + f'{self.spack_command_on_env} && spack concretize {force} {test}', check=True, logger=self.logger, info_msg=f'Concertization step for {self.spack_config.env.name}', exception_msg=f'Failed the concertization step for {self.spack_config.env.name}', exception=SpackConcertizeException) + def reindex(self): + """Reindex step for a spack environment + Raises: + SpackReindexException: If the spack reindex command fails. + """ + run_command("bash", "-c", + f'{self.spack_command_on_env} && spack reindex', + check=True, + logger=self.logger, + info_msg=f'Reindex step.', + exception_msg=f'Failed the reindex.', + exception=SpackReindexException) + + def spec_pacakge(self, package_name: str): + """Reindex step for a spack environment + Raises: + SpackSpecException: If the spack spec command fails. + """ + try: + spec_output = run_command("bash", "-c", + f'{self.spack_command_on_env} && spack spec {package_name}', + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + logger=self.logger, + info_msg=f'Spack spec {package_name}.', + exception_msg=f'Failed to spack spec {package_name}.', + exception=SpackSpecException).stdout + pattern = r'^\s*-\s*([\w.-]+@[\d.]+)' + match = re.search(pattern, spec_output) + if match: + return match.group(1) + return None + except SpackSpecException: + return None + def create_gpg_keys(self): """Creates GPG keys (which can be used when creating binary cashes) and adds it to the trusted keyring.""" if self.spack_config.gpg: @@ -219,8 +260,6 @@ class SpackOperation: ValueError: If mirror_name or mirror_path are empty. NoSpackEnvironmentException: If global_mirror is False and no environment is defined. """ - if not mirror_name or not mirror_path: - raise ValueError("mirror_name and mirror_path are required") autopush = '--autopush' if autopush else '' signed = '--signed' if signed else '' spack_add_mirror = f'spack mirror add {autopush} {signed} {mirror_name} {mirror_path}' @@ -266,6 +305,15 @@ class SpackOperation: exception_msg=f'Failed to trust GPG key for {self.spack_config.env.name}', exception=SpackGpgException) + def config(self, config_type: SpackConfigCommand, config_parameter): + run_command("bash", "-c", + f'{self.spack_command_on_env} && spack config {config_type.value} {config_parameter}', + check=True, + logger=self.logger, + info_msg='Spack config command', + exception_msg='Spack config command failed', + exception=SpackConfigException) + def mirror_list(self): """Returns of available mirrors. When an environment is activated it will return the mirrors associated with it, otherwise the mirrors set globally""" @@ -294,7 +342,7 @@ class SpackOperation: exception=SpackMirrorException) @check_spack_env - def install_packages(self, jobs: int, signed=True, fresh=False, debug=False): + def install_packages(self, jobs: int, signed=True, fresh=False, debug=False, test=None): """Installs all spack packages. Raises: NoSpackEnvironmentException: If the spack environment is not set up. @@ -302,8 +350,9 @@ class SpackOperation: signed = '' if signed else '--no-check-signature' fresh = '--fresh' if fresh else '' debug = '--debug' if debug else '' + test = f'--test {test}' if test else '' install_result = run_command("bash", "-c", - f'{self.spack_command_on_env} && spack {debug} install -v {signed} -j {jobs} {fresh}', + f'{self.spack_command_on_env} && spack {debug} install -v {signed} -j {jobs} {fresh} {test}', stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, @@ -359,10 +408,9 @@ class SpackOperation: os.system("exec bash") # Configure upstream Spack instance if specified if self.spack_config.upstream_instance: - upstreams_yaml_path = os.path.join(self.spack_dir, "etc/spack/defaults/upstreams.yaml") - with open(upstreams_yaml_path, "w") as file: - file.write(f"""upstreams: - upstream-spack-instance: - install_tree: {self.spack_config.upstream_instance}/spack/opt/spack - """) + search_path = os.path.join(self.spack_config.upstream_instance, 'spack', 'opt', 'spack', '**', '.spack-db') + spack_db_dirs = glob.glob(search_path, recursive=True) + upstream_prefix = [os.path.dirname(dir) for dir in spack_db_dirs] + for prefix in upstream_prefix: + self.config(SpackConfigCommand.ADD, f':upstream-spack-instance:install_tree:{prefix}') self.logger.info("Added upstream spack instance") diff --git a/dedal/spack_factory/SpackOperationCreateCache.py b/dedal/spack_factory/SpackOperationCreateCache.py index 2de1af93..45e0e744 100644 --- a/dedal/spack_factory/SpackOperationCreateCache.py +++ b/dedal/spack_factory/SpackOperationCreateCache.py @@ -1,8 +1,6 @@ import os -from dedal.error_handling.exceptions import NoSpackEnvironmentException - -from dedal.utils.utils import copy_to_tmp, copy_file +from dedal.utils.utils import copy_file from dedal.wrapper.spack_wrapper import check_spack_env from dedal.build_cache.BuildCacheManager import BuildCacheManager from dedal.configuration.SpackConfig import SpackConfig @@ -29,19 +27,19 @@ class SpackOperationCreateCache(SpackOperation): cache_version=spack_config.cache_version_build) @check_spack_env - def concretize_spack_env(self): + def concretize_spack_env(self, test=None): """Concretization step for a spack environment. After the concretization step is complete, the concretization file is uploaded to the OCI casing. Raises: NoSpackEnvironmentException: If the spack environment is not set up. """ - super().concretize_spack_env(force=True) + super().concretize_spack_env(force=True, test=test) dependency_path = self.spack_config.env.path / self.spack_config.env.name / 'spack.lock' copy_file(dependency_path, self.spack_config.concretization_dir, logger=self.logger) - self.cache_dependency.upload(self.spack_config.concretization_dir) + self.cache_dependency.upload(self.spack_config.concretization_dir, update_cache=self.spack_config.update_cache) self.logger.info(f'Created new spack concretization for create cache: {self.spack_config.env.name}') @check_spack_env - def install_packages(self, jobs: int = 2, debug=False): + def install_packages(self, jobs: int = 2, debug=False, test=None): """Installs all spack packages. After the installation is complete, all the binary cashes are pushed to the defined OCI registry Raises: NoSpackEnvironmentException: If the spack environment is not set up. @@ -56,7 +54,7 @@ class SpackOperationCreateCache(SpackOperation): autopush=signed, global_mirror=False) self.logger.info(f'Added mirror for {self.spack_config.env.name}') - super().install_packages(jobs=jobs, signed=signed, debug=debug, fresh=True) + super().install_packages(jobs=jobs, signed=signed, debug=debug, fresh=True, test=test) self.logger.info(f'Installed spack packages for {self.spack_config.env.name}') - self.build_cache.upload(self.spack_config.buildcache_dir) + self.build_cache.upload(self.spack_config.buildcache_dir, update_cache=self.spack_config.update_cache) self.logger.info(f'Pushed spack packages for {self.spack_config.env.name}') diff --git a/dedal/spack_factory/SpackOperationUseCache.py b/dedal/spack_factory/SpackOperationUseCache.py index 6411f27b..75194598 100644 --- a/dedal/spack_factory/SpackOperationUseCache.py +++ b/dedal/spack_factory/SpackOperationUseCache.py @@ -4,7 +4,6 @@ from pathlib import Path from dedal.build_cache.BuildCacheManager import BuildCacheManager from dedal.configuration.SpackConfig import SpackConfig -from dedal.error_handling.exceptions import NoSpackEnvironmentException from dedal.error_handling.exceptions import SpackInstallPackagesException from dedal.logger.logger_builder import get_logger from dedal.spack_factory.SpackOperation import SpackOperation @@ -54,7 +53,7 @@ class SpackOperationUseCache(SpackOperation): global_mirror=False) @check_spack_env - def concretize_spack_env(self): + def concretize_spack_env(self, test=None): """Concretization step for spack environment for using the concretization cache (spack.lock file). Downloads the concretization cache and moves it to the spack environment's folder Raises: @@ -67,16 +66,16 @@ class SpackOperationUseCache(SpackOperation): copy_file(self.spack_config.concretization_dir / 'spack.lock', self.env_path) # redo the concretization step if spack.lock file was not downloaded from the cache if not file_exists_and_not_empty(concretization_file_path): - super().concretize_spack_env(force=True) + super().concretize_spack_env(force=True, test=test) concretization_redo = True else: # redo the concretization step if spack.lock file was not downloaded from the cache - super().concretize_spack_env(force=True) + super().concretize_spack_env(force=True, test=test) concretization_redo = True return concretization_redo @check_spack_env - def install_packages(self, jobs: int, signed=True, debug=False): + def install_packages(self, jobs: int, signed=True, debug=False, test=None): """Installation step for spack environment for using the binary caches. Raises: @@ -84,8 +83,9 @@ class SpackOperationUseCache(SpackOperation): """ signed = '' if signed else '--no-check-signature' debug = '--debug' if debug else '' + test = f'--test {test}' if test else '' install_result = run_command("bash", "-c", - f'{self.spack_command_on_env} && spack {debug} install -v --reuse {signed} -j {jobs}', + f'{self.spack_command_on_env} && spack {debug} install -v --reuse {signed} -j {jobs} {test}', stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, diff --git a/dedal/tests/integration_tests/spack_from_cache_test.py b/dedal/tests/integration_tests/spack_from_cache_test.py index 0a6c47bb..42ecf3b7 100644 --- a/dedal/tests/integration_tests/spack_from_cache_test.py +++ b/dedal/tests/integration_tests/spack_from_cache_test.py @@ -22,7 +22,7 @@ def test_spack_from_cache_setup(tmp_path): spack_operation.install_spack(bashrc_path=str(tmp_path / Path('.bashrc'))) spack_operation.setup_spack_env() num_tags = len(spack_operation.build_cache.list_tags()) - concretization_download_file_path = concretization_dir/ 'spack.lock' + concretization_download_file_path = concretization_dir / 'spack.lock' assert file_exists_and_not_empty(concretization_download_file_path) == True assert count_files_in_folder(buildcache_dir) == num_tags assert 'local_cache' in spack_operation.mirror_list() @@ -37,7 +37,13 @@ def test_spack_from_cache_concretize(tmp_path): return spack_operation -def test_spack_from_cache_install(tmp_path): +def test_spack_from_cache_install_1(tmp_path): spack_operation = test_spack_from_cache_concretize(tmp_path) install_result = spack_operation.install_packages(jobs=2, signed=True, debug=False) assert install_result.returncode == 0 + + +def test_spack_from_cache_install_2(tmp_path): + spack_operation = test_spack_from_cache_concretize(tmp_path) + install_result = spack_operation.install_packages(jobs=2, signed=True, debug=False, test='root') + assert install_result.returncode == 0 diff --git a/dedal/tests/integration_tests/spack_from_scratch_test.py b/dedal/tests/integration_tests/spack_from_scratch_test.py index 0b0f77f2..50b19faa 100644 --- a/dedal/tests/integration_tests/spack_from_scratch_test.py +++ b/dedal/tests/integration_tests/spack_from_scratch_test.py @@ -41,6 +41,24 @@ def test_spack_from_scratch_setup_1(tmp_path): assert spack_operation.spack_repo_exists(env.name) == False +def test_spack_reindex(tmp_path): + install_dir = tmp_path + config = SpackConfig(install_dir=install_dir) + spack_operation = SpackOperationCreator.get_spack_operator(config) + assert isinstance(spack_operation, SpackOperation) + spack_operation.install_spack(bashrc_path=str(tmp_path / Path('.bashrc'))) + spack_operation.reindex() + +@pytest.mark.skip(reason="It does nopt work on bare metal operating systems") +def test_spack_spec(tmp_path): + install_dir = tmp_path + config = SpackConfig(install_dir=install_dir) + spack_operation = SpackOperationCreator.get_spack_operator(config) + assert isinstance(spack_operation, SpackOperation) + spack_operation.install_spack(bashrc_path=str(tmp_path / Path('.bashrc'))) + assert spack_operation.spec_pacakge('aida') == 'aida@3.2.1' + + def test_spack_from_scratch_setup_2(tmp_path): install_dir = tmp_path env = SpackDescriptor('ebrains-spack-builds', install_dir, ebrains_spack_builds_git) @@ -198,6 +216,20 @@ def test_spack_from_scratch_concretize_7(tmp_path): concretization_file_path = spack_operation.env_path / 'spack.lock' assert file_exists_and_not_empty(concretization_file_path) == True + def test_spack_from_scratch_concretize_8(tmp_path): + install_dir = tmp_path + env = SpackDescriptor('test-spack-env', install_dir, test_spack_env_git) + repo = SpackDescriptor('ebrains-spack-builds', install_dir, ebrains_spack_builds_git) + config = SpackConfig(env=env, install_dir=install_dir) + config.add_repo(repo) + spack_operation = SpackOperationCreator.get_spack_operator(config) + assert isinstance(spack_operation, SpackOperation) + spack_operation.install_spack(bashrc_path=str(tmp_path / Path('.bashrc'))) + spack_operation.setup_spack_env() + spack_operation.concretize_spack_env(force=True, test='root') + concretization_file_path = spack_operation.env_path / 'spack.lock' + assert file_exists_and_not_empty(concretization_file_path) == True + def test_spack_from_scratch_install(tmp_path): install_dir = tmp_path @@ -216,6 +248,23 @@ def test_spack_from_scratch_install(tmp_path): assert install_result.returncode == 0 +def test_spack_from_scratch_install_2(tmp_path): + install_dir = tmp_path + env = SpackDescriptor('test-spack-env', install_dir, test_spack_env_git) + repo = SpackDescriptor('ebrains-spack-builds', install_dir, ebrains_spack_builds_git) + config = SpackConfig(env=env, install_dir=install_dir) + config.add_repo(repo) + spack_operation = SpackOperationCreator.get_spack_operator(config) + assert isinstance(spack_operation, SpackOperation) + spack_operation.install_spack(bashrc_path=str(tmp_path / Path('.bashrc'))) + spack_operation.setup_spack_env() + spack_operation.concretize_spack_env(force=True, test='root') + concretization_file_path = spack_operation.env_path / 'spack.lock' + assert file_exists_and_not_empty(concretization_file_path) == True + install_result = spack_operation.install_packages(jobs=2, signed=False, fresh=True, debug=False, test='root') + assert install_result.returncode == 0 + + def test_spack_mirror_env(tmp_path): install_dir = tmp_path env = SpackDescriptor('test-spack-env', install_dir, test_spack_env_git) diff --git a/dedal/tests/integration_tests/utils_test.py b/dedal/tests/integration_tests/utils_test.py new file mode 100644 index 00000000..7923e175 --- /dev/null +++ b/dedal/tests/integration_tests/utils_test.py @@ -0,0 +1,50 @@ +from dedal.utils.utils import set_bashrc_variable + + +def test_add_new_variable(tmp_path): + var_name = 'TEST_VAR' + value = 'test_value' + bashrc_path = tmp_path / ".bashrc" + bashrc_path.touch() + set_bashrc_variable(var_name, value, bashrc_path=str(bashrc_path)) + content = bashrc_path.read_text() + assert f'export {var_name}={value}' in content + + +def test_update_existing_variable(tmp_path): + var_name = 'TEST_VAR' + value = 'test_value' + updated_value = 'new_value' + bashrc_path = tmp_path / ".bashrc" + bashrc_path.write_text(f'export {var_name}={value}\n') + set_bashrc_variable(var_name, updated_value, bashrc_path=str(bashrc_path), update_variable=True) + content = bashrc_path.read_text() + assert f'export {var_name}={updated_value}' in content + assert f'export {var_name}={value}' not in content + + +def test_do_not_update_existing_variable(tmp_path): + var_name = 'TEST_VAR' + value = 'test_value' + new_value = 'new_value' + bashrc_path = tmp_path / ".bashrc" + bashrc_path.write_text(f'export {var_name}={value}\n') + + set_bashrc_variable(var_name, new_value, bashrc_path=str(bashrc_path), update_variable=False) + + content = bashrc_path.read_text() + assert f'export {var_name}={value}' in content + assert f'export {var_name}={new_value}' not in content + + +def test_add_variable_with_special_characters(tmp_path): + var_name = 'TEST_VAR' + value = 'value_with_$pecial_chars' + escaped_value = 'value_with_\\$pecial_chars' + bashrc_path = tmp_path / ".bashrc" + bashrc_path.touch() + + set_bashrc_variable(var_name, value, bashrc_path=str(bashrc_path)) + + content = bashrc_path.read_text() + assert f'export {var_name}={escaped_value}' in content diff --git a/dedal/tests/unit_tests/spack_manager_api_test.py b/dedal/tests/unit_tests/spack_manager_api_test.py index 5d32a56d..2037a502 100644 --- a/dedal/tests/unit_tests/spack_manager_api_test.py +++ b/dedal/tests/unit_tests/spack_manager_api_test.py @@ -5,6 +5,7 @@ from unittest.mock import patch, MagicMock from click.testing import CliRunner from dedal.cli.spack_manager_api import show_config, clear_config, install_spack, add_spack_repo, install_packages, \ setup_spack_env, concretize, set_config +from dedal.enum.SpackViewEnum import SpackViewEnum from dedal.model.SpackDescriptor import SpackDescriptor @@ -173,6 +174,8 @@ def test_set_config(runner, mock_save_config, mocked_session_path): 'repos': [], 'cache_version_concretize': 'v1', 'cache_version_build': 'v1', + 'view': SpackViewEnum.VIEW, + 'update_cache': True, } mock_save_config.assert_called_once() diff --git a/dedal/utils/utils.py b/dedal/utils/utils.py index 4b322958..29c6a2a6 100644 --- a/dedal/utils/utils.py +++ b/dedal/utils/utils.py @@ -95,20 +95,21 @@ def copy_to_tmp(file_path: Path) -> Path: def set_bashrc_variable(var_name: str, value: str, bashrc_path: str = os.path.expanduser("~/.bashrc"), - logger: logging = logging.getLogger(__name__)): + logger: logging = logging.getLogger(__name__), update_variable=False): """Update or add an environment variable in ~/.bashrc.""" value = value.replace("$", r"\$") with open(bashrc_path, "r") as file: lines = file.readlines() pattern = re.compile(rf'^\s*export\s+{var_name}=.*$') - updated = False + found_variable = False # Modify the existing variable if found for i, line in enumerate(lines): if pattern.match(line): - lines[i] = f'export {var_name}={value}\n' - updated = True + if update_variable: + lines[i] = f'export {var_name}={value}\n' + found_variable = True break - if not updated: + if not found_variable: lines.append(f'\nexport {var_name}={value}\n') logger.info(f"Added in {bashrc_path} with: export {var_name}={value}") else: diff --git a/dedal/wrapper/spack_wrapper.py b/dedal/wrapper/spack_wrapper.py index 018cad48..ccfd50df 100644 --- a/dedal/wrapper/spack_wrapper.py +++ b/dedal/wrapper/spack_wrapper.py @@ -7,7 +7,7 @@ def check_spack_env(method): @functools.wraps(method) def wrapper(self, *args, **kwargs): if self.spack_env_exists(): - return method(self, *args, **kwargs) # Call the method with 'self' + return method(self, *args, **kwargs) else: self.logger.debug('No spack environment defined') raise NoSpackEnvironmentException('No spack environment defined') -- GitLab