Home | History | Annotate | Download | only in public
      1 #!/usr/bin/env python
      2 #
      3 # Copyright 2016 - The Android Open Source Project
      4 #
      5 # Licensed under the Apache License, Version 2.0 (the "License");
      6 # you may not use this file except in compliance with the License.
      7 # You may obtain a copy of the License at
      8 #
      9 #     http://www.apache.org/licenses/LICENSE-2.0
     10 #
     11 # Unless required by applicable law or agreed to in writing, software
     12 # distributed under the License is distributed on an "AS IS" BASIS,
     13 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     14 # See the License for the specific language governing permissions and
     15 # limitations under the License.
     16 
     17 """Public Device Driver APIs.
     18 
     19 This module provides public device driver APIs that can be called
     20 as a Python library.
     21 
     22 TODO(fdeng): The following APIs have not been implemented
     23   - RebootAVD(ip):
     24   - RegisterSshPubKey(username, key):
     25   - UnregisterSshPubKey(username, key):
     26   - CleanupStaleImages():
     27   - CleanupStaleDevices():
     28 """
     29 
     30 import datetime
     31 import logging
     32 import os
     33 
     34 import dateutil.parser
     35 import dateutil.tz
     36 
     37 from acloud.public import avd
     38 from acloud.public import errors
     39 from acloud.public import report
     40 from acloud.internal import constants
     41 from acloud.internal.lib import auth
     42 from acloud.internal.lib import android_build_client
     43 from acloud.internal.lib import android_compute_client
     44 from acloud.internal.lib import gstorage_client
     45 from acloud.internal.lib import utils
     46 
     47 logger = logging.getLogger(__name__)
     48 
     49 ALL_SCOPES = " ".join([android_build_client.AndroidBuildClient.SCOPE,
     50                        gstorage_client.StorageClient.SCOPE,
     51                        android_compute_client.AndroidComputeClient.SCOPE])
     52 
     53 MAX_BATCH_CLEANUP_COUNT = 100
     54 
     55 
     56 class AndroidVirtualDevicePool(object):
     57     """A class that manages a pool of devices."""
     58 
     59     def __init__(self, cfg, devices=None):
     60         self._devices = devices or []
     61         self._cfg = cfg
     62         credentials = auth.CreateCredentials(cfg, ALL_SCOPES)
     63         self._build_client = android_build_client.AndroidBuildClient(
     64             credentials)
     65         self._storage_client = gstorage_client.StorageClient(credentials)
     66         self._compute_client = android_compute_client.AndroidComputeClient(
     67             cfg, credentials)
     68 
     69     def _CreateGceImageWithBuildInfo(self, build_target, build_id):
     70         """Creates a Gce image using build from Launch Control.
     71 
     72         Clone avd-system.tar.gz of a build to a cache storage bucket
     73         using launch control api. And then create a Gce image.
     74 
     75         Args:
     76             build_target: Target name, e.g. "gce_x86-userdebug"
     77             build_id: Build id, a string, e.g. "2263051", "P2804227"
     78 
     79         Returns:
     80             String, name of the Gce image that has been created.
     81         """
     82         logger.info("Creating a new gce image using build: build_id %s, "
     83                     "build_target %s", build_id, build_target)
     84         disk_image_id = utils.GenerateUniqueName(
     85             suffix=self._cfg.disk_image_name)
     86         self._build_client.CopyTo(
     87             build_target,
     88             build_id,
     89             artifact_name=self._cfg.disk_image_name,
     90             destination_bucket=self._cfg.storage_bucket_name,
     91             destination_path=disk_image_id)
     92         disk_image_url = self._storage_client.GetUrl(
     93             self._cfg.storage_bucket_name, disk_image_id)
     94         try:
     95             image_name = self._compute_client.GenerateImageName(build_target,
     96                                                                 build_id)
     97             self._compute_client.CreateImage(image_name=image_name,
     98                                              source_uri=disk_image_url)
     99         finally:
    100             self._storage_client.Delete(self._cfg.storage_bucket_name,
    101                                         disk_image_id)
    102         return image_name
    103 
    104     def _CreateGceImageWithLocalFile(self, local_disk_image):
    105         """Create a Gce image with a local image file.
    106 
    107         The local disk image can be either a tar.gz file or a
    108         raw vmlinux image.
    109         e.g.  /tmp/avd-system.tar.gz or /tmp/android_system_disk_syslinux.img
    110         If a raw vmlinux image is provided, it will be archived into a tar.gz file.
    111 
    112         The final tar.gz file will be uploaded to a cache bucket in storage.
    113 
    114         Args:
    115             local_disk_image: string, path to a local disk image,
    116 
    117         Returns:
    118             String, name of the Gce image that has been created.
    119 
    120         Raises:
    121             DriverError: if a file with an unexpected extension is given.
    122         """
    123         logger.info("Creating a new gce image from a local file %s",
    124                     local_disk_image)
    125         with utils.TempDir() as tempdir:
    126             if local_disk_image.endswith(self._cfg.disk_raw_image_extension):
    127                 dest_tar_file = os.path.join(tempdir,
    128                                              self._cfg.disk_image_name)
    129                 utils.MakeTarFile(
    130                     src_dict={local_disk_image: self._cfg.disk_raw_image_name},
    131                     dest=dest_tar_file)
    132                 local_disk_image = dest_tar_file
    133             elif not local_disk_image.endswith(self._cfg.disk_image_extension):
    134                 raise errors.DriverError(
    135                     "Wrong local_disk_image type, must be a *%s file or *%s file"
    136                     % (self._cfg.disk_raw_image_extension,
    137                        self._cfg.disk_image_extension))
    138 
    139             disk_image_id = utils.GenerateUniqueName(
    140                 suffix=self._cfg.disk_image_name)
    141             self._storage_client.Upload(
    142                 local_src=local_disk_image,
    143                 bucket_name=self._cfg.storage_bucket_name,
    144                 object_name=disk_image_id,
    145                 mime_type=self._cfg.disk_image_mime_type)
    146         disk_image_url = self._storage_client.GetUrl(
    147             self._cfg.storage_bucket_name, disk_image_id)
    148         try:
    149             image_name = self._compute_client.GenerateImageName()
    150             self._compute_client.CreateImage(image_name=image_name,
    151                                              source_uri=disk_image_url)
    152         finally:
    153             self._storage_client.Delete(self._cfg.storage_bucket_name,
    154                                         disk_image_id)
    155         return image_name
    156 
    157     def CreateDevices(self,
    158                       num,
    159                       build_target=None,
    160                       build_id=None,
    161                       gce_image=None,
    162                       local_disk_image=None,
    163                       cleanup=True,
    164                       extra_data_disk_size_gb=None,
    165                       precreated_data_image=None):
    166         """Creates |num| devices for given build_target and build_id.
    167 
    168         - If gce_image is provided, will use it to create an instance.
    169         - If local_disk_image is provided, will upload it to a temporary
    170           caching storage bucket which is defined by user as |storage_bucket_name|
    171           And then create an gce image with it; and then create an instance.
    172         - If build_target and build_id are provided, will clone the disk image
    173           via launch control to the temporary caching storage bucket.
    174           And then create an gce image with it; and then create an instance.
    175 
    176         Args:
    177             num: Number of devices to create.
    178             build_target: Target name, e.g. "gce_x86-userdebug"
    179             build_id: Build id, a string, e.g. "2263051", "P2804227"
    180             gce_image: string, if given, will use this image
    181                        instead of creating a new one.
    182                        implies cleanup=False.
    183             local_disk_image: string, path to a local disk image, e.g.
    184                               /tmp/avd-system.tar.gz
    185             cleanup: boolean, if True clean up compute engine image after creating
    186                      the instance.
    187             extra_data_disk_size_gb: Integer, size of extra disk, or None.
    188             precreated_data_image: A string, the image to use for the extra disk.
    189 
    190         Raises:
    191             errors.DriverError: If no source is specified for image creation.
    192         """
    193         if gce_image:
    194             # GCE image is provided, we can directly move to instance creation.
    195             logger.info("Using existing gce image %s", gce_image)
    196             image_name = gce_image
    197             cleanup = False
    198         elif local_disk_image:
    199             image_name = self._CreateGceImageWithLocalFile(local_disk_image)
    200         elif build_target and build_id:
    201             image_name = self._CreateGceImageWithBuildInfo(build_target,
    202                                                            build_id)
    203         else:
    204             raise errors.DriverError(
    205                 "Invalid image source, must specify one of the following: gce_image, "
    206                 "local_disk_image, or build_target and build id.")
    207 
    208         # Create GCE instances.
    209         try:
    210             for _ in range(num):
    211                 instance = self._compute_client.GenerateInstanceName(
    212                     build_target, build_id)
    213                 extra_disk_name = None
    214                 if extra_data_disk_size_gb > 0:
    215                     extra_disk_name = self._compute_client.GetDataDiskName(
    216                         instance)
    217                     self._compute_client.CreateDisk(extra_disk_name,
    218                                                     precreated_data_image,
    219                                                     extra_data_disk_size_gb)
    220                 self._compute_client.CreateInstance(instance, image_name,
    221                                                     extra_disk_name)
    222                 ip = self._compute_client.GetInstanceIP(instance)
    223                 self.devices.append(avd.AndroidVirtualDevice(
    224                     ip=ip, instance_name=instance))
    225         finally:
    226             if cleanup:
    227                 self._compute_client.DeleteImage(image_name)
    228 
    229     def DeleteDevices(self):
    230         """Deletes devices.
    231 
    232         Returns:
    233             A tuple, (deleted, failed, error_msgs)
    234             deleted: A list of names of instances that have been deleted.
    235             faild: A list of names of instances that we fail to delete.
    236             error_msgs: A list of failure messages.
    237         """
    238         instance_names = [device.instance_name for device in self._devices]
    239         return self._compute_client.DeleteInstances(instance_names,
    240                                                     self._cfg.zone)
    241 
    242     def WaitForBoot(self):
    243         """Waits for all devices to boot up.
    244 
    245         Returns:
    246             A dictionary that contains all the failures.
    247             The key is the name of the instance that fails to boot,
    248             the value is an errors.DeviceBootTimeoutError object.
    249         """
    250         failures = {}
    251         for device in self._devices:
    252             try:
    253                 self._compute_client.WaitForBoot(device.instance_name)
    254             except errors.DeviceBootTimeoutError as e:
    255                 failures[device.instance_name] = e
    256         return failures
    257 
    258     @property
    259     def devices(self):
    260         """Returns a list of devices in the pool.
    261 
    262         Returns:
    263             A list of devices in the pool.
    264         """
    265         return self._devices
    266 
    267 
    268 def _AddDeletionResultToReport(report_obj, deleted, failed, error_msgs,
    269                                resource_name):
    270     """Adds deletion result to a Report object.
    271 
    272     This function will add the following to report.data.
    273       "deleted": [
    274          {"name": "resource_name", "type": "resource_name"},
    275        ],
    276       "failed": [
    277          {"name": "resource_name", "type": "resource_name"},
    278        ],
    279     This function will append error_msgs to report.errors.
    280 
    281     Args:
    282         report_obj: A Report object.
    283         deleted: A list of names of the resources that have been deleted.
    284         failed: A list of names of the resources that we fail to delete.
    285         error_msgs: A list of error message strings to be added to the report.
    286         resource_name: A string, representing the name of the resource.
    287     """
    288     for name in deleted:
    289         report_obj.AddData(key="deleted",
    290                            value={"name": name,
    291                                   "type": resource_name})
    292     for name in failed:
    293         report_obj.AddData(key="failed",
    294                            value={"name": name,
    295                                   "type": resource_name})
    296     report_obj.AddErrors(error_msgs)
    297     if failed or error_msgs:
    298         report_obj.SetStatus(report.Status.FAIL)
    299 
    300 
    301 def _FetchSerialLogsFromDevices(compute_client, instance_names, output_file,
    302                                 port):
    303     """Fetch serial logs from a port for a list of devices to a local file.
    304 
    305     Args:
    306         compute_client: An object of android_compute_client.AndroidComputeClient
    307         instance_names: A list of instance names.
    308         output_file: A path to a file ending with "tar.gz"
    309         port: The number of serial port to read from, 0 for serial output, 1 for
    310               logcat.
    311     """
    312     with utils.TempDir() as tempdir:
    313         src_dict = {}
    314         for instance_name in instance_names:
    315             serial_log = compute_client.GetSerialPortOutput(
    316                 instance=instance_name, port=port)
    317             file_name = "%s.log" % instance_name
    318             file_path = os.path.join(tempdir, file_name)
    319             src_dict[file_path] = file_name
    320             with open(file_path, "w") as f:
    321                 f.write(serial_log.encode("utf-8"))
    322         utils.MakeTarFile(src_dict, output_file)
    323 
    324 
    325 def _CreateSshKeyPairIfNecessary(cfg):
    326     """Create ssh key pair if necessary.
    327 
    328     Args:
    329         cfg: An Acloudconfig instance.
    330 
    331     Raises:
    332         error.DriverError: If it falls into an unexpected condition.
    333     """
    334     if not cfg.ssh_public_key_path:
    335         logger.warning("ssh_public_key_path is not specified in acloud config. "
    336                        "Project-wide public key will "
    337                        "be used when creating AVD instances. "
    338                        "Please ensure you have the correct private half of "
    339                        "a project-wide public key if you want to ssh into the "
    340                        "instances after creation.")
    341     elif cfg.ssh_public_key_path and not cfg.ssh_private_key_path:
    342         logger.warning("Only ssh_public_key_path is specified in acloud config,"
    343                        " but ssh_private_key_path is missing. "
    344                        "Please ensure you have the correct private half "
    345                        "if you want to ssh into the instances after creation.")
    346     elif cfg.ssh_public_key_path and cfg.ssh_private_key_path:
    347         utils.CreateSshKeyPairIfNotExist(
    348                 cfg.ssh_private_key_path, cfg.ssh_public_key_path)
    349     else:
    350         # Should never reach here.
    351         raise errors.DriverError(
    352                 "Unexpected error in _CreateSshKeyPairIfNecessary")
    353 
    354 
    355 def CreateAndroidVirtualDevices(cfg,
    356                                 build_target=None,
    357                                 build_id=None,
    358                                 num=1,
    359                                 gce_image=None,
    360                                 local_disk_image=None,
    361                                 cleanup=True,
    362                                 serial_log_file=None,
    363                                 logcat_file=None):
    364     """Creates one or multiple android devices.
    365 
    366     Args:
    367         cfg: An AcloudConfig instance.
    368         build_target: Target name, e.g. "gce_x86-userdebug"
    369         build_id: Build id, a string, e.g. "2263051", "P2804227"
    370         num: Number of devices to create.
    371         gce_image: string, if given, will use this gce image
    372                    instead of creating a new one.
    373                    implies cleanup=False.
    374         local_disk_image: string, path to a local disk image, e.g.
    375                           /tmp/avd-system.tar.gz
    376         cleanup: boolean, if True clean up compute engine image and
    377                  disk image in storage after creating the instance.
    378         serial_log_file: A path to a file where serial output should
    379                          be saved to.
    380         logcat_file: A path to a file where logcat logs should be saved.
    381 
    382     Returns:
    383         A Report instance.
    384     """
    385     r = report.Report(command="create")
    386     credentials = auth.CreateCredentials(cfg, ALL_SCOPES)
    387     compute_client = android_compute_client.AndroidComputeClient(cfg,
    388                                                                  credentials)
    389     try:
    390         _CreateSshKeyPairIfNecessary(cfg)
    391         device_pool = AndroidVirtualDevicePool(cfg)
    392         device_pool.CreateDevices(
    393             num,
    394             build_target,
    395             build_id,
    396             gce_image,
    397             local_disk_image,
    398             cleanup,
    399             extra_data_disk_size_gb=cfg.extra_data_disk_size_gb,
    400             precreated_data_image=cfg.precreated_data_image_map.get(
    401                 cfg.extra_data_disk_size_gb))
    402         failures = device_pool.WaitForBoot()
    403         # Write result to report.
    404         for device in device_pool.devices:
    405             device_dict = {"ip": device.ip,
    406                            "instance_name": device.instance_name}
    407             if device.instance_name in failures:
    408                 r.AddData(key="devices_failing_boot", value=device_dict)
    409                 r.AddError(str(failures[device.instance_name]))
    410             else:
    411                 r.AddData(key="devices", value=device_dict)
    412         if failures:
    413             r.SetStatus(report.Status.BOOT_FAIL)
    414         else:
    415             r.SetStatus(report.Status.SUCCESS)
    416 
    417         # Dump serial and logcat logs.
    418         if serial_log_file:
    419             _FetchSerialLogsFromDevices(
    420                 compute_client,
    421                 instance_names=[d.instance_name for d in device_pool.devices],
    422                 port=constants.DEFAULT_SERIAL_PORT,
    423                 output_file=serial_log_file)
    424         if logcat_file:
    425             _FetchSerialLogsFromDevices(
    426                 compute_client,
    427                 instance_names=[d.instance_name for d in device_pool.devices],
    428                 port=constants.LOGCAT_SERIAL_PORT,
    429                 output_file=logcat_file)
    430     except errors.DriverError as e:
    431         r.AddError(str(e))
    432         r.SetStatus(report.Status.FAIL)
    433     return r
    434 
    435 
    436 def DeleteAndroidVirtualDevices(cfg, instance_names):
    437     """Deletes android devices.
    438 
    439     Args:
    440         cfg: An AcloudConfig instance.
    441         instance_names: A list of names of the instances to delete.
    442 
    443     Returns:
    444         A Report instance.
    445     """
    446     r = report.Report(command="delete")
    447     credentials = auth.CreateCredentials(cfg, ALL_SCOPES)
    448     compute_client = android_compute_client.AndroidComputeClient(cfg,
    449                                                                  credentials)
    450     try:
    451         deleted, failed, error_msgs = compute_client.DeleteInstances(
    452             instance_names, cfg.zone)
    453         _AddDeletionResultToReport(
    454             r, deleted,
    455             failed, error_msgs,
    456             resource_name="instance")
    457         if r.status == report.Status.UNKNOWN:
    458             r.SetStatus(report.Status.SUCCESS)
    459     except errors.DriverError as e:
    460         r.AddError(str(e))
    461         r.SetStatus(report.Status.FAIL)
    462     return r
    463 
    464 
    465 def _FindOldItems(items, cut_time, time_key):
    466     """Finds items from |items| whose timestamp is earlier than |cut_time|.
    467 
    468     Args:
    469         items: A list of items. Each item is a dictionary represent
    470                the properties of the item. It should has a key as noted
    471                by time_key.
    472         cut_time: A datetime.datatime object.
    473         time_key: String, key for the timestamp.
    474 
    475     Returns:
    476         A list of those from |items| whose timestamp is earlier than cut_time.
    477     """
    478     cleanup_list = []
    479     for item in items:
    480         t = dateutil.parser.parse(item[time_key])
    481         if t < cut_time:
    482             cleanup_list.append(item)
    483     return cleanup_list
    484 
    485 
    486 def Cleanup(cfg, expiration_mins):
    487     """Cleans up stale gce images, gce instances, and disk images in storage.
    488 
    489     Args:
    490         cfg: An AcloudConfig instance.
    491         expiration_mins: Integer, resources older than |expiration_mins| will
    492                          be cleaned up.
    493 
    494     Returns:
    495         A Report instance.
    496     """
    497     r = report.Report(command="cleanup")
    498     try:
    499         cut_time = (datetime.datetime.now(dateutil.tz.tzlocal()) -
    500                     datetime.timedelta(minutes=expiration_mins))
    501         logger.info(
    502             "Cleaning up any gce images/instances and cached build artifacts."
    503             "in google storage that are older than %s", cut_time)
    504         credentials = auth.CreateCredentials(cfg, ALL_SCOPES)
    505         compute_client = android_compute_client.AndroidComputeClient(
    506             cfg, credentials)
    507         storage_client = gstorage_client.StorageClient(credentials)
    508 
    509         # Cleanup expired instances
    510         items = compute_client.ListInstances(zone=cfg.zone)
    511         cleanup_list = [
    512             item["name"]
    513             for item in _FindOldItems(items, cut_time, "creationTimestamp")
    514         ]
    515         logger.info("Found expired instances: %s", cleanup_list)
    516         for i in range(0, len(cleanup_list), MAX_BATCH_CLEANUP_COUNT):
    517             result = compute_client.DeleteInstances(
    518                 instances=cleanup_list[i:i + MAX_BATCH_CLEANUP_COUNT],
    519                 zone=cfg.zone)
    520             _AddDeletionResultToReport(r, *result, resource_name="instance")
    521 
    522         # Cleanup expired images
    523         items = compute_client.ListImages()
    524         skip_list = cfg.precreated_data_image_map.viewvalues()
    525         cleanup_list = [
    526             item["name"]
    527             for item in _FindOldItems(items, cut_time, "creationTimestamp")
    528             if item["name"] not in skip_list
    529         ]
    530         logger.info("Found expired images: %s", cleanup_list)
    531         for i in range(0, len(cleanup_list), MAX_BATCH_CLEANUP_COUNT):
    532             result = compute_client.DeleteImages(
    533                 image_names=cleanup_list[i:i + MAX_BATCH_CLEANUP_COUNT])
    534             _AddDeletionResultToReport(r, *result, resource_name="image")
    535 
    536         # Cleanup expired disks
    537         # Disks should have been attached to instances with autoDelete=True.
    538         # However, sometimes disks may not be auto deleted successfully.
    539         items = compute_client.ListDisks(zone=cfg.zone)
    540         cleanup_list = [
    541             item["name"]
    542             for item in _FindOldItems(items, cut_time, "creationTimestamp")
    543             if not item.get("users")
    544         ]
    545         logger.info("Found expired disks: %s", cleanup_list)
    546         for i in range(0, len(cleanup_list), MAX_BATCH_CLEANUP_COUNT):
    547             result = compute_client.DeleteDisks(
    548                 disk_names=cleanup_list[i:i + MAX_BATCH_CLEANUP_COUNT],
    549                 zone=cfg.zone)
    550             _AddDeletionResultToReport(r, *result, resource_name="disk")
    551 
    552         # Cleanup expired google storage
    553         items = storage_client.List(bucket_name=cfg.storage_bucket_name)
    554         cleanup_list = [
    555             item["name"]
    556             for item in _FindOldItems(items, cut_time, "timeCreated")
    557         ]
    558         logger.info("Found expired cached artifacts: %s", cleanup_list)
    559         for i in range(0, len(cleanup_list), MAX_BATCH_CLEANUP_COUNT):
    560             result = storage_client.DeleteFiles(
    561                 bucket_name=cfg.storage_bucket_name,
    562                 object_names=cleanup_list[i:i + MAX_BATCH_CLEANUP_COUNT])
    563             _AddDeletionResultToReport(
    564                 r, *result, resource_name="cached_build_artifact")
    565 
    566         # Everything succeeded, write status to report.
    567         if r.status == report.Status.UNKNOWN:
    568             r.SetStatus(report.Status.SUCCESS)
    569     except errors.DriverError as e:
    570         r.AddError(str(e))
    571         r.SetStatus(report.Status.FAIL)
    572     return r
    573 
    574 
    575 def AddSshRsa(cfg, user, ssh_rsa_path):
    576     """Add public ssh rsa key to the project.
    577 
    578     Args:
    579         cfg: An AcloudConfig instance.
    580         user: the name of the user which the key belongs to.
    581         ssh_rsa_path: The absolute path to public rsa key.
    582 
    583     Returns:
    584         A Report instance.
    585     """
    586     r = report.Report(command="sshkey")
    587     try:
    588         credentials = auth.CreateCredentials(cfg, ALL_SCOPES)
    589         compute_client = android_compute_client.AndroidComputeClient(
    590             cfg, credentials)
    591         compute_client.AddSshRsa(user, ssh_rsa_path)
    592         r.SetStatus(report.Status.SUCCESS)
    593     except errors.DriverError as e:
    594         r.AddError(str(e))
    595         r.SetStatus(report.Status.FAIL)
    596     return r
    597 
    598 
    599 def CheckAccess(cfg):
    600     """Check if user has access.
    601 
    602     Args:
    603          cfg: An AcloudConfig instance.
    604     """
    605     credentials = auth.CreateCredentials(cfg, ALL_SCOPES)
    606     compute_client = android_compute_client.AndroidComputeClient(
    607             cfg, credentials)
    608     logger.info("Checking if user has access to project %s", cfg.project)
    609     if not compute_client.CheckAccess():
    610         logger.error("User does not have access to project %s", cfg.project)
    611         # Print here so that command line user can see it.
    612         print "Looks like you do not have access to %s. " % cfg.project
    613         if cfg.project in cfg.no_project_access_msg_map:
    614             print cfg.no_project_access_msg_map[cfg.project]
    615