From 1a9769dd4224ed4d60a362af9a47f7cb30392f51 Mon Sep 17 00:00:00 2001
From: Simon McVittie <smcv@collabora.com>
Date: Tue, 9 Jun 2020 18:48:06 +0100
Subject: [PATCH] tests: Add an integration test for running in a container

This requires resources external to pressure-vessel, and makes some
assumptions about the container - but that's OK, because the first
significant user of pressure-vessel is Steam Runtime 1 'scout', which
is not exactly a moving target. We also do some of these checks in
various OSs' virtual machines when we build Steam Runtime releases, but
having this in-tree with the pressure-vessel source should shorten the
testing cycle.

See the doc-comment in tests/containers.py for usage.

At the moment all tests require a working version of bubblewrap, but
current/future work in the direction of pre-preparing a modified
runtime on disk should mean that we can do a subset of these tests
even if we cannot run bubblewrap. In particular, if we can do that
inside a Docker container, that will make it a lot easier for CI to
validate our assumptions on non-Debian-derived host operating systems.

Attentive reviewers will notice that there's infrastructure here to
be able to skip generating locales, but it isn't yet used. It will
be used when we have more variations on the theme of "enter a scout
container and run inside-scout.py", to avoid the time cost of
generating locales every time - but at the moment we only have two
runs, so the time taken to generate locales is not problematic.

This test is a bit slow, and it's going to get slower as we add more
thorough testing, so increase its timeout away from the Meson default.

Signed-off-by: Simon McVittie <smcv@collabora.com>
---
 meson_options.txt     |   1 +
 tests/containers.py   | 402 ++++++++++++++++++++++++++++++++
 tests/inside-scout.py | 530 ++++++++++++++++++++++++++++++++++++++++++
 tests/meson.build     |  11 +
 tests/testutils.py    |  14 ++
 5 files changed, 958 insertions(+)
 create mode 100755 tests/containers.py
 create mode 100755 tests/inside-scout.py

diff --git a/meson_options.txt b/meson_options.txt
index 8004724c5..bb52e5149 100644
--- a/meson_options.txt
+++ b/meson_options.txt
@@ -1,4 +1,5 @@
 option('man', type : 'boolean', value : false, description : 'enable man pages')
 option('python', type : 'string', value : '')
 option('srcdir', type : 'string', value : '')
+option('test_containers_dir', type : 'string', value : '')
 option('version', type : 'string', value : 'auto')
