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