blob: 63ec63d1da62a7b99012ad9cbd6d523bb2adefee [file] [log] [blame]
# Copyright 2015 The Chromium OS Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""This class defines the TestBed class."""
import logging
import re
import sys
import threading
import traceback
from multiprocessing import pool
import common
from autotest_lib.client.common_lib import error
from autotest_lib.client.common_lib import logging_config
from autotest_lib.server.cros.dynamic_suite import constants
from autotest_lib.server import autoserv_parser
from autotest_lib.server import utils
from autotest_lib.server.cros import provision
from autotest_lib.server.hosts import adb_host
from autotest_lib.server.hosts import base_label
from autotest_lib.server.hosts import host_info
from autotest_lib.server.hosts import testbed_label
from autotest_lib.server.hosts import teststation_host
# Thread pool size to provision multiple devices in parallel.
_POOL_SIZE = 4
# Pattern for the image name when used to provision a dut connected to testbed.
# It should follow the naming convention of
# branch/target/build_id[:serial][#count],
# where serial and count are optional. Count is the number of devices to
# provision to.
_IMAGE_NAME_PATTERN = '(.*/.*/[^:#]*)(?::(.*))?(?:#(\d+))?'
class TestBed(object):
"""This class represents a collection of connected teststations and duts."""
_parser = autoserv_parser.autoserv_parser
VERSION_PREFIX = provision.TESTBED_BUILD_VERSION_PREFIX
support_devserver_provision = False
def __init__(self, hostname='localhost', afe_host=None, adb_serials=None,
host_info_store=None, **dargs):
"""Initialize a TestBed.
This will create the Test Station Host and connected hosts (ADBHost for
now) and allow the user to retrieve them.
@param hostname: Hostname of the test station connected to the duts.
@param adb_serials: List of adb device serials.
@param host_info_store: A CachingHostInfoStore object.
@param afe_host: The host object attained from the AFE (get_hosts).
"""
logging.info('Initializing TestBed centered on host: %s', hostname)
self.hostname = hostname
self._afe_host = afe_host or utils.EmptyAFEHost()
self.host_info_store = (host_info_store or
host_info.InMemoryHostInfoStore())
self.labels = base_label.LabelRetriever(testbed_label.TESTBED_LABELS)
self.teststation = teststation_host.create_teststationhost(
hostname=hostname, afe_host=self._afe_host, **dargs)
self.is_client_install_supported = False
serials_from_attributes = self._afe_host.attributes.get('serials')
if serials_from_attributes:
serials_from_attributes = serials_from_attributes.split(',')
self.adb_device_serials = (adb_serials or
serials_from_attributes or
self.query_adb_device_serials())
self.adb_devices = {}
for adb_serial in self.adb_device_serials:
self.adb_devices[adb_serial] = adb_host.ADBHost(
hostname=hostname, teststation=self.teststation,
adb_serial=adb_serial, afe_host=self._afe_host,
host_info_store=self.host_info_store, **dargs)
def query_adb_device_serials(self):
"""Get a list of devices currently attached to the test station.
@returns a list of adb devices.
"""
return adb_host.ADBHost.parse_device_serials(
self.teststation.run('adb devices').stdout)
def get_all_hosts(self):
"""Return a list of all the hosts in this testbed.
@return: List of the hosts which includes the test station and the adb
devices.
"""
device_list = [self.teststation]
device_list.extend(self.adb_devices.values())
return device_list
def get_test_station(self):
"""Return the test station host object.
@return: The test station host object.
"""
return self.teststation
def get_adb_devices(self):
"""Return the adb host objects.
@return: A dict of adb device serials to their host objects.
"""
return self.adb_devices
def get_labels(self):
"""Return a list of the labels gathered from the devices connected.
@return: A list of strings that denote the labels from all the devices
connected.
"""
return self.labels.get_labels(self)
def update_labels(self):
"""Update the labels on the testbed."""
return self.labels.update_labels(self)
def get_platform(self):
"""Return the platform of the devices.
@return: A string representing the testbed platform.
"""
return 'testbed'
def repair(self):
"""Run through repair on all the devices."""
# board name is needed for adb_host to repair as the adb_host objects
# created for testbed doesn't have host label and attributes retrieved
# from AFE.
info = self.host_info_store.get()
board = info.board
# Remove the tailing -# in board name as it can be passed in from
# testbed board labels
match = re.match(r'^(.*)-\d+$', board)
if match:
board = match.group(1)
failures = []
for adb_device in self.get_adb_devices().values():
try:
adb_device.repair(board=board, os=info.os)
except:
exc_type, exc_value, exc_traceback = sys.exc_info()
failures.append((adb_device.adb_serial, exc_type, exc_value,
exc_traceback))
if failures:
serials = []
for serial, exc_type, exc_value, exc_traceback in failures:
serials.append(serial)
details = ''.join(traceback.format_exception(
exc_type, exc_value, exc_traceback))
logging.error('Failed to repair device with serial %s, '
'error:\n%s', serial, details)
raise error.AutoservRepairTotalFailure(
'Fail to repair %d devices: %s' %
(len(serials), ','.join(serials)))
def verify(self):
"""Run through verify on all the devices."""
for device in self.get_all_hosts():
device.verify()
def cleanup(self):
"""Run through cleanup on all the devices."""
for adb_device in self.get_adb_devices().values():
adb_device.cleanup()
def _parse_image(self, image_string):
"""Parse the image string to a dictionary.
Sample value of image_string:
Provision dut with serial ZX1G2 to build `branch1/shamu-userdebug/111`,
and provision another shamu with build `branch2/shamu-userdebug/222`
branch1/shamu-userdebug/111:ZX1G2,branch2/shamu-userdebug/222
Provision 10 shamu with build `branch1/shamu-userdebug/LATEST`
branch1/shamu-userdebug/LATEST#10
@param image_string: A comma separated string of images. The image name
is in the format of branch/target/build_id[:serial]. Serial is
optional once testbed machine_install supports allocating DUT
based on board.
@returns: A list of tuples of (build, serial). serial could be None if
it's not specified.
"""
images = []
for image in image_string.split(','):
match = re.match(_IMAGE_NAME_PATTERN, image)
# The image string cannot specify both serial and count.
if not match or (match.group(2) and match.group(3)):
raise error.InstallError(
'Image name of "%s" has invalid format. It should '
'follow naming convention of '
'branch/target/build_id[:serial][#count]', image)
if match.group(3):
images.extend([(match.group(1), None)]*int(match.group(3)))
else:
images.append((match.group(1), match.group(2)))
return images
@staticmethod
def _install_device(inputs):
"""Install build to a device with the given inputs.
@param inputs: A dictionary of the arguments needed to install a device.
Keys include:
host: An ADBHost object of the device.
build_url: Devserver URL to the build to install.
"""
host = inputs['host']
build_url = inputs['build_url']
build_local_path = inputs['build_local_path']
# Set the thread name with the serial so logging for installing
# different devices can have different thread name.
threading.current_thread().name = host.adb_serial
logging.info('Starting installing device %s:%s from build url %s',
host.hostname, host.adb_serial, build_url)
host.machine_install(build_url=build_url,
build_local_path=build_local_path)
logging.info('Finished installing device %s:%s from build url %s',
host.hostname, host.adb_serial, build_url)
def locate_devices(self, images):
"""Locate device for each image in the given images list.
If the given images all have no serial associated and have the same
image for the same board, testbed will assign all devices with the
desired board to the image. This allows tests to randomly pick devices
to run.
As an example, a testbed with 4 devices, 2 for board_1 and 2 for
board_2. If the given images value is:
[('board_1_build', None), ('board_2_build', None)]
The testbed will return following device allocation:
{'serial_1_board_1': 'board_1_build',
'serial_2_board_1': 'board_1_build',
'serial_1_board_2': 'board_2_build',
'serial_2_board_2': 'board_2_build',
}
That way, all board_1 duts will be installed with board_1_build, and
all board_2 duts will be installed with board_2_build. Test can pick
any dut from board_1 duts and same applies to board_2 duts.
@param images: A list of tuples of (build, serial). serial could be None
if it's not specified. Following are some examples:
[('branch1/shamu-userdebug/100', None),
('branch1/shamu-userdebug/100', None)]
[('branch1/hammerhead-userdebug/100', 'XZ123'),
('branch1/hammerhead-userdebug/200', None)]
where XZ123 is serial of one of the hammerheads connected to the
testbed.
@return: A dictionary of (serial, build). Note that build here should
not have a serial specified in it.
@raise InstallError: If not enough duts are available to install the
given images. Or there are more duts with the same board than
the images list specified.
"""
# The map between serial and build to install in that dut.
serial_build_pairs = {}
builds_without_serial = [build for build, serial in images
if not serial]
for build, serial in images:
if serial:
serial_build_pairs[serial] = build
# Return the mapping if all builds have serial specified.
if not builds_without_serial:
return serial_build_pairs
# serials grouped by the board of duts.
duts_by_name = {}
for serial, host in self.get_adb_devices().iteritems():
# Excluding duts already assigned to a build.
if serial in serial_build_pairs:
continue
aliases = host.get_device_aliases()
for alias in aliases:
duts_by_name.setdefault(alias, []).append(serial)
# Builds grouped by the board name.
builds_by_name = {}
for build in builds_without_serial:
match = re.match(adb_host.BUILD_REGEX, build)
if not match:
raise error.InstallError('Build %s is invalid. Failed to parse '
'the board name.' % build)
name = match.group('BUILD_TARGET')
builds_by_name.setdefault(name, []).append(build)
# Pair build with dut with matching board.
for name, builds in builds_by_name.iteritems():
duts = duts_by_name.get(name, [])
if len(duts) < len(builds):
raise error.InstallError(
'Expected number of DUTs for name %s is %d, got %d' %
(name, len(builds), len(duts) if duts else 0))
elif len(duts) == len(builds):
serial_build_pairs.update(dict(zip(duts, builds)))
else:
# In this cases, available dut number is greater than the number
# of builds.
if len(set(builds)) > 1:
raise error.InstallError(
'Number of available DUTs are greater than builds '
'needed, testbed cannot allocate DUTs for testing '
'deterministically.')
# Set all DUTs to the same build.
for serial in duts:
serial_build_pairs[serial] = builds[0]
return serial_build_pairs
def save_info(self, results_dir):
"""Saves info about the testbed to a directory.
@param results_dir: The directory to save to.
"""
for device in self.get_adb_devices().values():
device.save_info(results_dir, include_build_info=True)
def _stage_shared_build(self, serial_build_map):
"""Try to stage build on teststation to be shared by all provision jobs.
This logic only applies to the case that multiple devices are
provisioned to the same build. If the provision job does not fit this
requirement, this method will not stage any build.
@param serial_build_map: A map between dut's serial and the build to be
installed.
@return: A tuple of (build_url, build_local_path, teststation), where
build_url: url to the build on devserver
build_local_path: Path to a local directory in teststation that
contains the build.
teststation: A teststation object that is used to stage the
build.
If there are more than one build need to be staged or only one
device is used for the test, return (None, None, None)
"""
build_local_path = None
build_url = None
teststation = None
same_builds = set([build for build in serial_build_map.values()])
if len(same_builds) == 1 and len(serial_build_map.values()) > 1:
same_build = same_builds.pop()
logging.debug('All devices will be installed with build %s, stage '
'the shared build to be used for all provision jobs.',
same_build)
stage_host = self.get_adb_devices()[serial_build_map.keys()[0]]
teststation = stage_host.teststation
build_url, _ = stage_host.stage_build_for_install(same_build)
if stage_host.get_os_type() == adb_host.OS_TYPE_ANDROID:
build_local_path = stage_host.stage_android_image_files(
build_url)
else:
build_local_path = stage_host.stage_brillo_image_files(
build_url)
elif len(same_builds) > 1:
logging.debug('More than one build need to be staged, leave the '
'staging build tasks to individual provision job.')
else:
logging.debug('Only one device needs to be provisioned, leave the '
'staging build task to individual provision job.')
return build_url, build_local_path, teststation
def machine_install(self, image=None):
"""Install the DUT.
@param image: Image we want to install on this testbed, e.g.,
`branch1/shamu-eng/1001,branch2/shamu-eng/1002`
@returns A tuple of (the name of the image installed, None), where None
is a placeholder for update_url. Testbed does not have a single
update_url, thus it's set to None.
@returns A tuple of (image_name, host_attributes).
image_name is the name of images installed, e.g.,
`branch1/shamu-eng/1001,branch2/shamu-eng/1002`
host_attributes is a dictionary of (attribute, value), which
can be saved to afe_host_attributes table in database. This
method returns a dictionary with entries of job_repo_urls for
each provisioned devices:
`job_repo_url_[adb_serial]`: devserver_url, where devserver_url
is a url to the build staged on devserver.
For example:
{'job_repo_url_XZ001': 'http://10.1.1.3/branch1/shamu-eng/1001',
'job_repo_url_XZ002': 'http://10.1.1.3/branch2/shamu-eng/1002'}
"""
image = image or self._parser.options.image
if not image:
raise error.InstallError('No image string is provided to test bed.')
images = self._parse_image(image)
host_attributes = {}
# Change logging formatter to include thread name. This is to help logs
# from each provision runs have the dut's serial, which is set as the
# thread name.
logging_config.add_threadname_in_log()
serial_build_map = self.locate_devices(images)
build_url, build_local_path, teststation = self._stage_shared_build(
serial_build_map)
thread_pool = None
try:
arguments = []
for serial, build in serial_build_map.iteritems():
logging.info('Installing build %s on DUT with serial %s.',
build, serial)
host = self.get_adb_devices()[serial]
if build_url:
device_build_url = build_url
else:
device_build_url, _ = host.stage_build_for_install(build)
arguments.append({'host': host,
'build_url': device_build_url,
'build_local_path': build_local_path})
attribute_name = '%s_%s' % (constants.JOB_REPO_URL,
host.adb_serial)
host_attributes[attribute_name] = device_build_url
thread_pool = pool.ThreadPool(_POOL_SIZE)
thread_pool.map(self._install_device, arguments)
thread_pool.close()
except Exception as err:
logging.error(err.message)
finally:
if thread_pool:
thread_pool.join()
if build_local_path:
logging.debug('Clean up build artifacts %s:%s',
teststation.hostname, build_local_path)
teststation.run('rm -rf %s' % build_local_path)
return image, host_attributes
def get_attributes_to_clear_before_provision(self):
"""Get a list of attribute to clear before machine_install starts.
"""
return [host.job_repo_url_attribute for host in
self.adb_devices.values()]