diff --git a/tests/containers.py b/tests/containers.py
new file mode 100755
index 000000000..7be681096
--- /dev/null
+++ b/tests/containers.py
@@ -0,0 +1,402 @@
+#!/usr/bin/env python3
+# Copyright 2020 Collabora Ltd.
+#
+# SPDX-License-Identifier: MIT
+
+"""
+Test pressure-vessel against an out-of-band set of pre-prepared containers.
+
+To run during build-time testing, build with
+-Dtest_containers_dir=/path/to/containers, where /path/to/containers
+ideally contains at least:
+
+* pressure-vessel:
+    A sufficiently recent copy of pressure-vessel, used for the parts
+    that must run inside the container (because we cannot assume that
+    an arbitrary build done on the host system is compatible with the
+    libraries inside the container)
+* steam-runtime:
+    An LD_LIBRARY_PATH runtime, which we use to find versions of
+    steam-runtime-system-info and capsule-capture-libs that can run
+    on the host system
+* scout/files:
+    The Platform merged-/usr from the SteamLinuxRuntime depot
+* scout_sysroot:
+    An SDK sysroot like the one recommended for the Docker container
+
+and run (for example) 'meson test -v -C _build' as usual.
+
+The same test can also be run against a version of pressure-vessel
+that was built against scout:
+
+    export PRESSURE_VESSEL_TEST_CONTAINERS=/path/to/containers
+    ./sysroot/run-in-sysroot.py --sysroot ../scout-sysroot -- \
+        ninja -C _build-for-sysroot
+    env DESTDIR="$(pwd)/_build-for-sysroot/DESTDIR" \
+        ./sysroot/run-in-sysroot.py --sysroot ../scout-sysroot -- \
+        ninja -C _build-for-sysroot install
+    rm -fr "$PRESSURE_VESSEL_TEST_CONTAINERS/pressure-vessel"
+    env DESTDIR="$(pwd)/_build-for-sysroot/DESTDIR" \
+        ./sysroot/run-in-sysroot.py --sysroot ../scout-sysroot -- \
+        python3.5 ./build-relocatable-install.py \
+        --output="$PRESSURE_VESSEL_TEST_CONTAINERS/pressure-vessel" \
+        --check-source-directory="$PRESSURE_VESSEL_TEST_CONTAINERS" \
+        --allow-missing-sources \
+        --srcdir="$(pwd)" --set-version "$(git describe)"
+    ./tests/containers.py
+
+or against the SteamLinuxRuntime depot that gets uploaded to the Steam CDN:
+
+    export PRESSURE_VESSEL_TEST_CONTAINERS=../SteamLinuxRuntime/depot
+    ./tests/containers.py
+
+Influential environment variables:
+
+* AUTOPKGTEST_ARTIFACTS:
+    Write test artifacts to this directory (borrowed from Debian's
+    autopkgtest framework) instead of a temporary directory. This makes
+    debugging easier.
+* BWRAP:
+    A bubblewrap executable
+* G_TEST_SRCDIR:
+    The ./tests subdirectory of the source root, typically $(pwd)/tests
+* G_TEST_BUILDDIR:
+    The ./tests subdirectory of the build root, typically $(pwd)/_build/tests
+* PRESSURE_VESSEL_LIBCAPSULE_TOOLS:
+    Override the location of capsule-capture-libs etc.
+* PRESSURE_VESSEL_TEST_CONTAINERS:
+    A complete relocatable pressure-vessel installation, including its
+    dependencies such as libsteam-runtime-tools
+* PRESSURE_VESSEL_UNINSTALLED:
+    Set when running from the source/build trees
+* STEAM_RUNTIME_SYSTEM_INFO:
+    Path to a steam-runtime-system-info executable for the host system
+
+Please keep this script compatible with python3.4 so that it can be
+run on the oldest platforms where pressure-vessel works:
+SteamOS 2 'brewmaster', Debian 8 'jessie', Ubuntu 14.04 'trusty'.
+"""
+
+import contextlib
+import logging
+import os
+import shutil
+import subprocess
+import sys
+import unittest
+
+try:
+    import typing
+    typing      # placate pyflakes
+except ImportError:
+    pass
+
+from testutils import (
+    BaseTest,
+    run_subprocess,
+    tee_file_and_stderr,
+    test_main,
+)
+
+
+logger = logging.getLogger('test-containers')
+
+
+class TestContainers(BaseTest):
+    bwrap = None            # type: typing.Optional[str]
+    containers_dir = ''
+    pv_dir = ''
+    pv_wrap = ''
+
+    @classmethod
+    def setUpClass(cls) -> None:
+        super().setUpClass()
+
+        if not os.environ.get('PRESSURE_VESSEL_TEST_CONTAINERS', ''):
+            raise unittest.SkipTest('Containers not available')
+
+        bwrap = os.environ.get('BWRAP', shutil.which('bwrap'))
+
+        if bwrap is not None and subprocess.run(
+            [bwrap, '--dev-bind', '/', '/', 'sh', '-c', 'true'],
+            stdout=2,
+            stderr=2,
+        ).returncode != 0:
+            # Right now this means we will skip all the tests, but in
+            # future we will be able to do some tests that just do the
+            # setup and do not actually go as far as running the container.
+            cls.bwrap = None
+        else:
+            cls.bwrap = bwrap
+
+        cls.containers_dir = os.path.abspath(
+            os.environ['PRESSURE_VESSEL_TEST_CONTAINERS']
+        )
+
+        cls.pv_dir = os.path.join(cls.tmpdir.name, 'pressure-vessel')
+        os.makedirs(cls.pv_dir, exist_ok=True)
+
+        if 'PRESSURE_VESSEL_UNINSTALLED' in os.environ:
+            os.makedirs(os.path.join(cls.pv_dir, 'bin'))
+
+            for exe in (
+                'pressure-vessel-wrap',
+            ):
+                shutil.copy2(
+                    os.path.join(cls.top_builddir, 'src', exe),
+                    os.path.join(cls.pv_dir, 'bin', exe),
+                )
+
+            for exe in (
+                'pressure-vessel-locale-gen',
+            ):
+                shutil.copy2(
+                    os.path.join(cls.top_srcdir, exe),
+                    os.path.join(cls.pv_dir, 'bin', exe),
+                )
+
+            for exe in (
+                'pressure-vessel-try-setlocale',
+                'pressure-vessel-with-lock',
+            ):
+                in_containers_dir = os.path.join(
+                    cls.containers_dir,
+                    'pressure-vessel',
+                    'bin',
+                    exe,
+                )
+
+                if os.path.exists(in_containers_dir):
+                    # Asssume it's a close enough version that we can
+                    # use it with the newer pressure-vessel-wrap.
+                    # We don't necessarily want to use versions from
+                    # the builddir because they can have dependencies
+                    # that are newer than the container's libraries.
+                    shutil.copy2(
+                        in_containers_dir,
+                        os.path.join(cls.pv_dir, 'bin', exe),
+                    )
+                else:
+                    shutil.copy2(
+                        os.path.join(cls.top_builddir, 'src', exe),
+                        os.path.join(cls.pv_dir, 'bin', exe),
+                    )
+
+            for d in (
+                'subprojects',
+            ):
+                shutil.copytree(
+                    os.path.join(cls.top_builddir, d),
+                    os.path.join(cls.pv_dir, d),
+                    symlinks=True,
+                )
+
+            fallback_libcapsule_tools = os.path.join(
+                cls.containers_dir,
+                'steam-runtime', 'usr', 'lib', 'libcapsule', 'relocatable',
+            )
+
+            if not os.path.isdir(fallback_libcapsule_tools):
+                fallback_libcapsule_tools = '/usr/lib/libcapsule/relocatable'
+
+            for multiarch in ('i386-linux-gnu', 'x86_64-linux-gnu'):
+                for tool in ('capsule-capture-libs', 'capsule-symbols'):
+                    exe = multiarch + '-' + tool
+                    tool_path = os.path.join(cls.pv_dir, 'bin', exe)
+
+                    exe = multiarch + '-' + tool
+                    tool_path = os.path.join(cls.pv_dir, 'bin', exe)
+                    found = shutil.which(exe)
+                    relocatable = os.path.join(
+                        os.environ.get(
+                            'PRESSURE_VESSEL_LIBCAPSULE_TOOLS',
+                            fallback_libcapsule_tools,
+                        ),
+                        exe,
+                    )
+
+                    if found is not None:
+                        shutil.copy2(found, tool_path)
+                    elif os.path.isfile(relocatable):
+                        shutil.copy2(relocatable, tool_path)
+                    else:
+                        raise unittest.SkipTest('{} not found'.format(exe))
+        else:
+            cls.pv_dir = os.path.join(cls.containers_dir, 'pressure-vessel')
+
+            if not os.path.isdir(cls.pv_dir):
+                raise unittest.SkipTest('{} not found'.format(cls.pv_dir))
+
+        cls.pv_wrap = os.path.join(cls.pv_dir, 'bin', 'pressure-vessel-wrap')
+
+        host_srsi = os.getenv('STEAM_RUNTIME_SYSTEM_INFO')
+
+        if host_srsi is None:
+            rt = os.path.join(cls.containers_dir, 'steam-runtime')
+            cpu = os.uname().machine
+
+            if cpu == 'x86_64':
+                arch = 'amd64'
+            elif cpu.startswith('i') and len(cpu) == 4 and cpu.endswith('86'):
+                arch = 'i386'
+            else:
+                arch = cpu
+
+            host_srsi = shutil.which(
+                os.path.join(
+                    rt, arch, 'usr', 'bin', 'steam-runtime-system-info',
+                )
+            )
+
+        if host_srsi is None:
+            host_srsi = shutil.which('steam-runtime-system-info')
+
+        if host_srsi is not None:
+            with open(
+                os.path.join(cls.artifacts, 'host-srsi.json'),
+                'w',
+            ) as writer:
+                subprocess.run(
+                    [
+                        host_srsi,
+                        '--verbose',
+                    ],
+                    cwd=cls.artifacts,
+                    stdout=writer,
+                    stderr=2,
+                    universal_newlines=True,
+                )
+
+            os.environ['HOST_STEAM_RUNTIME_SYSTEM_INFO_JSON'] = os.path.join(
+                cls.artifacts, 'host-srsi.json',
+            )
+        else:
+            os.environ.pop('HOST_STEAM_RUNTIME_SYSTEM_INFO_JSON', None)
+
+        try:
+            os.environ['HOST_LD_LINUX_SO_REALPATH'] = os.path.realpath(
+                '/lib/ld-linux.so.2'
+            )
+        except OSError:
+            os.environ.pop('HOST_LD_LINUX_SO_REALPATH', None)
+
+        try:
+            os.environ['HOST_LD_LINUX_X86_64_SO_REALPATH'] = os.path.realpath(
+                '/lib64/ld-linux-x86-64.so.2'
+            )
+        except OSError:
+            os.environ.pop('HOST_LD_LINUX_X86_64_SO_REALPATH', None)
+
+    def setUp(self) -> None:
+        super().setUp()
+        cls = self.__class__
+        self.bwrap = cls.bwrap
+        self.containers_dir = cls.containers_dir
+        self.pv_dir = cls.pv_dir
+        self.pv_wrap = cls.pv_wrap
+
+        # The artifacts directory is going to be the current working
+        # directory inside the container, so we copy things we will
+        # need into that directory.
+        os.makedirs(os.path.join(cls.artifacts, 'tmp'), exist_ok=True)
+
+        for f in ('testutils.py', 'inside-scout.py'):
+            shutil.copy2(
+                os.path.join(cls.G_TEST_SRCDIR, f),
+                os.path.join(cls.artifacts, 'tmp', f),
+            )
+
+    def tearDown(self) -> None:
+        with contextlib.suppress(FileNotFoundError):
+            shutil.rmtree(os.path.join(self.artifacts, 'tmp'))
+
+        super().tearDown()
+
+    @classmethod
+    def tearDownClass(cls) -> None:
+        super().tearDownClass()
+
+    def run_subprocess(
+        self,
+        args,           # type: typing.Union[typing.List[str], str]
+        check=False,
+        input=None,     # type: typing.Optional[bytes]
+        timeout=None,   # type: typing.Optional[int]
+        **kwargs        # type: typing.Any
+    ):
+        logger.info('Running: %r', args)
+        return run_subprocess(
+            args, check=check, input=input, timeout=timeout, **kwargs
+        )
+
+    def _test_scout(
+        self,
+        test_name: str,
+        scout: str,
+        *,
+        locales: bool = False
+    ) -> None:
+        if self.bwrap is None:
+            self.skipTest('Unable to run bwrap (in a container?)')
+
+        if not os.path.isdir(scout):
+            self.skipTest('{} not found'.format(scout))
+
+        artifacts = os.path.join(
+            self.artifacts,
+            test_name,
+        )
+        os.makedirs(artifacts, exist_ok=True)
+
+        argv = [
+            self.pv_wrap,
+            '--runtime', scout,
+            '--verbose',
+        ]
+
+        if not locales:
+            argv.append('--no-generate-locales')
+
+        argv.extend([
+            '--',
+            'env',
+            'TEST_INSIDE_SCOUT_ARTIFACTS=' + artifacts,
+            'TEST_INSIDE_SCOUT_LOCALES=' + ('1' if locales else ''),
+            'python3.5',
+            os.path.join(self.artifacts, 'tmp', 'inside-scout.py'),
+        ])
+
+        with tee_file_and_stderr(
+            os.path.join(artifacts, 'inside-scout.log')
+        ) as tee:
+            completed = subprocess.run(
+                argv,
+                cwd=self.artifacts,
+                stdout=tee.stdin,
+                stderr=tee.stdin,
+                universal_newlines=True,
+            )
+
+        self.assertEqual(completed.returncode, 0)
+
+    def test_scout_sysroot(self) -> None:
+        scout = os.path.join(self.containers_dir, 'scout_sysroot')
+
+        if os.path.isdir(os.path.join(scout, 'files')):
+            scout = os.path.join(scout, 'files')
+
+        self._test_scout('scout_sysroot', scout, locales=True)
+
+    def test_scout_usr(self) -> None:
+        scout = os.path.join(self.containers_dir, 'scout', 'files')
+
+        self._test_scout('scout', scout, locales=True)
+
+
+if __name__ == '__main__':
+    assert sys.version_info >= (3, 4), \
+        'Python 3.4+ is required'
+
+    test_main()
+
+# vi: set sw=4 sts=4 et:
diff --git a/tests/inside-scout.py b/tests/inside-scout.py
new file mode 100755
index 000000000..3b9f48853
--- /dev/null
+++ b/tests/inside-scout.py
@@ -0,0 +1,530 @@
+#!/usr/bin/python3.5
+# Copyright 2020 Collabora Ltd.
+#
+# SPDX-License-Identifier: MIT
+
+import contextlib
+import ctypes
+import json
+import logging
+import os
+import shlex
+import subprocess
+import sys
+import typing
+from pathlib import Path
+
+from testutils import (
+    BaseTest,
+    test_main,
+)
+
+"""
+Test script intended to be run inside a SteamRT 1 'scout' container,
+to assert that everything is as it should be.
+"""
+
+logger = logging.getLogger('test-inside-scout')
+
+
+class HostInfo:
+    def __init__(self) -> None:
+        self.path = Path('/run/host')
+
+        self.os_release = {}   # type: typing.Dict[str, str]
+
+        for osr in ('etc/os-release', 'usr/lib/os-release'):
+            try:
+                reader = (self.path / osr).open()
+            except OSError:
+                continue
+            else:
+                with reader:
+                    for line in reader:
+                        if '=' not in line:
+                            logger.warning(
+                                'Invalid line in %r: %r',
+                                self.path / osr, line,
+                            )
+                            continue
+                        key, value = line.split('=', 1)
+
+                        try:
+                            tokens = shlex.split(value)
+                        except ValueError as e:
+                            logger.warning(
+                                'Invalid line in %r: %r: %s',
+                                self.path / osr, line, e,
+                            )
+                            continue
+
+                        if len(tokens) != 1:
+                            logger.warning(
+                                'Invalid line in %r: %r',
+                                self.path / osr, line,
+                            )
+                            continue
+
+                        self.os_release[key] = tokens[0]
+                break
+
+
+class TestInsideScout(BaseTest):
+    def setUp(self) -> None:
+        super().setUp()
+        self.host = HostInfo()
+
+        artifacts = os.getenv('TEST_INSIDE_SCOUT_ARTIFACTS')
+
+        if artifacts is not None:
+            self.artifacts = Path(artifacts)
+        else:
+            self.artifacts = Path(self.tmpdir.name)
+
+        self.artifacts.mkdir(exist_ok=True)
+
+    def tearDown(self) -> None:
+        super().tearDown()
+
+    @contextlib.contextmanager
+    def catch(
+        self,
+        msg,                # type: str
+        diagnostic=None,    # type: typing.Any
+        **kwargs            # type: typing.Any
+    ):
+        """
+        Run a sub-test, with additional logging. If it fails, we still
+        continue to test.
+        """
+
+        if kwargs:
+            logger.info('Starting: %r (%r)', msg, kwargs)
+        else:
+            logger.info('Starting: %r', msg)
+        with self.subTest(msg, **kwargs):
+            try:
+                yield
+            except Exception:
+                logger.error(msg, exc_info=True)
+
+                if diagnostic is not None:
+                    logger.error('%r', diagnostic)
+
+                raise
+
+    def test_os_release(self) -> None:
+        """
+        Assert that both /etc/os-release and /usr/lib/os-release have
+        the contents we expect them to.
+        """
+
+        for osr in ('/etc/os-release', '/usr/lib/os-release'):
+            data = {}   # type: typing.Dict[str, str]
+
+            with open(osr) as reader:
+                for line in reader:
+                    assert '=' in line, line
+                    key, value = line.split('=', 1)
+
+                    logger.info('%s %r: %r', osr, key, value)
+
+                    tokens = shlex.split(value)
+
+                    assert len(tokens) == 1, tokens
+                    data[key] = tokens[0]
+
+            self.assertEqual(data.get('VERSION_ID'), '1')
+            self.assertEqual(data.get('ID'), 'steamrt')
+            self.assertEqual(data.get('ID_LIKE'), 'ubuntu')
+            self.assertIsNotNone(data.get('BUILD_ID'))
+
+    def test_environ(self) -> None:
+        logger.info('PATH: %r', os.environ.get('PATH'))
+        logger.info(
+            'LD_LIBRARY_PATH: %r', os.environ.get('LD_LIBRARY_PATH')
+        )
+        # No actual *tests* here just yet - we just log what's there.
+
+    def test_glibc(self) -> None:
+        """
+        Assert that we took the glibc version from the host OS.
+
+        We assume this will always be true for scout, because scout
+        is based on Ubuntu 12.04, the oldest operating system we support;
+        and in cases where our glibc is the same version as the glibc of
+        the host OS, we prefer the host.
+        """
+        glibc = ctypes.cdll.LoadLibrary('libc.so.6')
+        gnu_get_libc_version = glibc.gnu_get_libc_version
+        gnu_get_libc_version.restype = ctypes.c_char_p
+        glibc_version = gnu_get_libc_version().decode('ascii')
+        logger.info('glibc version in use: %s', glibc_version)
+        major, minor, *rest = glibc_version.split('.')
+        self.assertGreaterEqual((int(major), int(minor)), (2, 15))
+
+        # This assumes that uname -m matches the multiarch tuple
+        # closely enough. On x86_64 it does, and on i386 we have
+        # symlinks /overrides/lib/i[456]86-linux-gnu.
+        host_glibc = ctypes.cdll.LoadLibrary(
+            '/overrides/lib/{}-linux-gnu/libc.so.6'.format(os.uname().machine),
+        )
+        gnu_get_libc_version = host_glibc.gnu_get_libc_version
+        gnu_get_libc_version.restype = ctypes.c_char_p
+        host_glibc_version = gnu_get_libc_version().decode('ascii')
+        logger.info('host glibc version: %s', host_glibc_version)
+        self.assertEqual(host_glibc_version, glibc_version)
+
+        if (
+            'HOST_LD_LINUX_SO_REALPATH' in os.environ
+            and Path('/usr/lib/i386-linux-gnu').is_dir()
+        ):
+            host_path = os.environ['HOST_LD_LINUX_SO_REALPATH']
+            expected = self.host.path / host_path.lstrip('/')
+            expected_stat = expected.stat()
+            logger.info('host ld-linux.so.2: %s', host_path)
+
+            for really in (
+                '/lib/ld-linux.so.2',
+                '/lib/i386-linux-gnu/ld-linux.so.2',
+                '/lib/i386-linux-gnu/ld-2.15.so',
+            ):
+                really_stat = Path(really).stat()
+                # Either it's a symlink to the same file, or the same file
+                # was mounted over it
+                self.assertEqual(really_stat.st_dev, expected_stat.st_dev)
+                self.assertEqual(really_stat.st_ino, expected_stat.st_ino)
+
+        if (
+            'HOST_LD_LINUX_X86_64_SO_REALPATH' in os.environ
+            and Path('/usr/lib/x86_64-linux-gnu').is_dir()
+        ):
+            host_path = os.environ['HOST_LD_LINUX_X86_64_SO_REALPATH']
+            expected = self.host.path / host_path.lstrip('/')
+            expected_stat = expected.stat()
+            logger.info('host ld-linux-x86-64.so.2: %s', host_path)
+
+            for really in (
+                '/lib64/ld-linux-x86-64.so.2',
+                '/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2',
+                '/lib/x86_64-linux-gnu/ld-2.15.so',
+            ):
+                really_stat = Path(really).stat()
+                self.assertEqual(really_stat.st_dev, expected_stat.st_dev)
+                self.assertEqual(really_stat.st_ino, expected_stat.st_ino)
+
+    def test_srsi(self) -> None:
+        if 'HOST_STEAM_RUNTIME_SYSTEM_INFO_JSON' in os.environ:
+            with open(
+                os.environ['HOST_STEAM_RUNTIME_SYSTEM_INFO_JSON'],
+                'r',
+            ) as reader:
+                host_parsed = json.load(reader)
+        else:
+            host_parsed = {}
+
+        with (self.artifacts / 'srsi.json').open('w') as writer:
+            logger.info('steam-runtime-system-info --verbose...')
+            completed = subprocess.run(
+                # We specifically want the container's version, not
+                # any other version that might have crept into PATH.
+                ['/usr/bin/steam-runtime-system-info', '--verbose'],
+                stdout=writer,
+                stderr=subprocess.PIPE,
+                check=True,
+            )
+            if completed.stderr:
+                logger.info(
+                    '(stderr) -> \n%s',
+                    completed.stderr.decode('utf-8', 'backslashreplace')
+                )
+            else:
+                logger.info('(no stderr)')
+
+        with (self.artifacts / 'srsi.json').open('r') as reader:
+            for line in reader:
+                print(line.rstrip('\n'), file=sys.stderr)
+
+        with (self.artifacts / 'srsi.json').open('r') as reader:
+            parsed = json.load(reader)
+
+        self.assertIsInstance(parsed, dict)
+
+        if host_parsed:
+            host_os_release = host_parsed.get('os-release', {})
+        else:
+            host_os_release = parsed.get(
+                'container', {}
+            ).get(
+                'host', {}
+            ).get(
+                'os-release', {}
+            )
+
+        if host_os_release.get('id') == 'debian':
+            logger.info('Host OS is Debian')
+            host_is_debian_derived = True
+        elif 'debian' in host_os_release.get('id_like', []):
+            logger.info('Host OS is Debian-derived')
+            host_is_debian_derived = True
+        else:
+            logger.info('Host OS is not Debian-derived')
+            host_is_debian_derived = False
+
+        with self.catch(
+            'runtime information',
+            diagnostic=parsed.get('runtime'),
+        ):
+            self.assertIn('runtime', parsed)
+            self.assertEqual('/', parsed['runtime'].get('path'))
+            self.assertIn('version', parsed['runtime'])
+            issues = parsed['runtime'].get('issues', [])
+            self.assertNotIn('disabled', issues)
+            self.assertNotIn('internal-error', issues)
+            self.assertNotIn('not-in-environment', issues)
+            self.assertNotIn('not-in-ld-path', issues)
+            self.assertNotIn('not-in-path', issues)
+            self.assertNotIn('not-runtime', issues)
+            self.assertNotIn('not-using-newer-host-libraries', issues)
+            self.assertNotIn('unexpected-location', issues)
+            self.assertNotIn('unexpected-version', issues)
+            # Don't assert whether it contains 'unofficial':
+            # we want to be able to test unofficial runtimes too
+
+            self.assertIn('overrides', parsed['runtime'])
+            self.assertNotIn('pinned_libs_32', parsed['runtime'])
+            self.assertNotIn('pinned_libs_64', parsed['runtime'])
+
+        with self.catch(
+            'os-release information',
+            diagnostic=parsed.get('os-release'),
+        ):
+            self.assertIn('os-release', parsed)
+            self.assertEqual('steamrt', parsed['os-release']['id'])
+            self.assertNotIn(
+                parsed['os-release']['id'],
+                parsed['os-release'].get('id_like', [])
+            )
+            self.assertIn('name', parsed['os-release'])
+            self.assertIn('pretty_name', parsed['os-release'])
+            self.assertIn('version_id', parsed['os-release'])
+            self.assertEqual('1', parsed['os-release']['version_id'])
+            self.assertEqual('scout', parsed['os-release']['version_codename'])
+            self.assertIn('build_id', parsed['os-release'])
+
+        with self.catch(
+            'container info',
+            diagnostic=parsed.get('container'),
+        ):
+            self.assertIn('container', parsed)
+            self.assertEqual(parsed['container']['type'], 'pressure-vessel')
+            self.assertEqual(parsed['container']['host']['path'], '/run/host')
+
+        if host_parsed:
+            self.assertEqual(
+                parsed['container']['host']['os-release'],
+                host_parsed['os-release'],
+            )
+
+            if os.environ.get('TEST_INSIDE_SCOUT_LOCALES'):
+                for locale, host_details in host_parsed.get(
+                    'locales', {}
+                ).items():
+                    self.assertIn(locale, parsed['locales'])
+                    details = parsed['locales'][locale]
+
+                    # Every locale that worked on the host works in the
+                    # container
+                    if 'resulting-name' in host_details:
+                        self.assertEqual(host_details, details)
+
+                    # en_US.UTF-8 should also work in the container, because we
+                    # make sure to generate it
+                    if locale == 'en_US.UTF-8':
+                        self.assertEqual(details.get('error'), None)
+
+                # en_US.UTF-8 should also work in the container, because we
+                # make sure to generate it
+                if locale == 'en_US.UTF-8':
+                    self.assertEqual(details.get('error'), None)
+
+        self.assertIn('architectures', parsed)
+
+        for multiarch in parsed['architectures']:
+            if not (Path('/usr/lib') / multiarch).is_dir():
+                continue
+
+            arch_info = parsed['architectures'][multiarch]
+            host_info = host_parsed.get('architectures', {}).get(multiarch, {})
+
+            with self.catch(
+                'per-architecture information',
+                diagnostic=arch_info,
+                arch=multiarch,
+            ):
+                self.assertTrue(arch_info['can-run'])
+                self.assertEqual([], arch_info['library-issues-summary'])
+                # Graphics driver support depends on the host system, so we
+                # don't assert that everything is fine, only that we have
+                # the information.
+                self.assertIn('graphics-details', arch_info)
+                self.assertIn('glx/gl', arch_info['graphics-details'])
+
+            for soname, details in arch_info['library-details'].items():
+                with self.catch(
+                    'per-library information',
+                    diagnostic=details,
+                    arch=multiarch,
+                    soname=soname,
+                ):
+                    self.assertIn('path', details)
+                    self.assertEqual(
+                        [],
+                        details.get('missing-symbols', []),
+                    )
+                    self.assertEqual(
+                        [],
+                        details.get('misversioned-symbols', []),
+                    )
+                    self.assertEqual([], details.get('issues', []))
+
+            for soname in (
+                'libBrokenLocale.so.1',
+                'libanl.so.1',
+                'libc.so.6',
+                'libcrypt.so.1',
+                'libdl.so.2',
+                'libm.so.6',
+                'libnsl.so.1',
+                'libpthread.so.0',
+                'libresolv.so.2',
+                'librt.so.1',
+                'libutil.so.1',
+            ):
+                # These are from glibc, which is depended on by Mesa, and
+                # is at least as new as scout's version in every supported
+                # version of the Steam Runtime.
+                self.assertEqual(
+                    arch_info['library-details'][soname]['path'],
+                    '/overrides/lib/{}/{}'.format(multiarch, soname),
+                )
+
+            for soname in (
+                'libSDL-1.2.so.0',
+                'libfltk.so.1.1',
+            ):
+                # These libraries are definitely not part of the graphics
+                # driver stack
+                self.assertEqual(
+                    arch_info['library-details'][soname]['path'],
+                    '/usr/lib/{}/{}'.format(multiarch, soname),
+                )
+
+            if host_info:
+                expect_symlinks = {
+                }    # type: typing.Dict[str, typing.List[str]]
+                for dri in host_info.get('dri_drivers', ()):
+                    path = dri['library_path']
+
+                    if path.startswith((    # any of:
+                        '/usr/lib/dri/',
+                        '/usr/lib32/dri/',
+                        '/usr/lib64/dri/',
+                        '/usr/lib/{}/dri/'.format(multiarch),
+                    )):
+                        # We don't make any assertion about the search
+                        # order here.
+                        host_path = '/run/host' + path
+
+                        # Take the realpath() on non-Debian-derived hosts,
+                        # because on Arch Linux, we find drivers in
+                        # /usr/lib64 that are physically in /usr/lib.
+                        # Be more strict on Debian because we know more
+                        # about the canonical paths there.
+                        if not host_is_debian_derived:
+                            with contextlib.suppress(OSError):
+                                host_path = os.path.realpath(host_path)
+
+                        expect_symlinks.setdefault(
+                            os.path.basename(path), []
+                        ).append(host_path)
+
+                for k, vs in expect_symlinks.items():
+                    with self.subTest(dri_symlink=k):
+                        link = '/overrides/lib/{}/dri/{}'.format(multiarch, k)
+                        logger.info('Target of %s should be in %s', link, vs)
+                        target = os.readlink(link)
+
+                        # Again, take the realpath() on non-Debian-derived
+                        # hosts, but be more strict on Debian.
+                        if not host_is_debian_derived:
+                            with contextlib.suppress(OSError):
+                                target = os.path.realpath(link)
+
+                        self.assertIn(target, vs)
+
+                for stack, host_details in (
+                    host_info.get('graphics-details', {}).items()
+                ):
+                    if stack not in arch_info['graphics-details']:
+                        continue
+
+                    # On Debian hosts with SDK containers, this might
+                    # not work because we get confused about whether
+                    # our libedit.so.2 is older or newer than the
+                    # host's. (T21954)
+                    if (
+                        host_is_debian_derived
+                        and (
+                            Path('/usr/lib') / multiarch / 'libedit.so.2'
+                        ).exists()
+                    ):
+                        logger.info(
+                            'libedit.so.2 exists, skipping graphics check'
+                        )
+                        continue
+
+                    # Compounding the above, capsule-capture-libs 0.20190926.0
+                    # will not capture the i386 libedit.so.2 from the host
+                    # if it thinks the *x86_64* libedit.so.2 from the
+                    # container is newer, due to a bug. We work around it
+                    # for now, to get some test coverage going.
+                    if (
+                        host_is_debian_derived
+                        and Path(
+                            '/usr/lib/x86_64-linux-gnu/libedit.so.2'
+                        ).exists()
+                    ):
+                        logger.info(
+                            'x86_64 libedit.so.2 exists, skipping graphics '
+                            'check'
+                        )
+                        continue
+
+                    with self.subTest(stack=stack):
+                        details = arch_info['graphics-details'][stack]
+
+                        # If it works on the host, it should work in
+                        # the container (modulo caveats above).
+                        if not host_details.get('issues', ()):
+                            self.assertFalse(details.get('issues', ()))
+
+                        for key in (
+                            'renderer',
+                            'version',
+                            'library-vendor',
+                        ):
+                            with self.subTest(key=key):
+                                if key in host_details:
+                                    self.assertEqual(
+                                        host_details[key],
+                                        details.get(key),
+                                    )
+
+
+if __name__ == '__main__':
+    assert sys.version_info >= (3, 5), 'Python 3.5+ is required'
+
+    test_main()
+
+# vi: set sw=4 sts=4 et:
diff --git a/tests/meson.build b/tests/meson.build
index 3c9541e30..8b2bc8d6f 100644
--- a/tests/meson.build
+++ b/tests/meson.build
@@ -24,10 +24,15 @@
 test_env = environment()
 test_env.set('G_TEST_SRCDIR', meson.current_source_dir())
 test_env.set('G_TEST_BUILDDIR', meson.current_build_dir())
