From 924d407637c95c6f6359b483fed103c9d5636984 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/53] esd: added logger class and fixed bug --- esd/logger/logger_config.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 esd/logger/logger_config.py diff --git a/esd/logger/logger_config.py b/esd/logger/logger_config.py new file mode 100644 index 00000000..3ca3b000 --- /dev/null +++ b/esd/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 76673b42deb20102afad4b5253da3152b103edc7 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/53] esd-spack-installation: setup and tests for a spack env --- .gitlab-ci.yml | 16 +- esd/build_cache/BuildCacheManager.py | 6 +- esd/error_handling/exceptions.py | 13 ++ esd/logger/logger_config.py | 33 ---- 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/specfile_storage_path_source.py | 6 +- esd/tests/spack_from_scratch_test.py | 63 ++++++ esd/tests/spack_install_test.py | 21 ++ esd/utils/bootstrap.sh | 6 + esd/utils/utils.py | 22 +++ pyproject.toml | 7 +- 20 files changed, 391 insertions(+), 48 deletions(-) create mode 100644 esd/error_handling/exceptions.py delete mode 100644 esd/logger/logger_config.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/tests/spack_from_scratch_test.py create mode 100644 esd/tests/spack_install_test.py create mode 100644 esd/utils/bootstrap.sh diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 9b2e92b5..0ce02907 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,6 +1,6 @@ stages: - - build - test + - build variables: BUILD_ENV_DOCKER_IMAGE: docker-registry.ebrains.eu/esd/tmp:latest @@ -21,18 +21,22 @@ 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 ./esd/tests/ --junitxml=test-results.xml + - chmod +x esd/utils/bootstrap.sh + - ./esd/utils/bootstrap.sh + - pip install . + - pytest ./esd/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 + - .esd.log + expire_in: 1 week + diff --git a/esd/build_cache/BuildCacheManager.py b/esd/build_cache/BuildCacheManager.py index 44e13b73..e1bd6824 100644 --- a/esd/build_cache/BuildCacheManager.py +++ b/esd/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/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/logger/logger_config.py b/esd/logger/logger_config.py deleted file mode 100644 index 3ca3b000..00000000 --- a/esd/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/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/specfile_storage_path_source.py b/esd/specfile_storage_path_source.py index 6e8a8889..4d2ff658 100644 --- a/esd/specfile_storage_path_source.py +++ b/esd/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/esd/tests/spack_from_scratch_test.py b/esd/tests/spack_from_scratch_test.py new file mode 100644 index 00000000..4981b760 --- /dev/null +++ b/esd/tests/spack_from_scratch_test.py @@ -0,0 +1,63 @@ +from pathlib import Path + +import pytest + +from esd.error_handling.exceptions import BashCommandException +from esd.model.SpackModel import SpackModel +from esd.spack_manager.factory.SpackManagerScratch import SpackManagerScratch + + +def test_spack_repo_exists_1(): + install_dir = Path('./install').resolve() + env = SpackModel('ebrains-spack-builds', install_dir) + spack_manager = SpackManagerScratch(env=env) + assert spack_manager.spack_repo_exists(env.env_name) == False + + +def test_spack_repo_exists_2(): + spack_manager = SpackManagerScratch() + assert spack_manager.spack_repo_exists('ebrains-spack-builds') == 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], 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, [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_environment', install_dir, ) + repo = env + spack_manager = SpackManagerScratch(env, [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_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_bash_error(): + env = SpackModel('ebrains-spack-builds', Path(), None) + repo = env + spack_manager = SpackManagerScratch(env, [repo], system_name='ebrainslab') + with pytest.raises(BashCommandException): + spack_manager.add_spack_repo(repo.path, repo.env_name) diff --git a/esd/tests/spack_install_test.py b/esd/tests/spack_install_test.py new file mode 100644 index 00000000..34f68323 --- /dev/null +++ b/esd/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/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 index 811d258e..d229e345 100644 --- a/esd/utils/utils.py +++ b/esd/utils/utils.py @@ -1,4 +1,6 @@ +import logging import shutil +import subprocess from pathlib import Path @@ -18,3 +20,23 @@ 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: 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 0f6d0cde..abcbe05d 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 = "esd" +name = "esd-tools" +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 81cc4cdb2687be98db651f0907b7ce8d369003e5 Mon Sep 17 00:00:00 2001 From: adrianciu <adrian.ciu@codemart.ro> Date: Wed, 5 Feb 2025 11:33:28 +0200 Subject: [PATCH 03/53] esd-spack-installation: fixing tests --- esd/spack_manager/SpackManager.py | 109 ++++++++++++++------------- esd/tests/spack_from_scratch_test.py | 17 +++-- esd/utils/utils.py | 27 +++++-- 3 files changed, 85 insertions(+), 68 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/tests/spack_from_scratch_test.py b/esd/tests/spack_from_scratch_test.py index 4981b760..71c84a21 100644 --- a/esd/tests/spack_from_scratch_test.py +++ b/esd/tests/spack_from_scratch_test.py @@ -23,7 +23,7 @@ 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], system_name='ebrainslab') + 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 @@ -33,31 +33,32 @@ def test_spack_from_scratch_setup_2(): 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, [repo, repo], system_name='ebrainslab') + 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_environment', install_dir, ) + env = SpackModel('new_env1', install_dir) repo = env - spack_manager = SpackManagerScratch(env, [repo, repo], system_name='ebrainslab') + 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_environment', install_dir, ) - spack_manager = SpackManagerScratch(env, system_name='ebrainslab') + env = SpackModel('new_env2', install_dir) + spack_manager = SpackManagerScratch(env=env) spack_manager.setup_spack_env() - assert spack_manager.spack_repo_exists(env.env_name) == True + assert spack_manager.spack_env_exists() == True def test_spack_from_scratch_bash_error(): env = SpackModel('ebrains-spack-builds', Path(), None) repo = env - spack_manager = SpackManagerScratch(env, [repo], system_name='ebrainslab') + # + spack_manager = SpackManagerScratch(env=env, repos=[repo], system_name='ebrainslab') with pytest.raises(BashCommandException): spack_manager.add_spack_repo(repo.path, repo.env_name) 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 a6c7030f494686bc2b4307b6d07e5293835afab3 Mon Sep 17 00:00:00 2001 From: adrianciu <adrian.ciu@codemart.ro> Date: Wed, 5 Feb 2025 15:18:03 +0200 Subject: [PATCH 04/53] esd-spack-installation: added spack env exceptions --- esd/error_handling/exceptions.py | 5 +++++ esd/spack_manager/SpackManager.py | 26 +++++++++++++++++--------- esd/tests/spack_from_scratch_test.py | 24 ++++++++++++++++-------- 3 files changed, 38 insertions(+), 17 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', diff --git a/esd/tests/spack_from_scratch_test.py b/esd/tests/spack_from_scratch_test.py index 71c84a21..cca0d2e9 100644 --- a/esd/tests/spack_from_scratch_test.py +++ b/esd/tests/spack_from_scratch_test.py @@ -2,21 +2,30 @@ from pathlib import Path import pytest -from esd.error_handling.exceptions import BashCommandException +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) - assert spack_manager.spack_repo_exists(env.env_name) == False + with pytest.raises(NoSpackEnvironmentException): + spack_manager.spack_repo_exists(env.env_name) -def test_spack_repo_exists_2(): - spack_manager = SpackManagerScratch() - assert spack_manager.spack_repo_exists('ebrains-spack-builds') == False +# 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(): @@ -55,10 +64,9 @@ def test_spack_from_scratch_setup_4(): assert spack_manager.spack_env_exists() == True -def test_spack_from_scratch_bash_error(): +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(BashCommandException): + with pytest.raises(NoSpackEnvironmentException): spack_manager.add_spack_repo(repo.path, repo.env_name) -- GitLab From 4ac0a2309d927aaefe58234a69feb454eebc7948 Mon Sep 17 00:00:00 2001 From: adrianciu <adrian.ciu@codemart.ro> Date: Wed, 5 Feb 2025 21:32:38 +0200 Subject: [PATCH 05/53] esd-spack-installation: added clean up after each test --- README.md | 1 + esd/tests/spack_from_scratch_test.py | 32 ++++++++++++++-------------- esd/tests/spack_install_test.py | 10 ++++----- 3 files changed, 22 insertions(+), 21 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/esd/tests/spack_from_scratch_test.py b/esd/tests/spack_from_scratch_test.py index cca0d2e9..3cdbc327 100644 --- a/esd/tests/spack_from_scratch_test.py +++ b/esd/tests/spack_from_scratch_test.py @@ -12,24 +12,24 @@ def test_spack_repo_exists_1(): 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) 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 = 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() +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') @@ -37,8 +37,8 @@ def test_spack_from_scratch_setup_1(): assert spack_manager.spack_repo_exists(env.env_name) == True -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 @@ -47,8 +47,8 @@ def test_spack_from_scratch_setup_2(): 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') @@ -56,8 +56,8 @@ def test_spack_from_scratch_setup_3(): 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.setup_spack_env() diff --git a/esd/tests/spack_install_test.py b/esd/tests/spack_install_test.py index 34f68323..50e94044 100644 --- a/esd/tests/spack_install_test.py +++ b/esd/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 6d92683ca051c68e81fd8f2be4e25fbd3cf98f43 Mon Sep 17 00:00:00 2001 From: adrianciu <adrian.ciu@codemart.ro> Date: Wed, 5 Feb 2025 23:09:24 +0200 Subject: [PATCH 06/53] esd-spack-installation: added concretization step and tests --- esd/error_handling/exceptions.py | 14 +++- .../factory/SpackManagerCreator.py | 10 +-- .../factory/SpackManagerScratch.py | 15 +++- esd/tests/spack_from_scratch_test.py | 69 ++++++++++++++++--- esd/tests/spack_install_test.py | 2 +- esd/tests/utils_test.py | 20 +++++- esd/utils/utils.py | 4 ++ 7 files changed, 113 insertions(+), 21 deletions(-) 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/tests/spack_from_scratch_test.py b/esd/tests/spack_from_scratch_test.py index 3cdbc327..c059ab45 100644 --- a/esd/tests/spack_from_scratch_test.py +++ b/esd/tests/spack_from_scratch_test.py @@ -4,18 +4,21 @@ 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: SpackManagerScratch = SpackManagerCreator.get_spack_manger(SpackManagerEnum.FROM_SCRATCH) assert spack_manager.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 = SpackManagerScratch(env=env) + spack_manager: SpackManagerScratch = SpackManagerCreator.get_spack_manger(SpackManagerEnum.FROM_SCRATCH, env=env) with pytest.raises(NoSpackEnvironmentException): spack_manager.spack_repo_exists(env.env_name) @@ -23,7 +26,7 @@ def test_spack_repo_exists_2(tmp_path): def test_spack_repo_exists_3(tmp_path): install_dir = tmp_path env = SpackModel('ebrains-spack-builds', install_dir) - spack_manager = SpackManagerScratch(env=env) + spack_manager: SpackManagerScratch = SpackManagerCreator.get_spack_manger(SpackManagerEnum.FROM_SCRATCH, env=env) spack_manager.setup_spack_env() assert spack_manager.spack_repo_exists(env.env_name) == False @@ -32,9 +35,10 @@ 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: SpackManagerScratch = 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(tmp_path): @@ -42,7 +46,9 @@ def test_spack_from_scratch_setup_2(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: SpackManagerScratch = 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 @@ -51,15 +57,17 @@ 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: SpackManagerScratch = 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(tmp_path ): +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: SpackManagerScratch = 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(tmp_path ): 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: SpackManagerScratch = 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: SpackManagerScratch = 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: SpackManagerScratch = 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: SpackManagerScratch = 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/esd/tests/spack_install_test.py b/esd/tests/spack_install_test.py index 50e94044..9a32d5c7 100644 --- a/esd/tests/spack_install_test.py +++ b/esd/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/tests/utils_test.py b/esd/tests/utils_test.py index 8bec6c58..7f930e2f 100644 --- a/esd/tests/utils_test.py +++ b/esd/tests/utils_test.py @@ -1,7 +1,7 @@ import pytest from pathlib import Path -from esd.utils.utils import clean_up +from esd.utils.utils import clean_up, file_exists_and_not_empty @pytest.fixture @@ -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/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 45c0a69ec67211429a098804b618cea1d75a1cd1 Mon Sep 17 00:00:00 2001 From: adrianciu <adrian.ciu@codemart.ro> Date: Thu, 6 Feb 2025 09:15:15 +0200 Subject: [PATCH 07/53] esd-spack-installation: added concretization step and tests --- esd/tests/spack_from_scratch_test.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/esd/tests/spack_from_scratch_test.py b/esd/tests/spack_from_scratch_test.py index c059ab45..d1aca83c 100644 --- a/esd/tests/spack_from_scratch_test.py +++ b/esd/tests/spack_from_scratch_test.py @@ -11,14 +11,14 @@ from esd.utils.utils import file_exists_and_not_empty def test_spack_repo_exists_1(): - spack_manager: SpackManagerScratch = SpackManagerCreator.get_spack_manger(SpackManagerEnum.FROM_SCRATCH) + 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(tmp_path): install_dir = tmp_path env = SpackModel('ebrains-spack-builds', install_dir) - spack_manager: SpackManagerScratch = SpackManagerCreator.get_spack_manger(SpackManagerEnum.FROM_SCRATCH, 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) @@ -26,7 +26,7 @@ def test_spack_repo_exists_2(tmp_path): def test_spack_repo_exists_3(tmp_path): install_dir = tmp_path env = SpackModel('ebrains-spack-builds', install_dir) - spack_manager: SpackManagerScratch = SpackManagerCreator.get_spack_manger(SpackManagerEnum.FROM_SCRATCH, env=env) + 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 @@ -35,7 +35,7 @@ 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 = SpackManagerCreator.get_spack_manger(SpackManagerEnum.FROM_SCRATCH, env=env, + 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 @@ -46,7 +46,7 @@ def test_spack_from_scratch_setup_2(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 = SpackManagerCreator.get_spack_manger(SpackManagerEnum.FROM_SCRATCH, env=env, + spack_manager = SpackManagerCreator.get_spack_manger(SpackManagerEnum.FROM_SCRATCH, env=env, repos=[repo, repo], system_name='ebrainslab') spack_manager.setup_spack_env() @@ -57,7 +57,7 @@ def test_spack_from_scratch_setup_3(tmp_path): install_dir = tmp_path env = SpackModel('new_env1', install_dir) repo = env - spack_manager: SpackManagerScratch = SpackManagerCreator.get_spack_manger(SpackManagerEnum.FROM_SCRATCH, env=env, + spack_manager = SpackManagerCreator.get_spack_manger(SpackManagerEnum.FROM_SCRATCH, env=env, repos=[repo, repo], system_name='ebrainslab') with pytest.raises(BashCommandException): @@ -67,7 +67,7 @@ def test_spack_from_scratch_setup_3(tmp_path): def test_spack_from_scratch_setup_4(tmp_path): install_dir = tmp_path env = SpackModel('new_env2', install_dir) - spack_manager: SpackManagerScratch = SpackManagerCreator.get_spack_manger(SpackManagerEnum.FROM_SCRATCH, 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 @@ -75,7 +75,7 @@ def test_spack_from_scratch_setup_4(tmp_path): def test_spack_not_a_valid_repo(): env = SpackModel('ebrains-spack-builds', Path(), None) repo = env - spack_manager: SpackManagerScratch = SpackManagerCreator.get_spack_manger(SpackManagerEnum.FROM_SCRATCH, env=env, + spack_manager = SpackManagerCreator.get_spack_manger(SpackManagerEnum.FROM_SCRATCH, env=env, repos=[repo], system_name='ebrainslab') with pytest.raises(NoSpackEnvironmentException): @@ -87,7 +87,7 @@ def test_spack_from_scratch_concretize_1(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 = SpackManagerCreator.get_spack_manger(SpackManagerEnum.FROM_SCRATCH, env=env, repos=[repo, repo], + 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) @@ -100,7 +100,7 @@ def test_spack_from_scratch_concretize_2(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 = SpackManagerCreator.get_spack_manger(SpackManagerEnum.FROM_SCRATCH, env=env, repos=[repo, repo], + 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) @@ -113,7 +113,7 @@ def test_spack_from_scratch_concretize_3(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 = SpackManagerCreator.get_spack_manger(SpackManagerEnum.FROM_SCRATCH, env=env, + spack_manager = SpackManagerCreator.get_spack_manger(SpackManagerEnum.FROM_SCRATCH, env=env, repos=[repo, repo], system_name='ebrainslab') spack_manager.setup_spack_env() -- GitLab From 94313b949f2ff61781056955f7e32ab458f66b70 Mon Sep 17 00:00:00 2001 From: adrianciu <adrianciu25@gmail.com> Date: Thu, 6 Feb 2025 09:56:09 +0200 Subject: [PATCH 08/53] 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 b18e16ba90c887dfb1c4b6d10d617749edf53c1a Mon Sep 17 00:00:00 2001 From: adrianciu <adrianciu25@gmail.com> Date: Thu, 6 Feb 2025 10:10:33 +0200 Subject: [PATCH 09/53] 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 ++++ esd/tests/spack_from_scratch_test.py | 83 +++++++++++++++++----- 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 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 diff --git a/esd/tests/spack_from_scratch_test.py b/esd/tests/spack_from_scratch_test.py index d1aca83c..2131e7df 100644 --- a/esd/tests/spack_from_scratch_test.py +++ b/esd/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 -- GitLab From d516d1d32f99c1da1c435058c2545fca24b73bed Mon Sep 17 00:00:00 2001 From: adrianciu <adrian.ciu@codemart.ro> Date: Thu, 6 Feb 2025 18:14:49 +0200 Subject: [PATCH 10/53] esd-spack-installation: added spack install packages method --- esd/spack_manager/SpackManager.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/esd/spack_manager/SpackManager.py b/esd/spack_manager/SpackManager.py index a7f46c27..5d14d1e0 100644 --- a/esd/spack_manager/SpackManager.py +++ b/esd/spack_manager/SpackManager.py @@ -167,6 +167,16 @@ class SpackManager(ABC): return spack_version.stdout.strip().split()[0] return None + @no_spack_env + def install_packages(self, jobs: int): + # spack install -v --j "$cpu_count" --fresh + run_command("bash", "-c", + f'source {self.spack_setup_script} && spack install --env {self.env.env_name} -v --j {jobs} --fresh', + 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.21.1", spack_repo='https://github.com/spack/spack'): try: -- GitLab From 5de54e8ba8824ca8942194dd55d9a45663dd8292 Mon Sep 17 00:00:00 2001 From: adrianciu <adrian.ciu@codemart.ro> Date: Thu, 6 Feb 2025 18:14:49 +0200 Subject: [PATCH 11/53] esd-spack-installation: added spack install packages method --- esd/spack_manager/SpackManager.py | 10 ++++++++++ esd/tests/spack_from_scratch_test.py | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/esd/spack_manager/SpackManager.py b/esd/spack_manager/SpackManager.py index a7f46c27..5d14d1e0 100644 --- a/esd/spack_manager/SpackManager.py +++ b/esd/spack_manager/SpackManager.py @@ -167,6 +167,16 @@ class SpackManager(ABC): return spack_version.stdout.strip().split()[0] return None + @no_spack_env + def install_packages(self, jobs: int): + # spack install -v --j "$cpu_count" --fresh + run_command("bash", "-c", + f'source {self.spack_setup_script} && spack install --env {self.env.env_name} -v --j {jobs} --fresh', + 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.21.1", spack_repo='https://github.com/spack/spack'): try: diff --git a/esd/tests/spack_from_scratch_test.py b/esd/tests/spack_from_scratch_test.py index 2131e7df..eab1fbf1 100644 --- a/esd/tests/spack_from_scratch_test.py +++ b/esd/tests/spack_from_scratch_test.py @@ -158,7 +158,7 @@ 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') + 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]) -- GitLab From afc8c5282d2d4d134c9c10a32b2e3b3d995caa82 Mon Sep 17 00:00:00 2001 From: adrianciu <adrian.ciu@codemart.ro> Date: Fri, 7 Feb 2025 10:33:32 +0200 Subject: [PATCH 12/53] esd-spack-installation: added spack install packages method --- .gitlab-ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 7af0dd30..3883f491 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -28,6 +28,7 @@ testing-pytest: - docker-runner image: ubuntu:22.04 script: + - echo $SPACK_ENV_ACCESS_TOKEN - chmod +x esd/utils/bootstrap.sh - ./esd/utils/bootstrap.sh - pip install . -- GitLab From 5950db4b9189968f8efc6023c4de3f610c11e8c4 Mon Sep 17 00:00:00 2001 From: adrianciu <adrian.ciu@codemart.ro> Date: Fri, 7 Feb 2025 11:07:59 +0200 Subject: [PATCH 13/53] esd-spack-installation: added spack install packages method --- .gitlab-ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 3883f491..3b2d80cc 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -28,11 +28,11 @@ testing-pytest: - docker-runner image: ubuntu:22.04 script: - - echo $SPACK_ENV_ACCESS_TOKEN - chmod +x esd/utils/bootstrap.sh - ./esd/utils/bootstrap.sh - - pip install . - - pytest ./esd/tests/ -s --junitxml=test-results.xml + - echo $SPACK_ENV_ACCESS_TOKEN +# - pip install . +# - pytest ./esd/tests/ -s --junitxml=test-results.xml artifacts: when: always reports: -- GitLab From 7fa44050f4e03dc8d481eb443cb24493924736db Mon Sep 17 00:00:00 2001 From: adrianciu <adrian.ciu@codemart.ro> Date: Fri, 7 Feb 2025 11:45:41 +0200 Subject: [PATCH 14/53] esd-spack-installation: added spack install packages method --- .gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 3b2d80cc..030ebdbd 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -30,7 +30,7 @@ testing-pytest: script: - chmod +x esd/utils/bootstrap.sh - ./esd/utils/bootstrap.sh - - echo $SPACK_ENV_ACCESS_TOKEN + - echo "$SPACK_ENV_ACCESS_TOKEN" # - pip install . # - pytest ./esd/tests/ -s --junitxml=test-results.xml artifacts: -- GitLab From 7364c3cf7e0548bee7457909fae076a2a11fbf30 Mon Sep 17 00:00:00 2001 From: adrianciu <adrian.ciu@codemart.ro> Date: Fri, 7 Feb 2025 11:50:06 +0200 Subject: [PATCH 15/53] esd-spack-installation: added spack install packages method --- .gitlab-ci.yml | 4 ++-- esd/tests/spack_from_scratch_test.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 030ebdbd..1affd3bd 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -31,8 +31,8 @@ testing-pytest: - chmod +x esd/utils/bootstrap.sh - ./esd/utils/bootstrap.sh - echo "$SPACK_ENV_ACCESS_TOKEN" -# - pip install . -# - pytest ./esd/tests/ -s --junitxml=test-results.xml + - pip install . + - pytest ./esd/tests/ -s --junitxml=test-results.xml artifacts: when: always reports: diff --git a/esd/tests/spack_from_scratch_test.py b/esd/tests/spack_from_scratch_test.py index eab1fbf1..984556e5 100644 --- a/esd/tests/spack_from_scratch_test.py +++ b/esd/tests/spack_from_scratch_test.py @@ -145,7 +145,7 @@ 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') + 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]) @@ -158,7 +158,7 @@ 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, - f'https://oauth2:{SPACK_ENV_ACCESS_TOKEN}gitlab.ebrains.eu/ri/projects-and-initiatives/virtualbraintwin/test-spack-env.git') + 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]) -- GitLab From 60faa456fcac849fa653cf4fcde871848d744f40 Mon Sep 17 00:00:00 2001 From: adrianciu <adrian.ciu@codemart.ro> Date: Thu, 6 Feb 2025 18:14:49 +0200 Subject: [PATCH 16/53] 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 +- esd/tests/spack_from_scratch_test.py | 37 +++++++++------------- 4 files changed, 36 insertions(+), 29 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(): diff --git a/esd/tests/spack_from_scratch_test.py b/esd/tests/spack_from_scratch_test.py index 2131e7df..e5627409 100644 --- a/esd/tests/spack_from_scratch_test.py +++ b/esd/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) -- GitLab From 03b4de042810448224ab8ece7793380b3138d4c7 Mon Sep 17 00:00:00 2001 From: adrianciu <adrian.ciu@codemart.ro> Date: Fri, 7 Feb 2025 18:50:52 +0200 Subject: [PATCH 17/53] esd-spack-installation: spack install method and additional tests --- esd/spack_manager/SpackManager.py | 29 ++++++++++--------- .../factory/SpackManagerBuildCache.py | 3 -- .../factory/SpackManagerScratch.py | 3 -- esd/tests/spack_from_scratch_test.py | 14 ++++++++- 4 files changed, 29 insertions(+), 20 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 diff --git a/esd/tests/spack_from_scratch_test.py b/esd/tests/spack_from_scratch_test.py index e5627409..782d6be8 100644 --- a/esd/tests/spack_from_scratch_test.py +++ b/esd/tests/spack_from_scratch_test.py @@ -12,7 +12,6 @@ test_spack_env_git = f'https://oauth2:{SPACK_ENV_ACCESS_TOKEN}@gitlab.ebrains.eu 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 @@ -159,3 +158,16 @@ def test_spack_from_scratch_concretize_7(tmp_path): 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_install(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' + assert file_exists_and_not_empty(concretization_file_path) == True + install_result = spack_manager.install_packages(jobs=2, signed=False, fresh=True, debug=False) + assert install_result.returncode == 0 -- GitLab From 837a48c6626a6ddca8d810c5cd64469eb519eb13 Mon Sep 17 00:00:00 2001 From: adrianciu <adrian.ciu@codemart.ro> Date: Mon, 10 Feb 2025 18:21:51 +0200 Subject: [PATCH 18/53] esd-spack-installation: refactoring --- esd/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/esd/build_cache/BuildCacheManager.py b/esd/build_cache/BuildCacheManager.py index e1bd6824..839ac524 100644 --- a/esd/build_cache/BuildCacheManager.py +++ b/esd/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 b9f536fae77b33ca28c2ada8a1d729593af98676 Mon Sep 17 00:00:00 2001 From: adrianciu <adrian.ciu@codemart.ro> Date: Tue, 11 Feb 2025 19:23:33 +0200 Subject: [PATCH 19/53] esd-spack-installation: major refactoring; fixed bug on updating env vars in .bashrc --- esd/build_cache/BuildCacheManager.py | 34 +-- esd/cli/SpackManager.py | 2 + 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/tests/spack_from_scratch_test.py | 211 ++++++++++-------- esd/tests/spack_install_test.py | 21 +- esd/tests/testing_variables.py | 6 + esd/utils/utils.py | 39 +++- esd/{spack_manager => }/wrapper/__init__.py | 0 .../wrapper/spack_wrapper.py | 0 20 files changed, 312 insertions(+), 254 deletions(-) create mode 100644 esd/cli/SpackManager.py 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 create mode 100644 esd/tests/testing_variables.py rename esd/{spack_manager => }/wrapper/__init__.py (100%) rename esd/{spack_manager => }/wrapper/spack_wrapper.py (100%) diff --git a/esd/build_cache/BuildCacheManager.py b/esd/build_cache/BuildCacheManager.py index 839ac524..cccb5846 100644 --- a/esd/build_cache/BuildCacheManager.py +++ b/esd/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/esd/cli/SpackManager.py b/esd/cli/SpackManager.py new file mode 100644 index 00000000..11eefce8 --- /dev/null +++ b/esd/cli/SpackManager.py @@ -0,0 +1,2 @@ +class SpackManager: + pass 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/tests/spack_from_scratch_test.py b/esd/tests/spack_from_scratch_test.py index 782d6be8..cdc405e7 100644 --- a/esd/tests/spack_from_scratch_test.py +++ b/esd/tests/spack_from_scratch_test.py @@ -1,173 +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 = 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 - install_result = spack_manager.install_packages(jobs=2, signed=False, fresh=True, debug=False) + install_result = spack_operation.install_packages(jobs=2, signed=False, fresh=True, debug=False) assert install_result.returncode == 0 diff --git a/esd/tests/spack_install_test.py b/esd/tests/spack_install_test.py index 9a32d5c7..28f8268e 100644 --- a/esd/tests/spack_install_test.py +++ b/esd/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/tests/testing_variables.py b/esd/tests/testing_variables.py new file mode 100644 index 00000000..ab95bfa1 --- /dev/null +++ b/esd/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/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 dd1d9727f850dd47c3d9eca2cb3c493b82f42161 Mon Sep 17 00:00:00 2001 From: adrianciu <adrian.ciu@codemart.ro> Date: Fri, 14 Feb 2025 11:28:46 +0200 Subject: [PATCH 20/53] esd-spack-installation: added additional spack commands --- esd/error_handling/exceptions.py | 10 ++++ esd/spack_factory/SpackOperation.py | 58 +++++++++++++++++---- esd/spack_factory/SpackOperationUseCache.py | 15 +++++- 3 files changed, 71 insertions(+), 12 deletions(-) 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..904582f4 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,52 @@ 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, gpg_name='example', gpg_mail='example@example.com'): + run_command("bash", "-c", + f'source {self.spack_setup_script} && spack gpg init && spack gpg create {gpg_name} {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) + + 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 7cc4f7585c2925838fc841aa720e280caa1e5266 Mon Sep 17 00:00:00 2001 From: Jithu Murugan <j.murugan@fz-juelich.de> Date: Thu, 20 Feb 2025 18:07:54 +0100 Subject: [PATCH 21/53] Following changes have been incorporated: - SpackOperation.py: The handle_result and trust_gpg_key methods were specifically updated with tests and docstrings. Also modified the add_mirror method as per the changes. - SpackOperationUseCache.py: Similar to SpackOperation.py, this file also got enhanced tests and docstrings, specifically for the setup_spack_env method. The focus is on setting up the environment for a user spack cache setup along with testing of the caching behavior and improving documentation. - BuildCacheManager.py: This file saw improvements in its test suite and the addition of docstrings. The tests likely cover various cache operations, and the docstrings explain the purpose and usage of the cache management methods. The get_public_key_from_cache, download, _log_warning_if_needed, and other methods were updated with tests and docstrings. - .gitlab-ci.yml: Changes likely include adding or modifying stages for running the new pytest tests and generating coverage reports. - pyproject.toml: This file was modified to add testing dependencies required for the enhanced test suite. Specifically, pytest, pytest-mock, pytest-ordering, and coverage were added under the test extra. This change enables running the new tests and generating coverage reports. The commands folder implements a command execution and management system, especially for Spack, using these key components and patterns: - Command Definitions (Enum/Constants): command_enum.py likely defines an enumeration (or constants) representing different commands. This promotes type safety and avoids stringly-typed code. - Command Sequence Builder: command_sequence_builder.py implements the Builder Pattern to construct CommandSequence objects (from command_sequence.py). This allows for flexible and readable creation of complex command sequences. - Command Sequence: command_sequence.py represents a sequence of commands to be executed. It encapsulates the order and configuration of commands. - Spack Command Factory: spack_command_sequence_factory.py uses the Factory Pattern to create Spack-specific CommandSequence objects. This centralizes the creation logic for different Spack commands and simplifies their usage. It likely uses command_sequence_builder.py internally. - Generic Command Runner: A command_runner.py (implied, not explicitly mentioned in the diff) likely handles the actual execution of commands, possibly using subprocesses. This abstracts the execution details from the command sequence logic. - Focus on Spack: The structure strongly suggests a focus on managing Spack environments and packages. The factory and command definitions likely reflect common Spack operations. Patterns used: - Enum/Constants Pattern: Used for defining distinct command types. - Builder Pattern: Used for constructing command sequences. - Factory Pattern: Used for creating Spack-specific command sequences. - Command Pattern: A close variant of the Command Pattern for encapsulating command execution. Abstraction of shell commands along with an invoker (command_runner) been implemented --- .gitlab-ci.yml | 2 +- MANIFEST.ini | 3 + esd/build_cache/BuildCacheManager.py | 51 +++- esd/commands/__init__.py | 8 + esd/commands/bash_command_executor.py | 93 +++++++ esd/commands/command.py | 29 +++ esd/commands/command_enum.py | 37 +++ esd/commands/command_registry.py | 59 +++++ esd/commands/command_runner.py | 150 +++++++++++ esd/commands/command_sequence.py | 47 ++++ esd/commands/command_sequence_builder.py | 71 ++++++ esd/commands/command_sequence_factory.py | 45 ++++ esd/commands/generic_shell_command.py | 46 ++++ esd/commands/preconfigured_command_enum.py | 28 +++ esd/commands/shell_command_factory.py | 33 +++ .../spack_command_sequence_factory.py | 211 ++++++++++++++++ esd/configuration/SpackConfig.py | 5 +- esd/spack_factory/SpackOperation.py | 134 ++++++++-- esd/spack_factory/SpackOperationUseCache.py | 29 ++- .../unit_tests/test_bash_command_executor.py | 232 ++++++++++++++++++ .../unit_tests/test_build_cache_manager.py | 155 ++++++++++++ esd/tests/unit_tests/test_command.py | 45 ++++ esd/tests/unit_tests/test_command_enum.py | 38 +++ esd/tests/unit_tests/test_command_runner.py | 123 ++++++++++ esd/tests/unit_tests/test_command_sequence.py | 91 +++++++ .../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 ++++++++++++ esd/tests/unit_tests/test_spack_operation.py | 209 ++++++++++++++++ .../test_spack_operation_use_cache.py | 90 +++++++ pyproject.toml | 20 +- 34 files changed, 2504 insertions(+), 44 deletions(-) create mode 100644 MANIFEST.ini create mode 100644 esd/commands/__init__.py create mode 100644 esd/commands/bash_command_executor.py create mode 100644 esd/commands/command.py create mode 100644 esd/commands/command_enum.py create mode 100644 esd/commands/command_registry.py create mode 100644 esd/commands/command_runner.py create mode 100644 esd/commands/command_sequence.py create mode 100644 esd/commands/command_sequence_builder.py create mode 100644 esd/commands/command_sequence_factory.py create mode 100644 esd/commands/generic_shell_command.py create mode 100644 esd/commands/preconfigured_command_enum.py create mode 100644 esd/commands/shell_command_factory.py create mode 100644 esd/commands/spack_command_sequence_factory.py create mode 100644 esd/tests/unit_tests/test_bash_command_executor.py create mode 100644 esd/tests/unit_tests/test_build_cache_manager.py create mode 100644 esd/tests/unit_tests/test_command.py create mode 100644 esd/tests/unit_tests/test_command_enum.py create mode 100644 esd/tests/unit_tests/test_command_runner.py create mode 100644 esd/tests/unit_tests/test_command_sequence.py create mode 100644 esd/tests/unit_tests/test_command_sequence_builder.py create mode 100644 esd/tests/unit_tests/test_command_sequence_factory.py create mode 100644 esd/tests/unit_tests/test_generic_shell_command.py create mode 100644 esd/tests/unit_tests/test_preconfigured_command_enum.py create mode 100644 esd/tests/unit_tests/test_shell_command_factory.py create mode 100644 esd/tests/unit_tests/test_spack_command_sequence_factory.py create mode 100644 esd/tests/unit_tests/test_spack_operation.py create mode 100644 esd/tests/unit_tests/test_spack_operation_use_cache.py diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 65062ec7..98a371df 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -31,7 +31,7 @@ testing-pytest: - chmod +x esd/utils/bootstrap.sh - ./esd/utils/bootstrap.sh - echo "$SPACK_ENV_ACCESS_TOKEN" - - pip install . + - pip install -e .[tests,dev] - pytest ./esd/tests/ -s --junitxml=test-results.xml artifacts: when: always diff --git a/MANIFEST.ini b/MANIFEST.ini new file mode 100644 index 00000000..d6809e09 --- /dev/null +++ b/MANIFEST.ini @@ -0,0 +1,3 @@ + +include README.md +recursive-include yashchiki/esd *.* \ No newline at end of file diff --git a/esd/build_cache/BuildCacheManager.py b/esd/build_cache/BuildCacheManager.py index cccb5846..8641a68e 100644 --- a/esd/build_cache/BuildCacheManager.py +++ b/esd/build_cache/BuildCacheManager.py @@ -1,10 +1,12 @@ +import glob import os -import oras.client +from os.path import join from pathlib import Path +import oras.client + 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): @@ -111,7 +113,50 @@ 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/esd/commands/__init__.py b/esd/commands/__init__.py new file mode 100644 index 00000000..ea9c384e --- /dev/null +++ b/esd/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/esd/commands/bash_command_executor.py b/esd/commands/bash_command_executor.py new file mode 100644 index 00000000..1811b757 --- /dev/null +++ b/esd/commands/bash_command_executor.py @@ -0,0 +1,93 @@ +""" 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 os +import subprocess +from logging import Logger + +from esd.commands.command import Command +from esd.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 diff --git a/esd/commands/command.py b/esd/commands/command.py new file mode 100644 index 00000000..07e9837c --- /dev/null +++ b/esd/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/esd/commands/command_enum.py b/esd/commands/command_enum.py new file mode 100644 index 00000000..7ef184cb --- /dev/null +++ b/esd/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/esd/commands/command_registry.py b/esd/commands/command_registry.py new file mode 100644 index 00000000..ea2ab646 --- /dev/null +++ b/esd/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 esd.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/esd/commands/command_runner.py b/esd/commands/command_runner.py new file mode 100644 index 00000000..a9bd89a0 --- /dev/null +++ b/esd/commands/command_runner.py @@ -0,0 +1,150 @@ +""" 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 esd.commands.bash_command_executor import BashCommandExecutor +from esd.commands.command_enum import CommandEnum +from esd.commands.command_sequence import CommandSequence +from esd.commands.command_sequence_factory import CommandSequenceFactory +from esd.commands.preconfigured_command_enum import PreconfiguredCommandEnum +from esd.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. + + 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. + """ + + 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() + 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/esd/commands/command_sequence.py b/esd/commands/command_sequence.py new file mode 100644 index 00000000..d0a3b469 --- /dev/null +++ b/esd/commands/command_sequence.py @@ -0,0 +1,47 @@ +""" 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 esd.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() diff --git a/esd/commands/command_sequence_builder.py b/esd/commands/command_sequence_builder.py new file mode 100644 index 00000000..5f1d2c3f --- /dev/null +++ b/esd/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 esd.commands.command_enum import CommandEnum +from esd.commands.command_registry import CommandRegistry +from esd.commands.command_sequence import CommandSequence +from esd.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/esd/commands/command_sequence_factory.py b/esd/commands/command_sequence_factory.py new file mode 100644 index 00000000..96804660 --- /dev/null +++ b/esd/commands/command_sequence_factory.py @@ -0,0 +1,45 @@ +""" 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 esd.commands.command_enum import CommandEnum +from esd.commands.command_sequence import CommandSequence +from esd.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/esd/commands/generic_shell_command.py b/esd/commands/generic_shell_command.py new file mode 100644 index 00000000..ea40079d --- /dev/null +++ b/esd/commands/generic_shell_command.py @@ -0,0 +1,46 @@ +""" 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 esd.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/esd/commands/preconfigured_command_enum.py b/esd/commands/preconfigured_command_enum.py new file mode 100644 index 00000000..14b747ad --- /dev/null +++ b/esd/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/esd/commands/shell_command_factory.py b/esd/commands/shell_command_factory.py new file mode 100644 index 00000000..99baec39 --- /dev/null +++ b/esd/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 esd.commands.command import Command +from esd.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/esd/commands/spack_command_sequence_factory.py b/esd/commands/spack_command_sequence_factory.py new file mode 100644 index 00000000..e8d7e522 --- /dev/null +++ b/esd/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 esd.commands.command_enum import CommandEnum +from esd.commands.command_sequence import CommandSequence +from esd.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/esd/configuration/SpackConfig.py b/esd/configuration/SpackConfig.py index 93a2e874..26c6617f 100644 --- a/esd/configuration/SpackConfig.py +++ b/esd/configuration/SpackConfig.py @@ -8,10 +8,7 @@ class SpackConfig: 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.repos = [] if repos is None else repos self.install_dir = install_dir self.upstream_instance = upstream_instance self.system_name = system_name diff --git a/esd/spack_factory/SpackOperation.py b/esd/spack_factory/SpackOperation.py index 904582f4..5a026283 100644 --- a/esd/spack_factory/SpackOperation.py +++ b/esd/spack_factory/SpackOperation.py @@ -2,13 +2,16 @@ import os import re import subprocess from pathlib import Path + +from esd.commands.command_runner import CommandRunner +from esd.commands.preconfigured_command_enum import PreconfiguredCommandEnum +from esd.configuration.SpackConfig import SpackConfig from esd.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 esd.wrapper.spack_wrapper import check_spack_env class SpackOperation: @@ -36,6 +39,7 @@ class SpackOperation: 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.command_runner = CommandRunner() def create_fetch_spack_environment(self): if self.spack_config.env.git_path: @@ -109,9 +113,7 @@ class SpackOperation: check=True, capture_output=True, text=True, logger=self.logger, info_msg=f'Checking if environment {self.spack_config.env.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): @@ -159,7 +161,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) @@ -173,26 +175,104 @@ class SpackOperation: exception_msg=f'Failed to create pgp keys mirror {self.spack_config.env.env_name}', exception=SpackGpgException) - 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 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 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", @@ -242,7 +322,7 @@ class SpackOperation: # 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 + # 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") diff --git a/esd/spack_factory/SpackOperationUseCache.py b/esd/spack_factory/SpackOperationUseCache.py index 313522d2..b6e7846c 100644 --- a/esd/spack_factory/SpackOperationUseCache.py +++ b/esd/spack_factory/SpackOperationUseCache.py @@ -1,8 +1,10 @@ import os + from esd.build_cache.BuildCacheManager import BuildCacheManager +from esd.configuration.SpackConfig import SpackConfig +from esd.error_handling.exceptions import NoSpackEnvironmentException from esd.logger.logger_builder import get_logger from esd.spack_factory.SpackOperation import SpackOperation -from esd.configuration.SpackConfig import SpackConfig class SpackOperationUseCache(SpackOperation): @@ -25,8 +27,31 @@ class SpackOperationUseCache(SpackOperation): cache_version=cache_version_build) def setup_spack_env(self): + """Sets up the Spack environment using cached data. + + Downloads the build cache, trusts the cached public key (if available), + and adds the build cache as a local mirror. + """ 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(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 + + + def concretize_spack_env(self, force=True): pass diff --git a/esd/tests/unit_tests/test_bash_command_executor.py b/esd/tests/unit_tests/test_bash_command_executor.py new file mode 100644 index 00000000..70633fa2 --- /dev/null +++ b/esd/tests/unit_tests/test_bash_command_executor.py @@ -0,0 +1,232 @@ +# 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 esd.commands.bash_command_executor import BashCommandExecutor +from esd.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 + original_os = os.name + mock_get_logger = mocker.patch("esd.commands.bash_command_executor.logging.getLogger") + mocker.patch("esd.commands.bash_command_executor.os.name", os_name) + # mock_os_name.return_value = os_name + + # Act + executor = BashCommandExecutor() + os.name = original_os # Reset the os.name to the original value + + # Assert + assert executor.bash_command == expected_bash_command + mock_get_logger.assert_called_once_with('esd.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("esd.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("esd.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("esd.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("esd.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 + original_os = os.name + mocker.patch("esd.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) + + # Cleanup + os.name = original_os + + @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 + original_os = os.name + mocker.patch("esd.commands.bash_command_executor.os.name", "nt") + with patch("esd.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) + + # Cleanup + os.name = original_os + + 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("esd.commands.bash_command_executor.subprocess.run") + def test_execute_happy_path_nt(self, mock_subprocess_run, mocker): + # Arrange + original_os = os.name + + mocker.patch("esd.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) + + # Cleanup + os.name = original_os + + @patch("esd.commands.bash_command_executor.os.name", "unknown") + def test_execute_unknown_os(self): + # Arrange + executor = BashCommandExecutor() + executor.add_command(MockCommand("echo hello")) + + # Act + assert executor.execute() == (None, + "Error: Bash Command: ['undefined'] not found: [WinError 2] The system cannot " + 'find the file specified') diff --git a/esd/tests/unit_tests/test_build_cache_manager.py b/esd/tests/unit_tests/test_build_cache_manager.py new file mode 100644 index 00000000..687570eb --- /dev/null +++ b/esd/tests/unit_tests/test_build_cache_manager.py @@ -0,0 +1,155 @@ +# 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 esd.build_cache.BuildCacheManager import BuildCacheManager + + +class TestBuildCacheManager: + + @fixture(scope="function") + def mock_build_cache_manager(self, mocker): + mocker.patch("esd.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 + assert result == str(build_cache_dir / "project0" / "_pgp" / "key0.pub") + log = (expected_log_message, *pgp_folders, pgp_folders[0]) if test_id == "more_than_one_gpg_folder" else ( + expected_log_message, *key_files, key_files[0]) + mock_build_cache_manager._logger.warning.assert_called_once_with(*log) + + @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.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.log_warning_if_needed(warn_message, items) + + # Assert + mock_build_cache_manager._logger.warning.assert_not_called() diff --git a/esd/tests/unit_tests/test_command.py b/esd/tests/unit_tests/test_command.py new file mode 100644 index 00000000..8f957a35 --- /dev/null +++ b/esd/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 esd.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/esd/tests/unit_tests/test_command_enum.py b/esd/tests/unit_tests/test_command_enum.py new file mode 100644 index 00000000..57c4ec34 --- /dev/null +++ b/esd/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 esd.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/esd/tests/unit_tests/test_command_runner.py b/esd/tests/unit_tests/test_command_runner.py new file mode 100644 index 00000000..24ec4a93 --- /dev/null +++ b/esd/tests/unit_tests/test_command_runner.py @@ -0,0 +1,123 @@ +# 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 esd.commands.command_enum import CommandEnum +from esd.commands.command_runner import CommandRunner +from esd.commands.command_sequence import CommandSequence +from esd.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("esd.commands.command_runner.BashCommandExecutor") + mocker.patch("esd.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 + + @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( + "esd.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/esd/tests/unit_tests/test_command_sequence.py b/esd/tests/unit_tests/test_command_sequence.py new file mode 100644 index 00000000..8b68e107 --- /dev/null +++ b/esd/tests/unit_tests/test_command_sequence.py @@ -0,0 +1,91 @@ +# 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 + +import pytest + +from esd.commands.command import Command +from esd.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" diff --git a/esd/tests/unit_tests/test_command_sequence_builder.py b/esd/tests/unit_tests/test_command_sequence_builder.py new file mode 100644 index 00000000..6c4062a4 --- /dev/null +++ b/esd/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 esd.commands.command import Command +from esd.commands.command_enum import CommandEnum +from esd.commands.command_registry import CommandRegistry +from esd.commands.command_sequence import CommandSequence +from esd.commands.command_sequence_builder import CommandSequenceBuilder +from esd.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/esd/tests/unit_tests/test_command_sequence_factory.py b/esd/tests/unit_tests/test_command_sequence_factory.py new file mode 100644 index 00000000..7515f14a --- /dev/null +++ b/esd/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 esd.commands.command_enum import CommandEnum +from esd.commands.command_sequence import CommandSequence +from esd.commands.command_sequence_builder import CommandSequenceBuilder +from esd.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("esd.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/esd/tests/unit_tests/test_generic_shell_command.py b/esd/tests/unit_tests/test_generic_shell_command.py new file mode 100644 index 00000000..e9287e04 --- /dev/null +++ b/esd/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 esd.commands.command import Command +from esd.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/esd/tests/unit_tests/test_preconfigured_command_enum.py b/esd/tests/unit_tests/test_preconfigured_command_enum.py new file mode 100644 index 00000000..02539d55 --- /dev/null +++ b/esd/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 esd.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/esd/tests/unit_tests/test_shell_command_factory.py b/esd/tests/unit_tests/test_shell_command_factory.py new file mode 100644 index 00000000..f2f95b90 --- /dev/null +++ b/esd/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 esd.commands.generic_shell_command import GenericShellCommand +from esd.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/esd/tests/unit_tests/test_spack_command_sequence_factory.py b/esd/tests/unit_tests/test_spack_command_sequence_factory.py new file mode 100644 index 00000000..9b6bc572 --- /dev/null +++ b/esd/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 esd.commands.command_enum import CommandEnum +from esd.commands.command_sequence import CommandSequence +from esd.commands.command_sequence_builder import CommandSequenceBuilder +from esd.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/esd/tests/unit_tests/test_spack_operation.py b/esd/tests/unit_tests/test_spack_operation.py new file mode 100644 index 00000000..4e2c520e --- /dev/null +++ b/esd/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 esd.build_cache.BuildCacheManager import BuildCacheManager +from esd.commands.preconfigured_command_enum import PreconfiguredCommandEnum +from esd.error_handling.exceptions import NoSpackEnvironmentException +from esd.spack_factory.SpackOperation import SpackOperation + + +class TestSpackOperationAddMirrorWithComposite: + + @fixture + def mock_spack_operation(self, mocker): + mocker.resetall() + mocker.patch("esd.spack_factory.SpackOperation.CommandRunner") + mocker.patch("esd.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/esd/tests/unit_tests/test_spack_operation_use_cache.py b/esd/tests/unit_tests/test_spack_operation_use_cache.py new file mode 100644 index 00000000..fd4e2778 --- /dev/null +++ b/esd/tests/unit_tests/test_spack_operation_use_cache.py @@ -0,0 +1,90 @@ +# 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 esd.error_handling.exceptions import NoSpackEnvironmentException +from esd.spack_factory.SpackOperationUseCache import SpackOperationUseCache + + +@pytest.fixture +def spack_operation_use_cache_mock(mocker): + super_mock = mocker.patch("esd.spack_factory.SpackOperationUseCache.super") + super_mock.return_value.setup_spack_env = mocker.MagicMock() + mocker.patch("esd.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("esd.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( + 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/pyproject.toml b/pyproject.toml index abcbe05d..c8b8f7b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,20 +6,26 @@ build-backend = "setuptools.build_meta" name = "esd-tools" 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", ] [tool.setuptools.data-files] -"esd-tools" = ["esd/logger/logging.conf"] \ No newline at end of file +"esd-tools" = ["esd/logger/logging.conf"] + +[options.extras_require] +test = [ + "pytest", + "pytest-mock", + "pytest-ordering", + "coverage" +] \ No newline at end of file -- GitLab From 238e1db98c5f2b1784fb7a9fb78e8026913d2353 Mon Sep 17 00:00:00 2001 From: Jithu Murugan <j.murugan@fz-juelich.de> Date: Fri, 21 Feb 2025 09:07:21 +0100 Subject: [PATCH 22/53] - Merged the changes from the parent branch and corrected as per the changes in the parent branch - Moved all the changes from esd to dedal folder. - Minor refactorings and other changes --- .gitlab-ci.yml | 6 +- MANIFEST.ini | 2 +- dedal/build_cache/BuildCacheManager.py | 12 +- {esd => dedal}/commands/__init__.py | 0 .../commands/bash_command_executor.py | 11 +- {esd => dedal}/commands/command.py | 0 {esd => dedal}/commands/command_enum.py | 0 {esd => dedal}/commands/command_registry.py | 2 +- {esd => dedal}/commands/command_runner.py | 69 +++- {esd => dedal}/commands/command_sequence.py | 9 +- .../commands/command_sequence_builder.py | 8 +- .../commands/command_sequence_factory.py | 7 +- .../commands/generic_shell_command.py | 3 +- .../commands/preconfigured_command_enum.py | 0 .../commands/shell_command_factory.py | 4 +- .../spack_command_sequence_factory.py | 6 +- dedal/spack_factory/SpackOperation.py | 132 +++++-- dedal/spack_factory/SpackOperationUseCache.py | 26 +- .../tests/spack_from_scratch_test.py | 12 +- {esd => dedal}/tests/spack_install_test.py | 4 +- .../unit_tests/test_bash_command_executor.py | 44 ++- .../unit_tests/test_build_cache_manager.py | 14 +- .../tests/unit_tests/test_command.py | 2 +- .../tests/unit_tests/test_command_enum.py | 2 +- .../tests/unit_tests/test_command_runner.py | 16 +- .../tests/unit_tests/test_command_sequence.py | 23 +- .../test_command_sequence_builder.py | 12 +- .../test_command_sequence_factory.py | 10 +- .../unit_tests/test_generic_shell_command.py | 4 +- .../test_preconfigured_command_enum.py | 2 +- .../unit_tests/test_shell_command_factory.py | 4 +- .../test_spack_command_sequence_factory.py | 8 +- .../tests/unit_tests/test_spack_operation.py | 12 +- .../test_spack_operation_use_cache.py | 13 +- dedal/utils/bootstrap.sh | 2 +- esd/configuration/SpackConfig.py | 22 -- esd/configuration/__init__.py | 0 esd/error_handling/__init__.py | 0 esd/error_handling/exceptions.py | 41 --- esd/model/SpackDescriptor.py | 13 - esd/model/__init__.py | 0 esd/spack_factory/SpackOperation.py | 346 ------------------ esd/spack_factory/SpackOperationCreator.py | 14 - esd/spack_factory/SpackOperationUseCache.py | 57 --- esd/spack_factory/__init__.py | 0 esd/tests/testing_variables.py | 6 - esd/tests/utils_test.py | 0 esd/utils/bootstrap.sh | 6 - esd/utils/utils.py | 0 esd/wrapper/__init__.py | 0 esd/wrapper/spack_wrapper.py | 15 - pyproject.toml | 5 +- 52 files changed, 348 insertions(+), 658 deletions(-) rename {esd => dedal}/commands/__init__.py (100%) rename {esd => dedal}/commands/bash_command_executor.py (91%) rename {esd => dedal}/commands/command.py (100%) rename {esd => dedal}/commands/command_enum.py (100%) rename {esd => dedal}/commands/command_registry.py (98%) rename {esd => dedal}/commands/command_runner.py (77%) rename {esd => dedal}/commands/command_sequence.py (86%) rename {esd => dedal}/commands/command_sequence_builder.py (91%) rename {esd => dedal}/commands/command_sequence_factory.py (90%) rename {esd => dedal}/commands/generic_shell_command.py (97%) rename {esd => dedal}/commands/preconfigured_command_enum.py (100%) rename {esd => dedal}/commands/shell_command_factory.py (89%) rename {esd => dedal}/commands/spack_command_sequence_factory.py (98%) rename {esd => dedal}/tests/spack_from_scratch_test.py (95%) rename {esd => dedal}/tests/spack_install_test.py (76%) rename {esd => dedal}/tests/unit_tests/test_bash_command_executor.py (84%) rename {esd => dedal}/tests/unit_tests/test_build_cache_manager.py (91%) rename {esd => dedal}/tests/unit_tests/test_command.py (96%) rename {esd => dedal}/tests/unit_tests/test_command_enum.py (94%) rename {esd => dedal}/tests/unit_tests/test_command_runner.py (87%) rename {esd => dedal}/tests/unit_tests/test_command_sequence.py (80%) rename {esd => dedal}/tests/unit_tests/test_command_sequence_builder.py (91%) rename {esd => dedal}/tests/unit_tests/test_command_sequence_factory.py (82%) rename {esd => dedal}/tests/unit_tests/test_generic_shell_command.py (95%) rename {esd => dedal}/tests/unit_tests/test_preconfigured_command_enum.py (93%) rename {esd => dedal}/tests/unit_tests/test_shell_command_factory.py (94%) rename {esd => dedal}/tests/unit_tests/test_spack_command_sequence_factory.py (96%) rename {esd => dedal}/tests/unit_tests/test_spack_operation.py (95%) rename {esd => dedal}/tests/unit_tests/test_spack_operation_use_cache.py (88%) delete mode 100644 esd/configuration/SpackConfig.py delete mode 100644 esd/configuration/__init__.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/tests/testing_variables.py delete mode 100644 esd/tests/utils_test.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/.gitlab-ci.yml b/.gitlab-ci.yml index b248ae04..c164c870 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -22,7 +22,7 @@ build-wheel: expire_in: 1 week -testing-pytest: +testing-pytest-coverage: stage: test tags: - docker-runner @@ -30,8 +30,8 @@ testing-pytest: script: - chmod +x dedal/utils/bootstrap.sh - ./dedal/utils/bootstrap.sh - - pip install e .[tests,dev] - - pytest ./dedal/tests/ -s --junitxml=test-results.xml + - pip install e .[tests] + - coverage run -m pytest -s --tb=short tests && coverage html -i -d htmlcov artifacts: when: always reports: diff --git a/MANIFEST.ini b/MANIFEST.ini index d6809e09..e62be467 100644 --- a/MANIFEST.ini +++ b/MANIFEST.ini @@ -1,3 +1,3 @@ include README.md -recursive-include yashchiki/esd *.* \ No newline at end of file +recursive-include yashchiki/dedal *.* \ No newline at end of file diff --git a/dedal/build_cache/BuildCacheManager.py b/dedal/build_cache/BuildCacheManager.py index eed6a45d..f52acb8a 100644 --- a/dedal/build_cache/BuildCacheManager.py +++ b/dedal/build_cache/BuildCacheManager.py @@ -3,9 +3,10 @@ import os from os.path import join from pathlib import Path +import oras.client + 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): @@ -112,12 +113,12 @@ 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: + 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. @@ -148,14 +149,13 @@ class BuildCacheManager(BuildCacheManagerInterface): if not pgp_folders: self._logger.warning("No _pgp folder found in the build cache!") return None - self._log_warning_if_needed( + 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( + 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/esd/commands/__init__.py b/dedal/commands/__init__.py similarity index 100% rename from esd/commands/__init__.py rename to dedal/commands/__init__.py diff --git a/esd/commands/bash_command_executor.py b/dedal/commands/bash_command_executor.py similarity index 91% rename from esd/commands/bash_command_executor.py rename to dedal/commands/bash_command_executor.py index 1811b757..db464c67 100644 --- a/esd/commands/bash_command_executor.py +++ b/dedal/commands/bash_command_executor.py @@ -11,8 +11,8 @@ import os import subprocess from logging import Logger -from esd.commands.command import Command -from esd.commands.command_sequence import CommandSequence +from dedal.commands.command import Command +from dedal.commands.command_sequence import CommandSequence class BashCommandExecutor: @@ -91,3 +91,10 @@ class BashCommandExecutor: 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/esd/commands/command.py b/dedal/commands/command.py similarity index 100% rename from esd/commands/command.py rename to dedal/commands/command.py diff --git a/esd/commands/command_enum.py b/dedal/commands/command_enum.py similarity index 100% rename from esd/commands/command_enum.py rename to dedal/commands/command_enum.py diff --git a/esd/commands/command_registry.py b/dedal/commands/command_registry.py similarity index 98% rename from esd/commands/command_registry.py rename to dedal/commands/command_registry.py index ea2ab646..adaa3d6e 100644 --- a/esd/commands/command_registry.py +++ b/dedal/commands/command_registry.py @@ -7,7 +7,7 @@ # Created by: Murugan, Jithu <j.murugan@fz-juelich.de> # Created on: 2025-02-17 -from esd.commands.command_enum import CommandEnum +from dedal.commands.command_enum import CommandEnum class CommandRegistry: diff --git a/esd/commands/command_runner.py b/dedal/commands/command_runner.py similarity index 77% rename from esd/commands/command_runner.py rename to dedal/commands/command_runner.py index a9bd89a0..88ee46a8 100644 --- a/esd/commands/command_runner.py +++ b/dedal/commands/command_runner.py @@ -13,12 +13,12 @@ import logging from logging import Logger from typing import Callable, Any -from esd.commands.bash_command_executor import BashCommandExecutor -from esd.commands.command_enum import CommandEnum -from esd.commands.command_sequence import CommandSequence -from esd.commands.command_sequence_factory import CommandSequenceFactory -from esd.commands.preconfigured_command_enum import PreconfiguredCommandEnum -from esd.commands.spack_command_sequence_factory import SpackCommandSequenceFactory +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: @@ -43,8 +43,64 @@ class CommandRunner: 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: @@ -88,6 +144,7 @@ class CommandRunner: """ 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, diff --git a/esd/commands/command_sequence.py b/dedal/commands/command_sequence.py similarity index 86% rename from esd/commands/command_sequence.py rename to dedal/commands/command_sequence.py index d0a3b469..6115215a 100644 --- a/esd/commands/command_sequence.py +++ b/dedal/commands/command_sequence.py @@ -7,7 +7,7 @@ # Created by: Murugan, Jithu <j.murugan@fz-juelich.de> # Created on: 2025-02-17 -from esd.commands.command import Command +from dedal.commands.command import Command class CommandSequence(Command): @@ -45,3 +45,10 @@ class CommandSequence(Command): 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/esd/commands/command_sequence_builder.py b/dedal/commands/command_sequence_builder.py similarity index 91% rename from esd/commands/command_sequence_builder.py rename to dedal/commands/command_sequence_builder.py index 5f1d2c3f..bd355057 100644 --- a/esd/commands/command_sequence_builder.py +++ b/dedal/commands/command_sequence_builder.py @@ -8,10 +8,10 @@ # Created on: 2025-02-19 from __future__ import annotations -from esd.commands.command_enum import CommandEnum -from esd.commands.command_registry import CommandRegistry -from esd.commands.command_sequence import CommandSequence -from esd.commands.shell_command_factory import ShellCommandFactory +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: diff --git a/esd/commands/command_sequence_factory.py b/dedal/commands/command_sequence_factory.py similarity index 90% rename from esd/commands/command_sequence_factory.py rename to dedal/commands/command_sequence_factory.py index 96804660..1c187245 100644 --- a/esd/commands/command_sequence_factory.py +++ b/dedal/commands/command_sequence_factory.py @@ -7,13 +7,14 @@ # Created by: Murugan, Jithu <j.murugan@fz-juelich.de> # Created on: 2025-02-17 -from esd.commands.command_enum import CommandEnum -from esd.commands.command_sequence import CommandSequence -from esd.commands.command_sequence_builder import CommandSequenceBuilder +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: diff --git a/esd/commands/generic_shell_command.py b/dedal/commands/generic_shell_command.py similarity index 97% rename from esd/commands/generic_shell_command.py rename to dedal/commands/generic_shell_command.py index ea40079d..0a02b095 100644 --- a/esd/commands/generic_shell_command.py +++ b/dedal/commands/generic_shell_command.py @@ -7,7 +7,7 @@ # Created by: Murugan, Jithu <j.murugan@fz-juelich.de> # Created on: 2025-02-17 -from esd.commands.command import Command +from dedal.commands.command import Command class GenericShellCommand(Command): @@ -16,6 +16,7 @@ class GenericShellCommand(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. diff --git a/esd/commands/preconfigured_command_enum.py b/dedal/commands/preconfigured_command_enum.py similarity index 100% rename from esd/commands/preconfigured_command_enum.py rename to dedal/commands/preconfigured_command_enum.py diff --git a/esd/commands/shell_command_factory.py b/dedal/commands/shell_command_factory.py similarity index 89% rename from esd/commands/shell_command_factory.py rename to dedal/commands/shell_command_factory.py index 99baec39..e63a456e 100644 --- a/esd/commands/shell_command_factory.py +++ b/dedal/commands/shell_command_factory.py @@ -7,8 +7,8 @@ # Created by: Murugan, Jithu <j.murugan@fz-juelich.de> # Created on: 2025-02-17 -from esd.commands.command import Command -from esd.commands.generic_shell_command import GenericShellCommand +from dedal.commands.command import Command +from dedal.commands.generic_shell_command import GenericShellCommand class ShellCommandFactory: diff --git a/esd/commands/spack_command_sequence_factory.py b/dedal/commands/spack_command_sequence_factory.py similarity index 98% rename from esd/commands/spack_command_sequence_factory.py rename to dedal/commands/spack_command_sequence_factory.py index e8d7e522..ce7afac3 100644 --- a/esd/commands/spack_command_sequence_factory.py +++ b/dedal/commands/spack_command_sequence_factory.py @@ -7,9 +7,9 @@ # Created by: Murugan, Jithu <j.murugan@fz-juelich.de> # Created on: 2025-02-17 -from esd.commands.command_enum import CommandEnum -from esd.commands.command_sequence import CommandSequence -from esd.commands.command_sequence_builder import CommandSequenceBuilder +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: diff --git a/dedal/spack_factory/SpackOperation.py b/dedal/spack_factory/SpackOperation.py index ecfbb8e5..d37a5e08 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 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: @@ -36,6 +39,7 @@ class SpackOperation: 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.command_runner = CommandRunner() def create_fetch_spack_environment(self): if self.spack_config.env.git_path: @@ -109,9 +113,7 @@ class SpackOperation: check=True, capture_output=True, text=True, logger=self.logger, info_msg=f'Checking if environment {self.spack_config.env.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): @@ -159,7 +161,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) @@ -176,26 +178,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'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 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 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/SpackOperationUseCache.py b/dedal/spack_factory/SpackOperationUseCache.py index efb9af76..a05b9c40 100644 --- a/dedal/spack_factory/SpackOperationUseCache.py +++ b/dedal/spack_factory/SpackOperationUseCache.py @@ -1,8 +1,10 @@ import os + from dedal.build_cache.BuildCacheManager import BuildCacheManager +from dedal.configuration.SpackConfig import SpackConfig +from dedal.error_handling.exceptions import NoSpackEnvironmentException from dedal.logger.logger_builder import get_logger from dedal.spack_factory.SpackOperation import SpackOperation -from dedal.configuration.SpackConfig import SpackConfig class SpackOperationUseCache(SpackOperation): @@ -25,8 +27,28 @@ class SpackOperationUseCache(SpackOperation): cache_version=cache_version_build) def setup_spack_env(self): + """Sets up the Spack environment using cached data. + + Downloads the build cache, trusts the cached public key (if available), + and adds the build cache as a local mirror. + """ 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(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 def concretize_spack_env(self, force=True): pass diff --git a/esd/tests/spack_from_scratch_test.py b/dedal/tests/spack_from_scratch_test.py similarity index 95% rename from esd/tests/spack_from_scratch_test.py rename to dedal/tests/spack_from_scratch_test.py index cdc405e7..2fec80f7 100644 --- a/esd/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/esd/tests/spack_install_test.py b/dedal/tests/spack_install_test.py similarity index 76% rename from esd/tests/spack_install_test.py rename to dedal/tests/spack_install_test.py index 28f8268e..564d5c6a 100644 --- a/esd/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/esd/tests/unit_tests/test_bash_command_executor.py b/dedal/tests/unit_tests/test_bash_command_executor.py similarity index 84% rename from esd/tests/unit_tests/test_bash_command_executor.py rename to dedal/tests/unit_tests/test_bash_command_executor.py index 70633fa2..f3624960 100644 --- a/esd/tests/unit_tests/test_bash_command_executor.py +++ b/dedal/tests/unit_tests/test_bash_command_executor.py @@ -11,8 +11,8 @@ from unittest.mock import patch import pytest -from esd.commands.bash_command_executor import BashCommandExecutor -from esd.commands.command import Command +from dedal.commands.bash_command_executor import BashCommandExecutor +from dedal.commands.command import Command class MockCommand(Command): @@ -34,8 +34,8 @@ class TestBashCommandExecutor: def test_init_success_path(self, mocker, test_id, os_name, expected_bash_command): # Arrange original_os = os.name - mock_get_logger = mocker.patch("esd.commands.bash_command_executor.logging.getLogger") - mocker.patch("esd.commands.bash_command_executor.os.name", os_name) + mock_get_logger = mocker.patch("dedal.commands.bash_command_executor.logging.getLogger") + mocker.patch("dedal.commands.bash_command_executor.os.name", os_name) # mock_os_name.return_value = os_name # Act @@ -44,7 +44,7 @@ class TestBashCommandExecutor: # Assert assert executor.bash_command == expected_bash_command - mock_get_logger.assert_called_once_with('esd.commands.bash_command_executor') + 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)] @@ -76,7 +76,7 @@ class TestBashCommandExecutor: # Assert assert str(except_info.value) == "Invalid command type. Use Command." - @patch("esd.commands.bash_command_executor.os.name", "unknown") + @patch("dedal.commands.bash_command_executor.os.name", "unknown") def test_init_unknown_os(self): # Act @@ -94,7 +94,7 @@ class TestBashCommandExecutor: ], ) - @patch("esd.commands.bash_command_executor.subprocess.run") + @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() @@ -114,7 +114,7 @@ class TestBashCommandExecutor: [mocker.call("Successfully executed command sequence, output: %s", mock_subprocess_run.return_value.stdout)]) - @patch("esd.commands.bash_command_executor.subprocess.run", + @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 @@ -131,12 +131,12 @@ class TestBashCommandExecutor: mock_subprocess_run.assert_called_once_with(['bash', '-c', 'some_command'], capture_output=True, text=True, check=True, timeout=172800) - @patch("esd.commands.bash_command_executor.subprocess.run", + @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 original_os = os.name - mocker.patch("esd.commands.bash_command_executor.os.name", "nt") + mocker.patch("dedal.commands.bash_command_executor.os.name", "nt") executor = BashCommandExecutor() executor.add_command(MockCommand("failing_command")) @@ -170,8 +170,8 @@ class TestBashCommandExecutor: def test_execute_other_errors(self, test_id, exception, expected_error_message, mocker): # Arrange original_os = os.name - mocker.patch("esd.commands.bash_command_executor.os.name", "nt") - with patch("esd.commands.bash_command_executor.subprocess.run", side_effect=exception) as mock_subprocess_run: + 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")) @@ -197,12 +197,12 @@ class TestBashCommandExecutor: # Assert assert str(except_info.value) == "No commands to execute." - @patch("esd.commands.bash_command_executor.subprocess.run") + @patch("dedal.commands.bash_command_executor.subprocess.run") def test_execute_happy_path_nt(self, mock_subprocess_run, mocker): # Arrange original_os = os.name - mocker.patch("esd.commands.bash_command_executor.os.name", "nt") + 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" @@ -220,7 +220,7 @@ class TestBashCommandExecutor: # Cleanup os.name = original_os - @patch("esd.commands.bash_command_executor.os.name", "unknown") + @patch("dedal.commands.bash_command_executor.os.name", "unknown") def test_execute_unknown_os(self): # Arrange executor = BashCommandExecutor() @@ -230,3 +230,17 @@ class TestBashCommandExecutor: assert executor.execute() == (None, "Error: Bash Command: ['undefined'] not found: [WinError 2] The system cannot " 'find the file specified') + + 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/esd/tests/unit_tests/test_build_cache_manager.py b/dedal/tests/unit_tests/test_build_cache_manager.py similarity index 91% rename from esd/tests/unit_tests/test_build_cache_manager.py rename to dedal/tests/unit_tests/test_build_cache_manager.py index 687570eb..29054b1a 100644 --- a/esd/tests/unit_tests/test_build_cache_manager.py +++ b/dedal/tests/unit_tests/test_build_cache_manager.py @@ -8,14 +8,14 @@ import pytest from _pytest.fixtures import fixture -from esd.build_cache.BuildCacheManager import BuildCacheManager +from dedal.build_cache.BuildCacheManager import BuildCacheManager class TestBuildCacheManager: @fixture(scope="function") def mock_build_cache_manager(self, mocker): - mocker.patch("esd.build_cache.BuildCacheManager.get_logger") + 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): @@ -61,8 +61,8 @@ class TestBuildCacheManager: # Assert assert result == str(build_cache_dir / "project0" / "_pgp" / "key0.pub") - log = (expected_log_message, *pgp_folders, pgp_folders[0]) if test_id == "more_than_one_gpg_folder" else ( - expected_log_message, *key_files, key_files[0]) + log = (expected_log_message, pgp_folders, pgp_folders[0]) if test_id == "more_than_one_gpg_folder" else ( + expected_log_message, key_files, key_files[0]) mock_build_cache_manager._logger.warning.assert_called_once_with(*log) @pytest.mark.parametrize("build_cache_dir, expected_log_message", [ @@ -133,10 +133,10 @@ class TestBuildCacheManager: warn_message = "test message" # Act - mock_build_cache_manager.log_warning_if_needed(warn_message, items) + 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]) + mock_build_cache_manager._logger.warning.assert_called_once_with(warn_message, items, items[0]) @pytest.mark.parametrize("items", [ [], @@ -149,7 +149,7 @@ class TestBuildCacheManager: warn_message = "test message" # Act - mock_build_cache_manager.log_warning_if_needed(warn_message, items) + 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/esd/tests/unit_tests/test_command.py b/dedal/tests/unit_tests/test_command.py similarity index 96% rename from esd/tests/unit_tests/test_command.py rename to dedal/tests/unit_tests/test_command.py index 8f957a35..3c864041 100644 --- a/esd/tests/unit_tests/test_command.py +++ b/dedal/tests/unit_tests/test_command.py @@ -8,7 +8,7 @@ import pytest -from esd.commands.command import Command +from dedal.commands.command import Command class ConcreteCommand(Command): def __init__(self, return_value: str): diff --git a/esd/tests/unit_tests/test_command_enum.py b/dedal/tests/unit_tests/test_command_enum.py similarity index 94% rename from esd/tests/unit_tests/test_command_enum.py rename to dedal/tests/unit_tests/test_command_enum.py index 57c4ec34..f29e2b4c 100644 --- a/esd/tests/unit_tests/test_command_enum.py +++ b/dedal/tests/unit_tests/test_command_enum.py @@ -8,7 +8,7 @@ import pytest -from esd.commands.command_enum import CommandEnum +from dedal.commands.command_enum import CommandEnum class TestCommandEnum: diff --git a/esd/tests/unit_tests/test_command_runner.py b/dedal/tests/unit_tests/test_command_runner.py similarity index 87% rename from esd/tests/unit_tests/test_command_runner.py rename to dedal/tests/unit_tests/test_command_runner.py index 24ec4a93..ac30fa08 100644 --- a/esd/tests/unit_tests/test_command_runner.py +++ b/dedal/tests/unit_tests/test_command_runner.py @@ -8,10 +8,10 @@ import pytest -from esd.commands.command_enum import CommandEnum -from esd.commands.command_runner import CommandRunner -from esd.commands.command_sequence import CommandSequence -from esd.commands.preconfigured_command_enum import PreconfiguredCommandEnum +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): @@ -25,8 +25,8 @@ class MockCommandSequence(CommandSequence): class TestCommandRunner: @pytest.fixture(scope="function") def mock_command_runner(self, mocker): - mocker.patch("esd.commands.command_runner.BashCommandExecutor") - mocker.patch("esd.commands.command_runner.logging.getLogger") + mocker.patch("dedal.commands.command_runner.BashCommandExecutor") + mocker.patch("dedal.commands.command_runner.logging.getLogger") return CommandRunner() @pytest.mark.parametrize( @@ -51,6 +51,8 @@ class TestCommandRunner: # 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", @@ -111,7 +113,7 @@ class TestCommandRunner: # Arrange mock_command_runner.execute_command = mocker.MagicMock(return_value=expected_result) mock_create_custom_command_sequence = mocker.patch( - "esd.commands.command_runner.CommandSequenceFactory.create_custom_command_sequence") + "dedal.commands.command_runner.CommandSequenceFactory.create_custom_command_sequence") mock_create_custom_command_sequence.return_value = MockCommandSequence("mock_command") # Act diff --git a/esd/tests/unit_tests/test_command_sequence.py b/dedal/tests/unit_tests/test_command_sequence.py similarity index 80% rename from esd/tests/unit_tests/test_command_sequence.py rename to dedal/tests/unit_tests/test_command_sequence.py index 8b68e107..e663c06b 100644 --- a/esd/tests/unit_tests/test_command_sequence.py +++ b/dedal/tests/unit_tests/test_command_sequence.py @@ -5,11 +5,12 @@ # 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 esd.commands.command import Command -from esd.commands.command_sequence import CommandSequence +from dedal.commands.command import Command +from dedal.commands.command_sequence import CommandSequence class MockCommand(Command): @@ -30,7 +31,8 @@ class TestCommandSequence: ("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"), + ("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"), @@ -89,3 +91,18 @@ class TestCommandSequence: # 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/esd/tests/unit_tests/test_command_sequence_builder.py b/dedal/tests/unit_tests/test_command_sequence_builder.py similarity index 91% rename from esd/tests/unit_tests/test_command_sequence_builder.py rename to dedal/tests/unit_tests/test_command_sequence_builder.py index 6c4062a4..e4004255 100644 --- a/esd/tests/unit_tests/test_command_sequence_builder.py +++ b/dedal/tests/unit_tests/test_command_sequence_builder.py @@ -10,12 +10,12 @@ from unittest.mock import Mock, patch import pytest -from esd.commands.command import Command -from esd.commands.command_enum import CommandEnum -from esd.commands.command_registry import CommandRegistry -from esd.commands.command_sequence import CommandSequence -from esd.commands.command_sequence_builder import CommandSequenceBuilder -from esd.commands.shell_command_factory import ShellCommandFactory +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: diff --git a/esd/tests/unit_tests/test_command_sequence_factory.py b/dedal/tests/unit_tests/test_command_sequence_factory.py similarity index 82% rename from esd/tests/unit_tests/test_command_sequence_factory.py rename to dedal/tests/unit_tests/test_command_sequence_factory.py index 7515f14a..7048690a 100644 --- a/esd/tests/unit_tests/test_command_sequence_factory.py +++ b/dedal/tests/unit_tests/test_command_sequence_factory.py @@ -10,10 +10,10 @@ from unittest.mock import Mock, patch import pytest -from esd.commands.command_enum import CommandEnum -from esd.commands.command_sequence import CommandSequence -from esd.commands.command_sequence_builder import CommandSequenceBuilder -from esd.commands.command_sequence_factory import CommandSequenceFactory +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: @@ -37,7 +37,7 @@ class TestCommandSequenceFactory: mock_builder.add_generic_command = mock_add_generic_command mock_add_generic_command.return_value = mock_builder - with patch("esd.commands.command_sequence_factory.CommandSequenceBuilder", 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) diff --git a/esd/tests/unit_tests/test_generic_shell_command.py b/dedal/tests/unit_tests/test_generic_shell_command.py similarity index 95% rename from esd/tests/unit_tests/test_generic_shell_command.py rename to dedal/tests/unit_tests/test_generic_shell_command.py index e9287e04..7ed779ae 100644 --- a/esd/tests/unit_tests/test_generic_shell_command.py +++ b/dedal/tests/unit_tests/test_generic_shell_command.py @@ -8,8 +8,8 @@ import pytest -from esd.commands.command import Command -from esd.commands.generic_shell_command import GenericShellCommand +from dedal.commands.command import Command +from dedal.commands.generic_shell_command import GenericShellCommand class TestGenericShellCommand: diff --git a/esd/tests/unit_tests/test_preconfigured_command_enum.py b/dedal/tests/unit_tests/test_preconfigured_command_enum.py similarity index 93% rename from esd/tests/unit_tests/test_preconfigured_command_enum.py rename to dedal/tests/unit_tests/test_preconfigured_command_enum.py index 02539d55..820c4d41 100644 --- a/esd/tests/unit_tests/test_preconfigured_command_enum.py +++ b/dedal/tests/unit_tests/test_preconfigured_command_enum.py @@ -8,7 +8,7 @@ import pytest -from esd.commands.preconfigured_command_enum import PreconfiguredCommandEnum +from dedal.commands.preconfigured_command_enum import PreconfiguredCommandEnum class TestPreconfiguredCommandEnum: diff --git a/esd/tests/unit_tests/test_shell_command_factory.py b/dedal/tests/unit_tests/test_shell_command_factory.py similarity index 94% rename from esd/tests/unit_tests/test_shell_command_factory.py rename to dedal/tests/unit_tests/test_shell_command_factory.py index f2f95b90..bfd2b2db 100644 --- a/esd/tests/unit_tests/test_shell_command_factory.py +++ b/dedal/tests/unit_tests/test_shell_command_factory.py @@ -8,8 +8,8 @@ import pytest -from esd.commands.generic_shell_command import GenericShellCommand -from esd.commands.shell_command_factory import ShellCommandFactory +from dedal.commands.generic_shell_command import GenericShellCommand +from dedal.commands.shell_command_factory import ShellCommandFactory class TestShellCommandFactory: diff --git a/esd/tests/unit_tests/test_spack_command_sequence_factory.py b/dedal/tests/unit_tests/test_spack_command_sequence_factory.py similarity index 96% rename from esd/tests/unit_tests/test_spack_command_sequence_factory.py rename to dedal/tests/unit_tests/test_spack_command_sequence_factory.py index 9b6bc572..0ffebcf3 100644 --- a/esd/tests/unit_tests/test_spack_command_sequence_factory.py +++ b/dedal/tests/unit_tests/test_spack_command_sequence_factory.py @@ -8,10 +8,10 @@ import pytest -from esd.commands.command_enum import CommandEnum -from esd.commands.command_sequence import CommandSequence -from esd.commands.command_sequence_builder import CommandSequenceBuilder -from esd.commands.spack_command_sequence_factory import SpackCommandSequenceFactory +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: diff --git a/esd/tests/unit_tests/test_spack_operation.py b/dedal/tests/unit_tests/test_spack_operation.py similarity index 95% rename from esd/tests/unit_tests/test_spack_operation.py rename to dedal/tests/unit_tests/test_spack_operation.py index 4e2c520e..f053459c 100644 --- a/esd/tests/unit_tests/test_spack_operation.py +++ b/dedal/tests/unit_tests/test_spack_operation.py @@ -9,10 +9,10 @@ import pytest from _pytest.fixtures import fixture -from esd.build_cache.BuildCacheManager import BuildCacheManager -from esd.commands.preconfigured_command_enum import PreconfiguredCommandEnum -from esd.error_handling.exceptions import NoSpackEnvironmentException -from esd.spack_factory.SpackOperation import SpackOperation +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: @@ -20,8 +20,8 @@ class TestSpackOperationAddMirrorWithComposite: @fixture def mock_spack_operation(self, mocker): mocker.resetall() - mocker.patch("esd.spack_factory.SpackOperation.CommandRunner") - mocker.patch("esd.spack_factory.SpackOperation.get_logger") + 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 diff --git a/esd/tests/unit_tests/test_spack_operation_use_cache.py b/dedal/tests/unit_tests/test_spack_operation_use_cache.py similarity index 88% rename from esd/tests/unit_tests/test_spack_operation_use_cache.py rename to dedal/tests/unit_tests/test_spack_operation_use_cache.py index fd4e2778..e6e96f46 100644 --- a/esd/tests/unit_tests/test_spack_operation_use_cache.py +++ b/dedal/tests/unit_tests/test_spack_operation_use_cache.py @@ -5,20 +5,21 @@ # Description: Brief description of the file. # Created by: Murugan, Jithu <j.murugan@fz-juelich.de> # Created on: 2025-02-20 - +import logging from pathlib import Path import pytest -from esd.error_handling.exceptions import NoSpackEnvironmentException -from esd.spack_factory.SpackOperationUseCache import SpackOperationUseCache +from dedal.commands.command_runner import CommandRunner +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("esd.spack_factory.SpackOperationUseCache.super") + super_mock = mocker.patch("dedal.spack_factory.SpackOperationUseCache.super") super_mock.return_value.setup_spack_env = mocker.MagicMock() - mocker.patch("esd.spack_factory.SpackOperationUseCache.BuildCacheManager") + 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() @@ -34,7 +35,7 @@ class TestSpackOperationUseCache: ("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("esd.spack_factory.SpackOperationUseCache.super") + 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() diff --git a/dedal/utils/bootstrap.sh b/dedal/utils/bootstrap.sh index 9b7d0131..58be94b9 100644 --- a/dedal/utils/bootstrap.sh +++ b/dedal/utils/bootstrap.sh @@ -1,4 +1,4 @@ -# Minimal prerequisites for installing the esd_library +# Minimal prerequisites for installing the dedal_library # pip must be installed on the OS echo "Bootstrapping..." apt update diff --git a/esd/configuration/SpackConfig.py b/esd/configuration/SpackConfig.py deleted file mode 100644 index 26c6617f..00000000 --- a/esd/configuration/SpackConfig.py +++ /dev/null @@ -1,22 +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 - self.repos = [] if repos is None else 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/configuration/__init__.py b/esd/configuration/__init__.py deleted file mode 100644 index e69de29b..00000000 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 0256f886..00000000 --- a/esd/error_handling/exceptions.py +++ /dev/null @@ -1,41 +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 - """ - -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/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 5a026283..00000000 --- a/esd/spack_factory/SpackOperation.py +++ /dev/null @@ -1,346 +0,0 @@ -import os -import re -import subprocess -from pathlib import Path - -from esd.commands.command_runner import CommandRunner -from esd.commands.preconfigured_command_enum import PreconfiguredCommandEnum -from esd.configuration.SpackConfig import SpackConfig -from esd.error_handling.exceptions import BashCommandException, NoSpackEnvironmentException, \ - SpackInstallPackagesException, SpackConcertizeException, SpackMirrorException, SpackGpgException -from esd.logger.logger_builder import get_logger -from esd.tests.testing_variables import SPACK_VERSION -from esd.utils.utils import run_command, git_clone_repo, log_command, set_bashrc_variable -from esd.wrapper.spack_wrapper import check_spack_env - - -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. - - 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}' - self.command_runner = CommandRunner() - - 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') - 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", - 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'{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}', - exception=SpackConcertizeException) - - def create_gpg_keys(self, gpg_name='example', gpg_mail='example@example.com'): - run_command("bash", "-c", - f'source {self.spack_setup_script} && spack gpg init && spack gpg create {gpg_name} {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) - - 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 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", - 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' - 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 b6e7846c..00000000 --- a/esd/spack_factory/SpackOperationUseCache.py +++ /dev/null @@ -1,57 +0,0 @@ -import os - -from esd.build_cache.BuildCacheManager import BuildCacheManager -from esd.configuration.SpackConfig import SpackConfig -from esd.error_handling.exceptions import NoSpackEnvironmentException -from esd.logger.logger_builder import get_logger -from esd.spack_factory.SpackOperation import SpackOperation - - -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'): - 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): - """Sets up the Spack environment using cached data. - - Downloads the build cache, trusts the cached public key (if available), - and adds the build cache as a local mirror. - """ - 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(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 - - - - - 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/tests/testing_variables.py b/esd/tests/testing_variables.py deleted file mode 100644 index ab95bfa1..00000000 --- a/esd/tests/testing_variables.py +++ /dev/null @@ -1,6 +0,0 @@ -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/esd/tests/utils_test.py b/esd/tests/utils_test.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 e69de29b..00000000 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 62d0146c..6aff0982 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,10 +22,11 @@ dependencies = [ [tool.setuptools.data-files] "dedal" = ["dedal/logger/logging.conf"] -[options.extras_require] +[project.optional-dependencies] test = [ "pytest", "pytest-mock", "pytest-ordering", "coverage" -] \ No newline at end of file +] +dev = ["mypy", "pylint", "black", "flake8"] \ No newline at end of file -- GitLab From af6ebf6fc909d6037d5860df08030099027ed94c Mon Sep 17 00:00:00 2001 From: Jithu Murugan <j.murugan@fz-juelich.de> Date: Fri, 21 Feb 2025 09:30:26 +0100 Subject: [PATCH 23/53] - Corrected the optional-dependencies name in .gitlab-ci.yml file which leads to the test failures. --- .gitlab-ci.yml | 2 +- pyproject.toml | 7 +------ 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index c164c870..20437823 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -30,7 +30,7 @@ testing-pytest-coverage: script: - chmod +x dedal/utils/bootstrap.sh - ./dedal/utils/bootstrap.sh - - pip install e .[tests] + - pip install -e .[test] - coverage run -m pytest -s --tb=short tests && coverage html -i -d htmlcov artifacts: when: always diff --git a/pyproject.toml b/pyproject.toml index 6aff0982..c8d849e0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,10 +23,5 @@ dependencies = [ "dedal" = ["dedal/logger/logging.conf"] [project.optional-dependencies] -test = [ - "pytest", - "pytest-mock", - "pytest-ordering", - "coverage" -] +test = ["pytest", "pytest-mock", "pytest-ordering", "coverage"] dev = ["mypy", "pylint", "black", "flake8"] \ No newline at end of file -- GitLab From a40d1ac1f030dc0874f740b82d79ebad7cbeb697 Mon Sep 17 00:00:00 2001 From: Jithu Murugan <j.murugan@fz-juelich.de> Date: Fri, 21 Feb 2025 09:38:40 +0100 Subject: [PATCH 24/53] - Corrected the path of tests folder in .gitlab-ci.yml file. --- .gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 20437823..30281ccc 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -31,7 +31,7 @@ testing-pytest-coverage: - chmod +x dedal/utils/bootstrap.sh - ./dedal/utils/bootstrap.sh - pip install -e .[test] - - coverage run -m pytest -s --tb=short tests && coverage html -i -d htmlcov + - coverage run -m pytest -s --tb=short ./dedal/tests/ && coverage html -i -d htmlcov artifacts: when: always reports: -- GitLab From e0beff0d5972ca9b2f4ddcf65aa81d965430ef22 Mon Sep 17 00:00:00 2001 From: adrianciu <adrianciu25@gmail.com> Date: Fri, 21 Feb 2025 10:49:01 +0200 Subject: [PATCH 25/53] esd-spack-installation: README --- .env | 9 ++++ README.md | 52 +++++++++++++++++-- dedal/spack_factory/SpackOperationUseCache.py | 4 +- 3 files changed, 60 insertions(+), 5 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..82058f34 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,50 @@ -# ~~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```. +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'), -- GitLab From 53ed5b0e1d60656c639c878c06d2f65428e42156 Mon Sep 17 00:00:00 2001 From: adrianciu <adrianciu25@gmail.com> Date: Fri, 21 Feb 2025 10:49:01 +0200 Subject: [PATCH 26/53] esd-spack-installation: README --- .env | 9 ++++ README.md | 52 +++++++++++++++++-- dedal/spack_factory/SpackOperationUseCache.py | 4 +- dedal/tests/testing_variables.py | 2 +- 4 files changed, 61 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..82058f34 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,50 @@ -# ~~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```. +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 8b660f4232da640cf8523237d662f910cdd1e42f Mon Sep 17 00:00:00 2001 From: Jithu Murugan <j.murugan@fz-juelich.de> Date: Fri, 21 Feb 2025 10:55:49 +0100 Subject: [PATCH 27/53] - Disabled coverage to see if the tests succeed alone --- .gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 30281ccc..3adf36d3 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -31,7 +31,7 @@ testing-pytest-coverage: - chmod +x dedal/utils/bootstrap.sh - ./dedal/utils/bootstrap.sh - pip install -e .[test] - - coverage run -m pytest -s --tb=short ./dedal/tests/ && coverage html -i -d htmlcov + - pytest ./dedal/tests/ -s --junitxml=test-results.xml artifacts: when: always reports: -- GitLab From ddddaa447bb1e9e0d5b679939084384f14be2560 Mon Sep 17 00:00:00 2001 From: Jithu Murugan <j.murugan@fz-juelich.de> Date: Fri, 21 Feb 2025 14:37:58 +0100 Subject: [PATCH 28/53] - Corrected the docstring for the setup_spack_env function along with the return type. --- dedal/spack_factory/SpackOperationUseCache.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/dedal/spack_factory/SpackOperationUseCache.py b/dedal/spack_factory/SpackOperationUseCache.py index 1105a6e4..6575ba99 100644 --- a/dedal/spack_factory/SpackOperationUseCache.py +++ b/dedal/spack_factory/SpackOperationUseCache.py @@ -26,11 +26,15 @@ class SpackOperationUseCache(SpackOperation): os.environ.get('BUILDCACHE_OCI_PASSWORD'), cache_version=cache_version_build) - def setup_spack_env(self): - """Sets up the Spack environment using cached data. + def setup_spack_env(self) -> None: + """Set up the spack environment for using the cache. - Downloads the build cache, trusts the cached public key (if available), - and adds the build cache as a local mirror. + 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. + NoSpackEnvironmentException: If the spack environment is not set up. """ super().setup_spack_env() try: -- GitLab From 5e4b78f9f55e452ab796ef5d8a6a08f83f124924 Mon Sep 17 00:00:00 2001 From: Jithu Murugan <j.murugan@fz-juelich.de> Date: Fri, 21 Feb 2025 15:55:41 +0100 Subject: [PATCH 29/53] - Corrected the failing unit tests and minor refactorings. --- dedal/commands/bash_command_executor.py | 4 +- dedal/spack_factory/SpackOperationUseCache.py | 4 +- .../unit_tests/test_bash_command_executor.py | 42 +++++++------------ .../unit_tests/test_build_cache_manager.py | 4 +- 4 files changed, 23 insertions(+), 31 deletions(-) diff --git a/dedal/commands/bash_command_executor.py b/dedal/commands/bash_command_executor.py index db464c67..aef9c576 100644 --- a/dedal/commands/bash_command_executor.py +++ b/dedal/commands/bash_command_executor.py @@ -7,9 +7,9 @@ # Created by: Murugan, Jithu <j.murugan@fz-juelich.de> # Created on: 2025-02-17 import logging -import os 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 @@ -29,7 +29,7 @@ class BashCommandExecutor: "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"]) + self.bash_command: list[str] = command_map.get(os_name, ["undefined"]) def add_command(self, command: Command) -> None: """Adds a command to the sequence. diff --git a/dedal/spack_factory/SpackOperationUseCache.py b/dedal/spack_factory/SpackOperationUseCache.py index 6575ba99..ce247ccd 100644 --- a/dedal/spack_factory/SpackOperationUseCache.py +++ b/dedal/spack_factory/SpackOperationUseCache.py @@ -33,14 +33,14 @@ class SpackOperationUseCache(SpackOperation): and adds the build cache mirror. Raises: - ValueError: If there is an issue with the build cache setup. + 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(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!") diff --git a/dedal/tests/unit_tests/test_bash_command_executor.py b/dedal/tests/unit_tests/test_bash_command_executor.py index f3624960..368d5136 100644 --- a/dedal/tests/unit_tests/test_bash_command_executor.py +++ b/dedal/tests/unit_tests/test_bash_command_executor.py @@ -33,14 +33,11 @@ class TestBashCommandExecutor: ) def test_init_success_path(self, mocker, test_id, os_name, expected_bash_command): # Arrange - original_os = os.name mock_get_logger = mocker.patch("dedal.commands.bash_command_executor.logging.getLogger") - mocker.patch("dedal.commands.bash_command_executor.os.name", os_name) - # mock_os_name.return_value = os_name + mocker.patch("dedal.commands.bash_command_executor.os_name", os_name) # Act executor = BashCommandExecutor() - os.name = original_os # Reset the os.name to the original value # Assert assert executor.bash_command == expected_bash_command @@ -76,7 +73,7 @@ class TestBashCommandExecutor: # Assert assert str(except_info.value) == "Invalid command type. Use Command." - @patch("dedal.commands.bash_command_executor.os.name", "unknown") + @patch("dedal.commands.bash_command_executor.os_name", "unknown") def test_init_unknown_os(self): # Act @@ -135,8 +132,7 @@ class TestBashCommandExecutor: side_effect=subprocess.CalledProcessError(1, "some_command", stderr="Mock stderr")) def test_execute_called_process_error(self, mock_subprocess_run, mocker): # Arrange - original_os = os.name - mocker.patch("dedal.commands.bash_command_executor.os.name", "nt") + mocker.patch("dedal.commands.bash_command_executor.os_name", "nt") executor = BashCommandExecutor() executor.add_command(MockCommand("failing_command")) @@ -149,9 +145,6 @@ class TestBashCommandExecutor: mock_subprocess_run.assert_called_once_with(['wsl', 'bash', '-c', 'failing_command'], capture_output=True, text=True, check=True, timeout=172800) - # Cleanup - os.name = original_os - @pytest.mark.parametrize( "test_id, exception, expected_error_message", [ @@ -169,8 +162,7 @@ class TestBashCommandExecutor: ) def test_execute_other_errors(self, test_id, exception, expected_error_message, mocker): # Arrange - original_os = os.name - mocker.patch("dedal.commands.bash_command_executor.os.name", "nt") + 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")) @@ -184,9 +176,6 @@ class TestBashCommandExecutor: mock_subprocess_run.assert_called_once_with(['wsl', 'bash', '-c', 'some_command'], capture_output=True, text=True, check=True, timeout=172800) - # Cleanup - os.name = original_os - def test_execute_no_commands(self): # Arrange executor = BashCommandExecutor() @@ -197,12 +186,11 @@ class TestBashCommandExecutor: # 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 - original_os = os.name - - mocker.patch("dedal.commands.bash_command_executor.os.name", "nt") + 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" @@ -217,19 +205,21 @@ class TestBashCommandExecutor: mock_subprocess_run.assert_called_once_with(['wsl', 'bash', '-c', 'echo hello'], capture_output=True, text=True, check=True, timeout=172800) - # Cleanup - os.name = original_os - - @patch("dedal.commands.bash_command_executor.os.name", "unknown") - def test_execute_unknown_os(self): + def test_execute_unknown_os(self, mocker): # Arrange + errors = { + "posix": "Error: Bash Command: ['undefined'] not found: [Errno 2] No such file or directory", + "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, - "Error: Bash Command: ['undefined'] not found: [WinError 2] The system cannot " - 'find the file specified') + assert executor.execute() == (None, expected_error) def test_reset(self, mocker): # Test ID: reset diff --git a/dedal/tests/unit_tests/test_build_cache_manager.py b/dedal/tests/unit_tests/test_build_cache_manager.py index 29054b1a..2a80149b 100644 --- a/dedal/tests/unit_tests/test_build_cache_manager.py +++ b/dedal/tests/unit_tests/test_build_cache_manager.py @@ -60,7 +60,9 @@ class TestBuildCacheManager: result = mock_build_cache_manager.get_public_key_from_cache(str(build_cache_dir)) # Assert - assert result == str(build_cache_dir / "project0" / "_pgp" / "key0.pub") + 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")] log = (expected_log_message, pgp_folders, pgp_folders[0]) if test_id == "more_than_one_gpg_folder" else ( expected_log_message, key_files, key_files[0]) mock_build_cache_manager._logger.warning.assert_called_once_with(*log) -- GitLab From aa13e8eea921059e93c8be105f622ad86389f4ca Mon Sep 17 00:00:00 2001 From: Jithu Murugan <j.murugan@fz-juelich.de> Date: Fri, 21 Feb 2025 16:21:15 +0100 Subject: [PATCH 30/53] - Corrected the failing unit tests and minor refactorings. --- dedal/tests/unit_tests/test_build_cache_manager.py | 10 +++++++--- .../tests/unit_tests/test_spack_operation_use_cache.py | 4 +--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/dedal/tests/unit_tests/test_build_cache_manager.py b/dedal/tests/unit_tests/test_build_cache_manager.py index 2a80149b..af5690eb 100644 --- a/dedal/tests/unit_tests/test_build_cache_manager.py +++ b/dedal/tests/unit_tests/test_build_cache_manager.py @@ -60,12 +60,16 @@ class TestBuildCacheManager: 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")] - log = (expected_log_message, pgp_folders, pgp_folders[0]) if test_id == "more_than_one_gpg_folder" else ( - expected_log_message, key_files, key_files[0]) - mock_build_cache_manager._logger.warning.assert_called_once_with(*log) + 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!'), 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 e6e96f46..fe5d9da3 100644 --- a/dedal/tests/unit_tests/test_spack_operation_use_cache.py +++ b/dedal/tests/unit_tests/test_spack_operation_use_cache.py @@ -5,12 +5,10 @@ # Description: Brief description of the file. # Created by: Murugan, Jithu <j.murugan@fz-juelich.de> # Created on: 2025-02-20 -import logging from pathlib import Path import pytest -from dedal.commands.command_runner import CommandRunner from dedal.error_handling.exceptions import NoSpackEnvironmentException from dedal.spack_factory.SpackOperationUseCache import SpackOperationUseCache @@ -51,7 +49,7 @@ class TestSpackOperationUseCache: 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( - spack_operation_use_cache_mock.spack_config.buildcache_dir) + 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) -- GitLab From ffe9984e53a3519786539d14f029f31f4972dbee Mon Sep 17 00:00:00 2001 From: Jithu Murugan <j.murugan@fz-juelich.de> Date: Fri, 21 Feb 2025 16:23:00 +0100 Subject: [PATCH 31/53] - Corrected the failing unit tests and minor refactorings. --- dedal/tests/unit_tests/test_bash_command_executor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dedal/tests/unit_tests/test_bash_command_executor.py b/dedal/tests/unit_tests/test_bash_command_executor.py index 368d5136..e216cc0f 100644 --- a/dedal/tests/unit_tests/test_bash_command_executor.py +++ b/dedal/tests/unit_tests/test_bash_command_executor.py @@ -208,7 +208,7 @@ class TestBashCommandExecutor: def test_execute_unknown_os(self, mocker): # Arrange errors = { - "posix": "Error: Bash Command: ['undefined'] not found: [Errno 2] No such file or directory", + "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' } -- GitLab From 115df39ad547792d111015e5483fc5ff3d27f284 Mon Sep 17 00:00:00 2001 From: Jithu Murugan <j.murugan@fz-juelich.de> Date: Fri, 21 Feb 2025 16:40:37 +0100 Subject: [PATCH 32/53] - Enabled coverage calculation alongside test execution in the test bench. --- .gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 3adf36d3..8b5459c7 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -31,7 +31,7 @@ testing-pytest-coverage: - chmod +x dedal/utils/bootstrap.sh - ./dedal/utils/bootstrap.sh - pip install -e .[test] - - pytest ./dedal/tests/ -s --junitxml=test-results.xml + - coverage run -m pytest -s --tb=short --junitxml=test-results.xml ./dedal/tests/ && coverage html -i -d htmlcov artifacts: when: always reports: -- GitLab From 29f27392c2bc30f049759aea1c892989bc720bf3 Mon Sep 17 00:00:00 2001 From: Jithu Murugan <j.murugan@fz-juelich.de> Date: Mon, 24 Feb 2025 10:01:37 +0100 Subject: [PATCH 33/53] - Updated .gitlab-ci.yml to include new separate stages testing and also for the coverage. --- .gitlab-ci.yml | 52 +++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 45 insertions(+), 7 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 8b5459c7..7adc146c 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,6 +1,7 @@ stages: - test - build + - coverage_report variables: BUILD_ENV_DOCKER_IMAGE: docker-registry.ebrains.eu/esd/tmp:latest @@ -21,23 +22,60 @@ build-wheel: - dist/*.tar.gz expire_in: 1 week - -testing-pytest-coverage: +unit_tests: stage: test tags: - docker-runner image: ubuntu:22.04 + before_script: + - chmod +x dedal/utils/bootstrap.sh + - ./dedal/utils/bootstrap.sh + - pip install -e .[test] script: + - coverage run s --tb=short --junitxml=test-results.xml -m pytest ./dedal/tests/unit_tests + - coverage xml -o coverage_unit.xml + artifacts: + paths: + - coverage_unit.xml + expire_in: 1 week + +integration_tests: + stage: test + tags: + - docker-runner + image: ubuntu:22.04 + before_script: - chmod +x dedal/utils/bootstrap.sh - ./dedal/utils/bootstrap.sh - pip install -e .[test] - - coverage run -m pytest -s --tb=short --junitxml=test-results.xml ./dedal/tests/ && coverage html -i -d htmlcov + script: + - coverage run s --tb=short --junitxml=test-results.xml -m pytest ./dedal/tests/integration_tests + - coverage xml -o coverage_integration.xml + artifacts: + paths: + - coverage_integration.xml + expire_in: 1 week + +merge_coverage: + stage: coverage_report + tags: + - docker-runner + image: ubuntu:22.04 + before_script: + - pip install coverage + script: + - coverage combine coverage_unit.xml coverage_integration.xml + - coverage report + - coverage xml -o coverage.xml + - coverage html -d coverage_html artifacts: - when: always reports: - junit: test-results.xml + coverage_report: + coverage_format: cobertura + path: coverage.xml paths: - - test-results.xml - - .dedal.log + - coverage.xml + - coverage_html expire_in: 1 week + coverage: '/TOTAL.*?(\d+\%)$/' -- GitLab From ecdff0572d05bf9c714d41c662f2d8815fa7a29e Mon Sep 17 00:00:00 2001 From: Jithu Murugan <j.murugan@fz-juelich.de> Date: Mon, 24 Feb 2025 10:47:59 +0100 Subject: [PATCH 34/53] - Corrected the job sequence within the test stage. --- .gitlab-ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 7adc146c..f35ffbbd 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -51,6 +51,7 @@ integration_tests: script: - coverage run s --tb=short --junitxml=test-results.xml -m pytest ./dedal/tests/integration_tests - coverage xml -o coverage_integration.xml + needs: ["unit_tests"] artifacts: paths: - coverage_integration.xml -- GitLab From 5957334c899e542b76c7375657c2e9a902c95eee Mon Sep 17 00:00:00 2001 From: Jithu Murugan <j.murugan@fz-juelich.de> Date: Mon, 24 Feb 2025 11:18:00 +0100 Subject: [PATCH 35/53] - Corrected the failing tests. --- .gitlab-ci.yml | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index f35ffbbd..57460e74 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -32,10 +32,14 @@ unit_tests: - ./dedal/utils/bootstrap.sh - pip install -e .[test] script: - - coverage run s --tb=short --junitxml=test-results.xml -m pytest ./dedal/tests/unit_tests + - coverage run -m pytest -s --tb=short --junitxml=test-results.xml ./dedal/tests/unit_tests - coverage xml -o coverage_unit.xml artifacts: + when: always + reports: + junit: test-results.xml paths: + - test-results.xml - coverage_unit.xml expire_in: 1 week @@ -49,11 +53,15 @@ integration_tests: - ./dedal/utils/bootstrap.sh - pip install -e .[test] script: - - coverage run s --tb=short --junitxml=test-results.xml -m pytest ./dedal/tests/integration_tests + - coverage run -m pytest -s --tb=short --junitxml=test-results.xml ./dedal/tests/integration_tests - coverage xml -o coverage_integration.xml needs: ["unit_tests"] artifacts: + when: always + reports: + junit: test-results.xml paths: + - test-results.xml - coverage_integration.xml expire_in: 1 week -- GitLab From 15362bd9a03db9882576a9486a2ff9ffa3a99a7f Mon Sep 17 00:00:00 2001 From: Jithu Murugan <j.murugan@fz-juelich.de> Date: Mon, 24 Feb 2025 15:55:36 +0100 Subject: [PATCH 36/53] - Fixed the failing merge_coverage job by ensuring the required coverage package is installed. Moved the common before_script to the default section. - Added the test coverage badge to README.md. --- .gitlab-ci.yml | 15 +++++---------- README.md | 2 ++ 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 57460e74..003ac773 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -6,6 +6,11 @@ stages: 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 @@ -27,10 +32,6 @@ unit_tests: tags: - docker-runner image: ubuntu:22.04 - before_script: - - chmod +x dedal/utils/bootstrap.sh - - ./dedal/utils/bootstrap.sh - - pip install -e .[test] script: - coverage run -m pytest -s --tb=short --junitxml=test-results.xml ./dedal/tests/unit_tests - coverage xml -o coverage_unit.xml @@ -48,10 +49,6 @@ integration_tests: tags: - docker-runner image: ubuntu:22.04 - before_script: - - chmod +x dedal/utils/bootstrap.sh - - ./dedal/utils/bootstrap.sh - - pip install -e .[test] script: - coverage run -m pytest -s --tb=short --junitxml=test-results.xml ./dedal/tests/integration_tests - coverage xml -o coverage_integration.xml @@ -70,8 +67,6 @@ merge_coverage: tags: - docker-runner image: ubuntu:22.04 - before_script: - - pip install coverage script: - coverage combine coverage_unit.xml coverage_integration.xml - coverage report diff --git a/README.md b/README.md index dd68dcfa..df769fea 100644 --- a/README.md +++ b/README.md @@ -51,3 +51,5 @@ 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```. + + -- GitLab From c3166ff5a440993b99db505b54a8837a0b3cf732 Mon Sep 17 00:00:00 2001 From: Jithu Murugan <j.murugan@fz-juelich.de> Date: Mon, 24 Feb 2025 17:55:58 +0100 Subject: [PATCH 37/53] - coverage combine cannot process Cobertura files. Therefore, merge the .coverage folder individually to prevent failures when combining the coverage results. --- .gitlab-ci.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 003ac773..6bf7c32f 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -34,14 +34,14 @@ unit_tests: image: ubuntu:22.04 script: - coverage run -m pytest -s --tb=short --junitxml=test-results.xml ./dedal/tests/unit_tests - - coverage xml -o coverage_unit.xml + - mv .coverage .coverage.unit # Rename to avoid overwriting artifacts: when: always reports: junit: test-results.xml paths: - test-results.xml - - coverage_unit.xml + - .coverage.unit expire_in: 1 week integration_tests: @@ -51,7 +51,7 @@ integration_tests: image: ubuntu:22.04 script: - coverage run -m pytest -s --tb=short --junitxml=test-results.xml ./dedal/tests/integration_tests - - coverage xml -o coverage_integration.xml + - mv .coverage .coverage.integration # Rename to avoid overwriting needs: ["unit_tests"] artifacts: when: always @@ -59,7 +59,7 @@ integration_tests: junit: test-results.xml paths: - test-results.xml - - coverage_integration.xml + - .coverage.integration expire_in: 1 week merge_coverage: @@ -68,7 +68,7 @@ merge_coverage: - docker-runner image: ubuntu:22.04 script: - - coverage combine coverage_unit.xml coverage_integration.xml + - coverage combine .coverage.unit .coverage.integration - coverage report - coverage xml -o coverage.xml - coverage html -d coverage_html -- GitLab From 92f210b512e094e1e0ac0685f0d0897d5ffd45c8 Mon Sep 17 00:00:00 2001 From: Jithu Murugan <j.murugan@fz-juelich.de> Date: Tue, 25 Feb 2025 11:43:50 +0100 Subject: [PATCH 38/53] - Added a 10 minute timeout for testing if any specific command hangs on subprocess.run command! --- dedal/utils/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dedal/utils/utils.py b/dedal/utils/utils.py index 9fc82ad5..de5529dc 100644 --- a/dedal/utils/utils.py +++ b/dedal/utils/utils.py @@ -30,7 +30,7 @@ def run_command(*args, logger=logging.getLogger(__name__), info_msg: str = '', e exception=None, **kwargs): try: logger.info(f'{info_msg}: args: {args}') - return subprocess.run(args, **kwargs) + return subprocess.run(args, **kwargs, timeout=10 * 60) except subprocess.CalledProcessError as e: if exception_msg is not None: logger.error(f"{exception_msg}: {e}") -- GitLab From ca24dca8bfb424b741d9fe927312f0aec5f38e69 Mon Sep 17 00:00:00 2001 From: Jithu Murugan <j.murugan@fz-juelich.de> Date: Tue, 25 Feb 2025 13:27:04 +0100 Subject: [PATCH 39/53] - Added a 10 minute timeout for testing if any specific command hangs on subprocess.run command! --- dedal/tests/unit_tests/utils_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dedal/tests/unit_tests/utils_test.py b/dedal/tests/unit_tests/utils_test.py index cd478606..6bb6eda9 100644 --- a/dedal/tests/unit_tests/utils_test.py +++ b/dedal/tests/unit_tests/utils_test.py @@ -104,7 +104,7 @@ def test_run_command_success(mocker): 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')) + mock_subprocess.assert_called_once_with(('bash', '-c', 'echo hello'), timeout=600) assert result.returncode == 0 -- GitLab From e0262adcd32e72102177f4d006664502e04a0fcb Mon Sep 17 00:00:00 2001 From: Jithu Murugan <j.murugan@fz-juelich.de> Date: Tue, 25 Feb 2025 15:51:19 +0100 Subject: [PATCH 40/53] - Commented the failing tests to verify the coverage calculation --- .../spack_from_scratch_test.py | 388 +++++++++--------- 1 file changed, 194 insertions(+), 194 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..a85294f9 100644 --- a/dedal/tests/integration_tests/spack_from_scratch_test.py +++ b/dedal/tests/integration_tests/spack_from_scratch_test.py @@ -8,197 +8,197 @@ 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): - 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 +# 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 -- GitLab From 6e2f574f12c83206a44c8239f81f7154bce9e594 Mon Sep 17 00:00:00 2001 From: Jithu Murugan <j.murugan@fz-juelich.de> Date: Tue, 25 Feb 2025 16:18:33 +0100 Subject: [PATCH 41/53] Revert "- Commented the failing tests to verify the coverage calculation" This reverts commit e0262adcd32e72102177f4d006664502e04a0fcb. --- .../spack_from_scratch_test.py | 388 +++++++++--------- 1 file changed, 194 insertions(+), 194 deletions(-) diff --git a/dedal/tests/integration_tests/spack_from_scratch_test.py b/dedal/tests/integration_tests/spack_from_scratch_test.py index a85294f9..2fec80f7 100644 --- a/dedal/tests/integration_tests/spack_from_scratch_test.py +++ b/dedal/tests/integration_tests/spack_from_scratch_test.py @@ -8,197 +8,197 @@ 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): -# 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 +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 -- GitLab From 4491132f703b9654758705795e8797601c20ab96 Mon Sep 17 00:00:00 2001 From: Jithu Murugan <j.murugan@fz-juelich.de> Date: Tue, 25 Feb 2025 16:19:01 +0100 Subject: [PATCH 42/53] Revert "- Added a 10 minute timeout for testing if any specific command hangs on subprocess.run command!" This reverts commit ca24dca8bfb424b741d9fe927312f0aec5f38e69. --- dedal/tests/unit_tests/utils_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dedal/tests/unit_tests/utils_test.py b/dedal/tests/unit_tests/utils_test.py index 6bb6eda9..cd478606 100644 --- a/dedal/tests/unit_tests/utils_test.py +++ b/dedal/tests/unit_tests/utils_test.py @@ -104,7 +104,7 @@ def test_run_command_success(mocker): 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'), timeout=600) + mock_subprocess.assert_called_once_with(('bash', '-c', 'echo hello')) assert result.returncode == 0 -- GitLab From 997d22b052d32246d67b4f702c0e816a88029618 Mon Sep 17 00:00:00 2001 From: Jithu Murugan <j.murugan@fz-juelich.de> Date: Tue, 25 Feb 2025 16:19:12 +0100 Subject: [PATCH 43/53] Revert "- Added a 10 minute timeout for testing if any specific command hangs on subprocess.run command!" This reverts commit 92f210b512e094e1e0ac0685f0d0897d5ffd45c8. --- dedal/utils/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dedal/utils/utils.py b/dedal/utils/utils.py index de5529dc..9fc82ad5 100644 --- a/dedal/utils/utils.py +++ b/dedal/utils/utils.py @@ -30,7 +30,7 @@ def run_command(*args, logger=logging.getLogger(__name__), info_msg: str = '', e exception=None, **kwargs): try: logger.info(f'{info_msg}: args: {args}') - return subprocess.run(args, **kwargs, timeout=10 * 60) + return subprocess.run(args, **kwargs) except subprocess.CalledProcessError as e: if exception_msg is not None: logger.error(f"{exception_msg}: {e}") -- GitLab From 0baf498bd407dab17e9f72cbd8351345b7ec1b07 Mon Sep 17 00:00:00 2001 From: adrianciu <adrian.ciu@codemart.ro> Date: Mon, 3 Mar 2025 12:04:32 +0200 Subject: [PATCH 44/53] dev: bootstrap script and README update --- README.md | 101 ++++++++++++++++-- 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 +- 6 files changed, 115 insertions(+), 21 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/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 -- GitLab From 84d30a756b71eaac291a6c77d448dd397e8badc6 Mon Sep 17 00:00:00 2001 From: Jithu Murugan <j.murugan@fz-juelich.de> Date: Wed, 5 Mar 2025 10:32:41 +0100 Subject: [PATCH 45/53] - Added .dedal.log also as a job artefact --- .gitlab-ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 6bf7c32f..0ce02898 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -41,6 +41,7 @@ unit_tests: junit: test-results.xml paths: - test-results.xml + - .dedal.log - .coverage.unit expire_in: 1 week @@ -59,6 +60,7 @@ integration_tests: junit: test-results.xml paths: - test-results.xml + - .dedal.log - .coverage.integration expire_in: 1 week -- GitLab From e11cb8d42092ecca6427333a17b5d0a418280ddc Mon Sep 17 00:00:00 2001 From: Jithu Murugan <j.murugan@fz-juelich.de> Date: Wed, 5 Mar 2025 11:59:34 +0100 Subject: [PATCH 46/53] - Merge corrections, removed unwanted imports and sorted the existign ones. --- dedal/spack_factory/SpackOperationUseCache.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/dedal/spack_factory/SpackOperationUseCache.py b/dedal/spack_factory/SpackOperationUseCache.py index b2ccc12d..16312e7f 100644 --- a/dedal/spack_factory/SpackOperationUseCache.py +++ b/dedal/spack_factory/SpackOperationUseCache.py @@ -1,5 +1,4 @@ import os - import subprocess from pathlib import Path @@ -9,8 +8,7 @@ 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 -- GitLab From 54c811b9e959031207ec1caaf6e51d477784a7c0 Mon Sep 17 00:00:00 2001 From: Jithu Murugan <j.murugan@fz-juelich.de> Date: Wed, 5 Mar 2025 14:51:01 +0100 Subject: [PATCH 47/53] - Corrected the failing tests. --- dedal/spack_factory/SpackOperationCreateCache.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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) -- GitLab From 6809526df58dd14ea72c31a8f272d036f37d655f Mon Sep 17 00:00:00 2001 From: Jithu Murugan <j.murugan@fz-juelich.de> Date: Wed, 5 Mar 2025 14:57:55 +0100 Subject: [PATCH 48/53] - Corrected the failing tests. --- dedal/spack_factory/SpackOperationCreateCache.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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) -- GitLab From c3d764c4a27f5cdbfe6b6a3891c044061e6d03a7 Mon Sep 17 00:00:00 2001 From: Jithu Murugan <j.murugan@fz-juelich.de> Date: Wed, 5 Mar 2025 15:21:30 +0100 Subject: [PATCH 49/53] - Corrected the failing tests. --- dedal/spack_factory/SpackOperation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dedal/spack_factory/SpackOperation.py b/dedal/spack_factory/SpackOperation.py index 7814cc8b..ad4f2389 100644 --- a/dedal/spack_factory/SpackOperation.py +++ b/dedal/spack_factory/SpackOperation.py @@ -220,7 +220,7 @@ class SpackOperation: result = self.command_runner.run_preconfigured_command_sequence( PreconfiguredCommandEnum.SPACK_MIRROR_ADD, self.spack_setup_script, - "" if global_mirror else self.env_path, + "" if global_mirror else str(self.env_path), mirror_name, mirror_path, autopush, -- GitLab From 2b350ba0f2eb38badd2eed8b121f30c113bcf5d5 Mon Sep 17 00:00:00 2001 From: adrianciu <adrian.ciu@codemart.ro> Date: Mon, 3 Mar 2025 12:04:32 +0200 Subject: [PATCH 50/53] 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 +- 7 files changed, 118 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 -- GitLab From 5a5bfa5eacfe020f650b4da124d640d6ab78fa03 Mon Sep 17 00:00:00 2001 From: Jithu Murugan <j.murugan@fz-juelich.de> Date: Thu, 6 Mar 2025 10:34:40 +0100 Subject: [PATCH 51/53] - Merge latest changes from dev branch. --- dedal/build_cache/BuildCacheManager.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/dedal/build_cache/BuildCacheManager.py b/dedal/build_cache/BuildCacheManager.py index e997a8f1..ed1e7d9e 100644 --- a/dedal/build_cache/BuildCacheManager.py +++ b/dedal/build_cache/BuildCacheManager.py @@ -1,11 +1,9 @@ import glob import os -import time - -import oras.client from os.path import join from pathlib import Path +import oras.client import oras.client from dedal.build_cache.BuildCacheManagerInterface import BuildCacheManagerInterface -- GitLab From 5e494c1a5ce60abb0e83851c0813f0ded7130e02 Mon Sep 17 00:00:00 2001 From: Jithu Murugan <j.murugan@fz-juelich.de> Date: Thu, 6 Mar 2025 10:35:31 +0100 Subject: [PATCH 52/53] - Merge latest changes from dev branch. --- dedal/build_cache/BuildCacheManager.py | 1 - 1 file changed, 1 deletion(-) diff --git a/dedal/build_cache/BuildCacheManager.py b/dedal/build_cache/BuildCacheManager.py index ed1e7d9e..62cb9af1 100644 --- a/dedal/build_cache/BuildCacheManager.py +++ b/dedal/build_cache/BuildCacheManager.py @@ -3,7 +3,6 @@ import os from os.path import join from pathlib import Path -import oras.client import oras.client from dedal.build_cache.BuildCacheManagerInterface import BuildCacheManagerInterface -- GitLab From 14474dd656100116d2763662ba27b1437e5543bf Mon Sep 17 00:00:00 2001 From: Jithu Murugan <j.murugan@fz-juelich.de> Date: Thu, 6 Mar 2025 11:38:59 +0100 Subject: [PATCH 53/53] - Moved the coverage badge to the top. --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c8a01e8c..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```. @@ -140,4 +141,4 @@ Installs spack packages present in the spack environment defined in configuratio # Dedal's UML diagram  - + -- GitLab