diff --git a/NEWS b/NEWS index 3300e1fb..8acb9b3f 100644 --- a/NEWS +++ b/NEWS @@ -1,3 +1,6 @@ +1.8.15.dev0 + * #911: Add a "key change-passphrase" action to change the passphrase protecting a repository key. + 1.8.14 * #896: Fix an error in borgmatic rcreate/init on an empty repository directory with Borg 1.4. * #898: Add glob ("*") support to the "--repository" flag. Just quote any values containing diff --git a/borgmatic/actions/change_passphrase.py b/borgmatic/actions/change_passphrase.py new file mode 100644 index 00000000..2a3df3a5 --- /dev/null +++ b/borgmatic/actions/change_passphrase.py @@ -0,0 +1,38 @@ +import logging + +import borgmatic.borg.change_passphrase +import borgmatic.config.validate + +logger = logging.getLogger(__name__) + + +def run_change_passphrase( + repository, + config, + local_borg_version, + change_passphrase_arguments, + global_arguments, + local_path, + remote_path, +): + ''' + Run the "key change-passprhase" action for the given repository. + ''' + if ( + change_passphrase_arguments.repository is None + or borgmatic.config.validate.repositories_match( + repository, change_passphrase_arguments.repository + ) + ): + logger.info( + f'{repository.get("label", repository["path"])}: Changing repository passphrase' + ) + borgmatic.borg.change_passphrase.change_passphrase( + repository['path'], + config, + local_borg_version, + change_passphrase_arguments, + global_arguments, + local_path=local_path, + remote_path=remote_path, + ) diff --git a/borgmatic/borg/change_passphrase.py b/borgmatic/borg/change_passphrase.py new file mode 100644 index 00000000..4f9f69c3 --- /dev/null +++ b/borgmatic/borg/change_passphrase.py @@ -0,0 +1,57 @@ +import logging + +import borgmatic.execute +import borgmatic.logger +from borgmatic.borg import environment, flags + +logger = logging.getLogger(__name__) + + +def change_passphrase( + repository_path, + config, + local_borg_version, + change_passphrase_arguments, + global_arguments, + local_path='borg', + remote_path=None, +): + ''' + Given a local or remote repository path, a configuration dict, the local Borg version, change + passphrase arguments, and optional local and remote Borg paths, change the repository passphrase + based on an interactive prompt. + ''' + borgmatic.logger.add_custom_log_levels() + umask = config.get('umask', None) + lock_wait = config.get('lock_wait', None) + + full_command = ( + (local_path, 'key', 'change-passphrase') + + (('--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_repository_flags( + repository_path, + local_borg_version, + ) + ) + + if global_arguments.dry_run: + logger.info(f'{repository_path}: Skipping change password (dry run)') + return + + borgmatic.execute.execute_command( + full_command, + output_file=borgmatic.execute.DO_NOT_CAPTURE, + output_log_level=logging.ANSWER, + extra_environment=environment.make_environment(config), + borg_local_path=local_path, + borg_exit_codes=config.get('borg_exit_codes'), + ) + + logger.answer( + f"{repository_path}: Don't forget to update your encryption_passphrase option (if needed)" + ) diff --git a/borgmatic/borg/export_key.py b/borgmatic/borg/export_key.py index 96edadac..0a8c901f 100644 --- a/borgmatic/borg/export_key.py +++ b/borgmatic/borg/export_key.py @@ -18,9 +18,9 @@ def export_key( remote_path=None, ): ''' - Given a local or remote repository path, a configuration dict, the local Borg version, and - optional local and remote Borg paths, export the repository key to the destination path - indicated in the export arguments. + Given a local or remote repository path, a configuration dict, the local Borg version, export + arguments, and optional local and remote Borg paths, export the repository key to the + destination path indicated in the export arguments. If the destination path is empty or "-", then print the key to stdout instead of to a file. @@ -58,7 +58,7 @@ def export_key( ) if global_arguments.dry_run: - logging.info(f'{repository_path}: Skipping key export (dry run)') + logger.info(f'{repository_path}: Skipping key export (dry run)') return execute_command( diff --git a/borgmatic/commands/arguments.py b/borgmatic/commands/arguments.py index bf7d3c01..6349d2e1 100644 --- a/borgmatic/commands/arguments.py +++ b/borgmatic/commands/arguments.py @@ -1427,6 +1427,23 @@ def make_parsers(): '-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', + description='Change the passphrase protecting the repository key', + add_help=False, + ) + key_change_passphrase_group = key_change_passphrase_parser.add_argument_group( + 'key change-passphrase arguments' + ) + key_change_passphrase_group.add_argument( + '--repository', + help='Path of repository to change the passphrase for, defaults to the configured repository if there is only one, quoted globs supported', + ) + key_change_passphrase_group.add_argument( + '-h', '--help', action='help', help='Show this help message and exit' + ) + borg_parser = action_parsers.add_parser( 'borg', aliases=ACTION_ALIASES['borg'], diff --git a/borgmatic/commands/borgmatic.py b/borgmatic/commands/borgmatic.py index 6b99e2db..dd9d0339 100644 --- a/borgmatic/commands/borgmatic.py +++ b/borgmatic/commands/borgmatic.py @@ -12,6 +12,7 @@ import colorama import borgmatic.actions.borg import borgmatic.actions.break_lock +import borgmatic.actions.change_passphrase import borgmatic.actions.check import borgmatic.actions.compact import borgmatic.actions.config.bootstrap @@ -481,6 +482,16 @@ def run_actions( local_path, remote_path, ) + elif action_name == 'change-passphrase' and action_name not in skip_actions: + borgmatic.actions.change_passphrase.run_change_passphrase( + repository, + config, + local_borg_version, + action_arguments, + global_arguments, + local_path, + remote_path, + ) elif action_name == 'delete' and action_name not in skip_actions: borgmatic.actions.delete.run_delete( repository, diff --git a/docs/Dockerfile b/docs/Dockerfile index 5023cc47..c3100c77 100644 --- a/docs/Dockerfile +++ b/docs/Dockerfile @@ -4,7 +4,7 @@ COPY . /app RUN apk add --no-cache py3-pip py3-ruamel.yaml py3-ruamel.yaml.clib RUN pip install --break-system-packages --no-cache /app && generate-borgmatic-config && chmod +r /etc/borgmatic/config.yaml RUN borgmatic --help > /command-line.txt \ - && for action in rcreate transfer create prune compact check delete extract config "config bootstrap" "config generate" "config validate" export-tar mount umount rdelete restore rlist list rinfo info break-lock borg; do \ + && for action in rcreate transfer create prune compact check delete extract config "config bootstrap" "config generate" "config validate" export-tar mount umount rdelete restore rlist list rinfo info break-lock "key export" "key change-passphrase" borg; do \ echo -e "\n--------------------------------------------------------------------------------\n" >> /command-line.txt \ && borgmatic $action --help >> /command-line.txt; done RUN /app/docs/fetch-contributors >> /contributors.html diff --git a/setup.py b/setup.py index 4e4c13b3..11ec344e 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,6 @@ from setuptools import find_packages, setup -VERSION = '1.8.14' +VERSION = '1.8.15.dev0' setup( diff --git a/tests/integration/config/test_schema.py b/tests/integration/config/test_schema.py index 1b57e461..c9c33cf6 100644 --- a/tests/integration/config/test_schema.py +++ b/tests/integration/config/test_schema.py @@ -14,7 +14,7 @@ def test_schema_line_length_stays_under_limit(): assert len(line.rstrip('\n')) <= MAXIMUM_LINE_LENGTH -ACTIONS_MODULE_NAMES_TO_OMIT = {'arguments', 'export_key', 'json'} +ACTIONS_MODULE_NAMES_TO_OMIT = {'arguments', 'change_passphrase', 'export_key', 'json'} ACTIONS_MODULE_NAMES_TO_ADD = {'key', 'umount'} diff --git a/tests/unit/actions/test_change_passphrase.py b/tests/unit/actions/test_change_passphrase.py new file mode 100644 index 00000000..1ede4a71 --- /dev/null +++ b/tests/unit/actions/test_change_passphrase.py @@ -0,0 +1,20 @@ +from flexmock import flexmock + +from borgmatic.actions import change_passphrase as module + + +def test_run_change_passphrase_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.change_passphrase).should_receive('change_passphrase') + change_passphrase_arguments = flexmock(repository=flexmock()) + + module.run_change_passphrase( + repository={'path': 'repo'}, + config={}, + local_borg_version=None, + change_passphrase_arguments=change_passphrase_arguments, + global_arguments=flexmock(), + local_path=None, + remote_path=None, + ) diff --git a/tests/unit/borg/test_change_passphrase.py b/tests/unit/borg/test_change_passphrase.py new file mode 100644 index 00000000..10b37244 --- /dev/null +++ b/tests/unit/borg/test_change_passphrase.py @@ -0,0 +1,165 @@ +import logging + +from flexmock import flexmock + +import borgmatic.logger +from borgmatic.borg import change_passphrase as module + +from ..test_verbosity import insert_logging_mock + + +def insert_execute_command_mock( + command, output_file=module.borgmatic.execute.DO_NOT_CAPTURE, borg_exit_codes=None +): + borgmatic.logger.add_custom_log_levels() + + flexmock(module.environment).should_receive('make_environment') + flexmock(module.borgmatic.execute).should_receive('execute_command').with_args( + command, + output_file=output_file, + output_log_level=module.logging.ANSWER, + borg_local_path=command[0], + borg_exit_codes=borg_exit_codes, + extra_environment=None, + ).once() + + +def test_change_passphrase_calls_borg_with_required_flags(): + flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) + insert_execute_command_mock(('borg', 'key', 'change-passphrase', 'repo')) + + module.change_passphrase( + repository_path='repo', + config={}, + local_borg_version='1.2.3', + change_passphrase_arguments=flexmock(), + global_arguments=flexmock(dry_run=False, log_json=False), + ) + + +def test_change_passphrase_calls_borg_with_local_path(): + flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) + insert_execute_command_mock(('borg1', 'key', 'change-passphrase', 'repo')) + + module.change_passphrase( + repository_path='repo', + config={}, + local_borg_version='1.2.3', + change_passphrase_arguments=flexmock(), + global_arguments=flexmock(dry_run=False, log_json=False), + local_path='borg1', + ) + + +def test_change_passphrase_calls_borg_using_exit_codes(): + flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) + borg_exit_codes = flexmock() + insert_execute_command_mock( + ('borg', 'key', 'change-passphrase', 'repo'), borg_exit_codes=borg_exit_codes + ) + + module.change_passphrase( + repository_path='repo', + config={'borg_exit_codes': borg_exit_codes}, + local_borg_version='1.2.3', + change_passphrase_arguments=flexmock(), + global_arguments=flexmock(dry_run=False, log_json=False), + ) + + +def test_change_passphrase_calls_borg_with_remote_path_flags(): + flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) + insert_execute_command_mock( + ('borg', 'key', 'change-passphrase', '--remote-path', 'borg1', 'repo') + ) + + module.change_passphrase( + repository_path='repo', + config={}, + local_borg_version='1.2.3', + change_passphrase_arguments=flexmock(), + global_arguments=flexmock(dry_run=False, log_json=False), + remote_path='borg1', + ) + + +def test_change_passphrase_calls_borg_with_umask_flags(): + flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) + insert_execute_command_mock(('borg', 'key', 'change-passphrase', '--umask', '0770', 'repo')) + + module.change_passphrase( + repository_path='repo', + config={'umask': '0770'}, + local_borg_version='1.2.3', + change_passphrase_arguments=flexmock(), + global_arguments=flexmock(dry_run=False, log_json=False), + ) + + +def test_change_passphrase_calls_borg_with_log_json_flags(): + flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) + insert_execute_command_mock(('borg', 'key', 'change-passphrase', '--log-json', 'repo')) + + module.change_passphrase( + repository_path='repo', + config={}, + local_borg_version='1.2.3', + change_passphrase_arguments=flexmock(), + global_arguments=flexmock(dry_run=False, log_json=True), + ) + + +def test_change_passphrase_calls_borg_with_lock_wait_flags(): + flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) + insert_execute_command_mock(('borg', 'key', 'change-passphrase', '--lock-wait', '5', 'repo')) + + module.change_passphrase( + repository_path='repo', + config={'lock_wait': '5'}, + local_borg_version='1.2.3', + change_passphrase_arguments=flexmock(), + global_arguments=flexmock(dry_run=False, log_json=False), + ) + + +def test_change_passphrase_with_log_info_calls_borg_with_info_parameter(): + flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) + insert_execute_command_mock(('borg', 'key', 'change-passphrase', '--info', 'repo')) + insert_logging_mock(logging.INFO) + + module.change_passphrase( + repository_path='repo', + config={}, + local_borg_version='1.2.3', + change_passphrase_arguments=flexmock(), + global_arguments=flexmock(dry_run=False, log_json=False), + ) + + +def test_change_passphrase_with_log_debug_calls_borg_with_debug_flags(): + flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) + insert_execute_command_mock( + ('borg', 'key', 'change-passphrase', '--debug', '--show-rc', 'repo') + ) + insert_logging_mock(logging.DEBUG) + + module.change_passphrase( + repository_path='repo', + config={}, + local_borg_version='1.2.3', + change_passphrase_arguments=flexmock(), + global_arguments=flexmock(dry_run=False, log_json=False), + ) + + +def test_change_passphrase_with_dry_run_skips_borg_call(): + flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) + flexmock(module.borgmatic.execute).should_receive('execute_command').never() + + module.change_passphrase( + repository_path='repo', + config={}, + local_borg_version='1.2.3', + change_passphrase_arguments=flexmock(paper=False, qr_html=False, path=None), + global_arguments=flexmock(dry_run=True, log_json=False), + ) diff --git a/tests/unit/borg/test_export_key.py b/tests/unit/borg/test_export_key.py index 157b1b3d..fbfb1ca3 100644 --- a/tests/unit/borg/test_export_key.py +++ b/tests/unit/borg/test_export_key.py @@ -239,7 +239,7 @@ def test_export_key_with_stdout_path_calls_borg_without_path_argument(): ) -def test_export_key_with_dry_run_skip_borg_call(): +def test_export_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()