Repository: spack/spack

Issue:
CC, CXX etc. might be overridden by external modules loaded by compilers
When a compiler configuration is set to load a module, that module might contain modifications to the environment that should not be taken into account when building a package from Spack (for instance to `CC`, `CXX`, `FC` and `F77`). This can lead to weird errors during the build.

Stumbled on this while using a module for Intel compilers that set variables like `ICCCFG` (needed during the build) and also variables like `CC` (breaking the build, and in the module because it was meant to be used outside of Spack).

### Expected Result

`CC` and similar variables always point to Spack compiler wrappers when building a package in Spack.

### Actual Result

Environment modifications done loading modules happen last, so `CC` could be modified by module files. Note that according to comments in `build_environment` they happen last for good reasons, so changing the order of applications of the various environment modifications might not be an option.

### Steps to reproduce the issue

To reproduce the issue it should suffice to have an entry in `compilers.yaml` like:
```yaml
compilers:
- compiler:
    environment: {}
    extra_rpaths: []
    flags: {}
    modules: ['intel/18.0.2']
    operating_system: rhel7
    paths:
      cc: /ssoft/spack/external/intel/2018.2/compilers_and_libraries_2018.2.199/linux/bin/intel64/icc
      cxx: /ssoft/spack/external/intel/2018.2/compilers_and_libraries_2018.2.199/linux/bin/intel64/icpc
      f77: /ssoft/spack/external/intel/2018.2/compilers_and_libraries_2018.2.199/linux/bin/intel64/ifort
      fc: /ssoft/spack/external/intel/2018.2/compilers_and_libraries_2018.2.199/linux/bin/intel64/ifort
    spec: intel@18.0.2
    target: x86_64
```
where the module loaded sets `CC`, `CXX`, etc.



Discussion:

Resolving patch:
diff --git a/lib/spack/spack/build_environment.py b/lib/spack/spack/build_environment.py
--- a/lib/spack/spack/build_environment.py
+++ b/lib/spack/spack/build_environment.py
@@ -71,6 +71,7 @@
 import spack.paths
 import spack.store
 from spack.environment import EnvironmentModifications, validate
+from spack.environment import preserve_environment
 from spack.util.environment import env_flag, filter_system_paths, get_path
 from spack.util.executable import Executable
 from spack.util.module_cmd import load_module, get_path_from_module
@@ -130,7 +131,7 @@ def __call__(self, *args, **kwargs):
 
 
 def set_compiler_environment_variables(pkg, env):
-    assert(pkg.spec.concrete)
+    assert pkg.spec.concrete
     compiler = pkg.compiler
 
     # Set compiler variables used by CMake and autotools
@@ -610,20 +611,26 @@ def setup_package(pkg, dirty):
     validate(spack_env, tty.warn)
     spack_env.apply_modifications()
 
-    # All module loads that otherwise would belong in previous functions
-    # have to occur after the spack_env object has its modifications applied.
-    # Otherwise the environment modifications could undo module changes, such
-    # as unsetting LD_LIBRARY_PATH after a module changes it.
-    for mod in pkg.compiler.modules:
-        # Fixes issue https://github.com/spack/spack/issues/3153
-        if os.environ.get("CRAY_CPU_TARGET") == "mic-knl":
-            load_module("cce")
-        load_module(mod)
-
-    if pkg.architecture.target.module_name:
-        load_module(pkg.architecture.target.module_name)
-
-    load_external_modules(pkg)
+    # Loading modules, in particular if they are meant to be used outside
+    # of Spack, can change environment variables that are relevant to the
+    # build of packages. To avoid a polluted environment, preserve the
+    # value of a few, selected, environment variables
+    with preserve_environment('CC', 'CXX', 'FC', 'F77'):
+        # All module loads that otherwise would belong in previous
+        # functions have to occur after the spack_env object has its
+        # modifications applied. Otherwise the environment modifications
+        # could undo module changes, such as unsetting LD_LIBRARY_PATH
+        # after a module changes it.
+        for mod in pkg.compiler.modules:
+            # Fixes issue https://github.com/spack/spack/issues/3153
+            if os.environ.get("CRAY_CPU_TARGET") == "mic-knl":
+                load_module("cce")
+            load_module(mod)
+
+        if pkg.architecture.target.module_name:
+            load_module(pkg.architecture.target.module_name)
+
+        load_external_modules(pkg)
 
 
 def fork(pkg, function, dirty, fake):
diff --git a/lib/spack/spack/environment.py b/lib/spack/spack/environment.py
--- a/lib/spack/spack/environment.py
+++ b/lib/spack/spack/environment.py
@@ -23,6 +23,7 @@
 # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
 ##############################################################################
 import collections
