#!/usr/bin/python3 -u
#
# autopkgtest is a tool for testing Debian binary packages
#
# autopkgtest is Copyright (C) 2006-2016 Canonical Ltd.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
#
# See the file CREDITS for a full list of credits information (often
# installed as /usr/share/doc/autopkgtest/CREDITS).

import signal
import tempfile
import sys
import subprocess
import traceback
import re
import os
import time
import shutil
import atexit
import json
import shlex

from debian.deb822 import Deb822
from debian.debian_support import Version

# support running out of git and from packaged install
our_base = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if os.path.isdir(os.path.join(our_base, 'virt')):
    sys.path.insert(0, os.path.join(our_base, 'lib'))
    vserver_dir = os.path.join(our_base, 'virt')
    os.environ['PATH'] = vserver_dir + ':' + os.environ.get('PATH', '')
else:
    sys.path.insert(0, '/usr/share/autopkgtest/lib')

import adtlog
import testdesc
import adt_binaries
from adt_testbed import (
    Testbed,
    TestbedPath,
)

from autopkgtest_args import parse_args

# ---------- global variables

tmp = None		# pathstring on host
testbed = None		# Testbed
opts = None             # argparse options
actions = None          # list of (action_type, path)
errorcode = 0		# exit status that we are going to use
binaries = None		# DebBinaries (.debs we have registered)
blamed = []


# ---------- convenience functions

def files_from_dsc(dsc_path):
    '''Get files from a .dsc or a .changes

    Return list of files, including the directory of dsc_path.
    '''
    try:
        files = testdesc.parse_rfc822(dsc_path).__next__()['Files'].split()
    except (StopIteration, KeyError):
        adtlog.badpkg('%s is invalid and does not contain Files:' % dsc_path)

    dsc_dir = os.path.dirname(dsc_path)

    return [os.path.join(dsc_dir, f) for f in files if '.' in f and '_' in f]


def blame(m):
    global blamed
    adtlog.debug('blame += %s' % m)
    blamed.append(m)


def setup_trace():
    global tmp

    if opts.output_dir is not None:
        os.makedirs(opts.output_dir, exist_ok=True)
        if os.listdir(opts.output_dir):
            adtlog.bomb('--output-dir "%s" is not empty' % opts.output_dir)
        tmp = opts.output_dir
    else:
        assert tmp is None
        tmp = tempfile.mkdtemp(prefix='autopkgtest.output.')
        os.chmod(tmp, 0o755)

    if opts.logfile is None and opts.output_dir is not None:
        opts.logfile = opts.output_dir + '/log'

    if opts.logfile is not None:
        # tee stdout/err into log file
        (fd, fifo_log) = tempfile.mkstemp(prefix='autopkgtest-fifo.')
        os.close(fd)
        os.unlink(fifo_log)
        os.mkfifo(fifo_log)
        atexit.register(os.unlink, fifo_log)
        start = str(int(time.time()))

        out_tee = subprocess.Popen(['tee', fifo_log],
                                   stdin=subprocess.PIPE)
        err_tee = subprocess.Popen(['tee', fifo_log, '-a', '/dev/stderr'],
                                   stdin=subprocess.PIPE,
                                   stdout=open('/dev/null', 'wb'))

        # In order to correctly timestamp individual lines we need to
        # disable stdin buffering via -Winteractive.
        log_awk = subprocess.Popen(['mawk', '-Winteractive', r'{ printf("%3ds %s\n", systime()-' + start + ', $0 );}',
                                    fifo_log],
                                   stdout=open(opts.logfile, 'wb'))

        adtlog.enable_colors = False
        os.dup2(out_tee.stdin.fileno(), sys.stdout.fileno())
        os.dup2(err_tee.stdin.fileno(), sys.stderr.fileno())

        def cleanup():
            os.close(sys.stdout.fileno())
            os.close(out_tee.stdin.fileno())
            out_tee.wait()
            os.close(sys.stderr.fileno())
            os.close(err_tee.stdin.fileno())
            err_tee.wait()
            log_awk.wait()

        atexit.register(cleanup)

    if opts.summary is not None:
        adtlog.summary_stream = open(opts.summary, 'w+b', 0)
    else:
        adtlog.summary_stream = open(os.path.join(tmp, 'summary'), 'w+b', 0)


