diff --git a/NEWS b/NEWS index 4c0e4f3e..38748947 100644 --- a/NEWS +++ b/NEWS @@ -1,5 +1,9 @@ 2.0.8.dev0 * #1114: Document systemd configuration changes for the ZFS filesystem hook. + * #1116: Add dumping of database containers via their container names, handy for backing up + database containers from the host. See the documentation for more information: + https://torsion.org/borgmatic/docs/how-to/backup-your-databases/#database-client-on-the-host + * #1116: Add optional database labels to make it easier to find your dumps within a Borg archive. * #1118: Fix a bug in which Borg hangs during database backup when different filesystems are in use. * #1122: To prevent the user from inadvertently excluding the "bootstrap" action's manifest, always diff --git a/borgmatic/actions/restore.py b/borgmatic/actions/restore.py index fe68d256..ac25bfd7 100644 --- a/borgmatic/actions/restore.py +++ b/borgmatic/actions/restore.py @@ -22,8 +22,8 @@ UNSPECIFIED = object() Dump = collections.namedtuple( 'Dump', - ('hook_name', 'data_source_name', 'hostname', 'port'), - defaults=('localhost', None), + ('hook_name', 'data_source_name', 'hostname', 'port', 'label', 'container'), + defaults=('localhost', None, None, None), ) @@ -33,7 +33,13 @@ def dumps_match(first, second, default_port=None): indicates that the field should match any value. If a default port is given, then consider any dump having that port to match with a dump having a None port. ''' - for field_name in first._fields: + # label kinda counts as an unique id, if they match ignore host/container/port + if first.label not in {None, UNSPECIFIED} and first.label == second.label: + field_list = ('hook_name', 'data_source_name') + else: + field_list = Dump._fields + + for field_name in field_list: first_value = getattr(first, field_name) second_value = getattr(second, field_name) @@ -57,14 +63,17 @@ def render_dump_metadata(dump): ''' Given a Dump instance, make a display string describing it for use in log messages. ''' + label = dump.label or UNSPECIFIED name = 'unspecified' if dump.data_source_name is UNSPECIFIED else dump.data_source_name - hostname = dump.hostname or UNSPECIFIED + host = dump.container or dump.hostname or UNSPECIFIED port = None if dump.port is UNSPECIFIED else dump.port - if port: - metadata = f'{name}@:{port}' if hostname is UNSPECIFIED else f'{name}@{hostname}:{port}' + if label is not UNSPECIFIED: + metadata = f'{name}@{label}' + elif port: + metadata = f'{name}@:{port}' if host is UNSPECIFIED else f'{name}@{host}:{port}' else: - metadata = f'{name}' if hostname is UNSPECIFIED else f'{name}@{hostname}' + metadata = f'{name}' if host is UNSPECIFIED else f'{name}@{host}' if dump.hook_name not in {None, UNSPECIFIED}: return f'{metadata} ({dump.hook_name})' @@ -101,6 +110,8 @@ def get_configured_data_source(config, restore_dump): hook_data_source.get('name'), hook_data_source.get('hostname', 'localhost'), hook_data_source.get('port'), + hook_data_source.get('label') or UNSPECIFIED, + hook_data_source.get('container'), ), restore_dump, default_port, @@ -172,7 +183,14 @@ def restore_single_dump( that data source from the archive. ''' dump_metadata = render_dump_metadata( - Dump(hook_name, data_source['name'], data_source.get('hostname'), data_source.get('port')), + Dump( + hook_name, + data_source['name'], + data_source.get('hostname'), + data_source.get('port'), + data_source.get('label') or UNSPECIFIED, + data_source.get('container'), + ), ) logger.info(f'Restoring data source {dump_metadata}') @@ -408,6 +426,8 @@ def get_dumps_to_restore(restore_arguments, dumps_from_archive): data_source_name=name, hostname=restore_arguments.original_hostname or UNSPECIFIED, port=restore_arguments.original_port, + label=restore_arguments.original_label or UNSPECIFIED, + container=restore_arguments.original_container or UNSPECIFIED, ) for name in restore_arguments.data_sources or (UNSPECIFIED,) } @@ -415,12 +435,16 @@ def get_dumps_to_restore(restore_arguments, dumps_from_archive): or restore_arguments.data_sources or restore_arguments.original_hostname or restore_arguments.original_port + or restore_arguments.original_label + or restore_arguments.original_container else { Dump( hook_name=UNSPECIFIED, data_source_name='all', hostname=UNSPECIFIED, port=UNSPECIFIED, + label=UNSPECIFIED, + container=UNSPECIFIED, ), } ) @@ -541,6 +565,7 @@ def run_restore( dumps_actually_restored = set() connection_params = { + 'container': restore_arguments.container, 'hostname': restore_arguments.hostname, 'port': restore_arguments.port, 'username': restore_arguments.username, @@ -554,13 +579,19 @@ def run_restore( config, restore_dump, ) - # For a dump that wasn't found via an exact match in the configuration, try to fallback # to an "all" data source. if not found_data_source: found_data_source = get_configured_data_source( config, - Dump(restore_dump.hook_name, 'all', restore_dump.hostname, restore_dump.port), + Dump( + restore_dump.hook_name, + 'all', + restore_dump.hostname, + restore_dump.port, + restore_dump.label, + restore_dump.container, + ), ) if not found_data_source: diff --git a/borgmatic/commands/arguments.py b/borgmatic/commands/arguments.py index e35cf5a5..9f77a7a8 100644 --- a/borgmatic/commands/arguments.py +++ b/borgmatic/commands/arguments.py @@ -1475,6 +1475,10 @@ def make_parsers(schema, unparsed_arguments): # noqa: PLR0915 '--port', help='Database port to restore to. Defaults to the "restore_port" option in borgmatic\'s configuration', ) + restore_group.add_argument( + '--container', + help='Container to restore to. Defaults to the "restore_container" option in borgmatic\'s configuration', + ) restore_group.add_argument( '--username', help='Username with which to connect to the database. Defaults to the "restore_username" option in borgmatic\'s configuration', @@ -1487,10 +1491,18 @@ def make_parsers(schema, unparsed_arguments): # noqa: PLR0915 '--restore-path', help='Path to restore SQLite database dumps to. Defaults to the "restore_path" option in borgmatic\'s configuration', ) + restore_group.add_argument( + '--original-label', + help='The label where the dump to restore came from, only necessary if you need to disambiguate dumps', + ) restore_group.add_argument( '--original-hostname', help='The hostname where the dump to restore came from, only necessary if you need to disambiguate dumps', ) + restore_group.add_argument( + '--original-container', + help='The container where the dump to restore came from, only necessary if you need to disambiguate dumps', + ) restore_group.add_argument( '--original-port', type=int, diff --git a/borgmatic/config/arguments.py b/borgmatic/config/arguments.py index e50314ae..295f48c5 100644 --- a/borgmatic/config/arguments.py +++ b/borgmatic/config/arguments.py @@ -146,9 +146,9 @@ def prepare_arguments_for_config(global_arguments, schema): keys = tuple(argument_name.split('.')) option_type = type_for_option(schema, keys) - # The argument doesn't correspond to any option in the schema, so ignore it. It's - # probably a flag that borgmatic has on the command-line but not in configuration. - if option_type is None: + # The argument doesn't correspond to any option in the schema, or it is a complex argument, so ignore it. + # It's probably a flag that borgmatic has on the command-line but not in configuration. + if option_type in {'object', None}: continue prepared_values.append( diff --git a/borgmatic/config/schema.yaml b/borgmatic/config/schema.yaml index 0a49416a..36da3d5e 100644 --- a/borgmatic/config/schema.yaml +++ b/borgmatic/config/schema.yaml @@ -1323,6 +1323,23 @@ properties: implicitly enables read_special (see above) to support dump and restore streaming. example: users + label: + type: string + description: | + Label to identify the database dump in the backup. + example: my_backup_label + container: + type: string + description: | + Container name/id to connect to. When specified the + hostname is ignored. Requires docker/podman CLI. + example: debian_stable + restore_container: + type: string + description: | + Container name/id to restore to. Defaults to the + "container" option. + example: restore_container hostname: type: string description: | @@ -1524,6 +1541,23 @@ properties: database hook implicitly enables read_special (see above) to support dump and restore streaming. example: users + label: + type: string + description: | + Label to identify the database dump in the backup. + example: my_backup_label + container: + type: string + description: | + Container name/id to connect to. When specified the + hostname is ignored. Requires docker/podman CLI. + example: debian_stable + restore_container: + type: string + description: | + Container name/id to restore to. Defaults to the + "container" option. + example: restore_container hostname: type: string description: | @@ -1689,6 +1723,23 @@ properties: database hook implicitly enables read_special (see above) to support dump and restore streaming. example: users + label: + type: string + description: | + Label to identify the database dump in the backup. + example: my_backup_label + container: + type: string + description: | + Container name/id to connect to. When specified the + hostname is ignored. Requires docker/podman CLI. + example: debian_stable + restore_container: + type: string + description: | + Container name/id to restore to. Defaults to the + "container" option. + example: restore_container hostname: type: string description: | @@ -1862,6 +1913,11 @@ properties: read_special (see above) to support dump and restore streaming. example: /var/lib/sqlite/users.db + label: + type: string + description: | + Label to identify the database dump in the backup. + example: my_backup_label restore_path: type: string description: | @@ -1910,6 +1966,23 @@ properties: database hook implicitly enables read_special (see above) to support dump and restore streaming. example: users + label: + type: string + description: | + Label to identify the database dump in the backup. + example: my_backup_label + container: + type: string + description: | + Container name/id to connect to. When specified the + hostname is ignored. Requires docker/podman CLI. + example: debian_stable + restore_container: + type: string + description: | + Container name/id to restore to. Defaults to the + "container" option. + example: restore_container hostname: type: string description: | diff --git a/borgmatic/hooks/data_source/config.py b/borgmatic/hooks/data_source/config.py new file mode 100644 index 00000000..59a0bfc9 --- /dev/null +++ b/borgmatic/hooks/data_source/config.py @@ -0,0 +1,104 @@ +import json +import logging +import shutil +import subprocess + +from borgmatic.execute import execute_command_and_capture_output + +IS_A_HOOK = False + +logger = logging.getLogger(__name__) + + +def resolve_database_option(option, data_source, connection_params=None, restore=False): + ''' + Resolves a database option from the given data source configuration dict and + connection parameters dict. If restore is set to True it will consider the + `restore_