blob: ae4081a68adeddc5fea245aad4e1eb37eea4343c [file] [log] [blame]
#!/usr/bin/env python
#
# Copyright 2017 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Packages a user.bootfs for a Fuchsia boot image, pulling in the runtime
dependencies of a binary, and then uses either QEMU from the Fuchsia SDK
to run, or starts the bootserver to allow running on a hardware device."""
import argparse
import os
import re
import shutil
import signal
import subprocess
import sys
import uuid
DIR_SOURCE_ROOT = os.path.abspath(
os.path.join(os.path.dirname(__file__), os.pardir, os.pardir))
SDK_ROOT = os.path.join(DIR_SOURCE_ROOT, 'third_party', 'fuchsia-sdk')
SYMBOLIZATION_TIMEOUT_SECS = 10
# The guest will get 192.168.3.9 from DHCP, while the host will be
# accessible as 192.168.3.2 .
GUEST_NET = '192.168.3.0/24'
GUEST_IP_ADDRESS = '192.168.3.9'
HOST_IP_ADDRESS = '192.168.3.2'
def _RunAndCheck(dry_run, args):
if dry_run:
print 'Run:', ' '.join(args)
return 0
try:
subprocess.check_call(args)
return 0
except subprocess.CalledProcessError as e:
return e.returncode
finally:
sys.stdout.flush()
sys.stderr.flush()
def _IsRunningOnBot():
return int(os.environ.get('CHROME_HEADLESS', 0)) != 0
def _DumpFile(dry_run, name, description):
"""Prints out the contents of |name| if |dry_run|."""
if not dry_run:
return
print
print 'Contents of %s (for %s)' % (name, description)
print '-' * 80
with open(name) as f:
sys.stdout.write(f.read())
print '-' * 80
def _MakeTargetImageName(common_prefix, output_directory, location):
"""Generates the relative path name to be used in the file system image.
common_prefix: a prefix of both output_directory and location that
be removed.
output_directory: an optional prefix on location that will also be removed.
location: the file path to relativize.
.so files will be stored into the lib subdirectory to be able to be found by
default by the loader.
Examples:
>>> _MakeTargetImageName(common_prefix='/work/cr/src',
... output_directory='/work/cr/src/out/fuch',
... location='/work/cr/src/base/test/data/xyz.json')
'base/test/data/xyz.json'
>>> _MakeTargetImageName(common_prefix='/work/cr/src',
... output_directory='/work/cr/src/out/fuch',
... location='/work/cr/src/out/fuch/icudtl.dat')
'icudtl.dat'
>>> _MakeTargetImageName(common_prefix='/work/cr/src',
... output_directory='/work/cr/src/out/fuch',
... location='/work/cr/src/out/fuch/libbase.so')
'lib/libbase.so'
"""
if not common_prefix.endswith(os.sep):
common_prefix += os.sep
assert output_directory.startswith(common_prefix)
output_dir_no_common_prefix = output_directory[len(common_prefix):]
assert location.startswith(common_prefix)
loc = location[len(common_prefix):]
if loc.startswith(output_dir_no_common_prefix):
loc = loc[len(output_dir_no_common_prefix)+1:]
# TODO(fuchsia): The requirements for finding/loading .so are in flux, so this
# ought to be reconsidered at some point. See https://crbug.com/732897.
if location.endswith('.so'):
loc = 'lib/' + loc
return loc
def _ExpandDirectories(file_mapping, mapper):
"""Walks directories listed in |file_mapping| and adds their contents to
|file_mapping|, using |mapper| to determine the target filename.
"""
expanded = {}
for target, source in file_mapping.items():
if os.path.isdir(source):
files = [os.path.join(dir_path, filename)
for dir_path, dir_names, file_names in os.walk(source)
for filename in file_names]
for f in files:
expanded[mapper(f)] = f
elif os.path.exists(source):
expanded[target] = source
else:
raise Exception('%s does not exist' % source)
return expanded
def _StripBinary(dry_run, bin_path):
"""Creates a stripped copy of the executable at |bin_path| and returns the
path to the stripped copy."""
strip_path = bin_path + '.bootfs_stripped'
_RunAndCheck(dry_run, ['/usr/bin/strip', bin_path, '-o', strip_path])
if not dry_run and not os.path.exists(strip_path):
raise Exception('strip did not create output file')
return strip_path
def _StripBinaries(dry_run, file_mapping):
"""Updates the supplied manifest |file_mapping|, by stripping any executables
and updating their entries to point to the stripped location. Returns a
mapping from target executables to their un-stripped paths, for use during
symbolization."""
symbols_mapping = {}
for target, source in file_mapping.iteritems():
with open(source, 'rb') as f:
file_tag = f.read(4)
if file_tag == '\x7fELF':
symbols_mapping[target] = source
file_mapping[target] = _StripBinary(dry_run, source)
return symbols_mapping
def _WriteManifest(manifest_file, file_mapping):
"""Writes |file_mapping| to the given |manifest_file| (a file object) in a
form suitable for consumption by mkbootfs."""
for target, source in file_mapping.viewitems():
manifest_file.write('%s=%s\n' % (target, source))
def ReadRuntimeDeps(deps_path, output_directory):
result = []
for f in open(deps_path):
abs_path = os.path.abspath(os.path.join(output_directory, f.strip()));
target_path = \
_MakeTargetImageName(DIR_SOURCE_ROOT, output_directory, abs_path)
result.append((target_path, abs_path))
return result
class BootfsData(object):
"""Results from BuildBootfs().
bootfs: Local path to .bootfs image file.
symbols_mapping: A dict mapping executables to their unstripped originals.
termination_string: A string to be grep'd for that indicates the target
process on the VM has terminated.
"""
def __init__(self, bootfs_name, symbols_mapping, termination_string):
self.bootfs = bootfs_name
self.symbols_mapping = symbols_mapping
self.termination_string = termination_string
def BuildBootfs(output_directory, runtime_deps, bin_name, child_args, dry_run):
# |runtime_deps| already contains (target, source) pairs for the runtime deps,
# so we can initialize |file_mapping| from it directly.
file_mapping = dict(runtime_deps)
# Generate a script that runs the binaries and shuts down QEMU (if used).
autorun_file = open(bin_name + '.bootfs_autorun', 'w')
autorun_file.write('#!/boot/bin/sh\n')
if _IsRunningOnBot():
# TODO(scottmg): Passed through for https://crbug.com/755282.
autorun_file.write('export CHROME_HEADLESS=1\n')
autorun_file.write('echo Executing ' + os.path.basename(bin_name) + ' ' +
' '.join(child_args) + '\n')
# Due to Fuchsia's object name length limit being small, we cd into /system
# and set PATH to "." to reduce the length of the main executable path.
autorun_file.write('cd /system\n')
autorun_file.write('PATH=. ' + os.path.basename(bin_name))
for arg in child_args:
autorun_file.write(' "%s"' % arg);
autorun_file.write('\n')
# Generate a unique string that the output processor can look for to know
# that the target process is finished.
termination_string = 'Process terminated %s.' % uuid.uuid4().hex
autorun_file.write('echo ' + termination_string + '\n')
autorun_file.flush()
os.chmod(autorun_file.name, 0750)
_DumpFile(dry_run, autorun_file.name, 'autorun')
# Add the autorun file, logger file, and target binary to |file_mapping|.
file_mapping['autorun'] = autorun_file.name
file_mapping[os.path.basename(bin_name)] = bin_name
# Find the full list of files to add to the bootfs.
file_mapping = _ExpandDirectories(
file_mapping,
lambda x: _MakeTargetImageName(DIR_SOURCE_ROOT, output_directory, x))
# Strip any binaries in the file list, and generate a manifest mapping.
symbols_mapping = _StripBinaries(dry_run, file_mapping)
# Write the target, source mappings to a file suitable for bootfs.
manifest_file = open(bin_name + '.bootfs_manifest', 'w')
_WriteManifest(manifest_file, file_mapping)
manifest_file.flush()
_DumpFile(dry_run, manifest_file.name, 'manifest')
# Run mkbootfs with the manifest to copy the necessary files into the bootfs.
mkbootfs_path = os.path.join(SDK_ROOT, 'tools', 'mkbootfs')
bootfs_name = bin_name + '.bootfs'
if _RunAndCheck(
dry_run,
[mkbootfs_path, '-o', bootfs_name,
'--target=boot', os.path.join(SDK_ROOT, 'bootdata.bin'),
'--target=system', manifest_file.name]) != 0:
return None
return BootfsData(bootfs_name, symbols_mapping, termination_string)
def _SymbolizeEntries(entries):
filename_re = re.compile(r'at ([-._a-zA-Z0-9/+]+):(\d+)')
# Use addr2line to symbolize all the |pc_offset|s in |entries| in one go.
# Entries with no |debug_binary| are also processed here, so that we get
# consistent output in that case, with the cannot-symbolize case.
addr2line_output = None
if entries[0].has_key('debug_binary'):
addr2line_args = (['addr2line', '-Cipf', '-p',
'--exe=' + entries[0]['debug_binary']] +
map(lambda entry: entry['pc_offset'], entries))
addr2line_output = subprocess.check_output(addr2line_args).splitlines()
assert addr2line_output
# Collate a set of |(frame_id, result)| pairs from the output lines.
results = {}
for entry in entries:
raw, frame_id = entry['raw'], entry['frame_id']
prefix = '#%s: ' % frame_id
if not addr2line_output:
# Either there was no addr2line output, or too little of it.
filtered_line = raw
else:
output_line = addr2line_output.pop(0)
# Relativize path to DIR_SOURCE_ROOT if we see a filename.
def RelativizePath(m):
relpath = os.path.relpath(os.path.normpath(m.group(1)), DIR_SOURCE_ROOT)
return 'at ' + relpath + ':' + m.group(2)
filtered_line = filename_re.sub(RelativizePath, output_line)
if '??' in filtered_line:
# If symbolization fails just output the raw backtrace.
filtered_line = raw
else:
# Release builds may inline things, resulting in "(inlined by)" lines.
inlined_by_prefix = " (inlined by)"
while (addr2line_output and
addr2line_output[0].startswith(inlined_by_prefix)):
inlined_by_line = '\n' + (' ' * len(prefix)) + addr2line_output.pop(0)
filtered_line += filename_re.sub(RelativizePath, inlined_by_line)
results[entry['frame_id']] = prefix + filtered_line
return results
def _FindDebugBinary(entry, file_mapping):
"""Looks up the binary listed in |entry| in the |file_mapping|, and returns
the corresponding host-side binary's filename, or None."""
binary = entry['binary']
if not binary:
return None
app_prefix = 'app:'
if binary.startswith(app_prefix):
binary = binary[len(app_prefix):]
# We change directory into /system/ before running the target executable, so
# all paths are relative to "/system/", and will typically start with "./".
# Some crashes still uses the full filesystem path, so cope with that as well.
system_prefix = '/system/'
cwd_prefix = './'
if binary.startswith(cwd_prefix):
binary = binary[len(cwd_prefix):]
elif binary.startswith(system_prefix):
binary = binary[len(system_prefix):]
# Allow any other paths to pass-through; sometimes neither prefix is present.
if binary in file_mapping:
return file_mapping[binary]
# |binary| may be truncated by the crashlogger, so if there is a unique
# match for the truncated name in |file_mapping|, use that instead.
matches = filter(lambda x: x.startswith(binary), file_mapping.keys())
if len(matches) == 1:
return file_mapping[matches[0]]
return None
def _SymbolizeBacktrace(backtrace, file_mapping):
# Group |backtrace| entries according to the associated binary, and locate
# the path to the debug symbols for that binary, if any.
batches = {}
for entry in backtrace:
debug_binary = _FindDebugBinary(entry, file_mapping)
if debug_binary:
entry['debug_binary'] = debug_binary
batches.setdefault(debug_binary, []).append(entry)
# Run _SymbolizeEntries on each batch and collate the results.
symbolized = {}
for batch in batches.itervalues():
symbolized.update(_SymbolizeEntries(batch))
# Map each backtrace to its symbolized form, by frame-id, and return the list.
return map(lambda entry: symbolized[entry['frame_id']], backtrace)
def _EnsureTunTap(dry_run):
"""Make sure the tun/tap device is configured. This cannot be done
automatically because it requires sudo, unfortunately, so we just print out
instructions.
"""
with open(os.devnull, 'w') as nul:
p = subprocess.Popen(
['tunctl', '-b', '-u', os.environ.get('USER'), '-t', 'qemu'],
stdout=subprocess.PIPE, stderr=nul)
output = p.communicate()[0].strip()
if output != 'qemu':
print 'Configuration of tun/tap device required:'
if not os.path.isfile('/usr/sbin/tunctl'):
print 'sudo apt-get install uml-utilities'
print 'sudo tunctl -u $USER -t qemu'
print 'sudo ifconfig qemu up'
return False
return True
def RunFuchsia(bootfs_data, use_device, dry_run, test_launcher_summary_output):
kernel_path = os.path.join(SDK_ROOT, 'kernel', 'magenta.bin')
if use_device:
# TODO(fuchsia): This doesn't capture stdout as there's no way to do so
# currently. See https://crbug.com/749242.
bootserver_path = os.path.join(SDK_ROOT, 'tools', 'bootserver')
bootserver_command = [bootserver_path, '-1', kernel_path,
bootfs_data.bootfs]
return _RunAndCheck(dry_run, bootserver_command)
if not _EnsureTunTap(dry_run):
return 1
qemu_path = os.path.join(SDK_ROOT, 'qemu', 'bin', 'qemu-system-x86_64')
qemu_command = [qemu_path,
'-m', '2048',
'-nographic',
'-machine', 'q35',
'-kernel', kernel_path,
'-initrd', bootfs_data.bootfs,
'-smp', '4',
'-enable-kvm',
'-cpu', 'host,migratable=no',
# Configure the tun/tap device used for retrieving test results and
# triggering shutdown.
'-netdev', 'tap,ifname=qemu,script=no,downscript=no,id=net0',
'-device', 'e1000,netdev=net0,mac=52:54:00:63:5e:7a',
# Configure virtual network. It is used in the tests to connect to
# testserver running on the host.
'-netdev', 'user,id=net1,net=%s,dhcpstart=%s,host=%s' %
(GUEST_NET, GUEST_IP_ADDRESS, HOST_IP_ADDRESS),
'-device', 'e1000,netdev=net1,mac=52:54:00:63:5e:7b',
# Use stdio for the guest OS only; don't attach the QEMU interactive
# monitor.
'-serial', 'stdio',
'-monitor', 'none',
# TERM=dumb tells the guest OS to not emit ANSI commands that trigger
# noisy ANSI spew from the user's terminal emulator.
'-append', 'TERM=dumb kernel.halt_on_panic=true',
]
if dry_run:
print 'Run:', ' '.join(qemu_command)
return 0
# Set up backtrace-parsing regexps.
qemu_prefix = re.compile(r'^.*> ')
backtrace_prefix = re.compile(r'bt#(?P<frame_id>\d+): ')
# Back-trace line matcher/parser assumes that 'pc' is always present, and
# expects that 'sp' and ('binary','pc_offset') may also be provided.
backtrace_entry = re.compile(
r'pc 0(?:x[0-9a-f]+)? ' +
r'(?:sp 0x[0-9a-f]+ )?' +
r'(?:\((?P<binary>\S+),(?P<pc_offset>0x[0-9a-f]+)\))?$')
# We pass a separate stdin stream to qemu. Sharing stdin across processes
# leads to flakiness due to the OS prematurely killing the stream and the
# Python script panicking and aborting.
# The precise root cause is still nebulous, but this fix works.
# See crbug.com/741194.
qemu_popen = subprocess.Popen(
qemu_command, stdout=subprocess.PIPE, stdin=open(os.devnull))
# A buffer of backtrace entries awaiting symbolization, stored as dicts:
# raw: The original back-trace line that followed the prefix.
# frame_id: backtrace frame number (starting at 0).
# binary: path to executable code corresponding to the current frame.
# pc_offset: memory offset within the executable.
backtrace_entries = []
success = False
while True:
line = qemu_popen.stdout.readline().strip()
if not line:
break
if 'SUCCESS: all tests passed.' in line:
success = True
if bootfs_data.termination_string in line:
print 'Retrieving results and starting shutdown...'
sys.stdout.flush()
if test_launcher_summary_output:
_RunAndCheck(dry_run,
[os.path.join(SDK_ROOT, 'tools', 'netcp'),
':/tmp/summary_output.json', test_launcher_summary_output])
_RunAndCheck(dry_run,
[os.path.join(SDK_ROOT, 'tools', 'netruncmd'), ':', 'dm poweroff'])
sys.stdout.write('Result retrieval complete.')
sys.stdout.flush()
# If the line is not from QEMU then don't try to process it.
matched = qemu_prefix.match(line)
if not matched:
print line
continue
guest_line = line[matched.end():]
# Look for the back-trace prefix, otherwise just print the line.
matched = backtrace_prefix.match(guest_line)
if not matched:
print line
continue
backtrace_line = guest_line[matched.end():]
# If this was the end of a back-trace then symbolize and print it.
frame_id = matched.group('frame_id')
if backtrace_line == 'end':
if backtrace_entries:
for processed in _SymbolizeBacktrace(backtrace_entries,
bootfs_data.symbols_mapping):
print processed
backtrace_entries = []
continue
# Otherwise, parse the program-counter offset, etc into |backtrace_entries|.
matched = backtrace_entry.match(backtrace_line)
if matched:
# |binary| and |pc_offset| will be None if not present.
backtrace_entries.append(
{'raw': backtrace_line, 'frame_id': frame_id,
'binary': matched.group('binary'),
'pc_offset': matched.group('pc_offset')})
else:
backtrace_entries.append(
{'raw': backtrace_line, 'frame_id': frame_id,
'binary': None, 'pc_offset': None})
qemu_popen.wait()
return 0 if success else 1