def run_tests(tests, tree):
    global errorcode, testbed

    # We should not get here if we have had an error other than skipping
    # tests
    assert errorcode in (0, 2), errorcode

    if not tests:
        # if we have skipped tests, don't claim that we don't have any
        if errorcode == 0:
            adtlog.report('*', 'SKIP no tests in this package')

        errorcode = 8
        return

    any_positive = False

    for t in tests:
        # Check for conditions that allow us to skip the test without touching
        # the testbed. In this case is it safe to stop processing the test
        # early (i.e. `continue`), leaving everything as it was left by the
        # previous test.
        if testbed.test_arch_is_foreign and 'skip-foreign-architecture' in t.restrictions:
            errorcode |= 2
            adtlog.report(t.name, 'SKIP test arch is foreign and skip-foreign-architecture set')
            continue

        # Set up clean test bed with given dependencies
        adtlog.info('test %s: preparing testbed' % t.name)
        testbed.reset(t.depends)
        binaries.publish()
        doTest = True

        try:
            testbed.install_deps(t.depends, opts.shell_fail,
                                 t.package_under_test_depends)
        except adtlog.BadPackageError as e:
            if 'skip-not-installable' in t.restrictions:
                errorcode |= 2
                adtlog.report(t.name, 'SKIP installation fails and skip-not-installable set')
            else:
                if opts.shell_fail:
                    testbed.run_shell()
                errorcode |= 12
                adtlog.report(t.name, 'FAIL badpkg')
                adtlog.preport('blame: ' + ' '.join(blamed))
                adtlog.preport('badpkg: ' + str(e))
            doTest = False

        if doTest:
            try:
                testbed.satisfy_restrictions(t.name, t.restrictions)
            except testdesc.Unsupported as unsupported:
                errorcode |= 2
                unsupported.report()
                doTest = False

        if doTest:
            testbed.run_test(tree, t, opts.env, opts.shell_fail, opts.shell,
                             opts.build_parallel)
            if t.skipped:
                errorcode |= 2
            elif not t.result:
                if 'flaky' in t.restrictions:
                    errorcode |= 2
                else:
                    errorcode |= 4
            elif 'superficial' not in t.restrictions:
                # A superficial test passing is merely a neutral result,
                # not a positive result
                any_positive = True

        if 'breaks-testbed' in t.restrictions:
            testbed.needs_reset()

    if errorcode in (0, 2) and not any_positive:
        # If we have skipped or ignored every non-superficial test, set
        # the same exit status as if we didn't have any tests
        errorcode = 8

    testbed.needs_reset()


def create_testinfo(vserver_args):
    global testbed

    info = {'virt_server': ' '.join([shlex.quote(w) for w in vserver_args])}

    if testbed.initial_kernel_version:
        info['kernel_version'] = testbed.initial_kernel_version
    if testbed.test_kernel_versions:
        info['test_kernel_versions'] = testbed.test_kernel_versions
    if opts.env:
        info['custom_environment'] = opts.env
    if testbed.nproc:
        info['nproc'] = testbed.nproc
    if testbed.cpu_model:
        info['cpu_model'] = testbed.cpu_model
    if testbed.cpu_flags:
        info['cpu_flags'] = testbed.cpu_flags

    with open(os.path.join(tmp, 'testinfo.json'), 'w') as f:
        json.dump(info, f, indent=2)


def print_exception(ei, msgprefix=''):
    (et, e, tb) = ei
    try:
        if msgprefix:
            adtlog.error(msgprefix)
        if et is adtlog.BadPackageError:
            adtlog.preport('blame: ' + ' '.join(blamed))
            adtlog.preport('badpkg: ' + e.args[0])
            adtlog.error('erroneous package: ' + e.args[0])
            adtlog.psummary('erroneous package: ' + e.args[0])
            return 12
        elif et is adtlog.TestbedFailure:
            adtlog.error('testbed failure: ' + e.args[0])
            adtlog.psummary('testbed failure: ' + e.args[0])
            return 16
        elif et is adtlog.AutopkgtestError:
            adtlog.psummary(e.args[0])
            adtlog.error(e.args[0])
            return 20
        else:
            adtlog.error('unexpected error:')
            adtlog.psummary('quitting: unexpected error, see log')
            traceback.print_exc(None, sys.stderr)
            return 20
    except OSError as exc:
        print(f"Issue while logging exception: {exc}", file=sys.stderr)
        print(f"Exception was '{e}'", file=sys.stderr)
        return 16


