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