Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

set up a minimal build environment when using system compiler #3399

Merged
merged 9 commits into from
Aug 18, 2020
4 changes: 4 additions & 0 deletions easybuild/tools/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@
DEFAULT_JOB_BACKEND = 'GC3Pie'
DEFAULT_LOGFILE_FORMAT = ("easybuild", "easybuild-%(name)s-%(version)s-%(date)s.%(time)s.log")
DEFAULT_MAX_FAIL_RATIO_PERMS = 0.5
DEFAULT_MINIMAL_BUILD_ENV = 'CC:gcc,CXX:g++'
DEFAULT_MNS = 'EasyBuildMNS'
DEFAULT_MODULE_SYNTAX = 'Lua'
DEFAULT_MODULES_TOOL = 'Lmod'
Expand Down Expand Up @@ -290,6 +291,9 @@ def mk_full_default_path(name, prefix=DEFAULT_PREFIX):
DEFAULT_MAX_FAIL_RATIO_PERMS: [
'max_fail_ratio_adjust_permissions',
],
DEFAULT_MINIMAL_BUILD_ENV: [
'minimal_build_env',
],
DEFAULT_PKG_RELEASE: [
'package_release',
],
Expand Down
17 changes: 11 additions & 6 deletions easybuild/tools/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,13 @@
from easybuild.tools.config import CONT_IMAGE_FORMATS, CONT_TYPES, DEFAULT_CONT_TYPE, DEFAULT_ALLOW_LOADED_MODULES
from easybuild.tools.config import DEFAULT_BRANCH, DEFAULT_FORCE_DOWNLOAD, DEFAULT_INDEX_MAX_AGE
from easybuild.tools.config import DEFAULT_JOB_BACKEND, DEFAULT_LOGFILE_FORMAT, DEFAULT_MAX_FAIL_RATIO_PERMS
from easybuild.tools.config import DEFAULT_MNS, DEFAULT_MODULE_SYNTAX, DEFAULT_MODULES_TOOL, DEFAULT_MODULECLASSES
from easybuild.tools.config import DEFAULT_PATH_SUBDIRS, DEFAULT_PKG_RELEASE, DEFAULT_PKG_TOOL, DEFAULT_PKG_TYPE
from easybuild.tools.config import DEFAULT_PNS, DEFAULT_PREFIX, DEFAULT_REPOSITORY, DEFAULT_WAIT_ON_LOCK_INTERVAL
from easybuild.tools.config import DEFAULT_WAIT_ON_LOCK_LIMIT, EBROOT_ENV_VAR_ACTIONS, ERROR, FORCE_DOWNLOAD_CHOICES
from easybuild.tools.config import GENERAL_CLASS, IGNORE, JOB_DEPS_TYPE_ABORT_ON_ERROR, JOB_DEPS_TYPE_ALWAYS_RUN
from easybuild.tools.config import LOADED_MODULES_ACTIONS, LOCAL_VAR_NAMING_CHECK_WARN, LOCAL_VAR_NAMING_CHECKS, WARN
from easybuild.tools.config import DEFAULT_MINIMAL_BUILD_ENV, DEFAULT_MNS, DEFAULT_MODULE_SYNTAX, DEFAULT_MODULES_TOOL
from easybuild.tools.config import DEFAULT_MODULECLASSES, DEFAULT_PATH_SUBDIRS, DEFAULT_PKG_RELEASE, DEFAULT_PKG_TOOL
from easybuild.tools.config import DEFAULT_PKG_TYPE, DEFAULT_PNS, DEFAULT_PREFIX, DEFAULT_REPOSITORY
from easybuild.tools.config import DEFAULT_WAIT_ON_LOCK_INTERVAL, DEFAULT_WAIT_ON_LOCK_LIMIT, EBROOT_ENV_VAR_ACTIONS
from easybuild.tools.config import ERROR, FORCE_DOWNLOAD_CHOICES, GENERAL_CLASS, IGNORE, JOB_DEPS_TYPE_ABORT_ON_ERROR
from easybuild.tools.config import JOB_DEPS_TYPE_ALWAYS_RUN, LOADED_MODULES_ACTIONS, LOCAL_VAR_NAMING_CHECK_WARN
from easybuild.tools.config import LOCAL_VAR_NAMING_CHECKS, WARN
from easybuild.tools.config import get_pretend_installpath, init, init_build_options, mk_full_default_path
from easybuild.tools.configobj import ConfigObj, ConfigObjError
from easybuild.tools.docs import FORMAT_TXT, FORMAT_RST
Expand Down Expand Up @@ -402,6 +403,10 @@ def override_options(self):
None, 'store_true', True),
'max-fail-ratio-adjust-permissions': ("Maximum ratio for failures to allow when adjusting permissions",
'float', 'store', DEFAULT_MAX_FAIL_RATIO_PERMS),
'minimal-build-env': ("Minimal build environment to define when using system toolchain, "
"specified as a comma-separated list that defines a mapping between name of "
"environment variable and its value separated by a colon (':')",
None, 'store', DEFAULT_MINIMAL_BUILD_ENV),
'minimal-toolchains': ("Use minimal toolchain when resolving dependencies", None, 'store_true', False),
'module-only': ("Only generate module file(s); skip all steps except for %s" % ', '.join(MODULE_ONLY_STEPS),
None, 'store_true', False),
Expand Down
66 changes: 63 additions & 3 deletions easybuild/tools/toolchain/toolchain.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@
import tempfile

