Skip to content
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
43031e4
Add function determining if path is ancestor of another path
gkaf89 Jan 19, 2025
5af0637
Add assertion about the existence of a list of paths
gkaf89 Jan 21, 2025
e92e469
Add function generating unused directories given a list of paths
gkaf89 Jan 11, 2025
460fe88
Copy build log and artifacts to permanents locations after a failure
gkaf89 Aug 2, 2024
4750fca
Enforce persistence copying restrictions during input option validation
gkaf89 Jan 24, 2025
d52d744
[STYLE] Follow style conventions in variable names and functions calls
gkaf89 Jan 30, 2025
ea723dd
Mention individual files copied during error artifact persistence
gkaf89 Feb 7, 2025
71b761c
simplify test for copying of logs/build dirs for failing installations
boegel Mar 1, 2025
b2c2193
use mkdir & remove_dir functions in additional filetools tests
boegel Mar 1, 2025
7baef32
minor tweaks in is_parent_path function
boegel Mar 1, 2025
f291d8c
enhance test for is_parent_path
boegel Mar 1, 2025
b0a10a1
rename create_unused_dirs to create_non_existing_paths, rename get_fi…
boegel Mar 1, 2025
7860075
rename --artifact-error-path to --failed-installs-build-dirs-path and…
boegel Mar 1, 2025
36aa118
rename persist_failed_compilation_log_and_artifacts to copy_build_dir…
boegel Mar 1, 2025
6eb27db
don't shadow 'source_paths' imported from tools.config in copy_build_…
boegel Mar 1, 2025
6e4024e
fix failing tests
boegel Mar 1, 2025
a4e8856
fix alphabetical ordering of filetools imports in easyblock.py
boegel Mar 2, 2025
6a6eef6
don't let setting --prefix configuration option imply that --failed-i…
boegel Mar 2, 2025
6f8d903
aply suggested changes in copy_build_dirs_logs_failed_installs
boegel Mar 2, 2025
25864b8
apply suggested changes for get_failed_installs_build_dirs_path + get…
boegel Mar 2, 2025
c50dc4e
simplify code to determine name of unique subdirectory in copy_build_…
boegel Mar 2, 2025
f63a743
rename --failed-installs-build-dirs-path to --failed-install-build-di…
boegel Mar 2, 2025
7c58cb9
fix alphabetical order of imports from tools.config in easyblock.py
boegel Mar 2, 2025
905b941
minor code cleanup in copy_build_dirs_logs_failed_install
boegel Mar 2, 2025
e184b67
fix code style check failure by creating inner function rather than a…
boegel Mar 2, 2025
1d757db
Merge branch '5.0.x' into feature/error-logging
boegel Mar 2, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions easybuild/base/testing.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,11 @@ def assertNotExists(self, path, msg=None):
msg = "'%s' should not exist" % path
self.assertFalse(os.path.exists(path), msg)

def assertAllExist(self, paths, msg=None):
"""Assert that all paths in the given list exist"""
for path in paths:
self.assertExists(path, msg)

def setUp(self):
"""Prepare test case."""
super(TestCase, self).setUp()
Expand Down
82 changes: 76 additions & 6 deletions easybuild/framework/easyblock.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
import inspect
import json
import os
import random
import re
import stat
import sys
Expand All @@ -57,6 +58,7 @@
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime
from enum import Enum
from string import ascii_letters
from textwrap import indent

import easybuild.tools.environment as env
Expand All @@ -81,15 +83,17 @@
from easybuild.tools.config import PYTHONPATH, SEARCH_PATH_BIN_DIRS, SEARCH_PATH_LIB_DIRS
from easybuild.tools.config import build_option, build_path, get_log_filename, get_repository, get_repositorypath
from easybuild.tools.config import install_path, log_path, package_path, source_paths
from easybuild.tools.config import get_failed_installs_build_dirs_path, get_failed_installs_logs_path
from easybuild.tools.environment import restore_env, sanitize_env
from easybuild.tools.filetools import CHECKSUM_TYPE_SHA256
from easybuild.tools.filetools import adjust_permissions, apply_patch, back_up_file, change_dir, check_lock
from easybuild.tools.filetools import compute_checksum, convert_name, copy_file, create_lock, create_patch_info
from easybuild.tools.filetools import derive_alt_pypi_url, diff_files, dir_contains_files, download_file
from easybuild.tools.filetools import encode_class_name, extract_file, find_backup_name_candidate
from easybuild.tools.filetools import get_cwd, get_source_tarball_from_git, is_alt_pypi_url
from easybuild.tools.filetools import is_binary, is_sha256_checksum, mkdir, move_file, move_logs, read_file, remove_dir
from easybuild.tools.filetools import remove_file, remove_lock, verify_checksum, weld_paths, write_file, symlink
from easybuild.tools.filetools import compute_checksum, convert_name, copy_dir, copy_file, create_lock
from easybuild.tools.filetools import create_non_existing_paths, create_patch_info, derive_alt_pypi_url, diff_files
from easybuild.tools.filetools import dir_contains_files, download_file, encode_class_name, extract_file
from easybuild.tools.filetools import find_backup_name_candidate, get_cwd, get_source_tarball_from_git, is_alt_pypi_url
from easybuild.tools.filetools import is_binary, is_parent_path, is_sha256_checksum, mkdir, move_file, move_logs
from easybuild.tools.filetools import read_file, remove_dir, remove_file, remove_lock, symlink, verify_checksum
from easybuild.tools.filetools import weld_paths, write_file
from easybuild.tools.hooks import (
BUILD_STEP, CLEANUP_STEP, CONFIGURE_STEP, EXTENSIONS_STEP, EXTRACT_STEP, FETCH_STEP, INSTALL_STEP, MODULE_STEP,
MODULE_WRITE, PACKAGE_STEP, PATCH_STEP, PERMISSIONS_STEP, POSTITER_STEP, POSTPROC_STEP, PREPARE_STEP, READY_STEP,
Expand Down Expand Up @@ -4422,6 +4426,69 @@ def print_dry_run_note(loc, silent=True):
dry_run_msg(msg, silent=silent)


def copy_build_dirs_logs_failed_installs(application_log, silent, app, easyconfig):
"""
Copy build directories and log files for failed installation (if desired)
"""
# there may be multiple log files, or the file name may be different due to zipping
logs = glob.glob(f"{application_log}*")

datestamp = time.strftime('%Y%m%d')
timestamp = time.strftime('%H%M%S')
salt = ''.join(random.choice(ascii_letters) for i in range(5))
unique_subdir = f'{datestamp}-{timestamp}-{salt}'

operation_args = []

logs_path = get_failed_installs_logs_path(easyconfig)
if logs and logs_path:
logs_path = os.path.join(logs_path, unique_subdir)

if is_parent_path(app.builddir, logs_path):
print_warning(
"Path to copy log files of failed installs to is subdirectory of build directory; not copying",
log=_log,
silent=silent
)
else:
operation_args.append(
(
copy_file,
logs,
logs_path,
f"Log file(s) of failed installation copied to {logs_path}"
)
)

build_dirs_path = get_failed_installs_build_dirs_path(easyconfig)
if build_dirs_path and os.path.isdir(app.builddir):
build_dirs_path = os.path.join(build_dirs_path, unique_subdir)

if is_parent_path(app.builddir, build_dirs_path):
print_warning(
"Path to copy build dirs of failed installs to is subdirectory of build directory; not copying",
log=_log,
silent=silent
)
else:
operation_args.append(
(
lambda src, dest: copy_dir(src, dest, dirs_exist_ok=True),
[app.builddir],
build_dirs_path,
f"Build directory of failed installation copied to {build_dirs_path}"
)
)

persistence_paths = [target_path for (_, _, target_path, _) in operation_args]
persistence_paths = create_non_existing_paths(persistence_paths)

for idx, (operation, paths, _, msg) in enumerate(operation_args):
for path in paths:
operation(path, persistence_paths[idx])
print_msg(msg, log=_log, silent=silent)


def build_and_install_one(ecdict, init_env):
"""
Build the software
Expand Down Expand Up @@ -4680,6 +4747,9 @@ def ensure_writable_log_dir(log_dir):
logs = glob.glob('%s*' % application_log)
print_msg("Results of the build can be found in the log file(s) %s" % ', '.join(logs), log=_log, silent=silent)

if not success:
copy_build_dirs_logs_failed_installs(application_log, silent, app, ecdict['ec'])

del app

return (success, application_log, error_msg, exit_code)
Expand Down
42 changes: 42 additions & 0 deletions easybuild/tools/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,8 @@ class ConfigurationVariables(BaseConfigurationVariables):
'buildpath',
'config',
'containerpath',
'failed_installs_build_dirs_path',
'failed_installs_logs_path',
'installpath',
'installpath_modules',
'installpath_software',
Expand Down Expand Up @@ -865,6 +867,46 @@ def log_path(ec=None):
return log_file_format(return_directory=True, ec=ec, date=date, timestamp=timestamp)


def get_failed_installs_build_dirs_path(ec):
"""
Return the 'failed_installs_build_dirs_path',
the location where build directories are copied if installation failed

:param ec: dict-like value with 'name' and 'version' keys defined
"""
failed_installs_build_dirs_path = ConfigurationVariables()['failed_installs_build_dirs_path']

if not failed_installs_build_dirs_path:
return None

try:
name, version = ec['name'], ec['version']
except KeyError:
raise EasyBuildError("The 'name' and 'version' keys are required.")

return os.path.join(failed_installs_build_dirs_path, name + '-' + version)


def get_failed_installs_logs_path(ec):
"""
Return the 'failed_installs_logs_path',
the location where log files are copied if installation failed

:param ec: dict-like value with 'name' and 'version' keys defined
"""
failed_installs_logs_path = ConfigurationVariables()['failed_installs_logs_path']

if not failed_installs_logs_path:
return None

try:
name, version = ec['name'], ec['version']
except KeyError:
raise EasyBuildError("The 'name' and 'version' keys are required.")

return os.path.join(failed_installs_logs_path, name + '-' + version)


def get_build_log_path():
"""
Return (temporary) directory for build log
Expand Down
79 changes: 79 additions & 0 deletions easybuild/tools/filetools.py
Original file line number Diff line number Diff line change
Expand Up @@ -603,6 +603,19 @@ def normalize_path(path):
return start_slashes + os.path.sep.join(filtered_comps)


def is_parent_path(path1, path2):
"""
Return True if path1 is a prefix of path2

:param path1: absolute or relative path
:param path2: absolute or relative path
"""
path1 = os.path.realpath(path1)
path2 = os.path.realpath(path2)
common_path = os.path.commonprefix([path1, path2])
return common_path == path1


def is_alt_pypi_url(url):
"""Determine whether specified URL is already an alternative PyPI URL, i.e. whether it contains a hash."""
# example: .../packages/5b/03/e135b19fadeb9b1ccb45eac9f60ca2dc3afe72d099f6bd84e03cb131f9bf/easybuild-2.7.0.tar.gz
Expand Down Expand Up @@ -3096,3 +3109,69 @@ def create_unused_dir(parent_folder, name):
set_gid_sticky_bits(path, recursive=True)

return path


def get_first_non_existing_parent_path(path):
"""
Get first directory that does not exist, starting at path and going up.
"""
path = os.path.abspath(path)

non_existing_parent = None
while not os.path.exists(path):
non_existing_parent = path
path = os.path.dirname(path)

return non_existing_parent


def create_non_existing_paths(paths, max_tries=10000):
"""
Create directories with given paths (including the parent directories).
When a directory in the same location for any of the specified paths already exists,
then the suffix '_<i>' is appended , with i iteratively picked between 0 and (max_tries-1),
until an index is found so that all required paths are non-existing.
All created directories have the same suffix.

:param paths: list of directory paths to be created
:param max_tries: maximum number of tries before failing
"""
paths = [os.path.abspath(p) for p in paths]
for idx_path, path in enumerate(paths):
for idx_parent, parent in enumerate(paths):
if idx_parent != idx_path and is_parent_path(parent, path):
raise EasyBuildError(f"Path '{parent}' is a parent path of '{path}'.")

first_non_existing_parent_paths = [get_first_non_existing_parent_path(p) for p in paths]

non_existing_paths = paths
all_paths_created = False
suffix = -1
while suffix < max_tries and not all_paths_created:
tried_paths = []
if suffix >= 0:
non_existing_paths = [f'{p}_{suffix}' for p in paths]
try:
for path in non_existing_paths:
tried_paths.append(path)
# os.makedirs will raise OSError if directory already exists
os.makedirs(path)
all_paths_created = True
except OSError as err:
# Distinguish between error due to existing folder and anything else
if not os.path.exists(tried_paths[-1]):
raise EasyBuildError("Failed to create directory %s: %s", tried_paths[-1], err)
remove(tried_paths[:-1])
except BaseException as err:
remove(tried_paths)
raise err
suffix += 1

if not all_paths_created:
raise EasyBuildError(f"Exceeded maximum number of attempts ({max_tries}) to generate non-existing paths")

# set group ID and sticky bits, if desired
for path in first_non_existing_parent_paths:
set_gid_sticky_bits(path, recursive=True)

return non_existing_paths
35 changes: 28 additions & 7 deletions easybuild/tools/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@
from easybuild.tools.docs import list_easyblocks, list_toolchains
from easybuild.tools.environment import restore_env, unset_env_vars
from easybuild.tools.filetools import CHECKSUM_TYPE_SHA256, CHECKSUM_TYPES, expand_glob_paths, get_cwd
from easybuild.tools.filetools import install_fake_vsc, move_file, which
from easybuild.tools.filetools import install_fake_vsc, move_file, which, is_parent_path
from easybuild.tools.github import GITHUB_PR_DIRECTION_DESC, GITHUB_PR_ORDER_CREATED
from easybuild.tools.github import GITHUB_PR_STATE_OPEN, GITHUB_PR_STATES, GITHUB_PR_ORDERS, GITHUB_PR_DIRECTIONS
from easybuild.tools.github import HAVE_GITHUB_API, HAVE_KEYRING, VALID_CLOSE_PR_REASONS
Expand Down Expand Up @@ -590,6 +590,12 @@ def config_options(self):
[DEFAULT_ENVVAR_USERS_MODULES]),
'external-modules-metadata': ("List of (glob patterns for) paths to files specifying metadata "
"for external modules (INI format)", 'strlist', 'store', None),
'failed-installs-build-dirs-path': ("Location where build directories are copied if installation fails; "
"an empty value disables copying of build directories",
None, 'store', None, {'metavar': "PATH"}),
'failed-installs-logs-path': ("Location where log files are copied if installation fails; "
"an empty value disables copying of log files",
None, 'store', None, {'metavar': "PATH"}),
'hooks': ("Location of Python module with hook implementations", 'str', 'store', None),
'ignore-dirs': ("Directory names to ignore when searching for files/dirs",
'strlist', 'store', ['.git', '.svn']),
Expand Down Expand Up @@ -1212,22 +1218,23 @@ def _postprocess_config(self):
# to avoid incorrect paths being used when EasyBuild changes the current working directory
# (see https://github.com/easybuilders/easybuild-framework/issues/3619);
# ensuring absolute paths for 'robot' is handled separately below,
# because we need to be careful with the argument pass to --robot;
# because we need to be careful with the argument passed to --robot;
# note: repositorypath is purposely not listed here, because it's a special case:
# - the value could consist of a 2-tuple (<path>, <relative_subdir>);
# - the <path> could also specify the location of a *remote* (Git( repository,
# which can be done in variety of formats (git@<url>:<org>/<repo>), https://<url>, etc.)
# (see also https://github.com/easybuilders/easybuild-framework/issues/3892);
path_opt_names = ['buildpath', 'containerpath', 'git_working_dirs_path', 'installpath',
'installpath_modules', 'installpath_software', 'prefix', 'packagepath',
'robot_paths', 'sourcepath']
path_opt_names = ['buildpath', 'containerpath', 'failed_installs_build_dirs_path', 'failed_installs_logs_path',
'git_working_dirs_path', 'installpath', 'installpath_modules', 'installpath_software',
'prefix', 'packagepath', 'robot_paths', 'sourcepath']

for opt_name in path_opt_names:
self._ensure_abs_path(opt_name)

if self.options.prefix is not None:
# prefix applies to all paths, and repository has to be reinitialised to take new repositorypath in account
# in the legacy-style configuration, repository is initialised in configuration file itself
# prefix applies to selected path configuration options;
# repository has to be reinitialised to take new repositorypath in account;
# in the legacy-style configuration, repository is initialised in configuration file itself;
path_opts = ['buildpath', 'containerpath', 'installpath', 'packagepath', 'repository', 'repositorypath',
'sourcepath']
for dest in path_opts:
Expand Down Expand Up @@ -1288,6 +1295,20 @@ def _postprocess_config(self):
if self.options.inject_checksums or self.options.inject_checksums_to_json:
self.options.pre_create_installdir = False

# Prevent that build directories and logs for failed installations are copied to location for build directories
if self.options.buildpath and self.options.failed_installs_logs_path:
if is_parent_path(self.options.buildpath, self.options.failed_installs_logs_path):
raise EasyBuildError(
f"The --failed-installs-logs-path ('{self.options.failed_installs_logs_path}') "
f"cannot reside in a subdirectory of the --buildpath ('{self.options.buildpath}')"
)
if self.options.buildpath and self.options.failed_installs_build_dirs_path:
if is_parent_path(self.options.buildpath, self.options.failed_installs_build_dirs_path):
raise EasyBuildError(
f"The --failed-installs-build-dirs-path ('{self.options.failed_installs_build_dirs_path}') "
f"cannot reside in a subdirectory of the --buildpath ('{self.options.buildpath}')"
)

def _postprocess_list_avail(self):
"""Create all the additional info that can be requested (exit at the end)"""
msg = ''
Expand Down
Loading
Loading