blob: 30c61773b9f1fbaefef843caa36e74b5ed67de52 [file] [log] [blame]
# Copyright 2019 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.
import copy
import attr
from recipe_engine import recipe_api
from RECIPE_MODULES.fuchsia.gce import api as gce_api
# System path at which authorized SSH keys are stored.
AUTHORIZED_KEY_PATH = "data/ssh/authorized_keys"
# The path to the botanist config on the host.
BOTANIST_DEVICE_CONFIG = "/etc/botanist/config.json"
# The log level to use for botanist invocations in test tasks. Can be one of
# "fatal", "error", "warning", "info", "debug", or "trace", where "trace" is
# the most verbose, and fatal is the least.
BOTANIST_LOG_LEVEL = "trace"
# Name of image manifest produced by the build.
IMAGES_JSON = "images.json"
_PRIVATE_KEY_PATH = "private_key"
CATALYST_CIPD_REVISION = "git_revision:d0f1c217a82b83a5566b8a66427a8c80a75c0293"
GCSPROXY_CIPD_REVISION = "git_revision:0cc1113899f4b755d22fe878723270fb24e65af7"
SERIAL_LOG_NAME = "serial_log.txt"
SYSLOG_NAME = "syslog.txt"
SERIAL_NAME = "serial.log"
SNAPSHOT_NAME = "snapshot.zip"
@attr.s
class TaskRequester(object):
"""Creates requests for swarming tasks that run tests."""
_api = attr.ib()
_buildbucket_build = attr.ib()
_per_test_timeout_secs = attr.ib(type=int)
_pave = attr.ib(type=bool)
_pool = attr.ib(type=str)
_swarming_expiration_timeout_secs = attr.ib(type=int)
_swarming_io_timeout_secs = attr.ib(type=int)
_timeout_secs = attr.ib(type=int)
_use_runtests = attr.ib(type=bool)
_default_service_account = attr.ib(type=str)
_targets_serial = attr.ib(type=bool)
_catapult_dashboard_master = attr.ib(type=str)
_catapult_dashboard_bot = attr.ib(type=str)
_release_branch = attr.ib(type=str)
_release_version = attr.ib(type=str)
_zircon_args = attr.ib(type=list)
_gcem_cloud_project = attr.ib(type=str, default=None)
_gcem_host = attr.ib(type=str, default=None)
def testrunner_request(self, shard, build_results):
"""Returns a swarming.TaskRequest that uses `testrunner` to run tests.
It constructs the appropriate testrunner command to run and passes that
as the subcommand to TaskRequester.request() which creates the task
request.
Args:
shard (testsharder.Shard): Test shard.
build_results (FuchsiaBuildResults): The Fuchsia build results to test.
"""
# Copy since we modify it for each shard.
build_results = copy.deepcopy(build_results)
# To freely archive files from the build directory, the source, and those we
# dynamically create, we create a tree of symlinks in a fresh directory and
# isolate that. This solves the problems of (a) finding a root directory
# that works for all artifacts, (b) being able to create files in that
# directory without fear of collision, and (c) not having to isolate
# extraneous files.
isolate_tree = self._api.file.symlink_tree(
root=self._api.path.mkdtemp("isolate")
)
test_manifest = "tests.json"
self._api.file.write_json(
"write test manifest",
isolate_tree.root.join(test_manifest),
shard.tests,
indent=2,
)
image_manifest = "%s/%s" % (self._api.artifacts.image_url(), IMAGES_JSON)
cmd = [
"./testrunner",
"-level",
"debug",
"-out-dir",
self._api.testing_requests.TEST_RESULTS_DIR_NAME,
"-snapshot-output",
SNAPSHOT_NAME,
]
if self._use_runtests:
cmd.append("-use-runtests")
if self._per_test_timeout_secs:
cmd.extend(["-per-test-timeout", "%ds" % self._per_test_timeout_secs])
cmd.append(test_manifest)
outputs = [self._api.testing_requests.TEST_RESULTS_DIR_NAME]
isolate_tools = ["testrunner"]
is_emu_type = self._api.emu.is_emulator_type(shard.device_type)
test_bot_cpu = build_results.set_metadata.target_arch if is_emu_type else "x64"
if test_bot_cpu == "x64":
# Relevant for automatic symbolization of things running on host. Only
# the x64 variation is available in the checkout and we have nothing
# that runs on an arm host that needs symbolizing.
isolate_tools.append("llvm-symbolizer")
# If targeting emu we include a private key corresponding to an authorized
# key already in the boot image; this is needed as we do not pave emu.
if is_emu_type and self._pave:
isolate_tree.register_link(
target=build_results.private_key,
linkname=isolate_tree.root.join(_PRIVATE_KEY_PATH),
)
# Used to dynamically extend fvm.blk, which is relevant only to emu.
isolate_tools.append("fvm")
return self.request(
shard,
build_results,
cmd,
isolate_tree,
isolate_tools,
image_manifest,
BOTANIST_LOG_LEVEL,
outputs,
)
def request(
self,
shard,
build_results,
subcmd,
isolate_tree,
isolate_tools,
image_manifest,
log_level,
subcmd_outputs=[],
):
"""Returns a swarming.TaskRequest for the specified shard.
This function handles all the common logic for setting up test tasks
such as isolating the necessary tools and constructing the proper
botanist command depending on the details of the provided shard and
build_results.
Args:
shard (testsharder.Shard): Test shard.
build_results (FuchsiaBuildResults): The Fuchsia build results to test.
subcmd (list(str)): The subcommand to run botanist with on the task.
isolate_tree (api.file.SymlinkTree): The tree into which the isolated
inputs for the task are linked.
isolate_tools (list(str)): A list of tools to add to the isolated
inputs.
image_manifest (str): The image manifest path or URL to pass to the
botanist command.
log_level (str): Passed to the botanist command.
subcmd_outputs (list(str)): The expected outputs of the subcmd, added
to the expected outputs of the task.
"""
cmd = []
outputs = []
ensure_file = self._api.cipd.EnsureFile()
dimensions = {"pool": self._pool}
is_emu_type = self._api.emu.is_emulator_type(shard.device_type)
is_gce_type = shard.device_type == "GCE"
# To take advantage of KVM, we execute emu-arm tasks on arm hardware.
test_bot_cpu = build_results.set_metadata.target_arch if is_emu_type else "x64"
# TODO(fxbug.dev/40840): In the hardware case, we cannot yet run the GCS
# proxy server in the task (it must be run outside of the container on the
# controller). In this case we rely on provisioning logic to set
# $GCS_PROXY_HOST in the environment so that we may pass this to botanist.
# Once we can scope the server in the task, delete this codepath and do as
# we do in the emu case.
#
# Note: in the emu case, we pass the placeholder of "localhost" to
# botanist. This is because we cannot know an absolute address ahead of
# time. Botanist has logic to resolve this address and scope it for the
# host and target accordingly.
#
# In the testing on GCE case, ephemerality is still unsupported, so
# leave gcsproxy out.
if is_emu_type:
gcsproxy_port = "8080"
gcsproxy_host = "localhost:%s" % gcsproxy_port
elif not is_gce_type:
gcsproxy_host = "$GCS_PROXY_HOST"
# This command spins up a metadata server that allows its subcommands to
# automagically authenticate with LUCI auth, provided the sub-exec'ed tool
# was written in go or dart and respectively makes use of the standard
# cloud.google.com/go/compute/metadata or
# github.com/dart-lang/googleapis_auth authentication libraries. Such
# libraries look for a metadata server under environment variables
# like $GCE_METADATA_HOST, which LUCI emulates.
service_account = shard.service_account or self._default_service_account
if service_account:
# TODO(fxbug.dev/37142): Find a way to use the version that LUCI is
# currently using, instead of 'latest'.
ensure_file.add_package("infra/tools/luci-auth/${platform}", "latest")
cmd.extend(["./luci-auth", "context", "--"])
if is_emu_type:
dimensions.update(
os="Debian", cpu=build_results.set_metadata.target_arch, kvm="1"
)
elif is_gce_type:
# Have any GCE shards target the GCE executors, which are e2-2
# machines running Linux.
dimensions.update(os="Linux", cores="2", gce="1")
else:
# No device -> no serial.
if self._targets_serial and shard.device_type:
dimensions["serial"] = "1"
dimensions.update(shard.dimensions)
# Ensure we use GCE VMs whenever possible.
is_linux = not shard.os or shard.os.lower() == "linux"
if is_linux and not is_gce_type:
dimensions["kvm"] = "1"
if (
(is_emu_type or not shard.device_type)
and test_bot_cpu == "x64"
and is_linux
):
dimensions["gce"] = "1"
dimensions["cores"] = "8"
if shard.targets_fuchsia:
if "bringup" not in build_results.set_metadata.product and is_emu_type:
# TODO(fxbug.dev/40840): As mentioned above, once this bug is resolved.
# we should run gcsproxy regardless of is_emu_type.
ensure_file.add_package(
"fuchsia/infra/gcsproxy/${platform}", GCSPROXY_CIPD_REVISION
)
cmd.extend(
["./gcsproxy", "-port", gcsproxy_port,]
)
if not is_emu_type and not is_gce_type:
# catalyst is needed for testing on physical hardware.
ensure_file.add_package(
"fuchsia/infra/catalyst/${platform}", CATALYST_CIPD_REVISION
)
cmd.append("./catalyst")
cmd.extend(
[
"./botanist",
"-level",
log_level,
"run",
"-images",
image_manifest,
"-timeout",
"%ds" % self._timeout_secs,
]
)
# In the emulator case, serial is redirected to stdio.
if not is_emu_type:
cmd.extend(["-serial-log", SERIAL_LOG_NAME])
outputs.append(SERIAL_LOG_NAME)
if self._pave:
cmd.extend(
["-syslog", SYSLOG_NAME,]
)
# Testing on GCE does not yet support ephemerality.
if not is_gce_type:
cmd.extend(
[
"-repo",
self._api.artifacts.package_repo_url(host=gcsproxy_host),
"-blobs",
self._api.artifacts.package_blob_url(host=gcsproxy_host),
]
)
outputs.append(SYSLOG_NAME)
if is_emu_type:
cmd.extend(["-ssh", _PRIVATE_KEY_PATH])
else:
cmd.append("-netboot")
for arg in self._zircon_args:
cmd.extend(["-zircon-args", arg])
config = BOTANIST_DEVICE_CONFIG
if is_emu_type:
config = "./qemu.json"
qemu_config = [
{
"type": shard.device_type.lower(),
"path": "./%s/bin" % shard.device_type.lower(),
"target": build_results.set_metadata.target_arch,
"cpu": 4,
"memory": self._api.emu.get_memory_for_variant(build_results),
"kvm": True,
# Is a directive to run the emu process in a way in which we can
# synthesize a 'serial device'. We need only do this in the bringup
# case, this being used for executing tests at that level;
# restriction to the minimal case is especially important as this
# mode shows tendencies to slow certain processes down.
"serial": not self._pave,
# Isolated to the root below in _isolate_build_artifacts().
# Used to dynamically extend fvm.blk to fit downloaded test
# packages.
"fvm_tool": "fvm" if self._pave else "",
"logfile": SERIAL_NAME,
}
]
outputs.append(SERIAL_NAME)
if shard.device_type == "AEMU":
self._api.emu.add_aemu_to_ensure_file(
ensure_file,
checkout=build_results.checkout.root_dir,
subdir="aemu/bin",
)
elif shard.device_type == "QEMU":
self._api.emu.add_qemu_to_ensure_file(
ensure_file,
checkout=build_results.checkout.root_dir,
subdir="qemu",
)
self._api.file.write_json(
"write qemu config",
isolate_tree.root.join("qemu.json"),
qemu_config,
indent=2,
)
elif is_gce_type:
config = "./gce.json"
ensure_file.add_package(
gce_api.GCEM_CLIENT_CIPD_PATH, gce_api.GCEM_CLIENT_CIPD_REVISION,
)
self._api.gce.create_botanist_config(
self._gcem_host,
self._gcem_cloud_project,
self._api.buildbucket.build.infra.swarming.parent_run_id,
isolate_tree.root.join("gce.json"),
)
cmd.extend(["-config", config])
cmd.extend(subcmd)
outputs.extend(subcmd_outputs)
isolated_hash = self._api.testing_requests._isolate_build_artifacts(
isolate_tree,
build_results,
shard=shard,
test_bot_cpu=test_bot_cpu,
extra_tools=isolate_tools,
)
tags = self._api.testing_requests.test_task_tags(
self._buildbucket_build,
build_results,
env_name="%s-%s"
% (shard.device_type or shard.os, build_results.set_metadata.target_arch),
task_name=shard.name,
)
request = (
self._api.swarming.task_request()
.with_name(shard.name)
.with_tags(self._api.testing_requests.create_swarming_tags(tags))
)
if service_account:
request = request.with_service_account(service_account)
return request.with_slice(
0,
request[0]
.with_command(cmd)
.with_isolated(isolated_hash)
.with_dimensions(**dimensions)
.with_expiration_secs(self._swarming_expiration_timeout_secs)
.with_io_timeout_secs(self._swarming_io_timeout_secs)
.with_execution_timeout_secs(self._timeout_secs)
.with_outputs(outputs)
.with_cipd_ensure_file(ensure_file)
.with_env_vars(
**self._api.testing_requests.test_task_env_vars(
self._buildbucket_build,
shard.device_type,
build_results,
catapult_dashboard_master=self._catapult_dashboard_master,
catapult_dashboard_bot=self._catapult_dashboard_bot,
release_branch=self._release_branch,
release_version=self._release_version,
image_manifest=image_manifest,
)
),
)
class TestingRequestsApi(recipe_api.RecipeApi):
"""APIs for constructing Swarming task requests to test Fuchsia."""
SERIAL_LOG_NAME = SERIAL_LOG_NAME
TEST_RESULTS_DIR_NAME = "out"
# The name of the tag to set on every task request that contains the name
# of the shard's environment (device type/OS and architecture).
TEST_ENVIRONMENT_TAG_NAME = "test_environment_name"
def task_requests(
self,
build_results,
buildbucket_build,
per_test_timeout_secs,
pool,
shards,
swarming_expiration_timeout_secs,
swarming_io_timeout_secs,
use_runtests,
timeout_secs,
default_service_account=None,
pave=True,
targets_serial=False,
catapult_dashboard_master=None,
catapult_dashboard_bot=None,
release_branch=None,
release_version=None,
zircon_args=(),
gcem_host=None,
gcem_cloud_project=None,
):
"""Returns a swarming.TaskRequest for each shard in build_artifact.shards.
Args:
build_results (FuchsiaBuildResults): The Fuchsia build results to test.
buildbucket_build (build_pb2.Build): The buildbucket build that is going
to orchestrate testing.
per_test_timeout_secs (int): Any test that executes for longer than this
will be considered failed.
pool (str): The Swarming pool to schedule test tasks in.
shards (list of testsharder.Shard): Test shards.
use_runtests (bool): Whether to use runtests (or else run_test_component)
when executing tests on target.
timeout_secs (int): The amount of seconds to wait for the tests to execute
before giving up.
default_service_account (str or None): The default service account to run the
test task with. This is required for fetching images from GCS.
pave (bool): Whether to pave (or else 'netboot') the system; this is
effectively equivalent to "not bringup" and is treated as such (even for
QEMU).
targets_serial (bool): Whether the task should target a bot with serial
enabled.
release_branches (str or None): The release branch corresponding to
the checkout. Passed as a task environment variable.
release_version (str or None): The release version that we checked out.
Passed as a task environment variable.
zircon_args (list(str)): Kernel command-line arguments to pass on boot.
gcem_host (str): The endpoint the GCE Mediator can be found at.
gcem_cloud_project (str): The cloud project the Mediator should create VMs in.
"""
# Embed the authorized key into the appropriate ZBI. This enabled us to SSH
# into QEMU, in which case we are unable to supply the key at pave-time (as
# QEMU instances are not paved.)
has_emu_shard = any(
self.m.emu.is_emulator_type(shard.device_type) for shard in shards
)
if pave and has_emu_shard:
self.m.zbi.zbi_path = build_results.zbi
zbi_path = build_results.build_dir.join(self._zbi_path(build_results))
self.m.zbi.copy_and_extend(
step_name="embed authorized key",
input_image=zbi_path,
output_image=zbi_path,
manifest={AUTHORIZED_KEY_PATH: build_results.authorized_key},
)
task_requester = self.get_task_requester(
buildbucket_build=buildbucket_build,
per_test_timeout_secs=per_test_timeout_secs,
pave=pave,
pool=pool,
swarming_expiration_timeout_secs=swarming_expiration_timeout_secs,
swarming_io_timeout_secs=swarming_io_timeout_secs,
timeout_secs=timeout_secs,
use_runtests=use_runtests,
default_service_account=default_service_account,
targets_serial=targets_serial,
catapult_dashboard_master=catapult_dashboard_master,
catapult_dashboard_bot=catapult_dashboard_bot,
release_branch=release_branch,
release_version=release_version,
zircon_args=zircon_args,
gcem_host=gcem_host,
gcem_cloud_project=gcem_cloud_project,
)
task_requests = []
for s in shards:
if s.device_type == "GCE" and not self.m.experimental.test_on_gce:
# Do not generate a task request for GCE shards if the
# test_on_gce flag is disabled.
continue
with self.m.step.nest("shard %s" % s.name):
task_requests.append(
task_requester.testrunner_request(s, build_results)
)
return task_requests
def get_task_requester(self, **kwargs):
"""Returns a TaskRequester with self.m as the api.
This allows all dependencies of TaskRequester to only need to be
imported into the testing_requests module.
"""
return TaskRequester(self.m, **kwargs)
def test_task_env_vars(
self,
build,
device_type,
build_results,
image_manifest,
catapult_dashboard_master=None,
catapult_dashboard_bot=None,
release_branch=None,
release_version=None,
):
"""Returns the environment variables to be set for the test task.
Returns:
A dict mapping string env var names to string values.
"""
# build = self.m.buildbucket.build
commit = build.input.gitiles_commit
llvm_symbolizer = self.m.path.basename(build_results.llvm_symbolizer)
env_vars = dict(
# `${ISOLATED_OUTDIR}` is a magic string that Swarming will replace
# with a temporary directory into which files will be automatically
# collected upon exit of a task.
FUCHSIA_TEST_OUTDIR="${ISOLATED_OUTDIR}",
# Don't set BUILDBUCKET_ID for builds run using led.
BUILDBUCKET_ID=str(build.id) if build.id else None,
BUILD_BOARD=build_results.set_metadata.board,
BUILD_TYPE=build_results.set_metadata.optimize,
BUILD_PRODUCT=build_results.set_metadata.product,
BUILD_TARGET=build_results.set_metadata.target_arch,
BUILDBUCKET_BUCKET=build.builder.bucket,
# Used for symbolization:
ASAN_SYMBOLIZER_PATH=llvm_symbolizer,
UBSAN_SYMBOLIZER_PATH=llvm_symbolizer,
LSAN_SYMBOLIZER_PATH=llvm_symbolizer,
# Used for generating data to upload to the Catapult performance
# dashboard.
# TODO(fxb/50210): Don't fall back to time.time() once led starts
# setting create_time again.
BUILD_CREATE_TIME=str(build.create_time.seconds or int(self.m.time.time())),
BUILDER_NAME=build.builder.builder,
FUCHSIA_DEVICE_TYPE=device_type,
INPUT_COMMIT_HOST=commit.host,
INPUT_COMMIT_PROJECT=commit.project,
INPUT_COMMIT_REF=commit.ref,
RELEASE_BRANCH=release_branch,
RELEASE_VERSION=release_version,
BOOTSERVER_PATH="./"
+ self.m.path.basename(build_results.tool("bootserver_new")),
IMAGE_MANIFEST_PATH=image_manifest,
SWARMING_BOT_FILE="${SWARMING_BOT_FILE}",
)
env_vars.update(
self.get_catapult_dashboard_env_vars(
catapult_dashboard_master, catapult_dashboard_bot, commit.ref
)
)
# For some reason, empty string environment variables sent to the swarming
# API get interpreted as null and rejected. So don't bother sending them to
# avoid breaking the task request.
# TODO(olivernewman): Figure out whether this logic should be moved into
# the upstream swarming module (or obviated by fixing the "" -> null
# behavior).
return {k: v for k, v in env_vars.iteritems() if v}
def test_task_tags(self, buildbucket_build, build_results, env_name, task_name):
return {
"board": (
build_results.set_metadata.board
or build_results.set_metadata.target_arch
),
"build_type": build_results.set_metadata.optimize,
"buildbucket_bucket": buildbucket_build.builder.bucket,
"buildbucket_builder": buildbucket_build.builder.builder,
"product": build_results.set_metadata.product,
"role": "tester",
"task_name": task_name,
# Consumed by google3 results uploader, and by the orchestrator
# when uploading to resultdb.
self.TEST_ENVIRONMENT_TAG_NAME: env_name,
"variants": build_results.set_metadata.variants,
}
def _isolate_build_artifacts(
self,
isolate_tree,
build_results,
shard=None,
test_bot_cpu="x64",
extra_tools=[],
):
"""Populates a tree with build artifacts and isolates it.
Specifically, the following is linked into or created within the tree:
- The images in the build are linked in and manifest of them is created
in the root, if targeting a fuchsia device;
- The Linux/Mac tests in the shard and their runtime dependencies.
Args:
isolate_tree (api.file.SymlinkTree): A tree into which artifacts may be
linked.
build_results (FuchsiaBuildResults): The result of a fuchsia build.
shard (api.testsharder.Shard or None): A test shard.
test_bot_cpu (str or None): The host cpu of the bot running the test task.
extra_tools (list(str)): A list of extra tools to isolate.
Returns:
The isolated hash that may be used to reference and download the
artifacts.
"""
if shard:
for dep in shard.deps:
# Prepare a symlink of a relative path within the build
# directory to the tree.
isolate_tree.register_link(
target=build_results.build_dir.join(dep),
linkname=isolate_tree.root.join(dep),
)
# TODO(fxb/38517): s/bootserver_new/bootserver.
tools = ["botanist", "bootserver_new"] + extra_tools
for tool_name in tools:
tool = build_results.tool(tool_name, cpu=test_bot_cpu)
isolate_tree.register_link(
target=tool, linkname=isolate_tree.root.join(tool_name)
)
isolate_tree.create_links("create tree of build artifacts")
isolated = self.m.isolated.isolated(isolate_tree.root)
isolated.add_dir(isolate_tree.root)
return isolated.archive("isolate build artifacts")
def _zbi_path(self, build_results, pave=True):
zbi = None
bootserver_type = "bootserver_%s" % ("pave" if pave else "netboot")
for image in build_results.images:
if image["type"] == "zbi":
if "--boot" in image.get(bootserver_type, []):
zbi = image
break
elif image["name"] == "zircon-a": # pragma: no cover
zbi = image
assert zbi, "No ZBI image found in image manifest"
return zbi["path"]
def create_swarming_tags(self, tags):
"""Creates a properly formatted tags dict to pass to a swarming task.
Args:
tags (dict): A dictionary of key-value pairs of the desired tags.
Returns:
A dictionary where the keys are strings and the values are lists of
strings.
"""
swarming_tags = {}
for k, val in tags.items():
if not val:
val = []
elif isinstance(val, basestring):
val = [val]
swarming_tags[str(k)] = [str(i) for i in val]
return swarming_tags
# This is included in the API just so that it can be unit-tested.
def get_catapult_dashboard_env_vars(self, master_name, bot_name, commit_ref):
if not master_name and not bot_name:
# Uploading to Catapult is disabled.
return {}
if not (master_name and bot_name):
raise ValueError(
"Catapult master and bot names not set consistently: %r, %r"
% (master_name, bot_name)
)
prefix = "refs/heads/releases/"
if commit_ref.startswith(prefix):
branch_name = commit_ref[len(prefix) :]
master_name += "." + branch_name
elif commit_ref != "refs/heads/master":
# Unrecognized Git branch/tag name. Disable uploading to Catapult
# by not setting the env vars.
return {}
return dict(
CATAPULT_DASHBOARD_MASTER=master_name, CATAPULT_DASHBOARD_BOT=bot_name
)