blob: 31cbb12f35568d7d023b0a473f46a481f232ff1f [file] [log] [blame]
#!/usr/bin/env python
#
# Copyright 2016 - The Android Open Source Project
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Public Device Driver APIs.
This module provides public device driver APIs that can be called
as a Python library.
TODO: The following APIs have not been implemented
- RebootAVD(ip):
- RegisterSshPubKey(username, key):
- UnregisterSshPubKey(username, key):
- CleanupStaleImages():
- CleanupStaleDevices():
"""
import datetime
import logging
import os
import socket
import subprocess
import dateutil.parser
import dateutil.tz
from acloud.public import avd
from acloud.public import errors
from acloud.public import report
from acloud.public.actions import common_operations
from acloud.internal import constants
from acloud.internal.lib import auth
from acloud.internal.lib import android_build_client
from acloud.internal.lib import android_compute_client
from acloud.internal.lib import gstorage_client
from acloud.internal.lib import utils
logger = logging.getLogger(__name__)
ALL_SCOPES = " ".join([android_build_client.AndroidBuildClient.SCOPE,
gstorage_client.StorageClient.SCOPE,
android_compute_client.AndroidComputeClient.SCOPE])
MAX_BATCH_CLEANUP_COUNT = 100
SSH_TUNNEL_CMD = ("/usr/bin/ssh -i %(rsa_key_file)s -o "
"UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -L "
"%(vnc_port)d:127.0.0.1:6444 -L %(adb_port)d:127.0.0.1:5555 "
"-N -f -l root %(ip_addr)s")
ADB_CONNECT_CMD = "adb connect 127.0.0.1:%(adb_port)d"
# pylint: disable=invalid-name
class AndroidVirtualDevicePool(object):
"""A class that manages a pool of devices."""
def __init__(self, cfg, devices=None):
self._devices = devices or []
self._cfg = cfg
credentials = auth.CreateCredentials(cfg, ALL_SCOPES)
self._build_client = android_build_client.AndroidBuildClient(
credentials)
self._storage_client = gstorage_client.StorageClient(credentials)
self._compute_client = android_compute_client.AndroidComputeClient(
cfg, credentials)
def _CreateGceImageWithBuildInfo(self, build_target, build_id):
"""Creates a Gce image using build from Launch Control.
Clone avd-system.tar.gz of a build to a cache storage bucket
using launch control api. And then create a Gce image.
Args:
build_target: Target name, e.g. "aosp_cf_x86_phone-userdebug"
build_id: Build id, a string, e.g. "2263051", "P2804227"
Returns:
String, name of the Gce image that has been created.
"""
logger.info("Creating a new gce image using build: build_id %s, "
"build_target %s", build_id, build_target)
disk_image_id = utils.GenerateUniqueName(
suffix=self._cfg.disk_image_name)
self._build_client.CopyTo(
build_target,
build_id,
artifact_name=self._cfg.disk_image_name,
destination_bucket=self._cfg.storage_bucket_name,
destination_path=disk_image_id)
disk_image_url = self._storage_client.GetUrl(
self._cfg.storage_bucket_name, disk_image_id)
try:
image_name = self._compute_client.GenerateImageName(build_target,
build_id)
self._compute_client.CreateImage(image_name=image_name,
source_uri=disk_image_url)
finally:
self._storage_client.Delete(self._cfg.storage_bucket_name,
disk_image_id)
return image_name
def _CreateGceImageWithLocalFile(self, local_disk_image):
"""Create a Gce image with a local image file.
The local disk image can be either a tar.gz file or a
raw vmlinux image.
e.g. /tmp/avd-system.tar.gz or /tmp/android_system_disk_syslinux.img
If a raw vmlinux image is provided, it will be archived into a tar.gz file.
The final tar.gz file will be uploaded to a cache bucket in storage.
Args:
local_disk_image: string, path to a local disk image,
Returns:
String, name of the Gce image that has been created.
Raises:
DriverError: if a file with an unexpected extension is given.
"""
logger.info("Creating a new gce image from a local file %s",
local_disk_image)
with utils.TempDir() as tempdir:
if local_disk_image.endswith(self._cfg.disk_raw_image_extension):
dest_tar_file = os.path.join(tempdir,
self._cfg.disk_image_name)
utils.MakeTarFile(
src_dict={local_disk_image: self._cfg.disk_raw_image_name},
dest=dest_tar_file)
local_disk_image = dest_tar_file
elif not local_disk_image.endswith(self._cfg.disk_image_extension):
raise errors.DriverError(
"Wrong local_disk_image type, must be a *%s file or *%s file"
% (self._cfg.disk_raw_image_extension,
self._cfg.disk_image_extension))
disk_image_id = utils.GenerateUniqueName(
suffix=self._cfg.disk_image_name)
self._storage_client.Upload(
local_src=local_disk_image,
bucket_name=self._cfg.storage_bucket_name,
object_name=disk_image_id,
mime_type=self._cfg.disk_image_mime_type)
disk_image_url = self._storage_client.GetUrl(
self._cfg.storage_bucket_name, disk_image_id)
try:
image_name = self._compute_client.GenerateImageName()
self._compute_client.CreateImage(image_name=image_name,
source_uri=disk_image_url)
finally:
self._storage_client.Delete(self._cfg.storage_bucket_name,
disk_image_id)
return image_name
def CreateDevices(self,
num,
build_target=None,
build_id=None,
gce_image=None,
local_disk_image=None,
cleanup=True,
extra_data_disk_size_gb=None,
precreated_data_image=None):
"""Creates |num| devices for given build_target and build_id.
- If gce_image is provided, will use it to create an instance.
- If local_disk_image is provided, will upload it to a temporary
caching storage bucket which is defined by user as |storage_bucket_name|
And then create an gce image with it; and then create an instance.
- If build_target and build_id are provided, will clone the disk image
via launch control to the temporary caching storage bucket.
And then create an gce image with it; and then create an instance.
Args:
num: Number of devices to create.
build_target: Target name, e.g. "aosp_cf_x86_phone-userdebug"
build_id: Build id, a string, e.g. "2263051", "P2804227"
gce_image: string, if given, will use this image
instead of creating a new one.
implies cleanup=False.
local_disk_image: string, path to a local disk image, e.g.
/tmp/avd-system.tar.gz
cleanup: boolean, if True clean up compute engine image after creating
the instance.
extra_data_disk_size_gb: Integer, size of extra disk, or None.
precreated_data_image: A string, the image to use for the extra disk.
Raises:
errors.DriverError: If no source is specified for image creation.
"""
if gce_image:
# GCE image is provided, we can directly move to instance creation.
logger.info("Using existing gce image %s", gce_image)
image_name = gce_image
cleanup = False
elif local_disk_image:
image_name = self._CreateGceImageWithLocalFile(local_disk_image)
elif build_target and build_id:
image_name = self._CreateGceImageWithBuildInfo(build_target,
build_id)
else:
raise errors.DriverError(
"Invalid image source, must specify one of the following: gce_image, "
"local_disk_image, or build_target and build id.")
# Create GCE instances.
try:
for _ in range(num):
instance = self._compute_client.GenerateInstanceName(
build_target, build_id)
extra_disk_name = None
if extra_data_disk_size_gb > 0:
extra_disk_name = self._compute_client.GetDataDiskName(
instance)
self._compute_client.CreateDisk(extra_disk_name,
precreated_data_image,
extra_data_disk_size_gb)
self._compute_client.CreateInstance(
instance=instance,
image_name=image_name,
extra_disk_name=extra_disk_name)
ip = self._compute_client.GetInstanceIP(instance)
self.devices.append(avd.AndroidVirtualDevice(
ip=ip, instance_name=instance))
finally:
if cleanup:
self._compute_client.DeleteImage(image_name)
def DeleteDevices(self):
"""Deletes devices.
Returns:
A tuple, (deleted, failed, error_msgs)
deleted: A list of names of instances that have been deleted.
faild: A list of names of instances that we fail to delete.
error_msgs: A list of failure messages.
"""
instance_names = [device.instance_name for device in self._devices]
return self._compute_client.DeleteInstances(instance_names,
self._cfg.zone)
def WaitForBoot(self):
"""Waits for all devices to boot up.
Returns:
A dictionary that contains all the failures.
The key is the name of the instance that fails to boot,
the value is an errors.DeviceBoottError object.
"""
failures = {}
for device in self._devices:
try:
self._compute_client.WaitForBoot(device.instance_name)
except errors.DeviceBootError as e:
failures[device.instance_name] = e
return failures
@property
def devices(self):
"""Returns a list of devices in the pool.
Returns:
A list of devices in the pool.
"""
return self._devices
def _AddDeletionResultToReport(report_obj, deleted, failed, error_msgs,
resource_name):
"""Adds deletion result to a Report object.
This function will add the following to report.data.
"deleted": [
{"name": "resource_name", "type": "resource_name"},
],
"failed": [
{"name": "resource_name", "type": "resource_name"},
],
This function will append error_msgs to report.errors.
Args:
report_obj: A Report object.
deleted: A list of names of the resources that have been deleted.
failed: A list of names of the resources that we fail to delete.
error_msgs: A list of error message strings to be added to the report.
resource_name: A string, representing the name of the resource.
"""
for name in deleted:
report_obj.AddData(key="deleted",
value={"name": name,
"type": resource_name})
for name in failed:
report_obj.AddData(key="failed",
value={"name": name,
"type": resource_name})
report_obj.AddErrors(error_msgs)
if failed or error_msgs:
report_obj.SetStatus(report.Status.FAIL)
def _FetchSerialLogsFromDevices(compute_client, instance_names, output_file,
port):
"""Fetch serial logs from a port for a list of devices to a local file.
Args:
compute_client: An object of android_compute_client.AndroidComputeClient
instance_names: A list of instance names.
output_file: A path to a file ending with "tar.gz"
port: The number of serial port to read from, 0 for serial output, 1 for
logcat.
"""
with utils.TempDir() as tempdir:
src_dict = {}
for instance_name in instance_names:
serial_log = compute_client.GetSerialPortOutput(
instance=instance_name, port=port)
file_name = "%s.log" % instance_name
file_path = os.path.join(tempdir, file_name)
src_dict[file_path] = file_name
with open(file_path, "w") as f:
f.write(serial_log.encode("utf-8"))
utils.MakeTarFile(src_dict, output_file)
def _PickFreePort():
"""Helper to pick a free port.
Returns:
A free port number.
"""
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind(("", 0))
port = s.getsockname()[1]
s.close()
return port
def _AutoConnect(device_dict, rsa_key_file):
"""Autoconnect to an AVD instance.
Args:
device_dict: device_dict representing the device we are autoconnecting
to. This dict will be updated with the adb & vnc tunnel
ports.
rsa_key_file: Private key file to use when creating the ssh tunnels.
"""
try:
adb_port = _PickFreePort()
vnc_port = _PickFreePort()
tunnel_cmd = SSH_TUNNEL_CMD % {"rsa_key_file": rsa_key_file,
"vnc_port": vnc_port,
"adb_port": adb_port,
"ip_addr": device_dict["ip"]}
logging.debug("Running '%s'", tunnel_cmd)
subprocess.check_call([tunnel_cmd], shell=True)
adb_connect_cmd = ADB_CONNECT_CMD % {"adb_port": adb_port}
logging.debug("Running '%s'", adb_connect_cmd)
device_dict["adb_tunnel_port"] = adb_port
device_dict["vnc_tunnel_port"] = vnc_port
subprocess.check_call([adb_connect_cmd], shell=True)
except subprocess.CalledProcessError:
logging.error("Failed to autoconnect %s through local adb tunnel port"
" %d and vnc tunnel port %d", device_dict["ip"], adb_port,
vnc_port)
# pylint: disable=too-many-locals
def CreateAndroidVirtualDevices(cfg,
build_target=None,
build_id=None,
num=1,
gce_image=None,
local_disk_image=None,
cleanup=True,
serial_log_file=None,
logcat_file=None,
autoconnect=False):
"""Creates one or multiple android devices.
Args:
cfg: An AcloudConfig instance.
build_target: Target name, e.g. "aosp_cf_x86_phone-userdebug"
build_id: Build id, a string, e.g. "2263051", "P2804227"
num: Number of devices to create.
gce_image: string, if given, will use this gce image
instead of creating a new one.
implies cleanup=False.
local_disk_image: string, path to a local disk image, e.g.
/tmp/avd-system.tar.gz
cleanup: boolean, if True clean up compute engine image and
disk image in storage after creating the instance.
serial_log_file: A path to a file where serial output should
be saved to.
logcat_file: A path to a file where logcat logs should be saved.
autoconnect: Create ssh tunnel(s) and adb connect after device creation.
Returns:
A Report instance.
"""
r = report.Report(command="create")
credentials = auth.CreateCredentials(cfg, ALL_SCOPES)
compute_client = android_compute_client.AndroidComputeClient(cfg,
credentials)
try:
common_operations.CreateSshKeyPairIfNecessary(cfg)
device_pool = AndroidVirtualDevicePool(cfg)
device_pool.CreateDevices(
num,
build_target,
build_id,
gce_image,
local_disk_image,
cleanup,
extra_data_disk_size_gb=cfg.extra_data_disk_size_gb,
precreated_data_image=cfg.precreated_data_image_map.get(
cfg.extra_data_disk_size_gb))
failures = device_pool.WaitForBoot()
# Write result to report.
for device in device_pool.devices:
device_dict = {"ip": device.ip,
"instance_name": device.instance_name}
if autoconnect:
_AutoConnect(device_dict, cfg.ssh_private_key_path)
if device.instance_name in failures:
r.AddData(key="devices_failing_boot", value=device_dict)
r.AddError(str(failures[device.instance_name]))
else:
r.AddData(key="devices", value=device_dict)
if failures:
r.SetStatus(report.Status.BOOT_FAIL)
else:
r.SetStatus(report.Status.SUCCESS)
# Dump serial and logcat logs.
if serial_log_file:
_FetchSerialLogsFromDevices(
compute_client,
instance_names=[d.instance_name for d in device_pool.devices],
port=constants.DEFAULT_SERIAL_PORT,
output_file=serial_log_file)
if logcat_file:
_FetchSerialLogsFromDevices(
compute_client,
instance_names=[d.instance_name for d in device_pool.devices],
port=constants.LOGCAT_SERIAL_PORT,
output_file=logcat_file)
except errors.DriverError as e:
r.AddError(str(e))
r.SetStatus(report.Status.FAIL)
return r
def DeleteAndroidVirtualDevices(cfg, instance_names):
"""Deletes android devices.
Args:
cfg: An AcloudConfig instance.
instance_names: A list of names of the instances to delete.
Returns:
A Report instance.
"""
r = report.Report(command="delete")
credentials = auth.CreateCredentials(cfg, ALL_SCOPES)
compute_client = android_compute_client.AndroidComputeClient(cfg,
credentials)
try:
deleted, failed, error_msgs = compute_client.DeleteInstances(
instance_names, cfg.zone)
_AddDeletionResultToReport(
r, deleted,
failed, error_msgs,
resource_name="instance")
if r.status == report.Status.UNKNOWN:
r.SetStatus(report.Status.SUCCESS)
except errors.DriverError as e:
r.AddError(str(e))
r.SetStatus(report.Status.FAIL)
return r
def _FindOldItems(items, cut_time, time_key):
"""Finds items from |items| whose timestamp is earlier than |cut_time|.
Args:
items: A list of items. Each item is a dictionary represent
the properties of the item. It should has a key as noted
by time_key.
cut_time: A datetime.datatime object.
time_key: String, key for the timestamp.
Returns:
A list of those from |items| whose timestamp is earlier than cut_time.
"""
cleanup_list = []
for item in items:
t = dateutil.parser.parse(item[time_key])
if t < cut_time:
cleanup_list.append(item)
return cleanup_list
def Cleanup(cfg, expiration_mins):
"""Cleans up stale gce images, gce instances, and disk images in storage.
Args:
cfg: An AcloudConfig instance.
expiration_mins: Integer, resources older than |expiration_mins| will
be cleaned up.
Returns:
A Report instance.
"""
r = report.Report(command="cleanup")
try:
cut_time = (datetime.datetime.now(dateutil.tz.tzlocal()) -
datetime.timedelta(minutes=expiration_mins))
logger.info(
"Cleaning up any gce images/instances and cached build artifacts."
"in google storage that are older than %s", cut_time)
credentials = auth.CreateCredentials(cfg, ALL_SCOPES)
compute_client = android_compute_client.AndroidComputeClient(
cfg, credentials)
storage_client = gstorage_client.StorageClient(credentials)
# Cleanup expired instances
items = compute_client.ListInstances(zone=cfg.zone)
cleanup_list = [
item["name"]
for item in _FindOldItems(items, cut_time, "creationTimestamp")
]
logger.info("Found expired instances: %s", cleanup_list)
for i in range(0, len(cleanup_list), MAX_BATCH_CLEANUP_COUNT):
result = compute_client.DeleteInstances(
instances=cleanup_list[i:i + MAX_BATCH_CLEANUP_COUNT],
zone=cfg.zone)
_AddDeletionResultToReport(r, *result, resource_name="instance")
# Cleanup expired images
items = compute_client.ListImages()
skip_list = cfg.precreated_data_image_map.viewvalues()
cleanup_list = [
item["name"]
for item in _FindOldItems(items, cut_time, "creationTimestamp")
if item["name"] not in skip_list
]
logger.info("Found expired images: %s", cleanup_list)
for i in range(0, len(cleanup_list), MAX_BATCH_CLEANUP_COUNT):
result = compute_client.DeleteImages(
image_names=cleanup_list[i:i + MAX_BATCH_CLEANUP_COUNT])
_AddDeletionResultToReport(r, *result, resource_name="image")
# Cleanup expired disks
# Disks should have been attached to instances with autoDelete=True.
# However, sometimes disks may not be auto deleted successfully.
items = compute_client.ListDisks(zone=cfg.zone)
cleanup_list = [
item["name"]
for item in _FindOldItems(items, cut_time, "creationTimestamp")
if not item.get("users")
]
logger.info("Found expired disks: %s", cleanup_list)
for i in range(0, len(cleanup_list), MAX_BATCH_CLEANUP_COUNT):
result = compute_client.DeleteDisks(
disk_names=cleanup_list[i:i + MAX_BATCH_CLEANUP_COUNT],
zone=cfg.zone)
_AddDeletionResultToReport(r, *result, resource_name="disk")
# Cleanup expired google storage
items = storage_client.List(bucket_name=cfg.storage_bucket_name)
cleanup_list = [
item["name"]
for item in _FindOldItems(items, cut_time, "timeCreated")
]
logger.info("Found expired cached artifacts: %s", cleanup_list)
for i in range(0, len(cleanup_list), MAX_BATCH_CLEANUP_COUNT):
result = storage_client.DeleteFiles(
bucket_name=cfg.storage_bucket_name,
object_names=cleanup_list[i:i + MAX_BATCH_CLEANUP_COUNT])
_AddDeletionResultToReport(
r, *result, resource_name="cached_build_artifact")
# Everything succeeded, write status to report.
if r.status == report.Status.UNKNOWN:
r.SetStatus(report.Status.SUCCESS)
except errors.DriverError as e:
r.AddError(str(e))
r.SetStatus(report.Status.FAIL)
return r
def AddSshRsa(cfg, user, ssh_rsa_path):
"""Add public ssh rsa key to the project.
Args:
cfg: An AcloudConfig instance.
user: the name of the user which the key belongs to.
ssh_rsa_path: The absolute path to public rsa key.
Returns:
A Report instance.
"""
r = report.Report(command="sshkey")
try:
credentials = auth.CreateCredentials(cfg, ALL_SCOPES)
compute_client = android_compute_client.AndroidComputeClient(
cfg, credentials)
compute_client.AddSshRsa(user, ssh_rsa_path)
r.SetStatus(report.Status.SUCCESS)
except errors.DriverError as e:
r.AddError(str(e))
r.SetStatus(report.Status.FAIL)
return r
def CheckAccess(cfg):
"""Check if user has access.
Args:
cfg: An AcloudConfig instance.
"""
credentials = auth.CreateCredentials(cfg, ALL_SCOPES)
compute_client = android_compute_client.AndroidComputeClient(
cfg, credentials)
logger.info("Checking if user has access to project %s", cfg.project)
if not compute_client.CheckAccess():
logger.error("User does not have access to project %s", cfg.project)
# Print here so that command line user can see it.
print "Looks like you do not have access to %s. " % cfg.project
if cfg.project in cfg.no_project_access_msg_map:
print cfg.no_project_access_msg_map[cfg.project]