Repository: easybuilders/easybuild-framework

Issue:
installing M4-1.4.18.eb with --rpath fails if x86_64-pc-linux-gnu-gcc command is available
The RPATH sanity check fails because there is no `(RPATH)` section found in the output of `readelf -d`.

It seems that Autotools prefers using `x86_64-pc-linux-gnu-gcc` as compiler command when it is around (which it is in a Gentoo Prefix environment), and EasyBuild currently doesn't install a wrapper for this (only for `gcc`, `g++`, etc.).

This is probably only an issue when the system compiler is used, since in that case EasyBuild doesn't define `$CC`, `$CXX` and co. When `$CC` is set to `gcc`, the M4 build system uses `gcc` as expected, and then the issue does not occur (since there's an RPATH wrapper installed for `gcc`).

Two possible solutions:

* install wrappers for additional compiler commands when using the system compiler (`cc`, `x86_64-pc-linux-gnu-gcc`, and who knows what else)
* define `$CC`, `$CXX` & co to try and ensure the compiler wrappers are picked up

I'm in favor of the latter approach, since that's likely to be easier in the long run.


Discussion:

Resolving patch:
diff --git a/easybuild/tools/config.py b/easybuild/tools/config.py
--- a/easybuild/tools/config.py
+++ b/easybuild/tools/config.py
@@ -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'
@@ -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',
     ],
diff --git a/easybuild/tools/options.py b/easybuild/tools/options.py
--- a/easybuild/tools/options.py
+++ b/easybuild/tools/options.py
@@ -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
@@ -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),
diff --git a/easybuild/tools/toolchain/toolchain.py b/easybuild/tools/toolchain/toolchain.py
--- a/easybuild/tools/toolchain/toolchain.py
+++ b/easybuild/tools/toolchain/toolchain.py
@@ -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
@@ -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'):
@@ -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:
@@ -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)
 


Test patch:
diff --git a/test/framework/toolchain.py b/test/framework/toolchain.py
--- a/test/framework/toolchain.py
+++ b/test/framework/toolchain.py
@@ -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()