1# Copyright 2019 - The Android Open Source Project 2# 3# Licensed under the Apache License, Version 2.0 (the "License"); 4# you may not use this file except in compliance with the License. 5# You may obtain a copy of the License at 6# 7# http://www.apache.org/licenses/LICENSE-2.0 8# 9# Unless required by applicable law or agreed to in writing, software 10# distributed under the License is distributed on an "AS IS" BASIS, 11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12# See the License for the specific language governing permissions and 13# limitations under the License. 14"""A client that manages Cuttlefish Virtual Device on compute engine. 15 16** CvdComputeClient ** 17 18CvdComputeClient derives from AndroidComputeClient. It manges a google 19compute engine project that is setup for running Cuttlefish Virtual Devices. 20It knows how to create a host instance from Cuttlefish Stable Host Image, fetch 21Android build, and start Android within the host instance. 22 23** Class hierarchy ** 24 25 base_cloud_client.BaseCloudApiClient 26 ^ 27 | 28 gcompute_client.ComputeClient 29 ^ 30 | 31 android_compute_client.AndroidComputeClient 32 ^ 33 | 34 cvd_compute_client_multi_stage.CvdComputeClient 35 36""" 37 38import logging 39import os 40import subprocess 41import tempfile 42import time 43 44from acloud import errors 45from acloud.internal import constants 46from acloud.internal.lib import android_build_client 47from acloud.internal.lib import android_compute_client 48from acloud.internal.lib import cvd_utils 49from acloud.internal.lib import gcompute_client 50from acloud.internal.lib import utils 51from acloud.internal.lib.ssh import Ssh 52from acloud.setup import mkcert 53 54 55logger = logging.getLogger(__name__) 56 57_DEFAULT_WEBRTC_DEVICE_ID = "cvd-1" 58_FETCHER_NAME = "fetch_cvd" 59_TRUST_REMOTE_INSTANCE_COMMAND = ( 60 f"\"sudo cp -p ~/{constants.WEBRTC_CERTS_PATH}/{constants.SSL_CA_NAME}.pem " 61 f"{constants.SSL_TRUST_CA_DIR}/{constants.SSL_CA_NAME}.crt;" 62 "sudo update-ca-certificates;\"") 63 64 65class CvdComputeClient(android_compute_client.AndroidComputeClient): 66 """Client that manages Android Virtual Device.""" 67 68 DATA_POLICY_CREATE_IF_MISSING = "create_if_missing" 69 # Data policy to customize disk size. 70 DATA_POLICY_ALWAYS_CREATE = "always_create" 71 72 def __init__(self, 73 acloud_config, 74 oauth2_credentials, 75 ins_timeout_secs=None, 76 report_internal_ip=None, 77 gpu=None): 78 """Initialize. 79 80 Args: 81 acloud_config: An AcloudConfig object. 82 oauth2_credentials: An oauth2client.OAuth2Credentials instance. 83 ins_timeout_secs: Integer, the maximum time to wait for the 84 instance ready. 85 report_internal_ip: Boolean to report the internal ip instead of 86 external ip. 87 gpu: String, GPU to attach to the device. 88 """ 89 super().__init__(acloud_config, oauth2_credentials) 90 91 self._build_api = ( 92 android_build_client.AndroidBuildClient(oauth2_credentials)) 93 self._ssh_private_key_path = acloud_config.ssh_private_key_path 94 self._ins_timeout_secs = ins_timeout_secs 95 self._report_internal_ip = report_internal_ip 96 self._gpu = gpu 97 # Store all failures result when creating one or multiple instances. 98 # This attribute is only used by the deprecated create_cf command. 99 self._all_failures = {} 100 self._extra_args_ssh_tunnel = acloud_config.extra_args_ssh_tunnel 101 self._ssh = None 102 self._ip = None 103 self._user = constants.GCE_USER 104 self._openwrt = None 105 self._stage = constants.STAGE_INIT 106 self._execution_time = {constants.TIME_ARTIFACT: 0, 107 constants.TIME_GCE: 0, 108 constants.TIME_LAUNCH: 0} 109 110 # pylint: disable=arguments-differ,broad-except 111 def CreateInstance(self, instance, image_name, image_project, 112 avd_spec, extra_scopes=None): 113 """Create/Reuse a GCE instance. 114 115 Args: 116 instance: instance name. 117 image_name: A string, the name of the GCE image. 118 image_project: A string, name of the project where the image lives. 119 Assume the default project if None. 120 avd_spec: An AVDSpec instance. 121 extra_scopes: A list of extra scopes to be passed to the instance. 122 123 Returns: 124 A string, representing instance name. 125 """ 126 # A blank data disk would be created on the host. Make sure the size of 127 # the boot disk is large enough to hold it. 128 boot_disk_size_gb = ( 129 int(self.GetImage(image_name, image_project)["diskSizeGb"]) + 130 avd_spec.cfg.extra_data_disk_size_gb) 131 132 if avd_spec.instance_name_to_reuse: 133 self._ip = self._ReusingGceInstance(avd_spec) 134 else: 135 self._VerifyZoneByQuota() 136 self._ip = self._CreateGceInstance(instance, image_name, image_project, 137 extra_scopes, boot_disk_size_gb, 138 avd_spec) 139 if avd_spec.connect_hostname: 140 self._gce_hostname = gcompute_client.GetGCEHostName( 141 self._project, instance, self._zone) 142 self._ssh = Ssh(ip=self._ip, 143 user=constants.GCE_USER, 144 ssh_private_key_path=self._ssh_private_key_path, 145 extra_args_ssh_tunnel=self._extra_args_ssh_tunnel, 146 report_internal_ip=self._report_internal_ip, 147 gce_hostname=self._gce_hostname) 148 try: 149 self.SetStage(constants.STAGE_SSH_CONNECT) 150 self._ssh.WaitForSsh(timeout=self._ins_timeout_secs) 151 if avd_spec.instance_name_to_reuse: 152 cvd_utils.CleanUpRemoteCvd(self._ssh, cvd_utils.GCE_BASE_DIR, 153 raise_error=False) 154 except Exception as e: 155 self._all_failures[instance] = e 156 return instance 157 158 def _GetGCEHostName(self, instance): 159 """Get the GCE host name with specific rule. 160 161 Args: 162 instance: Sting, instance name. 163 164 Returns: 165 One host name coverted by instance name, project name, and zone. 166 """ 167 if ":" in self._project: 168 domain = self._project.split(":")[0] 169 project_no_domain = self._project.split(":")[1] 170 project = f"{project_no_domain}.{domain}" 171 return f"nic0.{instance}.{self._zone}.c.{project}.internal.gcpnode.com" 172 return f"nic0.{instance}.{self._zone}.c.{self._project}.internal.gcpnode.com" 173 174 @utils.TimeExecute(function_description="Launching AVD(s) and waiting for boot up", 175 result_evaluator=utils.BootEvaluator) 176 def LaunchCvd(self, instance, avd_spec, base_dir, extra_args): 177 """Launch CVD. 178 179 Launch AVD with launch_cvd. If the process is failed, acloud would show 180 error messages and auto download log files from remote instance. 181 182 Args: 183 instance: String, instance name. 184 avd_spec: An AVDSpec instance. 185 base_dir: The remote directory containing the images and tools. 186 extra_args: Collection of strings, the extra arguments generated by 187 acloud. e.g., remote image paths. 188 189 Returns: 190 dict of faliures, return this dict for BootEvaluator to handle 191 LaunchCvd success or fail messages. 192 """ 193 self.SetStage(constants.STAGE_BOOT_UP) 194 timestart = time.time() 195 config = cvd_utils.GetConfigFromRemoteAndroidInfo(self._ssh, base_dir) 196 cmd = cvd_utils.GetRemoteLaunchCvdCmd( 197 base_dir, avd_spec, config, extra_args) 198 boot_timeout_secs = self._GetBootTimeout( 199 avd_spec.boot_timeout_secs or constants.DEFAULT_CF_BOOT_TIMEOUT) 200 201 self.ExtendReportData(constants.LAUNCH_CVD_COMMAND, cmd) 202 error_msg = cvd_utils.ExecuteRemoteLaunchCvd( 203 self._ssh, cmd, boot_timeout_secs) 204 self._execution_time[constants.TIME_LAUNCH] = time.time() - timestart 205 206 if error_msg: 207 return {instance: error_msg} 208 self._openwrt = avd_spec.openwrt 209 return {} 210 211 def _GetBootTimeout(self, timeout_secs): 212 """Get boot timeout. 213 214 Timeout settings includes download artifacts and boot up. 215 216 Args: 217 timeout_secs: integer of timeout value. 218 219 Returns: 220 The timeout values for device boots up. 221 """ 222 boot_timeout_secs = timeout_secs - self._execution_time[constants.TIME_ARTIFACT] 223 logger.debug("Timeout for boot: %s secs", boot_timeout_secs) 224 return boot_timeout_secs 225 226 @utils.TimeExecute(function_description="Reusing GCE instance") 227 def _ReusingGceInstance(self, avd_spec): 228 """Reusing a cuttlefish existing instance. 229 230 Args: 231 avd_spec: An AVDSpec instance. 232 233 Returns: 234 ssh.IP object, that stores internal and external ip of the instance. 235 """ 236 gcompute_client.ComputeClient.AddSshRsaInstanceMetadata( 237 self, constants.GCE_USER, avd_spec.cfg.ssh_public_key_path, 238 avd_spec.instance_name_to_reuse) 239 ip = gcompute_client.ComputeClient.GetInstanceIP( 240 self, instance=avd_spec.instance_name_to_reuse, zone=self._zone) 241 242 return ip 243 244 @utils.TimeExecute(function_description="Creating GCE instance") 245 def _CreateGceInstance(self, instance, image_name, image_project, 246 extra_scopes, boot_disk_size_gb, avd_spec): 247 """Create a single configured cuttlefish device. 248 249 Override method from parent class. 250 Args: 251 instance: String, instance name. 252 image_name: String, the name of the GCE image. 253 image_project: String, the name of the project where the image. 254 extra_scopes: A list of extra scopes to be passed to the instance. 255 boot_disk_size_gb: Integer, size of the boot disk in GB. 256 avd_spec: An AVDSpec instance. 257 258 Returns: 259 ssh.IP object, that stores internal and external ip of the instance. 260 """ 261 self.SetStage(constants.STAGE_GCE) 262 timestart = time.time() 263 metadata = self._metadata.copy() 264 265 if avd_spec: 266 metadata[constants.INS_KEY_AVD_TYPE] = avd_spec.avd_type 267 metadata[constants.INS_KEY_AVD_FLAVOR] = avd_spec.flavor 268 metadata[constants.INS_KEY_WEBRTC_DEVICE_ID] = ( 269 avd_spec.webrtc_device_id or _DEFAULT_WEBRTC_DEVICE_ID) 270 metadata[constants.INS_KEY_DISPLAY] = ("%sx%s (%s)" % ( 271 avd_spec.hw_property[constants.HW_X_RES], 272 avd_spec.hw_property[constants.HW_Y_RES], 273 avd_spec.hw_property[constants.HW_ALIAS_DPI])) 274 if avd_spec.gce_metadata: 275 for key, value in avd_spec.gce_metadata.items(): 276 metadata[key] = value 277 # Record webrtc port, it will be removed if cvd support to show it. 278 if avd_spec.connect_webrtc: 279 metadata[constants.INS_KEY_WEBRTC_PORT] = constants.WEBRTC_LOCAL_PORT 280 281 disk_args = self._GetDiskArgs( 282 instance, image_name, image_project, boot_disk_size_gb) 283 disable_external_ip = avd_spec.disable_external_ip if avd_spec else False 284 gcompute_client.ComputeClient.CreateInstance( 285 self, 286 instance=instance, 287 image_name=image_name, 288 image_project=image_project, 289 disk_args=disk_args, 290 metadata=metadata, 291 machine_type=self._machine_type, 292 network=self._network, 293 zone=self._zone, 294 gpu=self._gpu, 295 disk_type=avd_spec.disk_type if avd_spec else None, 296 extra_scopes=extra_scopes, 297 disable_external_ip=disable_external_ip) 298 ip = gcompute_client.ComputeClient.GetInstanceIP( 299 self, instance=instance, zone=self._zone) 300 logger.debug("'instance_ip': %s", ip.internal 301 if self._report_internal_ip else ip.external) 302 303 self._execution_time[constants.TIME_GCE] = time.time() - timestart 304 return ip 305 306 @utils.TimeExecute(function_description="Uploading build fetcher to instance") 307 def UpdateFetchCvd(self, fetch_cvd_version): 308 """Download fetch_cvd from the Build API, and upload it to a remote instance. 309 310 The version of fetch_cvd to use is retrieved from the configuration file. Once fetch_cvd 311 is on the instance, future commands can use it to download relevant Cuttlefish files from 312 the Build API on the instance itself. 313 314 Args: 315 fetch_cvd_version: String. The build id of fetch_cvd. 316 """ 317 self.SetStage(constants.STAGE_ARTIFACT) 318 download_dir = tempfile.mkdtemp() 319 download_target = os.path.join(download_dir, _FETCHER_NAME) 320 self._build_api.DownloadFetchcvd(download_target, fetch_cvd_version) 321 self._ssh.ScpPushFile(src_file=download_target, dst_file=_FETCHER_NAME) 322 os.remove(download_target) 323 os.rmdir(download_dir) 324 325 @utils.TimeExecute(function_description="Downloading build on instance") 326 def FetchBuild(self, default_build_info, system_build_info, 327 kernel_build_info, boot_build_info, bootloader_build_info, 328 android_efi_loader_build_info, ota_build_info, host_package_build_info): 329 """Execute fetch_cvd on the remote instance to get Cuttlefish runtime files. 330 331 Args: 332 default_build_info: The build that provides full cuttlefish images. 333 system_build_info: The build that provides the system image. 334 kernel_build_info: The build that provides the kernel. 335 boot_build_info: The build that provides the boot image. 336 bootloader_build_info: The build that provides the bootloader. 337 android_efi_loader_build_info: The build that provides the Android EFI app. 338 ota_build_info: The build that provides the OTA tools. 339 host_package_build_info: The build that provides the host package. 340 341 Returns: 342 List of string args for fetch_cvd. 343 """ 344 timestart = time.time() 345 fetch_cvd_args = ["-credential_source=gce"] 346 fetch_cvd_build_args = self._build_api.GetFetchBuildArgs( 347 default_build_info, system_build_info, kernel_build_info, 348 boot_build_info, bootloader_build_info, android_efi_loader_build_info, 349 ota_build_info, host_package_build_info) 350 fetch_cvd_args.extend(fetch_cvd_build_args) 351 352 self._ssh.Run("./fetch_cvd " + " ".join(fetch_cvd_args), 353 timeout=constants.DEFAULT_SSH_TIMEOUT) 354 self._execution_time[constants.TIME_ARTIFACT] = time.time() - timestart 355 356 @utils.TimeExecute(function_description="Update instance's certificates") 357 def UpdateCertificate(self): 358 """Update webrtc default certificates of the remote instance. 359 360 For trusting both the localhost and remote instance, the process will 361 upload certificates(rootCA.pem, server.crt, server.key) and the mkcert 362 tool from the client workstation to remote instance where running the 363 mkcert with the uploaded rootCA file and replace the webrtc frontend 364 default certificates for connecting to a remote webrtc AVD without the 365 insecure warning. 366 """ 367 local_cert_dir = os.path.join(os.path.expanduser("~"), 368 constants.SSL_DIR) 369 if mkcert.AllocateLocalHostCert(): 370 upload_files = [] 371 for cert_file in (constants.WEBRTC_CERTS_FILES + 372 [f"{constants.SSL_CA_NAME}.pem"]): 373 upload_files.append(os.path.join(local_cert_dir, 374 cert_file)) 375 try: 376 self._ssh.ScpPushFiles(upload_files, constants.WEBRTC_CERTS_PATH) 377 self._ssh.Run(_TRUST_REMOTE_INSTANCE_COMMAND) 378 except subprocess.CalledProcessError: 379 logger.debug("Update WebRTC frontend certificate failed.") 380 381 @utils.TimeExecute(function_description="Upload extra files to instance") 382 def UploadExtraFiles(self, extra_files): 383 """Upload extra files into GCE instance. 384 385 Args: 386 extra_files: List of namedtuple ExtraFile. 387 388 Raises: 389 errors.CheckPathError: The provided path doesn't exist. 390 """ 391 for extra_file in extra_files: 392 if not os.path.exists(extra_file.source): 393 raise errors.CheckPathError( 394 f"The path doesn't exist: {extra_file.source}") 395 self._ssh.ScpPushFile(extra_file.source, extra_file.target) 396 397 def GetSshConnectCmd(self): 398 """Get ssh connect command. 399 400 Returns: 401 String of ssh connect command. 402 """ 403 return self._ssh.GetBaseCmd(constants.SSH_BIN) 404 405 def GetInstanceIP(self, instance=None): 406 """Override method from parent class. 407 408 It need to get the IP address in the common_operation. If the class 409 already defind the ip address, return the ip address. 410 411 Args: 412 instance: String, representing instance name. 413 414 Returns: 415 ssh.IP object, that stores internal and external ip of the instance. 416 """ 417 if self._ip: 418 return self._ip 419 return gcompute_client.ComputeClient.GetInstanceIP( 420 self, instance=instance, zone=self._zone) 421 422 def GetHostImageName(self, stable_image_name, image_family, image_project): 423 """Get host image name. 424 425 Args: 426 stable_image_name: String of stable host image name. 427 image_family: String of image family. 428 image_project: String of image project. 429 430 Returns: 431 String of stable host image name. 432 433 Raises: 434 errors.ConfigError: There is no host image name in config file. 435 """ 436 if stable_image_name: 437 return stable_image_name 438 439 if image_family: 440 image_name = gcompute_client.ComputeClient.GetImageFromFamily( 441 self, image_family, image_project)["name"] 442 logger.debug("Get the host image name from image family: %s", image_name) 443 return image_name 444 445 raise errors.ConfigError( 446 "Please specify 'stable_host_image_name' or 'stable_host_image_family'" 447 " in config.") 448 449 def SetStage(self, stage): 450 """Set stage to know the create progress. 451 452 Args: 453 stage: Integer, the stage would like STAGE_INIT, STAGE_GCE. 454 """ 455 self._stage = stage 456 457 @property 458 def all_failures(self): 459 """Return all_failures""" 460 return self._all_failures 461 462 @property 463 def execution_time(self): 464 """Return execution_time""" 465 return self._execution_time 466 467 @property 468 def stage(self): 469 """Return stage""" 470 return self._stage 471 472 @property 473 def openwrt(self): 474 """Return openwrt""" 475 return self._openwrt 476 477 @property 478 def build_api(self): 479 """Return build_api""" 480 return self._build_api 481