blob: bc92f545d303b8a23a11ea023410ef3ad96f5013 [file] [log] [blame]
# Lint as: python2, python3
# 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 as 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 as 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 as e:
logging.warning("%s._prepare_volume() didn't find any device "
"attached: skipping volume preparation: %s",
self.__class__.__name__, e)
except error.CmdError as 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 open('/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 open(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