diff --git a/etc/ironic/rootwrap.d/ironic-lib.filters b/etc/ironic/rootwrap.d/ironic-lib.filters index 3d65ba0f..0b3f43ec 100644 --- a/etc/ironic/rootwrap.d/ironic-lib.filters +++ b/etc/ironic/rootwrap.d/ironic-lib.filters @@ -21,6 +21,7 @@ partprobe: CommandFilter, partprobe, root mkswap: CommandFilter, mkswap, root mkfs: CommandFilter, mkfs, root dd: CommandFilter, dd, root +mount: CommandFilter, mount, root # ironic_lib/disk_partitioner.py fuser: CommandFilter, fuser, root diff --git a/ironic_lib/tests/test_utils.py b/ironic_lib/tests/test_utils.py index 18d0cc9e..6cb1105a 100644 --- a/ironic_lib/tests/test_utils.py +++ b/ironic_lib/tests/test_utils.py @@ -668,3 +668,68 @@ class GetRouteSourceTestCase(base.IronicLibTestCase): source = utils.get_route_source('XXX') self.assertIsNone(source) + + +@mock.patch('shutil.rmtree', autospec=True) +@mock.patch.object(utils, 'execute', autospec=True) +@mock.patch('tempfile.mkdtemp', autospec=True) +class MountedTestCase(base.IronicLibTestCase): + + def test_temporary(self, mock_temp, mock_execute, mock_rmtree): + with utils.mounted('/dev/fake') as path: + self.assertIs(path, mock_temp.return_value) + mock_execute.assert_has_calls([ + mock.call("mount", '/dev/fake', mock_temp.return_value, + run_as_root=True), + mock.call("umount", mock_temp.return_value, run_as_root=True), + ]) + mock_rmtree.assert_called_once_with(mock_temp.return_value) + + def test_with_dest(self, mock_temp, mock_execute, mock_rmtree): + with utils.mounted('/dev/fake', '/mnt/fake') as path: + self.assertEqual('/mnt/fake', path) + mock_execute.assert_has_calls([ + mock.call("mount", '/dev/fake', '/mnt/fake', run_as_root=True), + mock.call("umount", '/mnt/fake', run_as_root=True), + ]) + self.assertFalse(mock_temp.called) + self.assertFalse(mock_rmtree.called) + + def test_with_opts(self, mock_temp, mock_execute, mock_rmtree): + with utils.mounted('/dev/fake', '/mnt/fake', + opts=['ro', 'foo=bar']) as path: + self.assertEqual('/mnt/fake', path) + mock_execute.assert_has_calls([ + mock.call("mount", '/dev/fake', '/mnt/fake', '-o', 'ro,foo=bar', + run_as_root=True), + mock.call("umount", '/mnt/fake', run_as_root=True), + ]) + + def test_with_type(self, mock_temp, mock_execute, mock_rmtree): + with utils.mounted('/dev/fake', '/mnt/fake', + fs_type='iso9660') as path: + self.assertEqual('/mnt/fake', path) + mock_execute.assert_has_calls([ + mock.call("mount", '/dev/fake', '/mnt/fake', '-t', 'iso9660', + run_as_root=True), + mock.call("umount", '/mnt/fake', run_as_root=True), + ]) + + def test_failed_to_mount(self, mock_temp, mock_execute, mock_rmtree): + mock_execute.side_effect = OSError + self.assertRaises(OSError, utils.mounted('/dev/fake').__enter__) + mock_execute.assert_called_once_with("mount", '/dev/fake', + mock_temp.return_value, + run_as_root=True) + mock_rmtree.assert_called_once_with(mock_temp.return_value) + + def test_failed_to_unmount(self, mock_temp, mock_execute, mock_rmtree): + mock_execute.side_effect = [('', ''), + processutils.ProcessExecutionError] + with utils.mounted('/dev/fake', '/mnt/fake') as path: + self.assertEqual('/mnt/fake', path) + mock_execute.assert_has_calls([ + mock.call("mount", '/dev/fake', '/mnt/fake', run_as_root=True), + mock.call("umount", '/mnt/fake', run_as_root=True), + ]) + self.assertFalse(mock_rmtree.called) diff --git a/ironic_lib/utils.py b/ironic_lib/utils.py index fd6a7fdf..65f73c54 100644 --- a/ironic_lib/utils.py +++ b/ironic_lib/utils.py @@ -18,12 +18,15 @@ """Utilities and helper functions.""" +import contextlib import copy import errno import ipaddress import logging import os import re +import shutil +import tempfile from urllib import parse as urlparse from oslo_concurrency import processutils @@ -585,3 +588,53 @@ def get_route_source(dest, ignore_link_local=True): except (IndexError, ValueError): LOG.debug('No route to host %(dest)s, route record: %(rec)s', {'dest': dest, 'rec': out}) + + +@contextlib.contextmanager +def mounted(source, dest=None, opts=None, fs_type=None): + """A context manager for a temporary mount. + + :param source: A device to mount. + :param dest: Mount destination. If not specified, a temporary directory + will be created and removed afterwards. An existing destination is + not removed. + :param opts: Mount options (``-o`` argument). + :param fs_type: File system type (``-t`` argument). + :returns: A generator yielding the destination. + """ + params = [] + if opts: + params.extend(['-o', ','.join(opts)]) + if fs_type: + params.extend(['-t', fs_type]) + + if dest is None: + dest = tempfile.mkdtemp() + clean_up = True + else: + clean_up = False + + mounted = False + try: + execute("mount", source, dest, *params, run_as_root=True) + mounted = True + yield dest + finally: + if mounted: + try: + execute("umount", dest, run_as_root=True) + except (EnvironmentError, + processutils.ProcessExecutionError) as exc: + LOG.warning( + 'Unable to unmount temporary location %(dest)s: %(err)s', + {'dest': dest, 'err': exc}) + # NOTE(dtantsur): don't try to remove a still mounted location + clean_up = False + + if clean_up: + try: + shutil.rmtree(dest) + except EnvironmentError as exc: + LOG.warning( + 'Unable to remove temporary location %(dest)s: %(err)s', + {'dest': dest, 'err': exc})