Repository: easybuilders/easybuild-framework

Issue:
using "eb --skip --force" to install additional extensions fails if original installation was done with "--read-only-installdir"
For example, when installing additional extensions for `R-bundle-Bioconductor:

```
Error: ERROR: no permission to install to directory /software/R-bundle-Bioconductor/3.9-foss-2019a-R-3.6.0
```


Discussion:
Mmmh, is this really a bug? I see that as the owner of the installation you might not want to be bothered with these pesky things. But in shared environments, if I use `--read-only-installdir` maybe I don't want others to add things to my setup. The current behaviour acts at least as a measure to disuade them.
@damianam As long as the account used to run `eb` has the permissions to enable write permissions on the installation directory, EasyBuild should temporarily re-enable write permissions when `--skip --force` is used, so additional extensions can be installed.

The read-only permissions are mostly set to prevent easyconfig A (for example a Python package) installing stuff in the installation directory of B (for example the `Python` installation itself).

Resolving patch:
diff --git a/easybuild/framework/easyblock.py b/easybuild/framework/easyblock.py
--- a/easybuild/framework/easyblock.py
+++ b/easybuild/framework/easyblock.py
@@ -3401,6 +3401,11 @@ def build_and_install_one(ecdict, init_env):
             reprod_dir_root = os.path.dirname(app.logfile)
             reprod_dir = reproduce_build(app, reprod_dir_root)
 
+            if os.path.exists(app.installdir) and build_option('read_only_installdir') and (
+                    build_option('rebuild') or build_option('force')):
+                # re-enable write permissions so we can install additional modules
+                adjust_permissions(app.installdir, stat.S_IWUSR, add=True, recursive=True)
+
         result = app.run_all_steps(run_test_cases=run_test_cases)
 
         if not dry_run:
@@ -3435,7 +3440,7 @@ def build_and_install_one(ecdict, init_env):
             if build_option('read_only_installdir'):
                 # temporarily re-enable write permissions for copying log/easyconfig to install dir
                 if os.path.exists(new_log_dir):
-                    adjust_permissions(new_log_dir, stat.S_IWUSR, add=True, recursive=False)
+                    adjust_permissions(new_log_dir, stat.S_IWUSR, add=True, recursive=True)
                 else:
                     adjust_permissions(app.installdir, stat.S_IWUSR, add=True, recursive=False)
                     mkdir(new_log_dir, parents=True)


Test patch:
diff --git a/test/framework/options.py b/test/framework/options.py
--- a/test/framework/options.py
+++ b/test/framework/options.py
@@ -3405,9 +3405,12 @@ def mk_eb_test_cmd(self, args):
         """Construct test command for 'eb' with given options."""
 
         # make sure that location to 'easybuild.main' is included in $PYTHONPATH
-        pythonpath = os.getenv('PYTHONPATH')
         easybuild_loc = os.path.dirname(os.path.dirname(easybuild.main.__file__))
-        os.environ['PYTHONPATH'] = ':'.join([easybuild_loc, pythonpath])
+        try:
+            pythonpath = easybuild_loc + ':' + os.environ['PYTHONPATH']
+        except KeyError:
+            pythonpath = easybuild_loc
+        os.environ['PYTHONPATH'] = pythonpath
 
         return '; '.join([
             "cd %s" % self.test_prefix,
diff --git a/test/framework/toy_build.py b/test/framework/toy_build.py
--- a/test/framework/toy_build.py
+++ b/test/framework/toy_build.py
@@ -130,11 +130,13 @@ def check_toy(self, installpath, outtxt, version='0.0', versionprefix='', versio
         # make sure installation log file and easyconfig file are copied to install dir
         software_path = os.path.join(installpath, 'software', 'toy', full_version)
         install_log_path_pattern = os.path.join(software_path, 'easybuild', 'easybuild-toy-%s*.log' % version)
-        self.assertTrue(len(glob.glob(install_log_path_pattern)) == 1, "Found 1 file at %s" % install_log_path_pattern)
+        self.assertTrue(len(glob.glob(install_log_path_pattern)) >= 1,
+                        "Found  at least 1 file at %s" % install_log_path_pattern)
 
         # make sure test report is available
         test_report_path_pattern = os.path.join(software_path, 'easybuild', 'easybuild-toy-%s*test_report.md' % version)
-        self.assertTrue(len(glob.glob(test_report_path_pattern)) == 1, "Found 1 file at %s" % test_report_path_pattern)
+        self.assertTrue(len(glob.glob(test_report_path_pattern)) >= 1,
+                        "Found  at least 1 file at %s" % test_report_path_pattern)
 
         ec_file_path = os.path.join(software_path, 'easybuild', 'toy-%s.eb' % full_version)
         self.assertTrue(os.path.exists(ec_file_path))
@@ -144,7 +146,7 @@ def check_toy(self, installpath, outtxt, version='0.0', versionprefix='', versio
 
     def test_toy_build(self, extra_args=None, ec_file=None, tmpdir=None, verify=True, fails=False, verbose=True,
                        raise_error=False, test_report=None, versionsuffix='', testing=True,
-                       raise_systemexit=False):
+                       raise_systemexit=False, force=True):
         """Perform a toy build."""
         if extra_args is None:
             extra_args = []
@@ -160,9 +162,10 @@ def test_toy_build(self, extra_args=None, ec_file=None, tmpdir=None, verify=True
             '--installpath=%s' % self.test_installpath,
             '--debug',
             '--unittest-file=%s' % self.logfile,
-            '--force',
             '--robot=%s' % os.pathsep.join([self.test_buildpath, os.path.dirname(__file__)]),
         ]
+        if force:
+            args.append('--force')
         if tmpdir is not None:
             args.append('--tmpdir=%s' % tmpdir)
         if test_report is not None:
@@ -574,13 +577,18 @@ def test_toy_permissions(self):
 
     def test_toy_permissions_installdir(self):
         """Test --read-only-installdir and --group-write-installdir."""
+        # Avoid picking up the already prepared fake module
+        try:
+            del os.environ['MODULEPATH']
+        except KeyError:
+            pass
         # set umask hard to verify default reliably
         orig_umask = os.umask(0o022)
 
         toy_ec = os.path.join(os.path.dirname(__file__), 'easyconfigs', 'test_ecs', 't', 'toy', 'toy-0.0.eb')
         test_ec_txt = read_file(toy_ec)
         # take away read permissions, to check whether they are correctly restored by EasyBuild after installation
-        test_ec_txt += "\npostinstallcmds = ['chmod -R og-r %(installdir)s']"
+        test_ec_txt += "\npostinstallcmds += ['chmod -R og-r %(installdir)s']"
 
         test_ec = os.path.join(self.test_prefix, 'test.eb')
         write_file(test_ec, test_ec_txt)
@@ -600,19 +608,30 @@ def test_toy_permissions_installdir(self):
         shutil.rmtree(self.test_installpath)
 
         # check whether --read-only-installdir works as intended
-        self.test_toy_build(ec_file=test_ec, extra_args=['--read-only-installdir'])
-        installdir_perms = os.stat(toy_install_dir).st_mode & 0o777
-        self.assertEqual(installdir_perms, 0o555, "%s has read-only permissions" % toy_install_dir)
-        installdir_perms = os.stat(os.path.dirname(toy_install_dir)).st_mode & 0o777
-        self.assertEqual(installdir_perms, 0o755, "%s has default permissions" % os.path.dirname(toy_install_dir))
+        # Tested 5 times:
+        # 1. Non existing build -> Install and set read-only
+        # 2. Existing build with --rebuild -> Reinstall and set read-only
+        # 3. Existing build with --force -> Reinstall and set read-only
+        # 4-5: Same as 2-3 but with --skip
+        for extra_args in ([], ['--rebuild'], ['--force'], ['--skip', '--rebuild'], ['--skip', '--force']):
+            self.mock_stdout(True)
+            self.test_toy_build(ec_file=test_ec, extra_args=['--read-only-installdir'] + extra_args, force=False)
+            self.mock_stdout(False)
 
-        # also log file copied into install dir should be read-only (not just the 'easybuild/' subdir itself)
-        log_path = glob.glob(os.path.join(toy_install_dir, 'easybuild', '*log'))[0]
-        log_perms = os.stat(log_path).st_mode & 0o777
-        self.assertEqual(log_perms, 0o444, "%s has read-only permissions" % log_path)
+            installdir_perms = os.stat(os.path.dirname(toy_install_dir)).st_mode & 0o777
+            self.assertEqual(installdir_perms, 0o755, "%s has default permissions" % os.path.dirname(toy_install_dir))
 
-        toy_bin_perms = os.stat(toy_bin).st_mode & 0o777
-        self.assertEqual(toy_bin_perms, 0o555, "%s has read-only permissions" % toy_bin_perms)
+            installdir_perms = os.stat(toy_install_dir).st_mode & 0o777
+            self.assertEqual(installdir_perms, 0o555, "%s has read-only permissions" % toy_install_dir)
+            toy_bin_perms = os.stat(toy_bin).st_mode & 0o777
+            self.assertEqual(toy_bin_perms, 0o555, "%s has read-only permissions" % toy_bin_perms)
+            toy_bin_perms = os.stat(os.path.join(toy_install_dir, 'README')).st_mode & 0o777
+            self.assertEqual(toy_bin_perms, 0o444, "%s has read-only permissions" % toy_bin_perms)
+
+            # also log file copied into install dir should be read-only (not just the 'easybuild/' subdir itself)
+            log_path = glob.glob(os.path.join(toy_install_dir, 'easybuild', '*log'))[0]
+            log_perms = os.stat(log_path).st_mode & 0o777
+            self.assertEqual(log_perms, 0o444, "%s has read-only permissions" % log_path)
 
         adjust_permissions(toy_install_dir, stat.S_IWUSR, add=True)
         shutil.rmtree(self.test_installpath)