Browse Source

Read and write data source dump metadata files within an archive (#1136).

Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/1136
Dan Helfman 1 week ago
parent
commit
000d633590

+ 1 - 0
NEWS

@@ -11,6 +11,7 @@
    with orphaned files that need recovery.
    with orphaned files that need recovery.
  * #1133: Fix the "spot" check to include borgmatic configuration files that were backed up to
  * #1133: Fix the "spot" check to include borgmatic configuration files that were backed up to
    support the "bootstrap" action.
    support the "bootstrap" action.
+ * #1136: For all database hooks, record metadata about the dumps contained within an archive.
  * #1139: Set "borgmatic" as the user agent when connecting to monitoring services.
  * #1139: Set "borgmatic" as the user agent when connecting to monitoring services.
  * When running tests, use Ruff for faster and more comprehensive code linting and formatting,
  * When running tests, use Ruff for faster and more comprehensive code linting and formatting,
    replacing Flake8, Black, isort, etc.
    replacing Flake8, Black, isort, etc.

+ 60 - 3
borgmatic/actions/restore.py

@@ -252,6 +252,66 @@ def collect_dumps_from_archive(
     borgmatic runtime directory, query the archive for the names of data sources dumps it contains
     borgmatic runtime directory, query the archive for the names of data sources dumps it contains
     and return them as a set of Dump instances.
     and return them as a set of Dump instances.
     '''
     '''
+    dumps_from_archive = set()
+
+    # There is (at most) one dump metadata file per data source hook. Load each.
+    for dumps_metadata_path in borgmatic.borg.list.capture_archive_listing(
+        repository,
+        archive,
+        config,
+        local_borg_version,
+        global_arguments,
+        list_paths=[
+            'sh:'
+            + borgmatic.hooks.data_source.dump.make_data_source_dump_path(
+                base_directory,
+                '*_databases/dumps.json',
+            )
+            # Probe for dump metadata files in multiple locations, as the default location is
+            # "/borgmatic/*_databases/dumps.json" with Borg 1.4+, but instead begins with the
+            # borgmatic runtime directory for older versions of Borg.
+            for base_directory in (
+                'borgmatic',
+                borgmatic.config.paths.make_runtime_directory_glob(borgmatic_runtime_directory),
+            )
+        ],
+        local_path=local_path,
+        remote_path=remote_path,
+    ):
+        if not dumps_metadata_path:
+            continue
+
+        dumps_from_archive.update(
+            set(
+                borgmatic.hooks.data_source.dump.parse_data_source_dumps_metadata(
+                    borgmatic.borg.extract.extract_archive(
+                        global_arguments.dry_run,
+                        repository,
+                        archive,
+                        [dumps_metadata_path],
+                        config,
+                        local_borg_version,
+                        global_arguments,
+                        local_path=local_path,
+                        remote_path=remote_path,
+                        extract_to_stdout=True,
+                    )
+                    .stdout.read()
+                    .decode(),
+                    dumps_metadata_path,
+                )
+            )
+        )
+
+    # If we've successfully loaded any dumps metadata, we're done.
+    if dumps_from_archive:
+        logger.debug('Collecting database dumps from archive data source dumps metadata files')
+
+        return dumps_from_archive
+
+    # No dumps metadata files were found, so for backwards compatibility, fall back to parsing the
+    # paths of dumps found in the archive to get their respective dump metadata.
+    logger.debug('Collecting database dumps from archive data source dump paths (fallback)')
     borgmatic_source_directory = str(
     borgmatic_source_directory = str(
         pathlib.Path(borgmatic.config.paths.get_borgmatic_source_directory(config)),
         pathlib.Path(borgmatic.config.paths.get_borgmatic_source_directory(config)),
     )
     )
@@ -281,9 +341,6 @@ def collect_dumps_from_archive(
         remote_path=remote_path,
         remote_path=remote_path,
     )
     )
 
 
-    # Parse the paths of dumps found in the archive to get their respective dump metadata.
-    dumps_from_archive = set()
-
     for dump_path in dump_paths:
     for dump_path in dump_paths:
         if not dump_path:
         if not dump_path:
             continue
             continue

+ 45 - 0
borgmatic/hooks/data_source/dump.py

@@ -1,8 +1,11 @@
 import fnmatch
 import fnmatch
+import json
 import logging
 import logging
 import os
 import os
 import shutil
 import shutil
 
 
+import borgmatic.actions.restore
+
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
 IS_A_HOOK = False
 IS_A_HOOK = False
@@ -33,6 +36,48 @@ def make_data_source_dump_filename(dump_path, name, hostname=None, port=None):
     )
     )
 
 
 
 
+def write_data_source_dumps_metadata(borgmatic_runtime_directory, hook_name, dumps_metadata):
+    '''
+    Given the borgmatic runtime directory, a data source hook name, and a sequence of
+    borgmatic.actions.restore.Dump instances of dump metadata, write a metadata file describing all
+    of those dumps. This metadata is being dumped so that it's available upon restore, e.g. to
+    support the user selecting which data source(s) should be restored.
+
+    Raise ValueError if writing to the file results in an operating system error.
+    '''
+    dumps_metadata_path = os.path.join(borgmatic_runtime_directory, hook_name, 'dumps.json')
+
+    try:
+        with open(dumps_metadata_path, 'w', encoding='utf-8') as metadata_file:
+            json.dump(
+                {
+                    'dumps': [dump._asdict() for dump in dumps_metadata],
+                },
+                metadata_file,
+                sort_keys=True,
+            )
+    except OSError as error:
+        raise ValueError(f'Error writing to dumps metadata at {dumps_metadata_path}: {error}')
+
+
+def parse_data_source_dumps_metadata(dumps_json, dumps_metadata_path):
+    '''
+    Given a dumps metadata JSON string as extracted from an archive and its path within the archive,
+    parse it into a tuple of borgmatic.actions.restore.Dump instances and return them.
+
+    Raise ValueError if parsing the JSON results in a JSON decode error or the data does not have
+    the expected keys.
+    '''
+    try:
+        return tuple(
+            borgmatic.actions.restore.Dump(**dump) for dump in json.loads(dumps_json)['dumps']
+        )
+    except (json.JSONDecodeError, TypeError, KeyError) as error:
+        raise ValueError(
+            f'Cannot read archive data source dumps metadata at {dumps_metadata_path} due to invalid JSON: {error}',
+        )
+
+
 def create_parent_directory_for_dump(dump_path):
 def create_parent_directory_for_dump(dump_path):
     '''
     '''
     Create a directory to contain the given dump path.
     Create a directory to contain the given dump path.

+ 24 - 4
borgmatic/hooks/data_source/mariadb.py

