Add "key export" action to export a copy of the repository key (#345).

This commit is contained in:
Dan Helfman 2023-08-07 12:28:39 -07:00
parent fd8c56c6be
commit 6dca7c1c15
9 changed files with 426 additions and 3 deletions

6
NEWS
View File

@ -1,9 +1,11 @@
1.8.2.dev0
* #345: Add "key export" action to export a copy of the repository key for safekeeping in case
the original goes missing or gets damaged.
* #727: Add a MariaDB database hook that uses native MariaDB commands instead of the deprecated
MySQL ones. Be aware though that any existing backups made with the "mysql_databases:" hook are
only restorable with a "mysql_databases:" configuration.
* Add a source code reference for getting oriented with the borgmatic code as a developer:
https://torsion.org/borgmatic/docs/reference/source-code/
* Add source code reference documentation for getting oriented with the borgmatic code as a
developer: https://torsion.org/borgmatic/docs/reference/source-code/
1.8.1
* #326: Add documentation for restoring a database to an alternate host:

View File

@ -0,0 +1,33 @@
import logging
import borgmatic.borg.export_key
import borgmatic.config.validate
logger = logging.getLogger(__name__)
def run_export_key(
repository,
config,
local_borg_version,
export_arguments,
global_arguments,
local_path,
remote_path,
):
'''
Run the "key export" action for the given repository.
'''
if export_arguments.repository is None or borgmatic.config.validate.repositories_match(
repository, export_arguments.repository
):
logger.info(f'{repository.get("label", repository["path"])}: Exporting repository key')
borgmatic.borg.export_key.export_key(
repository['path'],
config,
local_borg_version,
export_arguments,
global_arguments,
local_path=local_path,
remote_path=remote_path,
)

View File

@ -0,0 +1,70 @@
import logging
import os
import borgmatic.logger
from borgmatic.borg import environment, flags
from borgmatic.execute import DO_NOT_CAPTURE, execute_command
logger = logging.getLogger(__name__)
def export_key(
repository_path,
config,
local_borg_version,
export_arguments,
global_arguments,
local_path='borg',
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.
If the destination path is empty or "-", then print the key to stdout instead of to a file.
Raise FileExistsError if a path is given but it already exists on disk.
'''
borgmatic.logger.add_custom_log_levels()
umask = config.get('umask', None)
lock_wait = config.get('lock_wait', None)
if export_arguments.path and export_arguments.path != '-':
if os.path.exists(export_arguments.path):
raise FileExistsError(
f'Destination path {export_arguments.path} already exists. Aborting.'
)
output_file = None
else:
output_file = DO_NOT_CAPTURE
full_command = (
(local_path, 'key', 'export')
+ (('--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', export_arguments.paper)
+ flags.make_flags('qr-html', export_arguments.qr_html)
+ flags.make_repository_flags(
repository_path,
local_borg_version,
)
+ ((export_arguments.path,) if output_file is None else ())
)
if global_arguments.dry_run:
logging.info(f'{repository_path}: Skipping key export (dry run)')
return
execute_command(
full_command,
output_file=output_file,
output_log_level=logging.ANSWER,
borg_local_path=local_path,
extra_environment=environment.make_environment(config),
)

View File

@ -23,6 +23,7 @@ ACTION_ALIASES = {
'info': ['-i'],
'transfer': [],
'break-lock': [],
'key': [],
'borg': [],
}
@ -1176,6 +1177,51 @@ def make_parsers():
'-h', '--help', action='help', help='Show this help message and exit'
)
key_parser = action_parsers.add_parser(
'key',
aliases=ACTION_ALIASES['key'],
help='Perform repository key related operations',
description='Perform repository key related operations',
add_help=False,
)
key_group = key_parser.add_argument_group('key arguments')
key_group.add_argument('-h', '--help', action='help', help='Show this help message and exit')
key_parsers = key_parser.add_subparsers(
title='key sub-actions',
)
key_export_parser = key_parsers.add_parser(
'export',
help='Export a copy of the repository key for safekeeping in case the original goes missing or gets damaged',
description='Export a copy of the repository key for safekeeping in case the original goes missing or gets damaged',
add_help=False,
)
key_export_group = key_export_parser.add_argument_group('key export arguments')
key_export_group.add_argument(
'--paper',
action='store_true',
help='Export the key in a text format suitable for printing and later manual entry',
)
key_export_group.add_argument(
'--qr-html',
action='store_true',
help='Export the key in an HTML format suitable for printing and later manual entry or QR code scanning',
)
key_export_group.add_argument(
'--repository',
help='Path of repository to export the key for, defaults to the configured repository if there is only one',
)
key_export_group.add_argument(
'--path',
metavar='PATH',
help='Path to export the key to, defaults to stdout (but be careful about dirtying the output with --verbosity)',
)
key_export_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

@ -22,6 +22,7 @@ import borgmatic.actions.config.bootstrap
import borgmatic.actions.config.generate
import borgmatic.actions.config.validate
import borgmatic.actions.create
import borgmatic.actions.export_key
import borgmatic.actions.export_tar
import borgmatic.actions.extract
import borgmatic.actions.info
@ -448,6 +449,16 @@ def run_actions(
local_path,
remote_path,
)
elif action_name == 'export':
borgmatic.actions.export_key.run_export_key(
repository,
config,
local_borg_version,
action_arguments,
global_arguments,
local_path,
remote_path,
)
elif action_name == 'borg':
borgmatic.actions.borg.run_borg(
repository,

View File

@ -310,7 +310,8 @@ problem: the `restore` action figures out which repository to use.
But if you have multiple repositories configured, then you'll need to specify
the repository to use via the `--repository` flag. This can be done either
with the repository's path or its label as configured in your borgmatic configuration file.
with the repository's path or its label as configured in your borgmatic
configuration file.
```bash
borgmatic restore --repository repo.borg --archive host-2023-...

View File

@ -0,0 +1,20 @@
from flexmock import flexmock
from borgmatic.actions import export_key as module
def test_run_export_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.export_key).should_receive('export_key')
export_arguments = flexmock(repository=flexmock())
module.run_export_key(
repository={'path': 'repo'},
config={},
local_borg_version=None,
export_arguments=export_arguments,
global_arguments=flexmock(),
local_path=None,
remote_path=None,
)

View File

@ -0,0 +1,222 @@
import logging
import pytest
from flexmock import flexmock
import borgmatic.logger
from borgmatic.borg import export_key as module
from ..test_verbosity import insert_logging_mock
def insert_execute_command_mock(command, output_file=module.DO_NOT_CAPTURE):
borgmatic.logger.add_custom_log_levels()
flexmock(module.environment).should_receive('make_environment')
flexmock(module).should_receive('execute_command').with_args(
command,
output_file=output_file,
output_log_level=module.logging.ANSWER,
borg_local_path='borg',
extra_environment=None,
).once()
def test_export_key_calls_borg_with_required_flags():
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', 'export', 'repo'))
module.export_key(
repository_path='repo',
config={},
local_borg_version='1.2.3',
export_arguments=flexmock(paper=False, qr_html=False, path=None),
global_arguments=flexmock(dry_run=False, log_json=False),
)
def test_export_key_calls_borg_with_remote_path_flags():
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', 'export', '--remote-path', 'borg1', 'repo'))
module.export_key(
repository_path='repo',
config={},
local_borg_version='1.2.3',
export_arguments=flexmock(paper=False, qr_html=False, path=None),
global_arguments=flexmock(dry_run=False, log_json=False),
remote_path='borg1',
)
def test_export_key_calls_borg_with_umask_flags():
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', 'export', '--umask', '0770', 'repo'))
module.export_key(
repository_path='repo',
config={'umask': '0770'},
local_borg_version='1.2.3',
export_arguments=flexmock(paper=False, qr_html=False, path=None),
global_arguments=flexmock(dry_run=False, log_json=False),
)
def test_export_key_calls_borg_with_log_json_flags():
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', 'export', '--log-json', 'repo'))
module.export_key(
repository_path='repo',
config={},
local_borg_version='1.2.3',
export_arguments=flexmock(paper=False, qr_html=False, path=None),
global_arguments=flexmock(dry_run=False, log_json=True),
)
def test_export_key_calls_borg_with_lock_wait_flags():
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', 'export', '--lock-wait', '5', 'repo'))
module.export_key(
repository_path='repo',
config={'lock_wait': '5'},
local_borg_version='1.2.3',
export_arguments=flexmock(paper=False, qr_html=False, path=None),
global_arguments=flexmock(dry_run=False, log_json=False),
)
def test_export_key_with_log_info_calls_borg_with_info_parameter():
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', 'export', '--info', 'repo'))
insert_logging_mock(logging.INFO)
module.export_key(
repository_path='repo',
config={},
local_borg_version='1.2.3',
export_arguments=flexmock(paper=False, qr_html=False, path=None),
global_arguments=flexmock(dry_run=False, log_json=False),
)
def test_export_key_with_log_debug_calls_borg_with_debug_flags():
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', 'export', '--debug', '--show-rc', 'repo'))
insert_logging_mock(logging.DEBUG)
module.export_key(
repository_path='repo',
config={},
local_borg_version='1.2.3',
export_arguments=flexmock(paper=False, qr_html=False, path=None),
global_arguments=flexmock(dry_run=False, log_json=False),
)
def test_export_key_calls_borg_with_paper_flags():
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', 'export', '--paper', 'repo'))
module.export_key(
repository_path='repo',
config={},
local_borg_version='1.2.3',
export_arguments=flexmock(paper=True, qr_html=False, path=None),
global_arguments=flexmock(dry_run=False, log_json=False),
)
def test_export_key_calls_borg_with_paper_flag():
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', 'export', '--paper', 'repo'))
module.export_key(
repository_path='repo',
config={},
local_borg_version='1.2.3',
export_arguments=flexmock(paper=True, qr_html=False, path=None),
global_arguments=flexmock(dry_run=False, log_json=False),
)
def test_export_key_calls_borg_with_qr_html_flag():
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', 'export', '--qr-html', 'repo'))
module.export_key(
repository_path='repo',
config={},
local_borg_version='1.2.3',
export_arguments=flexmock(paper=False, qr_html=True, path=None),
global_arguments=flexmock(dry_run=False, log_json=False),
)
def test_export_key_calls_borg_with_path_argument():
flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',))
flexmock(module.os.path).should_receive('exists').and_return(False)
insert_execute_command_mock(('borg', 'key', 'export', 'repo', 'dest'), output_file=None)
module.export_key(
repository_path='repo',
config={},
local_borg_version='1.2.3',
export_arguments=flexmock(paper=False, qr_html=False, path='dest'),
global_arguments=flexmock(dry_run=False, log_json=False),
)
def test_export_key_with_already_existent_path_raises():
flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',))
flexmock(module.os.path).should_receive('exists').and_return(True)
flexmock(module).should_receive('execute_command').never()
with pytest.raises(FileExistsError):
module.export_key(
repository_path='repo',
config={},
local_borg_version='1.2.3',
export_arguments=flexmock(paper=False, qr_html=False, path='dest'),
global_arguments=flexmock(dry_run=False, log_json=False),
)
def test_export_key_with_stdout_path_calls_borg_without_path_argument():
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', 'export', 'repo'))
module.export_key(
repository_path='repo',
config={},
local_borg_version='1.2.3',
export_arguments=flexmock(paper=False, qr_html=False, path='-'),
global_arguments=flexmock(dry_run=False, log_json=False),
)
def test_export_key_with_dry_run_skip_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()
module.export_key(
repository_path='repo',
config={},
local_borg_version='1.2.3',
export_arguments=flexmock(paper=False, qr_html=False, path=None),
global_arguments=flexmock(dry_run=True, log_json=False),
)

View File

@ -748,6 +748,24 @@ def test_run_actions_runs_break_lock():
)
def test_run_actions_runs_export_key():
flexmock(module).should_receive('add_custom_log_levels')
flexmock(module.command).should_receive('execute_hook')
flexmock(borgmatic.actions.export_key).should_receive('run_export_key').once()
tuple(
module.run_actions(
arguments={'global': flexmock(dry_run=False, log_file='foo'), 'export': flexmock()},
config_filename=flexmock(),
config={'repositories': []},
local_path=flexmock(),
remote_path=flexmock(),
local_borg_version=flexmock(),
repository={'path': 'repo'},
)
)
def test_run_actions_runs_borg():
flexmock(module).should_receive('add_custom_log_levels')
flexmock(module.command).should_receive('execute_hook')