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