@@ -257,11 +257,12 @@ def dump_data_sources(
     '''
     '''
     dry_run_label = ' (dry run; not actually dumping anything)' if dry_run else ''
     dry_run_label = ' (dry run; not actually dumping anything)' if dry_run else ''
     processes = []
     processes = []
+    dumps_metadata = []
 
 
     logger.info(f'Dumping MariaDB databases{dry_run_label}')
     logger.info(f'Dumping MariaDB databases{dry_run_label}')
+    dump_path = make_dump_path(borgmatic_runtime_directory)
 
 
     for database in databases:
     for database in databases:
-        dump_path = make_dump_path(borgmatic_runtime_directory)
         username = borgmatic.hooks.credential.parse.resolve_credential(
         username = borgmatic.hooks.credential.parse.resolve_credential(
             database.get('username'),
             database.get('username'),
             config,
             config,
@@ -294,9 +295,17 @@ def dump_data_sources(
             raise ValueError('Cannot find any MariaDB databases to dump.')
             raise ValueError('Cannot find any MariaDB databases to dump.')
 
 
         if database['name'] == 'all' and database.get('format'):
         if database['name'] == 'all' and database.get('format'):
-            for dump_name in dump_database_names:
+            for database_name in dump_database_names:
+                dumps_metadata.append(
+                    borgmatic.actions.restore.Dump(
+                        'mariadb_databases',
+                        database_name,
+                        database.get('hostname', 'localhost'),
+                        database.get('port'),
+                    )
+                )
                 renamed_database = copy.copy(database)
                 renamed_database = copy.copy(database)
-                renamed_database['name'] = dump_name
+                renamed_database['name'] = database_name
                 processes.append(
                 processes.append(
                     execute_dump_command(
                     execute_dump_command(
                         renamed_database,
                         renamed_database,
@@ -304,13 +313,21 @@ def dump_data_sources(
                         username,
                         username,
                         password,
                         password,
                         dump_path,
                         dump_path,
-                        (dump_name,),
+                        (database_name,),
                         environment,
                         environment,
                         dry_run,
                         dry_run,
                         dry_run_label,
                         dry_run_label,
                     ),
                     ),
                 )
                 )
         else:
         else:
+            dumps_metadata.append(
+                borgmatic.actions.restore.Dump(
+                    'mariadb_databases',
+                    database['name'],
+                    database.get('hostname', 'localhost'),
+                    database.get('port'),
+                )
+            )
             processes.append(
             processes.append(
                 execute_dump_command(
                 execute_dump_command(
                     database,
                     database,
@@ -326,6 +343,9 @@ def dump_data_sources(
             )
             )
 
 
     if not dry_run:
     if not dry_run:
+        dump.write_data_source_dumps_metadata(
+            borgmatic_runtime_directory, 'mariadb_databases', dumps_metadata
+        )
         patterns.append(
         patterns.append(
             borgmatic.borg.pattern.Pattern(
             borgmatic.borg.pattern.Pattern(
                 os.path.join(borgmatic_runtime_directory, 'mariadb_databases'),
                 os.path.join(borgmatic_runtime_directory, 'mariadb_databases'),

+ 13 - 0
borgmatic/hooks/data_source/mongodb.py

@@ -53,9 +53,19 @@ def dump_data_sources(
     logger.info(f'Dumping MongoDB databases{dry_run_label}')
     logger.info(f'Dumping MongoDB databases{dry_run_label}')
 
 
     processes = []
     processes = []
+    dumps_metadata = []
 
 
     for database in databases:
     for database in databases:
         name = database['name']
         name = database['name']
+        dumps_metadata.append(
+            borgmatic.actions.restore.Dump(
+                'mongodb_databases',
+                name,
+                database.get('hostname', 'localhost'),
+                database.get('port'),
+            )
+        )
+
         dump_filename = dump.make_data_source_dump_filename(
         dump_filename = dump.make_data_source_dump_filename(
             make_dump_path(borgmatic_runtime_directory),
             make_dump_path(borgmatic_runtime_directory),
             name,
             name,
@@ -82,6 +92,9 @@ def dump_data_sources(
             )
             )
 
 
     if not dry_run:
     if not dry_run:
+        dump.write_data_source_dumps_metadata(
+            borgmatic_runtime_directory, 'mongodb_databases', dumps_metadata
+        )
         patterns.append(
         patterns.append(
             borgmatic.borg.pattern.Pattern(
             borgmatic.borg.pattern.Pattern(
                 os.path.join(borgmatic_runtime_directory, 'mongodb_databases'),
                 os.path.join(borgmatic_runtime_directory, 'mongodb_databases'),

+ 24 - 4
borgmatic/hooks/data_source/mysql.py

@@ -188,11 +188,12 @@ def dump_data_sources(
     '''
     '''
     dry_run_label = ' (dry run; not actually dumping anything)' if dry_run else ''
     dry_run_label = ' (dry run; not actually dumping anything)' if dry_run else ''
     processes = []
     processes = []
+    dumps_metadata = []
 
 
     logger.info(f'Dumping MySQL databases{dry_run_label}')
     logger.info(f'Dumping MySQL databases{dry_run_label}')
+    dump_path = make_dump_path(borgmatic_runtime_directory)
 
 
     for database in databases:
     for database in databases:
-        dump_path = make_dump_path(borgmatic_runtime_directory)
         username = borgmatic.hooks.credential.parse.resolve_credential(
         username = borgmatic.hooks.credential.parse.resolve_credential(
             database.get('username'),
             database.get('username'),
             config,
             config,
@@ -225,9 +226,17 @@ def dump_data_sources(
             raise ValueError('Cannot find any MySQL databases to dump.')
             raise ValueError('Cannot find any MySQL databases to dump.')
 
 
         if database['name'] == 'all' and database.get('format'):
         if database['name'] == 'all' and database.get('format'):
-            for dump_name in dump_database_names:
+            for database_name in dump_database_names:
+                dumps_metadata.append(
+                    borgmatic.actions.restore.Dump(
+                        'mysql_databases',
+                        database_name,
+                        database.get('hostname', 'localhost'),
+                        database.get('port'),
+                    )
+                )
                 renamed_database = copy.copy(database)
                 renamed_database = copy.copy(database)
-                renamed_database['name'] = dump_name
+                renamed_database['name'] = database_name
                 processes.append(
                 processes.append(
                     execute_dump_command(
                     execute_dump_command(
                         renamed_database,
                         renamed_database,
@@ -235,13 +244,21 @@ def dump_data_sources(
                         username,
                         username,
                         password,
                         password,
                         dump_path,
                         dump_path,
-                        (dump_name,),
+                        (database_name,),
                         environment,
                         environment,
                         dry_run,
                         dry_run,
                         dry_run_label,
                         dry_run_label,
                     ),
                     ),
                 )
                 )
         else:
         else:
+            dumps_metadata.append(
+                borgmatic.actions.restore.Dump(
+                    'mysql_databases',
+                    database['name'],
+                    database.get('hostname', 'localhost'),
+                    database.get('port'),
+                )
+            )
             processes.append(
             processes.append(
                 execute_dump_command(
                 execute_dump_command(
                     database,
                     database,
@@ -257,6 +274,9 @@ def dump_data_sources(
             )
             )
 
 
     if not dry_run:
     if not dry_run:
+        dump.write_data_source_dumps_metadata(
+            borgmatic_runtime_directory, 'mysql_databases', dumps_metadata
+        )
         patterns.append(
         patterns.append(
             borgmatic.borg.pattern.Pattern(
             borgmatic.borg.pattern.Pattern(
                 os.path.join(borgmatic_runtime_directory, 'mysql_databases'),
                 os.path.join(borgmatic_runtime_directory, 'mysql_databases'),

+ 14 - 1
borgmatic/hooks/data_source/postgresql.py

@@ -150,12 +150,13 @@ def dump_data_sources(
     '''
     '''
     dry_run_label = ' (dry run; not actually dumping anything)' if dry_run else ''
     dry_run_label = ' (dry run; not actually dumping anything)' if dry_run else ''
     processes = []
     processes = []
+    dumps_metadata = []
 
 
     logger.info(f'Dumping PostgreSQL databases{dry_run_label}')
     logger.info(f'Dumping PostgreSQL databases{dry_run_label}')
+    dump_path = make_dump_path(borgmatic_runtime_directory)
 
 
     for database in databases:
     for database in databases:
         environment = make_environment(database, config)
         environment = make_environment(database, config)
-        dump_path = make_dump_path(borgmatic_runtime_directory)
         dump_database_names = database_names_to_dump(database, config, environment, dry_run)
         dump_database_names = database_names_to_dump(database, config, environment, dry_run)
 
 
         if not dump_database_names:
         if not dump_database_names:
@@ -165,6 +166,14 @@ def dump_data_sources(
             raise ValueError('Cannot find any PostgreSQL databases to dump.')
             raise ValueError('Cannot find any PostgreSQL databases to dump.')
 
 
         for database_name in dump_database_names:
         for database_name in dump_database_names:
+            dumps_metadata.append(
+                borgmatic.actions.restore.Dump(
+                    'postgresql_databases',
+                    database_name,
+                    database.get('hostname', 'localhost'),
+                    database.get('port'),
+                )
+            )
             dump_format = database.get('format', None if database_name == 'all' else 'custom')
             dump_format = database.get('format', None if database_name == 'all' else 'custom')
             compression = database.get('compression')
             compression = database.get('compression')
             default_dump_command = 'pg_dumpall' if database_name == 'all' else 'pg_dump'
             default_dump_command = 'pg_dumpall' if database_name == 'all' else 'pg_dump'
@@ -178,6 +187,7 @@ def dump_data_sources(
                 database.get('hostname'),
                 database.get('hostname'),
                 database.get('port'),
                 database.get('port'),
             )
             )
+
             if os.path.exists(dump_filename):
             if os.path.exists(dump_filename):
                 logger.warning(
                 logger.warning(
                     f'Skipping duplicate dump of PostgreSQL database "{database_name}" to {dump_filename}',
                     f'Skipping duplicate dump of PostgreSQL database "{database_name}" to {dump_filename}',
@@ -247,6 +257,9 @@ def dump_data_sources(
                 )
                 )
 
 
     if not dry_run:
     if not dry_run:
+        dump.write_data_source_dumps_metadata(
+            borgmatic_runtime_directory, 'postgresql_databases', dumps_metadata
+        )
         patterns.append(
         patterns.append(
             borgmatic.borg.pattern.Pattern(
             borgmatic.borg.pattern.Pattern(
                 os.path.join(borgmatic_runtime_directory, 'postgresql_databases'),
                 os.path.join(borgmatic_runtime_directory, 'postgresql_databases'),

+ 10 - 0
borgmatic/hooks/data_source/sqlite.py

@@ -49,11 +49,18 @@ def dump_data_sources(
     '''
     '''
     dry_run_label = ' (dry run; not actually dumping anything)' if dry_run else ''
     dry_run_label = ' (dry run; not actually dumping anything)' if dry_run else ''
     processes = []
     processes = []
+    dumps_metadata = []
 
 
     logger.info(f'Dumping SQLite databases{dry_run_label}')
     logger.info(f'Dumping SQLite databases{dry_run_label}')
 
 
     for database in databases:
     for database in databases:
         database_path = database['path']
         database_path = database['path']
+        dumps_metadata.append(
+            borgmatic.actions.restore.Dump(
+                'sqlite_databases',
+                database['name'],
+            )
+        )
 
 
         if database['name'] == 'all':
         if database['name'] == 'all':
             logger.warning('The "all" database name has no meaning for SQLite databases')
             logger.warning('The "all" database name has no meaning for SQLite databases')
@@ -95,6 +102,9 @@ def dump_data_sources(
         )
         )
 
 
     if not dry_run:
     if not dry_run:
+        dump.write_data_source_dumps_metadata(
+            borgmatic_runtime_directory, 'sqlite_databases', dumps_metadata
+        )
         patterns.append(
         patterns.append(
             borgmatic.borg.pattern.Pattern(
             borgmatic.borg.pattern.Pattern(
                 os.path.join(borgmatic_runtime_directory, 'sqlite_databases'),
                 os.path.join(borgmatic_runtime_directory, 'sqlite_databases'),

+ 132 - 14
tests/unit/actions/test_restore.py

@@ -462,27 +462,76 @@ def test_restore_single_dump_with_directory_dump_and_dry_run_skips_directory_mov
     )
     )
 
 
 
 
-def test_collect_dumps_from_archive_parses_archive_paths():
-    flexmock(module.borgmatic.config.paths).should_receive(
-        'get_borgmatic_source_directory',
-    ).and_return('/root/.borgmatic')
+def test_collect_dumps_from_archive_with_dumps_metadata_parses_it():
     flexmock(module.borgmatic.hooks.data_source.dump).should_receive(
     flexmock(module.borgmatic.hooks.data_source.dump).should_receive(
         'make_data_source_dump_path',
         'make_data_source_dump_path',
     ).and_return('')
     ).and_return('')
+    flexmock(module.borgmatic.config.paths).should_receive(
+        'make_runtime_directory_glob'
+    ).and_return('')
+    flexmock(module.borgmatic.borg.list).should_receive('capture_archive_listing').and_return(
+        [
+            'borgmatic/postgresql_databases/dumps.json',
+            'borgmatic/mysql_databases/dumps.json',
+        ],
+    )
+    flexmock(module.borgmatic.borg.extract).should_receive('extract_archive').and_return(
+        flexmock(stdout=flexmock(read=lambda: b''))
+    )
+    dumps_metadata = [
+        module.Dump('postgresql_databases', 'foo'),
+        module.Dump('postgresql_databases', 'bar', 'host', 1234),
+        module.Dump('mysql_databases', 'quux'),
+    ]
+    flexmock(module.borgmatic.hooks.data_source.dump).should_receive(
+        'parse_data_source_dumps_metadata'
+    ).and_return(dumps_metadata)
+    flexmock(module.borgmatic.config.paths).should_receive('get_borgmatic_source_directory').never()
+
+    archive_dumps = module.collect_dumps_from_archive(
+        repository={'path': 'repo'},
+        archive='archive',
+        config={},
+        local_borg_version=flexmock(),
+        global_arguments=flexmock(dry_run=False, log_json=False),
+        local_path=flexmock(),
+        remote_path=flexmock(),
+        borgmatic_runtime_directory='/run/borgmatic',
+    )
+
+    assert archive_dumps == set(dumps_metadata)
+
+
+def test_collect_dumps_from_archive_with_empty_dumps_metadata_path_falls_back_to_parsing_archive_paths():
+    flexmock(module.borgmatic.config.paths).should_receive(
+        'make_runtime_directory_glob'
+    ).and_return('')
     flexmock(module.borgmatic.borg.list).should_receive('capture_archive_listing').and_return(
     flexmock(module.borgmatic.borg.list).should_receive('capture_archive_listing').and_return(
+        ['']
+    ).and_return(
         [
         [
             'borgmatic/postgresql_databases/localhost/foo',
             'borgmatic/postgresql_databases/localhost/foo',
             'borgmatic/postgresql_databases/host:1234/bar',
             'borgmatic/postgresql_databases/host:1234/bar',
             'borgmatic/mysql_databases/localhost/quux',
             'borgmatic/mysql_databases/localhost/quux',
         ],
         ],
     )
     )
+    flexmock(module.borgmatic.borg.extract).should_receive('extract_archive').never()
+    flexmock(module.borgmatic.hooks.data_source.dump).should_receive(
+        'parse_data_source_dumps_metadata'
+    ).never()
+    flexmock(module.borgmatic.config.paths).should_receive(
+        'get_borgmatic_source_directory',
+    ).and_return('/root/.borgmatic')
+    flexmock(module.borgmatic.hooks.data_source.dump).should_receive(
+        'make_data_source_dump_path',
+    ).and_return('')
 
 
     archive_dumps = module.collect_dumps_from_archive(
     archive_dumps = module.collect_dumps_from_archive(
         repository={'path': 'repo'},
         repository={'path': 'repo'},
         archive='archive',
         archive='archive',
         config={},
         config={},
         local_borg_version=flexmock(),
         local_borg_version=flexmock(),
-        global_arguments=flexmock(log_json=False),
+        global_arguments=flexmock(dry_run=False, log_json=False),
         local_path=flexmock(),
         local_path=flexmock(),
         remote_path=flexmock(),
         remote_path=flexmock(),
         borgmatic_runtime_directory='/run/borgmatic',
         borgmatic_runtime_directory='/run/borgmatic',
@@ -495,14 +544,55 @@ def test_collect_dumps_from_archive_parses_archive_paths():
     }
     }
 
 
 
 
-def test_collect_dumps_from_archive_parses_archive_paths_with_different_base_directories():
+def test_collect_dumps_from_archive_without_dumps_metadata_falls_back_to_parsing_archive_paths():
+    flexmock(module.borgmatic.config.paths).should_receive(
+        'make_runtime_directory_glob'
+    ).and_return('')
+    flexmock(module.borgmatic.borg.list).should_receive('capture_archive_listing').and_return(
+        []
+    ).and_return(
+        [
+            'borgmatic/postgresql_databases/localhost/foo',
+            'borgmatic/postgresql_databases/host:1234/bar',
+            'borgmatic/mysql_databases/localhost/quux',
+        ],
+    )
+    flexmock(module.borgmatic.borg.extract).should_receive('extract_archive').never()
+    flexmock(module.borgmatic.hooks.data_source.dump).should_receive(
+        'parse_data_source_dumps_metadata'
+    ).never()
     flexmock(module.borgmatic.config.paths).should_receive(
     flexmock(module.borgmatic.config.paths).should_receive(
         'get_borgmatic_source_directory',
         'get_borgmatic_source_directory',
     ).and_return('/root/.borgmatic')
     ).and_return('/root/.borgmatic')
     flexmock(module.borgmatic.hooks.data_source.dump).should_receive(
     flexmock(module.borgmatic.hooks.data_source.dump).should_receive(
         'make_data_source_dump_path',
         'make_data_source_dump_path',
     ).and_return('')
     ).and_return('')
+
+    archive_dumps = module.collect_dumps_from_archive(
+        repository={'path': 'repo'},
+        archive='archive',
+        config={},
+        local_borg_version=flexmock(),
+        global_arguments=flexmock(dry_run=False, log_json=False),
+        local_path=flexmock(),
+        remote_path=flexmock(),
+        borgmatic_runtime_directory='/run/borgmatic',
+    )
+
+    assert archive_dumps == {
+        module.Dump('postgresql_databases', 'foo'),
+        module.Dump('postgresql_databases', 'bar', 'host', 1234),
+        module.Dump('mysql_databases', 'quux'),
+    }
+
+
+def test_collect_dumps_from_archive_parses_archive_paths_with_different_base_directories():
+    flexmock(module.borgmatic.config.paths).should_receive(
+        'make_runtime_directory_glob'
+    ).and_return('')
     flexmock(module.borgmatic.borg.list).should_receive('capture_archive_listing').and_return(
     flexmock(module.borgmatic.borg.list).should_receive('capture_archive_listing').and_return(
+        []
+    ).and_return(
         [
         [
             'borgmatic/postgresql_databases/localhost/foo',
             'borgmatic/postgresql_databases/localhost/foo',
             '.borgmatic/postgresql_databases/localhost/bar',
             '.borgmatic/postgresql_databases/localhost/bar',
@@ -510,6 +600,16 @@ def test_collect_dumps_from_archive_parses_archive_paths_with_different_base_dir
             '/var/run/0/borgmatic/mysql_databases/localhost/quux',
             '/var/run/0/borgmatic/mysql_databases/localhost/quux',
         ],
         ],
     )
     )
+    flexmock(module.borgmatic.borg.extract).should_receive('extract_archive').never()
+    flexmock(module.borgmatic.hooks.data_source.dump).should_receive(
+        'parse_data_source_dumps_metadata'
+    ).never()
+    flexmock(module.borgmatic.config.paths).should_receive(
+        'get_borgmatic_source_directory',
+    ).and_return('/root/.borgmatic')
+    flexmock(module.borgmatic.hooks.data_source.dump).should_receive(
+        'make_data_source_dump_path',
+    ).and_return('')
 
 
     archive_dumps = module.collect_dumps_from_archive(
     archive_dumps = module.collect_dumps_from_archive(
         repository={'path': 'repo'},
         repository={'path': 'repo'},
@@ -532,17 +632,26 @@ def test_collect_dumps_from_archive_parses_archive_paths_with_different_base_dir
 
 
 def test_collect_dumps_from_archive_parses_directory_format_archive_paths():
 def test_collect_dumps_from_archive_parses_directory_format_archive_paths():
     flexmock(module.borgmatic.config.paths).should_receive(
     flexmock(module.borgmatic.config.paths).should_receive(
-        'get_borgmatic_source_directory',
-    ).and_return('/root/.borgmatic')
-    flexmock(module.borgmatic.hooks.data_source.dump).should_receive(
-        'make_data_source_dump_path',
+        'make_runtime_directory_glob'
     ).and_return('')
     ).and_return('')
     flexmock(module.borgmatic.borg.list).should_receive('capture_archive_listing').and_return(
     flexmock(module.borgmatic.borg.list).should_receive('capture_archive_listing').and_return(
+        []
+    ).and_return(
         [
         [
             'borgmatic/postgresql_databases/localhost/foo/table1',
             'borgmatic/postgresql_databases/localhost/foo/table1',
             'borgmatic/postgresql_databases/localhost/foo/table2',
             'borgmatic/postgresql_databases/localhost/foo/table2',
         ],
         ],
     )
     )
+    flexmock(module.borgmatic.borg.extract).should_receive('extract_archive').never()
+    flexmock(module.borgmatic.hooks.data_source.dump).should_receive(
+        'parse_data_source_dumps_metadata'
+    ).never()
+    flexmock(module.borgmatic.config.paths).should_receive(
+        'get_borgmatic_source_directory',
+    ).and_return('/root/.borgmatic')
+    flexmock(module.borgmatic.hooks.data_source.dump).should_receive(
+        'make_data_source_dump_path',
+    ).and_return('')
 
 
     archive_dumps = module.collect_dumps_from_archive(
     archive_dumps = module.collect_dumps_from_archive(
         repository={'path': 'repo'},
         repository={'path': 'repo'},
@@ -562,12 +671,11 @@ def test_collect_dumps_from_archive_parses_directory_format_archive_paths():
 
 
 def test_collect_dumps_from_archive_skips_bad_archive_paths_or_bad_path_components():
 def test_collect_dumps_from_archive_skips_bad_archive_paths_or_bad_path_components():
     flexmock(module.borgmatic.config.paths).should_receive(
     flexmock(module.borgmatic.config.paths).should_receive(
-        'get_borgmatic_source_directory',
-    ).and_return('/root/.borgmatic')
-    flexmock(module.borgmatic.hooks.data_source.dump).should_receive(
-        'make_data_source_dump_path',
+        'make_runtime_directory_glob'
     ).and_return('')
     ).and_return('')
     flexmock(module.borgmatic.borg.list).should_receive('capture_archive_listing').and_return(
     flexmock(module.borgmatic.borg.list).should_receive('capture_archive_listing').and_return(
+        []
+    ).and_return(
         [
         [
             'borgmatic/postgresql_databases/localhost/foo',
             'borgmatic/postgresql_databases/localhost/foo',
             'borgmatic/postgresql_databases/localhost:abcd/bar',
             'borgmatic/postgresql_databases/localhost:abcd/bar',
@@ -576,6 +684,16 @@ def test_collect_dumps_from_archive_skips_bad_archive_paths_or_bad_path_componen
             '',
             '',
         ],
         ],
     )
     )
+    flexmock(module.borgmatic.borg.extract).should_receive('extract_archive').never()
+    flexmock(module.borgmatic.hooks.data_source.dump).should_receive(
+        'parse_data_source_dumps_metadata'
+    ).never()
+    flexmock(module.borgmatic.config.paths).should_receive(
+        'get_borgmatic_source_directory',
+    ).and_return('/root/.borgmatic')
+    flexmock(module.borgmatic.hooks.data_source.dump).should_receive(
+        'make_data_source_dump_path',
+    ).and_return('')
 
 
     archive_dumps = module.collect_dumps_from_archive(
     archive_dumps = module.collect_dumps_from_archive(
         repository={'path': 'repo'},
         repository={'path': 'repo'},

+ 72 - 0
tests/unit/hooks/data_source/test_dump.py

@@ -1,3 +1,6 @@
+import io
+import sys
+
 import pytest
 import pytest
 from flexmock import flexmock
 from flexmock import flexmock
 
 
@@ -31,6 +34,75 @@ def test_make_data_source_dump_filename_with_invalid_name_raises():
         module.make_data_source_dump_filename('databases', 'invalid/name')
         module.make_data_source_dump_filename('databases', 'invalid/name')
 
 
 
 
+def test_write_data_source_dumps_metadata_writes_json_to_file():
+    dumps_metadata = [
+        module.borgmatic.actions.restore.Dump('databases', 'foo'),
+        module.borgmatic.actions.restore.Dump('databases', 'bar'),
+    ]
+    dumps_stream = io.StringIO('password')
+    dumps_stream.name = '/run/borgmatic/databases/dumps.json'
+    builtins = flexmock(sys.modules['builtins'])
+    builtins.should_receive('open').with_args(dumps_stream.name, 'w', encoding='utf-8').and_return(
+        dumps_stream
+    )
+    flexmock(dumps_stream).should_receive('close')  # Prevent close() so getvalue() below works.
+
+    module.write_data_source_dumps_metadata('/run/borgmatic', 'databases', dumps_metadata)
+
+    assert (
+        dumps_stream.getvalue()
+        == '{"dumps": [{"data_source_name": "foo", "hook_name": "databases", "hostname": "localhost", "port": null}, {"data_source_name": "bar", "hook_name": "databases", "hostname": "localhost", "port": null}]}'
+    )
+
+
+def test_write_data_source_dumps_metadata_with_operating_system_error_raises():
+    dumps_metadata = [
+        module.borgmatic.actions.restore.Dump('databases', 'foo'),
+        module.borgmatic.actions.restore.Dump('databases', 'bar'),
+    ]
+    dumps_stream = io.StringIO('password')
+    dumps_stream.name = '/run/borgmatic/databases/dumps.json'
+    builtins = flexmock(sys.modules['builtins'])
+    builtins.should_receive('open').with_args(dumps_stream.name, 'w', encoding='utf-8').and_raise(
+        OSError
+    )
+
+    with pytest.raises(ValueError):
+        module.write_data_source_dumps_metadata('/run/borgmatic', 'databases', dumps_metadata)
+
+
+def test_parse_data_source_dumps_metadata_converts_json_to_dump_instances():
+    dumps_json = '{"dumps": [{"data_source_name": "foo", "hook_name": "databases", "hostname": "localhost", "port": null}, {"data_source_name": "bar", "hook_name": "databases", "hostname": "example.org", "port": 1234}]}'
+
+    assert module.parse_data_source_dumps_metadata(
+        dumps_json, 'borgmatic/databases/dumps.json'
+    ) == (
+        module.borgmatic.actions.restore.Dump('databases', 'foo'),
+        module.borgmatic.actions.restore.Dump('databases', 'bar', 'example.org', 1234),
+    )
+
+
+def test_parse_data_source_dumps_metadata_with_invalid_json_raises():
+    with pytest.raises(ValueError):
+        module.parse_data_source_dumps_metadata('[{', 'borgmatic/databases/dumps.json')
+
+
+def test_parse_data_source_dumps_metadata_with_unknown_keys_raises():
+    dumps_json = (
+        '{"dumps": [{"data_source_name": "foo", "hook_name": "databases", "wtf": "is this"}]}'
+    )
+
+    with pytest.raises(ValueError):
+        module.parse_data_source_dumps_metadata(dumps_json, 'borgmatic/databases/dumps.json')
+
+
+def test_parse_data_source_dumps_metadata_with_missing_dumps_key_raises():
+    dumps_json = '{"not": "what we are looking for"}'
+
+    with pytest.raises(ValueError):
+        module.parse_data_source_dumps_metadata(dumps_json, 'borgmatic/databases/dumps.json')
+
+
 def test_create_parent_directory_for_dump_does_not_raise():
 def test_create_parent_directory_for_dump_does_not_raise():
     flexmock(module.os).should_receive('makedirs')
     flexmock(module.os).should_receive('makedirs')
 
 

+ 39 - 0
tests/unit/hooks/data_source/test_mariadb.py

@@ -349,6 +349,15 @@ def test_dump_data_sources_dumps_each_database():
             dry_run_label=object,
             dry_run_label=object,
         ).and_return(process).once()
         ).and_return(process).once()
 
 
+    flexmock(module.dump).should_receive('write_data_source_dumps_metadata').with_args(
+        '/run/borgmatic',
+        'mariadb_databases',
+        [
+            module.borgmatic.actions.restore.Dump('mariadb_databases', 'foo'),
+            module.borgmatic.actions.restore.Dump('mariadb_databases', 'bar'),
+        ],
+    ).once()
+
     assert (
     assert (
         module.dump_data_sources(
         module.dump_data_sources(
             databases,
             databases,
@@ -390,6 +399,13 @@ def test_dump_data_sources_dumps_with_password():
         dry_run=object,
         dry_run=object,
         dry_run_label=object,
         dry_run_label=object,
     ).and_return(process).once()
     ).and_return(process).once()
+    flexmock(module.dump).should_receive('write_data_source_dumps_metadata').with_args(
+        '/run/borgmatic',
+        'mariadb_databases',
+        [
+            module.borgmatic.actions.restore.Dump('mariadb_databases', 'foo'),
+        ],
+    ).once()
 
 
     assert module.dump_data_sources(
     assert module.dump_data_sources(
         [database],
         [database],
@@ -434,6 +450,13 @@ def test_dump_data_sources_dumps_with_environment_password_transport_passes_pass
         dry_run=object,
         dry_run=object,
         dry_run_label=object,
         dry_run_label=object,
     ).and_return(process).once()
     ).and_return(process).once()
+    flexmock(module.dump).should_receive('write_data_source_dumps_metadata').with_args(
+        '/run/borgmatic',
+        'mariadb_databases',
+        [
+            module.borgmatic.actions.restore.Dump('mariadb_databases', 'foo'),
+        ],
+    ).once()
 
 
     assert module.dump_data_sources(
     assert module.dump_data_sources(
         [database],
         [database],
@@ -465,6 +488,13 @@ def test_dump_data_sources_dumps_all_databases_at_once():
         dry_run=object,
         dry_run=object,
         dry_run_label=object,
         dry_run_label=object,
     ).and_return(process).once()
     ).and_return(process).once()
+    flexmock(module.dump).should_receive('write_data_source_dumps_metadata').with_args(
+        '/run/borgmatic',
+        'mariadb_databases',
+        [
+            module.borgmatic.actions.restore.Dump('mariadb_databases', 'all'),
+        ],
+    ).once()
 
 
     assert module.dump_data_sources(
     assert module.dump_data_sources(
         databases,
         databases,
@@ -499,6 +529,15 @@ def test_dump_data_sources_dumps_all_databases_separately_when_format_configured
             dry_run_label=object,
             dry_run_label=object,
         ).and_return(process).once()
         ).and_return(process).once()
 
 
+    flexmock(module.dump).should_receive('write_data_source_dumps_metadata').with_args(
+        '/run/borgmatic',
+        'mariadb_databases',
+        [
+            module.borgmatic.actions.restore.Dump('mariadb_databases', 'foo'),
+            module.borgmatic.actions.restore.Dump('mariadb_databases', 'bar'),
+        ],
+    ).once()
+
     assert (
     assert (
         module.dump_data_sources(
         module.dump_data_sources(
             databases,
             databases,

+ 58 - 4
tests/unit/hooks/data_source/test_mongodb.py

@@ -39,6 +39,15 @@ def test_dump_data_sources_runs_mongodump_for_each_database():
             run_to_completion=False,
             run_to_completion=False,
         ).and_return(process).once()
         ).and_return(process).once()
 
 
+    flexmock(module.dump).should_receive('write_data_source_dumps_metadata').with_args(
+        '/run/borgmatic',
+        'mongodb_databases',
+        [
+            module.borgmatic.actions.restore.Dump('mongodb_databases', 'foo'),
+            module.borgmatic.actions.restore.Dump('mongodb_databases', 'bar'),
+        ],
+    ).once()
+
     assert (
     assert (
         module.dump_data_sources(
         module.dump_data_sources(
             databases,
             databases,
@@ -60,6 +69,7 @@ def test_dump_data_sources_with_dry_run_skips_mongodump():
     ).and_return('databases/localhost/bar')
     ).and_return('databases/localhost/bar')
     flexmock(module.dump).should_receive('create_named_pipe_for_dump').never()
     flexmock(module.dump).should_receive('create_named_pipe_for_dump').never()
     flexmock(module).should_receive('execute_command').never()
     flexmock(module).should_receive('execute_command').never()
+    flexmock(module.dump).should_receive('write_data_source_dumps_metadata').never()
 
 
     assert (
     assert (
         module.dump_data_sources(
         module.dump_data_sources(
@@ -75,7 +85,7 @@ def test_dump_data_sources_with_dry_run_skips_mongodump():
 
 
 
 
 def test_dump_data_sources_runs_mongodump_with_hostname_and_port():
 def test_dump_data_sources_runs_mongodump_with_hostname_and_port():
-    databases = [{'name': 'foo', 'hostname': 'database.example.org', 'port': 5433}]
+    databases = [{'name': 'foo', 'hostname': 'database.example.org', 'port': 27018}]
     process = flexmock()
     process = flexmock()
     flexmock(module).should_receive('make_dump_path').and_return('')
     flexmock(module).should_receive('make_dump_path').and_return('')
     flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return(
     flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return(
@@ -89,7 +99,7 @@ def test_dump_data_sources_runs_mongodump_with_hostname_and_port():
             '--host',
             '--host',
             'database.example.org',
             'database.example.org',
             '--port',
             '--port',
-            '5433',
+            '27018',
             '--db',
             '--db',
             'foo',
             'foo',
             '--archive',
             '--archive',
@@ -99,6 +109,15 @@ def test_dump_data_sources_runs_mongodump_with_hostname_and_port():
         shell=True,
         shell=True,
         run_to_completion=False,
         run_to_completion=False,
     ).and_return(process).once()
     ).and_return(process).once()
+    flexmock(module.dump).should_receive('write_data_source_dumps_metadata').with_args(
+        '/run/borgmatic',
+        'mongodb_databases',
+        [
+            module.borgmatic.actions.restore.Dump(
+                'mongodb_databases', 'foo', 'database.example.org', 27018
+            ),
+        ],
+    ).once()
 
 
     assert module.dump_data_sources(
     assert module.dump_data_sources(
         databases,
         databases,
@@ -150,6 +169,13 @@ def test_dump_data_sources_runs_mongodump_with_username_and_password():
         shell=True,
         shell=True,
         run_to_completion=False,
         run_to_completion=False,
     ).and_return(process).once()
     ).and_return(process).once()
+    flexmock(module.dump).should_receive('write_data_source_dumps_metadata').with_args(
+        '/run/borgmatic',
+        'mongodb_databases',
+        [
+            module.borgmatic.actions.restore.Dump('mongodb_databases', 'foo'),
+        ],
+    ).once()
 
 
     assert module.dump_data_sources(
     assert module.dump_data_sources(
         databases,
         databases,
@@ -174,6 +200,13 @@ def test_dump_data_sources_runs_mongodump_with_directory_format():
         ('mongodump', '--out', 'databases/localhost/foo', '--db', 'foo'),
         ('mongodump', '--out', 'databases/localhost/foo', '--db', 'foo'),
         shell=True,
         shell=True,
     ).and_return(flexmock()).once()
     ).and_return(flexmock()).once()
+    flexmock(module.dump).should_receive('write_data_source_dumps_metadata').with_args(
+        '/run/borgmatic',
+        'mongodb_databases',
+        [
+            module.borgmatic.actions.restore.Dump('mongodb_databases', 'foo'),
+        ],
+    ).once()
 
 
     assert (
     assert (
         module.dump_data_sources(
         module.dump_data_sources(
@@ -210,6 +243,13 @@ def test_dump_data_sources_runs_mongodump_with_options():
         shell=True,
         shell=True,
         run_to_completion=False,
         run_to_completion=False,
     ).and_return(process).once()
     ).and_return(process).once()
+    flexmock(module.dump).should_receive('write_data_source_dumps_metadata').with_args(
+        '/run/borgmatic',
+        'mongodb_databases',
+        [
+            module.borgmatic.actions.restore.Dump('mongodb_databases', 'foo'),
+        ],
+    ).once()
 
 
     assert module.dump_data_sources(
     assert module.dump_data_sources(
         databases,
         databases,
@@ -235,6 +275,13 @@ def test_dump_data_sources_runs_mongodumpall_for_all_databases():
         shell=True,
         shell=True,
         run_to_completion=False,
         run_to_completion=False,
     ).and_return(process).once()
     ).and_return(process).once()
+    flexmock(module.dump).should_receive('write_data_source_dumps_metadata').with_args(
+        '/run/borgmatic',
+        'mongodb_databases',
+        [
+            module.borgmatic.actions.restore.Dump('mongodb_databases', 'all'),
+        ],
+    ).once()
 
 
     assert module.dump_data_sources(
     assert module.dump_data_sources(
         databases,
         databases,
@@ -308,7 +355,7 @@ def test_restore_data_source_dump_runs_mongorestore():
 
 
 def test_restore_data_source_dump_runs_mongorestore_with_hostname_and_port():
 def test_restore_data_source_dump_runs_mongorestore_with_hostname_and_port():
     hook_config = [
     hook_config = [
-        {'name': 'foo', 'hostname': 'database.example.org', 'port': 5433, 'schemas': None},
+        {'name': 'foo', 'hostname': 'database.example.org', 'port': 27018, 'schemas': None},
     ]
     ]
     extract_process = flexmock(stdout=flexmock())
     extract_process = flexmock(stdout=flexmock())
 
 
@@ -325,7 +372,7 @@ def test_restore_data_source_dump_runs_mongorestore_with_hostname_and_port():
             '--host',
             '--host',
             'database.example.org',
             'database.example.org',
             '--port',
             '--port',
-            '5433',
+            '27018',
         ],
         ],
         processes=[extract_process],
         processes=[extract_process],
         output_log_level=logging.DEBUG,
         output_log_level=logging.DEBUG,
@@ -708,6 +755,13 @@ def test_dump_data_sources_uses_custom_mongodump_command():
         shell=True,
         shell=True,
         run_to_completion=False,
         run_to_completion=False,
     ).and_return(process).once()
     ).and_return(process).once()
+    flexmock(module.dump).should_receive('write_data_source_dumps_metadata').with_args(
+        '/run/borgmatic',
+        'mongodb_databases',
+        [
+            module.borgmatic.actions.restore.Dump('mongodb_databases', 'foo'),
+        ],
+    ).once()
 
 
     assert module.dump_data_sources(
     assert module.dump_data_sources(
         databases,
         databases,

+ 40 - 0
tests/unit/hooks/data_source/test_mysql.py

@@ -232,6 +232,15 @@ def test_dump_data_sources_dumps_each_database():
             dry_run_label=object,
             dry_run_label=object,
         ).and_return(process).once()
         ).and_return(process).once()
 
 
+    flexmock(module.dump).should_receive('write_data_source_dumps_metadata').with_args(
+        '/run/borgmatic',
+        'mysql_databases',
+        [
+            module.borgmatic.actions.restore.Dump('mysql_databases', 'foo'),
+            module.borgmatic.actions.restore.Dump('mysql_databases', 'bar'),
+        ],
+    ).once()
+
     assert (
     assert (
         module.dump_data_sources(
         module.dump_data_sources(
             databases,
             databases,
@@ -273,6 +282,13 @@ def test_dump_data_sources_dumps_with_password():
         dry_run=object,
         dry_run=object,
         dry_run_label=object,
         dry_run_label=object,
     ).and_return(process).once()
     ).and_return(process).once()
+    flexmock(module.dump).should_receive('write_data_source_dumps_metadata').with_args(
+        '/run/borgmatic',
+        'mysql_databases',
+        [
+            module.borgmatic.actions.restore.Dump('mysql_databases', 'foo'),
+        ],
+    ).once()
 
 
     assert module.dump_data_sources(
     assert module.dump_data_sources(
         [database],
         [database],
@@ -317,6 +333,13 @@ def test_dump_data_sources_dumps_with_environment_password_transport_passes_pass
         dry_run=object,
         dry_run=object,
         dry_run_label=object,
         dry_run_label=object,
     ).and_return(process).once()
     ).and_return(process).once()
+    flexmock(module.dump).should_receive('write_data_source_dumps_metadata').with_args(
+        '/run/borgmatic',
+        'mysql_databases',
+        [
+            module.borgmatic.actions.restore.Dump('mysql_databases', 'foo'),
+        ],
+    ).once()
 
 
     assert module.dump_data_sources(
     assert module.dump_data_sources(
         [database],
         [database],
@@ -349,6 +372,14 @@ def test_dump_data_sources_dumps_all_databases_at_once():
         dry_run_label=object,
         dry_run_label=object,
     ).and_return(process).once()
     ).and_return(process).once()
 
 
+    flexmock(module.dump).should_receive('write_data_source_dumps_metadata').with_args(
+        '/run/borgmatic',
+        'mysql_databases',
+        [
+            module.borgmatic.actions.restore.Dump('mysql_databases', 'all'),
+        ],
+    ).once()
+
     assert module.dump_data_sources(
     assert module.dump_data_sources(
         databases,
         databases,
         {},
         {},
@@ -382,6 +413,15 @@ def test_dump_data_sources_dumps_all_databases_separately_when_format_configured
             dry_run_label=object,
             dry_run_label=object,
         ).and_return(process).once()
         ).and_return(process).once()
 
 
+    flexmock(module.dump).should_receive('write_data_source_dumps_metadata').with_args(
+        '/run/borgmatic',
+        'mysql_databases',
+        [
+            module.borgmatic.actions.restore.Dump('mysql_databases', 'foo'),
+            module.borgmatic.actions.restore.Dump('mysql_databases', 'bar'),
+        ],
+    ).once()
+
     assert (
     assert (
         module.dump_data_sources(
         module.dump_data_sources(
             databases,
             databases,

+ 82 - 0
tests/unit/hooks/data_source/test_postgresql.py

@@ -273,6 +273,14 @@ def test_dump_data_sources_runs_pg_dump_for_each_database():
             environment={'PGSSLMODE': 'disable'},
             environment={'PGSSLMODE': 'disable'},
             run_to_completion=False,
             run_to_completion=False,
         ).and_return(process).once()
         ).and_return(process).once()
+    flexmock(module.dump).should_receive('write_data_source_dumps_metadata').with_args(
+        '/run/borgmatic',
+        'postgresql_databases',
+        [
+            module.borgmatic.actions.restore.Dump('postgresql_databases', 'foo'),
+            module.borgmatic.actions.restore.Dump('postgresql_databases', 'bar'),
+        ],
+    ).once()
 
 
     assert (
     assert (
         module.dump_data_sources(
         module.dump_data_sources(
@@ -336,6 +344,14 @@ def test_dump_data_sources_with_duplicate_dump_skips_pg_dump():
     flexmock(module.os.path).should_receive('exists').and_return(True)
     flexmock(module.os.path).should_receive('exists').and_return(True)
     flexmock(module.dump).should_receive('create_named_pipe_for_dump').never()
     flexmock(module.dump).should_receive('create_named_pipe_for_dump').never()
     flexmock(module).should_receive('execute_command').never()
     flexmock(module).should_receive('execute_command').never()
+    flexmock(module.dump).should_receive('write_data_source_dumps_metadata').with_args(
+        '/run/borgmatic',
+        'postgresql_databases',
+        [
+            module.borgmatic.actions.restore.Dump('postgresql_databases', 'foo'),
+            module.borgmatic.actions.restore.Dump('postgresql_databases', 'bar'),
+        ],
+    ).once()
 
 
     assert (
     assert (
         module.dump_data_sources(
         module.dump_data_sources(
@@ -366,6 +382,7 @@ def test_dump_data_sources_with_dry_run_skips_pg_dump():
     ).replace_with(lambda value, config: value)
     ).replace_with(lambda value, config: value)
     flexmock(module.dump).should_receive('create_named_pipe_for_dump').never()
     flexmock(module.dump).should_receive('create_named_pipe_for_dump').never()
     flexmock(module).should_receive('execute_command').never()
     flexmock(module).should_receive('execute_command').never()
+    flexmock(module.dump).should_receive('write_data_source_dumps_metadata').never()
 
 
     assert (
     assert (
         module.dump_data_sources(
         module.dump_data_sources(
@@ -415,6 +432,15 @@ def test_dump_data_sources_runs_pg_dump_with_hostname_and_port():
         environment={'PGSSLMODE': 'disable'},
         environment={'PGSSLMODE': 'disable'},
         run_to_completion=False,
         run_to_completion=False,
     ).and_return(process).once()
     ).and_return(process).once()
+    flexmock(module.dump).should_receive('write_data_source_dumps_metadata').with_args(
+        '/run/borgmatic',
+        'postgresql_databases',
+        [
+            module.borgmatic.actions.restore.Dump(
+                'postgresql_databases', 'foo', 'database.example.org', 5433
+            ),
+        ],
+    ).once()
 
 
     assert module.dump_data_sources(
     assert module.dump_data_sources(
         databases,
         databases,
@@ -461,6 +487,13 @@ def test_dump_data_sources_runs_pg_dump_with_username_and_password():
         environment={'PGPASSWORD': 'trustsome1', 'PGSSLMODE': 'disable'},
         environment={'PGPASSWORD': 'trustsome1', 'PGSSLMODE': 'disable'},
         run_to_completion=False,
         run_to_completion=False,
     ).and_return(process).once()
     ).and_return(process).once()
+    flexmock(module.dump).should_receive('write_data_source_dumps_metadata').with_args(
+        '/run/borgmatic',
+        'postgresql_databases',
+        [
+            module.borgmatic.actions.restore.Dump('postgresql_databases', 'foo'),
+        ],
+    ).once()
 
 
     assert module.dump_data_sources(
     assert module.dump_data_sources(
         databases,
         databases,
@@ -507,6 +540,13 @@ def test_dump_data_sources_with_username_injection_attack_gets_escaped():
         environment={'PGPASSWORD': 'trustsome1', 'PGSSLMODE': 'disable'},
         environment={'PGPASSWORD': 'trustsome1', 'PGSSLMODE': 'disable'},
         run_to_completion=False,
         run_to_completion=False,
     ).and_return(process).once()
     ).and_return(process).once()
+    flexmock(module.dump).should_receive('write_data_source_dumps_metadata').with_args(
+        '/run/borgmatic',
+        'postgresql_databases',
+        [
+            module.borgmatic.actions.restore.Dump('postgresql_databases', 'foo'),
+        ],
+    ).once()
 
 
     assert module.dump_data_sources(
     assert module.dump_data_sources(
         databases,
         databases,
@@ -548,6 +588,13 @@ def test_dump_data_sources_runs_pg_dump_with_directory_format():
         shell=True,
         shell=True,
         environment={'PGSSLMODE': 'disable'},
         environment={'PGSSLMODE': 'disable'},
     ).and_return(flexmock()).once()
     ).and_return(flexmock()).once()
+    flexmock(module.dump).should_receive('write_data_source_dumps_metadata').with_args(
+        '/run/borgmatic',
+        'postgresql_databases',
+        [
+            module.borgmatic.actions.restore.Dump('postgresql_databases', 'foo'),
+        ],
+    ).once()
 
 
     assert (
     assert (
         module.dump_data_sources(
         module.dump_data_sources(
@@ -595,6 +642,13 @@ def test_dump_data_sources_runs_pg_dump_with_string_compression():
         environment={'PGSSLMODE': 'disable'},
         environment={'PGSSLMODE': 'disable'},
         run_to_completion=False,
         run_to_completion=False,
     ).and_return(processes[0]).once()
     ).and_return(processes[0]).once()
+    flexmock(module.dump).should_receive('write_data_source_dumps_metadata').with_args(
+        '/run/borgmatic',
+        'postgresql_databases',
+        [
+            module.borgmatic.actions.restore.Dump('postgresql_databases', 'foo'),
+        ],
+    ).once()
 
 
     assert (
     assert (
         module.dump_data_sources(
         module.dump_data_sources(
@@ -642,6 +696,13 @@ def test_dump_data_sources_runs_pg_dump_with_integer_compression():
         environment={'PGSSLMODE': 'disable'},
         environment={'PGSSLMODE': 'disable'},
         run_to_completion=False,
         run_to_completion=False,
     ).and_return(processes[0]).once()
     ).and_return(processes[0]).once()
+    flexmock(module.dump).should_receive('write_data_source_dumps_metadata').with_args(
+        '/run/borgmatic',
+        'postgresql_databases',
+        [
+            module.borgmatic.actions.restore.Dump('postgresql_databases', 'foo'),
+        ],
+    ).once()
 
 
     assert (
     assert (
         module.dump_data_sources(
         module.dump_data_sources(
@@ -688,6 +749,13 @@ def test_dump_data_sources_runs_pg_dump_with_options():
         environment={'PGSSLMODE': 'disable'},
         environment={'PGSSLMODE': 'disable'},
         run_to_completion=False,
         run_to_completion=False,
     ).and_return(process).once()
     ).and_return(process).once()
+    flexmock(module.dump).should_receive('write_data_source_dumps_metadata').with_args(
+        '/run/borgmatic',
+        'postgresql_databases',
+        [
+            module.borgmatic.actions.restore.Dump('postgresql_databases', 'foo'),
+        ],
+    ).once()
 
 
     assert module.dump_data_sources(
     assert module.dump_data_sources(
         databases,
         databases,
@@ -720,6 +788,13 @@ def test_dump_data_sources_runs_pg_dumpall_for_all_databases():
         environment={'PGSSLMODE': 'disable'},
         environment={'PGSSLMODE': 'disable'},
         run_to_completion=False,
         run_to_completion=False,
     ).and_return(process).once()
     ).and_return(process).once()
+    flexmock(module.dump).should_receive('write_data_source_dumps_metadata').with_args(
+        '/run/borgmatic',
+        'postgresql_databases',
+        [
+            module.borgmatic.actions.restore.Dump('postgresql_databases', 'all'),
+        ],
+    ).once()
 
 
     assert module.dump_data_sources(
     assert module.dump_data_sources(
         databases,
         databases,
@@ -764,6 +839,13 @@ def test_dump_data_sources_runs_non_default_pg_dump():
         environment={'PGSSLMODE': 'disable'},
         environment={'PGSSLMODE': 'disable'},
         run_to_completion=False,
         run_to_completion=False,
     ).and_return(process).once()
     ).and_return(process).once()
+    flexmock(module.dump).should_receive('write_data_source_dumps_metadata').with_args(
+        '/run/borgmatic',
+        'postgresql_databases',
+        [
+            module.borgmatic.actions.restore.Dump('postgresql_databases', 'foo'),
+        ],
+    ).once()
 
 
     assert module.dump_data_sources(
     assert module.dump_data_sources(
         databases,
         databases,

+ 44 - 0
tests/unit/hooks/data_source/test_sqlite.py

@@ -26,6 +26,13 @@ def test_dump_data_sources_logs_and_skips_if_dump_already_exists():
     flexmock(module.os.path).should_receive('exists').and_return(True)
     flexmock(module.os.path).should_receive('exists').and_return(True)
     flexmock(module.dump).should_receive('create_named_pipe_for_dump').never()
     flexmock(module.dump).should_receive('create_named_pipe_for_dump').never()
     flexmock(module).should_receive('execute_command').never()
     flexmock(module).should_receive('execute_command').never()
+    flexmock(module.dump).should_receive('write_data_source_dumps_metadata').with_args(
+        '/run/borgmatic',
+        'sqlite_databases',
+        [
+            module.borgmatic.actions.restore.Dump('sqlite_databases', 'database'),
+        ],
+    ).once()
 
 
     assert (
     assert (
         module.dump_data_sources(
         module.dump_data_sources(
@@ -56,6 +63,14 @@ def test_dump_data_sources_dumps_each_database():
     flexmock(module).should_receive('execute_command').and_return(processes[0]).and_return(
     flexmock(module).should_receive('execute_command').and_return(processes[0]).and_return(
         processes[1],
         processes[1],
     )
     )
+    flexmock(module.dump).should_receive('write_data_source_dumps_metadata').with_args(
+        '/run/borgmatic',
+        'sqlite_databases',
+        [
+            module.borgmatic.actions.restore.Dump('sqlite_databases', 'database1'),
+            module.borgmatic.actions.restore.Dump('sqlite_databases', 'database2'),
+        ],
+    ).once()
 
 
     assert (
     assert (
         module.dump_data_sources(
         module.dump_data_sources(
@@ -93,6 +108,13 @@ def test_dump_data_sources_with_path_injection_attack_gets_escaped():
         shell=True,
         shell=True,
         run_to_completion=False,
         run_to_completion=False,
     ).and_return(processes[0])
     ).and_return(processes[0])
+    flexmock(module.dump).should_receive('write_data_source_dumps_metadata').with_args(
+        '/run/borgmatic',
+        'sqlite_databases',
+        [
+            module.borgmatic.actions.restore.Dump('sqlite_databases', 'database1'),
+        ],
+    ).once()
 
 
     assert (
     assert (
         module.dump_data_sources(
         module.dump_data_sources(
@@ -135,6 +157,13 @@ def test_dump_data_sources_runs_non_default_sqlite_with_path_injection_attack_ge
         shell=True,
         shell=True,
         run_to_completion=False,
         run_to_completion=False,
     ).and_return(processes[0])
     ).and_return(processes[0])
+    flexmock(module.dump).should_receive('write_data_source_dumps_metadata').with_args(
+        '/run/borgmatic',
+        'sqlite_databases',
+        [
+            module.borgmatic.actions.restore.Dump('sqlite_databases', 'database1'),
+        ],
+    ).once()
 
 
     assert (
     assert (
         module.dump_data_sources(
         module.dump_data_sources(
@@ -163,6 +192,13 @@ def test_dump_data_sources_with_non_existent_path_warns_and_dumps_database():
     flexmock(module.os.path).should_receive('exists').and_return(False)
     flexmock(module.os.path).should_receive('exists').and_return(False)
     flexmock(module.dump).should_receive('create_named_pipe_for_dump')
     flexmock(module.dump).should_receive('create_named_pipe_for_dump')
     flexmock(module).should_receive('execute_command').and_return(processes[0])
     flexmock(module).should_receive('execute_command').and_return(processes[0])
+    flexmock(module.dump).should_receive('write_data_source_dumps_metadata').with_args(
+        '/run/borgmatic',
+        'sqlite_databases',
+        [
+            module.borgmatic.actions.restore.Dump('sqlite_databases', 'database1'),
+        ],
+    ).once()
 
 
     assert (
     assert (
         module.dump_data_sources(
         module.dump_data_sources(
@@ -193,6 +229,13 @@ def test_dump_data_sources_with_name_all_warns_and_dumps_all_databases():
     flexmock(module.os.path).should_receive('exists').and_return(False)
     flexmock(module.os.path).should_receive('exists').and_return(False)
     flexmock(module.dump).should_receive('create_named_pipe_for_dump')
     flexmock(module.dump).should_receive('create_named_pipe_for_dump')
     flexmock(module).should_receive('execute_command').and_return(processes[0])
     flexmock(module).should_receive('execute_command').and_return(processes[0])
+    flexmock(module.dump).should_receive('write_data_source_dumps_metadata').with_args(
+        '/run/borgmatic',
+        'sqlite_databases',
+        [
+            module.borgmatic.actions.restore.Dump('sqlite_databases', 'all'),
+        ],
+    ).once()
 
 
     assert (
     assert (
         module.dump_data_sources(
         module.dump_data_sources(
@@ -217,6 +260,7 @@ def test_dump_data_sources_does_not_dump_if_dry_run():
     flexmock(module.os.path).should_receive('exists').and_return(False)
     flexmock(module.os.path).should_receive('exists').and_return(False)
     flexmock(module.dump).should_receive('create_named_pipe_for_dump').never()
     flexmock(module.dump).should_receive('create_named_pipe_for_dump').never()
     flexmock(module).should_receive('execute_command').never()
     flexmock(module).should_receive('execute_command').never()
+    flexmock(module.dump).should_receive('write_data_source_dumps_metadata').never()
 
 
     assert (
     assert (
         module.dump_data_sources(
         module.dump_data_sources(