Browse Source

Support for mounting an archive as a FUSE filesystem via "borgmatic mount" action, and unmounting via "borgmatic umount" (#123).

tags/1.4.15
Dan Helfman 7 months ago
parent
commit
375036e409
13 changed files with 436 additions and 6 deletions
  1. +5
    -0
      NEWS
  2. +46
    -0
      borgmatic/borg/mount.py
  3. +20
    -0
      borgmatic/borg/umount.py
  4. +56
    -0
      borgmatic/commands/arguments.py
  5. +25
    -0
      borgmatic/commands/borgmatic.py
  6. +1
    -1
      docs/Dockerfile
  7. +29
    -0
      docs/how-to/extract-a-backup.md
  8. +1
    -1
      setup.py
  9. +48
    -3
      tests/integration/commands/test_arguments.py
  10. +1
    -1
      tests/unit/borg/test_extract.py
  11. +144
    -0
      tests/unit/borg/test_mount.py
  12. +33
    -0
      tests/unit/borg/test_umount.py
  13. +27
    -0
      tests/unit/commands/test_borgmatic.py

+ 5
- 0
NEWS View File

@@ -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.


+ 46
- 0
borgmatic/borg/mount.py View File

@@ -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)

+ 20
- 0
borgmatic/borg/umount.py View File

@@ -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)

+ 56
- 0
borgmatic/commands/arguments.py View File

@@ -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'],


+ 25
- 0
borgmatic/commands/borgmatic.py View File

@@ -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



+ 1
- 1
docs/Dockerfile View File

@@ -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



+ 29
- 0
docs/how-to/extract-a-backup.md View File

@@ -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/)


+ 1
- 1
setup.py View File

@@ -1,6 +1,6 @@
from setuptools import find_packages, setup

VERSION = '1.4.14'
VERSION = '1.4.15'


setup(


+ 48
- 3
tests/integration/commands/test_arguments.py View File

@@ -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'])



+ 1
- 1
tests/unit/borg/test_extract.py View File

@@ -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'))



+ 144
- 0
tests/unit/borg/test_mount.py View File

@@ -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={},
)

+ 33
- 0
tests/unit/borg/test_umount.py View File

@@ -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')

+ 27
- 0
tests/unit/commands/test_borgmatic.py View File

@@ -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(),)


Loading…
Cancel
Save