Browse Source

Add key import action to import a copy of repository key from backup (#345).

Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/1036
Reviewed-by: Dan Helfman <witten@torsion.org>
Dan Helfman 2 months ago
parent
commit
9ac2a2e286

+ 33 - 0
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,
+        )

+ 71 - 0
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 ValueError 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 ValueError(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.INFO,
+        environment=environment.make_environment(config),
+        working_directory=working_directory,
+        borg_local_path=local_path,
+        borg_exit_codes=config.get('borg_exit_codes'),
+    )

+ 25 - 0
borgmatic/commands/arguments.py

@@ -1479,6 +1479,31 @@ def make_parsers():
         '-h', '--help', action='help', help='Show this help message and exit'
         '-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(
     key_change_passphrase_parser = key_parsers.add_parser(
         'change-passphrase',
         'change-passphrase',
         help='Change the passphrase protecting the repository key',
         help='Change the passphrase protecting the repository key',

+ 11 - 0
borgmatic/commands/borgmatic.py

@@ -20,6 +20,7 @@ import borgmatic.actions.create
 import borgmatic.actions.delete
 import borgmatic.actions.delete
 import borgmatic.actions.export_key
 import borgmatic.actions.export_key
 import borgmatic.actions.export_tar
 import borgmatic.actions.export_tar
+import borgmatic.actions.import_key
 import borgmatic.actions.extract
 import borgmatic.actions.extract
 import borgmatic.actions.info
 import borgmatic.actions.info
 import borgmatic.actions.list
 import borgmatic.actions.list
@@ -533,6 +534,16 @@ def run_actions(
                         local_path,
                         local_path,
                         remote_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:
                 elif action_name == 'change-passphrase' and action_name not in skip_actions:
                     borgmatic.actions.change_passphrase.run_change_passphrase(
                     borgmatic.actions.change_passphrase.run_change_passphrase(
                         repository,
                         repository,

+ 7 - 1
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
         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'}
 ACTIONS_MODULE_NAMES_TO_ADD = {'key', 'umount'}
 
 
 
 

+ 20 - 0
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,
+    )

+ 279 - 0
tests/unit/borg/test_import_key.py

@@ -0,0 +1,279 @@
+import logging
+
+import pytest
+from flexmock import flexmock
+
+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
+):
+
+    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.INFO,
+        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_flags').and_return(())
+    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_flags').and_return(())
+    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_flags').and_return(())
+    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_flags').and_return(())
+    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_flags').and_return(())
+    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_flags').and_return(())
+    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_flags').and_return(())
+    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_flags').and_return(())
+    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_flags').and_return(())
+    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_flags').and_return(('--paper',))
+    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_flags').and_return(())
+    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_flags').and_return(())
+    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(ValueError):
+        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_flags').and_return(())
+    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_flags').and_return(())
+    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_flags').and_return(())
+    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_flags').and_return(())
+    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),
+    )

+ 20 - 0
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():
 def test_run_actions_runs_change_passphrase():
     flexmock(module).should_receive('add_custom_log_levels')
     flexmock(module).should_receive('add_custom_log_levels')
     flexmock(module).should_receive('get_skip_actions').and_return([])
     flexmock(module).should_receive('get_skip_actions').and_return([])