#!/usr/bin/env python3
# Copyright 2019-2020 Collabora Ltd.
#
# SPDX-License-Identifier: MIT

r"""
To run manually:

./download-pressure-vessel.py .cache
./populate-depot.py \
    --depot=depots/test \
    --depot-version local \
    --suite soldier \
    --version latest \
    --ssh-host ... \
    --ssh-path .../steamrt-SUITE/snapshots \
    --pressure-vessel-archive .cache/pressure-vessel-bin.tar.gz \
    --minimize \
    --unpack-ld-library-path depots/ldlp \
    soldier

rm -fr depots/artifacts
mkdir depots/artifacts

TEST_CONTAINER_RUNTIME_SUITE=soldier \
TEST_CONTAINER_RUNTIME_LD_LIBRARY_PATH_RUNTIME=depots/ldlp/steam-runtime \
TEST_CONTAINER_RUNTIME_STEAMRT_SOURCE=depots/ldlp/steamrt \
AUTOPKGTEST_ARTIFACTS=depots/artifacts \
./tests/depot/pressure-vessel.py
"""

import contextlib
import json
import logging
import os
import os.path
import shutil
import subprocess
import sys
import tempfile
import unittest

try:
    import typing
    typing      # placate pyflakes
except ImportError:
    pass


logger = logging.getLogger('test-pressure-vessel')


class MyCompletedProcess:
    """
    A minimal reimplementation of subprocess.CompletedProcess from
    Python 3.5+, so that this test can be run on the Python 3.4
    interpreter in Debian 8 'jessie', SteamOS 2 'brewmaster' and
    Ubuntu 14.04 'trusty'.
    """

    def __init__(
        self,
        args='',                # type: typing.Union[typing.List[str], str]
        returncode=-1,          # type: int
        stdout=None,            # type: typing.Optional[bytes]
        stderr=None             # type: typing.Optional[bytes]
    ) -> None:
        self.args = args
        self.returncode = returncode
        self.stdout = stdout
        self.stderr = stderr

    def check_returncode(self) -> None:
        if self.returncode != 0:
            raise subprocess.CalledProcessError(
                self.returncode,
                str(self.args),
                output=self.stdout,
            )


