diff --git a/NEWS b/NEWS index d93f2a71..065859f6 100644 --- a/NEWS +++ b/NEWS @@ -1,9 +1,11 @@ 1.8.2.dev0 + * #345: Add "key export" action to export a copy of the repository key for safekeeping in case + the original goes missing or gets damaged. * #727: Add a MariaDB database hook that uses native MariaDB commands instead of the deprecated MySQL ones. Be aware though that any existing backups made with the "mysql_databases:" hook are only restorable with a "mysql_databases:" configuration. - * Add a source code reference for getting oriented with the borgmatic code as a developer: - https://torsion.org/borgmatic/docs/reference/source-code/ + * Add source code reference documentation for getting oriented with the borgmatic code as a + developer: https://torsion.org/borgmatic/docs/reference/source-code/ 1.8.1 * #326: Add documentation for restoring a database to an alternate host: diff --git a/borgmatic/actions/export_key.py b/borgmatic/actions/export_key.py new file mode 100644 index 00000000..56a72e7f --- /dev/null +++ b/borgmatic/actions/export_key.py @@ -0,0 +1,33 @@ +import logging + +import borgmatic.borg.export_key +import borgmatic.config.validate + +logger = logging.getLogger(__name__) + + +def run_export_key( + repository, + config, + local_borg_version, + export_arguments, + global_arguments, + local_path, + remote_path, +): + ''' + Run the "key export" action for the given repository. + ''' + if export_arguments.repository is None or borgmatic.config.validate.repositories_match( + repository, export_arguments.repository + ): + logger.info(f'{repository.get("label", repository["path"])}: Exporting repository key') + borgmatic.borg.export_key.export_key( + repository['path'], + config, + local_borg_version, + export_arguments, + global_arguments, + local_path=local_path, + remote_path=remote_path, + ) diff --git a/borgmatic/borg/export_key.py b/borgmatic/borg/export_key.py new file mode 100644 index 00000000..b249e0bb --- /dev/null +++ b/borgmatic/borg/export_key.py @@ -0,0 +1,70 @@ +import logging +import os + +import borgmatic.logger +from borgmatic.borg import environment, flags +from borgmatic.execute import DO_NOT_CAPTURE, execute_command + +logger = logging.getLogger(__name__) + + +def export_key( + repository_path, + config, + local_borg_version, + export_arguments, + global_arguments, + local_path='borg', + remote_path=None, +): + ''' + Given a local or remote repository path, a configuration dict, the local Borg version, and + optional local and remote Borg paths, export the repository key to the destination path + indicated in the export arguments. + + If the destination path is empty or "-", then print the key to stdout instead of to a file. + + Raise FileExistsError if a path is given but it already exists on disk. + ''' + borgmatic.logger.add_custom_log_levels() + umask = config.get('umask', None) + lock_wait = config.get('lock_wait', None) + + if export_arguments.path and export_arguments.path != '-': + if os.path.exists(export_arguments.path): + raise FileExistsError( + f'Destination path {export_arguments.path} already exists. Aborting.' + ) + + output_file = None + else: + output_file = DO_NOT_CAPTURE + + full_command = ( + (local_path, 'key', 'export') + + (('--remote-path', remote_path) if remote_path else ()) + + (('--umask', str(umask)) if umask else ()) + + (('--log-json',) if global_arguments.log_json 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 ()) + + flags.make_flags('paper', export_arguments.paper) + + flags.make_flags('qr-html', export_arguments.qr_html) + + flags.make_repository_flags( + repository_path, + local_borg_version, + ) + + ((export_arguments.path,) if output_file is None else ()) + ) + + if global_arguments.dry_run: + logging.info(f'{repository_path}: Skipping key export (dry run)') + return + + execute_command( + full_command, + output_file=output_file, + output_log_level=logging.ANSWER, + borg_local_path=local_path, + extra_environment=environment.make_environment(config), + ) diff --git a/borgmatic/commands/arguments.py b/borgmatic/commands/arguments.py index 71e80a26..cf9a5cfe 100644 --- a/borgmatic/commands/arguments.py +++ b/borgmatic/commands/arguments.py @@ -23,6 +23,7 @@ ACTION_ALIASES = { 'info': ['-i'], 'transfer': [], 'break-lock': [], + 'key': [], 'borg': [], } @@ -1176,6 +1177,51 @@ def make_parsers(): '-h', '--help', action='help', help='Show this help message and exit' ) + key_parser = action_parsers.add_parser( + 'key', + aliases=ACTION_ALIASES['key'], + help='Perform repository key related operations', + description='Perform repository key related operations', + add_help=False, + ) + + key_group = key_parser.add_argument_group('key arguments') + key_group.add_argument('-h', '--help', action='help', help='Show this help message and exit') + + key_parsers = key_parser.add_subparsers( + title='key sub-actions', + ) + + key_export_parser = key_parsers.add_parser( + 'export', + help='Export a copy of the repository key for safekeeping in case the original goes missing or gets damaged', + description='Export a copy of the repository key for safekeeping in case the original goes missing or gets damaged', + add_help=False, + ) + key_export_group = key_export_parser.add_argument_group('key export arguments') + key_export_group.add_argument( + '--paper', + action='store_true', + help='Export the key in a text format suitable for printing and later manual entry', + ) + key_export_group.add_argument( + '--qr-html', + action='store_true', + help='Export the key in an HTML format suitable for printing and later manual entry or QR code scanning', + ) + key_export_group.add_argument( + '--repository', + help='Path of repository to export the key for, defaults to the configured repository if there is only one', + ) + key_export_group.add_argument( + '--path', + metavar='PATH', + help='Path to export the key to, defaults to stdout (but be careful about dirtying the output with --verbosity)', + ) + key_export_group.add_argument( + '-h', '--help', action='help', help='Show this help message and exit' + ) + borg_parser = action_parsers.add_parser( 'borg', aliases=ACTION_ALIASES['borg'], diff --git a/borgmatic/commands/borgmatic.py b/borgmatic/commands/borgmatic.py index 8f61a0d3..40584bec 100644 --- a/borgmatic/commands/borgmatic.py +++ b/borgmatic/commands/borgmatic.py @@ -22,6 +22,7 @@ import borgmatic.actions.config.bootstrap import borgmatic.actions.config.generate import borgmatic.actions.config.validate import borgmatic.actions.create +import borgmatic.actions.export_key import borgmatic.actions.export_tar import borgmatic.actions.extract import borgmatic.actions.info @@ -448,6 +449,16 @@ def run_actions( local_path, remote_path, ) + elif action_name == 'export': + borgmatic.actions.export_key.run_export_key( + repository, + config, + local_borg_version, + action_arguments, + global_arguments, + local_path, + remote_path, + ) elif action_name == 'borg': borgmatic.actions.borg.run_borg( repository, diff --git a/docs/how-to/backup-your-databases.md b/docs/how-to/backup-your-databases.md index 1761311a..f4508356 100644 --- a/docs/how-to/backup-your-databases.md +++ b/docs/how-to/backup-your-databases.md @@ -310,7 +310,8 @@ problem: the `restore` action figures out which repository to use. But if you have multiple repositories configured, then you'll need to specify the repository to use via the `--repository` flag. This can be done either -with the repository's path or its label as configured in your borgmatic configuration file. +with the repository's path or its label as configured in your borgmatic +configuration file. ```bash borgmatic restore --repository repo.borg --archive host-2023-... diff --git a/tests/unit/actions/test_export_key.py b/tests/unit/actions/test_export_key.py new file mode 100644 index 00000000..7be545e0 --- /dev/null +++ b/tests/unit/actions/test_export_key.py @@ -0,0 +1,20 @@ +from flexmock import flexmock + +from borgmatic.actions import export_key as module + + +def test_run_export_key_does_not_raise(): + flexmock(module.logger).answer = lambda message: None + flexmock(module.borgmatic.config.validate).should_receive('repositories_match').and_return(True) + flexmock(module.borgmatic.borg.export_key).should_receive('export_key') + export_arguments = flexmock(repository=flexmock()) + + module.run_export_key( + repository={'path': 'repo'}, + config={}, + local_borg_version=None, + export_arguments=export_arguments, + global_arguments=flexmock(), + local_path=None, + remote_path=None, + ) diff --git a/tests/unit/borg/test_export_key.py b/tests/unit/borg/test_export_key.py new file mode 100644 index 00000000..e505a57b --- /dev/null +++ b/tests/unit/borg/test_export_key.py @@ -0,0 +1,222 @@ +import logging + +import pytest +from flexmock import flexmock + +import borgmatic.logger +from borgmatic.borg import export_key as module + +from ..test_verbosity import insert_logging_mock + + +def insert_execute_command_mock(command, output_file=module.DO_NOT_CAPTURE): + borgmatic.logger.add_custom_log_levels() + + flexmock(module.environment).should_receive('make_environment') + flexmock(module).should_receive('execute_command').with_args( + command, + output_file=output_file, + output_log_level=module.logging.ANSWER, + borg_local_path='borg', + extra_environment=None, + ).once() + + +def test_export_key_calls_borg_with_required_flags(): + flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) + flexmock(module.os.path).should_receive('exists').never() + insert_execute_command_mock(('borg', 'key', 'export', 'repo')) + + module.export_key( + repository_path='repo', + config={}, + local_borg_version='1.2.3', + export_arguments=flexmock(paper=False, qr_html=False, path=None), + global_arguments=flexmock(dry_run=False, log_json=False), + ) + + +def test_export_key_calls_borg_with_remote_path_flags(): + flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) + flexmock(module.os.path).should_receive('exists').never() + insert_execute_command_mock(('borg', 'key', 'export', '--remote-path', 'borg1', 'repo')) + + module.export_key( + repository_path='repo', + config={}, + local_borg_version='1.2.3', + export_arguments=flexmock(paper=False, qr_html=False, path=None), + global_arguments=flexmock(dry_run=False, log_json=False), + remote_path='borg1', + ) + + +def test_export_key_calls_borg_with_umask_flags(): + flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) + flexmock(module.os.path).should_receive('exists').never() + insert_execute_command_mock(('borg', 'key', 'export', '--umask', '0770', 'repo')) + + module.export_key( + repository_path='repo', + config={'umask': '0770'}, + local_borg_version='1.2.3', + export_arguments=flexmock(paper=False, qr_html=False, path=None), + global_arguments=flexmock(dry_run=False, log_json=False), + ) + + +def test_export_key_calls_borg_with_log_json_flags(): + flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) + flexmock(module.os.path).should_receive('exists').never() + insert_execute_command_mock(('borg', 'key', 'export', '--log-json', 'repo')) + + module.export_key( + repository_path='repo', + config={}, + local_borg_version='1.2.3', + export_arguments=flexmock(paper=False, qr_html=False, path=None), + global_arguments=flexmock(dry_run=False, log_json=True), + ) + + +def test_export_key_calls_borg_with_lock_wait_flags(): + flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) + flexmock(module.os.path).should_receive('exists').never() + insert_execute_command_mock(('borg', 'key', 'export', '--lock-wait', '5', 'repo')) + + module.export_key( + repository_path='repo', + config={'lock_wait': '5'}, + local_borg_version='1.2.3', + export_arguments=flexmock(paper=False, qr_html=False, path=None), + global_arguments=flexmock(dry_run=False, log_json=False), + ) + + +def test_export_key_with_log_info_calls_borg_with_info_parameter(): + flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) + flexmock(module.os.path).should_receive('exists').never() + insert_execute_command_mock(('borg', 'key', 'export', '--info', 'repo')) + insert_logging_mock(logging.INFO) + + module.export_key( + repository_path='repo', + config={}, + local_borg_version='1.2.3', + export_arguments=flexmock(paper=False, qr_html=False, path=None), + global_arguments=flexmock(dry_run=False, log_json=False), + ) + + +def test_export_key_with_log_debug_calls_borg_with_debug_flags(): + flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) + flexmock(module.os.path).should_receive('exists').never() + insert_execute_command_mock(('borg', 'key', 'export', '--debug', '--show-rc', 'repo')) + insert_logging_mock(logging.DEBUG) + + module.export_key( + repository_path='repo', + config={}, + local_borg_version='1.2.3', + export_arguments=flexmock(paper=False, qr_html=False, path=None), + global_arguments=flexmock(dry_run=False, log_json=False), + ) + + +def test_export_key_calls_borg_with_paper_flags(): + flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) + flexmock(module.os.path).should_receive('exists').never() + insert_execute_command_mock(('borg', 'key', 'export', '--paper', 'repo')) + + module.export_key( + repository_path='repo', + config={}, + local_borg_version='1.2.3', + export_arguments=flexmock(paper=True, qr_html=False, path=None), + global_arguments=flexmock(dry_run=False, log_json=False), + ) + + +def test_export_key_calls_borg_with_paper_flag(): + flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) + flexmock(module.os.path).should_receive('exists').never() + insert_execute_command_mock(('borg', 'key', 'export', '--paper', 'repo')) + + module.export_key( + repository_path='repo', + config={}, + local_borg_version='1.2.3', + export_arguments=flexmock(paper=True, qr_html=False, path=None), + global_arguments=flexmock(dry_run=False, log_json=False), + ) + + +def test_export_key_calls_borg_with_qr_html_flag(): + flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) + flexmock(module.os.path).should_receive('exists').never() + insert_execute_command_mock(('borg', 'key', 'export', '--qr-html', 'repo')) + + module.export_key( + repository_path='repo', + config={}, + local_borg_version='1.2.3', + export_arguments=flexmock(paper=False, qr_html=True, path=None), + global_arguments=flexmock(dry_run=False, log_json=False), + ) + + +def test_export_key_calls_borg_with_path_argument(): + flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) + flexmock(module.os.path).should_receive('exists').and_return(False) + insert_execute_command_mock(('borg', 'key', 'export', 'repo', 'dest'), output_file=None) + + module.export_key( + repository_path='repo', + config={}, + local_borg_version='1.2.3', + export_arguments=flexmock(paper=False, qr_html=False, path='dest'), + global_arguments=flexmock(dry_run=False, log_json=False), + ) + + +def test_export_key_with_already_existent_path_raises(): + flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) + flexmock(module.os.path).should_receive('exists').and_return(True) + flexmock(module).should_receive('execute_command').never() + + with pytest.raises(FileExistsError): + module.export_key( + repository_path='repo', + config={}, + local_borg_version='1.2.3', + export_arguments=flexmock(paper=False, qr_html=False, path='dest'), + global_arguments=flexmock(dry_run=False, log_json=False), + ) + + +def test_export_key_with_stdout_path_calls_borg_without_path_argument(): + flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) + flexmock(module.os.path).should_receive('exists').never() + insert_execute_command_mock(('borg', 'key', 'export', 'repo')) + + module.export_key( + repository_path='repo', + config={}, + local_borg_version='1.2.3', + export_arguments=flexmock(paper=False, qr_html=False, path='-'), + global_arguments=flexmock(dry_run=False, log_json=False), + ) + + +def test_export_key_with_dry_run_skip_borg_call(): + flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) + flexmock(module.os.path).should_receive('exists').never() + flexmock(module).should_receive('execute_command').never() + + module.export_key( + repository_path='repo', + config={}, + local_borg_version='1.2.3', + export_arguments=flexmock(paper=False, qr_html=False, path=None), + global_arguments=flexmock(dry_run=True, log_json=False), + ) diff --git a/tests/unit/commands/test_borgmatic.py b/tests/unit/commands/test_borgmatic.py index 1c44e281..abed9cda 100644 --- a/tests/unit/commands/test_borgmatic.py +++ b/tests/unit/commands/test_borgmatic.py @@ -748,6 +748,24 @@ def test_run_actions_runs_break_lock(): ) +def test_run_actions_runs_export_key(): + flexmock(module).should_receive('add_custom_log_levels') + flexmock(module.command).should_receive('execute_hook') + flexmock(borgmatic.actions.export_key).should_receive('run_export_key').once() + + tuple( + module.run_actions( + arguments={'global': flexmock(dry_run=False, log_file='foo'), 'export': flexmock()}, + config_filename=flexmock(), + config={'repositories': []}, + local_path=flexmock(), + remote_path=flexmock(), + local_borg_version=flexmock(), + repository={'path': 'repo'}, + ) + ) + + def test_run_actions_runs_borg(): flexmock(module).should_receive('add_custom_log_levels') flexmock(module.command).should_receive('execute_hook')