from easybuild.base import fancylogger
from easybuild.tools.build_log import EasyBuildError, dry_run_msg
from easybuild.tools.build_log import EasyBuildError, dry_run_msg, print_warning
from easybuild.tools.config import build_option, install_path
from easybuild.tools.environment import setvar
from easybuild.tools.filetools import adjust_permissions, find_eb_script, read_file, which, write_file
Expand Down Expand Up @@ -238,6 +238,59 @@ def is_system_toolchain(self):
"""Return boolean to indicate whether this toolchain is a system(/dummy) toolchain."""
return is_system_toolchain(self.name)

def set_minimal_build_env(self):
"""Set up a minimal build environment, by setting (only) the $CC and $CXX environment variables."""

# this is only relevant when using a system toolchain,
# for proper toolchains these variables will get set via the call to set_variables()

minimal_build_env_raw = build_option('minimal_build_env')

minimal_build_env = {}
for key_val in minimal_build_env_raw.split(','):
parts = key_val.split(':')
if len(parts) == 2:
key, val = parts
minimal_build_env[key] = val
else:
raise EasyBuildError("Incorrect mapping in --minimal-build-env value: '%s'", key_val)

env_vars = {}
for key, val in minimal_build_env.items():
# for key environment variables like $CC and $CXX we are extra careful,
# by making sure the specified command is actually available
if key in ['CC', 'CXX']:
warning_msg = None
if os.path.isabs(val):
if os.path.exists(val):
self.log.info("Specified path for $%s exists: %s", key, val)
env_vars.update({key: val})
else:
warning_msg = "Specified path '%s' does not exist"
else:
cmd_path = which(val)
if cmd_path:
self.log.info("Found compiler command %s at %s, so setting $%s in minimal build environment",
val, cmd_path, key)
env_vars.update({key: val})
else:
warning_msg = "'%s' command not found in $PATH" % val

if warning_msg:
print_warning(warning_msg + ", not setting $%s in minimal build environment" % key, log=self.log)
else:
# no checking for environment variables other than $CC or $CXX
env_vars.update({key: val})

# set specified environment variables, but print a warning
# if we're redefining anything that was already set to a *different* value
for key, new_value in env_vars.items():
curr_value = os.getenv(key)
if curr_value and curr_value != new_value:
print_warning("$%s was defined as '%s', but is now set to '%s' in minimal build environment",
key, curr_value, new_value)
setvar(key, new_value)