class TestPressureVessel(unittest.TestCase):
    def setUp(self) -> None:
        # Absolute path to the directory containing pressure-vessel
        # and the runtime(s).
        self.depot = os.path.abspath('depot')

        # Apt suite used for the runtime (scout, soldier or something newer).
        # Default: scout
        self.suite = os.getenv(
            'TEST_CONTAINER_RUNTIME_SUITE', 'scout'
        )

        # dpkg architectures in the runtime, with primary architecture
        # first. Default: amd64, i386
        self.dpkg_architectures = os.getenv(
            'TEST_CONTAINER_RUNTIME_ARCHITECTURES',
            'amd64,i386'
        ).split(',')

        # Path to an unpacked LD_LIBRARY_PATH runtime, or None.
        self.ld_library_path_runtime = os.getenv(
            'TEST_CONTAINER_RUNTIME_LD_LIBRARY_PATH_RUNTIME',
            None,
        )       # type: typing.Optional[str]

        # Path to an unpacked steamrt source package for the target
        # suite, or None. If it contains SUITE it will be replaced by
        # the suite name.
        self.steamrt_source = os.getenv(
            'TEST_CONTAINER_RUNTIME_STEAMRT_SOURCE',
            None,
        )       # type: typing.Optional[str]

        self.tmpdir = tempfile.TemporaryDirectory()
        self.addCleanup(self.tmpdir.cleanup)

        ldlp = self.ld_library_path_runtime

        if ldlp is not None:
            if os.access(ldlp, os.W_OK):
                self.ld_library_path_runtime = ldlp = os.path.abspath(ldlp)
            else:
                old = ldlp
                ldlp = os.path.join(self.tmpdir.name, 'ldlp')
                shutil.copytree(old, ldlp, symlinks=True)
                self.ld_library_path_runtime = ldlp

        if self.suite == 'soldier' and ldlp is not None:
            self.scout_layered = os.path.abspath(
                'runtimes/scout-on-soldier'
            )

            old = self.scout_layered
            new = os.path.join(self.tmpdir.name, 'scout-on-soldier')
            shutil.copytree(old, new, symlinks=True)
            os.symlink(ldlp, os.path.join(new, 'steam-runtime'))
            self.scout_layered = new
        else:
            self.scout_layered = ''

        artifacts = os.getenv('AUTOPKGTEST_ARTIFACTS')

        if artifacts is not None:
            self.artifacts = artifacts
        else:
            self.artifacts = self.tmpdir.name

    @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 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 get_runtime_build_id(self):
        with self.catch('get build ID'):
            with open(os.path.join(self.depot, 'VERSIONS.txt')) as reader:
                for line in reader:
                    if line.startswith('#'):
                        continue

                    fields = line.rstrip('\n').split('\t')

                    if len(fields) < 2:
                        continue

                    if fields[0] == self.suite:
                        logger.info('%s build ID: %s', self.suite, fields[1])
                        return fields[1]
                else:
                    raise RuntimeError(
                        'suite {} not found in VERSIONS.txt'.format(self.suite)
                    )

    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
    ):
        """
        This is basically a reimplementation of subprocess.run()
        from Python 3.5+, so that this test can be run on the Python
        3.4 interpreter in Debian 8 'jessie', SteamOS 2 'brewmaster'
        and Ubuntu 14.04 'trusty'. It also adds extra logging.
        """

        logger.info('Running: %r', args)

        popen = subprocess.Popen(args, **kwargs)    # type: ignore
        out, err = popen.communicate(input=input, timeout=timeout)
        completed = MyCompletedProcess(
            args=args,
            returncode=popen.returncode,
            stdout=out,
            stderr=err,
        )

        if check:
            completed.check_returncode()

        return completed

    def tee_file_and_stderr(self, 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_general_info(self) -> None:
        with self.tee_file_and_stderr(
            os.path.join(self.artifacts, 'depot-contents.txt')
        ) as tee:
            with self.catch('List contents of depot'):
                completed = self.run_subprocess(
                    ['find', '.', '-ls'],
                    cwd=self.depot,
                    stdout=tee.stdin,
                    stderr=subprocess.PIPE,
                )

                if completed.stderr:
                    logger.info(
                        '(stderr) ->\n%s',
                        completed.stderr.decode('utf-8', 'backslashreplace'),
                    )
                else:
                    logger.info('(no stderr)')

                completed.check_returncode()

        with self.catch('Read VERSION.txt'):
            with open(
                os.path.join(
                    'depot', 'pressure-vessel', 'metadata', 'VERSION.txt',
                ),
                'r'
            ) as reader:
                logger.info(
                    'pressure-vessel version %s',
                    reader.read().strip(),
                )

        for exe in (
            'srt-bwrap',
            'i386-linux-gnu-capsule-capture-libs',
            'x86_64-linux-gnu-capsule-capture-libs',
            'pressure-vessel-wrap',
            'steam-runtime-system-info',
        ):
            with self.catch('--version', exe=exe):
                exe_path = os.path.join('pressure-vessel', 'bin', exe)

                if (
                    (
                        exe == 'srt-bwrap'
                        or exe.endswith('capsule-capture-libs')
                    )
                    and not os.path.exists(os.path.join(self.depot, exe_path))
                ):
                    exe_path = os.path.join(
                        'pressure-vessel', 'libexec', 'steam-runtime-tools-0',
                        exe,
                    )

                if (
                    exe == 'srt-bwrap'
                    and not os.path.exists(os.path.join(self.depot, exe_path))
                ):
                    exe = 'pv-bwrap'
                    exe_path = os.path.join('pressure-vessel', 'bin', exe)

                completed = self.run_subprocess(
                    [
                        exe_path,
                        '--version',
                    ],
                    cwd=self.depot,
                    stdout=subprocess.PIPE,
                    stderr=subprocess.PIPE,
                )
                logger.info(
                    '%s --version ->\n%s',
                    exe,
                    completed.stdout.decode('utf-8').strip(),
                )

                if completed.stderr:
                    logger.info(
                        '(stderr) ->\n%s',
                        completed.stderr.decode('utf-8', 'backslashreplace'),
                    )
                else:
                    logger.info('(no stderr)')

                completed.check_returncode()

        with self.tee_file_and_stderr(
            os.path.join(self.artifacts, 's-r-s-i-outside.json'),
        ) as tee:
            with self.catch('steam-runtime-system-info outside container'):
                completed = self.run_subprocess(
                    [
                        'env',
                        'G_MESSAGES_DEBUG=all',
                        os.path.join(
                            'pressure-vessel', 'bin',
                            'steam-runtime-system-info',
                        ),
                        '--verbose',
                    ],
                    cwd=self.depot,
                    stdout=tee.stdin,
                    stderr=subprocess.PIPE,
                )

                if completed.stderr:
                    logger.info(
                        'steam-runtime-system-info --verbose (stderr) ->\n%s',
                        completed.stderr.decode('utf-8', 'backslashreplace'),
                    )
                else:
                    logger.info('(no stderr)')

                completed.check_returncode()

        self.get_runtime_build_id()

    def get_pressure_vessel_adverb(
        self,
        ld_library_path_runtime=None,       # type: typing.Optional[str]
        scout_layered=False
    ):
        # type: (...) -> typing.List[str]
        adverb = [os.path.join(self.depot, 'run')]

        if self.ld_library_path_runtime is not None:
            adverb = adverb + [
                '--filesystem=' + self.ld_library_path_runtime,
            ]

        adverb = adverb + [
            '--verbose',
            '--',
        ]

        if ld_library_path_runtime is not None:
            adverb = [
                os.path.join(ld_library_path_runtime, 'run.sh'),
            ] + adverb

        if scout_layered:
            adverb = adverb + [
                os.path.join(
                    self.scout_layered,
                    'scout-on-soldier-entry-point-v2',
                ),
                '--verbose',
                '--',
            ]

        return adverb

    def test_pressure_vessel(
        self,
        artifact_prefix='s-r-s-i-inside',
        ld_library_path_runtime=None,       # type: typing.Optional[str]
        scout_layered=False
    ) -> None:
        if scout_layered:
            with self.tee_file_and_stderr(
                os.path.join(self.artifacts, 'scout-layered-contents.txt')
            ) as tee, self.catch(
                'List contents of scout_layered'
            ):
                completed = self.run_subprocess(
                    ['find', '.', '-ls'],
                    cwd=self.scout_layered,
                    stdout=tee.stdin,
                    stderr=subprocess.PIPE,
                )

                if completed.stderr:
                    logger.info(
                        '(stderr) ->\n%s',
                        completed.stderr.decode('utf-8', 'backslashreplace'),
                    )
                else:
                    logger.info('(no stderr)')

                completed.check_returncode()

        adverb = self.get_pressure_vessel_adverb(
            ld_library_path_runtime=ld_library_path_runtime,
            scout_layered=scout_layered,
        )

        with self.catch('cat /etc/os-release in container'):
            completed = self.run_subprocess(
                adverb + ['cat', '/etc/os-release'],
                cwd=self.tmpdir.name,
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
            )

            logger.info(
                'os-release:\n%s',
                completed.stdout.decode('utf-8'),
            )

            if completed.stderr:
                logger.info(
                    '(stderr) ->\n%s',
                    completed.stderr.decode('utf-8', 'backslashreplace'),
                )
            else:
                logger.info('(no stderr)')

            completed.check_returncode()

        with self.tee_file_and_stderr(
            os.path.join(self.artifacts, artifact_prefix + '.json'),
        ) as tee:
            with self.catch('run s-r-s-i in container'):
                completed = self.run_subprocess(
                    adverb + [
                        'env', 'G_MESSAGES_DEBUG=all',
                        'steam-runtime-system-info', '--verbose'
                    ],
                    cwd=self.tmpdir.name,
                    stdout=tee.stdin,
                    stderr=subprocess.PIPE,
                )

                if completed.stderr:
                    logger.info(
                        '(stderr) ->\n%s',
                        completed.stderr.decode('utf-8', 'backslashreplace'),
                    )
                else:
                    logger.info('(no stderr)')

                completed.check_returncode()

        with open(
            os.path.join(self.artifacts, artifact_prefix + '.json'),
            'r',
        ) as reader:
            parsed = json.load(reader)

        runtime_build_id = self.get_runtime_build_id()

        self.assertIsInstance(parsed, dict)
        self.assertIn('can-write-uinput', parsed)
        self.assertIn('steam-installation', parsed)

        with self.catch(
            'runtime information',
            diagnostic=parsed.get('runtime'),
        ):
            self.assertIn('runtime', parsed)
            if scout_layered:
                self.assertEqual(
                    os.path.join(self.scout_layered, 'var', 'steam-runtime'),
                    parsed['runtime'].get('path'),
                )
            else:
                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('on-network-filesystem', issues)
            self.assertNotIn('on-non-unix-filesystem', issues)
            self.assertNotIn('on-unknown-filesystem', issues)
            self.assertNotIn('unexpected-location', issues)
            self.assertNotIn('unexpected-version', issues)
            self.assertNotIn('unknown', issues)
            # Don't assert whether it contains 'unofficial':
            # we want to be able to test unofficial runtimes too

            self.assertIn('overrides', parsed['runtime'])

            if scout_layered:
                self.assertIn('pinned_libs_32', parsed['runtime'])
                self.assertIn('pinned_libs_64', parsed['runtime'])
            else:
                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'])

            if self.suite == 'scout':
                self.assertEqual('1', parsed['os-release']['version_id'])
            elif self.suite == 'soldier':
                self.assertEqual('2', parsed['os-release']['version_id'])

            self.assertEqual(
                self.suite,
                parsed['os-release']['version_codename'],
            )
            self.assertEqual(
                runtime_build_id,
                parsed['os-release']['build_id'],
            )

        self.assertIn('architectures', parsed)

        for arch in self.dpkg_architectures:
            if arch == 'i386':
                multiarch = 'i386-linux-gnu'
            elif arch == 'amd64':
                multiarch = 'x86_64-linux-gnu'
            else:
                continue

            self.assertIn(multiarch, parsed['architectures'])
            arch_info = parsed['architectures'][multiarch]
            library_issues = set()

            for soname, details in arch_info['library-details'].items():
                with self.catch(
                    'per-library information',
                    diagnostic=details,
                    arch=arch,
                    soname=soname,
                ):
                    self.assertIn('path', details)
                    messages = details.get('messages', [])
                    missing_symbols = details.get('missing-symbols', [])
                    issues = details.get('issues', [])

                    if soname == 'libgbm.so.1':
                        # We still test on Steam Runtime 2 and Debian 10,
                        # which have a sufficiently old Mesa version that
                        # they do not have all the symbols we expect to find
                        # in libgbm.so.1.
                        library_issues |= set(issues)
                        issues = list(set(issues) - set(['missing-symbols']))
                        missing_symbols = list(
                            set(missing_symbols) - set(['gbm_format_get_name'])
                        )
                    elif soname in (
                        'libform.so.6',
                        'libformw.so.6',
                        'libmenu.so.6',
                        'libmenuw.so.6',
                        'libncurses.so.6',
                        'libncursesw.so.6',
                        'libpanel.so.6',
                        'libpanelw.so.6',
                    ) and 'libtinfo.so.6' in ''.join(messages):
                        # We have two related known issues for libncurses:
                        # see steamrt/tasks#341.
                        # When running SteamRT 3 on a much newer host OS
                        # such as Debian >= 12, we correctly choose the host
                        # libtinfo.so.6, but that doesn't have the short-lived
                        # internal symver NCURSES[6]_TINFO_6.2.current which
                        # is required by our ncurses family of libraries.
                        # Conversely, when running SteamRT 4 or newer on
                        # Debian 11, the host libtinfo.so.6 has that symver,
                        # which means we err on the side of considering our
                        # libtinfo to be older, and then the host libtinfo
                        # is too old for our ncurses family of libraries.
                        library_issues |= set(issues)
                        issues = list(set(issues) - set(['cannot-load']))

                    self.assertEqual([], missing_symbols)
                    self.assertEqual(
                        [],
                        details.get('misversioned-symbols', []),
                    )
                    self.assertEqual([], issues)

            with self.catch(
                'per-architecture information',
                diagnostic=arch_info,
                arch=arch,
            ):
                self.assertTrue(arch_info['can-run'])
                self.assertEqual(
                    library_issues,
                    set(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'])

        # Locale support depends on the host system, so we don't assert
        # that everything is fine, only that we have the information.
        self.assertIn('locale-issues', parsed)
        self.assertIn('locales', parsed)

        # 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('egl', parsed)
        self.assertIn('vulkan', parsed)

    def test_unruntime(self) -> None:
        if self.ld_library_path_runtime is not None:
            self.run_subprocess(
                os.path.join(self.ld_library_path_runtime, 'setup.sh'),
                check=True,
                stdout=2,
                stderr=2,
            )

            self.test_pressure_vessel(
                artifact_prefix='s-r-s-i-inside-unruntime',
                ld_library_path_runtime=self.ld_library_path_runtime,
            )
        else:
            self.skipTest(
                'TEST_CONTAINER_RUNTIME_LD_LIBRARY_PATH_RUNTIME not provided'
            )

    def test_scout_layered(self) -> None:
        if self.suite != 'soldier':
            self.skipTest('Suite is not soldier')
        elif self.ld_library_path_runtime is not None:
            assert self.scout_layered
            self.test_pressure_vessel(
                artifact_prefix='scout-layered',
                ld_library_path_runtime=self.ld_library_path_runtime,
                scout_layered=True,
            )
        else:
            self.skipTest(
                'TEST_CONTAINER_RUNTIME_LD_LIBRARY_PATH_RUNTIME not provided'
            )

    def test_steamrt_platform(self) -> None:
        steamrt_source = self.steamrt_source

        if steamrt_source is None:
            self.skipTest(
                'TEST_CONTAINER_RUNTIME_STEAMRT_SOURCE not provided'
            )
            return

        steamrt_source = steamrt_source.replace('SUITE', self.suite)

        if not os.path.exists(
            os.path.join(steamrt_source, 'debian', 'tests', 'platform')
        ):
            self.skipTest(
                'No script at '
                '$TEST_CONTAINER_RUNTIME_STEAMRT_SOURCE/debian/tests/platform'
            )
            return

        adverb = self.get_pressure_vessel_adverb()

        with self.tee_file_and_stderr(
            os.path.join(self.artifacts, 'platform.log'),
        ) as tee:
            with self.catch('run steamrt platform test in container'):
                self.run_subprocess(
                    adverb + ['debian/tests/platform'],
                    cwd=steamrt_source,
                    stdout=tee.stdin,
                    stderr=subprocess.STDOUT,
                    check=True,
                )

    def tearDown(self) -> None:
        pass


if __name__ == '__main__':
    logging.basicConfig(level=logging.DEBUG)

    sys.path[:0] = [os.path.join(
        os.path.dirname(os.path.dirname(__file__)),
        'third-party',
    )]

    import pycotap
    unittest.main(
        buffer=False,
        testRunner=pycotap.TAPTestRunner,
    )

# vi: set sw=4 sts=4 et:
