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(AndroidComputeClient, self).__init__(acloud_config,
73                                                   oauth2_credentials)
74        self._zone = acloud_config.zone
75        self._machine_type = acloud_config.machine_type
76        self._min_machine_size = acloud_config.min_machine_size
77        self._network = acloud_config.network
78        self._orientation = acloud_config.orientation
79        self._resolution = acloud_config.resolution
80        self._metadata = acloud_config.metadata_variable.copy()
81        self._ssh_public_key_path = acloud_config.ssh_public_key_path
82        self._launch_args = acloud_config.launch_args
83        self._instance_name_pattern = acloud_config.instance_name_pattern
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("_", "-").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_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_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(AndroidComputeClient, self).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(AndroidComputeClient, self).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            raise
364
365    def WaitForBoot(self, instance, boot_timeout_secs=None):
366        """Wait for boot to completes or hit timeout.
367
368        Args:
369            instance: string, instance name.
370            boot_timeout_secs: Integer, the maximum time in seconds used to
371                               wait for the AVD to boot.
372        """
373        boot_timeout_secs = boot_timeout_secs or constants.DEFAULT_CF_BOOT_TIMEOUT
374        logger.info("Waiting for instance to boot up %s for %s secs",
375                    instance, boot_timeout_secs)
376        timeout_exception = errors.DeviceBootTimeoutError(
377            "Device %s did not finish on boot within timeout (%s secs)" %
378            (instance, boot_timeout_secs))
379        utils.PollAndWait(
380            func=self.CheckBoot,
381            expected_return=True,
382            timeout_exception=timeout_exception,
383            timeout_secs=boot_timeout_secs,
384            sleep_interval_secs=self.BOOT_CHECK_INTERVAL_SECS,
385            instance=instance)
386        logger.info("Instance boot completed: %s", instance)
387
388    def GetInstanceIP(self, instance, zone=None):
389        """Get Instance IP given instance name.
390
391        Args:
392            instance: String, representing instance name.
393            zone: String, representing zone name, e.g. "us-central1-f"
394
395        Returns:
396            ssh.IP object, that stores internal and external ip of the instance.
397        """
398        return super(AndroidComputeClient, self).GetInstanceIP(
399            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(AndroidComputeClient, self).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