+import contextlib
 import inspect
 import json
 import os
@@ -30,6 +31,9 @@
 import sys
 import os.path
 import subprocess
+
+import llnl.util.tty as tty
+
 from llnl.util.lang import dedupe
 
 
@@ -598,3 +602,41 @@ def inspect_path(root, inspections, exclude=None):
                 env.prepend_path(variable, expected)
 
     return env
+
+
+@contextlib.contextmanager
+def preserve_environment(*variables):
+    """Ensures that the value of the environment variables passed as
+    arguments is the same before entering to the context manager and after
+    exiting it.
+
+    Variables that are unset before entering the context manager will be
+    explicitly unset on exit.
+
+    Args:
+        variables (list of str): list of environment variables to be preserved
+    """
+    cache = {}
+    for var in variables:
+        # The environment variable to be preserved might not be there.
+        # In that case store None as a placeholder.
+        cache[var] = os.environ.get(var, None)
+
+    yield
+
+    for var in variables:
+        value = cache[var]
+        msg = '[PRESERVE_ENVIRONMENT]'
+        if value is not None:
+            # Print a debug statement if the value changed
+            if var not in os.environ:
+                msg += ' {0} was unset, will be reset to "{1}"'
+                tty.debug(msg.format(var, value))
+            elif os.environ[var] != value:
+                msg += ' {0} was set to "{1}", will be reset to "{2}"'
+                tty.debug(msg.format(var, os.environ[var], value))
+            os.environ[var] = value
+        elif var in os.environ:
+            msg += ' {0} was set to "{1}", will be unset'
+            tty.debug(msg.format(var, os.environ[var]))
+            del os.environ[var]


Test patch:
diff --git a/lib/spack/spack/test/build_environment.py b/lib/spack/spack/test/build_environment.py
--- a/lib/spack/spack/test/build_environment.py
+++ b/lib/spack/spack/test/build_environment.py
@@ -25,6 +25,8 @@
 import os
 import pytest
 
+import spack.build_environment
+import spack.spec
 from spack.paths import build_env_path
 from spack.build_environment import dso_suffix, _static_to_shared_library
 from spack.util.executable import Executable
@@ -96,3 +98,28 @@ def test_static_to_shared_library(build_environment):
 
             assert output == expected[arch].format(
                 static_lib, shared_lib, os.path.basename(shared_lib))
+
+
+@pytest.mark.regression('8345')
+@pytest.mark.usefixtures('config', 'mock_packages')
+def test_cc_not_changed_by_modules(monkeypatch):
+
+    s = spack.spec.Spec('cmake')
+    s.concretize()
+    pkg = s.package
+
+    def _set_wrong_cc(x):
+        os.environ['CC'] = 'NOT_THIS_PLEASE'
+        os.environ['ANOTHER_VAR'] = 'THIS_IS_SET'
+
+    monkeypatch.setattr(
+        spack.build_environment, 'load_module', _set_wrong_cc
+    )
+    monkeypatch.setattr(
+        pkg.compiler, 'modules', ['some_module']
+    )
+
+    spack.build_environment.setup_package(pkg, False)
+
+    assert os.environ['CC'] != 'NOT_THIS_PLEASE'
+    assert os.environ['ANOTHER_VAR'] == 'THIS_IS_SET'
diff --git a/lib/spack/spack/test/environment.py b/lib/spack/spack/test/environment.py
--- a/lib/spack/spack/test/environment.py
+++ b/lib/spack/spack/test/environment.py
@@ -306,3 +306,22 @@ def test_source_files(files_to_be_sourced):
     assert modifications['PATH_LIST'][1].value == '/path/fourth'
     assert isinstance(modifications['PATH_LIST'][2], PrependPath)
     assert modifications['PATH_LIST'][2].value == '/path/first'
+
+
+@pytest.mark.regression('8345')
+def test_preserve_environment(prepare_environment_for_tests):
+    # UNSET_ME is defined, and will be unset in the context manager,
+    # NOT_SET is not in the environment and will be set within the
+    # context manager, PATH_LIST is set and will be changed.
+    with environment.preserve_environment('UNSET_ME', 'NOT_SET', 'PATH_LIST'):
+        os.environ['NOT_SET'] = 'a'
+        assert os.environ['NOT_SET'] == 'a'
+
+        del os.environ['UNSET_ME']
+        assert 'UNSET_ME' not in os.environ
+
+        os.environ['PATH_LIST'] = 'changed'
+
+    assert 'NOT_SET' not in os.environ
+    assert os.environ['UNSET_ME'] == 'foo'
+    assert os.environ['PATH_LIST'] == '/path/second:/path/third'