# Copyright (c) 2012 The Chromium OS Authors. All rights reserved. # Use of this source code is governed by a BSD-style license that can be # found in the LICENSE file. """Storage device utilities to be used in storage device based tests """ import logging, re, os, time, hashlib from autotest_lib.client.bin import test, utils from autotest_lib.client.common_lib import error from autotest_lib.client.cros import liststorage class StorageException(error.TestError): """Indicates that a storage/volume operation failed. It is fatal to the test unless caught. """ pass class StorageScanner(object): """Scan device for storage points. It also performs basic operations on found storage devices as mount/umount, creating file with randomized content or checksum file content. Each storage device is defined by a dictionary containing the following keys: device: the device path (e.g. /dev/sdb1) bus: the bus name (e.g. usb, ata, etc) model: the kind of device (e.g. Multi-Card, USB_DISK_2.0, SanDisk) size: the size of the volume/partition ib bytes (int) fs_uuid: the UUID for the filesystem (str) fstype: filesystem type is_mounted: wether the FS is mounted (0=False,1=True) mountpoint: where the FS is mounted (if mounted=1) or a suggestion where to mount it (if mounted=0) Also |filter()| and |scan()| will use the same dictionary keys associated with regular expression in order to filter a result set. Multiple keys act in an AND-fashion way. The absence of a key in the filter make the filter matching all the values for said key in the storage dictionary. Example: {'device':'/dev/sd[ab]1', 'is_mounted':'0'} will match all the found devices which block device file is either /dev/sda1 or /dev/sdb1, AND are not mounted, excluding all other devices from the matched result. """ storages = None def __init__(self): self.__mounted = {} def filter(self, storage_filter={}): """Filters a stored result returning a list of matching devices. The passed dictionary represent the filter and its values are regular expressions (str). If an element of self.storage matches the regex defined in all the keys for a filter, the item will be part of the returning value. Calling this method does not change self.storages, thus can be called several times against the same result set. @param storage_filter: a dictionary representing the filter. @return a list of dictionaries representing the found devices after the application of the filter. The list can be empty if no device has been found. """ ret = [] for storage in self.storages: matches = True for key in storage_filter: if not re.match(storage_filter[key], storage[key]): matches = False break if matches: ret.append(storage.copy()) return ret def scan(self, storage_filter={}): """Scan the current storage devices. If no parameter is given, it will return all the storage devices found. Otherwise it will internally call self.filter() with the passed filter. The result (being it filtered or not) will be saved in self.storages. Such list can be (re)-filtered using self.filter(). @param storage_filter: a dict representing the filter, default is matching anything. @return a list of found dictionaries representing the found devices. The list can be empty if no device has been found. """ self.storages = liststorage.get_all() if storage_filter: self.storages = self.filter(storage_filter) return self.storages def mount_volume(self, index=None, storage_dict=None, args=''): """Mount the passed volume. Either index or storage_dict can be set, but not both at the same time. If neither is passed, it will mount the first volume found in self.storage. @param index: (int) the index in self.storages for the storage device/volume to be mounted. @param storage_dict: (dict) the storage dictionary representing the storage device, the dictionary should be obtained from self.storage or using self.scan() or self.filter(). @param args: (str) args to be passed to the mount command, if needed. e.g., "-o foo,bar -t ext3". """ if index is None and storage_dict is None: storage_dict = self.storages[0] elif isinstance(index, int): storage_dict = self.storages[index] elif not isinstance(storage_dict, dict): raise TypeError('Either index or storage_dict passed ' 'with the wrong type') if storage_dict['is_mounted']: logging.debug('Volume "%s" is already mounted, skipping ' 'mount_volume().') return logging.info('Mounting %(device)s in %(mountpoint)s.', storage_dict) try: # Create the dir in case it does not exist. os.mkdir(storage_dict['mountpoint']) except OSError, e: # If it's not "file exists", report the exception. if e.errno != 17: raise e cmd = 'mount %s' % args cmd += ' %(device)s %(mountpoint)s' % storage_dict utils.system(cmd) storage_dict['is_mounted'] = True self.__mounted[storage_dict['mountpoint']] = storage_dict def umount_volume(self, index=None, storage_dict=None, args=''): """Un-mount the passed volume, by index or storage dictionary. Either index or storage_dict can be set, but not both at the same time. If neither is passed, it will mount the first volume found in self.storage. @param index: (int) the index in self.storages for the storage device/volume to be mounted. @param storage_dict: (dict) the storage dictionary representing the storage device, the dictionary should be obtained from self.storage or using self.scan() or self.filter(). @param args: (str) args to be passed to the umount command, if needed. e.g., '-f -t' for force+lazy umount. """ if index is None and storage_dict is None: storage_dict = self.storages[0] elif isinstance(index, int): storage_dict = self.storages[index] elif not isinstance(storage_dict, dict): raise TypeError('Either index or storage_dict passed ' 'with the wrong type') if not storage_dict['is_mounted']: logging.debug('Volume "%s" is already unmounted: skipping ' 'umount_volume().') return logging.info('Unmounting %(device)s from %(mountpoint)s.', storage_dict) cmd = 'umount %s' % args cmd += ' %(device)s' % storage_dict utils.system(cmd) # We don't care if it fails, it might be busy for a /proc/mounts issue. # See BUG=chromium-os:32105 try: os.rmdir(storage_dict['mountpoint']) except OSError, e: logging.debug('Removing %s failed: %s: ignoring.', storage_dict['mountpoint'], e) storage_dict['is_mounted'] = False # If we previously mounted it, remove it from our internal list. if storage_dict['mountpoint'] in self.__mounted: del self.__mounted[storage_dict['mountpoint']] def unmount_all(self): """Unmount all volumes mounted by self.mount_volume(). """ # We need to copy it since we are iterating over a dict which will # change size. for volume in self.__mounted.copy(): self.umount_volume(storage_dict=self.__mounted[volume]) class StorageTester(test.test): """This is a class all tests about Storage can use. It has methods to - create random files - compute a file's md5 checksum - look/wait for a specific device (specified using StorageScanner dictionary format) Subclasses can override the _prepare_volume() method in order to disable them or change their behaviours. Subclasses should take care of unmount all the mounted filesystems when needed (e.g. on cleanup phase), calling self.umount_volume() or self.unmount_all(). """ scanner = None def initialize(self, filter_dict={'bus':'usb'}, filesystem='ext2'): """Initialize the test. Instantiate a StorageScanner instance to be used by tests and prepare any volume matched by |filter_dict|. Volume preparation is done by the _prepare_volume() method, which can be overriden by subclasses. @param filter_dict: a dictionary to filter attached USB devices to be initialized. @param filesystem: the filesystem name to format the attached device. """ super(StorageTester, self).initialize() self.scanner = StorageScanner() self._prepare_volume(filter_dict, filesystem=filesystem) # Be sure that if any operation above uses self.scanner related # methods, its result is cleaned after use. self.storages = None def _prepare_volume(self, filter_dict, filesystem='ext2'): """Prepare matching volumes for test. Prepare all the volumes matching |filter_dict| for test by formatting the matching storages with |filesystem|. This method is called by StorageTester.initialize(), a subclass can override this method to change its behaviour. Setting it to None (or a not callable) will disable it. @param filter_dict: a filter for the storages to be prepared. @param filesystem: filesystem with which volumes will be formatted. """ if not os.path.isfile('/sbin/mkfs.%s' % filesystem): raise error.TestError('filesystem not supported by mkfs installed ' 'on this device') try: storages = self.wait_for_devices(filter_dict, cycles=1, mount_volume=False)[0] for storage in storages: logging.debug('Preparing volume on %s.', storage['device']) cmd = 'mkfs.%s %s' % (filesystem, storage['device']) utils.system(cmd) except StorageException, e: logging.warning("%s._prepare_volume() didn't find any device " "attached: skipping volume preparation: %s", self.__class__.__name__, e) except error.CmdError, e: logging.warning("%s._prepare_volume() couldn't format volume: %s", self.__class__.__name__, e) logging.debug('Volume preparation finished.') def wait_for_devices(self, storage_filter, time_to_sleep=1, cycles=10, mount_volume=True): """Cycles |cycles| times waiting |time_to_sleep| seconds each cycle, looking for a device matching |storage_filter| @param storage_filter: a dictionary holding a set of storage device's keys which are used as filter, to look for devices. @see StorageDevice class documentation. @param time_to_sleep: time (int) to wait after each |cycles|. @param cycles: number of tentatives. Use -1 for infinite. @raises StorageException if no device can be found. @return (storage_dict, waited_time) tuple. storage_dict is the found device list and waited_time is the time spent waiting for the device to be found. """ msg = ('Scanning for %s for %d times, waiting each time ' '%d secs' % (storage_filter, cycles, time_to_sleep)) if mount_volume: logging.debug('%s and mounting each matched volume.', msg) else: logging.debug('%s, but not mounting each matched volume.', msg) if cycles == -1: logging.info('Waiting until device is inserted, ' 'no timeout has been set.') cycle = 0 while cycles == -1 or cycle < cycles: ret = self.scanner.scan(storage_filter) if ret: logging.debug('Found %s (mount_volume=%d).', ret, mount_volume) if mount_volume: for storage in ret: self.scanner.mount_volume(storage_dict=storage) return (ret, cycle*time_to_sleep) else: logging.debug('Storage %s not found, wait and rescan ' '(cycle %d).', storage_filter, cycle) # Wait a bit and rescan storage list. time.sleep(time_to_sleep) cycle += 1 # Device still not found. msg = ('Could not find anything matching "%s" after %d seconds' % (storage_filter, time_to_sleep*cycles)) raise StorageException(msg) def wait_for_device(self, storage_filter, time_to_sleep=1, cycles=10, mount_volume=True): """Cycles |cycles| times waiting |time_to_sleep| seconds each cycle, looking for a device matching |storage_filter|. This method needs to match one and only one device. @raises StorageException if no device can be found or more than one is found. @param storage_filter: a dictionary holding a set of storage device's keys which are used as filter, to look for devices The filter has to be match a single device, a multiple matching filter will lead to StorageException to e risen. Use self.wait_for_devices() if more than one device is allowed to be found. @see StorageDevice class documentation. @param time_to_sleep: time (int) to wait after each |cycles|. @param cycles: number of tentatives. Use -1 for infinite. @return (storage_dict, waited_time) tuple. storage_dict is the found device list and waited_time is the time spent waiting for the device to be found. """ storages, waited_time = self.wait_for_devices(storage_filter, time_to_sleep=time_to_sleep, cycles=cycles, mount_volume=mount_volume) if len(storages) > 1: msg = ('filter matched more than one storage volume, use ' '%s.wait_for_devices() if you need more than one match' % self.__class__) raise StorageException(msg) # Return the first element if only this one has been matched. return (storages[0], waited_time) # Some helpers not present in utils.py to abstract normal file operations. def create_file(path, size): """Create a file using /dev/urandom. @param path: the path of the file. @param size: the file size in bytes. """ logging.debug('Creating %s (size %d) from /dev/urandom.', path, size) with file('/dev/urandom', 'rb') as urandom: utils.open_write_close(path, urandom.read(size)) def checksum_file(path): """Compute the MD5 Checksum for a file. @param path: the path of the file. @return a string with the checksum. """ chunk_size = 1024 m = hashlib.md5() with file(path, 'rb') as f: for chunk in f.read(chunk_size): m.update(chunk) logging.debug("MD5 checksum for %s is %s.", path, m.hexdigest()) return m.hexdigest() def args_to_storage_dict(args): """Map args into storage dictionaries. This function is to be used (likely) in control files to obtain a storage dictionary from command line arguments. @param args: a list of arguments as passed to control file. @return a tuple (storage_dict, rest_of_args) where storage_dict is a dictionary for storage filtering and rest_of_args is a dictionary of keys which do not match storage dict keys. """ args_dict = utils.args_to_dict(args) storage_dict = {} # A list of all allowed keys and their type. key_list = ('device', 'bus', 'model', 'size', 'fs_uuid', 'fstype', 'is_mounted', 'mountpoint') def set_if_exists(src, dst, key): """If |src| has |key| copies its value to |dst|. @return True if |key| exists in |src|, False otherwise. """ if key in src: dst[key] = src[key] return True else: return False for key in key_list: if set_if_exists(args_dict, storage_dict, key): del args_dict[key] # Return the storage dict and the leftovers of the args to be evaluated # later. return storage_dict, args_dict