+test_env.set(
+  'PRESSURE_VESSEL_TEST_CONTAINERS',
+  get_option('test_containers_dir'),
+)
 test_env.set('PRESSURE_VESSEL_UNINSTALLED', 'yes')
 
 tests = [
   'cheap-copy.py',
+  'containers.py',
   'invocation.py',
   'mypy.sh',
   'pycodestyle.sh',
@@ -76,16 +81,22 @@ endforeach
 
 foreach test_name : tests
   test_args = ['-v', files(test_name)]
+  timeout = 30
 
   if test_name.endswith('.py')
     test_args += ['-e', python.path()]
   endif
 
+  if test_name.endswith('containers.py')
+    timeout = 300
+  endif
+
   if prove.found()
     test(
       test_name, prove,
       args : test_args,
       env : test_env,
+      timeout : timeout,
     )
   endif
 endforeach
diff --git a/tests/testutils.py b/tests/testutils.py
index e85584b8f..a5dc0e475 100644
--- a/tests/testutils.py
+++ b/tests/testutils.py
@@ -131,6 +131,20 @@ class BaseTest(unittest.TestCase):
         cls.tmpdir.cleanup()
 
 
+def tee_file_and_stderr(path: str) -> subprocess.Popen:
+    """
+    Return a context manager with a stdin attribute.
+    Anything written to its stdin will be written to `path`
+    and also to stderr.
+    """
+    return subprocess.Popen(
+        ['tee', '--', path],
+        stdin=subprocess.PIPE,
+        stdout=2,
+        stderr=2,
+    )
+
+
 def test_main():
     logging.basicConfig(level=logging.DEBUG)
 
-- 
GitLab