Skip to content

Commit ae07512

Browse files
authored
Merge pull request #4601 from gkaf89/feature/error-logging
copy build directory and/or log file(s) if installation failed to path specified via `--failed-install-build-dirs-path` or `--failed-install-logs-path`
2 parents 19964d3 + 1d757db commit ae07512

File tree

9 files changed

+642
-15
lines changed

9 files changed

+642
-15
lines changed

easybuild/base/testing.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,11 @@ def assertNotExists(self, path, msg=None):
117117
msg = "'%s' should not exist" % path
118118
self.assertFalse(os.path.exists(path), msg)
119119

120+
def assertAllExist(self, paths, msg=None):
121+
"""Assert that all paths in the given list exist"""
122+
for path in paths:
123+
self.assertExists(path, msg)
124+
120125
def setUp(self):
121126
"""Prepare test case."""
122127
super(TestCase, self).setUp()

easybuild/framework/easyblock.py

Lines changed: 62 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
import inspect
4949
import json
5050
import os
51+
import random
5152
import re
5253
import stat
5354
import sys
@@ -57,6 +58,7 @@
5758
from concurrent.futures import ThreadPoolExecutor
5859
from datetime import datetime
5960
from enum import Enum
61+
from string import ascii_letters
6062
from textwrap import indent
6163

6264
import easybuild.tools.environment as env
@@ -79,17 +81,19 @@
7981
from easybuild.tools.config import EASYBUILD_SOURCES_URL, EBPYTHONPREFIXES # noqa
8082
from easybuild.tools.config import FORCE_DOWNLOAD_ALL, FORCE_DOWNLOAD_PATCHES, FORCE_DOWNLOAD_SOURCES
8183
from easybuild.tools.config import MOD_SEARCH_PATH_HEADERS, PYTHONPATH, SEARCH_PATH_BIN_DIRS, SEARCH_PATH_LIB_DIRS
82-
from easybuild.tools.config import build_option, build_path, get_log_filename, get_repository, get_repositorypath
84+
from easybuild.tools.config import build_option, build_path, get_failed_install_build_dirs_path
85+
from easybuild.tools.config import get_failed_install_logs_path, get_log_filename, get_repository, get_repositorypath
8386
from easybuild.tools.config import install_path, log_path, package_path, source_paths
8487
from easybuild.tools.environment import restore_env, sanitize_env
8588
from easybuild.tools.filetools import CHECKSUM_TYPE_SHA256
8689
from easybuild.tools.filetools import adjust_permissions, apply_patch, back_up_file, change_dir, check_lock
87-
from easybuild.tools.filetools import compute_checksum, convert_name, copy_file, create_lock, create_patch_info
88-
from easybuild.tools.filetools import derive_alt_pypi_url, diff_files, dir_contains_files, download_file
89-
from easybuild.tools.filetools import encode_class_name, extract_file, find_backup_name_candidate
90-
from easybuild.tools.filetools import get_cwd, get_source_tarball_from_git, is_alt_pypi_url
91-
from easybuild.tools.filetools import is_binary, is_sha256_checksum, mkdir, move_file, move_logs, read_file, remove_dir
92-
from easybuild.tools.filetools import remove_file, remove_lock, verify_checksum, weld_paths, write_file, symlink
90+
from easybuild.tools.filetools import compute_checksum, convert_name, copy_dir, copy_file, create_lock
91+
from easybuild.tools.filetools import create_non_existing_paths, create_patch_info, derive_alt_pypi_url, diff_files
92+
from easybuild.tools.filetools import dir_contains_files, download_file, encode_class_name, extract_file
93+
from easybuild.tools.filetools import find_backup_name_candidate, get_cwd, get_source_tarball_from_git, is_alt_pypi_url
94+
from easybuild.tools.filetools import is_binary, is_parent_path, is_sha256_checksum, mkdir, move_file, move_logs
95+
from easybuild.tools.filetools import read_file, remove_dir, remove_file, remove_lock, symlink, verify_checksum
96+
from easybuild.tools.filetools import weld_paths, write_file
9397
from easybuild.tools.hooks import (
9498
BUILD_STEP, CLEANUP_STEP, CONFIGURE_STEP, EXTENSIONS_STEP, EXTRACT_STEP, FETCH_STEP, INSTALL_STEP, MODULE_STEP,
9599
MODULE_WRITE, PACKAGE_STEP, PATCH_STEP, PERMISSIONS_STEP, POSTITER_STEP, POSTPROC_STEP, PREPARE_STEP, READY_STEP,
@@ -4438,6 +4442,54 @@ def print_dry_run_note(loc, silent=True):
44384442
dry_run_msg(msg, silent=silent)
44394443

44404444

4445+
def copy_build_dirs_logs_failed_install(application_log, silent, app, easyconfig):
4446+
"""
4447+
Copy build directories and log files for failed installation (if desired)
4448+
"""
4449+
logs_path = get_failed_install_logs_path(easyconfig)
4450+
build_dirs_path = get_failed_install_build_dirs_path(easyconfig)
4451+
4452+
# there may be multiple log files, or the file name may be different due to zipping
4453+
logs = glob.glob(f"{application_log}*")
4454+
4455+
timestamp = time.strftime('%Y%m%d-%H%M%S')
4456+
salt = ''.join(random.choices(ascii_letters, k=5))
4457+
unique_subdir = f'{timestamp}-{salt}'
4458+
4459+
operation_args = []
4460+
4461+
if logs_path and logs:
4462+
logs_path = os.path.join(logs_path, unique_subdir)
4463+
4464+
if is_parent_path(app.builddir, logs_path):
4465+
msg = "Path to copy log files of failed installs to is subdirectory of build directory; not copying"
4466+
print_warning(msg, log=_log, silent=silent)
4467+
else:
4468+
msg = f"Log file(s) of failed installation copied to {logs_path}"
4469+
operation_args.append((copy_file, logs, logs_path, msg))
4470+
4471+
if build_dirs_path and os.path.isdir(app.builddir):
4472+
build_dirs_path = os.path.join(build_dirs_path, unique_subdir)
4473+
4474+
if is_parent_path(app.builddir, build_dirs_path):
4475+
msg = "Path to copy build dirs of failed installs to is subdirectory of build directory; not copying"
4476+
print_warning(msg, log=_log, silent=silent)
4477+
else:
4478+
msg = f"Build directory of failed installation copied to {build_dirs_path}"
4479+
4480+
def operation(src, dest):
4481+
copy_dir(src, dest, dirs_exist_ok=True)
4482+
4483+
operation_args.append((operation, [app.builddir], build_dirs_path, msg))
4484+
4485+
persistence_paths = create_non_existing_paths(target_path for (_, _, target_path, _) in operation_args)
4486+
4487+
for idx, (operation, paths, _, msg) in enumerate(operation_args):
4488+
for path in paths:
4489+
operation(path, persistence_paths[idx])
4490+
print_msg(msg, log=_log, silent=silent)
4491+
4492+
44414493
def build_and_install_one(ecdict, init_env):
44424494
"""
44434495
Build the software
@@ -4696,6 +4748,9 @@ def ensure_writable_log_dir(log_dir):
46964748
logs = glob.glob('%s*' % application_log)
46974749
print_msg("Results of the build can be found in the log file(s) %s" % ', '.join(logs), log=_log, silent=silent)
46984750

4751+
if not success:
4752+
copy_build_dirs_logs_failed_install(application_log, silent, app, ecdict['ec'])
4753+
46994754
del app
47004755

47014756
return (success, application_log, error_msg, exit_code)

easybuild/tools/config.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -505,6 +505,8 @@ class ConfigurationVariables(BaseConfigurationVariables):
505505
'buildpath',
506506
'config',
507507
'containerpath',
508+
'failed_install_build_dirs_path',
509+
'failed_install_logs_path',
508510
'installpath',
509511
'installpath_modules',
510512
'installpath_software',
@@ -877,6 +879,42 @@ def log_path(ec=None):
877879
return log_file_format(return_directory=True, ec=ec, date=date, timestamp=timestamp)
878880

879881

882+
def get_failed_install_build_dirs_path(ec):
883+
"""
884+
Return the location where the build directory is copied to if installation failed
885+
886+
:param ec: dict-like value with 'name' and 'version' keys defined
887+
"""
888+
base_path = ConfigurationVariables()['failed_install_build_dirs_path']
889+
if not base_path:
890+
return None
891+
892+
try:
893+
name, version = ec['name'], ec['version']
894+
except KeyError:
895+
raise EasyBuildError("The 'name' and 'version' keys are required.")
896+
897+
return os.path.join(base_path, f'{name}-{version}')
898+
899+
900+
def get_failed_install_logs_path(ec):
901+
"""
902+
Return the location where log files are copied to if installation failed
903+
904+
:param ec: dict-like value with 'name' and 'version' keys defined
905+
"""
906+
base_path = ConfigurationVariables()['failed_install_logs_path']
907+
if not base_path:
908+
return None
909+
910+
try:
911+
name, version = ec['name'], ec['version']
912+
except KeyError:
913+
raise EasyBuildError("The 'name' and 'version' keys are required.")
914+
915+
return os.path.join(base_path, f'{name}-{version}')
916+
917+
880918
def get_build_log_path():
881919
"""
882920
Return (temporary) directory for build log

easybuild/tools/filetools.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -603,6 +603,19 @@ def normalize_path(path):
603603
return start_slashes + os.path.sep.join(filtered_comps)
604604

605605

606+
def is_parent_path(path1, path2):
607+
"""
608+
Return True if path1 is a prefix of path2
609+
610+
:param path1: absolute or relative path
611+
:param path2: absolute or relative path
612+
"""
613+
path1 = os.path.realpath(path1)
614+
path2 = os.path.realpath(path2)
615+
common_path = os.path.commonprefix([path1, path2])
616+
return common_path == path1
617+
618+
606619
def is_alt_pypi_url(url):
607620
"""Determine whether specified URL is already an alternative PyPI URL, i.e. whether it contains a hash."""
608621
# example: .../packages/5b/03/e135b19fadeb9b1ccb45eac9f60ca2dc3afe72d099f6bd84e03cb131f9bf/easybuild-2.7.0.tar.gz
@@ -3096,3 +3109,69 @@ def create_unused_dir(parent_folder, name):
30963109
set_gid_sticky_bits(path, recursive=True)
30973110

30983111
return path
3112+
3113+
3114+
def get_first_non_existing_parent_path(path):
3115+
"""
3116+
Get first directory that does not exist, starting at path and going up.
3117+
"""
3118+
path = os.path.abspath(path)
3119+
3120+
non_existing_parent = None
3121+
while not os.path.exists(path):
3122+
non_existing_parent = path
3123+
path = os.path.dirname(path)
3124+
3125+
return non_existing_parent
3126+
3127+
3128+
def create_non_existing_paths(paths, max_tries=10000):
3129+
"""
3130+
Create directories with given paths (including the parent directories).
3131+
When a directory in the same location for any of the specified paths already exists,
3132+
then the suffix '_<i>' is appended , with i iteratively picked between 0 and (max_tries-1),
3133+
until an index is found so that all required paths are non-existing.
3134+
All created directories have the same suffix.
3135+
3136+
:param paths: list of directory paths to be created
3137+
:param max_tries: maximum number of tries before failing
3138+
"""
3139+
paths = [os.path.abspath(p) for p in paths]
3140+
for idx_path, path in enumerate(paths):
3141+
for idx_parent, parent in enumerate(paths):
3142+
if idx_parent != idx_path and is_parent_path(parent, path):
3143+
raise EasyBuildError(f"Path '{parent}' is a parent path of '{path}'.")
3144+
3145+
first_non_existing_parent_paths = [get_first_non_existing_parent_path(p) for p in paths]
3146+
3147+
non_existing_paths = paths
3148+
all_paths_created = False
3149+
suffix = -1
3150+
while suffix < max_tries and not all_paths_created:
3151+
tried_paths = []
3152+
if suffix >= 0:
3153+
non_existing_paths = [f'{p}_{suffix}' for p in paths]
3154+
try:
3155+
for path in non_existing_paths:
3156+
tried_paths.append(path)
3157+
# os.makedirs will raise OSError if directory already exists
3158+
os.makedirs(path)
3159+
all_paths_created = True
3160+
except OSError as err:
3161+
# Distinguish between error due to existing folder and anything else
3162+
if not os.path.exists(tried_paths[-1]):
3163+
raise EasyBuildError("Failed to create directory %s: %s", tried_paths[-1], err)
3164+
remove(tried_paths[:-1])
3165+
except BaseException as err:
3166+
remove(tried_paths)
3167+
raise err
3168+
suffix += 1
3169+
3170+
if not all_paths_created:
3171+
raise EasyBuildError(f"Exceeded maximum number of attempts ({max_tries}) to generate non-existing paths")
3172+
3173+
# set group ID and sticky bits, if desired
3174+
for path in first_non_existing_parent_paths:
3175+
set_gid_sticky_bits(path, recursive=True)
3176+
3177+
return non_existing_paths

easybuild/tools/options.py

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@
8787
from easybuild.tools.docs import list_easyblocks, list_toolchains
8888
from easybuild.tools.environment import restore_env, unset_env_vars
8989
from easybuild.tools.filetools import CHECKSUM_TYPE_SHA256, CHECKSUM_TYPES, expand_glob_paths, get_cwd
90-
from easybuild.tools.filetools import install_fake_vsc, move_file, which
90+
from easybuild.tools.filetools import install_fake_vsc, move_file, which, is_parent_path
9191
from easybuild.tools.github import GITHUB_PR_DIRECTION_DESC, GITHUB_PR_ORDER_CREATED
9292
from easybuild.tools.github import GITHUB_PR_STATE_OPEN, GITHUB_PR_STATES, GITHUB_PR_ORDERS, GITHUB_PR_DIRECTIONS
9393
from easybuild.tools.github import HAVE_GITHUB_API, HAVE_KEYRING, VALID_CLOSE_PR_REASONS
@@ -592,6 +592,12 @@ def config_options(self):
592592
[DEFAULT_ENVVAR_USERS_MODULES]),
593593
'external-modules-metadata': ("List of (glob patterns for) paths to files specifying metadata "
594594
"for external modules (INI format)", 'strlist', 'store', None),
595+
'failed-install-build-dirs-path': ("Location where build directories are copied if installation fails; "
596+
"an empty value disables copying of build directories",
597+
None, 'store', None, {'metavar': "PATH"}),
598+
'failed-install-logs-path': ("Location where log files are copied if installation fails; "
599+
"an empty value disables copying of log files",
600+
None, 'store', None, {'metavar': "PATH"}),
595601
'hooks': ("Location of Python module with hook implementations", 'str', 'store', None),
596602
'ignore-dirs': ("Directory names to ignore when searching for files/dirs",
597603
'strlist', 'store', ['.git', '.svn']),
@@ -1217,22 +1223,23 @@ def _postprocess_config(self):
12171223
# to avoid incorrect paths being used when EasyBuild changes the current working directory
12181224
# (see https://github.com/easybuilders/easybuild-framework/issues/3619);
12191225
# ensuring absolute paths for 'robot' is handled separately below,
1220-
# because we need to be careful with the argument pass to --robot;
1226+
# because we need to be careful with the argument passed to --robot;
12211227
# note: repositorypath is purposely not listed here, because it's a special case:
12221228
# - the value could consist of a 2-tuple (<path>, <relative_subdir>);
12231229
# - the <path> could also specify the location of a *remote* (Git( repository,
12241230
# which can be done in variety of formats (git@<url>:<org>/<repo>), https://<url>, etc.)
12251231
# (see also https://github.com/easybuilders/easybuild-framework/issues/3892);
1226-
path_opt_names = ['buildpath', 'containerpath', 'git_working_dirs_path', 'installpath',
1227-
'installpath_modules', 'installpath_software', 'prefix', 'packagepath',
1228-
'robot_paths', 'sourcepath']
1232+
path_opt_names = ['buildpath', 'containerpath', 'failed_install_build_dirs_path', 'failed_install_logs_path',
1233+
'git_working_dirs_path', 'installpath', 'installpath_modules', 'installpath_software',
1234+
'prefix', 'packagepath', 'robot_paths', 'sourcepath']
12291235

