blob: 7a095c59df43b3b24486411cc7521f6daaae49f1 [file] [log] [blame]
# Copyright 2018 The Fuchsia Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
from recipe_engine import recipe_api
import collections
import hashlib
import pipes
import re
# List of available targets.
TARGETS = ['arm64', 'x86-64']
# List of available build types.
BUILD_TYPES = ['debug', 'release', 'thinlto', 'lto']
# The kernel binary to pass to qemu.
ZIRCON_IMAGE_NAME = 'zircon.bin'
# The name of the CoW Fuchsia image to create.
FUCHSIA_IMAGE_NAME = 'fuchsia.qcow2'
# The FVM block name.
FVM_BLOCK_NAME = 'fvm.blk'
# The PCI address to use for the block device to contain test results.
TEST_FS_PCI_ADDR = '06.0'
# How long to wait (in seconds) before killing the test swarming task if there's
# no output being produced.
TEST_IO_TIMEOUT_SECS = 60
RUNCMDS_PACKAGE = '''
{
"resources": [
{
"bootfs_path": "data/infra/runcmds",
"file": "%s"
}
]
}
'''
def _zircon_project(target):
"""Returns the zircon project for the target string."""
return {'arm64': 'arm64', 'x86-64': 'x86'}[target]
def _gn_target(target):
"""Returns the GN target for the target string."""
return {'arm64': 'aarch64', 'x86-64': 'x86-64'}[target]
class FuchsiaBuildResults(object):
"""Represents a completed build of Fuchsia."""
def __init__(self, target, zircon_build_dir, fuchsia_build_dir, has_tests):
assert target in TARGETS
self._zircon_build_dir = zircon_build_dir
self._fuchsia_build_dir = fuchsia_build_dir
self._target = target
self._has_tests = has_tests
@property
def target(self):
"""The build target for this build."""
return self._target
@property
def zircon_build_dir(self):
"""The directory where Zircon build artifacts may be found."""
return self._zircon_build_dir
@property
def fuchsia_build_dir(self):
"""The directory where Fuchsia build artifacts may be found."""
return self._fuchsia_build_dir
@property
def has_tests(self):
"""Whether or not this build has the necessary additions to be tested."""
return self._has_tests
class FuchsiaApi(recipe_api.RecipeApi):
"""APIs for checking out, building, and testing Fuchsia."""
def __init__(self, *args, **kwargs):
super(FuchsiaApi, self).__init__(*args, **kwargs)
def checkout(self, manifest, remote, project=None, patch_ref=None,
patch_gerrit_url=None, patch_project=None, upload_snapshot=False):
"""Uses Jiri to check out a Fuchsia project.
The patch_* arguments must all be set, or none at all.
The checkout is made into api.path['start_dir'].
Args:
manifest (str): A path to the manifest in the remote (e.g. manifest/minimal)
remote (str): A URL to the remote repository which Jiri will be pointed at
project (str): The name of the project
patch_ref (str): A reference ID to the patch in Gerrit to apply
patch_gerrit_url (str): A URL of the patch in Gerrit to apply
patch_project (str): The name of Gerrit project
upload_snapshot (bool): Whether to upload a Jiri snapshot to GCS
"""
with self.m.context(infra_steps=True):
self.m.jiri.ensure_jiri()
self.m.jiri.checkout(
manifest,
remote,
project,
patch_ref,
patch_gerrit_url,
patch_project,
)
if patch_ref:
self.m.jiri.update(gc=True, rebase_tracked=True, local_manifest=True)
if upload_snapshot:
self.m.gsutil.ensure_gsutil()
snapshot_file = self.m.path['tmp_base'].join('jiri.snapshot')
self.m.jiri.snapshot(snapshot_file)
digest = self.m.hash.sha1('hash snapshot', snapshot_file,
test_data='8ac5404b688b34f2d34d1c8a648413aca30b7a97')
self.m.gsutil.upload('fuchsia-snapshots', snapshot_file, digest,
link_name='jiri.snapshot',
name='upload jiri.snapshot',
unauthenticated_url=True)
def _create_runcmds_package(self, test_cmds):
"""Creates a Fuchsia package which contains a script for running tests automatically."""
# The device topological path is the toplogical path to the block device
# which will contain test output.
device_topological_path = '/dev/sys/pci/00:%s/virtio-block/block' % (
TEST_FS_PCI_ADDR,
)
# Script that mounts the block device to contain test output and runs tests,
# dropping test output into the block device.
runcmds = [
'#!/boot/bin/sh',
'msleep 5000',
'mkdir /test',
'mount %s /test' % device_topological_path,
] + test_cmds + [
'umount /test',
'dm poweroff',
]
runcmds_path = self.m.path['tmp_base'].join('runcmds')
self.m.file.write_text('write runcmds', runcmds_path, '\n'.join(runcmds))
self.m.step.active_result.presentation.logs['runcmds'] = runcmds
runcmds_package_path = self.m.path['tmp_base'].join('runcmds_package')
runcmds_package = RUNCMDS_PACKAGE % runcmds_path
self.m.file.write_text('write runcmds package', runcmds_package_path, runcmds_package)
self.m.step.active_result.presentation.logs['runcmds_package'] = runcmds_package.splitlines()
return str(runcmds_package_path)
def _build_zircon(self, target):
"""Builds zircon for the specified target."""
self.m.step('zircon', [
self.m.path['start_dir'].join('scripts', 'build-zircon.sh'),
'-c',
'-H',
'-p', _zircon_project(target),
])
def _setup_goma(self):
"""Sets up goma directory and returns an environment for goma."""
goma_dir = self.m.properties.get('goma_dir', None)
if goma_dir:
self.m.goma.set_goma_dir(goma_dir)
self.m.goma.ensure_goma()
goma_env = {}
if self.m.properties.get('goma_local_cache', False):
goma_env['GOMA_LOCAL_OUTPUT_CACHE_DIR'] = self.m.path['cache'].join('goma', 'localoutputcache')
return goma_env
def _build_fuchsia(self, build, build_type, packages, variants, gn_args):
"""Builds fuchsia given a FuchsiaBuildResults and other GN options."""
goma_env = self._setup_goma()
with self.m.step.nest('build fuchsia'):
with self.m.goma.build_with_goma(env=goma_env):
gen_cmd = [
self.m.path['start_dir'].join('build', 'gn', 'gen.py'),
'--target_cpu=%s' % _gn_target(build.target),
'--packages=%s' % ','.join(packages),
'--platforms=%s' % _zircon_project(build.target),
]
gen_cmd += ['--variant=%s' % v for v in variants]
gen_cmd.append('--goma=%s' % self.m.goma.goma_dir)
if build_type != 'debug':
gen_cmd.append('--release')
if build_type == 'lto':
gen_cmd.append('--lto=full')
elif build_type == 'thinlto':
gen_cmd.append('--lto=thin')
gn_args.append('thinlto_cache_dir=\"%s\"' %
str(self.m.path['cache'].join('thinlto')))
for arg in gn_args:
gen_cmd.append('--args')
gen_cmd.append(arg)
self.m.step('gen', gen_cmd)
ninja_cmd = [
self.m.path['start_dir'].join('buildtools', 'ninja'),
'-C', build.fuchsia_build_dir,
]
ninja_cmd.extend(['-j', self.m.goma.recommended_goma_jobs])
self.m.step('ninja', ninja_cmd)
def build(self, target, build_type, packages, variants=(), gn_args=(),
test_cmds=()):
"""Builds Fuchsia from a Jiri checkout.
Expects a Fuchsia Jiri checkout at api.path['start_dir'].
Args:
target (str): The build target, see TARGETS for allowed targets
build_type (str): One of the build types in BUILD_TYPES
packages (sequence[str]): A sequence of packages to pass to GN to build
variants (sequence[str]): A sequence of build variants to pass to gen.py
via --variant
gn_args (sequence[str]): Additional arguments to pass to GN
test_cmds (sequence[str]): A sequence of commands to run on the device
during testing. If empty, no test package will be added to the build.
Returns:
A FuchsiaBuildResults, representing the recently completed build.
"""
assert target in TARGETS
assert build_type in BUILD_TYPES
if test_cmds:
packages.append(self._create_runcmds_package(test_cmds))
if build_type == 'debug':
build_dir = 'debug'
else:
build_dir = 'release'
out_dir = self.m.path['start_dir'].join('out')
build = FuchsiaBuildResults(
target=target,
zircon_build_dir=out_dir.join('build-zircon', 'build-%s' % _zircon_project(target)),
fuchsia_build_dir=out_dir.join('%s-%s' % (build_dir, _gn_target(target))),
has_tests=bool(test_cmds),
)
with self.m.step.nest('build'):
self._build_zircon(target)
self._build_fuchsia(build, build_type, packages, variants, gn_args)
self.m.minfs.minfs_path = out_dir.join('build-zircon', 'tools', 'minfs')
return build
def _symbolize(self, build_dir, data):
"""Invokes zircon's symbolization script to symbolize the given data."""
symbolize_cmd = [
self.m.path['start_dir'].join('zircon', 'scripts', 'symbolize'),
'--no-echo',
'--build-dir', build_dir,
]
symbolize_result = self.m.step('symbolize', symbolize_cmd,
stdin=self.m.raw_io.input(data=data),
stdout=self.m.raw_io.output(),
step_test_data=lambda: self.m.raw_io.test_api.stream_output(''))
symbolized_lines = symbolize_result.stdout.splitlines()
if symbolized_lines:
symbolize_result.presentation.logs['symbolized backtraces'] = symbolized_lines
symbolize_result.presentation.status = self.m.step.FAILURE
def _isolate_artifacts(self, ramdisk_name, zircon_build_dir, fuchsia_build_dir, extra_files=()):
"""Isolates known Fuchia build artifacts necessary for testing.
Args:
ramdisk_name (str): The name of the zircon ramdisk image.
zircon_build_dir (Path): A path to the build artifacts produced by
building Zircon.
fuchsia_build_dir (Path): A path to the build artifacts produced by
building Fuchsia.
extra_files (seq[Path]): A list of paths which point to additional files
which will be isolated together with the Fuchsia and Zircon build
artifacts.
Returns:
The isolated hash that may be used to reference and download the
artifacts.
"""
self.m.isolated.ensure_isolated(version='latest')
isolated = self.m.isolated.isolated()
# Add zircon image to isolated at the top-level.
isolated.add_file(zircon_build_dir.join(ZIRCON_IMAGE_NAME), wd=zircon_build_dir)
# Add ramdisk binary blob to isolated at the top-level.
isolated.add_file(fuchsia_build_dir.join(ramdisk_name), wd=fuchsia_build_dir)
# Create qcow2 image from FVM_BLOCK_NAME and add to isolated at the top-level.
self.m.qemu.ensure_qemu(version='latest')
with self.m.context(cwd=fuchsia_build_dir.join('images')):
self.m.qemu.create_image(
image=FUCHSIA_IMAGE_NAME,
backing_file=FVM_BLOCK_NAME,
fmt='qcow2',
)
isolated.add_file(self.m.context.cwd.join(FVM_BLOCK_NAME))
isolated.add_file(self.m.context.cwd.join(FUCHSIA_IMAGE_NAME))
# Add the extra files to isolated at the top-level.
for path in extra_files:
isolated.add_file(path, wd=self.m.path.abs_to_path(self.m.path.dirname(path)))
# Archive the isolated.
return isolated.archive('isolate artifacts')
def test(self, build):
"""Tests a Fuchsia build.
Expects the build and artifacts to be at the same place they were at
the end of the build.
Args:
build (FuchsiaBuildResults): The Fuchsia build to test
"""
assert build.has_tests
self.m.swarming.ensure_swarming(version='latest')
ramdisk_name = 'bootdata-blobstore-%s.bin' % _zircon_project(build.target)
qemu_arch = {
'arm64': 'aarch64',
'x86-64': 'x86_64',
}[build.target]
cmdline = [
'zircon.autorun.system=/system/data/infra/runcmds',
'kernel.halt-on-panic=true',
]
# As part of running tests, we'll send a MinFS image over to another machine
# which will be declared as a block device in QEMU, at which point
# Fuchsia will mount it and write test output to. input_image_name refers to
# the name of the image as its created by this recipe, and sent off to the
# test machine. output_image_name refers to the name of the image as its
# returned back from the other machine.
input_image_name = 'input.fs'
output_image_name = 'output.fs'
qemu_cmd = [
'./qemu/bin/qemu-system-' + qemu_arch, # Dropped in by CIPD.
'-m', '4096',
'-smp', '4',
'-nographic',
'-machine', {'aarch64': 'virt,gic_version=host', 'x86_64': 'q35'}[qemu_arch],
'-kernel', ZIRCON_IMAGE_NAME,
'-serial', 'stdio',
'-monitor', 'none',
'-initrd', ramdisk_name,
'-enable-kvm', '-cpu', 'host',
'-append', ' '.join(cmdline),
'-drive', 'file=%s,format=qcow2,if=none,id=maindisk' % FUCHSIA_IMAGE_NAME,
'-device', 'virtio-blk-pci,drive=maindisk',
'-drive', 'file=%s,format=raw,if=none,id=testdisk' % output_image_name,
'-device', 'virtio-blk-pci,drive=testdisk,addr=%s' % TEST_FS_PCI_ADDR,
]
# Create a qemu runner script which trivially copies the blank MinFS image
# to hold test results, in order to work around a bug in swarming where
# modifying cached isolate downloads will modify the cache contents.
#
# TODO(mknyszek): Once the isolate bug (http://crbug.com/812925) gets fixed,
# don't send a runner script to the bot anymore, since we don't need to do
# this hack to cp the image.
qemu_runner_script = [
'#!/bin/sh',
'cp %s %s' % (input_image_name, output_image_name),
' '.join(map(pipes.quote, qemu_cmd)),
]
# Write the QEMU runner to disk so that we can isolate it.
qemu_runner_name = 'run-qemu.sh'
qemu_runner = self.m.path['start_dir'].join(qemu_runner_name)
self.m.file.write_text(
'write qemu runner',
qemu_runner,
'\n'.join(qemu_runner_script),
)
# Create MinFS image (which will hold test output). We choose to make the
# MinFS image 16M because our current test output takes up ~1.5 MB in an
# absolute worst case (holding all Topaz + Zircon tests), so 16M is chosen
# because it is ~10x more space than we need.
test_image = self.m.path['start_dir'].join(input_image_name)
self.m.minfs.create(test_image, '16M', name='create test image')
# Isolate the Fuchsia build artifacts in addition to the test image and the
# qemu runner.
isolated_hash = self._isolate_artifacts(
ramdisk_name,
build.zircon_build_dir,
build.fuchsia_build_dir,
extra_files=[
test_image,
qemu_runner,
],
)
qemu_cipd_arch = {
'arm64': 'arm64',
'x86-64': 'amd64',
}[build.target]
with self.m.context(infra_steps=True):
# Trigger task.
trigger_result = self.m.swarming.trigger(
'all tests',
['/bin/sh', qemu_runner_name],
isolated=isolated_hash,
dump_json=self.m.path.join(self.m.path['tmp_base'], 'qemu_test_results.json'),
dimensions={
'pool': 'fuchsia.tests',
'os': 'Debian',
'cpu': build.target,
'kvm': '1',
},
io_timeout=TEST_IO_TIMEOUT_SECS,
outputs=[output_image_name],
cipd_packages=[('qemu', 'fuchsia/qemu/linux-%s' % qemu_cipd_arch, 'latest')],
)
# Collect results.
results = self.m.swarming.collect('20m', requests_json=self.m.json.input(trigger_result.json.output))
assert len(results) == 1
result = results[0]
self.analyze_collect_result('task results', result, build.zircon_build_dir)
self.analyze_test_results(
'test results',
result[output_image_name],
build.fuchsia_build_dir,
result.output,
)
def analyze_collect_result(self, step_name, result, zircon_build_dir):
"""Analyzes a swarming.CollectResult and reports results as a step.
Args:
step_name (str): The display name of the step for this analysis.
result (swarming.CollectResult): The swarming collection result to analyze.
zircon_build_dir (Path): A path to the zircon build directory for symbolization
artifacts.
Raises:
A StepFailure if a kernel panic is detected, or if the tests timed out.
An InfraFailure if the swarming task failed for a different reason.
"""
step_result = self.m.step(step_name, None)
kernel_output_lines = result.output.split('\n')
step_result.presentation.logs['output'] = kernel_output_lines
if result.is_infra_failure():
raise self.m.step.InfraFailure('Failed to collect: %s' % result.output)
elif result.is_failure():
# If the kernel panics, chances are it will result in a task failure since
# the task will likely time out and QEMU will be forcibly killed.
if 'KERNEL PANIC' in result.output:
step_result.presentation.step_text = 'kernel panic'
step_result.presentation.status = self.m.step.FAILURE
self._symbolize(zircon_build_dir, result.output)
raise self.m.step.StepFailure('Found kernel panic. See symbolized output for details.')
# If we have a timeout with a successful collect, then this must be an
# io_timeout failure, since task timeout > collect timeout.
if result.timed_out():
step_result.presentation.step_text = 'i/o timeout'
step_result.presentation.status = self.m.step.FAILURE
self._symbolize(zircon_build_dir, result.output)
failure_lines = [
'I/O timed out, no output for %s seconds.' % TEST_IO_TIMEOUT_SECS,
'Last 10 lines of kernel output:',
] + kernel_output_lines[-10:]
raise self.m.step.StepFailure('\n'.join(failure_lines))
# At this point its likely an infra issue with QEMU,
# though a deadlock might also reach this state.
step_result.presentation.status = self.m.step.EXCEPTION
raise self.m.step.InfraFailure('Swarming task failed:\n%s' % result.output)
def analyze_test_results(self, step_name, minfs_image_path, build_dir, output):
"""Analyzes a MinFS image filled with task results, whose path is derived from a
CollectResult.
Args:
step_name (str): The name of the step under which to test the analysis steps.
minfs_image_path (Path): A relative path to the MinFS image that may be used to
derive the full path to the MinFS image from a CollectResult.
build_dir (Path): A path to the build directory for symbolization artifacts.
output (str): Kernel output which may be passed to the symbolizer script.
Raises:
A StepFailure if any of the discovered tests failed.
"""
with self.m.step.nest(step_name):
test_results_dir = self.m.path['start_dir'].join('minfs_isolate_results')
with self.m.context(infra_steps=True):
# Copy test results out of image.
test_output = self.m.minfs.cp(
# Paths inside of the MinFS image are prefixed with '::', so '::'
# refers to the root of the MinFS image.
'::',
self.m.raw_io.output_dir(leak_to=test_results_dir),
minfs_image_path,
name='extract',
step_test_data=lambda: self.m.raw_io.test_api.output_dir({
'hello.out': 'I am output.'
}),
).raw_io.output_dir
# Read the tests summary.
test_summary = self.m.json.read(
'read summary',
test_results_dir.join('summary.json'),
step_test_data=lambda: self.m.json.test_api.output({
'tests': [{
'name': '/hello',
'output_file': 'hello.out',
'result': 'PASS',
}],
}),
).json.output
# Report test results.
failed_tests = collections.OrderedDict()
for test in test_summary['tests']:
name = test['name']
step_result = self.m.step(name, None)
output_name = test['output_file']
step_result.presentation.logs['stdio'] = test_output[output_name].split('\n')
if test['result'] != 'PASS':
step_result.presentation.status = self.m.step.FAILURE
failed_tests[name] = test_output[output_name]
# Symbolize the kernel output if any tests failed.
if failed_tests:
self._symbolize(build_dir, output)
raise self.m.step.StepFailure('Test failure(s): ' + ', '.join(failed_tests.keys()))