def cleanup():
    try:
        if testbed is not None:
            if binaries is not None:
                binaries.reset()
            testbed.stop()
        if opts.output_dir is None and tmp is not None:
            shutil.rmtree(tmp, ignore_errors=True)
    except Exception:
        print_exception(sys.exc_info(),
                        '%s\n: error cleaning up:\n' % os.path.basename(sys.argv[0]))
        sys.exit(20)


def signal_handler(signum, frame):
    adtlog.error('Received signal %i, cleaning up...' % signum)
    signal.signal(signum, signal.SIG_DFL)
    try:
        # don't call cleanup() here, resetting apt takes too long
        if testbed:
            testbed.stop()
    finally:
        os.kill(os.getpid(), signum)


# ---------- processing of sources (building)


def deb_package_name(deb):
    '''Return package name from a .deb'''

    try:
        return subprocess.check_output(['dpkg-deb', '--field', deb, 'Package'],
                                       universal_newlines=True).strip()
    except subprocess.CalledProcessError as e:
        adtlog.badpkg('failed to parse binary package: %s' % e)


def source_rules_command(script, which, cwd=None, results_lines=0):
    if cwd is None:
        cwd = '/'

    if adtlog.verbosity > 1:
        script = ['exec 3>&1 >&2', 'set -x', 'cd ' + cwd] + script
    else:
        script = ['exec 3>&1 >&2', 'cd ' + cwd] + script
    script = '; '.join(script)

    # run command as user, if available
    if testbed.user and 'root-on-testbed' in testbed.caps:
        script = "su --shell=/bin/sh %s -c 'set -e; %s'" % (testbed.user, script)

    (rc, out, _) = testbed.execute(['sh', '-ec', script],
                                   stdout=subprocess.PIPE,
                                   xenv=opts.env,
                                   kind='build')
    results = out.rstrip('\n').splitlines()
    if rc:
        if opts.shell_fail:
            testbed.run_shell()
        if rc == 100:
            testbed.bomb('rules %s failed with exit code %d (apt failure)' % (which, rc))
        else:
            adtlog.badpkg('rules %s failed with exit code %d' % (which, rc))
    if results_lines is not None and len(results) != results_lines:
        adtlog.badpkg('got %d lines of results from %s where %d expected: %r'
                      % (len(results), which, results_lines, results))
    if results_lines == 1:
        return results[0]
    return results


def find_source_version_to_download(src):
    '''Honoring pinning, find the version of the source package to download.

    The version for "apt-get source pkg=version" is determined honoring the
    current apt pinning. This is done by putting all binary packages built from
    the source package in one "pool". This includes binary packages build from
    src:pkg from all the configured suites/pockets. In this pool we then look
    for the packages with the highest priority, and among these we select the
    highest version. This is the same logic followed by apt to determine which
    package to install, as documented in apt_preferences(5).
    '''

    # Debian >= 9, Ubuntu >= 16.04
    apt_cache_supports_only_source = testbed.apt_version >= Version('1.1~exp10')
    if apt_cache_supports_only_source:
        only_source_opt = '--only-source'
    else:
        only_source_opt = ''

    (rc, stdout, stderr) = testbed.execute(
        ['apt-cache', 'showsrc', only_source_opt, src],
        stdout=subprocess.PIPE, stderr=subprocess.PIPE)

    if rc > 0 or (stdout == '' and stderr != ''):
        adtlog.badpkg("apt-cache showsrc didn't succeed: %s" % stderr)
    # To make sure we have at least one paragraph that iter_paragraphs can
    # work on.
    assert ':' in stdout, "unexpected apt-cache output"

    # Somewhere in the next loop, we need the os of the testbed; Currently all
    # official Debian architectures are Linux, but we have hurd and we had
    # kfreebsd in the ports. So, support other os' but let's optimize for linux
    # and only query the testbed if we're not on linux.
    os = 'linux'
    if not testbed.initial_kernel_version.startswith("Linux"):
        os = testbed.check_exec(['dpkg-architecture', '-q', 'DEB_HOST_ARCH_OS'])

    # The output apt-get showsrc follows the ordering of sources.
    # Ordering by package version makes the following logic easier.
    candidate_packages = sorted(Deb822.iter_paragraphs(stdout), key=lambda k: Version(k.get("Version", "~")))
    binpkgs_all = set()
    for paragr in candidate_packages:
        # We're not interested if this paragraph if it's not from the
        # source we requested (only possible in fallback mode).
        if not apt_cache_supports_only_source and paragr.get('Package') != src:
            continue

        # This version should never be the outcome anyways, but let's
        # quite early.
        if 'Extra-Source-Only' in paragr.keys():
            continue

        # Very old source packages don't have Package-List: yet, fall
        # back to Binary: (Binary: is generally not sufficient as it
        # gets truncated for long lists, although that problem should
        # fade out as well).
        if 'Package-List' not in paragr.keys():
            binpkgs_all.update(set(paragr.get('Binary').split(', ')))
        else:
            package_list = paragr.get('Package-List').strip().split('\n')
            for bin in package_list:
                # package deb section optional arch=arch1,arch2
                # package udeb debian-installer optional arch=arch1 profile=!noudeb
                parts = bin.split()

                # only consider packages with Package-Type: deb, and skip for
                # for example udeb packages, which are not even installable with
                # the sources we set up.
                if parts[1] != 'deb':
                    continue

                # Package-List entries created by dpkg-source from dpkg
                # (<< 1.17.7) lack the architecture (and build-profiles)
                # information. In this case let's assume the package is
                # installable on the arch are are interested in.
                if len(parts) < 5:
                    binpkgs_all.add(parts[0])
                    continue

                assert parts[4].startswith('arch=')
                archs = parts[4].split('=')[1].split(',')
                if any([x in archs for x in [
                        'all',
                        'any',
                        'any-' + testbed.test_arch,
                        testbed.test_arch,
                        os + "-any"]]):
                    binpkgs_all.add(parts[0])

    if not binpkgs_all:
        adtlog.badpkg("no binaries found for src:%s" % src)

    max_priority = -32768  # priorities are signed shorts
    max_src_version = Version('~')
    for bin in binpkgs_all:
        version, priority = testbed.get_candidate(bin)
        if not version:
            # TODO: do we want to ignore all failures?
            continue

        if priority < max_priority:
            continue

        # Now get the version of the source of this candidate as it might
        # be different than the binary version.
        (rc, stdout, _) = testbed.execute(
            ['apt-cache', 'show', bin + '=' + str(version)],
            stdout=subprocess.PIPE)
        if rc > 0 or stdout == '':
            # TODO: do we really want to ignore all failures?
            continue
        pkg_info = Deb822(stdout)
        # If there's no Source item, the source and binary are named the
        # same and have the same version.
        pkg_info_src = pkg_info.get('Source', bin).split()
        # Check that the binary is still from this source. Inconsistencies may
        # happen when two source package build the same binary package, as
        # described in LP: #2066290.
        if pkg_info_src[0] != src:
            continue
        # Get the version (absent if same as the binary)
        if len(pkg_info_src) > 1:
            # source version comes between brackets
            version = Version(pkg_info_src[1][1:-1])

        if priority == max_priority:
            max_src_version = max(version, max_src_version)
        else:
            # Here we may be setting max_src_version to a lower value.
            # The "max" in max_src_version is to be intended as "the
            # max version among those with the highest priority".
            max_src_version = version
            max_priority = priority

    return src + "=" + str(max_src_version)


