diff --git a/borgmatic/actions/import_key.py b/borgmatic/actions/import_key.py new file mode 100644 index 00000000..42813efa --- /dev/null +++ b/borgmatic/actions/import_key.py @@ -0,0 +1,33 @@ +import logging + +import borgmatic.borg.import_key +import borgmatic.config.validate + +logger = logging.getLogger(__name__) + + +def run_import_key( + repository, + config, + local_borg_version, + import_arguments, + global_arguments, + local_path, + remote_path, +): + ''' + Run the "key import" action for the given repository. + ''' + if import_arguments.repository is None or borgmatic.config.validate.repositories_match( + repository, import_arguments.repository + ): + logger.info('Importing repository key') + borgmatic.borg.import_key.import_key( + repository['path'], + config, + local_borg_version, + import_arguments, + global_arguments, + local_path=local_path, + remote_path=remote_path, + ) diff --git a/borgmatic/borg/import_key.py b/borgmatic/borg/import_key.py new file mode 100644 index 00000000..f8dd0604 --- /dev/null +++ b/borgmatic/borg/import_key.py @@ -0,0 +1,71 @@ +import logging +import os + +import borgmatic.config.paths +import borgmatic.logger +from borgmatic.borg import environment, flags +from borgmatic.execute import DO_NOT_CAPTURE, execute_command + +logger = logging.getLogger(__name__) + + +def import_key( + repository_path, + config, + local_borg_version, + import_arguments, + global_arguments, + local_path='borg', + remote_path=None, +): + ''' + Given a local or remote repository path, a configuration dict, the local Borg version, import + arguments, and optional local and remote Borg paths, import the repository key from the + path indicated in the import arguments. + + If the path is empty or "-", then read the key from stdin. + + Raise FileNotFoundError if the path is given and it does not exist. + ''' + borgmatic.logger.add_custom_log_levels() + umask = config.get('umask', None) + lock_wait = config.get('lock_wait', None) + working_directory = borgmatic.config.paths.get_working_directory(config) + + if import_arguments.path and import_arguments.path != '-': + if not os.path.exists(os.path.join(working_directory or '', import_arguments.path)): + raise FileNotFoundError(f'Path {import_arguments.path} does not exist. Aborting.') + + input_file = None + else: + input_file = DO_NOT_CAPTURE + + full_command = ( + (local_path, 'key', 'import') + + (('--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', import_arguments.paper) + + flags.make_repository_flags( + repository_path, + local_borg_version, + ) + + ((import_arguments.path,) if input_file is None else ()) + ) + + if global_arguments.dry_run: + logger.info('Skipping key import (dry run)') + return + + execute_command( + full_command, + input_file=input_file, + output_log_level=logging.ANSWER, + environment=environment.make_environment(config), + working_directory=working_directory, + borg_local_path=local_path, + borg_exit_codes=config.get('borg_exit_codes'), + ) diff --git a/borgmatic/commands/arguments.py b/borgmatic/commands/arguments.py index e95ec5f4..b0114b2a 100644 --- a/borgmatic/commands/arguments.py +++ b/borgmatic/commands/arguments.py @@ -1479,6 +1479,31 @@ def make_parsers(): '-h', '--help', action='help', help='Show this help message and exit' ) + key_import_parser = key_parsers.add_parser( + 'import', + help='Import a copy of the repository key from backup', + description='Import a copy of the repository key from backup', + add_help=False, + ) + key_import_group = key_import_parser.add_argument_group('key import arguments') + key_import_group.add_argument( + '--paper', + action='store_true', + help='Import interactively from a backup done with ``--paper``', + ) + key_import_group.add_argument( + '--repository', + help='Path of repository to import the key from, defaults to the configured repository if there is only one, quoted globs supported', + ) + key_import_group.add_argument( + '--path', + metavar='PATH', + help='Path to import the key from backup, defaults to stdin', + ) + key_import_group.add_argument( + '-h', '--help', action='help', help='Show this help message and exit' + ) + key_change_passphrase_parser = key_parsers.add_parser( 'change-passphrase', help='Change the passphrase protecting the repository key', diff --git a/borgmatic/commands/borgmatic.py b/borgmatic/commands/borgmatic.py index 5691df71..d46c6f51 100644 --- a/borgmatic/commands/borgmatic.py +++ b/borgmatic/commands/borgmatic.py @@ -20,6 +20,7 @@ import borgmatic.actions.create import borgmatic.actions.delete import borgmatic.actions.export_key import borgmatic.actions.export_tar +import borgmatic.actions.import_key import borgmatic.actions.extract import borgmatic.actions.info import borgmatic.actions.list @@ -528,6 +529,16 @@ def run_actions( local_path, remote_path, ) + elif action_name == 'import' and action_name not in skip_actions: + borgmatic.actions.import_key.run_import_key( + repository, + config, + local_borg_version, + action_arguments, + global_arguments, + local_path, + remote_path, + ) elif action_name == 'change-passphrase' and action_name not in skip_actions: borgmatic.actions.change_passphrase.run_change_passphrase( repository, diff --git a/tests/integration/config/test_schema.py b/tests/integration/config/test_schema.py index 30eb1c47..5cf114b1 100644 --- a/tests/integration/config/test_schema.py +++ b/tests/integration/config/test_schema.py @@ -14,7 +14,13 @@ def test_schema_line_length_stays_under_limit(): assert len(line.rstrip('\n')) <= MAXIMUM_LINE_LENGTH -ACTIONS_MODULE_NAMES_TO_OMIT = {'arguments', 'change_passphrase', 'export_key', 'json'} +ACTIONS_MODULE_NAMES_TO_OMIT = { + 'arguments', + 'change_passphrase', + 'export_key', + 'import_key', + 'json', +} ACTIONS_MODULE_NAMES_TO_ADD = {'key', 'umount'} diff --git a/tests/unit/actions/test_import_key.py b/tests/unit/actions/test_import_key.py new file mode 100644 index 00000000..565e3df3 --- /dev/null +++ b/tests/unit/actions/test_import_key.py @@ -0,0 +1,20 @@ +from flexmock import flexmock + +from borgmatic.actions import import_key as module + + +def test_run_import_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.import_key).should_receive('import_key') + import_arguments = flexmock(repository=flexmock()) + + module.run_import_key( + repository={'path': 'repo'}, + config={}, + local_borg_version=None, + import_arguments=import_arguments, + global_arguments=flexmock(), + local_path=None, + remote_path=None, + ) diff --git a/tests/unit/borg/test_import_key.py b/tests/unit/borg/test_import_key.py new file mode 100644 index 00000000..a9e8878c --- /dev/null +++ b/tests/unit/borg/test_import_key.py @@ -0,0 +1,265 @@ +import logging + +import pytest +from flexmock import flexmock + +import borgmatic.logger +from borgmatic.borg import import_key as module + +from ..test_verbosity import insert_logging_mock + + +def insert_execute_command_mock( + command, input_file=module.DO_NOT_CAPTURE, working_directory=None, borg_exit_codes=None +): + borgmatic.logger.add_custom_log_levels() + + flexmock(module.environment).should_receive('make_environment') + flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return( + working_directory, + ) + flexmock(module).should_receive('execute_command').with_args( + command, + input_file=input_file, + output_log_level=module.logging.ANSWER, + environment=None, + working_directory=working_directory, + borg_local_path=command[0], + borg_exit_codes=borg_exit_codes, + ).once() + + +def test_import_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', 'import', 'repo')) + + module.import_key( + repository_path='repo', + config={}, + local_borg_version='1.2.3', + import_arguments=flexmock(paper=False, path=None), + global_arguments=flexmock(dry_run=False, log_json=False), + ) + + +def test_import_key_calls_borg_with_local_path(): + flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) + flexmock(module.os.path).should_receive('exists').never() + insert_execute_command_mock(('borg1', 'key', 'import', 'repo')) + + module.import_key( + repository_path='repo', + config={}, + local_borg_version='1.2.3', + import_arguments=flexmock(paper=False, path=None), + global_arguments=flexmock(dry_run=False, log_json=False), + local_path='borg1', + ) + + +def test_import_key_calls_borg_using_exit_codes(): + flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) + flexmock(module.os.path).should_receive('exists').never() + borg_exit_codes = flexmock() + insert_execute_command_mock(('borg', 'key', 'import', 'repo'), borg_exit_codes=borg_exit_codes) + + module.import_key( + repository_path='repo', + config={'borg_exit_codes': borg_exit_codes}, + local_borg_version='1.2.3', + import_arguments=flexmock(paper=False, path=None), + global_arguments=flexmock(dry_run=False, log_json=False), + ) + + +def test_import_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', 'import', '--remote-path', 'borg1', 'repo')) + + module.import_key( + repository_path='repo', + config={}, + local_borg_version='1.2.3', + import_arguments=flexmock(paper=False, path=None), + global_arguments=flexmock(dry_run=False, log_json=False), + remote_path='borg1', + ) + + +def test_import_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', 'import', '--umask', '0770', 'repo')) + + module.import_key( + repository_path='repo', + config={'umask': '0770'}, + local_borg_version='1.2.3', + import_arguments=flexmock(paper=False, path=None), + global_arguments=flexmock(dry_run=False, log_json=False), + ) + + +def test_import_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', 'import', '--log-json', 'repo')) + + module.import_key( + repository_path='repo', + config={}, + local_borg_version='1.2.3', + import_arguments=flexmock(paper=False, path=None), + global_arguments=flexmock(dry_run=False, log_json=True), + ) + + +def test_import_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', 'import', '--lock-wait', '5', 'repo')) + + module.import_key( + repository_path='repo', + config={'lock_wait': '5'}, + local_borg_version='1.2.3', + import_arguments=flexmock(paper=False, path=None), + global_arguments=flexmock(dry_run=False, log_json=False), + ) + + +def test_import_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', 'import', '--info', 'repo')) + insert_logging_mock(logging.INFO) + + module.import_key( + repository_path='repo', + config={}, + local_borg_version='1.2.3', + import_arguments=flexmock(paper=False, path=None), + global_arguments=flexmock(dry_run=False, log_json=False), + ) + + +def test_import_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', 'import', '--debug', '--show-rc', 'repo')) + insert_logging_mock(logging.DEBUG) + + module.import_key( + repository_path='repo', + config={}, + local_borg_version='1.2.3', + import_arguments=flexmock(paper=False, path=None), + global_arguments=flexmock(dry_run=False, log_json=False), + ) + + +def test_import_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', 'import', '--paper', 'repo')) + + module.import_key( + repository_path='repo', + config={}, + local_borg_version='1.2.3', + import_arguments=flexmock(paper=True, path=None), + global_arguments=flexmock(dry_run=False, log_json=False), + ) + + +def test_import_key_calls_borg_with_path_argument(): + flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) + flexmock(module.os.path).should_receive('exists').with_args('source').and_return(True) + insert_execute_command_mock(('borg', 'key', 'import', 'repo', 'source'), input_file=None) + + module.import_key( + repository_path='repo', + config={}, + local_borg_version='1.2.3', + import_arguments=flexmock(paper=False, path='source'), + global_arguments=flexmock(dry_run=False, log_json=False), + ) + + +def test_import_key_with_non_existent_path_raises(): + flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) + flexmock(module.os.path).should_receive('exists').and_return(False) + flexmock(module).should_receive('execute_command').never() + + with pytest.raises(FileNotFoundError): + module.import_key( + repository_path='repo', + config={}, + local_borg_version='1.2.3', + import_arguments=flexmock(paper=False, path='source'), + global_arguments=flexmock(dry_run=False, log_json=False), + ) + + +def test_import_key_with_stdin_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', 'import', 'repo')) + + module.import_key( + repository_path='repo', + config={}, + local_borg_version='1.2.3', + import_arguments=flexmock(paper=False, path='-'), + global_arguments=flexmock(dry_run=False, log_json=False), + ) + + +def test_import_key_with_dry_run_skips_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.import_key( + repository_path='repo', + config={}, + local_borg_version='1.2.3', + import_arguments=flexmock(paper=False, path=None), + global_arguments=flexmock(dry_run=True, log_json=False), + ) + + +def test_import_key_calls_borg_with_working_directory(): + 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', 'import', 'repo'), working_directory='/working/dir') + + module.import_key( + repository_path='repo', + config={'working_directory': '/working/dir'}, + local_borg_version='1.2.3', + import_arguments=flexmock(paper=False, path=None), + global_arguments=flexmock(dry_run=False, log_json=False), + ) + + +def test_import_key_calls_borg_with_path_argument_and_working_directory(): + flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) + flexmock(module.os.path).should_receive('exists').with_args('/working/dir/source').and_return( + True + ).once() + insert_execute_command_mock( + ('borg', 'key', 'import', 'repo', 'source'), + input_file=None, + working_directory='/working/dir', + ) + + module.import_key( + repository_path='repo', + config={'working_directory': '/working/dir'}, + local_borg_version='1.2.3', + import_arguments=flexmock(paper=False, path='source'), + global_arguments=flexmock(dry_run=False, log_json=False), + ) diff --git a/tests/unit/commands/test_borgmatic.py b/tests/unit/commands/test_borgmatic.py index c31cf904..160f4454 100644 --- a/tests/unit/commands/test_borgmatic.py +++ b/tests/unit/commands/test_borgmatic.py @@ -1390,6 +1390,26 @@ def test_run_actions_runs_export_key(): ) +def test_run_actions_runs_import_key(): + flexmock(module).should_receive('add_custom_log_levels') + flexmock(module).should_receive('get_skip_actions').and_return([]) + flexmock(module.command).should_receive('Before_after_hooks').and_return(flexmock()) + flexmock(borgmatic.actions.import_key).should_receive('run_import_key').once() + + tuple( + module.run_actions( + arguments={'global': flexmock(dry_run=False, log_file='foo'), 'import': flexmock()}, + config_filename=flexmock(), + config={'repositories': []}, + config_paths=[], + local_path=flexmock(), + remote_path=flexmock(), + local_borg_version=flexmock(), + repository={'path': 'repo'}, + ) + ) + + def test_run_actions_runs_change_passphrase(): flexmock(module).should_receive('add_custom_log_levels') flexmock(module).should_receive('get_skip_actions').and_return([])