Commit 013c7ff7 authored by Jérome Perrin's avatar Jérome Perrin

Extend tests to detect shared libraries using system libraries

On some test machines, we saw test failures because openssl version mismatch:

```
Traceback (most recent call last):
  File "/srv/slapgrid/slappart14/srv/testnode/cqg/soft/18d28ecd49e5e0f732e2ce0c00c57519/parts/slapos.core-repository/slapos/testing/testcase.py", line 227, in installSoftwareUrlList
    checkSoftware(cls.slap, software_url)
  File "/srv/slapgrid/slappart14/srv/testnode/cqg/soft/18d28ecd49e5e0f732e2ce0c00c57519/parts/slapos.core-repository/slapos/testing/testcase.py", line 211, in checkSoftware
    raise RuntimeError('\n'.join(error_list))
RuntimeError: ./parts/proftpd/libexec/mod_auth_web.so:
./parts/proftpd/libexec/mod_auth_web.so: /usr/lib/x86_64-linux-gnu/libssl.so.1.1: version `OPENSSL_1_1_1' not found (required by /srv/slapgrid/slappart14/srv/testnode/cqg/inst/test0-0/tmp/shared/curl/724c785f86fff6993ff24ef745d4dbb9/lib/libcurl.so.4)
```

What we see in this traceback is that the version of openssl used was the one from system ( in `/usr/lib/` ), which should not happen because we are using setting rpath to slapos libraries. We had a test checking for missing rpath that could not resolved, but our test did not catch missing rpath that can be resolved on a system library, which is also wrong because this system library might be a different version like here or even be missing.

/reviewed-on nexedi/slapos.core!172
parents 9b6b637d 4656f063
...@@ -29,6 +29,7 @@ ...@@ -29,6 +29,7 @@
import unittest import unittest
import os import os
import fnmatch import fnmatch
import re
import glob import glob
import logging import logging
import shutil import shutil
...@@ -37,7 +38,7 @@ from six.moves.urllib.parse import urlparse ...@@ -37,7 +38,7 @@ from six.moves.urllib.parse import urlparse
try: try:
import subprocess32 as subprocess import subprocess32 as subprocess
except ImportError: except ImportError:
import subprocess import subprocess # type: ignore
subprocess # pyflakes subprocess # pyflakes
from .utils import getPortFromPath from .utils import getPortFromPath
...@@ -49,7 +50,7 @@ from ..grid.utils import md5digest ...@@ -49,7 +50,7 @@ from ..grid.utils import md5digest
from ..util import mkdir_p from ..util import mkdir_p
try: try:
from typing import Iterable, Tuple, Callable, Type from typing import Iterable, Tuple, Callable, Type, Dict, List, Optional
except ImportError: except ImportError:
pass pass
...@@ -63,9 +64,9 @@ def makeModuleSetUpAndTestCaseClass( ...@@ -63,9 +64,9 @@ def makeModuleSetUpAndTestCaseClass(
verbose=bool(int(os.environ.get('SLAPOS_TEST_VERBOSE', 0))), verbose=bool(int(os.environ.get('SLAPOS_TEST_VERBOSE', 0))),
shared_part_list=os.environ.get('SLAPOS_TEST_SHARED_PART_LIST', shared_part_list=os.environ.get('SLAPOS_TEST_SHARED_PART_LIST',
'').split(os.pathsep), '').split(os.pathsep),
snapshot_directory=os.environ.get('SLAPOS_TEST_LOG_DIRECTORY') snapshot_directory=os.environ.get('SLAPOS_TEST_LOG_DIRECTORY'),
): ):
# type: (str, str, str, str, bool, bool, List[str]) -> Tuple[Callable[[], None], Type[SlapOSInstanceTestCase]] # type: (str, str, str, str, bool, bool, Iterable[str], Optional[str]) -> Tuple[Callable[[], None], Type[SlapOSInstanceTestCase]]
"""Create a setup module function and a testcase for testing `software_url`. """Create a setup module function and a testcase for testing `software_url`.
This function returns a tuple of two arguments: This function returns a tuple of two arguments:
...@@ -124,7 +125,6 @@ def makeModuleSetUpAndTestCaseClass( ...@@ -124,7 +125,6 @@ def makeModuleSetUpAndTestCaseClass(
if not snapshot_directory: if not snapshot_directory:
snapshot_directory = os.path.join(base_directory, "snapshots") snapshot_directory = os.path.join(base_directory, "snapshots")
cls = type( cls = type(
'SlapOSInstanceTestCase for {}'.format(software_url), 'SlapOSInstanceTestCase for {}'.format(software_url),
(SlapOSInstanceTestCase,), { (SlapOSInstanceTestCase,), {
...@@ -139,7 +139,9 @@ def makeModuleSetUpAndTestCaseClass( ...@@ -139,7 +139,9 @@ def makeModuleSetUpAndTestCaseClass(
'_test_file_snapshot_directory': snapshot_directory '_test_file_snapshot_directory': snapshot_directory
}) })
class SlapOSInstanceTestCase_(cls, SlapOSInstanceTestCase): class SlapOSInstanceTestCase_(
cls, # type: ignore # https://github.com/python/mypy/issues/2813
SlapOSInstanceTestCase):
# useless intermediate class so that editors provide completion anyway. # useless intermediate class so that editors provide completion anyway.
pass pass
...@@ -161,43 +163,138 @@ def checkSoftware(slap, software_url): ...@@ -161,43 +163,138 @@ def checkSoftware(slap, software_url):
This perform a few basic static checks for common problems This perform a few basic static checks for common problems
with software installations. with software installations.
""" """
software_hash = md5digest(software_url)
error_list = []
# Check that all components set rpath correctly and we don't have miss linking any libraries. # Check that all components set rpath correctly and we don't have miss linking any libraries.
for path in (os.path.join(slap.software_directory, # Also check that they are not linked against system libraries, except a white list of core
software_hash), slap.shared_directory): # system libraries.
if not glob.glob(os.path.join(path, '*')): system_lib_white_list = set((
# shared might be empty (when using a slapos command that does not support shared yet). 'libc',
continue 'libcrypt',
out = '' 'libdl',
'libgcc_s',
'libgomp',
'libm',
'libnsl',
'libpthread',
'libresolv',
'librt',
'libstdc++',
'libutil',
))
# we also ignore a few patterns for part that are known to be binary distributions,
# for which we generate LD_LIBRARY_PATH wrappers or we don't use directly.
ignored_file_patterns = set((
'*/parts/java-re*/*',
'*/parts/firefox*/*',
'*/parts/chromium-*/*',
'*/parts/chromedriver*/*',
# nss is not a binary distribution, but for some reason it has invalid rpath, but it does
# not seem to be a problem in our use cases.
'*/parts/nss/*',
'*/node_modules/phantomjs*/*',
'*/grafana/tools/phantomjs/*',
))
software_hash = md5digest(software_url)
error_list = []
ldd_so_resolved_re = re.compile(
r'\t(?P<library_name>.*) => (?P<library_path>.*) \(0x')
ldd_already_loaded_re = re.compile(r'\t(?P<library_name>.*) \(0x')
ldd_not_found_re = re.compile(r'.*not found.*')
class DynamicLibraryNotFound(Exception):
"""Exception raised when ldd cannot resolve a library.
"""
def getLddOutput(path):
# type: (str) -> Dict[str, str]
"""Parse ldd output on executable as `path` and returns a mapping
of library paths or None when library is not found, keyed by library so name.
Raises a `DynamicLibraryNotFound` if any dynamic library is not found.
Special entries, like VDSO ( linux-vdso.so.1 ) or ELF interpreter
( /lib64/ld-linux-x86-64.so.2 ) are ignored.
"""
libraries = {} # type: Dict[str, str]
try: try:
out = subprocess.check_output( ldd_output = subprocess.check_output(
"find . -type f -executable " ('ldd', path),
# We ignore parts that are binary distributions.
"| egrep -v /parts/java-re.*/ "
"| egrep -v /parts/firefox-.*/ "
"| egrep -v /parts/chromium-.*/ "
"| egrep -v /parts/chromedriver-.*/ "
"| egrep -v /parts/renderjs-repository.git/node_modules/.* "
# nss has no valid rpath. It does not seem to be a problem in our case.
"| egrep -v /parts/nss/ "
"| xargs ldd "
r"| egrep '(^\S|not found)' "
"| grep -B1 'not found'",
shell=True,
stderr=subprocess.STDOUT, stderr=subprocess.STDOUT,
cwd=path, universal_newlines=True,
) )
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
# The "good case" is when grep does not match anything, but in if e.output not in ('\tnot a dynamic executable\n',):
# that case, it exists with exit code 1, so we accept this case. raise
if e.returncode != 1 or e.output: return libraries
error_list.append(e.output) if ldd_output == '\tstatically linked\n':
if out: return libraries
error_list.append(out)
not_found = []
for line in ldd_output.splitlines():
resolved_so_match = ldd_so_resolved_re.match(line)
ldd_already_loaded_match = ldd_already_loaded_re.match(line)
not_found_match = ldd_not_found_re.match(line)
if resolved_so_match:
libraries[resolved_so_match.group(
'library_name')] = resolved_so_match.group('library_path')
elif ldd_already_loaded_match:
# VDSO or ELF, ignore . See https://stackoverflow.com/a/35805410/7294664 for more about this
pass
elif not_found_match:
not_found.append(line)
else:
raise RuntimeError('Unknown ldd line %s for %s.' % (line, path))
if not_found:
not_found_text = '\n'.join(not_found)
raise DynamicLibraryNotFound(
'{path} has some not found libraries:\n{not_found_text}'.format(
**locals()))
return libraries
def checkExecutableLink(paths_to_check, valid_paths_for_libs):
# type: (Iterable[str], Iterable[str]) -> List[str]
"""Check shared libraries linked with executables in `paths_to_check`.
Only libraries from `valid_paths_for_libs` are accepted.
Returns a list of error messages.
"""
executable_link_error_list = []
for path in paths_to_check:
for root, dirs, files in os.walk(path):
for f in files:
f = os.path.join(root, f)
if any(fnmatch.fnmatch(f, ignored_pattern)
for ignored_pattern in ignored_file_patterns):
continue
if os.access(f, os.X_OK):
try:
libs = getLddOutput(f)
except DynamicLibraryNotFound as e:
executable_link_error_list.append(str(e))
else:
for lib, lib_path in libs.items():
if lib.split('.')[0] in system_lib_white_list:
continue
# dynamically linked programs can only be linked with libraries
# present in software or in shared parts repository.
if any(lib_path.startswith(valid_path)
for valid_path in valid_paths_for_libs):
continue
executable_link_error_list.append(
'{f} uses system library {lib_path} for {lib}'.format(
**locals()))
return executable_link_error_list
paths_to_check = (
os.path.join(slap.software_directory, software_hash),
slap.shared_directory,
)
error_list.extend(
checkExecutableLink(
paths_to_check,
paths_to_check + tuple(slap._shared_part_list),
))
# check this software is not referenced in any shared parts. # check this software is not referenced in any shared parts.
for signature_file in glob.glob(os.path.join(slap.shared_directory, '*', '*', for signature_file in glob.glob(os.path.join(slap.shared_directory, '*', '*',
...@@ -403,7 +500,8 @@ class SlapOSInstanceTestCase(unittest.TestCase): ...@@ -403,7 +500,8 @@ class SlapOSInstanceTestCase(unittest.TestCase):
""" """
cls._cleanup("{}.{}.tearDownClass".format(cls.__module__, cls.__name__)) cls._cleanup("{}.{}.tearDownClass".format(cls.__module__, cls.__name__))
if not cls._debug: if not cls._debug:
cls.logger.debug("cleaning up slapos log files in %s", cls.slap._log_directory) cls.logger.debug(
"cleaning up slapos log files in %s", cls.slap._log_directory)
for log_file in glob.glob(os.path.join(cls.slap._log_directory, '*')): for log_file in glob.glob(os.path.join(cls.slap._log_directory, '*')):
os.unlink(log_file) os.unlink(log_file)
...@@ -524,11 +622,12 @@ class SlapOSInstanceTestCase(unittest.TestCase): ...@@ -524,11 +622,12 @@ class SlapOSInstanceTestCase(unittest.TestCase):
"{}._cleanup leaked_partitions".format(snapshot_name)) "{}._cleanup leaked_partitions".format(snapshot_name))
for cp in leaked_partitions: for cp in leaked_partitions:
try: try:
# XXX is this really the reference ?
partition_reference = cp.getInstanceParameterDict()['instance_title']
cls.slap.request( cls.slap.request(
software_release=cp.getSoftwareRelease().getURI(), software_release=cp.getSoftwareRelease().getURI(),
# software_type=cp.getType(), # TODO # software_type=cp.getType(), # TODO
# XXX is this really the reference ? partition_reference=partition_reference,
partition_reference=cp.getInstanceParameterDict()['instance_title'],
state="destroyed") state="destroyed")
except: except:
cls.logger.exception( cls.logger.exception(
...@@ -549,10 +648,14 @@ class SlapOSInstanceTestCase(unittest.TestCase): ...@@ -549,10 +648,14 @@ class SlapOSInstanceTestCase(unittest.TestCase):
cls.logger.exception("Error during stop") cls.logger.exception("Error during stop")
cls._storeSystemSnapshot("{}._cleanup stop".format(snapshot_name)) cls._storeSystemSnapshot("{}._cleanup stop".format(snapshot_name))
leaked_supervisor_configs = glob.glob( leaked_supervisor_configs = glob.glob(
os.path.join(cls.slap.instance_directory, 'etc', 'supervisord.conf.d', '*.conf')) os.path.join(
cls.slap.instance_directory, 'etc', 'supervisord.conf.d', '*.conf'))
if leaked_supervisor_configs: if leaked_supervisor_configs:
[os.unlink(config) for config in leaked_supervisor_configs] for config in leaked_supervisor_configs:
raise AssertionError("Test leaked supervisor configurations: %s" % leaked_supervisor_configs) os.unlink(config)
raise AssertionError(
"Test leaked supervisor configurations: %s" %
leaked_supervisor_configs)
@classmethod @classmethod
def requestDefaultInstance(cls, state='started'): def requestDefaultInstance(cls, state='started'):
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment