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