fix racy sbang (#33549)

Spack currently creates a temporary sbang that is moved "atomically" in place,
but this temporary causes races when multiple processes start installing sbang.

Let's just stick to an idempotent approach. Notice that we only re-install sbang
if Spack updates it (since we do file compare), and sbang was only touched
18 times in the past 6 years, whereas we hit the sbang tempfile issue
frequently with parallel install on a fresh spack instance in CI.

Also fixes a bug where permissions weren't updated if config changed but
the latest version of the sbang file was already installed.
This commit is contained in:
Harmen Stoppels 2022-11-08 20:05:05 +01:00 committed by GitHub
parent 4a5e68816b
commit 4d28a64661
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 63 additions and 41 deletions

View File

@ -1000,16 +1000,45 @@ def hash_directory(directory, ignore=[]):
return md5_hash.hexdigest() return md5_hash.hexdigest()
def _try_unlink(path):
try:
os.unlink(path)
except (IOError, OSError):
# But if that fails, that's OK.
pass
@contextmanager @contextmanager
@system_path_filter @system_path_filter
def write_tmp_and_move(filename): def write_tmp_and_move(path, mode="w"):
"""Write to a temporary file, then move into place.""" """Write to a temporary file in the same directory, then move into place."""
dirname = os.path.dirname(filename) # Rely on NamedTemporaryFile to give a unique file without races
basename = os.path.basename(filename) # in the directory of the target file.
tmp = os.path.join(dirname, ".%s.tmp" % basename) file = tempfile.NamedTemporaryFile(
with open(tmp, "w") as f: prefix="." + os.path.basename(path),
yield f suffix=".tmp",
shutil.move(tmp, filename) dir=os.path.dirname(path),
mode=mode,
delete=False, # we delete it ourselves
)
tmp_path = file.name
try:
yield file
except BaseException:
# On any failure, try to remove the temporary file.
_try_unlink(tmp_path)
raise
finally:
# Always close the file decriptor
file.close()
# Atomically move into existence.
try:
os.rename(tmp_path, path)
except (IOError, OSError):
_try_unlink(tmp_path)
raise
@contextmanager @contextmanager

View File

@ -45,7 +45,7 @@
import llnl.util.lang import llnl.util.lang
import llnl.util.tty as tty import llnl.util.tty as tty
from llnl.util.filesystem import mkdirp, rename from llnl.util.filesystem import mkdirp, write_tmp_and_move
import spack.compilers import spack.compilers
import spack.paths import spack.paths
@ -287,10 +287,8 @@ def _write_section(self, section):
parent = os.path.dirname(self.path) parent = os.path.dirname(self.path)
mkdirp(parent) mkdirp(parent)
tmp = os.path.join(parent, ".%s.tmp" % os.path.basename(self.path)) with write_tmp_and_move(self.path) as f:
with open(tmp, "w") as f:
syaml.dump_config(data_to_write, stream=f, default_flow_style=False) syaml.dump_config(data_to_write, stream=f, default_flow_style=False)
rename(tmp, self.path)
except (yaml.YAMLError, IOError) as e: except (yaml.YAMLError, IOError) as e:
raise ConfigFileError("Error writing to config file: '%s'" % str(e)) raise ConfigFileError("Error writing to config file: '%s'" % str(e))

View File

@ -186,44 +186,39 @@ def install_sbang():
``sbang`` here ensures that users can access the script and that ``sbang`` here ensures that users can access the script and that
``sbang`` itself is in a short path. ``sbang`` itself is in a short path.
""" """
# copy in a new version of sbang if it differs from what's in spack
sbang_path = sbang_install_path() sbang_path = sbang_install_path()
if os.path.exists(sbang_path) and filecmp.cmp(spack.paths.sbang_script, sbang_path):
return
# make $install_tree/bin all = spack.spec.Spec("all")
group_name = spack.package_prefs.get_package_group(all)
config_mode = spack.package_prefs.get_package_dir_permissions(all)
group_id = grp.getgrnam(group_name).gr_gid if group_name else None
# First setup the bin dir correctly.
sbang_bin_dir = os.path.dirname(sbang_path) sbang_bin_dir = os.path.dirname(sbang_path)
if not os.path.isdir(sbang_bin_dir):
fs.mkdirp(sbang_bin_dir) fs.mkdirp(sbang_bin_dir)
# get permissions for bin dir from configuration files # Set group and ownership like we do on package directories
group_name = spack.package_prefs.get_package_group(spack.spec.Spec("all")) if group_id:
config_mode = spack.package_prefs.get_package_dir_permissions(spack.spec.Spec("all")) os.chown(sbang_bin_dir, os.stat(sbang_bin_dir).st_uid, group_id)
os.chmod(sbang_bin_dir, config_mode)
if group_name:
os.chmod(sbang_bin_dir, config_mode) # Use package directory permissions
else: else:
fs.set_install_permissions(sbang_bin_dir) fs.set_install_permissions(sbang_bin_dir)
# set group on sbang_bin_dir if not already set (only if set in configuration) # Then check if we need to install sbang itself.
# TODO: after we drop python2 support, use shutil.chown to avoid gid lookups that try:
# can fail for remote groups already_installed = filecmp.cmp(spack.paths.sbang_script, sbang_path)
if group_name and os.stat(sbang_bin_dir).st_gid != grp.getgrnam(group_name).gr_gid: except (IOError, OSError):
os.chown(sbang_bin_dir, os.stat(sbang_bin_dir).st_uid, grp.getgrnam(group_name).gr_gid) already_installed = False
# copy over the fresh copy of `sbang` if not already_installed:
sbang_tmp_path = os.path.join( with fs.write_tmp_and_move(sbang_path) as f:
os.path.dirname(sbang_path), shutil.copy(spack.paths.sbang_script, f.name)
".%s.tmp" % os.path.basename(sbang_path),
)
shutil.copy(spack.paths.sbang_script, sbang_tmp_path)
# set permissions on `sbang` (including group if set in configuration) # Set permissions on `sbang` (including group if set in configuration)
os.chmod(sbang_tmp_path, config_mode) os.chmod(sbang_path, config_mode)
if group_name: if group_id:
os.chown(sbang_tmp_path, os.stat(sbang_tmp_path).st_uid, grp.getgrnam(group_name).gr_gid) os.chown(sbang_path, os.stat(sbang_path).st_uid, group_id)
# Finally, move the new `sbang` into place atomically
os.rename(sbang_tmp_path, sbang_path)
def post_install(spec): def post_install(spec):