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
17"""Public Device Driver APIs.
18
19This module provides public device driver APIs that can be called
20as a Python library.
21
22TODO(fdeng): The following APIs have not been implemented
23  - RebootAVD(ip):
24  - RegisterSshPubKey(username, key):
25  - UnregisterSshPubKey(username, key):
26  - CleanupStaleImages():
27  - CleanupStaleDevices():
28"""
29
30import datetime
31import logging
32import os
33
34import dateutil.parser
35import dateutil.tz
36
37from acloud.public import avd
38from acloud.public import errors
39from acloud.public import report
40from acloud.internal import constants
41from acloud.internal.lib import auth
42from acloud.internal.lib import android_build_client
43from acloud.internal.lib import android_compute_client
44from acloud.internal.lib import gstorage_client
45from acloud.internal.lib import utils
46
47logger = logging.getLogger(__name__)
48
49ALL_SCOPES = " ".join([android_build_client.AndroidBuildClient.SCOPE,
50                       gstorage_client.StorageClient.SCOPE,
51                       android_compute_client.AndroidComputeClient.SCOPE])
52
53MAX_BATCH_CLEANUP_COUNT = 100
54
55
56class AndroidVirtualDevicePool(object):
57    """A class that manages a pool of devices."""
58
59    def __init__(self, cfg, devices=None):
60        self._devices = devices or []
61        self._cfg = cfg
62        credentials = auth.CreateCredentials(cfg, ALL_SCOPES)
63        self._build_client = android_build_client.AndroidBuildClient(
64            credentials)
65        self._storage_client = gstorage_client.StorageClient(credentials)
66        self._compute_client = android_compute_client.AndroidComputeClient(
67            cfg, credentials)
68
69    def _CreateGceImageWithBuildInfo(self, build_target, build_id):
70        """Creates a Gce image using build from Launch Control.
71
72        Clone avd-system.tar.gz of a build to a cache storage bucket
73        using launch control api. And then create a Gce image.
74
75        Args:
76            build_target: Target name, e.g. "gce_x86-userdebug"
77            build_id: Build id, a string, e.g. "2263051", "P2804227"
78
79        Returns:
80            String, name of the Gce image that has been created.
81        """
82        logger.info("Creating a new gce image using build: build_id %s, "
83                    "build_target %s", build_id, build_target)
84        disk_image_id = utils.GenerateUniqueName(
85            suffix=self._cfg.disk_image_name)
86        self._build_client.CopyTo(
87            build_target,
88            build_id,
89            artifact_name=self._cfg.disk_image_name,
90            destination_bucket=self._cfg.storage_bucket_name,
91            destination_path=disk_image_id)
92        disk_image_url = self._storage_client.GetUrl(
93            self._cfg.storage_bucket_name, disk_image_id)
94        try:
95            image_name = self._compute_client.GenerateImageName(build_target,
96                                                                build_id)
97            self._compute_client.CreateImage(image_name=image_name,
98                                             source_uri=disk_image_url)
99        finally:
100            self._storage_client.Delete(self._cfg.storage_bucket_name,
101                                        disk_image_id)
102        return image_name
103
104    def _CreateGceImageWithLocalFile(self, local_disk_image):
105        """Create a Gce image with a local image file.
106
107        The local disk image can be either a tar.gz file or a
108        raw vmlinux image.
109        e.g.  /tmp/avd-system.tar.gz or /tmp/android_system_disk_syslinux.img
110        If a raw vmlinux image is provided, it will be archived into a tar.gz file.
111
112        The final tar.gz file will be uploaded to a cache bucket in storage.
113
114        Args:
115            local_disk_image: string, path to a local disk image,
116
117        Returns:
118            String, name of the Gce image that has been created.
119
120        Raises:
121            DriverError: if a file with an unexpected extension is given.
122        """
123        logger.info("Creating a new gce image from a local file %s",
124                    local_disk_image)
125        with utils.TempDir() as tempdir:
126            if local_disk_image.endswith(self._cfg.disk_raw_image_extension):
127                dest_tar_file = os.path.join(tempdir,
128                                             self._cfg.disk_image_name)
129                utils.MakeTarFile(
130                    src_dict={local_disk_image: self._cfg.disk_raw_image_name},
131                    dest=dest_tar_file)
132                local_disk_image = dest_tar_file
133            elif not local_disk_image.endswith(self._cfg.disk_image_extension):
134                raise errors.DriverError(
135                    "Wrong local_disk_image type, must be a *%s file or *%s file"
136                    % (self._cfg.disk_raw_image_extension,
137                       self._cfg.disk_image_extension))
138
139            disk_image_id = utils.GenerateUniqueName(
140                suffix=self._cfg.disk_image_name)
141            self._storage_client.Upload(
142                local_src=local_disk_image,
143                bucket_name=self._cfg.storage_bucket_name,
144                object_name=disk_image_id,
145                mime_type=self._cfg.disk_image_mime_type)
146        disk_image_url = self._storage_client.GetUrl(
147            self._cfg.storage_bucket_name, disk_image_id)
148        try:
149            image_name = self._compute_client.GenerateImageName()
150            self._compute_client.CreateImage(image_name=image_name,
151                                             source_uri=disk_image_url)
152        finally:
153            self._storage_client.Delete(self._cfg.storage_bucket_name,
154                                        disk_image_id)
155        return image_name
156
157    def CreateDevices(self,
158                      num,
159                      build_target=None,
160                      build_id=None,
161                      gce_image=None,
162                      local_disk_image=None,
163                      cleanup=True,
164                      extra_data_disk_size_gb=None,
165                      precreated_data_image=None):
166        """Creates |num| devices for given build_target and build_id.
167
168        - If gce_image is provided, will use it to create an instance.
169        - If local_disk_image is provided, will upload it to a temporary
170          caching storage bucket which is defined by user as |storage_bucket_name|
171          And then create an gce image with it; and then create an instance.
172        - If build_target and build_id are provided, will clone the disk image
173          via launch control to the temporary caching storage bucket.
174          And then create an gce image with it; and then create an instance.
175
176        Args:
177            num: Number of devices to create.
178            build_target: Target name, e.g. "gce_x86-userdebug"
179            build_id: Build id, a string, e.g. "2263051", "P2804227"
180            gce_image: string, if given, will use this image
181                       instead of creating a new one.
182                       implies cleanup=False.
183            local_disk_image: string, path to a local disk image, e.g.
184                              /tmp/avd-system.tar.gz
185            cleanup: boolean, if True clean up compute engine image after creating
186                     the instance.
187            extra_data_disk_size_gb: Integer, size of extra disk, or None.
188            precreated_data_image: A string, the image to use for the extra disk.
189
190        Raises:
191            errors.DriverError: If no source is specified for image creation.
192        """
193        if gce_image:
194            # GCE image is provided, we can directly move to instance creation.
195            logger.info("Using existing gce image %s", gce_image)
196            image_name = gce_image
197            cleanup = False
198        elif local_disk_image:
199            image_name = self._CreateGceImageWithLocalFile(local_disk_image)
200        elif build_target and build_id:
201            image_name = self._CreateGceImageWithBuildInfo(build_target,
202                                                           build_id)
203        else:
204            raise errors.DriverError(
205                "Invalid image source, must specify one of the following: gce_image, "
206                "local_disk_image, or build_target and build id.")
207
208        # Create GCE instances.
209        try:
210            for _ in range(num):
211                instance = self._compute_client.GenerateInstanceName(
212                    build_target, build_id)
213                extra_disk_name = None
214                if extra_data_disk_size_gb > 0:
215                    extra_disk_name = self._compute_client.GetDataDiskName(
216                        instance)
217                    self._compute_client.CreateDisk(extra_disk_name,
218                                                    precreated_data_image,
219                                                    extra_data_disk_size_gb)
220                self._compute_client.CreateInstance(instance, image_name,
221                                                    extra_disk_name)
222                ip = self._compute_client.GetInstanceIP(instance)
223                self.devices.append(avd.AndroidVirtualDevice(
224                    ip=ip, instance_name=instance))
225        finally:
226            if cleanup:
227                self._compute_client.DeleteImage(image_name)
228
229    def DeleteDevices(self):
230        """Deletes devices.
231
232        Returns:
233            A tuple, (deleted, failed, error_msgs)
234            deleted: A list of names of instances that have been deleted.
235            faild: A list of names of instances that we fail to delete.
236            error_msgs: A list of failure messages.
237        """
238        instance_names = [device.instance_name for device in self._devices]
239        return self._compute_client.DeleteInstances(instance_names,
240                                                    self._cfg.zone)
241
242    def WaitForBoot(self):
243        """Waits for all devices to boot up.
244
245        Returns:
246            A dictionary that contains all the failures.
247            The key is the name of the instance that fails to boot,
248            the value is an errors.DeviceBootTimeoutError object.
249        """
250        failures = {}
251        for device in self._devices:
252            try:
253                self._compute_client.WaitForBoot(device.instance_name)
254            except errors.DeviceBootTimeoutError as e:
255                failures[device.instance_name] = e
256        return failures
257
258    @property
259    def devices(self):
260        """Returns a list of devices in the pool.
261
262        Returns:
263            A list of devices in the pool.
264        """
265        return self._devices
266
267
268def _AddDeletionResultToReport(report_obj, deleted, failed, error_msgs,
269                               resource_name):
270    """Adds deletion result to a Report object.
271
272    This function will add the following to report.data.
273      "deleted": [
274         {"name": "resource_name", "type": "resource_name"},
275       ],
276      "failed": [
277         {"name": "resource_name", "type": "resource_name"},
278       ],
279    This function will append error_msgs to report.errors.
280
281    Args:
282        report_obj: A Report object.
283        deleted: A list of names of the resources that have been deleted.
284        failed: A list of names of the resources that we fail to delete.
285        error_msgs: A list of error message strings to be added to the report.
286        resource_name: A string, representing the name of the resource.
287    """
288    for name in deleted:
289        report_obj.AddData(key="deleted",
290                           value={"name": name,
291                                  "type": resource_name})
292    for name in failed:
293        report_obj.AddData(key="failed",
294                           value={"name": name,
295                                  "type": resource_name})
296    report_obj.AddErrors(error_msgs)
297    if failed or error_msgs:
298        report_obj.SetStatus(report.Status.FAIL)
299
300
301def _FetchSerialLogsFromDevices(compute_client, instance_names, output_file,
302                                port):
303    """Fetch serial logs from a port for a list of devices to a local file.
304
305    Args:
306        compute_client: An object of android_compute_client.AndroidComputeClient
307        instance_names: A list of instance names.
308        output_file: A path to a file ending with "tar.gz"
309        port: The number of serial port to read from, 0 for serial output, 1 for
310              logcat.
311    """
312    with utils.TempDir() as tempdir:
313        src_dict = {}
314        for instance_name in instance_names:
315            serial_log = compute_client.GetSerialPortOutput(
316                instance=instance_name, port=port)
317            file_name = "%s.log" % instance_name
318            file_path = os.path.join(tempdir, file_name)
319            src_dict[file_path] = file_name
320            with open(file_path, "w") as f:
321                f.write(serial_log.encode("utf-8"))
322        utils.MakeTarFile(src_dict, output_file)
323
324
325def _CreateSshKeyPairIfNecessary(cfg):
326    """Create ssh key pair if necessary.
327
328    Args:
329        cfg: An Acloudconfig instance.
330
331    Raises:
332        error.DriverError: If it falls into an unexpected condition.
333    """
334    if not cfg.ssh_public_key_path:
335        logger.warning("ssh_public_key_path is not specified in acloud config. "
336                       "Project-wide public key will "
337                       "be used when creating AVD instances. "
338                       "Please ensure you have the correct private half of "
339                       "a project-wide public key if you want to ssh into the "
340                       "instances after creation.")
341    elif cfg.ssh_public_key_path and not cfg.ssh_private_key_path:
342        logger.warning("Only ssh_public_key_path is specified in acloud config,"
343                       " but ssh_private_key_path is missing. "
344                       "Please ensure you have the correct private half "
345                       "if you want to ssh into the instances after creation.")
346    elif cfg.ssh_public_key_path and cfg.ssh_private_key_path:
347        utils.CreateSshKeyPairIfNotExist(
348                cfg.ssh_private_key_path, cfg.ssh_public_key_path)
349    else:
350        # Should never reach here.
351        raise errors.DriverError(
352                "Unexpected error in _CreateSshKeyPairIfNecessary")
353
354
355def CreateAndroidVirtualDevices(cfg,
356                                build_target=None,
357                                build_id=None,
358                                num=1,
359                                gce_image=None,
360                                local_disk_image=None,
361                                cleanup=True,
362                                serial_log_file=None,
363                                logcat_file=None):
364    """Creates one or multiple android devices.
365
366    Args:
367        cfg: An AcloudConfig instance.
368        build_target: Target name, e.g. "gce_x86-userdebug"
369        build_id: Build id, a string, e.g. "2263051", "P2804227"
370        num: Number of devices to create.
371        gce_image: string, if given, will use this gce image
372                   instead of creating a new one.
373                   implies cleanup=False.
374        local_disk_image: string, path to a local disk image, e.g.
375                          /tmp/avd-system.tar.gz
376        cleanup: boolean, if True clean up compute engine image and
377                 disk image in storage after creating the instance.
378        serial_log_file: A path to a file where serial output should
379                         be saved to.
380        logcat_file: A path to a file where logcat logs should be saved.
381
382    Returns:
383        A Report instance.
384    """
385    r = report.Report(command="create")
386    credentials = auth.CreateCredentials(cfg, ALL_SCOPES)
387    compute_client = android_compute_client.AndroidComputeClient(cfg,
388                                                                 credentials)
389    try:
390        _CreateSshKeyPairIfNecessary(cfg)
391        device_pool = AndroidVirtualDevicePool(cfg)
392        device_pool.CreateDevices(
393            num,
394            build_target,
395            build_id,
396            gce_image,
397            local_disk_image,
398            cleanup,
399            extra_data_disk_size_gb=cfg.extra_data_disk_size_gb,
400            precreated_data_image=cfg.precreated_data_image_map.get(
401                cfg.extra_data_disk_size_gb))
402        failures = device_pool.WaitForBoot()
403        # Write result to report.
404        for device in device_pool.devices:
405            device_dict = {"ip": device.ip,
406                           "instance_name": device.instance_name}
407            if device.instance_name in failures:
408                r.AddData(key="devices_failing_boot", value=device_dict)
409                r.AddError(str(failures[device.instance_name]))
410            else:
411                r.AddData(key="devices", value=device_dict)
412        if failures:
413            r.SetStatus(report.Status.BOOT_FAIL)
414        else:
415            r.SetStatus(report.Status.SUCCESS)
416
417        # Dump serial and logcat logs.
418        if serial_log_file:
419            _FetchSerialLogsFromDevices(
420                compute_client,
421                instance_names=[d.instance_name for d in device_pool.devices],
422                port=constants.DEFAULT_SERIAL_PORT,
423                output_file=serial_log_file)
424        if logcat_file:
425            _FetchSerialLogsFromDevices(
426                compute_client,
427                instance_names=[d.instance_name for d in device_pool.devices],
428                port=constants.LOGCAT_SERIAL_PORT,
429                output_file=logcat_file)
430    except errors.DriverError as e:
431        r.AddError(str(e))
432        r.SetStatus(report.Status.FAIL)
433    return r
434
435
436def DeleteAndroidVirtualDevices(cfg, instance_names):
437    """Deletes android devices.
438
439    Args:
440        cfg: An AcloudConfig instance.
441        instance_names: A list of names of the instances to delete.
442
443    Returns:
444        A Report instance.
445    """
446    r = report.Report(command="delete")
447    credentials = auth.CreateCredentials(cfg, ALL_SCOPES)
448    compute_client = android_compute_client.AndroidComputeClient(cfg,
449                                                                 credentials)
450    try:
451        deleted, failed, error_msgs = compute_client.DeleteInstances(
452            instance_names, cfg.zone)
453        _AddDeletionResultToReport(
454            r, deleted,
455            failed, error_msgs,
456            resource_name="instance")
457        if r.status == report.Status.UNKNOWN:
458            r.SetStatus(report.Status.SUCCESS)
459    except errors.DriverError as e:
460        r.AddError(str(e))
461        r.SetStatus(report.Status.FAIL)
462    return r
463
464
465def _FindOldItems(items, cut_time, time_key):
466    """Finds items from |items| whose timestamp is earlier than |cut_time|.
467
468    Args:
469        items: A list of items. Each item is a dictionary represent
470               the properties of the item. It should has a key as noted
471               by time_key.
472        cut_time: A datetime.datatime object.
473        time_key: String, key for the timestamp.
474
475    Returns:
476        A list of those from |items| whose timestamp is earlier than cut_time.
477    """
478    cleanup_list = []
479    for item in items:
480        t = dateutil.parser.parse(item[time_key])
481        if t < cut_time:
482            cleanup_list.append(item)
483    return cleanup_list
484
485
486def Cleanup(cfg, expiration_mins):
487    """Cleans up stale gce images, gce instances, and disk images in storage.
488
489    Args:
490        cfg: An AcloudConfig instance.
491        expiration_mins: Integer, resources older than |expiration_mins| will
492                         be cleaned up.
493
494    Returns:
495        A Report instance.
496    """
497    r = report.Report(command="cleanup")
498    try:
499        cut_time = (datetime.datetime.now(dateutil.tz.tzlocal()) -
500                    datetime.timedelta(minutes=expiration_mins))
501        logger.info(
502            "Cleaning up any gce images/instances and cached build artifacts."
503            "in google storage that are older than %s", cut_time)
504        credentials = auth.CreateCredentials(cfg, ALL_SCOPES)
505        compute_client = android_compute_client.AndroidComputeClient(
506            cfg, credentials)
507        storage_client = gstorage_client.StorageClient(credentials)
508
509        # Cleanup expired instances
510        items = compute_client.ListInstances(zone=cfg.zone)
511        cleanup_list = [
512            item["name"]
513            for item in _FindOldItems(items, cut_time, "creationTimestamp")
514        ]
515        logger.info("Found expired instances: %s", cleanup_list)
516        for i in range(0, len(cleanup_list), MAX_BATCH_CLEANUP_COUNT):
517            result = compute_client.DeleteInstances(
518                instances=cleanup_list[i:i + MAX_BATCH_CLEANUP_COUNT],
519                zone=cfg.zone)
520            _AddDeletionResultToReport(r, *result, resource_name="instance")
521
522        # Cleanup expired images
523        items = compute_client.ListImages()
524        skip_list = cfg.precreated_data_image_map.viewvalues()
525        cleanup_list = [
526            item["name"]
527            for item in _FindOldItems(items, cut_time, "creationTimestamp")
528            if item["name"] not in skip_list
529        ]
530        logger.info("Found expired images: %s", cleanup_list)
531        for i in range(0, len(cleanup_list), MAX_BATCH_CLEANUP_COUNT):
532            result = compute_client.DeleteImages(
533                image_names=cleanup_list[i:i + MAX_BATCH_CLEANUP_COUNT])
534            _AddDeletionResultToReport(r, *result, resource_name="image")
535
536        # Cleanup expired disks
537        # Disks should have been attached to instances with autoDelete=True.
538        # However, sometimes disks may not be auto deleted successfully.
539        items = compute_client.ListDisks(zone=cfg.zone)
540        cleanup_list = [
541            item["name"]
542            for item in _FindOldItems(items, cut_time, "creationTimestamp")
543            if not item.get("users")
544        ]
545        logger.info("Found expired disks: %s", cleanup_list)
546        for i in range(0, len(cleanup_list), MAX_BATCH_CLEANUP_COUNT):
547            result = compute_client.DeleteDisks(
548                disk_names=cleanup_list[i:i + MAX_BATCH_CLEANUP_COUNT],
549                zone=cfg.zone)
550            _AddDeletionResultToReport(r, *result, resource_name="disk")
551
552        # Cleanup expired google storage
553        items = storage_client.List(bucket_name=cfg.storage_bucket_name)
554        cleanup_list = [
555            item["name"]
556            for item in _FindOldItems(items, cut_time, "timeCreated")
557        ]
558        logger.info("Found expired cached artifacts: %s", cleanup_list)
559        for i in range(0, len(cleanup_list), MAX_BATCH_CLEANUP_COUNT):
560            result = storage_client.DeleteFiles(
561                bucket_name=cfg.storage_bucket_name,
562                object_names=cleanup_list[i:i + MAX_BATCH_CLEANUP_COUNT])
563            _AddDeletionResultToReport(
564                r, *result, resource_name="cached_build_artifact")
565
566        # Everything succeeded, write status to report.
567        if r.status == report.Status.UNKNOWN:
568            r.SetStatus(report.Status.SUCCESS)
569    except errors.DriverError as e:
570        r.AddError(str(e))
571        r.SetStatus(report.Status.FAIL)
572    return r
573
574
575def AddSshRsa(cfg, user, ssh_rsa_path):
576    """Add public ssh rsa key to the project.
577
578    Args:
579        cfg: An AcloudConfig instance.
580        user: the name of the user which the key belongs to.
581        ssh_rsa_path: The absolute path to public rsa key.
582
583    Returns:
584        A Report instance.
585    """
586    r = report.Report(command="sshkey")
587    try:
588        credentials = auth.CreateCredentials(cfg, ALL_SCOPES)
589        compute_client = android_compute_client.AndroidComputeClient(
590            cfg, credentials)
591        compute_client.AddSshRsa(user, ssh_rsa_path)
592        r.SetStatus(report.Status.SUCCESS)
593    except errors.DriverError as e:
594        r.AddError(str(e))
595        r.SetStatus(report.Status.FAIL)
596    return r
597
598
599def CheckAccess(cfg):
600    """Check if user has access.
601
602    Args:
603         cfg: An AcloudConfig instance.
604    """
605    credentials = auth.CreateCredentials(cfg, ALL_SCOPES)
606    compute_client = android_compute_client.AndroidComputeClient(
607            cfg, credentials)
608    logger.info("Checking if user has access to project %s", cfg.project)
609    if not compute_client.CheckAccess():
610        logger.error("User does not have access to project %s", cfg.project)
611        # Print here so that command line user can see it.
612        print "Looks like you do not have access to %s. " % cfg.project
613        if cfg.project in cfg.no_project_access_msg_map:
614            print cfg.no_project_access_msg_map[cfg.project]
615