def build_source(kind, arg, built_binaries):
    '''Prepare action argument for testing

    This builds packages when necessary and registers their binaries, copies
    tests into the testbed, etc.

    Return a TestbedPath to the unpacked tests tree.
    '''
    blame(arg)
    testbed.reset([])

    def debug_b(m):
        adtlog.debug('build_source: <%s:%s> %s' % (kind, arg, m))

    # copy necessary source files into testbed and set create_command for final unpacking
    if kind == 'source':
        dsc = arg
        dsc_tb = os.path.join(testbed.scratch, os.path.basename(dsc))

        # copy .dsc file itself
        TestbedPath(testbed, dsc, dsc_tb).copydown()
        # copy files from it
        for part in files_from_dsc(dsc):
            p = TestbedPath(testbed, part, os.path.join(testbed.scratch, os.path.basename(part)))
            p.copydown()

        create_command = 'dpkg-source -x "%s" src' % dsc_tb

    elif kind == 'unbuilt-tree':
        dsc = os.path.join(tmp, 'fake.dsc')
        with open(dsc, 'w', encoding='UTF-8') as f_dsc:
            with open(os.path.join(arg, 'debian/control'), encoding='UTF-8') as f_control:
                for line in f_control:
                    if line == '\n':
                        break
                    f_dsc.write(line)
            f_dsc.write('Binary: none-so-this-is-not-a-package-name\n')
        atexit.register(lambda f: os.path.exists(f) and os.unlink(f), dsc)

        # copy unbuilt tree into testbed
        ubtree = TestbedPath(testbed, arg,
                             os.path.join(testbed.scratch, 'ubtree-' + os.path.basename(arg)))
        ubtree.copydown()
        create_command = 'cp -rd --preserve=timestamps -- "%s" real-tree' % ubtree.tb
        create_command += '; [ -x real-tree/debian/rules ] && dpkg-source --before-build real-tree'

    elif kind == 'built-tree':
        # this is a special case: we don't want to build, or even copy down
        # (and back up) the tree here for efficiency; so shortcut everything
        # below and just set the tests_tree and get the package version
        tests_tree = TestbedPath(testbed, arg, os.path.join(testbed.scratch, 'tree'), is_dir=True)

        changelog = os.path.join(arg, 'debian', 'changelog')
        if os.path.exists(changelog):
            with open(changelog, 'rb') as f:
                (testpkg_name, testpkg_version, _) = f.readline().decode().split(' ', 2)
                testpkg_version = testpkg_version[1:-1]  # chop off parentheses

            adtlog.info('testing package %s version %s' % (testpkg_name, testpkg_version))
            if opts.output_dir:
                with open(os.path.join(tmp, 'testpkg-version'), 'w') as f:
                    f.write('%s %s\n' % (testpkg_name, testpkg_version))
        return tests_tree

    elif kind == 'apt-source':
        src_ver = find_source_version_to_download(arg)

        # apt-get source is terribly noisy; only show what gets downloaded
        create_command = (
            'OUT=$(apt-get source -d -q --only-source %(src_ver)s 2>&1) || RC=$?;'
            'if [ -n "$RC" ]; then'
            '  if echo "$OUT" | grep -q "Unable to find a source package"; then'
            '    exit 1;'
            '  else'
            '    exit $RC;'
            '  fi;'
            'fi;'
            'echo "$OUT" | grep ^Get: || true;'
            'dpkg-source -x %(src)s_*.dsc src >/dev/null' % {'src': arg, 'src_ver': src_ver}
        )

    elif kind == 'git-source':
        url, _, branch = arg.partition('#')
        create_command = "git clone '%s' || { sleep 15; git clone '%s'; }" % (url, url)
        if branch:
            # This is url#branch or url#refspec (for pull requests)
            create_command += "; (cd [a-z0-9]*; git fetch -fu origin '%s:testbranch' || { sleep 15; git fetch -fu origin '%s:testbranch'; }; git checkout testbranch)" % (branch, branch)

        testbed.satisfy_dependencies_string('git, ca-certificates', 'install git for --git-source')
        # If possible, reset the testbed to its more minimal state
        # (which we assume does not include git) before running tests
        testbed.needs_reset()
    else:
        adtlog.bomb('unknown action kind for build_source: ' + kind)

    if kind in ['source', 'apt-source', 'unbuilt-tree']:
        testbed.install_deps([])
        if testbed.execute(['sh', '-ec', 'command -v dpkg-source'],
                           stdout=subprocess.PIPE,
                           stderr=subprocess.PIPE)[0] != 0:
            adtlog.debug('dpkg-source not available in testbed, installing dpkg-dev')
            # Install dpkg-source for unpacking .dsc
            testbed.satisfy_dependencies_string('dpkg-dev',
                                                'install dpkg-dev')
            # If possible, reset the testbed to its more minimal state
            # before running tests
            testbed.needs_reset()

    # run create_command
    script = [
        'builddir=$(mktemp -d %s/build.XXX)' % testbed.scratch,
        'cd $builddir',
        create_command,
        'chmod -R a+rX .',
        'cd [a-z0-9]*/.',
        'pwd >&3',
        'sed -n "1 {s/).*//; s/ (/\\n/; p}" debian/changelog >&3'
    ]

    (result_pwd, testpkg_name, testpkg_version) = \
        source_rules_command(script, 'extract', results_lines=3)

    # record tested package version
    adtlog.info('testing package %s version %s' % (testpkg_name, testpkg_version))
    if opts.output_dir:
        with open(os.path.join(tmp, 'testpkg-version'), 'w') as f:
            f.write('%s %s\n' % (testpkg_name, testpkg_version))

    # For optional builds:
    #
    # We might need to build the package because:
    #   - we want its binaries
    #   - the test control file says so (assuming we have any tests)

    build_needed = False
    if built_binaries:
        adtlog.info('build needed for binaries')
        build_needed = True
    else:
        # we want to get the downloaded debian tree from the testbed,
        # so that we can properly parse it. It is possible that the
        # test that has build-needed isn't going to run (e.g. due to
        # Architecture or restrictions) so let's avoid building when
        # it's not needed. (Bug #1002477)
        debian_tree = TestbedPath(testbed,
                                  os.path.join(tmp, 'pkg', 'debian'),
                                  os.path.join(result_pwd, 'debian'), True)
        debian_tree.copyup()
        pkg_root = os.path.dirname(debian_tree.host)
        try:
            (tests, _) = testdesc.parse_debian_source(
                srcdir=pkg_root,
                testbed_caps=testbed.caps,
                test_arch=testbed.test_arch,
                test_arch_is_foreign=testbed.test_arch_is_foreign,
                control_path=opts.override_control,
                auto_control=opts.auto_control,
                ignore_restrictions=opts.ignore_restrictions
            )
            for t in tests:
                if 'build-needed' in t.restrictions:
                    build_needed = True
        except testdesc.InvalidControl as e:
            adtlog.badpkg(str(e))

        # Clean up the just copied dir as we don't need it anymore
        shutil.rmtree(pkg_root, ignore_errors=True)

        if build_needed:
            adtlog.info('build needed for tests')
        else:
            adtlog.info('build not needed')

    if build_needed:
        testbed.needs_reset()
        if kind not in ['dsc', 'apt-source']:
            testbed.install_deps([])

        dpkg_buildpackage = ["dpkg-buildpackage", "-us", "-uc", "-b"]
        assert testbed.nproc
        deb_build_profiles = [
            "noudeb",
            "nocheck",
            f"parallel={opts.build_parallel or testbed.nproc}",
        ]
        # The "noddebs" option is deprecated in favor or "noautodbgsym",
        # but we keep it for compatibility with old debhelper versions,
        deb_build_options = ["nocheck", "noautodbgsym", "noddebs"]
        extra_bd = []

        if opts.gainroot:
            dpkg_buildpackage.append(f"-r{opts.gainroot}")
        else:
            if testbed.user or 'root-on-testbed' not in testbed.caps:
                extra_bd += ['fakeroot']

        if testbed.test_arch_is_foreign:
            dpkg_buildpackage.append(f'--host-arch={testbed.test_arch}')
            deb_build_profiles.append("cross")
            extra_bd.append(f'crossbuild-essential-{testbed.test_arch}:native')
            # Install libc-dev and libstdc++-dev as a workaround for #815172. Also see:
            # https://salsa.debian.org/debian/sbuild/-/blob/d5fad46720b19e/lib/Sbuild/Build.pm#L838
            extra_bd.append(f'libc-dev:{testbed.test_arch}')
            extra_bd.append(f'libstdc++-dev:{testbed.test_arch}')

        # Append these last to allow overriding via autopkgtest --env.
        deb_build_options.append("$DEB_BUILD_OPTIONS")
        deb_build_profiles.append("$DEB_BUILD_PROFILES")

        # Convert lists to strings.
        deb_build_options = " ".join(deb_build_options)
        deb_build_profiles = " ".join(deb_build_profiles)
        dpkg_buildpackage = " ".join(dpkg_buildpackage)
        dpkg_buildpackage = " ".join(
            [
                f'DEB_BUILD_OPTIONS="{deb_build_options}"',
                f'DEB_BUILD_PROFILES="{deb_build_profiles}"',
                dpkg_buildpackage,
            ]
        )

        apt_version = testbed.apt_version
        assert apt_version is not None
        # Debian >= 9, Ubuntu >= 16.04
        apt_can_install_build_deps = (apt_version >= Version('1.1~exp1'))

        if apt_can_install_build_deps:
            # This uses apt-get build-dep, supported since apt 1.1~exp1 (Debian >= 9, Ubuntu >= 16.04)
            bd_xenv = opts.env.copy()
            for i, env_assignment in enumerate(bd_xenv):
                if env_assignment.startswith("DEB_BUILD_PROFILES="):
                    bd_xenv[i] += " nocheck noudeb"
                    break
            else:
                bd_xenv.append("DEB_BUILD_PROFILES=nocheck noudeb")
            testbed.install_build_deps_for_package(
                src_package_path=result_pwd,
                shell_on_failure=opts.shell_fail,
                xenv=bd_xenv,
            )
        else:
            # Legacy way for installing build-dependencies.
            # Drop this if branch after the minimum testbed requirements are
            # bumped to Debian >> 16.04 and Debian >> 9.
            if kind in ('apt-source', 'git-source'):
                pkg_control = TestbedPath(testbed,
                                          os.path.join(tmp, 'apt-control'),
                                          os.path.join(result_pwd, 'debian/control'), False)
                pkg_control.copyup()
                dsc = pkg_control.host
            with open(dsc, encoding='UTF-8') as f:
                d = Deb822(sequence=f)
            bd = d.get('Build-Depends', '')
            bdi = d.get('Build-Depends-Indep', '')
            bda = d.get('Build-Depends-Arch', '')
            # apt-get build-dep installs build-essential automatically.
            # This does not happen when installing build-deps in the
            # legacy way, so let's add it to extra_bd.
            extra_bd += ['build-essential']
            testbed.satisfy_dependencies_string(bd + ', ' + bdi + ', ' + bda, arg,
                                                shell_on_failure=opts.shell_fail)

        if extra_bd:
            testbed.satisfy_dependencies_string(', '.join(extra_bd), arg,
                                                shell_on_failure=opts.shell_fail)

        # keep patches applied for tests
        source_rules_command([dpkg_buildpackage, 'dpkg-source --before-build .'], 'build', cwd=result_pwd)

    # copy built tree from testbed to hosts
    tests_tree = TestbedPath(testbed, os.path.join(tmp, 'tests-tree'), result_pwd, is_dir=True)
    atexit.register(shutil.rmtree, tests_tree.host, ignore_errors=True)
    tests_tree.copyup()

    if not build_needed:
        return tests_tree

    if built_binaries:
        debug_b('want built binaries, getting and registering built debs')
        result_debs = testbed.check_exec(['sh', '-ec', 'cd "%s"; echo *.deb' %
                                          os.path.dirname(result_pwd)], stdout=True).strip()
        if result_debs == '*.deb':
            debs = []
        else:
            debs = result_debs.split()
        debug_b('debs=' + repr(debs))

        # determine built debs and copy them from testbed
        deb_re = re.compile(r'^([-+.0-9a-z]+)_[^_/]+(?:_[^_/]+)\.deb$')
        for deb in debs:
            m = deb_re.match(deb)
            if not m:
                adtlog.badpkg("badly-named binary `%s'" % deb)
            pkgname = m.groups()[0]
            debug_b(' deb=%s, pkgname=%s' % (deb, pkgname))
            deb_path = TestbedPath(testbed,
                                   os.path.join(tmp, os.path.basename(deb)),
                                   os.path.join(result_pwd, '..', deb),
                                   False)
            deb_path.copyup()
            binaries.register(deb_path.host, pkgname)
        debug_b('got all built binaries')

    return tests_tree


