From eb977080921970a0d936bf010519cab02c4841ec Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Fri, 6 Dec 2024 16:02:33 -0800 Subject: [PATCH] Completed tests for LVM (#80). --- borgmatic/hooks/data_source/lvm.py | 31 +- tests/unit/hooks/data_source/test_lvm.py | 474 +++++++++++++++++++++++ 2 files changed, 495 insertions(+), 10 deletions(-) diff --git a/borgmatic/hooks/data_source/lvm.py b/borgmatic/hooks/data_source/lvm.py index 6a5d0759..329d252a 100644 --- a/borgmatic/hooks/data_source/lvm.py +++ b/borgmatic/hooks/data_source/lvm.py @@ -337,15 +337,17 @@ def remove_data_source_dumps(hook_config, config, log_prefix, borgmatic_runtime_ f'{log_prefix}: Unmounting LVM snapshot at {snapshot_mount_path}{dry_run_label}' ) - if not dry_run: - try: - unmount_snapshot(umount_command, snapshot_mount_path) - except FileNotFoundError: - logger.debug(f'{log_prefix}: Could not find "{umount_command}" command') - return - except subprocess.CalledProcessError as error: - logger.debug(f'{log_prefix}: {error}') - return + if dry_run: + continue + + try: + unmount_snapshot(umount_command, snapshot_mount_path) + except FileNotFoundError: + logger.debug(f'{log_prefix}: Could not find "{umount_command}" command') + return + except subprocess.CalledProcessError as error: + logger.debug(f'{log_prefix}: {error}') + return if not dry_run: shutil.rmtree(snapshots_directory) @@ -353,7 +355,16 @@ def remove_data_source_dumps(hook_config, config, log_prefix, borgmatic_runtime_ # Delete snapshots. lvremove_command = hook_config.get('lvremove_command', 'lvremove') - for snapshot in get_snapshots(hook_config.get('lvs_command', 'lvs')): + try: + snapshots = get_snapshots(hook_config.get('lvs_command', 'lvs')) + except FileNotFoundError as error: + logger.debug(f'{log_prefix}: Could not find "{error.filename}" command') + return + except subprocess.CalledProcessError as error: + logger.debug(f'{log_prefix}: {error}') + return + + for snapshot in snapshots: # Only delete snapshots that borgmatic actually created! if not snapshot.name.split('_')[-1].startswith(BORGMATIC_SNAPSHOT_PREFIX): continue diff --git a/tests/unit/hooks/data_source/test_lvm.py b/tests/unit/hooks/data_source/test_lvm.py index 7dadbe36..1f70087a 100644 --- a/tests/unit/hooks/data_source/test_lvm.py +++ b/tests/unit/hooks/data_source/test_lvm.py @@ -615,3 +615,477 @@ def test_get_snapshots_with_lvs_json_missing_keys_errors(): with pytest.raises(ValueError): assert module.get_snapshots('lvs') + + +def test_remove_data_source_dumps_unmounts_and_remove_snapshots(): + config = {'lvm': {}} + flexmock(module).should_receive('get_logical_volumes').and_return( + ( + module.Logical_volume( + name='lvolume1', + device_path='/dev/lvolume1', + mount_point='/mnt/lvolume1', + contained_source_directories=('/mnt/lvolume1/subdir',), + ), + module.Logical_volume( + name='lvolume2', + device_path='/dev/lvolume2', + mount_point='/mnt/lvolume2', + contained_source_directories=('/mnt/lvolume2',), + ), + ) + ) + flexmock(module.borgmatic.config.paths).should_receive( + 'replace_temporary_subdirectory_with_glob' + ).and_return('/run/borgmatic') + flexmock(module.glob).should_receive('glob').replace_with(lambda path: [path]) + flexmock(module.os.path).should_receive('isdir').and_return(True) + flexmock(module.shutil).should_receive('rmtree') + flexmock(module).should_receive('unmount_snapshot').with_args( + 'umount', + '/run/borgmatic/lvm_snapshots/mnt/lvolume1', + ).once() + flexmock(module).should_receive('unmount_snapshot').with_args( + 'umount', + '/run/borgmatic/lvm_snapshots/mnt/lvolume2', + ).once() + flexmock(module).should_receive('get_snapshots').and_return( + ( + module.Snapshot('lvolume1_borgmatic-1234', '/dev/lvolume1'), + module.Snapshot('lvolume2_borgmatic-1234', '/dev/lvolume2'), + module.Snapshot('nonborgmatic', '/dev/nonborgmatic'), + ), + ) + flexmock(module).should_receive('remove_snapshot').with_args('lvremove', '/dev/lvolume1').once() + flexmock(module).should_receive('remove_snapshot').with_args('lvremove', '/dev/lvolume2').once() + flexmock(module).should_receive('remove_snapshot').with_args( + 'nonborgmatic', '/dev/nonborgmatic' + ).never() + + module.remove_data_source_dumps( + hook_config=config['lvm'], + config=config, + log_prefix='test', + borgmatic_runtime_directory='/run/borgmatic', + dry_run=False, + ) + + +def test_remove_data_source_dumps_bails_for_missing_lsblk_command(): + config = {'lvm': {}} + flexmock(module).should_receive('get_logical_volumes').and_raise(FileNotFoundError) + flexmock(module.borgmatic.config.paths).should_receive( + 'replace_temporary_subdirectory_with_glob' + ).never() + flexmock(module).should_receive('unmount_snapshot').never() + flexmock(module).should_receive('remove_snapshot').never() + + module.remove_data_source_dumps( + hook_config=config['lvm'], + config=config, + log_prefix='test', + borgmatic_runtime_directory='/run/borgmatic', + dry_run=False, + ) + + +def test_remove_data_source_dumps_bails_for_lsblk_command_error(): + config = {'lvm': {}} + flexmock(module).should_receive('get_logical_volumes').and_raise( + module.subprocess.CalledProcessError(1, 'wtf') + ) + flexmock(module.borgmatic.config.paths).should_receive( + 'replace_temporary_subdirectory_with_glob' + ).never() + flexmock(module).should_receive('unmount_snapshot').never() + flexmock(module).should_receive('remove_snapshot').never() + + module.remove_data_source_dumps( + hook_config=config['lvm'], + config=config, + log_prefix='test', + borgmatic_runtime_directory='/run/borgmatic', + dry_run=False, + ) + + +def test_remove_data_source_dumps_with_missing_snapshot_directory_skips_unmount(): + config = {'lvm': {}} + flexmock(module).should_receive('get_logical_volumes').and_return( + ( + module.Logical_volume( + name='lvolume1', + device_path='/dev/lvolume1', + mount_point='/mnt/lvolume1', + contained_source_directories=('/mnt/lvolume1/subdir',), + ), + module.Logical_volume( + name='lvolume2', + device_path='/dev/lvolume2', + mount_point='/mnt/lvolume2', + contained_source_directories=('/mnt/lvolume2',), + ), + ) + ) + flexmock(module.borgmatic.config.paths).should_receive( + 'replace_temporary_subdirectory_with_glob' + ).and_return('/run/borgmatic') + flexmock(module.glob).should_receive('glob').replace_with(lambda path: [path]) + flexmock(module.os.path).should_receive('isdir').with_args( + '/run/borgmatic/lvm_snapshots' + ).and_return(False) + flexmock(module.shutil).should_receive('rmtree').never() + flexmock(module).should_receive('unmount_snapshot').never() + flexmock(module).should_receive('get_snapshots').and_return( + ( + module.Snapshot('lvolume1_borgmatic-1234', '/dev/lvolume1'), + module.Snapshot('lvolume2_borgmatic-1234', '/dev/lvolume2'), + ), + ) + flexmock(module).should_receive('remove_snapshot').with_args('lvremove', '/dev/lvolume1').once() + flexmock(module).should_receive('remove_snapshot').with_args('lvremove', '/dev/lvolume2').once() + + module.remove_data_source_dumps( + hook_config=config['lvm'], + config=config, + log_prefix='test', + borgmatic_runtime_directory='/run/borgmatic', + dry_run=False, + ) + + +def test_remove_data_source_dumps_with_missing_snapshot_mount_path_skips_unmount(): + config = {'lvm': {}} + flexmock(module).should_receive('get_logical_volumes').and_return( + ( + module.Logical_volume( + name='lvolume1', + device_path='/dev/lvolume1', + mount_point='/mnt/lvolume1', + contained_source_directories=('/mnt/lvolume1/subdir',), + ), + module.Logical_volume( + name='lvolume2', + device_path='/dev/lvolume2', + mount_point='/mnt/lvolume2', + contained_source_directories=('/mnt/lvolume2',), + ), + ) + ) + flexmock(module.borgmatic.config.paths).should_receive( + 'replace_temporary_subdirectory_with_glob' + ).and_return('/run/borgmatic') + flexmock(module.glob).should_receive('glob').replace_with(lambda path: [path]) + flexmock(module.os.path).should_receive('isdir').with_args( + '/run/borgmatic/lvm_snapshots' + ).and_return(True) + flexmock(module.os.path).should_receive('isdir').with_args( + '/run/borgmatic/lvm_snapshots/mnt/lvolume1' + ).and_return(False) + flexmock(module.os.path).should_receive('isdir').with_args( + '/run/borgmatic/lvm_snapshots/mnt/lvolume2' + ).and_return(True) + flexmock(module.shutil).should_receive('rmtree') + flexmock(module).should_receive('unmount_snapshot').with_args( + 'umount', + '/run/borgmatic/lvm_snapshots/mnt/lvolume1', + ).never() + flexmock(module).should_receive('unmount_snapshot').with_args( + 'umount', + '/run/borgmatic/lvm_snapshots/mnt/lvolume2', + ).once() + flexmock(module).should_receive('get_snapshots').and_return( + ( + module.Snapshot('lvolume1_borgmatic-1234', '/dev/lvolume1'), + module.Snapshot('lvolume2_borgmatic-1234', '/dev/lvolume2'), + ), + ) + flexmock(module).should_receive('remove_snapshot').with_args('lvremove', '/dev/lvolume1').once() + flexmock(module).should_receive('remove_snapshot').with_args('lvremove', '/dev/lvolume2').once() + + module.remove_data_source_dumps( + hook_config=config['lvm'], + config=config, + log_prefix='test', + borgmatic_runtime_directory='/run/borgmatic', + dry_run=False, + ) + + +def test_remove_data_source_dumps_with_successful_mount_point_removal_skips_unmount(): + config = {'lvm': {}} + flexmock(module).should_receive('get_logical_volumes').and_return( + ( + module.Logical_volume( + name='lvolume1', + device_path='/dev/lvolume1', + mount_point='/mnt/lvolume1', + contained_source_directories=('/mnt/lvolume1/subdir',), + ), + module.Logical_volume( + name='lvolume2', + device_path='/dev/lvolume2', + mount_point='/mnt/lvolume2', + contained_source_directories=('/mnt/lvolume2',), + ), + ) + ) + flexmock(module.borgmatic.config.paths).should_receive( + 'replace_temporary_subdirectory_with_glob' + ).and_return('/run/borgmatic') + flexmock(module.glob).should_receive('glob').replace_with(lambda path: [path]) + flexmock(module.os.path).should_receive('isdir').with_args( + '/run/borgmatic/lvm_snapshots' + ).and_return(True) + flexmock(module.os.path).should_receive('isdir').with_args( + '/run/borgmatic/lvm_snapshots/mnt/lvolume1' + ).and_return(True).and_return(False) + flexmock(module.os.path).should_receive('isdir').with_args( + '/run/borgmatic/lvm_snapshots/mnt/lvolume2' + ).and_return(True).and_return(True) + flexmock(module.shutil).should_receive('rmtree') + flexmock(module).should_receive('unmount_snapshot').with_args( + 'umount', + '/run/borgmatic/lvm_snapshots/mnt/lvolume1', + ).never() + flexmock(module).should_receive('unmount_snapshot').with_args( + 'umount', + '/run/borgmatic/lvm_snapshots/mnt/lvolume2', + ).once() + flexmock(module).should_receive('get_snapshots').and_return( + ( + module.Snapshot('lvolume1_borgmatic-1234', '/dev/lvolume1'), + module.Snapshot('lvolume2_borgmatic-1234', '/dev/lvolume2'), + ), + ) + flexmock(module).should_receive('remove_snapshot').with_args('lvremove', '/dev/lvolume1').once() + flexmock(module).should_receive('remove_snapshot').with_args('lvremove', '/dev/lvolume2').once() + + module.remove_data_source_dumps( + hook_config=config['lvm'], + config=config, + log_prefix='test', + borgmatic_runtime_directory='/run/borgmatic', + dry_run=False, + ) + + +def test_remove_data_source_dumps_bails_for_missing_umount_command(): + config = {'lvm': {}} + flexmock(module).should_receive('get_logical_volumes').and_return( + ( + module.Logical_volume( + name='lvolume1', + device_path='/dev/lvolume1', + mount_point='/mnt/lvolume1', + contained_source_directories=('/mnt/lvolume1/subdir',), + ), + module.Logical_volume( + name='lvolume2', + device_path='/dev/lvolume2', + mount_point='/mnt/lvolume2', + contained_source_directories=('/mnt/lvolume2',), + ), + ) + ) + flexmock(module.borgmatic.config.paths).should_receive( + 'replace_temporary_subdirectory_with_glob' + ).and_return('/run/borgmatic') + flexmock(module.glob).should_receive('glob').replace_with(lambda path: [path]) + flexmock(module.os.path).should_receive('isdir').and_return(True) + flexmock(module.shutil).should_receive('rmtree') + flexmock(module).should_receive('unmount_snapshot').with_args( + 'umount', + '/run/borgmatic/lvm_snapshots/mnt/lvolume1', + ).and_raise(FileNotFoundError) + flexmock(module).should_receive('unmount_snapshot').with_args( + 'umount', + '/run/borgmatic/lvm_snapshots/mnt/lvolume2', + ).never() + flexmock(module).should_receive('get_snapshots').never() + flexmock(module).should_receive('remove_snapshot').never() + + module.remove_data_source_dumps( + hook_config=config['lvm'], + config=config, + log_prefix='test', + borgmatic_runtime_directory='/run/borgmatic', + dry_run=False, + ) + + +def test_remove_data_source_dumps_bails_for_umount_command_error(): + config = {'lvm': {}} + flexmock(module).should_receive('get_logical_volumes').and_return( + ( + module.Logical_volume( + name='lvolume1', + device_path='/dev/lvolume1', + mount_point='/mnt/lvolume1', + contained_source_directories=('/mnt/lvolume1/subdir',), + ), + module.Logical_volume( + name='lvolume2', + device_path='/dev/lvolume2', + mount_point='/mnt/lvolume2', + contained_source_directories=('/mnt/lvolume2',), + ), + ) + ) + flexmock(module.borgmatic.config.paths).should_receive( + 'replace_temporary_subdirectory_with_glob' + ).and_return('/run/borgmatic') + flexmock(module.glob).should_receive('glob').replace_with(lambda path: [path]) + flexmock(module.os.path).should_receive('isdir').and_return(True) + flexmock(module.shutil).should_receive('rmtree') + flexmock(module).should_receive('unmount_snapshot').with_args( + 'umount', + '/run/borgmatic/lvm_snapshots/mnt/lvolume1', + ).and_raise(module.subprocess.CalledProcessError(1, 'wtf')) + flexmock(module).should_receive('unmount_snapshot').with_args( + 'umount', + '/run/borgmatic/lvm_snapshots/mnt/lvolume2', + ).never() + flexmock(module).should_receive('get_snapshots').never() + flexmock(module).should_receive('remove_snapshot').never() + + module.remove_data_source_dumps( + hook_config=config['lvm'], + config=config, + log_prefix='test', + borgmatic_runtime_directory='/run/borgmatic', + dry_run=False, + ) + + +def test_remove_data_source_dumps_bails_for_missing_lvs_command(): + config = {'lvm': {}} + flexmock(module).should_receive('get_logical_volumes').and_return( + ( + module.Logical_volume( + name='lvolume1', + device_path='/dev/lvolume1', + mount_point='/mnt/lvolume1', + contained_source_directories=('/mnt/lvolume1/subdir',), + ), + module.Logical_volume( + name='lvolume2', + device_path='/dev/lvolume2', + mount_point='/mnt/lvolume2', + contained_source_directories=('/mnt/lvolume2',), + ), + ) + ) + flexmock(module.borgmatic.config.paths).should_receive( + 'replace_temporary_subdirectory_with_glob' + ).and_return('/run/borgmatic') + flexmock(module.glob).should_receive('glob').replace_with(lambda path: [path]) + flexmock(module.os.path).should_receive('isdir').and_return(True) + flexmock(module.shutil).should_receive('rmtree') + flexmock(module).should_receive('unmount_snapshot').with_args( + 'umount', + '/run/borgmatic/lvm_snapshots/mnt/lvolume1', + ).once() + flexmock(module).should_receive('unmount_snapshot').with_args( + 'umount', + '/run/borgmatic/lvm_snapshots/mnt/lvolume2', + ).once() + flexmock(module).should_receive('get_snapshots').and_raise(FileNotFoundError) + flexmock(module).should_receive('remove_snapshot').never() + + module.remove_data_source_dumps( + hook_config=config['lvm'], + config=config, + log_prefix='test', + borgmatic_runtime_directory='/run/borgmatic', + dry_run=False, + ) + + +def test_remove_data_source_dumps_bails_for_lvs_command_error(): + config = {'lvm': {}} + flexmock(module).should_receive('get_logical_volumes').and_return( + ( + module.Logical_volume( + name='lvolume1', + device_path='/dev/lvolume1', + mount_point='/mnt/lvolume1', + contained_source_directories=('/mnt/lvolume1/subdir',), + ), + module.Logical_volume( + name='lvolume2', + device_path='/dev/lvolume2', + mount_point='/mnt/lvolume2', + contained_source_directories=('/mnt/lvolume2',), + ), + ) + ) + flexmock(module.borgmatic.config.paths).should_receive( + 'replace_temporary_subdirectory_with_glob' + ).and_return('/run/borgmatic') + flexmock(module.glob).should_receive('glob').replace_with(lambda path: [path]) + flexmock(module.os.path).should_receive('isdir').and_return(True) + flexmock(module.shutil).should_receive('rmtree') + flexmock(module).should_receive('unmount_snapshot').with_args( + 'umount', + '/run/borgmatic/lvm_snapshots/mnt/lvolume1', + ).once() + flexmock(module).should_receive('unmount_snapshot').with_args( + 'umount', + '/run/borgmatic/lvm_snapshots/mnt/lvolume2', + ).once() + flexmock(module).should_receive('get_snapshots').and_raise( + module.subprocess.CalledProcessError(1, 'wtf') + ) + flexmock(module).should_receive('remove_snapshot').never() + + module.remove_data_source_dumps( + hook_config=config['lvm'], + config=config, + log_prefix='test', + borgmatic_runtime_directory='/run/borgmatic', + dry_run=False, + ) + + +def test_remove_data_source_with_dry_run_skips_snapshot_unmount_and_delete(): + config = {'lvm': {}} + flexmock(module).should_receive('get_logical_volumes').and_return( + ( + module.Logical_volume( + name='lvolume1', + device_path='/dev/lvolume1', + mount_point='/mnt/lvolume1', + contained_source_directories=('/mnt/lvolume1/subdir',), + ), + module.Logical_volume( + name='lvolume2', + device_path='/dev/lvolume2', + mount_point='/mnt/lvolume2', + contained_source_directories=('/mnt/lvolume2',), + ), + ) + ) + flexmock(module.borgmatic.config.paths).should_receive( + 'replace_temporary_subdirectory_with_glob' + ).and_return('/run/borgmatic') + flexmock(module.glob).should_receive('glob').replace_with(lambda path: [path]) + flexmock(module.os.path).should_receive('isdir').and_return(True) + flexmock(module.shutil).should_receive('rmtree').never() + flexmock(module).should_receive('unmount_snapshot').never() + flexmock(module).should_receive('get_snapshots').and_return( + ( + module.Snapshot('lvolume1_borgmatic-1234', '/dev/lvolume1'), + module.Snapshot('lvolume2_borgmatic-1234', '/dev/lvolume2'), + module.Snapshot('nonborgmatic', '/dev/nonborgmatic'), + ), + ).once() + flexmock(module).should_receive('remove_snapshot').never() + + module.remove_data_source_dumps( + hook_config=config['lvm'], + config=config, + log_prefix='test', + borgmatic_runtime_directory='/run/borgmatic', + dry_run=True, + )