Procházet zdrojové kódy

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

Dan Helfman před 1 rokem
rodič
revize
6dca7c1c15

+ 4 - 2
NEWS

@@ -1,9 +1,11 @@
 1.8.2.dev0
 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
  * #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
    MySQL ones. Be aware though that any existing backups made with the "mysql_databases:" hook are
    only restorable with a "mysql_databases:" configuration.
    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
 1.8.1
  * #326: Add documentation for restoring a database to an alternate host:
  * #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'],
     'info': ['-i'],
     'transfer': [],
     'transfer': [],
     'break-lock': [],
     'break-lock': [],
+    'key': [],
     'borg': [],
     'borg': [],
 }
 }
 
 
@@ -1176,6 +1177,51 @@ def make_parsers():
         '-h', '--help', action='help', help='Show this help message and exit'
         '-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_parser = action_parsers.add_parser(
         'borg',
         'borg',
         aliases=ACTION_ALIASES['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.generate
 import borgmatic.actions.config.validate
 import borgmatic.actions.config.validate
 import borgmatic.actions.create
 import borgmatic.actions.create
+import borgmatic.actions.export_key
 import borgmatic.actions.export_tar
 import borgmatic.actions.export_tar
 import borgmatic.actions.extract
 import borgmatic.actions.extract
 import borgmatic.actions.info
 import borgmatic.actions.info
@@ -448,6 +449,16 @@ def run_actions(
                 local_path,
                 local_path,
                 remote_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':
         elif action_name == 'borg':
             borgmatic.actions.borg.run_borg(
             borgmatic.actions.borg.run_borg(
                 repository,
                 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
 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
 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
 ```bash
 borgmatic restore --repository repo.borg --archive host-2023-...
 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():
 def test_run_actions_runs_borg():
     flexmock(module).should_receive('add_custom_log_levels')
     flexmock(module).should_receive('add_custom_log_levels')
     flexmock(module.command).should_receive('execute_hook')
     flexmock(module.command).should_receive('execute_hook')