def process_actions():
    global actions, binaries, errorcode

    binaries = adt_binaries.DebBinaries(testbed, tmp)
    if opts.override_control and not os.access(opts.override_control, os.R_OK):
        adtlog.bomb('cannot read ' + opts.override_control)
    control_override = opts.override_control
    only_tests = opts.only_tests
    skip_tests = opts.skip_tests
    tests_tree = None

    for (kind, arg, built_binaries) in actions:
        # non-tests/build actions
        if kind == 'binary':
            blame('arg:' + arg)
            pkg = deb_package_name(arg)
            blame('deb:' + pkg)
            binaries.register(arg, pkg)
            continue

        # tests/build actions
        assert kind in ('source', 'unbuilt-tree', 'built-tree', 'apt-source',
                        'git-source')
        adtlog.info('@@@@@@@@@@@@@@@@@@@@ %s %s' % (kind, arg))

        # remove tests tree from previous action
        if tests_tree and tests_tree.tb:
            adtlog.debug('cleaning up previous tests tree %s on testbed' % tests_tree.tb)
            testbed.execute(['rm', '-rf', tests_tree.tb])

        tests_tree = build_source(kind, arg, built_binaries)
        try:
            (tests, skipped) = testdesc.parse_debian_source(
                srcdir=tests_tree.host,
                testbed_caps=testbed.caps,
                test_arch=testbed.test_arch,
                test_arch_is_foreign=testbed.test_arch_is_foreign,
                control_path=control_override,
                auto_control=opts.auto_control,
                ignore_restrictions=opts.ignore_restrictions,
                only_tests=only_tests)
        except testdesc.InvalidControl as e:
            adtlog.badpkg(str(e))

        if opts.validate:
            adtlog.report("*", "Test specification is valid")
            return

        if skipped:
            errorcode |= 2

        if only_tests:
            adtlog.debug('only running %s for package %s %s' %
                         (only_tests, kind, arg))
            tests = [t for t in tests if t.name in only_tests]
            if not tests:
                adtlog.error('%s %s has no test matching --test-name %s' %
                             (kind, arg, ', '.join(only_tests)))
                # error code will be set later

        if skip_tests:
            adtlog.debug('filtering out "%s" for package %s %s' %
                         (' '.join(skip_tests), kind, arg))
            tests = [t for t in tests if t.name not in skip_tests]
            skip_tests = None

        control_override = None
        run_tests(tests, tests_tree)

        adtlog.summary_stream.flush()
        if adtlog.verbosity >= 1:
            adtlog.summary_stream.seek(0)
            adtlog.info('@@@@@@@@@@@@@@@@@@@@ summary')
            sys.stderr.buffer.write(adtlog.summary_stream.read())

    adtlog.summary_stream.close()
    adtlog.summary_stream = None


