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>
This commit is contained in:
Dan Helfman
2025-03-21 21:22:50 +00:00
8 changed files with 466 additions and 1 deletions
+33
View File
@@ -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
View File
@@ -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
View File
@@ -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',
+11
View File
@@ -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
@@ -533,6 +534,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,
+7 -1
View File
@@ -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'}
+20
View File
@@ -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
View File
@@ -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
View File
@@ -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([])