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"""A client that manages Android compute engine instances. 17 18** AndroidComputeClient ** 19 20AndroidComputeClient derives from ComputeClient. It manges a google 21compute engine project that is setup for running Android instances. 22It knows how to create android GCE images and instances. 23 24** Class hierarchy ** 25 26 base_cloud_client.BaseCloudApiClient 27 ^ 28 | 29 gcompute_client.ComputeClient 30 ^ 31 | 32 gcompute_client.AndroidComputeClient 33""" 34 35import getpass 36import logging 37import os 38import uuid 39 40from acloud import errors 41from acloud.internal import constants 42from acloud.internal.lib import gcompute_client 43from acloud.internal.lib import utils 44 45logger = logging.getLogger(__name__) 46 47 48class AndroidComputeClient(gcompute_client.ComputeClient): 49 """Client that manages Anadroid Virtual Device.""" 50 IMAGE_NAME_FMT = "img-{uuid}-{build_id}-{build_target}" 51 DATA_DISK_NAME_FMT = "data-{instance}" 52 BOOT_COMPLETED_MSG = "VIRTUAL_DEVICE_BOOT_COMPLETED" 53 BOOT_STARTED_MSG = "VIRTUAL_DEVICE_BOOT_STARTED" 54 BOOT_TIMEOUT_SECS = 5 * 60 # 5 mins, usually it should take ~2 mins 55 BOOT_CHECK_INTERVAL_SECS = 10 56 57 OPERATION_TIMEOUT_SECS = 20 * 60 # Override parent value, 20 mins 58 59 NAME_LENGTH_LIMIT = 63 60 # If the generated name ends with '-', replace it with REPLACER. 61 REPLACER = "e" 62 63 def __init__(self, acloud_config, oauth2_credentials): 64 """Initialize. 65 66 Args: 67 acloud_config: An AcloudConfig object. 68 oauth2_credentials: An oauth2client.OAuth2Credentials instance. 69 """ 70 super(AndroidComputeClient, self).__init__(acloud_config, 71 oauth2_credentials) 72 self._zone = acloud_config.zone 73 self._machine_type = acloud_config.machine_type 74 self._min_machine_size = acloud_config.min_machine_size 75 self._network = acloud_config.network 76 self._orientation = acloud_config.orientation 77 self._resolution = acloud_config.resolution 78 self._metadata = acloud_config.metadata_variable.copy() 79 self._ssh_public_key_path = acloud_config.ssh_public_key_path 80 self._launch_args = acloud_config.launch_args 81 self._instance_name_pattern = acloud_config.instance_name_pattern 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. "aosp_cf_x86_phone-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( 135 build_target=build_target, 136 build_id=build_id, 137 uuid=uuid.uuid4().hex[:8]) 138 return cls._FormalizeName(name) 139 140 @classmethod 141 def GetDataDiskName(cls, instance): 142 """Get data disk name for an instance. 143 144 Args: 145 instance: An instance_name. 146 147 Returns: 148 The corresponding data disk name. 149 """ 150 name = cls.DATA_DISK_NAME_FMT.format(instance=instance) 151 return cls._FormalizeName(name) 152 153 def GenerateInstanceName(self, 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. "aosp_cf_x86_phone-userdebug" 160 build_id: Build id, a string, e.g. "2263051", "P2804227" 161 162 Returns: 163 A string, representing instance name. 164 """ 165 name = self._instance_name_pattern.format(build_target=build_target, 166 build_id=build_id, 167 uuid=uuid.uuid4().hex[:8]) 168 return self._FormalizeName(name) 169 170 def CreateDisk(self, 171 disk_name, 172 source_image, 173 size_gb, 174 zone=None, 175 source_project=None, 176 disk_type=gcompute_client.PersistentDiskType.STANDARD): 177 """Create a gce disk. 178 179 Args: 180 disk_name: String, name of disk. 181 source_image: String, name to the image name. 182 size_gb: Integer, size in gigabytes. 183 zone: String, name of the zone, e.g. us-central1-b. 184 source_project: String, required if the image is located in a different 185 project. 186 disk_type: String, a value from PersistentDiskType, STANDARD 187 for regular hard disk or SSD for solid state disk. 188 """ 189 if self.CheckDiskExists(disk_name, self._zone): 190 raise errors.DriverError( 191 "Failed to create disk %s, already exists." % disk_name) 192 if source_image and not self.CheckImageExists(source_image): 193 raise errors.DriverError( 194 "Failed to create disk %s, source image %s does not exist." % 195 (disk_name, source_image)) 196 super(AndroidComputeClient, self).CreateDisk( 197 disk_name, 198 source_image=source_image, 199 size_gb=size_gb, 200 zone=zone or self._zone) 201 202 @staticmethod 203 def _LoadSshPublicKey(ssh_public_key_path): 204 """Load the content of ssh public key from a file. 205 206 Args: 207 ssh_public_key_path: String, path to the public key file. 208 E.g. ~/.ssh/acloud_rsa.pub 209 Returns: 210 String, content of the file. 211 212 Raises: 213 errors.DriverError if the public key file does not exist 214 or the content is not valid. 215 """ 216 key_path = os.path.expanduser(ssh_public_key_path) 217 if not os.path.exists(key_path): 218 raise errors.DriverError( 219 "SSH public key file %s does not exist." % key_path) 220 221 with open(key_path) as f: 222 rsa = f.read() 223 rsa = rsa.strip() if rsa else rsa 224 utils.VerifyRsaPubKey(rsa) 225 return rsa 226 227 # pylint: disable=too-many-locals, arguments-differ 228 @utils.TimeExecute("Creating GCE Instance") 229 def CreateInstance(self, 230 instance, 231 image_name, 232 machine_type=None, 233 metadata=None, 234 network=None, 235 zone=None, 236 disk_args=None, 237 image_project=None, 238 gpu=None, 239 extra_disk_name=None, 240 labels=None, 241 avd_spec=None, 242 extra_scopes=None): 243 """Create a gce instance with a gce image. 244 245 Args: 246 instance: String, instance name. 247 image_name: String, source image used to create this disk. 248 machine_type: String, representing machine_type, 249 e.g. "n1-standard-1" 250 metadata: Dict, maps a metadata name to its value. 251 network: String, representing network name, e.g. "default" 252 zone: String, representing zone name, e.g. "us-central1-f" 253 disk_args: A list of extra disk args (strings), see _GetDiskArgs 254 for example, if None, will create a disk using the given 255 image. 256 image_project: String, name of the project where the image 257 belongs. Assume the default project if None. 258 gpu: String, type of gpu to attach. e.g. "nvidia-tesla-k80", if 259 None no gpus will be attached. For more details see: 260 https://cloud.google.com/compute/docs/gpus/add-gpus 261 extra_disk_name: String,the name of the extra disk to attach. 262 labels: Dict, will be added to the instance's labels. 263 avd_spec: AVDSpec object that tells us what we're going to create. 264 extra_scopes: List, extra scopes (strings) to be passed to the 265 instance. 266 """ 267 self._CheckMachineSize() 268 disk_args = self._GetDiskArgs(instance, image_name) 269 metadata = self._metadata.copy() 270 metadata["cfg_sta_display_resolution"] = self._resolution 271 metadata["t_force_orientation"] = self._orientation 272 metadata[constants.INS_KEY_AVD_TYPE] = avd_spec.avd_type 273 274 # Use another METADATA_DISPLAY to record resolution which will be 275 # retrieved in acloud list cmd. We try not to use cvd_01_x_res 276 # since cvd_01_xxx metadata is going to deprecated by cuttlefish. 277 metadata[constants.INS_KEY_DISPLAY] = ("%sx%s (%s)" % ( 278 avd_spec.hw_property[constants.HW_X_RES], 279 avd_spec.hw_property[constants.HW_Y_RES], 280 avd_spec.hw_property[constants.HW_ALIAS_DPI])) 281 282 # Add per-instance ssh key 283 if self._ssh_public_key_path: 284 rsa = self._LoadSshPublicKey(self._ssh_public_key_path) 285 logger.info( 286 "ssh_public_key_path is specified in config: %s, " 287 "will add the key to the instance.", self._ssh_public_key_path) 288 metadata["sshKeys"] = "%s:%s" % (getpass.getuser(), rsa) 289 else: 290 logger.warning("ssh_public_key_path is not specified in config, " 291 "only project-wide key will be effective.") 292 293 # Add labels for giving the instances ability to be filter for 294 # acloud list/delete cmds. 295 labels = {constants.LABEL_CREATE_BY: getpass.getuser()} 296 297 super(AndroidComputeClient, self).CreateInstance( 298 instance, image_name, self._machine_type, metadata, self._network, 299 self._zone, disk_args, image_project, gpu, extra_disk_name, 300 labels=labels, extra_scopes=extra_scopes) 301 302 def CheckBootFailure(self, serial_out, instance): 303 """Determine if serial output has indicated any boot failure. 304 305 Subclass has to define this function to detect failures 306 in the boot process 307 308 Args: 309 serial_out: string 310 instance: string, instance name. 311 312 Raises: 313 Raises errors.DeviceBootError exception if a failure is detected. 314 """ 315 pass 316 317 def CheckBoot(self, instance): 318 """Check once to see if boot completes. 319 320 Args: 321 instance: string, instance name. 322 323 Returns: 324 True if the BOOT_COMPLETED_MSG or BOOT_STARTED_MSG appears in serial 325 port output, otherwise False. 326 """ 327 try: 328 serial_out = self.GetSerialPortOutput(instance=instance, port=1) 329 self.CheckBootFailure(serial_out, instance) 330 return ((self.BOOT_COMPLETED_MSG in serial_out) 331 or (self.BOOT_STARTED_MSG in serial_out)) 332 except errors.HttpError as e: 333 if e.code == 400: 334 logger.debug("CheckBoot: Instance is not ready yet %s", str(e)) 335 return False 336 raise 337 338 def WaitForBoot(self, instance): 339 """Wait for boot to completes or hit timeout. 340 341 Args: 342 instance: string, instance name. 343 """ 344 logger.info("Waiting for instance to boot up: %s", instance) 345 timeout_exception = errors.DeviceBootTimeoutError( 346 "Device %s did not finish on boot within timeout (%s secs)" % 347 (instance, self.BOOT_TIMEOUT_SECS)), 348 utils.PollAndWait( 349 func=self.CheckBoot, 350 expected_return=True, 351 timeout_exception=timeout_exception, 352 timeout_secs=self.BOOT_TIMEOUT_SECS, 353 sleep_interval_secs=self.BOOT_CHECK_INTERVAL_SECS, 354 instance=instance) 355 logger.info("Instance boot completed: %s", instance) 356 357 def GetInstanceIP(self, instance, zone=None): 358 """Get Instance IP given instance name. 359 360 Args: 361 instance: String, representing instance name. 362 zone: String, representing zone name, e.g. "us-central1-f" 363 364 Returns: 365 NamedTuple of (internal, external) IP of the instance. 366 """ 367 return super(AndroidComputeClient, self).GetInstanceIP( 368 instance, zone or self._zone) 369 370 def GetSerialPortOutput(self, instance, zone=None, port=1): 371 """Get serial port output. 372 373 Args: 374 instance: string, instance name. 375 zone: String, representing zone name, e.g. "us-central1-f" 376 port: int, which COM port to read from, 1-4, default to 1. 377 378 Returns: 379 String, contents of the output. 380 381 Raises: 382 errors.DriverError: For malformed response. 383 """ 384 return super(AndroidComputeClient, self).GetSerialPortOutput( 385 instance, zone or self._zone, port) 386 387 def GetInstanceNamesByIPs(self, ips, zone=None): 388 """Get Instance names by IPs. 389 390 This function will go through all instances, which 391 could be slow if there are too many instances. However, currently 392 GCE doesn't support search for instance by IP. 393 394 Args: 395 ips: A set of IPs. 396 zone: String, representing zone name, e.g. "us-central1-f" 397 398 Returns: 399 A dictionary where key is ip and value is instance name or None 400 if instance is not found for the given IP. 401 """ 402 return super(AndroidComputeClient, self).GetInstanceNamesByIPs( 403 ips, zone or self._zone) 404