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 45 46logger = logging.getLogger(__name__) 47 48 49class AndroidComputeClient(gcompute_client.ComputeClient): 50 """Client that manages Anadroid Virtual Device.""" 51 IMAGE_NAME_FMT = "img-{uuid}-{build_id}-{build_target}" 52 DATA_DISK_NAME_FMT = "data-{instance}" 53 BOOT_COMPLETED_MSG = "VIRTUAL_DEVICE_BOOT_COMPLETED" 54 BOOT_STARTED_MSG = "VIRTUAL_DEVICE_BOOT_STARTED" 55 BOOT_TIMEOUT_SECS = 5 * 60 # 5 mins, usually it should take ~2 mins 56 BOOT_CHECK_INTERVAL_SECS = 10 57 58 OPERATION_TIMEOUT_SECS = 20 * 60 # Override parent value, 20 mins 59 60 NAME_LENGTH_LIMIT = 63 61 # If the generated name ends with '-', replace it with REPLACER. 62 REPLACER = "e" 63 64 def __init__(self, acloud_config, oauth2_credentials): 65 """Initialize. 66 67 Args: 68 acloud_config: An AcloudConfig object. 69 oauth2_credentials: An oauth2client.OAuth2Credentials instance. 70 """ 71 super(AndroidComputeClient, self).__init__(acloud_config, 72 oauth2_credentials) 73 self._zone = acloud_config.zone 74 self._machine_type = acloud_config.machine_type 75 self._min_machine_size = acloud_config.min_machine_size 76 self._network = acloud_config.network 77 self._orientation = acloud_config.orientation 78 self._resolution = acloud_config.resolution 79 self._metadata = acloud_config.metadata_variable.copy() 80 self._ssh_public_key_path = acloud_config.ssh_public_key_path 81 self._launch_args = acloud_config.launch_args 82 self._instance_name_pattern = acloud_config.instance_name_pattern 83 self._AddPerInstanceSshkey() 84 85 def _AddPerInstanceSshkey(self): 86 """Add per-instance ssh key. 87 88 Assign the ssh publick key to instacne then use ssh command to 89 control remote instance via the ssh publick key. Added sshkey for two 90 users. One is vsoc01, another is current user. 91 92 """ 93 if self._ssh_public_key_path: 94 rsa = self._LoadSshPublicKey(self._ssh_public_key_path) 95 logger.info("ssh_public_key_path is specified in config: %s, " 96 "will add the key to the instance.", 97 self._ssh_public_key_path) 98 self._metadata["sshKeys"] = "{0}:{2}\n{1}:{2}".format(getpass.getuser(), 99 constants.GCE_USER, 100 rsa) 101 else: 102 logger.warning( 103 "ssh_public_key_path is not specified in config, " 104 "only project-wide key will be effective.") 105 106 @classmethod 107 def _FormalizeName(cls, name): 108 """Formalize the name to comply with RFC1035. 109 110 The name must be 1-63 characters long and match the regular expression 111 [a-z]([-a-z0-9]*[a-z0-9])? which means the first character must be a 112 lowercase letter, and all following characters must be a dash, 113 lowercase letter, or digit, except the last character, which cannot be 114 a dash. 115 116 Args: 117 name: A string. 118 119 Returns: 120 name: A string that complies with RFC1035. 121 """ 122 name = name.replace("_", "-").lower() 123 name = name[:cls.NAME_LENGTH_LIMIT] 124 if name[-1] == "-": 125 name = name[:-1] + cls.REPLACER 126 return name 127 128 def _CheckMachineSize(self): 129 """Check machine size. 130 131 Check if the desired machine type |self._machine_type| meets 132 the requirement of minimum machine size specified as 133 |self._min_machine_size|. 134 135 Raises: 136 errors.DriverError: if check fails. 137 """ 138 if self.CompareMachineSize(self._machine_type, self._min_machine_size, 139 self._zone) < 0: 140 raise errors.DriverError( 141 "%s does not meet the minimum required machine size %s" % 142 (self._machine_type, self._min_machine_size)) 143 144 @classmethod 145 def GenerateImageName(cls, build_target=None, build_id=None): 146 """Generate an image name given build_target, build_id. 147 148 Args: 149 build_target: Target name, e.g. "aosp_cf_x86_phone-userdebug" 150 build_id: Build id, a string, e.g. "2263051", "P2804227" 151 152 Returns: 153 A string, representing image name. 154 """ 155 if not build_target and not build_id: 156 return "image-" + uuid.uuid4().hex 157 name = cls.IMAGE_NAME_FMT.format( 158 build_target=build_target, 159 build_id=build_id, 160 uuid=uuid.uuid4().hex[:8]) 161 return cls._FormalizeName(name) 162 163 @classmethod 164 def GetDataDiskName(cls, instance): 165 """Get data disk name for an instance. 166 167 Args: 168 instance: An instance_name. 169 170 Returns: 171 The corresponding data disk name. 172 """ 173 name = cls.DATA_DISK_NAME_FMT.format(instance=instance) 174 return cls._FormalizeName(name) 175 176 def GenerateInstanceName(self, build_target=None, build_id=None): 177 """Generate an instance name given build_target, build_id. 178 179 Target is not used as instance name has a length limit. 180 181 Args: 182 build_target: Target name, e.g. "aosp_cf_x86_phone-userdebug" 183 build_id: Build id, a string, e.g. "2263051", "P2804227" 184 185 Returns: 186 A string, representing instance name. 187 """ 188 name = self._instance_name_pattern.format(build_target=build_target, 189 build_id=build_id, 190 uuid=uuid.uuid4().hex[:8]) 191 return self._FormalizeName(name) 192 193 def CreateDisk(self, 194 disk_name, 195 source_image, 196 size_gb, 197 zone=None, 198 source_project=None, 199 disk_type=gcompute_client.PersistentDiskType.STANDARD): 200 """Create a gce disk. 201 202 Args: 203 disk_name: String, name of disk. 204 source_image: String, name to the image name. 205 size_gb: Integer, size in gigabytes. 206 zone: String, name of the zone, e.g. us-central1-b. 207 source_project: String, required if the image is located in a different 208 project. 209 disk_type: String, a value from PersistentDiskType, STANDARD 210 for regular hard disk or SSD for solid state disk. 211 """ 212 if self.CheckDiskExists(disk_name, self._zone): 213 raise errors.DriverError( 214 "Failed to create disk %s, already exists." % disk_name) 215 if source_image and not self.CheckImageExists(source_image): 216 raise errors.DriverError( 217 "Failed to create disk %s, source image %s does not exist." % 218 (disk_name, source_image)) 219 super(AndroidComputeClient, self).CreateDisk( 220 disk_name, 221 source_image=source_image, 222 size_gb=size_gb, 223 zone=zone or self._zone) 224 225 @staticmethod 226 def _LoadSshPublicKey(ssh_public_key_path): 227 """Load the content of ssh public key from a file. 228 229 Args: 230 ssh_public_key_path: String, path to the public key file. 231 E.g. ~/.ssh/acloud_rsa.pub 232 Returns: 233 String, content of the file. 234 235 Raises: 236 errors.DriverError if the public key file does not exist 237 or the content is not valid. 238 """ 239 key_path = os.path.expanduser(ssh_public_key_path) 240 if not os.path.exists(key_path): 241 raise errors.DriverError( 242 "SSH public key file %s does not exist." % key_path) 243 244 with open(key_path) as f: 245 rsa = f.read() 246 rsa = rsa.strip() if rsa else rsa 247 utils.VerifyRsaPubKey(rsa) 248 return rsa 249 250 # pylint: disable=too-many-locals, arguments-differ 251 @utils.TimeExecute("Creating GCE Instance") 252 def CreateInstance(self, 253 instance, 254 image_name, 255 machine_type=None, 256 metadata=None, 257 network=None, 258 zone=None, 259 disk_args=None, 260 image_project=None, 261 gpu=None, 262 extra_disk_name=None, 263 avd_spec=None, 264 extra_scopes=None, 265 tags=None): 266 """Create a gce instance with a gce image. 267 268 Args: 269 instance: String, instance name. 270 image_name: String, source image used to create this disk. 271 machine_type: String, representing machine_type, 272 e.g. "n1-standard-1" 273 metadata: Dict, maps a metadata name to its value. 274 network: String, representing network name, e.g. "default" 275 zone: String, representing zone name, e.g. "us-central1-f" 276 disk_args: A list of extra disk args (strings), see _GetDiskArgs 277 for example, if None, will create a disk using the given 278 image. 279 image_project: String, name of the project where the image 280 belongs. Assume the default project if None. 281 gpu: String, type of gpu to attach. e.g. "nvidia-tesla-k80", if 282 None no gpus will be attached. For more details see: 283 https://cloud.google.com/compute/docs/gpus/add-gpus 284 extra_disk_name: String,the name of the extra disk to attach. 285 avd_spec: AVDSpec object that tells us what we're going to create. 286 extra_scopes: List, extra scopes (strings) to be passed to the 287 instance. 288 tags: A list of tags to associate with the instance. e.g. 289 ["http-server", "https-server"] 290 """ 291 self._CheckMachineSize() 292 disk_args = self._GetDiskArgs(instance, image_name) 293 metadata = self._metadata.copy() 294 metadata["cfg_sta_display_resolution"] = self._resolution 295 metadata["t_force_orientation"] = self._orientation 296 metadata[constants.INS_KEY_AVD_TYPE] = avd_spec.avd_type 297 298 # Use another METADATA_DISPLAY to record resolution which will be 299 # retrieved in acloud list cmd. We try not to use cvd_01_x_res 300 # since cvd_01_xxx metadata is going to deprecated by cuttlefish. 301 metadata[constants.INS_KEY_DISPLAY] = ("%sx%s (%s)" % ( 302 avd_spec.hw_property[constants.HW_X_RES], 303 avd_spec.hw_property[constants.HW_Y_RES], 304 avd_spec.hw_property[constants.HW_ALIAS_DPI])) 305 306 super(AndroidComputeClient, self).CreateInstance( 307 instance, image_name, self._machine_type, metadata, self._network, 308 self._zone, disk_args, image_project, gpu, extra_disk_name, 309 extra_scopes=extra_scopes, tags=tags) 310 311 def CheckBootFailure(self, serial_out, instance): 312 """Determine if serial output has indicated any boot failure. 313 314 Subclass has to define this function to detect failures 315 in the boot process 316 317 Args: 318 serial_out: string 319 instance: string, instance name. 320 321 Raises: 322 Raises errors.DeviceBootError exception if a failure is detected. 323 """ 324 pass 325 326 def CheckBoot(self, instance): 327 """Check once to see if boot completes. 328 329 Args: 330 instance: string, instance name. 331 332 Returns: 333 True if the BOOT_COMPLETED_MSG or BOOT_STARTED_MSG appears in serial 334 port output, otherwise False. 335 """ 336 try: 337 serial_out = self.GetSerialPortOutput(instance=instance, port=1) 338 self.CheckBootFailure(serial_out, instance) 339 return ((self.BOOT_COMPLETED_MSG in serial_out) 340 or (self.BOOT_STARTED_MSG in serial_out)) 341 except errors.HttpError as e: 342 if e.code == 400: 343 logger.debug("CheckBoot: Instance is not ready yet %s", str(e)) 344 return False 345 raise 346 347 def WaitForBoot(self, instance, boot_timeout_secs=None): 348 """Wait for boot to completes or hit timeout. 349 350 Args: 351 instance: string, instance name. 352 boot_timeout_secs: Integer, the maximum time in seconds used to 353 wait for the AVD to boot. 354 """ 355 boot_timeout_secs = boot_timeout_secs or self.BOOT_TIMEOUT_SECS 356 logger.info("Waiting for instance to boot up %s for %s secs", 357 instance, boot_timeout_secs) 358 timeout_exception = errors.DeviceBootTimeoutError( 359 "Device %s did not finish on boot within timeout (%s secs)" % 360 (instance, boot_timeout_secs)), 361 utils.PollAndWait( 362 func=self.CheckBoot, 363 expected_return=True, 364 timeout_exception=timeout_exception, 365 timeout_secs=boot_timeout_secs, 366 sleep_interval_secs=self.BOOT_CHECK_INTERVAL_SECS, 367 instance=instance) 368 logger.info("Instance boot completed: %s", instance) 369 370 def GetInstanceIP(self, instance, zone=None): 371 """Get Instance IP given instance name. 372 373 Args: 374 instance: String, representing instance name. 375 zone: String, representing zone name, e.g. "us-central1-f" 376 377 Returns: 378 ssh.IP object, that stores internal and external ip of the instance. 379 """ 380 return super(AndroidComputeClient, self).GetInstanceIP( 381 instance, zone or self._zone) 382 383 def GetSerialPortOutput(self, instance, zone=None, port=1): 384 """Get serial port output. 385 386 Args: 387 instance: string, instance name. 388 zone: String, representing zone name, e.g. "us-central1-f" 389 port: int, which COM port to read from, 1-4, default to 1. 390 391 Returns: 392 String, contents of the output. 393 394 Raises: 395 errors.DriverError: For malformed response. 396 """ 397 return super(AndroidComputeClient, self).GetSerialPortOutput( 398 instance, zone or self._zone, port) 399 400 def GetInstanceNamesByIPs(self, ips, zone=None): 401 """Get Instance names by IPs. 402 403 This function will go through all instances, which 404 could be slow if there are too many instances. However, currently 405 GCE doesn't support search for instance by IP. 406 407 Args: 408 ips: A set of IPs. 409 zone: String, representing zone name, e.g. "us-central1-f" 410 411 Returns: 412 A dictionary where key is ip and value is instance name or None 413 if instance is not found for the given IP. 414 """ 415 return super(AndroidComputeClient, self).GetInstanceNamesByIPs( 416 ips, zone or self._zone) 417