Add a "key change-passphrase" action to change the passphrase protecting a repository key (#911).
All checks were successful
build / test (push) Successful in 6m22s
build / docs (push) Successful in 1m53s

This commit is contained in:
Dan Helfman 2024-09-01 11:13:39 -07:00
parent 1197d6d0f6
commit 1fe6ae83a8
12 changed files with 319 additions and 8 deletions

3
NEWS
View File

@ -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

View File

@ -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,
)

View File

@ -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)"
)

View File

@ -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(

View File

@ -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'],

View File

@ -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,

View File

@ -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

View File

@ -1,6 +1,6 @@
from setuptools import find_packages, setup
VERSION = '1.8.14'
VERSION = '1.8.15.dev0'
setup(

View File

@ -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'}

View File

@ -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,
)

View File

@ -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),
)

View File

@ -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()