Browse Source

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

Dan Helfman 1 year ago
parent
commit
6dca7c1c15

+ 4 - 2
NEWS

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

+ 33 - 0
borgmatic/actions/export_key.py

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

+ 70 - 0
borgmatic/borg/export_key.py

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

+ 46 - 0
borgmatic/commands/arguments.py

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

+ 11 - 0
borgmatic/commands/borgmatic.py

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

+ 2 - 1
docs/how-to/backup-your-databases.md

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

+ 20 - 0
tests/unit/actions/test_export_key.py

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

+ 222 - 0
tests/unit/borg/test_export_key.py

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

+ 18 - 0
tests/unit/commands/test_borgmatic.py

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