diff options
| author | Zuul <zuul@review.opendev.org> | 2022-07-20 08:23:55 +0000 |
|---|---|---|
| committer | Gerrit Code Review <review@openstack.org> | 2022-07-20 08:23:55 +0000 |
| commit | 21b21a5f15bce4871f6af0dc06ebf50d0e9b0967 (patch) | |
| tree | b39fc6a45ecc4aeac290a91a71e2ff99534abe7a /ironic_python_agent | |
| parent | 6a1334a0683121dc94390c8a57aec1c3af208398 (diff) | |
| parent | beb7484858d56ef34699895412881945c5507c81 (diff) | |
| download | ironic-python-agent-21b21a5f15bce4871f6af0dc06ebf50d0e9b0967.tar.gz | |
Merge "Guard shared device/cluster filesystems"
Diffstat (limited to 'ironic_python_agent')
| -rw-r--r-- | ironic_python_agent/config.py | 11 | ||||
| -rw-r--r-- | ironic_python_agent/errors.py | 19 | ||||
| -rw-r--r-- | ironic_python_agent/hardware.py | 121 | ||||
| -rw-r--r-- | ironic_python_agent/tests/unit/test_hardware.py | 202 |
4 files changed, 347 insertions, 6 deletions
diff --git a/ironic_python_agent/config.py b/ironic_python_agent/config.py index 8901c45a..9251a3e3 100644 --- a/ironic_python_agent/config.py +++ b/ironic_python_agent/config.py @@ -315,6 +315,17 @@ cli_opts = [ min=0, max=99, # 100 is when IPA is booted help='Priority of the inject_files deploy step (disabled ' 'by default), an integer between 1 and .'), + cfg.BoolOpt('guard-special-filesystems', + default=APARAMS.get('ipa-guard-special-filesystems', True), + help='Guard "special" shared device filesystems from ' + 'cleaning by the stock hardware manager\'s cleaning ' + 'methods. If one of these filesystems is detected ' + 'during cleaning, the cleaning process will be aborted ' + 'and infrastructure operator intervention may be ' + 'required as this option is intended to prevent ' + 'cleaning from inadvertently destroying a running ' + 'cluster which may be visible over a storage fabric ' + 'such as FibreChannel.'), ] CONF.register_cli_opts(cli_opts) diff --git a/ironic_python_agent/errors.py b/ironic_python_agent/errors.py index 1f97c0e2..a0994207 100644 --- a/ironic_python_agent/errors.py +++ b/ironic_python_agent/errors.py @@ -348,3 +348,22 @@ class HeartbeatConnectionError(IronicAPIError): def __init__(self, details): super(HeartbeatConnectionError, self).__init__(details) + + +class ProtectedDeviceError(CleaningError): + """Error raised when a cleaning is halted due to a protected device.""" + + message = 'Protected device located, cleaning aborted.' + + def __init__(self, device, what): + details = ('Protected %(what)s located on device %(device)s. ' + 'This volume or contents may be a shared block device. ' + 'Please consult your storage administrator, and restart ' + 'cleaning after either detaching the volumes, or ' + 'instructing IPA to not protect contents. See Ironic ' + 'Python Agent documentation for more information.' % + {'what': what, + 'device': device}) + + self.message = details + super(CleaningError, self).__init__(details) diff --git a/ironic_python_agent/hardware.py b/ironic_python_agent/hardware.py index 0f7e4f82..dfcce6f8 100644 --- a/ironic_python_agent/hardware.py +++ b/ironic_python_agent/hardware.py @@ -924,6 +924,10 @@ class HardwareManager(object, metaclass=abc.ABCMeta): :param node: Ironic node object :param ports: list of Ironic port objects + :raises: ProtectedDeviceFound if a device has been identified which + may require manual intervention due to the contents and + operational risk which exists as it could also be a sign + of an environmental misconfiguration. :return: a dictionary in the form {device.name: erasure output} """ erase_results = {} @@ -937,6 +941,7 @@ class HardwareManager(object, metaclass=abc.ABCMeta): thread_pool = ThreadPool(min(max_pool_size, len(block_devices))) for block_device in block_devices: params = {'node': node, 'block_device': block_device} + safety_check_block_device(node, block_device.name) erase_results[block_device.name] = thread_pool.apply_async( dispatch_to_managers, ('erase_block_device',), params) thread_pool.close() @@ -1541,6 +1546,10 @@ class GenericHardwareManager(HardwareManager): :param ports: list of Ironic port objects :raises BlockDeviceEraseError: when there's an error erasing the block device + :raises: ProtectedDeviceFound if a device has been identified which + may require manual intervention due to the contents and + operational risk which exists as it could also be a sign + of an environmental misconfiguration. """ block_devices = self.list_block_devices(include_partitions=True) # NOTE(coreywright): Reverse sort by device name so a partition (eg @@ -1549,6 +1558,7 @@ class GenericHardwareManager(HardwareManager): block_devices.sort(key=lambda dev: dev.name, reverse=True) erase_errors = {} for dev in self._list_erasable_devices(): + safety_check_block_device(node, dev.name) try: disk_utils.destroy_disk_metadata(dev.name, node['uuid']) except processutils.ProcessExecutionError as e: @@ -1572,6 +1582,10 @@ class GenericHardwareManager(HardwareManager): :param ports: list of Ironic port objects :raises BlockDeviceEraseError: when there's an error erasing the block device + :raises: ProtectedDeviceFound if a device has been identified which + may require manual intervention due to the contents and + operational risk which exists as it could also be a sign + of an environmental misconfiguration. """ erase_errors = {} info = node.get('driver_internal_info', {}) @@ -1579,6 +1593,7 @@ class GenericHardwareManager(HardwareManager): LOG.debug("No erasable devices have been found.") return for dev in self._list_erasable_devices(): + safety_check_block_device(node, dev.name) try: if self._is_nvme(dev): execute_nvme_erase = info.get( @@ -2957,3 +2972,109 @@ def get_multipath_status(): # as if we directly try and work with the global var, we will be racing # tests endlessly. return MULTIPATH_ENABLED + + +def safety_check_block_device(node, device): + """Performs safety checking of a block device before destroying. + + In order to guard against distruction of file systems such as + shared-disk file systems + (https://en.wikipedia.org/wiki/Clustered_file_system#SHARED-DISK) + or similar filesystems where multiple distinct computers may have + unlocked concurrent IO access to the entire block device or + SAN Logical Unit Number, we need to evaluate, and block cleaning + from occuring on these filesystems *unless* we have been explicitly + configured to do so. + + This is because cleaning is an intentionally distructive operation, + and once started against such a device, given the complexities of + shared disk clustered filesystems where concurrent access is a design + element, in all likelihood the entire cluster can be negatively + impacted, and an operator will be forced to recover from snapshot and + or backups of the volume's contents. + + :param node: A node, or cached node object. + :param device: String representing the path to the block + device to be checked. + :raises: ProtectedDeviceError when a device is identified with + one of these known clustered filesystems, and the overall + settings have not indicated for the agent to skip such + safety checks. + """ + + # NOTE(TheJulia): While this seems super rare, I found out after this + # thread of discussion started that I have customers which have done + # this and wiped out SAN volumes and their contents unintentionally + # as a result of these filesystems not being guarded. + # For those not familiar with shared disk clustered filesystems, think + # of it as like your impacting a Ceph cluster, except your suddenly + # removing the underlying disks from the OSD, and the entire cluster + # goes down. + + if not CONF.guard_special_filesystems: + return + di_info = node.get('driver_internal_info', {}) + if not di_info.get('wipe_special_filesystems', True): + return + report, _e = il_utils.execute('lsblk', '-Pbia', + '-oFSTYPE,UUID,PTUUID,PARTTYPE,PARTUUID', + device) + + lines = report.splitlines() + + identified_fs_types = [] + identified_ids = [] + for line in lines: + device = {} + # Split into KEY=VAL pairs + vals = shlex.split(line) + if not vals: + continue + for key, val in (v.split('=', 1) for v in vals): + if key == 'FSTYPE': + identified_fs_types.append(val) + if key in ['UUID', 'PTUUID', 'PARTTYPE', 'PARTUUID']: + identified_ids.append(val) + # Ignore block types not specified + + _check_for_special_partitions_filesystems( + device, + identified_ids, + identified_fs_types) + + +def _check_for_special_partitions_filesystems(device, ids, fs_types): + """Compare supplied IDs, Types to known items, and raise if found. + + :param device: The block device in use, specificially for logging. + :param ids: A list above IDs found to check. + :param fs_types: A list of FS types found to check. + :raises: ProtectedDeviceError should a partition label or metadata + be discovered which suggests a shared disk clustered filesystem + has been discovered. + """ + + guarded_ids = { + # Apparently GPFS can used shared volumes.... + '37AFFC90-EF7D-4E96-91C3-2D7AE055B174': 'IBM GPFS Partition', + # Shared volume parallel filesystem + 'AA31E02A-400F-11DB-9590-000C2911D1B8': 'VMware VMFS Partition (GPT)', + '0xfb': 'VMware VMFS Partition (MBR)', + } + for key, value in guarded_ids.items(): + for id_value in ids: + if key == id_value: + raise errors.ProtectedDeviceError( + device=device, + what=value) + + guarded_fs_types = { + 'gfs2': 'Red Hat Global File System 2', + } + + for key, value in guarded_fs_types.items(): + for fs in fs_types: + if key == fs: + raise errors.ProtectedDeviceError( + device=device, + what=value) diff --git a/ironic_python_agent/tests/unit/test_hardware.py b/ironic_python_agent/tests/unit/test_hardware.py index a610eb2f..21349050 100644 --- a/ironic_python_agent/tests/unit/test_hardware.py +++ b/ironic_python_agent/tests/unit/test_hardware.py @@ -1710,10 +1710,12 @@ class TestGenericHardwareManager(base.IronicAgentTest): mocked_listdir.assert_has_calls(expected_calls) mocked_mpath.assert_called_once_with() + @mock.patch.object(hardware, 'safety_check_block_device', autospec=True) @mock.patch.object(hardware, 'ThreadPool', autospec=True) @mock.patch.object(hardware, 'dispatch_to_managers', autospec=True) def test_erase_devices_no_parallel_by_default(self, mocked_dispatch, - mock_threadpool): + mock_threadpool, + mock_safety_check): # NOTE(TheJulia): This test was previously more elaborate and # had a high failure rate on py37 and py38. So instead, lets just @@ -1732,10 +1734,43 @@ class TestGenericHardwareManager(base.IronicAgentTest): calls = [mock.call(1)] self.hardware.erase_devices({}, []) mock_threadpool.assert_has_calls(calls) + mock_safety_check.assert_has_calls([ + mock.call({}, '/dev/sdj'), + mock.call({}, '/dev/hdaa') + ]) + + @mock.patch.object(hardware, 'safety_check_block_device', autospec=True) + @mock.patch.object(hardware, 'ThreadPool', autospec=True) + @mock.patch.object(hardware, 'dispatch_to_managers', autospec=True) + def test_erase_devices_no_parallel_by_default_protected_device( + self, mocked_dispatch, + mock_threadpool, + mock_safety_check): + mock_safety_check.side_effect = errors.ProtectedDeviceError( + device='foo', + what='bar') + + self.hardware.list_block_devices = mock.Mock() + + self.hardware.list_block_devices.return_value = [ + hardware.BlockDevice('/dev/sdj', 'big', 1073741824, True), + hardware.BlockDevice('/dev/hdaa', 'small', 65535, False), + ] + + calls = [mock.call(1)] + self.assertRaises(errors.ProtectedDeviceError, + self.hardware.erase_devices, {}, []) + mock_threadpool.assert_has_calls(calls) + mock_safety_check.assert_has_calls([ + mock.call({}, '/dev/sdj'), + ]) + mock_threadpool.apply_async.assert_not_called() + @mock.patch.object(hardware, 'safety_check_block_device', autospec=True) @mock.patch('multiprocessing.pool.ThreadPool.apply_async', autospec=True) @mock.patch.object(hardware, 'dispatch_to_managers', autospec=True) - def test_erase_devices_concurrency(self, mocked_dispatch, mocked_async): + def test_erase_devices_concurrency(self, mocked_dispatch, mocked_async, + mock_safety_check): internal_info = self.node['driver_internal_info'] internal_info['disk_erasure_concurrency'] = 10 mocked_dispatch.return_value = 'erased device' @@ -1760,9 +1795,15 @@ class TestGenericHardwareManager(base.IronicAgentTest): for dev in (blkdev1, blkdev2)] mocked_async.assert_has_calls(calls) self.assertEqual(expected, result) + mock_safety_check.assert_has_calls([ + mock.call(self.node, '/dev/sdj'), + mock.call(self.node, '/dev/hdaa'), + ]) + @mock.patch.object(hardware, 'safety_check_block_device', autospec=True) @mock.patch.object(hardware, 'ThreadPool', autospec=True) - def test_erase_devices_concurrency_pool_size(self, mocked_pool): + def test_erase_devices_concurrency_pool_size(self, mocked_pool, + mock_safety_check): self.hardware.list_block_devices = mock.Mock() self.hardware.list_block_devices.return_value = [ hardware.BlockDevice('/dev/sdj', 'big', 1073741824, True), @@ -1782,6 +1823,10 @@ class TestGenericHardwareManager(base.IronicAgentTest): self.hardware.erase_devices(self.node, []) mocked_pool.assert_called_with(1) + mock_safety_check.assert_has_calls([ + mock.call(self.node, '/dev/sdj'), + mock.call(self.node, '/dev/hdaa'), + ]) @mock.patch.object(hardware, 'dispatch_to_managers', autospec=True) def test_erase_devices_without_disk(self, mocked_dispatch): @@ -2441,6 +2486,7 @@ class TestGenericHardwareManager(base.IronicAgentTest): mock.call('/sys/fs/pstore/' + arg) for arg in pstore_entries ]) + @mock.patch.object(hardware, 'safety_check_block_device', autospec=True) @mock.patch.object(il_utils, 'execute', autospec=True) @mock.patch.object(disk_utils, 'destroy_disk_metadata', autospec=True) @mock.patch.object(hardware.GenericHardwareManager, @@ -2449,7 +2495,7 @@ class TestGenericHardwareManager(base.IronicAgentTest): '_list_erasable_devices', autospec=True) def test_erase_devices_express( self, mock_list_erasable_devices, mock_nvme_erase, - mock_destroy_disk_metadata, mock_execute): + mock_destroy_disk_metadata, mock_execute, mock_safety_check): block_devices = [ hardware.BlockDevice('/dev/sda', 'sata', 65535, False), hardware.BlockDevice('/dev/md0', 'raid-device', 32767, False), @@ -2466,7 +2512,44 @@ class TestGenericHardwareManager(base.IronicAgentTest): mock.call('/dev/md0', self.node['uuid'])], mock_destroy_disk_metadata.call_args_list) mock_list_erasable_devices.assert_called_with(self.hardware) + mock_safety_check.assert_has_calls([ + mock.call(self.node, '/dev/sda'), + mock.call(self.node, '/dev/md0'), + mock.call(self.node, '/dev/nvme0n1'), + mock.call(self.node, '/dev/nvme1n1') + ]) + + @mock.patch.object(hardware, 'safety_check_block_device', autospec=True) + @mock.patch.object(il_utils, 'execute', autospec=True) + @mock.patch.object(disk_utils, 'destroy_disk_metadata', autospec=True) + @mock.patch.object(hardware.GenericHardwareManager, + '_nvme_erase', autospec=True) + @mock.patch.object(hardware.GenericHardwareManager, + '_list_erasable_devices', autospec=True) + def test_erase_devices_express_stops_on_safety_failure( + self, mock_list_erasable_devices, mock_nvme_erase, + mock_destroy_disk_metadata, mock_execute, mock_safety_check): + mock_safety_check.side_effect = errors.ProtectedDeviceError( + device='foo', + what='bar') + block_devices = [ + hardware.BlockDevice('/dev/sda', 'sata', 65535, False), + hardware.BlockDevice('/dev/md0', 'raid-device', 32767, False), + hardware.BlockDevice('/dev/nvme0n1', 'nvme', 32767, False), + hardware.BlockDevice('/dev/nvme1n1', 'nvme', 32767, False) + ] + mock_list_erasable_devices.return_value = list(block_devices) + + self.assertRaises(errors.ProtectedDeviceError, + self.hardware.erase_devices_express, self.node, []) + mock_nvme_erase.assert_not_called() + mock_destroy_disk_metadata.assert_not_called() + mock_list_erasable_devices.assert_called_with(self.hardware) + mock_safety_check.assert_has_calls([ + mock.call(self.node, '/dev/sda') + ]) + @mock.patch.object(hardware, 'safety_check_block_device', autospec=True) @mock.patch.object(il_utils, 'execute', autospec=True) @mock.patch.object(hardware.GenericHardwareManager, '_is_virtual_media_device', autospec=True) @@ -2475,7 +2558,7 @@ class TestGenericHardwareManager(base.IronicAgentTest): @mock.patch.object(disk_utils, 'destroy_disk_metadata', autospec=True) def test_erase_devices_metadata( self, mock_metadata, mock_list_devs, mock__is_vmedia, - mock_execute): + mock_execute, mock_safety_check): block_devices = [ hardware.BlockDevice('/dev/sr0', 'vmedia', 12345, True), hardware.BlockDevice('/dev/sdb2', 'raid-member', 32767, False), @@ -2509,7 +2592,62 @@ class TestGenericHardwareManager(base.IronicAgentTest): mock.call(self.hardware, block_devices[2]), mock.call(self.hardware, block_devices[5])], mock__is_vmedia.call_args_list) + mock_safety_check.assert_has_calls([ + mock.call(self.node, '/dev/sda1'), + mock.call(self.node, '/dev/sda'), + # This is kind of redundant code/pattern behavior wise + # but you never know what someone has done... + mock.call(self.node, '/dev/md0') + ]) + @mock.patch.object(hardware, 'safety_check_block_device', autospec=True) + @mock.patch.object(il_utils, 'execute', autospec=True) + @mock.patch.object(hardware.GenericHardwareManager, + '_is_virtual_media_device', autospec=True) + @mock.patch.object(hardware.GenericHardwareManager, + 'list_block_devices', autospec=True) + @mock.patch.object(disk_utils, 'destroy_disk_metadata', autospec=True) + def test_erase_devices_metadata_safety_check( + self, mock_metadata, mock_list_devs, mock__is_vmedia, + mock_execute, mock_safety_check): + block_devices = [ + hardware.BlockDevice('/dev/sr0', 'vmedia', 12345, True), + hardware.BlockDevice('/dev/sdb2', 'raid-member', 32767, False), + hardware.BlockDevice('/dev/sda', 'small', 65535, False), + hardware.BlockDevice('/dev/sda1', '', 32767, False), + hardware.BlockDevice('/dev/sda2', 'raid-member', 32767, False), + hardware.BlockDevice('/dev/md0', 'raid-device', 32767, False) + ] + # NOTE(coreywright): Don't return the list, but a copy of it, because + # we depend on its elements' order when referencing it later during + # verification, but the method under test sorts the list changing it. + mock_list_devs.return_value = list(block_devices) + mock__is_vmedia.side_effect = lambda _, dev: dev.name == '/dev/sr0' + mock_execute.side_effect = [ + ('sdb2 linux_raid_member host:1 f9978968', ''), + ('sda2 linux_raid_member host:1 f9978969', ''), + ('sda1', ''), ('sda', ''), ('md0', '')] + mock_safety_check.side_effect = [ + None, + errors.ProtectedDeviceError( + device='foo', + what='bar') + ] + + self.assertRaises(errors.ProtectedDeviceError, + self.hardware.erase_devices_metadata, + self.node, []) + + self.assertEqual([mock.call('/dev/sda1', self.node['uuid'])], + mock_metadata.call_args_list) + mock_list_devs.assert_called_with(self.hardware, + include_partitions=True) + mock_safety_check.assert_has_calls([ + mock.call(self.node, '/dev/sda1'), + mock.call(self.node, '/dev/sda'), + ]) + + @mock.patch.object(hardware, 'safety_check_block_device', autospec=True) @mock.patch.object(hardware.GenericHardwareManager, '_is_linux_raid_member', autospec=True) @mock.patch.object(hardware.GenericHardwareManager, @@ -2519,7 +2657,7 @@ class TestGenericHardwareManager(base.IronicAgentTest): @mock.patch.object(disk_utils, 'destroy_disk_metadata', autospec=True) def test_erase_devices_metadata_error( self, mock_metadata, mock_list_devs, mock__is_vmedia, - mock__is_raid_member): + mock__is_raid_member, mock_safety_check): block_devices = [ hardware.BlockDevice('/dev/sda', 'small', 65535, False), hardware.BlockDevice('/dev/sdb', 'big', 10737418240, True), @@ -2553,6 +2691,10 @@ class TestGenericHardwareManager(base.IronicAgentTest): self.assertEqual([mock.call(self.hardware, block_devices[1]), mock.call(self.hardware, block_devices[0])], mock__is_vmedia.call_args_list) + mock_safety_check.assert_has_calls([ + mock.call(self.node, '/dev/sdb'), + mock.call(self.node, '/dev/sda') + ]) @mock.patch.object(il_utils, 'execute', autospec=True) def test__is_linux_raid_member(self, mocked_execute): @@ -4991,3 +5133,51 @@ class TestAPIClientSaveAndUse(base.IronicAgentTest): calls = [mock.call('list_hardware_info'), mock.call('wait_for_disks')] mock_dispatch.assert_has_calls(calls) + + +@mock.patch.object(il_utils, 'execute', autospec=True) +class TestProtectedDiskSafetyChecks(base.IronicAgentTest): + + def test_special_filesystem_guard_not_enabled(self, mock_execute): + CONF.set_override('guard_special_filesystems', False) + hardware.safety_check_block_device({}, '/dev/foo') + mock_execute.assert_not_called() + + def test_special_filesystem_guard_node_indicates_skip(self, mock_execute): + node = { + 'driver_internal_info': { + 'wipe_special_filesystems': False + } + } + mock_execute.return_value = ('', '') + hardware.safety_check_block_device(node, '/dev/foo') + mock_execute.assert_not_called() + + def test_special_filesystem_guard_enabled_no_results(self, mock_execute): + mock_execute.return_value = ('', '') + hardware.safety_check_block_device({}, '/dev/foo') + + def test_special_filesystem_guard_raises(self, mock_execute): + GFS2 = 'FSTYPE="gfs2"\n' + GPFS1 = 'UUID="37AFFC90-EF7D-4E96-91C3-2D7AE055B174"\n' + GPFS2 = 'PTUUID="37AFFC90-EF7D-4E96-91C3-2D7AE055B174"\n' + GPFS3 = 'PARTTYPE="37AFFC90-EF7D-4E96-91C3-2D7AE055B174"\n' + GPFS4 = 'PARTUUID="37AFFC90-EF7D-4E96-91C3-2D7AE055B174"\n' + VMFS1 = 'UUID="AA31E02A-400F-11DB-9590-000C2911D1B8"\n' + VMFS2 = 'UUID="AA31E02A-400F-11DB-9590-000C2911D1B8"\n' + VMFS3 = 'UUID="AA31E02A-400F-11DB-9590-000C2911D1B8"\n' + VMFS4 = 'UUID="AA31E02A-400F-11DB-9590-000C2911D1B8"\n' + VMFS5 = 'UUID="0xfb"\n' + VMFS6 = 'PTUUID="0xfb"\n' + VMFS7 = 'PARTTYPE="0xfb"\n' + VMFS8 = 'PARTUUID="0xfb"\n' + + expected_failures = [GFS2, GPFS1, GPFS2, GPFS3, GPFS4, VMFS1, VMFS2, + VMFS3, VMFS4, VMFS5, VMFS6, VMFS7, VMFS8] + for failure in expected_failures: + mock_execute.reset_mock() + mock_execute.return_value = (failure, '') + self.assertRaises(errors.ProtectedDeviceError, + hardware.safety_check_block_device, + {}, '/dev/foo') + self.assertEqual(1, mock_execute.call_count) |
