• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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