diff --git a/NEWS b/NEWS
index 9c03f52a..3599a20c 100644
--- a/NEWS
+++ b/NEWS
@@ -6,6 +6,7 @@
action flags. For example, each entry in "repositories:" now has an "encryption" option that
applies to the "repo-create" action. See the documentation for more information:
https://torsion.org/borgmatic/docs/reference/configuration/
+ * #345: Add a "key import" action to import a repository key from backup.
* #790, #821: Deprecate all "before_*", "after_*" and "on_error" command hooks in favor of more
flexible "commands:". See the documentation for more information:
https://torsion.org/borgmatic/docs/how-to/add-preparation-and-cleanup-steps-to-backups/
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..9e902778
--- /dev/null
+++ b/borgmatic/borg/import_key.py
@@ -0,0 +1,70 @@
+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.
+ '''
+ 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'),
+ )
diff --git a/borgmatic/commands/arguments.py b/borgmatic/commands/arguments.py
index cb4bbcef..f00f49bb 100644
--- a/borgmatic/commands/arguments.py
+++ b/borgmatic/commands/arguments.py
@@ -1654,6 +1654,31 @@ def make_parsers(schema, unparsed_arguments):
'-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 504a094c..19816250 100644
--- a/borgmatic/commands/borgmatic.py
+++ b/borgmatic/commands/borgmatic.py
@@ -23,6 +23,7 @@ import borgmatic.actions.delete
import borgmatic.actions.export_key
import borgmatic.actions.export_tar
import borgmatic.actions.extract
+import borgmatic.actions.import_key
import borgmatic.actions.info
import borgmatic.actions.list
import borgmatic.actions.mount
@@ -536,6 +537,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/docs/how-to/add-preparation-and-cleanup-steps-to-backups.md b/docs/how-to/add-preparation-and-cleanup-steps-to-backups.md
index 977e87f1..7637fd72 100644
--- a/docs/how-to/add-preparation-and-cleanup-steps-to-backups.md
+++ b/docs/how-to/add-preparation-and-cleanup-steps-to-backups.md
@@ -72,7 +72,7 @@ steps that correspond to `before` preparation commands—even when something goe
wrong. This is a departure from the way that the deprecated `after_*` hooks
worked in borgmatic prior to version 2.0.0.
-Additionally, when command hooks run, they respect the "working_directory"
+Additionally, when command hooks run, they respect the `working_directory`
option if it is configured, meaning that the hook commands are run in that
directory.
@@ -143,7 +143,7 @@ or between those two hooks. This allows you to perform cleanup steps that
correspond to `before_*` preparation commands—even when something goes wrong.
New in version 2.0.0 When command
-hooks run, they respect the "working_directory" option if it is configured,
+hooks run, they respect the `working_directory` option if it is configured,
meaning that the hook commands are run in that directory.
New in version 1.7.0 The
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..dfbcd900
--- /dev/null
+++ b/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),
+ )
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([])