def main():
    global testbed, opts, actions, errorcode
    try:
        (opts, actions, vserver_args) = parse_args()
    except SystemExit:
        # argparser exits with error 2 by default, but we have a different
        # meaning for that already
        sys.exit(20)

    # ensure proper cleanup on signals
    signal.signal(signal.SIGTERM, signal_handler)
    signal.signal(signal.SIGQUIT, signal_handler)

    try:
        setup_trace()
        testbed = Testbed(
            vserver_argv=vserver_args,
            output_dir=tmp,
            user=opts.user,
            shell_fail=opts.shell_fail,
            setup_commands=opts.setup_commands,
            setup_commands_boot=opts.setup_commands_boot,
            add_apt_pockets=opts.apt_pocket,
            copy_files=opts.copy,
            enable_apt_fallback=opts.enable_apt_fallback,
            needs_internet=opts.needs_internet,
            add_apt_sources=getattr(opts, 'add_apt_sources', []),
            add_apt_releases=getattr(opts, 'add_apt_releases', []),
            pin_packages=opts.pin_packages,
            apt_default_release=opts.apt_default_release,
            apt_upgrade=opts.apt_upgrade,
            test_arch=opts.test_architecture,
        )
        testbed.start()
        testbed.open()
        process_actions()
    except Exception:
        errorcode = print_exception(sys.exc_info(), '')
    if tmp:
        try:
            create_testinfo(vserver_args)
        except Exception:
            errorcode = print_exception(sys.exc_info(), '')
    cleanup()
    sys.exit(errorcode)


main()
