Merge branch 'main' into config-command-line

This commit is contained in:
Dan Helfman
2025-03-21 14:30:39 -07:00
10 changed files with 468 additions and 3 deletions
+1
View File
@@ -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/
+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,
)
+70
View File
@@ -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'),
)
+25
View File
@@ -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',
+11
View File
@@ -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,
@@ -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.
<span class="minilink minilink-addedin">New in version 2.0.0</span> 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.
<span class="minilink minilink-addedin">New in version 1.7.0</span> The
+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([])