1# Lint as: python2, python3
2# Copyright (c) 2012 The Chromium OS Authors. All rights reserved.
3# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5
6"""Storage device utilities to be used in storage device based tests
7"""
8
9import logging, re, os, time, hashlib
10
11from autotest_lib.client.bin import test, utils
12from autotest_lib.client.common_lib import error
13from autotest_lib.client.cros import liststorage
14
15
16class StorageException(error.TestError):
17    """Indicates that a storage/volume operation failed.
18    It is fatal to the test unless caught.
19    """
20    pass
21
22
23class StorageScanner(object):
24    """Scan device for storage points.
25
26    It also performs basic operations on found storage devices as mount/umount,
27    creating file with randomized content or checksum file content.
28
29    Each storage device is defined by a dictionary containing the following
30    keys:
31
32    device: the device path (e.g. /dev/sdb1)
33    bus: the bus name (e.g. usb, ata, etc)
34    model: the kind of device (e.g. Multi-Card, USB_DISK_2.0, SanDisk)
35    size: the size of the volume/partition ib bytes (int)
36    fs_uuid: the UUID for the filesystem (str)
37    fstype: filesystem type
38    is_mounted: wether the FS is mounted (0=False,1=True)
39    mountpoint: where the FS is mounted (if mounted=1) or a suggestion where to
40                mount it (if mounted=0)
41
42    Also |filter()| and |scan()| will use the same dictionary keys associated
43    with regular expression in order to filter a result set.
44    Multiple keys act in an AND-fashion way. The absence of a key in the filter
45    make the filter matching all the values for said key in the storage
46    dictionary.
47
48    Example: {'device':'/dev/sd[ab]1', 'is_mounted':'0'} will match all the
49    found devices which block device file is either /dev/sda1 or /dev/sdb1, AND
50    are not mounted, excluding all other devices from the matched result.
51    """
52    storages = None
53
54
55    def __init__(self):
56        self.__mounted = {}
57
58
59    def filter(self, storage_filter={}):
60        """Filters a stored result returning a list of matching devices.
61
62        The passed dictionary represent the filter and its values are regular
63        expressions (str). If an element of self.storage matches the regex
64        defined in all the keys for a filter, the item will be part of the
65        returning value.
66
67        Calling this method does not change self.storages, thus can be called
68        several times against the same result set.
69
70        @param storage_filter: a dictionary representing the filter.
71
72        @return a list of dictionaries representing the found devices after the
73                application of the filter. The list can be empty if no device
74                has been found.
75        """
76        ret = []
77
78        for storage in self.storages:
79            matches = True
80            for key in storage_filter:
81                if not re.match(storage_filter[key], storage[key]):
82                    matches = False
83                    break
84            if matches:
85                ret.append(storage.copy())
86
87        return ret
88
89
90    def scan(self, storage_filter={}):
91        """Scan the current storage devices.
92
93        If no parameter is given, it will return all the storage devices found.
94        Otherwise it will internally call self.filter() with the passed
95        filter.
96        The result (being it filtered or not) will be saved in self.storages.
97
98        Such list can be (re)-filtered using self.filter().
99
100        @param storage_filter: a dict representing the filter, default is
101                matching anything.
102
103        @return a list of found dictionaries representing the found devices.
104                 The list can be empty if no device has been found.
105        """
106        self.storages = liststorage.get_all()
107
108        if storage_filter:
109            self.storages = self.filter(storage_filter)
110
111        return self.storages
112
113
114    def mount_volume(self, index=None, storage_dict=None, args=''):
115        """Mount the passed volume.
116
117        Either index or storage_dict can be set, but not both at the same time.
118        If neither is passed, it will mount the first volume found in
119        self.storage.
120
121        @param index: (int) the index in self.storages for the storage
122                device/volume to be mounted.
123        @param storage_dict: (dict) the storage dictionary representing the
124                storage device, the dictionary should be obtained from
125                self.storage or using self.scan() or self.filter().
126        @param args: (str) args to be passed to the mount command, if needed.
127                     e.g., "-o foo,bar -t ext3".
128        """
129        if index is None and storage_dict is None:
130            storage_dict = self.storages[0]
131        elif isinstance(index, int):
132            storage_dict = self.storages[index]
133        elif not isinstance(storage_dict, dict):
134            raise TypeError('Either index or storage_dict passed '
135                            'with the wrong type')
136
137        if storage_dict['is_mounted']:
138            logging.debug('Volume "%s" is already mounted, skipping '
139                          'mount_volume().')
140            return
141
142        logging.info('Mounting %(device)s in %(mountpoint)s.', storage_dict)
143
144        try:
145            # Create the dir in case it does not exist.
146            os.mkdir(storage_dict['mountpoint'])
147        except OSError as e:
148            # If it's not "file exists", report the exception.
149            if e.errno != 17:
150                raise e
151        cmd = 'mount %s' % args
152        cmd += ' %(device)s %(mountpoint)s' % storage_dict
153        utils.system(cmd)
154        storage_dict['is_mounted'] = True
155        self.__mounted[storage_dict['mountpoint']] = storage_dict
156
157
158    def umount_volume(self, index=None, storage_dict=None, args=''):
159        """Un-mount the passed volume, by index or storage dictionary.
160
161        Either index or storage_dict can be set, but not both at the same time.
162        If neither is passed, it will mount the first volume found in
163        self.storage.
164
165        @param index: (int) the index in self.storages for the storage
166                device/volume to be mounted.
167        @param storage_dict: (dict) the storage dictionary representing the
168                storage device, the dictionary should be obtained from
169                self.storage or using self.scan() or self.filter().
170        @param args: (str) args to be passed to the umount command, if needed.
171                     e.g., '-f -t' for force+lazy umount.
172        """
173        if index is None and storage_dict is None:
174            storage_dict = self.storages[0]
175        elif isinstance(index, int):
176            storage_dict = self.storages[index]
177        elif not isinstance(storage_dict, dict):
178            raise TypeError('Either index or storage_dict passed '
179                            'with the wrong type')
180
181
182        if not storage_dict['is_mounted']:
183            logging.debug('Volume "%s" is already unmounted: skipping '
184                          'umount_volume().')
185            return
186
187        logging.info('Unmounting %(device)s from %(mountpoint)s.',
188                     storage_dict)
189        cmd = 'umount %s' % args
190        cmd += ' %(device)s' % storage_dict
191        utils.system(cmd)
192        # We don't care if it fails, it might be busy for a /proc/mounts issue.
193        # See BUG=chromium-os:32105
194        try:
195            os.rmdir(storage_dict['mountpoint'])
196        except OSError as e:
197            logging.debug('Removing %s failed: %s: ignoring.',
198                          storage_dict['mountpoint'], e)
199        storage_dict['is_mounted'] = False
200        # If we previously mounted it, remove it from our internal list.
201        if storage_dict['mountpoint'] in self.__mounted:
202            del self.__mounted[storage_dict['mountpoint']]
203
204
205    def unmount_all(self):
206        """Unmount all volumes mounted by self.mount_volume().
207        """
208        # We need to copy it since we are iterating over a dict which will
209        # change size.
210        for volume in self.__mounted.copy():
211            self.umount_volume(storage_dict=self.__mounted[volume])
212
213
214class StorageTester(test.test):
215    """This is a class all tests about Storage can use.
216
217    It has methods to
218    - create random files
219    - compute a file's md5 checksum
220    - look/wait for a specific device (specified using StorageScanner
221      dictionary format)
222
223    Subclasses can override the _prepare_volume() method in order to disable
224    them or change their behaviours.
225
226    Subclasses should take care of unmount all the mounted filesystems when
227    needed (e.g. on cleanup phase), calling self.umount_volume() or
228    self.unmount_all().
229    """
230    scanner = None
231
232
233    def initialize(self, filter_dict={'bus':'usb'}, filesystem='ext2'):
234        """Initialize the test.
235
236        Instantiate a StorageScanner instance to be used by tests and prepare
237        any volume matched by |filter_dict|.
238        Volume preparation is done by the _prepare_volume() method, which can be
239        overriden by subclasses.
240
241        @param filter_dict: a dictionary to filter attached USB devices to be
242                            initialized.
243        @param filesystem: the filesystem name to format the attached device.
244        """
245        super(StorageTester, self).initialize()
246
247        self.scanner = StorageScanner()
248
249        self._prepare_volume(filter_dict, filesystem=filesystem)
250
251        # Be sure that if any operation above uses self.scanner related
252        # methods, its result is cleaned after use.
253        self.storages = None
254
255
256    def _prepare_volume(self, filter_dict, filesystem='ext2'):
257        """Prepare matching volumes for test.
258
259        Prepare all the volumes matching |filter_dict| for test by formatting
260        the matching storages with |filesystem|.
261
262        This method is called by StorageTester.initialize(), a subclass can
263        override this method to change its behaviour.
264        Setting it to None (or a not callable) will disable it.
265
266        @param filter_dict: a filter for the storages to be prepared.
267        @param filesystem: filesystem with which volumes will be formatted.
268        """
269        if not os.path.isfile('/sbin/mkfs.%s' % filesystem):
270            raise error.TestError('filesystem not supported by mkfs installed '
271                                  'on this device')
272
273        try:
274            storages = self.wait_for_devices(filter_dict, cycles=1,
275                                             mount_volume=False)[0]
276
277            for storage in storages:
278                logging.debug('Preparing volume on %s.', storage['device'])
279                cmd = 'mkfs.%s %s' % (filesystem, storage['device'])
280                utils.system(cmd)
281        except StorageException as e:
282            logging.warning("%s._prepare_volume() didn't find any device "
283                            "attached: skipping volume preparation: %s",
284                            self.__class__.__name__, e)
285        except error.CmdError as e:
286            logging.warning("%s._prepare_volume() couldn't format volume: %s",
287                            self.__class__.__name__, e)
288
289        logging.debug('Volume preparation finished.')
290
291
292    def wait_for_devices(self, storage_filter, time_to_sleep=1, cycles=10,
293                         mount_volume=True):
294        """Cycles |cycles| times waiting |time_to_sleep| seconds each cycle,
295        looking for a device matching |storage_filter|
296
297        @param storage_filter: a dictionary holding a set of  storage device's
298                keys which are used as filter, to look for devices.
299                @see StorageDevice class documentation.
300        @param time_to_sleep: time (int) to wait after each |cycles|.
301        @param cycles: number of tentatives. Use -1 for infinite.
302
303        @raises StorageException if no device can be found.
304
305        @return (storage_dict, waited_time) tuple. storage_dict is the found
306                 device list and waited_time is the time spent waiting for the
307                 device to be found.
308        """
309        msg = ('Scanning for %s for %d times, waiting each time '
310               '%d secs' % (storage_filter, cycles, time_to_sleep))
311        if mount_volume:
312            logging.debug('%s and mounting each matched volume.', msg)
313        else:
314            logging.debug('%s, but not mounting each matched volume.', msg)
315
316        if cycles == -1:
317            logging.info('Waiting until device is inserted, '
318                         'no timeout has been set.')
319
320        cycle = 0
321        while cycles == -1 or cycle < cycles:
322            ret = self.scanner.scan(storage_filter)
323            if ret:
324                logging.debug('Found %s (mount_volume=%d).', ret, mount_volume)
325                if mount_volume:
326                    for storage in ret:
327                        self.scanner.mount_volume(storage_dict=storage)
328
329                return (ret, cycle*time_to_sleep)
330            else:
331                logging.debug('Storage %s not found, wait and rescan '
332                              '(cycle %d).', storage_filter, cycle)
333                # Wait a bit and rescan storage list.
334                time.sleep(time_to_sleep)
335                cycle += 1
336
337        # Device still not found.
338        msg = ('Could not find anything matching "%s" after %d seconds' %
339                (storage_filter, time_to_sleep*cycles))
340        raise StorageException(msg)
341
342
343    def wait_for_device(self, storage_filter, time_to_sleep=1, cycles=10,
344                        mount_volume=True):
345        """Cycles |cycles| times waiting |time_to_sleep| seconds each cycle,
346        looking for a device matching |storage_filter|.
347
348        This method needs to match one and only one device.
349        @raises StorageException if no device can be found or more than one is
350                 found.
351
352        @param storage_filter: a dictionary holding a set of  storage device's
353                keys which are used as filter, to look for devices
354                The filter has to be match a single device, a multiple matching
355                filter will lead to StorageException to e risen. Use
356                self.wait_for_devices() if more than one device is allowed to
357                be found.
358                @see StorageDevice class documentation.
359        @param time_to_sleep: time (int) to wait after each |cycles|.
360        @param cycles: number of tentatives. Use -1 for infinite.
361
362        @return (storage_dict, waited_time) tuple. storage_dict is the found
363                 device list and waited_time is the time spent waiting for the
364                 device to be found.
365        """
366        storages, waited_time = self.wait_for_devices(storage_filter,
367            time_to_sleep=time_to_sleep,
368            cycles=cycles,
369            mount_volume=mount_volume)
370        if len(storages) > 1:
371            msg = ('filter matched more than one storage volume, use '
372                '%s.wait_for_devices() if you need more than one match' %
373                self.__class__)
374            raise StorageException(msg)
375
376        # Return the first element if only this one has been matched.
377        return (storages[0], waited_time)
378
379
380# Some helpers not present in utils.py to abstract normal file operations.
381
382def create_file(path, size):
383    """Create a file using /dev/urandom.
384
385    @param path: the path of the file.
386    @param size: the file size in bytes.
387    """
388    logging.debug('Creating %s (size %d) from /dev/urandom.', path, size)
389    with open('/dev/urandom', 'rb') as urandom:
390        utils.open_write_close(path, urandom.read(size))
391
392
393def checksum_file(path):
394    """Compute the MD5 Checksum for a file.
395
396    @param path: the path of the file.
397
398    @return a string with the checksum.
399    """
400    chunk_size = 1024
401
402    m = hashlib.md5()
403    with open(path, 'rb') as f:
404        for chunk in f.read(chunk_size):
405            m.update(chunk)
406
407    logging.debug("MD5 checksum for %s is %s.", path, m.hexdigest())
408
409    return m.hexdigest()
410
411
412def args_to_storage_dict(args):
413    """Map args into storage dictionaries.
414
415    This function is to be used (likely) in control files to obtain a storage
416    dictionary from command line arguments.
417
418    @param args: a list of arguments as passed to control file.
419
420    @return a tuple (storage_dict, rest_of_args) where storage_dict is a
421            dictionary for storage filtering and rest_of_args is a dictionary
422            of keys which do not match storage dict keys.
423    """
424    args_dict = utils.args_to_dict(args)
425    storage_dict = {}
426
427    # A list of all allowed keys and their type.
428    key_list = ('device', 'bus', 'model', 'size', 'fs_uuid', 'fstype',
429                'is_mounted', 'mountpoint')
430
431    def set_if_exists(src, dst, key):
432        """If |src| has |key| copies its value to |dst|.
433
434        @return True if |key| exists in |src|, False otherwise.
435        """
436        if key in src:
437            dst[key] = src[key]
438            return True
439        else:
440            return False
441
442    for key in key_list:
443        if set_if_exists(args_dict, storage_dict, key):
444            del args_dict[key]
445
446    # Return the storage dict and the leftovers of the args to be evaluated
447    # later.
448    return storage_dict, args_dict
449