12301236
for opt_name in path_opt_names:
12311237
self._ensure_abs_path(opt_name)
12321238

12331239
if self.options.prefix is not None:
1234-
# prefix applies to all paths, and repository has to be reinitialised to take new repositorypath in account
1235-
# in the legacy-style configuration, repository is initialised in configuration file itself
1240+
# prefix applies to selected path configuration options;
1241+
# repository has to be reinitialised to take new repositorypath in account;
1242+
# in the legacy-style configuration, repository is initialised in configuration file itself;
12361243
path_opts = ['buildpath', 'containerpath', 'installpath', 'packagepath', 'repository', 'repositorypath',
12371244
'sourcepath']
12381245
for dest in path_opts:
@@ -1293,6 +1300,20 @@ def _postprocess_config(self):
12931300
if self.options.inject_checksums or self.options.inject_checksums_to_json:
12941301
self.options.pre_create_installdir = False
12951302

1303+
# Prevent that build directories and logs for failed installations are copied to location for build directories
1304+
if self.options.buildpath and self.options.failed_install_logs_path:
1305+
if is_parent_path(self.options.buildpath, self.options.failed_install_logs_path):
1306+
raise EasyBuildError(
1307+
f"The --failed-install-logs-path ('{self.options.failed_install_logs_path}') "
1308+
f"cannot reside in a subdirectory of the --buildpath ('{self.options.buildpath}')"
1309+
)
1310+
if self.options.buildpath and self.options.failed_install_build_dirs_path:
1311+
if is_parent_path(self.options.buildpath, self.options.failed_install_build_dirs_path):
1312+
raise EasyBuildError(
1313+
f"The --failed-install-build-dirs-path ('{self.options.failed_install_build_dirs_path}') "
1314+
f"cannot reside in a subdirectory of the --buildpath ('{self.options.buildpath}')"
1315+
)
1316+
12961317
def _postprocess_list_avail(self):
12971318
"""Create all the additional info that can be requested (exit at the end)"""
12981319
msg = ''

0 commit comments

Comments
 (0)