Fix prefix-collision detection for projections (#24049)

If two Specs have the same hash (and prefix) but are not equal, Spack
originally had logic to detect this and raise an error (since both
cannot be installed in the same place). Recently this has eroded and
the check no-longer works; moreover, when defining projections (which
may truncate the hash or other distinguishing properties from the
prefix) Spack was also failing to detect collisions (in both of these
cases, Spack would overwrite the old prefix with the new Spec).

This PR maintains a list of all "taken" prefixes: if a hash is not
registered (i.e. recorded as installed in the database) but the prefix
is occupied, that is a collision. This can detect collisions created
by defining projections (specifically when they omit the hash).

The PR does not detect collisions where specs have the same hash
(and prefix) but are not equal.
This commit is contained in:
Harmen Stoppels 2021-06-29 23:44:56 +02:00 committed by GitHub
parent 1eb2798c43
commit 304249604a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 82 additions and 33 deletions

View File

@ -23,12 +23,13 @@
import contextlib import contextlib
import datetime import datetime
import os import os
import six
import socket import socket
import sys import sys
import time import time
from typing import Dict # novm from typing import Dict # novm
import six
try: try:
import uuid import uuid
_use_uuid = True _use_uuid = True
@ -38,7 +39,6 @@
import llnl.util.filesystem as fs import llnl.util.filesystem as fs
import llnl.util.tty as tty import llnl.util.tty as tty
import spack.repo import spack.repo
import spack.spec import spack.spec
import spack.store import spack.store
@ -382,6 +382,11 @@ def __init__(self, root, db_dir=None, upstream_dbs=None,
desc='database') desc='database')
self._data = {} self._data = {}
# For every installed spec we keep track of its install prefix, so that
# we can answer the simple query whether a given path is already taken
# before installing a different spec.
self._installed_prefixes = set()
self.upstream_dbs = list(upstream_dbs) if upstream_dbs else [] self.upstream_dbs = list(upstream_dbs) if upstream_dbs else []
# whether there was an error at the start of a read transaction # whether there was an error at the start of a read transaction
@ -774,6 +779,7 @@ def invalid_record(hash_key, error):
# Pass 1: Iterate through database and build specs w/o dependencies # Pass 1: Iterate through database and build specs w/o dependencies
data = {} data = {}
installed_prefixes = set()
for hash_key, rec in installs.items(): for hash_key, rec in installs.items():
try: try:
# This constructs a spec DAG from the list of all installs # This constructs a spec DAG from the list of all installs
@ -784,6 +790,9 @@ def invalid_record(hash_key, error):
# TODO: would a more immmutable spec implementation simplify # TODO: would a more immmutable spec implementation simplify
# this? # this?
data[hash_key] = InstallRecord.from_dict(spec, rec) data[hash_key] = InstallRecord.from_dict(spec, rec)
if not spec.external and 'installed' in rec and rec['installed']:
installed_prefixes.add(rec['path'])
except Exception as e: except Exception as e:
invalid_record(hash_key, e) invalid_record(hash_key, e)
@ -805,6 +814,7 @@ def invalid_record(hash_key, error):
rec.spec._mark_root_concrete() rec.spec._mark_root_concrete()
self._data = data self._data = data
self._installed_prefixes = installed_prefixes
def reindex(self, directory_layout): def reindex(self, directory_layout):
"""Build database index from scratch based on a directory layout. """Build database index from scratch based on a directory layout.
@ -824,6 +834,7 @@ def _read_suppress_error():
except CorruptDatabaseError as e: except CorruptDatabaseError as e:
self._error = e self._error = e
self._data = {} self._data = {}
self._installed_prefixes = set()
transaction = lk.WriteTransaction( transaction = lk.WriteTransaction(
self.lock, acquire=_read_suppress_error, release=self._write self.lock, acquire=_read_suppress_error, release=self._write
@ -838,12 +849,14 @@ def _read_suppress_error():
self._error = None self._error = None
old_data = self._data old_data = self._data
old_installed_prefixes = self._installed_prefixes
try: try:
self._construct_from_directory_layout( self._construct_from_directory_layout(
directory_layout, old_data) directory_layout, old_data)
except BaseException: except BaseException:
# If anything explodes, restore old data, skip write. # If anything explodes, restore old data, skip write.
self._data = old_data self._data = old_data
self._installed_prefixes = old_installed_prefixes
raise raise
def _construct_entry_from_directory_layout(self, directory_layout, def _construct_entry_from_directory_layout(self, directory_layout,
@ -880,6 +893,7 @@ def _construct_from_directory_layout(self, directory_layout, old_data):
with directory_layout.disable_upstream_check(): with directory_layout.disable_upstream_check():
# Initialize data in the reconstructed DB # Initialize data in the reconstructed DB
self._data = {} self._data = {}
self._installed_prefixes = set()
# Start inspecting the installed prefixes # Start inspecting the installed prefixes
processed_specs = set() processed_specs = set()
@ -1087,6 +1101,8 @@ def _add(
path = None path = None
if not spec.external and directory_layout: if not spec.external and directory_layout:
path = directory_layout.path_for_spec(spec) path = directory_layout.path_for_spec(spec)
if path in self._installed_prefixes:
raise Exception("Install prefix collision.")
try: try:
directory_layout.check_installed(spec) directory_layout.check_installed(spec)
installed = True installed = True
@ -1094,6 +1110,7 @@ def _add(
tty.warn( tty.warn(
'Dependency missing: may be deprecated or corrupted:', 'Dependency missing: may be deprecated or corrupted:',
path, str(e)) path, str(e))
self._installed_prefixes.add(path)
elif spec.external_path: elif spec.external_path:
path = spec.external_path path = spec.external_path
@ -1173,6 +1190,7 @@ def _decrement_ref_count(self, spec):
if rec.ref_count == 0 and not rec.installed: if rec.ref_count == 0 and not rec.installed:
del self._data[key] del self._data[key]
for dep in spec.dependencies(_tracked_deps): for dep in spec.dependencies(_tracked_deps):
self._decrement_ref_count(dep) self._decrement_ref_count(dep)
@ -1190,11 +1208,17 @@ def _remove(self, spec):
key = self._get_matching_spec_key(spec) key = self._get_matching_spec_key(spec)
rec = self._data[key] rec = self._data[key]
# This install prefix is now free for other specs to use, even if the
# spec is only marked uninstalled.
if not rec.spec.external:
self._installed_prefixes.remove(rec.path)
if rec.ref_count > 0: if rec.ref_count > 0:
rec.installed = False rec.installed = False
return rec.spec return rec.spec
del self._data[key] del self._data[key]
for dep in rec.spec.dependencies(_tracked_deps): for dep in rec.spec.dependencies(_tracked_deps):
# FIXME: the two lines below needs to be updated once #11983 is # FIXME: the two lines below needs to be updated once #11983 is
# FIXME: fixed. The "if" statement should be deleted and specs are # FIXME: fixed. The "if" statement should be deleted and specs are
@ -1538,6 +1562,10 @@ def missing(self, spec):
upstream, record = self.query_by_spec_hash(key) upstream, record = self.query_by_spec_hash(key)
return record and not record.installed return record and not record.installed
def is_occupied_install_prefix(self, path):
with self.read_transaction():
return path in self._installed_prefixes
@property @property
def unused_specs(self): def unused_specs(self):
"""Return all the specs that are currently installed but not needed """Return all the specs that are currently installed but not needed

View File

@ -33,12 +33,13 @@
import itertools import itertools
import os import os
import shutil import shutil
import six
import sys import sys
import time import time
from collections import defaultdict from collections import defaultdict
import six
import llnl.util.filesystem as fs import llnl.util.filesystem as fs
import llnl.util.lock as lk import llnl.util.lock as lk
import llnl.util.tty as tty import llnl.util.tty as tty
@ -51,7 +52,6 @@
import spack.package_prefs as prefs import spack.package_prefs as prefs
import spack.repo import spack.repo
import spack.store import spack.store
from llnl.util.tty.color import colorize from llnl.util.tty.color import colorize
from llnl.util.tty.log import log_output from llnl.util.tty.log import log_output
from spack.util.environment import dump_environment from spack.util.environment import dump_environment
@ -814,22 +814,27 @@ def _prepare_for_install(self, task):
# Determine if the spec is flagged as installed in the database # Determine if the spec is flagged as installed in the database
rec, installed_in_db = self._check_db(task.pkg.spec) rec, installed_in_db = self._check_db(task.pkg.spec)
if not installed_in_db:
# Ensure there is no other installed spec with the same prefix dir
if spack.store.db.is_occupied_install_prefix(task.pkg.spec.prefix):
raise InstallError(
"Install prefix collision for {0}".format(task.pkg_id),
long_msg="Prefix directory {0} already used by another "
"installed spec.".format(task.pkg.spec.prefix))
# Make sure the installation directory is in the desired state # Make sure the installation directory is in the desired state
# for uninstalled specs. # for uninstalled specs.
partial = False if os.path.isdir(task.pkg.spec.prefix):
if not installed_in_db and os.path.isdir(task.pkg.spec.prefix):
if not keep_prefix: if not keep_prefix:
task.pkg.remove_prefix() task.pkg.remove_prefix()
else: else:
tty.debug('{0} is partially installed' tty.debug('{0} is partially installed'.format(task.pkg_id))
.format(task.pkg_id))
partial = True
# Destroy the stage for a locally installed, non-DIYStage, package # Destroy the stage for a locally installed, non-DIYStage, package
if restage and task.pkg.stage.managed_by_spack: if restage and task.pkg.stage.managed_by_spack:
task.pkg.stage.destroy() task.pkg.stage.destroy()
if not partial and self.layout.check_installed(task.pkg.spec) and ( if installed_in_db and (
rec.spec.dag_hash() not in task.request.overwrite or rec.spec.dag_hash() not in task.request.overwrite or
rec.installation_time > task.request.overwrite_time rec.installation_time > task.request.overwrite_time
): ):

View File

@ -26,17 +26,15 @@
def parse_date(string): # type: ignore def parse_date(string): # type: ignore
pytest.skip("dateutil package not available") pytest.skip("dateutil package not available")
import archspec.cpu.microarchitecture
import archspec.cpu.schema
import py import py
import pytest import pytest
import archspec.cpu.microarchitecture
import archspec.cpu.schema
from llnl.util.filesystem import mkdirp, remove_linked_tree, working_dir
import spack.architecture import spack.architecture
import spack.caches
import spack.compilers import spack.compilers
import spack.config import spack.config
import spack.caches
import spack.database import spack.database
import spack.directory_layout import spack.directory_layout
import spack.environment as ev import spack.environment as ev
@ -47,14 +45,14 @@ def parse_date(string): # type: ignore
import spack.repo import spack.repo
import spack.stage import spack.stage
import spack.store import spack.store
import spack.subprocess_context
import spack.util.executable import spack.util.executable
import spack.util.gpg import spack.util.gpg
import spack.subprocess_context
import spack.util.spack_yaml as syaml import spack.util.spack_yaml as syaml
from llnl.util.filesystem import mkdirp, remove_linked_tree, working_dir
from spack.util.pattern import Bunch
from spack.fetch_strategy import FetchStrategyComposite, URLFetchStrategy
from spack.fetch_strategy import FetchError from spack.fetch_strategy import FetchError
from spack.fetch_strategy import FetchStrategyComposite, URLFetchStrategy
from spack.util.pattern import Bunch
# #
@ -767,7 +765,7 @@ def __init__(self, root):
self.root = root self.root = root
def path_for_spec(self, spec): def path_for_spec(self, spec):
return '/'.join([self.root, spec.name]) return '/'.join([self.root, spec.name + '-' + spec.dag_hash()])
def check_installed(self, spec): def check_installed(self, spec):
return True return True

View File

@ -4,20 +4,20 @@
# SPDX-License-Identifier: (Apache-2.0 OR MIT) # SPDX-License-Identifier: (Apache-2.0 OR MIT)
import os import os
import pytest
import shutil import shutil
import llnl.util.filesystem as fs import pytest
from spack.package import InstallError, PackageBase, PackageStillNeededError import llnl.util.filesystem as fs
import spack.error import spack.error
import spack.patch import spack.patch
import spack.repo import spack.repo
import spack.store import spack.store
from spack.spec import Spec
import spack.util.spack_json as sjson import spack.util.spack_json as sjson
from spack.package import InstallError, PackageBase, PackageStillNeededError
from spack.package import (_spack_build_envfile, _spack_build_logfile, from spack.package import (_spack_build_envfile, _spack_build_logfile,
_spack_configure_argsfile) _spack_configure_argsfile)
from spack.spec import Spec
def find_nothing(*args): def find_nothing(*args):
@ -325,6 +325,23 @@ def test_second_install_no_overwrite_first(install_mockery, mock_fetch):
spack.package.Package.remove_prefix = remove_prefix spack.package.Package.remove_prefix = remove_prefix
def test_install_prefix_collision_fails(config, mock_fetch, mock_packages, tmpdir):
"""
Test that different specs with coinciding install prefixes will fail
to install.
"""
projections = {'all': 'all-specs-project-to-this-prefix'}
store = spack.store.Store(str(tmpdir), projections=projections)
with spack.store.use_store(store):
with spack.config.override('config:checksum', False):
pkg_a = Spec('libelf@0.8.13').concretized().package
pkg_b = Spec('libelf@0.8.12').concretized().package
pkg_a.do_install()
with pytest.raises(InstallError, match="Install prefix collision"):
pkg_b.do_install()
def test_store(install_mockery, mock_fetch): def test_store(install_mockery, mock_fetch):
spec = Spec('cmake-client').concretized() spec = Spec('cmake-client').concretized()
pkg = spec.package pkg = spec.package

View File

@ -3,9 +3,10 @@
# #
# SPDX-License-Identifier: (Apache-2.0 OR MIT) # SPDX-License-Identifier: (Apache-2.0 OR MIT)
from spack import *
import os import os
from spack import *
class Gdb(AutotoolsPackage, GNUMirrorPackage): class Gdb(AutotoolsPackage, GNUMirrorPackage):
"""GDB, the GNU Project debugger, allows you to see what is going on """GDB, the GNU Project debugger, allows you to see what is going on