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