1# Copyright (c) 2012 The Chromium OS Authors. All rights reserved.
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
4
5import dbus, gobject, logging, os, stat
6from dbus.mainloop.glib import DBusGMainLoop
7
8import common
9from autotest_lib.client.bin import utils
10from autotest_lib.client.common_lib import autotemp, error
11from mainloop import ExceptionForward
12from mainloop import GenericTesterMainLoop
13
14
15"""This module contains several helper classes for writing tests to verify the
16CrosDisks DBus interface. In particular, the CrosDisksTester class can be used
17to derive functional tests that interact with the CrosDisks server over DBus.
18"""
19
20
21class ExceptionSuppressor(object):
22    """A context manager class for suppressing certain types of exception.
23
24    An instance of this class is expected to be used with the with statement
25    and takes a set of exception classes at instantiation, which are types of
26    exception to be suppressed (and logged) in the code block under the with
27    statement.
28
29    Example:
30
31        with ExceptionSuppressor(OSError, IOError):
32            # An exception, which is a sub-class of OSError or IOError, is
33            # suppressed in the block code under the with statement.
34    """
35    def __init__(self, *args):
36        self.__suppressed_exc_types = (args)
37
38    def __enter__(self):
39        return self
40
41    def __exit__(self, exc_type, exc_value, traceback):
42        if exc_type and issubclass(exc_type, self.__suppressed_exc_types):
43            try:
44                logging.exception('Suppressed exception: %s(%s)',
45                                  exc_type, exc_value)
46            except Exception:
47                pass
48            return True
49        return False
50
51
52class DBusClient(object):
53    """ A base class of a DBus proxy client to test a DBus server.
54
55    This class is expected to be used along with a GLib main loop and provides
56    some convenient functions for testing the DBus API exposed by a DBus server.
57    """
58    def __init__(self, main_loop, bus, bus_name, object_path):
59        """Initializes the instance.
60
61        Args:
62            main_loop: The GLib main loop.
63            bus: The bus where the DBus server is connected to.
64            bus_name: The bus name owned by the DBus server.
65            object_path: The object path of the DBus server.
66        """
67        self.__signal_content = {}
68        self.main_loop = main_loop
69        self.signal_timeout_in_seconds = 10
70        logging.debug('Getting D-Bus proxy object on bus "%s" and path "%s"',
71                      bus_name, object_path)
72        self.proxy_object = bus.get_object(bus_name, object_path)
73
74    def clear_signal_content(self, signal_name):
75        """Clears the content of the signal.
76
77        Args:
78            signal_name: The name of the signal.
79        """
80        if signal_name in self.__signal_content:
81            self.__signal_content[signal_name] = None
82
83    def get_signal_content(self, signal_name):
84        """Gets the content of a signal.
85
86        Args:
87            signal_name: The name of the signal.
88
89        Returns:
90            The content of a signal or None if the signal is not being handled.
91        """
92        return self.__signal_content.get(signal_name)
93
94    def handle_signal(self, interface, signal_name, argument_names=()):
95        """Registers a signal handler to handle a given signal.
96
97        Args:
98            interface: The DBus interface of the signal.
99            signal_name: The name of the signal.
100            argument_names: A list of argument names that the signal contains.
101        """
102        if signal_name in self.__signal_content:
103            return
104
105        self.__signal_content[signal_name] = None
106
107        def signal_handler(*args):
108            self.__signal_content[signal_name] = dict(zip(argument_names, args))
109
110        logging.debug('Handling D-Bus signal "%s(%s)" on interface "%s"',
111                      signal_name, ', '.join(argument_names), interface)
112        self.proxy_object.connect_to_signal(signal_name, signal_handler,
113                                            interface)
114
115    def wait_for_signal(self, signal_name):
116        """Waits for the reception of a signal.
117
118        Args:
119            signal_name: The name of the signal to wait for.
120
121        Returns:
122            The content of the signal.
123        """
124        if signal_name not in self.__signal_content:
125            return None
126
127        def check_signal_content():
128            context = self.main_loop.get_context()
129            while context.iteration(False):
130                pass
131            return self.__signal_content[signal_name] is not None
132
133        logging.debug('Waiting for D-Bus signal "%s"', signal_name)
134        utils.poll_for_condition(condition=check_signal_content,
135                                 desc='%s signal' % signal_name,
136                                 timeout=self.signal_timeout_in_seconds)
137        content = self.__signal_content[signal_name]
138        logging.debug('Received D-Bus signal "%s(%s)"', signal_name, content)
139        self.__signal_content[signal_name] = None
140        return content
141
142    def expect_signal(self, signal_name, expected_content):
143        """Waits the the reception of a signal and verifies its content.
144
145        Args:
146            signal_name: The name of the signal to wait for.
147            expected_content: The expected content of the signal, which can be
148                              partially specified. Only specified fields are
149                              compared between the actual and expected content.
150
151        Returns:
152            The actual content of the signal.
153
154        Raises:
155            error.TestFail: A test failure when there is a mismatch between the
156                            actual and expected content of the signal.
157        """
158        actual_content = self.wait_for_signal(signal_name)
159        logging.debug("%s signal: expected=%s actual=%s",
160                      signal_name, expected_content, actual_content)
161        for argument, expected_value in expected_content.iteritems():
162            if argument not in actual_content:
163                raise error.TestFail(
164                    ('%s signal missing "%s": expected=%s, actual=%s') %
165                    (signal_name, argument, expected_content, actual_content))
166
167            if actual_content[argument] != expected_value:
168                raise error.TestFail(
169                    ('%s signal not matched on "%s": expected=%s, actual=%s') %
170                    (signal_name, argument, expected_content, actual_content))
171        return actual_content
172
173
174class CrosDisksClient(DBusClient):
175    """A DBus proxy client for testing the CrosDisks DBus server.
176    """
177
178    CROS_DISKS_BUS_NAME = 'org.chromium.CrosDisks'
179    CROS_DISKS_INTERFACE = 'org.chromium.CrosDisks'
180    CROS_DISKS_OBJECT_PATH = '/org/chromium/CrosDisks'
181    DBUS_PROPERTIES_INTERFACE = 'org.freedesktop.DBus.Properties'
182    FORMAT_COMPLETED_SIGNAL = 'FormatCompleted'
183    FORMAT_COMPLETED_SIGNAL_ARGUMENTS = (
184        'status', 'path'
185    )
186    MOUNT_COMPLETED_SIGNAL = 'MountCompleted'
187    MOUNT_COMPLETED_SIGNAL_ARGUMENTS = (
188        'status', 'source_path', 'source_type', 'mount_path'
189    )
190
191    def __init__(self, main_loop, bus):
192        """Initializes the instance.
193
194        Args:
195            main_loop: The GLib main loop.
196            bus: The bus where the DBus server is connected to.
197        """
198        super(CrosDisksClient, self).__init__(main_loop, bus,
199                                              self.CROS_DISKS_BUS_NAME,
200                                              self.CROS_DISKS_OBJECT_PATH)
201        self.interface = dbus.Interface(self.proxy_object,
202                                        self.CROS_DISKS_INTERFACE)
203        self.properties = dbus.Interface(self.proxy_object,
204                                         self.DBUS_PROPERTIES_INTERFACE)
205        self.handle_signal(self.CROS_DISKS_INTERFACE,
206                           self.FORMAT_COMPLETED_SIGNAL,
207                           self.FORMAT_COMPLETED_SIGNAL_ARGUMENTS)
208        self.handle_signal(self.CROS_DISKS_INTERFACE,
209                           self.MOUNT_COMPLETED_SIGNAL,
210                           self.MOUNT_COMPLETED_SIGNAL_ARGUMENTS)
211
212    def is_alive(self):
213        """Invokes the CrosDisks IsAlive method.
214
215        Returns:
216            True if the CrosDisks server is alive or False otherwise.
217        """
218        return self.interface.IsAlive()
219
220    def enumerate_auto_mountable_devices(self):
221        """Invokes the CrosDisks EnumerateAutoMountableDevices method.
222
223        Returns:
224            A list of sysfs paths of devices that are auto-mountable by
225            CrosDisks.
226        """
227        return self.interface.EnumerateAutoMountableDevices()
228
229    def enumerate_devices(self):
230        """Invokes the CrosDisks EnumerateMountableDevices method.
231
232        Returns:
233            A list of sysfs paths of devices that are recognized by
234            CrosDisks.
235        """
236        return self.interface.EnumerateDevices()
237
238    def get_device_properties(self, path):
239        """Invokes the CrosDisks GetDeviceProperties method.
240
241        Args:
242            path: The device path.
243
244        Returns:
245            The properties of the device in a dictionary.
246        """
247        return self.interface.GetDeviceProperties(path)
248
249    def format(self, path, filesystem_type=None, options=None):
250        """Invokes the CrosDisks Format method.
251
252        Args:
253            path: The device path to format.
254            filesystem_type: The filesystem type used for formatting the device.
255            options: A list of options used for formatting the device.
256        """
257        if filesystem_type is None:
258            filesystem_type = ''
259        if options is None:
260            options = []
261        self.clear_signal_content(self.FORMAT_COMPLETED_SIGNAL)
262        self.interface.Format(path, filesystem_type, options)
263
264    def wait_for_format_completion(self):
265        """Waits for the CrosDisks FormatCompleted signal.
266
267        Returns:
268            The content of the FormatCompleted signal.
269        """
270        return self.wait_for_signal(self.FORMAT_COMPLETED_SIGNAL)
271
272    def expect_format_completion(self, expected_content):
273        """Waits and verifies for the CrosDisks FormatCompleted signal.
274
275        Args:
276            expected_content: The expected content of the FormatCompleted
277                              signal, which can be partially specified.
278                              Only specified fields are compared between the
279                              actual and expected content.
280
281        Returns:
282            The actual content of the FormatCompleted signal.
283
284        Raises:
285            error.TestFail: A test failure when there is a mismatch between the
286                            actual and expected content of the FormatCompleted
287                            signal.
288        """
289        return self.expect_signal(self.FORMAT_COMPLETED_SIGNAL,
290                                  expected_content)
291
292    def mount(self, path, filesystem_type=None, options=None):
293        """Invokes the CrosDisks Mount method.
294
295        Args:
296            path: The device path to mount.
297            filesystem_type: The filesystem type used for mounting the device.
298            options: A list of options used for mounting the device.
299        """
300        if filesystem_type is None:
301            filesystem_type = ''
302        if options is None:
303            options = []
304        self.clear_signal_content(self.MOUNT_COMPLETED_SIGNAL)
305        self.interface.Mount(path, filesystem_type, options)
306
307    def unmount(self, path, options=None):
308        """Invokes the CrosDisks Unmount method.
309
310        Args:
311            path: The device or mount path to unmount.
312            options: A list of options used for unmounting the path.
313        """
314        if options is None:
315            options = []
316        self.interface.Unmount(path, options)
317
318    def wait_for_mount_completion(self):
319        """Waits for the CrosDisks MountCompleted signal.
320
321        Returns:
322            The content of the MountCompleted signal.
323        """
324        return self.wait_for_signal(self.MOUNT_COMPLETED_SIGNAL)
325
326    def expect_mount_completion(self, expected_content):
327        """Waits and verifies for the CrosDisks MountCompleted signal.
328
329        Args:
330            expected_content: The expected content of the MountCompleted
331                              signal, which can be partially specified.
332                              Only specified fields are compared between the
333                              actual and expected content.
334
335        Returns:
336            The actual content of the MountCompleted signal.
337
338        Raises:
339            error.TestFail: A test failure when there is a mismatch between the
340                            actual and expected content of the MountCompleted
341                            signal.
342        """
343        return self.expect_signal(self.MOUNT_COMPLETED_SIGNAL,
344                                  expected_content)
345
346
347class CrosDisksTester(GenericTesterMainLoop):
348    """A base tester class for testing the CrosDisks server.
349
350    A derived class should override the get_tests method to return a list of
351    test methods. The perform_one_test method invokes each test method in the
352    list to verify some functionalities of CrosDisks server.
353    """
354    def __init__(self, test):
355        bus_loop = DBusGMainLoop(set_as_default=True)
356        bus = dbus.SystemBus(mainloop=bus_loop)
357        self.main_loop = gobject.MainLoop()
358        super(CrosDisksTester, self).__init__(test, self.main_loop)
359        self.cros_disks = CrosDisksClient(self.main_loop, bus)
360
361    def get_tests(self):
362        """Returns a list of test methods to be invoked by perform_one_test.
363
364        A derived class should override this method.
365
366        Returns:
367            A list of test methods.
368        """
369        return []
370
371    @ExceptionForward
372    def perform_one_test(self):
373        """Exercises each test method in the list returned by get_tests.
374        """
375        tests = self.get_tests()
376        self.remaining_requirements = set([test.func_name for test in tests])
377        for test in tests:
378            test()
379            self.requirement_completed(test.func_name)
380
381
382class FilesystemTestObject(object):
383    """A base class to represent a filesystem test object.
384
385    A filesystem test object can be a file, directory or symbolic link.
386    A derived class should override the _create and _verify method to implement
387    how the test object should be created and verified, respectively, on a
388    filesystem.
389    """
390    def __init__(self, path, content, mode):
391        """Initializes the instance.
392
393        Args:
394            path: The relative path of the test object.
395            content: The content of the test object.
396            mode: The file permissions given to the test object.
397        """
398        self._path = path
399        self._content = content
400        self._mode = mode
401
402    def create(self, base_dir):
403        """Creates the test object in a base directory.
404
405        Args:
406            base_dir: The base directory where the test object is created.
407
408        Returns:
409            True if the test object is created successfully or False otherwise.
410        """
411        if not self._create(base_dir):
412            logging.debug('Failed to create filesystem test object at "%s"',
413                          os.path.join(base_dir, self._path))
414            return False
415        return True
416
417    def verify(self, base_dir):
418        """Verifies the test object in a base directory.
419
420        Args:
421            base_dir: The base directory where the test object is expected to be
422                      found.
423
424        Returns:
425            True if the test object is found in the base directory and matches
426            the expected content, or False otherwise.
427        """
428        if not self._verify(base_dir):
429            logging.debug('Failed to verify filesystem test object at "%s"',
430                          os.path.join(base_dir, self._path))
431            return False
432        return True
433
434    def _create(self, base_dir):
435        return False
436
437    def _verify(self, base_dir):
438        return False
439
440
441class FilesystemTestDirectory(FilesystemTestObject):
442    """A filesystem test object that represents a directory."""
443
444    def __init__(self, path, content, mode=stat.S_IRWXU|stat.S_IRGRP| \
445                 stat.S_IXGRP|stat.S_IROTH|stat.S_IXOTH):
446        super(FilesystemTestDirectory, self).__init__(path, content, mode)
447
448    def _create(self, base_dir):
449        path = os.path.join(base_dir, self._path) if self._path else base_dir
450
451        if self._path:
452            with ExceptionSuppressor(OSError):
453                os.makedirs(path)
454                os.chmod(path, self._mode)
455
456        if not os.path.isdir(path):
457            return False
458
459        for content in self._content:
460            if not content.create(path):
461                return False
462        return True
463
464    def _verify(self, base_dir):
465        path = os.path.join(base_dir, self._path) if self._path else base_dir
466        if not os.path.isdir(path):
467            return False
468
469        for content in self._content:
470            if not content.verify(path):
471                return False
472        return True
473
474
475class FilesystemTestFile(FilesystemTestObject):
476    """A filesystem test object that represents a file."""
477
478    def __init__(self, path, content, mode=stat.S_IRUSR|stat.S_IWUSR| \
479                 stat.S_IRGRP|stat.S_IROTH):
480        super(FilesystemTestFile, self).__init__(path, content, mode)
481
482    def _create(self, base_dir):
483        path = os.path.join(base_dir, self._path)
484        with ExceptionSuppressor(IOError):
485            with open(path, 'wb+') as f:
486                f.write(self._content)
487            with ExceptionSuppressor(OSError):
488                os.chmod(path, self._mode)
489            return True
490        return False
491
492    def _verify(self, base_dir):
493        path = os.path.join(base_dir, self._path)
494        with ExceptionSuppressor(IOError):
495            with open(path, 'rb') as f:
496                return f.read() == self._content
497        return False
498
499
500class DefaultFilesystemTestContent(FilesystemTestDirectory):
501    def __init__(self):
502        super(DefaultFilesystemTestContent, self).__init__('', [
503            FilesystemTestFile('file1', '0123456789'),
504            FilesystemTestDirectory('dir1', [
505                FilesystemTestFile('file1', ''),
506                FilesystemTestFile('file2', 'abcdefg'),
507                FilesystemTestDirectory('dir2', [
508                    FilesystemTestFile('file3', 'abcdefg'),
509                ]),
510            ]),
511        ], stat.S_IRWXU|stat.S_IRGRP|stat.S_IXGRP|stat.S_IROTH|stat.S_IXOTH)
512
513
514class VirtualFilesystemImage(object):
515    def __init__(self, block_size, block_count, filesystem_type,
516                 *args, **kwargs):
517        """Initializes the instance.
518
519        Args:
520            block_size: The number of bytes of each block in the image.
521            block_count: The number of blocks in the image.
522            filesystem_type: The filesystem type to be given to the mkfs
523                             program for formatting the image.
524
525        Keyword Args:
526            mount_filesystem_type: The filesystem type to be given to the
527                                   mount program for mounting the image.
528            mkfs_options: A list of options to be given to the mkfs program.
529        """
530        self._block_size = block_size
531        self._block_count = block_count
532        self._filesystem_type = filesystem_type
533        self._mount_filesystem_type = kwargs.get('mount_filesystem_type')
534        if self._mount_filesystem_type is None:
535            self._mount_filesystem_type = filesystem_type
536        self._mkfs_options = kwargs.get('mkfs_options')
537        if self._mkfs_options is None:
538            self._mkfs_options = []
539        self._image_file = None
540        self._loop_device = None
541        self._mount_dir = None
542
543    def __del__(self):
544        with ExceptionSuppressor(Exception):
545            self.clean()
546
547    def __enter__(self):
548        self.create()
549        return self
550
551    def __exit__(self, exc_type, exc_value, traceback):
552        self.clean()
553        return False
554
555    def _remove_temp_path(self, temp_path):
556        """Removes a temporary file or directory created using autotemp."""
557        if temp_path:
558            with ExceptionSuppressor(Exception):
559                path = temp_path.name
560                temp_path.clean()
561                logging.debug('Removed "%s"', path)
562
563    def _remove_image_file(self):
564        """Removes the image file if one has been created."""
565        self._remove_temp_path(self._image_file)
566        self._image_file = None
567
568    def _remove_mount_dir(self):
569        """Removes the mount directory if one has been created."""
570        self._remove_temp_path(self._mount_dir)
571        self._mount_dir = None
572
573    @property
574    def image_file(self):
575        """Gets the path of the image file.
576
577        Returns:
578            The path of the image file or None if no image file has been
579            created.
580        """
581        return self._image_file.name if self._image_file else None
582
583    @property
584    def loop_device(self):
585        """Gets the loop device where the image file is attached to.
586
587        Returns:
588            The path of the loop device where the image file is attached to or
589            None if no loop device is attaching the image file.
590        """
591        return self._loop_device
592
593    @property
594    def mount_dir(self):
595        """Gets the directory where the image file is mounted to.
596
597        Returns:
598            The directory where the image file is mounted to or None if no
599            mount directory has been created.
600        """
601        return self._mount_dir.name if self._mount_dir else None
602
603    def create(self):
604        """Creates a zero-filled image file with the specified size.
605
606        The created image file is temporary and removed when clean()
607        is called.
608        """
609        self.clean()
610        self._image_file = autotemp.tempfile(unique_id='fsImage')
611        try:
612            logging.debug('Creating zero-filled image file at "%s"',
613                          self._image_file.name)
614            utils.run('dd if=/dev/zero of=%s bs=%s count=%s' %
615                      (self._image_file.name, self._block_size,
616                       self._block_count))
617        except error.CmdError as exc:
618            self._remove_image_file()
619            message = 'Failed to create filesystem image: %s' % exc
620            raise RuntimeError(message)
621
622    def clean(self):
623        """Removes the image file if one has been created.
624
625        Before removal, the image file is detached from the loop device that
626        it is attached to.
627        """
628        self.detach_from_loop_device()
629        self._remove_image_file()
630
631    def attach_to_loop_device(self):
632        """Attaches the created image file to a loop device.
633
634        Creates the image file, if one has not been created, by calling
635        create().
636
637        Returns:
638            The path of the loop device where the image file is attached to.
639        """
640        if self._loop_device:
641            return self._loop_device
642
643        if not self._image_file:
644            self.create()
645
646        logging.debug('Attaching image file "%s" to loop device',
647                      self._image_file.name)
648        utils.run('losetup -f %s' % self._image_file.name)
649        output = utils.system_output('losetup -j %s' % self._image_file.name)
650        # output should look like: "/dev/loop0: [000d]:6329 (/tmp/test.img)"
651        self._loop_device = output.split(':')[0]
652        logging.debug('Attached image file "%s" to loop device "%s"',
653                      self._image_file.name, self._loop_device)
654        return self._loop_device
655
656    def detach_from_loop_device(self):
657        """Detaches the image file from the loop device."""
658        if not self._loop_device:
659            return
660
661        self.unmount()
662
663        logging.debug('Cleaning up remaining mount points of loop device "%s"',
664                      self._loop_device)
665        utils.run('umount -f %s' % self._loop_device, ignore_status=True)
666
667        logging.debug('Detaching image file "%s" from loop device "%s"',
668                      self._image_file.name, self._loop_device)
669        utils.run('losetup -d %s' % self._loop_device)
670        self._loop_device = None
671
672    def format(self):
673        """Formats the image file as the specified filesystem."""
674        self.attach_to_loop_device()
675        try:
676            logging.debug('Formatting image file at "%s" as "%s" filesystem',
677                          self._image_file.name, self._filesystem_type)
678            utils.run('yes | mkfs -t %s %s %s' %
679                      (self._filesystem_type, ' '.join(self._mkfs_options),
680                       self._loop_device))
681            logging.debug('blkid: %s', utils.system_output(
682                'blkid -c /dev/null %s' % self._loop_device,
683                ignore_status=True))
684        except error.CmdError as exc:
685            message = 'Failed to format filesystem image: %s' % exc
686            raise RuntimeError(message)
687
688    def mount(self, options=None):
689        """Mounts the image file to a directory.
690
691        Args:
692            options: An optional list of mount options.
693        """
694        if self._mount_dir:
695            return self._mount_dir.name
696
697        if options is None:
698            options = []
699
700        options_arg = ','.join(options)
701        if options_arg:
702            options_arg = '-o ' + options_arg
703
704        self.attach_to_loop_device()
705        self._mount_dir = autotemp.tempdir(unique_id='fsImage')
706        try:
707            logging.debug('Mounting image file "%s" (%s) to directory "%s"',
708                          self._image_file.name, self._loop_device,
709                          self._mount_dir.name)
710            utils.run('mount -t %s %s %s %s' %
711                      (self._mount_filesystem_type, options_arg,
712                       self._loop_device, self._mount_dir.name))
713        except error.CmdError as exc:
714            self._remove_mount_dir()
715            message = ('Failed to mount virtual filesystem image "%s": %s' %
716                       (self._image_file.name, exc))
717            raise RuntimeError(message)
718        return self._mount_dir.name
719
720    def unmount(self):
721        """Unmounts the image file from the mounted directory."""
722        if not self._mount_dir:
723            return
724
725        try:
726            logging.debug('Unmounting image file "%s" (%s) from directory "%s"',
727                          self._image_file.name, self._loop_device,
728                          self._mount_dir.name)
729            utils.run('umount %s' % self._mount_dir.name)
730        except error.CmdError as exc:
731            message = ('Failed to unmount virtual filesystem image "%s": %s' %
732                       (self._image_file.name, exc))
733            raise RuntimeError(message)
734        finally:
735            self._remove_mount_dir()
736