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