def base_init(self):
"""Initialise missing class attributes (log, options, variables)."""
if not hasattr(self, 'log'):
Expand Down Expand Up @@ -780,8 +833,14 @@ def prepare(self, onlymod=None, deps=None, silent=False, loadmod=True,
if loadmod:
self._load_modules(silent=silent)

if not self.is_system_toolchain():
if self.is_system_toolchain():

# define minimal build environment when using system toolchain;
# this is mostly done to try controlling which compiler commands are being used,
# cfr. https://github.com/easybuilders/easybuild-framework/issues/3398
self.set_minimal_build_env()

else:
trace_msg("defining build environment for %s/%s toolchain" % (self.name, self.version))

if not self.dry_run:
Expand Down Expand Up @@ -953,10 +1012,11 @@ def prepare_rpath_wrappers(self, rpath_filter_dirs=None, rpath_include_dirs=None
}
write_file(cmd_wrapper, cmd_wrapper_txt)
adjust_permissions(cmd_wrapper, stat.S_IXUSR)
self.log.info("Wrapper script for %s: %s (log: %s)", orig_cmd, which(cmd), rpath_wrapper_log)

# prepend location to this wrapper to $PATH
setvar('PATH', '%s:%s' % (wrapper_dir, os.getenv('PATH')))

self.log.info("RPATH wrapper script for %s: %s (log: %s)", orig_cmd, which(cmd), rpath_wrapper_log)
else:
self.log.debug("Not installing RPATH wrapper for non-existing command '%s'", cmd)

Expand Down
196 changes: 196 additions & 0 deletions test/framework/toolchain.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,8 +166,204 @@ def test_is_system_toolchain(self):
self.assertTrue(tc.is_system_toolchain())
self.assertTrue(dummy_depr_warning in stderr, "Found '%s' in: %s" % (dummy_depr_warning, stderr))

def unset_compiler_env_vars(self):
"""Unset environment variables before checking whether they're set by the toolchain prep mechanism."""

comp_env_vars = ['CC', 'CXX', 'F77', 'F90', 'FC']

env_vars = ['CFLAGS', 'CXXFLAGS', 'F90FLAGS', 'FCFLAGS', 'FFLAGS'] + comp_env_vars[:]
env_vars.extend(['MPI%s' % x for x in comp_env_vars])
env_vars.extend(['OMPI_%s' % x for x in comp_env_vars])

for key in env_vars:
if key in os.environ:
del os.environ[key]

def test_toolchain_compiler_env_vars(self):
"""Test whether environment variables for compilers are defined by toolchain mechanism."""

# clean environment
self.unset_compiler_env_vars()
for key in ['CC', 'CXX', 'F77', 'F90', 'FC']:
self.assertEqual(os.getenv(key), None)

# install dummy 'gcc' and 'g++' commands, to make sure they're available
# (required since Toolchain.set_minimal_build_env checks whether these commands exist)
for cmd in ['gcc', 'g++']:
fake_cmd = os.path.join(self.test_prefix, cmd)
write_file(fake_cmd, '#!/bin/bash')
adjust_permissions(fake_cmd, stat.S_IRUSR | stat.S_IXUSR)
os.environ['PATH'] = self.test_prefix + ':' + os.environ['PATH']

# first try with system compiler: only minimal build environment is set up
tc = self.get_toolchain('system', version='system')
tc.set_options({})

# no warning about redefining if $CC/$CXX are not defined
self.mock_stderr(True)
self.mock_stdout(True)
tc.prepare()
stderr, stdout = self.get_stderr(), self.get_stdout()
self.mock_stderr(False)
self.mock_stdout(False)

self.assertEqual(stderr, '')
self.assertEqual(stdout, '')

# only $CC and $CXX are set, no point is setting environment variables for Fortran
# since gfortran is often not installed on the system
self.assertEqual(os.getenv('CC'), 'gcc')
self.assertEqual(os.getenv('CXX'), 'g++')
for key in ['F77', 'F90', 'FC']:
self.assertEqual(os.getenv(key), None)

# env vars for compiler flags and MPI compiler commands are not set for system toolchain
flags_keys = ['CFLAGS', 'CXXFLAGS', 'F90FLAGS', 'FCFLAGS', 'FFLAGS']
mpi_keys = ['MPICC', 'MPICXX', 'MPIFC', 'OMPI_CC', 'OMPI_CXX', 'OMPI_FC']
for key in flags_keys + mpi_keys:
self.assertEqual(os.getenv(key), None)

self.unset_compiler_env_vars()
for key in ['CC', 'CXX', 'F77', 'F90', 'FC']:
self.assertEqual(os.getenv(key), None)

# warning is printed when EasyBuild redefines environment variables in minimal build environment
os.environ['CC'] = 'foo'
os.environ['CXX'] = 'bar'

self.mock_stderr(True)
self.mock_stdout(True)
tc.prepare()
stderr, stdout = self.get_stderr(), self.get_stdout()
self.mock_stderr(False)
self.mock_stdout(False)

self.assertEqual(stdout, '')

for key, prev_val, new_val in [('CC', 'foo', 'gcc'), ('CXX', 'bar', 'g++')]:
warning_msg = "WARNING: $%s was defined as '%s', " % (key, prev_val)
warning_msg += "but is now set to '%s' in minimal build environment" % new_val
self.assertTrue(warning_msg in stderr)

self.assertEqual(os.getenv('CC'), 'gcc')
self.assertEqual(os.getenv('CXX'), 'g++')

# no warning if the values are identical to the ones used in the minimal build environment
self.mock_stderr(True)
self.mock_stdout(True)
tc.prepare()
stderr, stdout = self.get_stderr(), self.get_stdout()
self.mock_stderr(False)
self.mock_stdout(False)

self.assertEqual(stderr, '')
self.assertEqual(stdout, '')

del os.environ['CC']
del os.environ['CXX']

# check whether specification in --minimal-build-env is picked up
init_config(build_options={'minimal_build_env': 'CC:g++'})

tc.prepare()
self.assertEqual(os.getenv('CC'), 'g++')
self.assertEqual(os.getenv('CXX'), None)

del os.environ['CC']

# check whether a warning is printed when a value specified for $CC or $CXX is not found
init_config(build_options={'minimal_build_env': 'CC:nosuchcommand,CXX:gcc'})

self.mock_stderr(True)
self.mock_stdout(True)
tc.prepare()
stderr, stdout = self.get_stderr(), self.get_stdout()
self.mock_stderr(False)
self.mock_stdout(False)

warning_msg = "WARNING: 'nosuchcommand' command not found in $PATH, "
warning_msg += "not setting $CC in minimal build environment"
self.assertTrue(warning_msg in stderr)
self.assertEqual(stdout, '')

self.assertEqual(os.getenv('CC'), None)
self.assertEqual(os.getenv('CXX'), 'gcc')

# no warning for defining environment variable that was previously undefined,
# only warning on redefining, other values can be whatever (and can include spaces)
init_config(build_options={'minimal_build_env': 'CC:gcc,CXX:g++,CFLAGS:-O2,CXXFLAGS:-O3 -g,FC:gfortan'})

for key in ['CFLAGS', 'CXXFLAGS', 'FC']:
if key in os.environ:
del os.environ[key]

self.mock_stderr(True)
self.mock_stdout(True)
tc.prepare()
stderr, stdout = self.get_stderr(), self.get_stdout()
self.mock_stderr(False)
self.mock_stdout(False)

self.assertEqual(os.getenv('CC'), 'gcc')
self.assertEqual(os.getenv('CXX'), 'g++')
self.assertEqual(os.getenv('CFLAGS'), '-O2')
self.assertEqual(os.getenv('CXXFLAGS'), '-O3 -g')
self.assertEqual(os.getenv('FC'), 'gfortan')

# incorrect spec in minimal_build_env results in an error
init_config(build_options={'minimal_build_env': 'CC=gcc'})
error_pattern = "Incorrect mapping in --minimal-build-env value: 'CC=gcc'"
self.assertErrorRegex(EasyBuildError, error_pattern, tc.prepare)

init_config(build_options={'minimal_build_env': 'foo:bar:baz'})
error_pattern = "Incorrect mapping in --minimal-build-env value: 'foo:bar:baz'"
self.assertErrorRegex(EasyBuildError, error_pattern, tc.prepare)

init_config(build_options={'minimal_build_env': 'CC:gcc,foo'})
error_pattern = "Incorrect mapping in --minimal-build-env value: 'foo'"
self.assertErrorRegex(EasyBuildError, error_pattern, tc.prepare)

init_config(build_options={'minimal_build_env': 'foo:bar:baz,CC:gcc'})
error_pattern = "Incorrect mapping in --minimal-build-env value: 'foo:bar:baz'"
self.assertErrorRegex(EasyBuildError, error_pattern, tc.prepare)

init_config(build_options={'minimal_build_env': 'CC:gcc,'})
error_pattern = "Incorrect mapping in --minimal-build-env value: ''"
self.assertErrorRegex(EasyBuildError, error_pattern, tc.prepare)

# for a full toolchain, a more extensive build environment is set up (incl. $CFLAGS & co),
# and the specs in --minimal-build-env are ignored
tc = self.get_toolchain('foss', version='2018a')
tc.set_options({})

# catch potential warning about too long $TMPDIR value that causes trouble for Open MPI (irrelevant here)
self.mock_stderr(True)
tc.prepare()
self.mock_stderr(False)

self.assertEqual(os.getenv('CC'), 'gcc')
self.assertEqual(os.getenv('CXX'), 'g++')
self.assertEqual(os.getenv('F77'), 'gfortran')
self.assertEqual(os.getenv('F90'), 'gfortran')
self.assertEqual(os.getenv('FC'), 'gfortran')

self.assertEqual(os.getenv('MPICC'), 'mpicc')
self.assertEqual(os.getenv('MPICXX'), 'mpicxx')
self.assertEqual(os.getenv('MPIF77'), 'mpifort')
self.assertEqual(os.getenv('MPIF90'), 'mpifort')
self.assertEqual(os.getenv('MPIFC'), 'mpifort')

self.assertEqual(os.getenv('OMPI_CC'), 'gcc')
self.assertEqual(os.getenv('OMPI_CXX'), 'g++')
self.assertEqual(os.getenv('OMPI_F77'), 'gfortran')
self.assertEqual(os.getenv('OMPI_FC'), 'gfortran')

for key in ['CFLAGS', 'CXXFLAGS', 'F90FLAGS', 'FCFLAGS', 'FFLAGS']:
self.assertEqual(os.getenv(key), "-O2 -ftree-vectorize -march=native -fno-math-errno")

def test_get_variable_compilers(self):
"""Test get_variable function to obtain compiler variables."""

tc = self.get_toolchain('foss', version='2018a')
tc.prepare()

Expand Down