Home | History | Annotate | Download | only in lib
      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 """A client that manages Android compute engine instances.
     18 
     19 ** AndroidComputeClient **
     20 
     21 AndroidComputeClient derives from ComputeClient. It manges a google
     22 compute engine project that is setup for running Android instances.
     23 It knows how to create android GCE images and instances.
     24 
     25 ** Class hierarchy **
     26 
     27   base_cloud_client.BaseCloudApiClient
     28                 ^
     29                 |
     30        gcompute_client.ComputeClient
     31                 ^
     32                 |
     33     gcompute_client.AndroidComputeClient
     34 
     35 TODO(fdeng):
     36   Merge caci/framework/gce_manager.py
     37   with this module, update callers of gce_manager.py to use this module.
     38 """
     39 
     40 import getpass
     41 import logging
     42 import os
     43 import uuid
     44 
     45 from acloud.internal.lib import gcompute_client
     46 from acloud.internal.lib import utils
     47 from acloud.public import errors
     48 
     49 logger = logging.getLogger(__name__)
     50 
     51 
     52 class AndroidComputeClient(gcompute_client.ComputeClient):
     53     """Client that manages Anadroid Virtual Device."""
     54 
     55     INSTANCE_NAME_FMT = "ins-{uuid}-{build_id}-{build_target}"
     56     IMAGE_NAME_FMT = "img-{uuid}-{build_id}-{build_target}"
     57     DATA_DISK_NAME_FMT = "data-{instance}"
     58     BOOT_COMPLETED_MSG = "VIRTUAL_DEVICE_BOOT_COMPLETED"
     59     BOOT_TIMEOUT_SECS = 5 * 60  # 5 mins, usually it should take ~2 mins
     60     BOOT_CHECK_INTERVAL_SECS = 10
     61     NAME_LENGTH_LIMIT = 63
     62     # If the generated name ends with '-', replace it with REPLACER.
     63     REPLACER = "e"
     64 
     65     def __init__(self, acloud_config, oauth2_credentials):
     66         """Initialize.
     67 
     68         Args:
     69             acloud_config: An AcloudConfig object.
     70             oauth2_credentials: An oauth2client.OAuth2Credentials instance.
     71         """
     72         super(AndroidComputeClient, self).__init__(acloud_config,
     73                                                    oauth2_credentials)
     74         self._zone = acloud_config.zone
     75         self._machine_type = acloud_config.machine_type
     76         self._min_machine_size = acloud_config.min_machine_size
     77         self._network = acloud_config.network
     78         self._orientation = acloud_config.orientation
     79         self._resolution = acloud_config.resolution
     80         self._metadata = acloud_config.metadata_variable.copy()
     81         self._ssh_public_key_path = acloud_config.ssh_public_key_path
     82 
     83     @classmethod
     84     def _FormalizeName(cls, name):
     85         """Formalize the name to comply with RFC1035.
     86 
     87         The name must be 1-63 characters long and match the regular expression
     88         [a-z]([-a-z0-9]*[a-z0-9])? which means the first character must be a
     89         lowercase letter, and all following characters must be a dash,
     90         lowercase letter, or digit, except the last character, which cannot be
     91         a dash.
     92 
     93         Args:
     94           name: A string.
     95 
     96         Returns:
     97           name: A string that complies with RFC1035.
     98         """
     99         name = name.replace("_", "-").lower()
    100         name = name[:cls.NAME_LENGTH_LIMIT]
    101         if name[-1] == "-":
    102           name = name[:-1] + cls.REPLACER
    103         return name
    104 
    105     def _CheckMachineSize(self):
    106         """Check machine size.
    107 
    108         Check if the desired machine type |self._machine_type| meets
    109         the requirement of minimum machine size specified as
    110         |self._min_machine_size|.
    111 
    112         Raises:
    113             errors.DriverError: if check fails.
    114         """
    115         if self.CompareMachineSize(self._machine_type, self._min_machine_size,
    116                                    self._zone) < 0:
    117             raise errors.DriverError(
    118                 "%s does not meet the minimum required machine size %s" %
    119                 (self._machine_type, self._min_machine_size))
    120 
    121     @classmethod
    122     def GenerateImageName(cls, build_target=None, build_id=None):
    123         """Generate an image name given build_target, build_id.
    124 
    125         Args:
    126             build_target: Target name, e.g. "gce_x86-userdebug"
    127             build_id: Build id, a string, e.g. "2263051", "P2804227"
    128 
    129         Returns:
    130             A string, representing image name.
    131         """
    132         if not build_target and not build_id:
    133             return "image-" + uuid.uuid4().hex
    134         name = cls.IMAGE_NAME_FMT.format(build_target=build_target,
    135                                          build_id=build_id,
    136                                          uuid=uuid.uuid4().hex[:8])
    137         return cls._FormalizeName(name)
    138 
    139     @classmethod
    140     def GetDataDiskName(cls, instance):
    141         """Get data disk name for an instance.
    142 
    143         Args:
    144             instance: An instance_name.
    145 
    146         Returns:
    147             The corresponding data disk name.
    148         """
    149         name = cls.DATA_DISK_NAME_FMT.format(instance=instance)
    150         return cls._FormalizeName(name)
    151 
    152     @classmethod
    153     def GenerateInstanceName(cls, build_target=None, build_id=None):
    154         """Generate an instance name given build_target, build_id.
    155 
    156         Target is not used as instance name has a length limit.
    157 
    158         Args:
    159             build_target: Target name, e.g. "gce_x86-userdebug"
    160             build_id: Build id, a string, e.g. "2263051", "P2804227"
    161 
    162         Returns:
    163             A string, representing instance name.
    164         """
    165         if not build_target and not build_id:
    166             return "instance-" + uuid.uuid4().hex
    167         name = cls.INSTANCE_NAME_FMT.format(
    168             build_target=build_target,
    169             build_id=build_id,
    170             uuid=uuid.uuid4().hex[:8]).replace("_", "-").lower()
    171         return cls._FormalizeName(name)
    172 
    173     def CreateDisk(self, disk_name, source_image, size_gb):
    174         """Create a gce disk.
    175 
    176         Args:
    177             disk_name: A string.
    178             source_image: A string, name to the image name.
    179             size_gb: Integer, size in gigabytes.
    180         """
    181         if self.CheckDiskExists(disk_name, self._zone):
    182             raise errors.DriverError(
    183                 "Failed to create disk %s, already exists." % disk_name)
    184         if source_image and not self.CheckImageExists(source_image):
    185             raise errors.DriverError(
    186                 "Failed to create disk %s, source image %s does not exist." %
    187                 (disk_name, source_image))
    188         super(AndroidComputeClient, self).CreateDisk(disk_name,
    189                                                      source_image=source_image,
    190                                                      size_gb=size_gb,
    191                                                      zone=self._zone)
    192 
    193     def CreateImage(self, image_name, source_uri):
    194         """Create a gce image.
    195 
    196         Args:
    197             image_name: String, name of the image.
    198             source_uri: A full Google Storage URL to the disk image.
    199                         e.g. "https://storage.googleapis.com/my-bucket/
    200                               avd-system-2243663.tar.gz"
    201         """
    202         if not self.CheckImageExists(image_name):
    203             super(AndroidComputeClient, self).CreateImage(image_name,
    204                                                           source_uri)
    205 
    206     def _GetExtraDiskArgs(self, extra_disk_name):
    207         """Get extra disk arg for given disk.
    208 
    209         Args:
    210             extra_disk_name: Name of the disk.
    211 
    212         Returns:
    213             A dictionary of disk args.
    214         """
    215         return [{
    216             "type": "PERSISTENT",
    217             "mode": "READ_WRITE",
    218             "source": "projects/%s/zones/%s/disks/%s" % (
    219                 self._project, self._zone, extra_disk_name),
    220             "autoDelete": True,
    221             "boot": False,
    222             "interface": "SCSI",
    223             "deviceName": extra_disk_name,
    224         }]
    225 
    226     @staticmethod
    227     def _LoadSshPublicKey(ssh_public_key_path):
    228         """Load the content of ssh public key from a file.
    229 
    230         Args:
    231             ssh_public_key_path: String, path to the public key file.
    232                                E.g. ~/.ssh/acloud_rsa.pub
    233         Returns:
    234             String, content of the file.
    235 
    236         Raises:
    237             errors.DriverError if the public key file does not exist
    238             or the content is not valid.
    239         """
    240         key_path = os.path.expanduser(ssh_public_key_path)
    241         if not os.path.exists(key_path):
    242             raise errors.DriverError(
    243                 "SSH public key file %s does not exist." % key_path)
    244 
    245         with open(key_path) as f:
    246             rsa = f.read()
    247             rsa = rsa.strip() if rsa else rsa
    248             utils.VerifyRsaPubKey(rsa)
    249         return rsa
    250 
    251     def CreateInstance(self, instance, image_name, extra_disk_name=None):
    252         """Create a gce instance given an gce image.
    253 
    254         Args:
    255             instance: instance name.
    256             image_name: A string, the name of the GCE image.
    257         """
    258         self._CheckMachineSize()
    259         disk_args = self._GetDiskArgs(instance, image_name)
    260         if extra_disk_name:
    261             disk_args.extend(self._GetExtraDiskArgs(extra_disk_name))
    262         metadata = self._metadata.copy()
    263         metadata["cfg_sta_display_resolution"] = self._resolution
    264         metadata["t_force_orientation"] = self._orientation
    265 
    266         # Add per-instance ssh key
    267         if self._ssh_public_key_path:
    268             rsa = self._LoadSshPublicKey(self._ssh_public_key_path)
    269             logger.info("ssh_public_key_path is specified in config: %s, "
    270                         "will add the key to the instance.",
    271                         self._ssh_public_key_path)
    272             metadata["sshKeys"] = "%s:%s" % (getpass.getuser(), rsa)
    273         else:
    274             logger.warning(
    275                 "ssh_public_key_path is not specified in config, "
    276                 "only project-wide key will be effective.")
    277 
    278         super(AndroidComputeClient, self).CreateInstance(
    279             instance, image_name, self._machine_type, metadata, self._network,
    280             self._zone, disk_args)
    281 
    282     def CheckBoot(self, instance):
    283         """Check once to see if boot completes.
    284 
    285         Args:
    286             instance: string, instance name.
    287 
    288         Returns:
    289             True if the BOOT_COMPLETED_MSG appears in serial port output.
    290             otherwise False.
    291         """
    292         try:
    293             return self.BOOT_COMPLETED_MSG in self.GetSerialPortOutput(
    294                 instance=instance, port=1)
    295         except errors.HttpError as e:
    296             if e.code == 400:
    297                 logger.debug("CheckBoot: Instance is not ready yet %s",
    298                               str(e))
    299                 return False
    300             raise
    301 
    302     def WaitForBoot(self, instance):
    303         """Wait for boot to completes or hit timeout.
    304 
    305         Args:
    306             instance: string, instance name.
    307         """
    308         logger.info("Waiting for instance to boot up: %s", instance)
    309         timeout_exception = errors.DeviceBootTimeoutError(
    310             "Device %s did not finish on boot within timeout (%s secs)" %
    311             (instance, self.BOOT_TIMEOUT_SECS)),
    312         utils.PollAndWait(func=self.CheckBoot,
    313                           expected_return=True,
    314                           timeout_exception=timeout_exception,
    315                           timeout_secs=self.BOOT_TIMEOUT_SECS,
    316                           sleep_interval_secs=self.BOOT_CHECK_INTERVAL_SECS,
    317                           instance=instance)
    318         logger.info("Instance boot completed: %s", instance)
    319 
    320     def GetInstanceIP(self, instance):
    321         """Get Instance IP given instance name.
    322 
    323         Args:
    324             instance: String, representing instance name.
    325 
    326         Returns:
    327             string, IP of the instance.
    328         """
    329         return super(AndroidComputeClient, self).GetInstanceIP(instance,
    330                                                                self._zone)
    331 
    332     def GetSerialPortOutput(self, instance, port=1):
    333         """Get serial port output.
    334 
    335         Args:
    336             instance: string, instance name.
    337             port: int, which COM port to read from, 1-4, default to 1.
    338 
    339         Returns:
    340             String, contents of the output.
    341 
    342         Raises:
    343             errors.DriverError: For malformed response.
    344         """
    345         return super(AndroidComputeClient, self).GetSerialPortOutput(
    346             instance, self._zone, port)
    347 
    348     def GetInstanceNamesByIPs(self, ips):
    349         """Get Instance names by IPs.
    350 
    351         This function will go through all instances, which
    352         could be slow if there are too many instances.  However, currently
    353         GCE doesn't support search for instance by IP.
    354 
    355         Args:
    356             ips: A set of IPs.
    357 
    358         Returns:
    359             A dictionary where key is ip and value is instance name or None
    360             if instance is not found for the given IP.
    361         """
    362         return super(AndroidComputeClient, self).GetInstanceNamesByIPs(
    363             ips, self._zone)
    364