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 5 import dbus, gobject, logging, os, stat 6 from dbus.mainloop.glib import DBusGMainLoop 7 8 import common 9 from autotest_lib.client.bin import utils 10 from autotest_lib.client.common_lib import autotemp, error 11 from mainloop import ExceptionForward 12 from mainloop import GenericTesterMainLoop 13 14 15 """This module contains several helper classes for writing tests to verify the 16 CrosDisks DBus interface. In particular, the CrosDisksTester class can be used 17 to derive functional tests that interact with the CrosDisks server over DBus. 18 """ 19 20 21 class 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 52 class 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 174 class 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 347 class 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 382 class 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 441 class 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 475 class 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 500 class 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 514 class 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