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