From 375036e40989f0af24472ca7c721c3bffc7d4fed Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Mon, 25 Nov 2019 14:56:20 -0800 Subject: [PATCH] Support for mounting an archive as a FUSE filesystem via "borgmatic mount" action, and unmounting via "borgmatic umount" (#123). --- NEWS | 5 + borgmatic/borg/mount.py | 46 ++++++ borgmatic/borg/umount.py | 20 +++ borgmatic/commands/arguments.py | 56 ++++++++ borgmatic/commands/borgmatic.py | 25 ++++ docs/Dockerfile | 2 +- docs/how-to/extract-a-backup.md | 29 ++++ setup.py | 2 +- tests/integration/commands/test_arguments.py | 51 ++++++- tests/unit/borg/test_extract.py | 2 +- tests/unit/borg/test_mount.py | 144 +++++++++++++++++++ tests/unit/borg/test_umount.py | 33 +++++ tests/unit/commands/test_borgmatic.py | 27 ++++ 13 files changed, 436 insertions(+), 6 deletions(-) create mode 100644 borgmatic/borg/mount.py create mode 100644 borgmatic/borg/umount.py create mode 100644 tests/unit/borg/test_mount.py create mode 100644 tests/unit/borg/test_umount.py diff --git a/NEWS b/NEWS index 26948a57..1a5db8d1 100644 --- a/NEWS +++ b/NEWS @@ -1,3 +1,8 @@ +1.4.15 + * #123: Support for mounting an archive as a FUSE filesystem via "borgmatic mount" action, and + unmounting via "borgmatic umount". See the documentation for more information: + https://torsion.org/borgmatic/docs/how-to/extract-a-backup/#mount-a-filesystem + 1.4.14 * Show summary log errors regardless of verbosity level, and log the "summary:" header with a log level based on the contained summary logs. diff --git a/borgmatic/borg/mount.py b/borgmatic/borg/mount.py new file mode 100644 index 00000000..6580717c --- /dev/null +++ b/borgmatic/borg/mount.py @@ -0,0 +1,46 @@ +import logging + +from borgmatic.execute import execute_command, execute_command_without_capture + +logger = logging.getLogger(__name__) + + +def mount_archive( + repository, + archive, + mount_point, + paths, + foreground, + options, + storage_config, + local_path='borg', + remote_path=None, +): + ''' + Given a local or remote repository path, an archive name, a filesystem mount point, zero or more + paths to mount from the archive, extra Borg mount options, a storage configuration dict, and + optional local and remote Borg paths, mount the archive onto the mount point. + ''' + umask = storage_config.get('umask', None) + lock_wait = storage_config.get('lock_wait', None) + + full_command = ( + (local_path, 'mount') + + (('--remote-path', remote_path) if remote_path else ()) + + (('--umask', str(umask)) if umask else ()) + + (('--lock-wait', str(lock_wait)) if lock_wait else ()) + + (('--info',) if logger.getEffectiveLevel() == logging.INFO else ()) + + (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ()) + + (('--foreground',) if foreground else ()) + + (('-o', options) if options else ()) + + ('::'.join((repository, archive)),) + + (mount_point,) + + (tuple(paths) if paths else ()) + ) + + # Don't capture the output when foreground mode is used so that ctrl-C can work properly. + if foreground: + execute_command_without_capture(full_command) + return + + execute_command(full_command) diff --git a/borgmatic/borg/umount.py b/borgmatic/borg/umount.py new file mode 100644 index 00000000..d7033963 --- /dev/null +++ b/borgmatic/borg/umount.py @@ -0,0 +1,20 @@ +import logging + +from borgmatic.execute import execute_command + +logger = logging.getLogger(__name__) + + +def unmount_archive(mount_point, local_path='borg'): + ''' + Given a mounted filesystem mount point, and an optional local Borg paths, umount the filesystem + from the mount point. + ''' + full_command = ( + (local_path, 'umount') + + (('--info',) if logger.getEffectiveLevel() == logging.INFO else ()) + + (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ()) + + (mount_point,) + ) + + execute_command(full_command, error_on_warnings=True) diff --git a/borgmatic/commands/arguments.py b/borgmatic/commands/arguments.py index 6f2ab01e..98d94848 100644 --- a/borgmatic/commands/arguments.py +++ b/borgmatic/commands/arguments.py @@ -9,6 +9,8 @@ SUBPARSER_ALIASES = { 'create': ['--create', '-C'], 'check': ['--check', '-k'], 'extract': ['--extract', '-x'], + 'mount': ['--mount', '-m'], + 'umount': ['--umount', '-u'], 'restore': ['--restore', '-r'], 'list': ['--list', '-l'], 'info': ['--info', '-i'], @@ -312,6 +314,60 @@ def parse_arguments(*unparsed_arguments): '-h', '--help', action='help', help='Show this help message and exit' ) + mount_parser = subparsers.add_parser( + 'mount', + aliases=SUBPARSER_ALIASES['mount'], + help='Mount files from a named archive as a FUSE filesystem', + description='Mount a named archive as a FUSE filesystem', + add_help=False, + ) + mount_group = mount_parser.add_argument_group('mount arguments') + mount_group.add_argument( + '--repository', + help='Path of repository to use, defaults to the configured repository if there is only one', + ) + mount_group.add_argument('--archive', help='Name of archive to mount', required=True) + mount_group.add_argument( + '--mount-point', + metavar='PATH', + dest='mount_point', + help='Path where filesystem is to be mounted', + required=True, + ) + mount_group.add_argument( + '--path', + metavar='PATH', + nargs='+', + dest='paths', + help='Paths to mount from archive, defaults to the entire archive', + ) + mount_group.add_argument( + '--foreground', + dest='foreground', + default=False, + action='store_true', + help='Stay in foreground until ctrl-C is pressed', + ) + mount_group.add_argument('--options', dest='options', help='Extra Borg mount options') + mount_group.add_argument('-h', '--help', action='help', help='Show this help message and exit') + + umount_parser = subparsers.add_parser( + 'umount', + aliases=SUBPARSER_ALIASES['umount'], + help='Unmount a FUSE filesystem that was mounted with "borgmatic mount"', + description='Unmount a mounted FUSE filesystem', + add_help=False, + ) + umount_group = umount_parser.add_argument_group('umount arguments') + umount_group.add_argument( + '--mount-point', + metavar='PATH', + dest='mount_point', + help='Path of filesystem to unmount', + required=True, + ) + umount_group.add_argument('-h', '--help', action='help', help='Show this help message and exit') + restore_parser = subparsers.add_parser( 'restore', aliases=SUBPARSER_ALIASES['restore'], diff --git a/borgmatic/commands/borgmatic.py b/borgmatic/commands/borgmatic.py index 44b71287..4cd63bb4 100644 --- a/borgmatic/commands/borgmatic.py +++ b/borgmatic/commands/borgmatic.py @@ -15,7 +15,9 @@ from borgmatic.borg import extract as borg_extract from borgmatic.borg import info as borg_info from borgmatic.borg import init as borg_init from borgmatic.borg import list as borg_list +from borgmatic.borg import mount as borg_mount from borgmatic.borg import prune as borg_prune +from borgmatic.borg import umount as borg_umount from borgmatic.commands.arguments import parse_arguments from borgmatic.config import checks, collect, convert, validate from borgmatic.hooks import command, dispatch, dump, monitor @@ -246,6 +248,27 @@ def run_actions( destination_path=arguments['extract'].destination, progress=arguments['extract'].progress, ) + if 'mount' in arguments: + if arguments['mount'].repository is None or repository == arguments['mount'].repository: + logger.info('{}: Mounting archive {}'.format(repository, arguments['mount'].archive)) + borg_mount.mount_archive( + repository, + arguments['mount'].archive, + arguments['mount'].mount_point, + arguments['mount'].paths, + arguments['mount'].foreground, + arguments['mount'].options, + storage, + local_path=local_path, + remote_path=remote_path, + ) + if 'umount' in arguments: + logger.info( + '{}: Unmounting mount point {}'.format(repository, arguments['umount'].mount_point) + ) + borg_umount.unmount_archive( + mount_point=arguments['umount'].mount_point, local_path=local_path + ) if 'restore' in arguments: if arguments['restore'].repository is None or repository == arguments['restore'].repository: logger.info( @@ -421,6 +444,8 @@ def collect_configuration_run_summary_logs(configs, arguments): repository = arguments['extract'].repository elif 'list' in arguments and arguments['list'].archive: repository = arguments['list'].repository + elif 'mount' in arguments: + repository = arguments['mount'].repository else: repository = None diff --git a/docs/Dockerfile b/docs/Dockerfile index 829098fd..9512154f 100644 --- a/docs/Dockerfile +++ b/docs/Dockerfile @@ -3,7 +3,7 @@ FROM python:3.7.4-alpine3.10 as borgmatic COPY . /app RUN pip install --no-cache /app && generate-borgmatic-config && chmod +r /etc/borgmatic/config.yaml RUN borgmatic --help > /command-line.txt \ - && for action in init prune create check extract restore list info; do \ + && for action in init prune create check extract mount umount restore list info; do \ echo -e "\n--------------------------------------------------------------------------------\n" >> /command-line.txt \ && borgmatic "$action" --help >> /command-line.txt; done diff --git a/docs/how-to/extract-a-backup.md b/docs/how-to/extract-a-backup.md index 554bf725..6270d4d9 100644 --- a/docs/how-to/extract-a-backup.md +++ b/docs/how-to/extract-a-backup.md @@ -87,6 +87,35 @@ so that you can extract files from your archive without impacting your live databases. +## Mount a filesystem + +If instead of extracting files, you'd like to explore the files from an +archive as a [FUSE](https://en.wikipedia.org/wiki/Filesystem_in_Userspace) +filesystem, you can use the `borgmatic mount` action. Here's an example: + +```bash +borgmatic mount --archive host-2019-... --mount-point /mnt +``` + +This mounts the entire archive on the given mount point `/mnt`, so that you +can look in there for your files. + +If you'd like to restrict the mounted filesystem to only particular paths from +your archive, use the `--path` flag, similar to the `extract` action above. +For instance: + +```bash +borgmatic mount --archive host-2019-... --mount-point /mnt --path var/lib +``` + +When you're all done exploring your files, unmount your mount point. No +`--archive` flag is needed: + +```bash +borgmatic umount --mount-point /mnt +``` + + ## Related documentation * [Set up backups with borgmatic](https://torsion.org/borgmatic/docs/how-to/set-up-backups/) diff --git a/setup.py b/setup.py index dfa85e81..cf16442a 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,6 @@ from setuptools import find_packages, setup -VERSION = '1.4.14' +VERSION = '1.4.15' setup( diff --git a/tests/integration/commands/test_arguments.py b/tests/integration/commands/test_arguments.py index 46203d5e..5a432be0 100644 --- a/tests/integration/commands/test_arguments.py +++ b/tests/integration/commands/test_arguments.py @@ -256,7 +256,7 @@ def test_parse_arguments_disallows_glob_archives_with_successful(): ) -def test_parse_arguments_disallows_repository_without_extract_or_list(): +def test_parse_arguments_disallows_repository_unless_action_consumes_it(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) with pytest.raises(SystemExit): @@ -271,20 +271,36 @@ def test_parse_arguments_allows_repository_with_extract(): ) +def test_parse_arguments_allows_repository_with_mount(): + flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) + + module.parse_arguments( + '--config', + 'myconfig', + 'mount', + '--repository', + 'test.borg', + '--archive', + 'test', + '--mount-point', + '/mnt', + ) + + def test_parse_arguments_allows_repository_with_list(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) module.parse_arguments('--config', 'myconfig', 'list', '--repository', 'test.borg') -def test_parse_arguments_disallows_archive_without_extract_restore_or_list(): +def test_parse_arguments_disallows_archive_unless_action_consumes_it(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) with pytest.raises(SystemExit): module.parse_arguments('--config', 'myconfig', '--archive', 'test') -def test_parse_arguments_disallows_paths_without_extract(): +def test_parse_arguments_disallows_paths_unless_action_consumes_it(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) with pytest.raises(SystemExit): @@ -297,6 +313,14 @@ def test_parse_arguments_allows_archive_with_extract(): module.parse_arguments('--config', 'myconfig', 'extract', '--archive', 'test') +def test_parse_arguments_allows_archive_with_mount(): + flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) + + module.parse_arguments( + '--config', 'myconfig', 'mount', '--archive', 'test', '--mount-point', '/mnt' + ) + + def test_parse_arguments_allows_archive_with_dashed_extract(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) @@ -328,6 +352,13 @@ def test_parse_arguments_requires_archive_with_extract(): module.parse_arguments('--config', 'myconfig', 'extract') +def test_parse_arguments_requires_archive_with_mount(): + flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) + + with pytest.raises(SystemExit): + module.parse_arguments('--config', 'myconfig', 'mount', '--mount-point', '/mnt') + + def test_parse_arguments_requires_archive_with_restore(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) @@ -335,6 +366,20 @@ def test_parse_arguments_requires_archive_with_restore(): module.parse_arguments('--config', 'myconfig', 'restore') +def test_parse_arguments_requires_mount_point_with_mount(): + flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) + + with pytest.raises(SystemExit): + module.parse_arguments('--config', 'myconfig', 'mount', '--archive', 'test') + + +def test_parse_arguments_requires_mount_point_with_umount(): + flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) + + with pytest.raises(SystemExit): + module.parse_arguments('--config', 'myconfig', 'umount') + + def test_parse_arguments_allows_progress_before_create(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) diff --git a/tests/unit/borg/test_extract.py b/tests/unit/borg/test_extract.py index e298ed24..027523c8 100644 --- a/tests/unit/borg/test_extract.py +++ b/tests/unit/borg/test_extract.py @@ -87,7 +87,7 @@ def test_extract_last_archive_dry_run_calls_borg_with_lock_wait_parameters(): module.extract_last_archive_dry_run(repository='repo', lock_wait=5) -def test_extract_archive_calls_borg_with_restore_path_parameters(): +def test_extract_archive_calls_borg_with_path_parameters(): flexmock(module.os.path).should_receive('abspath').and_return('repo') insert_execute_command_mock(('borg', 'extract', 'repo::archive', 'path1', 'path2')) diff --git a/tests/unit/borg/test_mount.py b/tests/unit/borg/test_mount.py new file mode 100644 index 00000000..998dc648 --- /dev/null +++ b/tests/unit/borg/test_mount.py @@ -0,0 +1,144 @@ +import logging + +from flexmock import flexmock + +from borgmatic.borg import mount as module + +from ..test_verbosity import insert_logging_mock + + +def insert_execute_command_mock(command): + flexmock(module).should_receive('execute_command').with_args(command).once() + + +def test_mount_archive_calls_borg_with_required_parameters(): + insert_execute_command_mock(('borg', 'mount', 'repo::archive', '/mnt')) + + module.mount_archive( + repository='repo', + archive='archive', + mount_point='/mnt', + paths=None, + foreground=False, + options=None, + storage_config={}, + ) + + +def test_mount_archive_calls_borg_with_path_parameters(): + insert_execute_command_mock(('borg', 'mount', 'repo::archive', '/mnt', 'path1', 'path2')) + + module.mount_archive( + repository='repo', + archive='archive', + mount_point='/mnt', + paths=['path1', 'path2'], + foreground=False, + options=None, + storage_config={}, + ) + + +def test_mount_archive_calls_borg_with_remote_path_parameters(): + insert_execute_command_mock( + ('borg', 'mount', '--remote-path', 'borg1', 'repo::archive', '/mnt') + ) + + module.mount_archive( + repository='repo', + archive='archive', + mount_point='/mnt', + paths=None, + foreground=False, + options=None, + storage_config={}, + remote_path='borg1', + ) + + +def test_mount_archive_calls_borg_with_umask_parameters(): + insert_execute_command_mock(('borg', 'mount', '--umask', '0770', 'repo::archive', '/mnt')) + + module.mount_archive( + repository='repo', + archive='archive', + mount_point='/mnt', + paths=None, + foreground=False, + options=None, + storage_config={'umask': '0770'}, + ) + + +def test_mount_archive_calls_borg_with_lock_wait_parameters(): + insert_execute_command_mock(('borg', 'mount', '--lock-wait', '5', 'repo::archive', '/mnt')) + + module.mount_archive( + repository='repo', + archive='archive', + mount_point='/mnt', + paths=None, + foreground=False, + options=None, + storage_config={'lock_wait': '5'}, + ) + + +def test_mount_archive_with_log_info_calls_borg_with_info_parameter(): + insert_execute_command_mock(('borg', 'mount', '--info', 'repo::archive', '/mnt')) + insert_logging_mock(logging.INFO) + + module.mount_archive( + repository='repo', + archive='archive', + mount_point='/mnt', + paths=None, + foreground=False, + options=None, + storage_config={}, + ) + + +def test_mount_archive_with_log_debug_calls_borg_with_debug_parameters(): + insert_execute_command_mock(('borg', 'mount', '--debug', '--show-rc', 'repo::archive', '/mnt')) + insert_logging_mock(logging.DEBUG) + + module.mount_archive( + repository='repo', + archive='archive', + mount_point='/mnt', + paths=None, + foreground=False, + options=None, + storage_config={}, + ) + + +def test_mount_archive_calls_borg_with_foreground_parameter(): + flexmock(module).should_receive('execute_command_without_capture').with_args( + ('borg', 'mount', '--foreground', 'repo::archive', '/mnt') + ).once() + + module.mount_archive( + repository='repo', + archive='archive', + mount_point='/mnt', + paths=None, + foreground=True, + options=None, + storage_config={}, + ) + + +def test_mount_archive_calls_borg_with_options_parameters(): + insert_execute_command_mock(('borg', 'mount', '-o', 'super_mount', 'repo::archive', '/mnt')) + + module.mount_archive( + repository='repo', + archive='archive', + mount_point='/mnt', + paths=None, + foreground=False, + options='super_mount', + storage_config={}, + ) diff --git a/tests/unit/borg/test_umount.py b/tests/unit/borg/test_umount.py new file mode 100644 index 00000000..78942ac3 --- /dev/null +++ b/tests/unit/borg/test_umount.py @@ -0,0 +1,33 @@ +import logging + +from flexmock import flexmock + +from borgmatic.borg import umount as module + +from ..test_verbosity import insert_logging_mock + + +def insert_execute_command_mock(command): + flexmock(module).should_receive('execute_command').with_args( + command, error_on_warnings=True + ).once() + + +def test_unmount_archive_calls_borg_with_required_parameters(): + insert_execute_command_mock(('borg', 'umount', '/mnt')) + + module.unmount_archive(mount_point='/mnt') + + +def test_unmount_archive_with_log_info_calls_borg_with_info_parameter(): + insert_execute_command_mock(('borg', 'umount', '--info', '/mnt')) + insert_logging_mock(logging.INFO) + + module.unmount_archive(mount_point='/mnt') + + +def test_unmount_archive_with_log_debug_calls_borg_with_debug_parameters(): + insert_execute_command_mock(('borg', 'umount', '--debug', '--show-rc', '/mnt')) + insert_logging_mock(logging.DEBUG) + + module.unmount_archive(mount_point='/mnt') diff --git a/tests/unit/commands/test_borgmatic.py b/tests/unit/commands/test_borgmatic.py index 6b0bd68c..b0a887b1 100644 --- a/tests/unit/commands/test_borgmatic.py +++ b/tests/unit/commands/test_borgmatic.py @@ -219,6 +219,33 @@ def test_collect_configuration_run_summary_logs_extract_with_repository_error(): assert logs == expected_logs +def test_collect_configuration_run_summary_logs_info_for_success_with_mount(): + flexmock(module.validate).should_receive('guard_configuration_contains_repository') + flexmock(module).should_receive('run_configuration').and_return([]) + arguments = {'mount': flexmock(repository='repo')} + + logs = tuple( + module.collect_configuration_run_summary_logs({'test.yaml': {}}, arguments=arguments) + ) + + assert {log.levelno for log in logs} == {logging.INFO} + + +def test_collect_configuration_run_summary_logs_mount_with_repository_error(): + flexmock(module.validate).should_receive('guard_configuration_contains_repository').and_raise( + ValueError + ) + expected_logs = (flexmock(),) + flexmock(module).should_receive('make_error_log_records').and_return(expected_logs) + arguments = {'mount': flexmock(repository='repo')} + + logs = tuple( + module.collect_configuration_run_summary_logs({'test.yaml': {}}, arguments=arguments) + ) + + assert logs == expected_logs + + def test_collect_configuration_run_summary_logs_missing_configs_error(): arguments = {'global': flexmock(config_paths=[])} expected_logs = (flexmock(),)