Compare commits

..
173 changed files with 2166 additions and 5645 deletions

53
NEWS
View file

@ -1,56 +1,3 @@
1.9.12
* #1005: Fix the credential hooks to avoid using Python 3.12+ string features. Now borgmatic will
work with Python 3.9, 3.10, and 3.11 again.
1.9.11
* #795: Add credential loading from file, KeePassXC, and Docker/Podman secrets. See the
documentation for more information:
https://torsion.org/borgmatic/docs/how-to/provide-your-passwords/
* #996: Fix the "create" action to omit the repository label prefix from Borg's output when
databases are enabled.
* #998: Send the "encryption_passphrase" option to Borg via an anonymous pipe, which is more secure
than using an environment variable.
* #999: Fix a runtime directory error from a conflict between "extra_borg_options" and special file
detection.
* #1001: For the ZFS, Btrfs, and LVM hooks, only make snapshots for root patterns that come from
a borgmatic configuration option (e.g. "source_directories")—not from other hooks within
borgmatic.
* #1001: Fix a ZFS/LVM error due to colliding snapshot mount points for nested datasets or logical
volumes.
* #1001: Don't try to snapshot ZFS datasets that have the "canmount=off" property.
* Fix another error in the Btrfs hook when a subvolume mounted at "/" is configured in borgmatic's
source directories.
1.9.10
* #966: Add a "{credential ...}" syntax for loading systemd credentials into borgmatic
configuration files. See the documentation for more information:
https://torsion.org/borgmatic/docs/how-to/provide-your-passwords/
* #987: Fix a "list" action error when the "encryption_passcommand" option is set.
* #987: When both "encryption_passcommand" and "encryption_passphrase" are configured, prefer
"encryption_passphrase" even if it's an empty value.
* #988: With the "max_duration" option or the "--max-duration" flag, run the archives and
repository checks separately so they don't interfere with one another. Previously, borgmatic
refused to run checks in this situation.
* #989: Fix the log message code to avoid using Python 3.10+ logging features. Now borgmatic will
work with Python 3.9 again.
* Capture and delay any log records produced before logging is fully configured, so early log
records don't get lost.
* Add support for Python 3.13.
1.9.9
* #635: Log the repository path or label on every relevant log message, not just some logs.
* #961: When the "encryption_passcommand" option is set, call the command once from borgmatic to
collect the encryption passphrase and then pass it to Borg multiple times. See the documentation
for more information: https://torsion.org/borgmatic/docs/how-to/provide-your-passwords/
* #981: Fix a "spot" check file count delta error.
* #982: Fix for borgmatic "exclude_patterns" and "exclude_from" recursing into excluded
subdirectories.
* #983: Fix the Btrfs hook to support subvolumes with names like "@home" different from their
mount points.
* #985: Change the default value for the "--original-hostname" flag from "localhost" to no host
specified. This way, the "restore" action works without a hostname if there's a single matching
database dump.
1.9.8
* #979: Fix root patterns so they don't have an invalid "sh:" prefix before getting passed to Borg.
* Expand the recent contributors documentation section to include ticket submitters—not just code

View file

@ -56,8 +56,6 @@ borgmatic is powered by [Borg Backup](https://www.borgbackup.org/).
## Integrations
### Data
<a href="https://www.postgresql.org/"><img src="docs/static/postgresql.png" alt="PostgreSQL" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://www.mysql.com/"><img src="docs/static/mysql.png" alt="MySQL" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://mariadb.com/"><img src="docs/static/mariadb.png" alt="MariaDB" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
@ -67,11 +65,6 @@ borgmatic is powered by [Borg Backup](https://www.borgbackup.org/).
<a href="https://btrfs.readthedocs.io/"><img src="docs/static/btrfs.png" alt="Btrfs" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://sourceware.org/lvm2/"><img src="docs/static/lvm.png" alt="LVM" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://rclone.org"><img src="docs/static/rclone.png" alt="rclone" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://www.borgbase.com/?utm_source=borgmatic"><img src="docs/static/borgbase.png" alt="BorgBase" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
### Monitoring
<a href="https://healthchecks.io/"><img src="docs/static/healthchecks.png" alt="Healthchecks" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://uptime.kuma.pet/"><img src="docs/static/uptimekuma.png" alt="Uptime Kuma" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://cronitor.io/"><img src="docs/static/cronitor.png" alt="Cronitor" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
@ -83,14 +76,7 @@ borgmatic is powered by [Borg Backup](https://www.borgbackup.org/).
<a href="https://github.com/caronc/apprise/wiki"><img src="docs/static/apprise.png" alt="Apprise" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://www.zabbix.com/"><img src="docs/static/zabbix.png" alt="Zabbix" height="40px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://sentry.io/"><img src="docs/static/sentry.png" alt="Sentry" height="40px" style="margin-bottom:20px; margin-right:20px;"></a>
### Credentials
<a href="https://systemd.io/"><img src="docs/static/systemd.png" alt="Sentry" height="40px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://www.docker.com/"><img src="docs/static/docker.png" alt="Docker" height="40px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://podman.io/"><img src="docs/static/podman.png" alt="Podman" height="40px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://keepassxc.org/"><img src="docs/static/keepassxc.png" alt="Podman" height="40px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://www.borgbase.com/?utm_source=borgmatic"><img src="docs/static/borgbase.png" alt="BorgBase" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
## Getting started

View file

@ -22,7 +22,9 @@ def run_borg(
if borg_arguments.repository is None or borgmatic.config.validate.repositories_match(
repository, borg_arguments.repository
):
logger.info('Running arbitrary Borg command')
logger.info(
f'{repository.get("label", repository["path"])}: Running arbitrary Borg command'
)
archive_name = borgmatic.borg.repo_list.resolve_archive_name(
repository['path'],
borg_arguments.archive,

View file

@ -21,7 +21,9 @@ def run_break_lock(
if break_lock_arguments.repository is None or borgmatic.config.validate.repositories_match(
repository, break_lock_arguments.repository
):
logger.info('Breaking repository and cache locks')
logger.info(
f'{repository.get("label", repository["path"])}: Breaking repository and cache locks'
)
borgmatic.borg.break_lock.break_lock(
repository['path'],
config,

View file

@ -16,7 +16,7 @@ def run_change_passphrase(
remote_path,
):
'''
Run the "key change-passphrase" action for the given repository.
Run the "key change-passprhase" action for the given repository.
'''
if (
change_passphrase_arguments.repository is None
@ -24,7 +24,9 @@ def run_change_passphrase(
repository, change_passphrase_arguments.repository
)
):
logger.info('Changing repository passphrase')
logger.info(
f'{repository.get("label", repository["path"])}: Changing repository passphrase'
)
borgmatic.borg.change_passphrase.change_passphrase(
repository['path'],
config,

View file

@ -363,6 +363,7 @@ def collect_spot_check_source_paths(
borgmatic.hooks.dispatch.call_hooks(
'use_streaming',
config,
repository['path'],
borgmatic.hooks.dispatch.Hook_type.DATA_SOURCE,
).values()
)
@ -386,12 +387,13 @@ def collect_spot_check_source_paths(
stream_processes=stream_processes,
)
)
borg_environment = borgmatic.borg.environment.make_environment(config)
working_directory = borgmatic.config.paths.get_working_directory(config)
paths_output = borgmatic.execute.execute_command_and_capture_output(
create_flags + create_positional_arguments,
capture_stderr=True,
environment=borgmatic.borg.environment.make_environment(config),
extra_environment=borg_environment,
working_directory=working_directory,
borg_local_path=local_path,
borg_exit_codes=config.get('borg_exit_codes'),
@ -441,7 +443,7 @@ def collect_spot_check_archive_paths(
config,
local_borg_version,
global_arguments,
path_format='{type} {path}{NUL}', # noqa: FS003
path_format='{type} {path}{NL}', # noqa: FS003
local_path=local_path,
remote_path=remote_path,
)
@ -466,14 +468,15 @@ def compare_spot_check_hashes(
global_arguments,
local_path,
remote_path,
log_prefix,
source_paths,
):
'''
Given a repository configuration dict, the name of the latest archive, a configuration dict, the
local Borg version, global arguments as an argparse.Namespace instance, the local Borg path, the
remote Borg path, and spot check source paths, compare the hashes for a sampling of the source
paths with hashes from corresponding paths in the given archive. Return a sequence of the paths
that fail that hash comparison.
remote Borg path, a log label, and spot check source paths, compare the hashes for a sampling of
the source paths with hashes from corresponding paths in the given archive. Return a sequence of
the paths that fail that hash comparison.
'''
# Based on the configured sample percentage, come up with a list of random sample files from the
# source directories.
@ -489,7 +492,7 @@ def compare_spot_check_hashes(
if os.path.exists(os.path.join(working_directory or '', source_path))
}
logger.debug(
f'Sampling {sample_count} source paths (~{spot_check_config["data_sample_percentage"]}%) for spot check'
f'{log_prefix}: Sampling {sample_count} source paths (~{spot_check_config["data_sample_percentage"]}%) for spot check'
)
source_sample_paths_iterator = iter(source_sample_paths)
@ -537,7 +540,7 @@ def compare_spot_check_hashes(
local_borg_version,
global_arguments,
list_paths=source_sample_paths_subset,
path_format='{xxh64} {path}{NUL}', # noqa: FS003
path_format='{xxh64} {path}{NL}', # noqa: FS003
local_path=local_path,
remote_path=remote_path,
)
@ -577,7 +580,8 @@ def spot_check(
disk to those stored in the latest archive. If any differences are beyond configured tolerances,
then the check fails.
'''
logger.debug('Running spot check')
log_prefix = f'{repository.get("label", repository["path"])}'
logger.debug(f'{log_prefix}: Running spot check')
try:
spot_check_config = next(
@ -600,7 +604,7 @@ def spot_check(
remote_path,
borgmatic_runtime_directory,
)
logger.debug(f'{len(source_paths)} total source paths for spot check')
logger.debug(f'{log_prefix}: {len(source_paths)} total source paths for spot check')
archive = borgmatic.borg.repo_list.resolve_archive_name(
repository['path'],
@ -611,7 +615,7 @@ def spot_check(
local_path,
remote_path,
)
logger.debug(f'Using archive {archive} for spot check')
logger.debug(f'{log_prefix}: Using archive {archive} for spot check')
archive_paths = collect_spot_check_archive_paths(
repository,
@ -623,11 +627,11 @@ def spot_check(
remote_path,
borgmatic_runtime_directory,
)
logger.debug(f'{len(archive_paths)} total archive paths for spot check')
logger.debug(f'{log_prefix}: {len(archive_paths)} total archive paths for spot check')
if len(source_paths) == 0:
logger.debug(
f'Paths in latest archive but not source paths: {", ".join(set(archive_paths)) or "none"}'
f'{log_prefix}: Paths in latest archive but not source paths: {", ".join(set(archive_paths)) or "none"}'
)
raise ValueError(
'Spot check failed: There are no source paths to compare against the archive'
@ -640,10 +644,10 @@ def spot_check(
if count_delta_percentage > spot_check_config['count_tolerance_percentage']:
rootless_source_paths = set(path.lstrip(os.path.sep) for path in source_paths)
logger.debug(
f'Paths in source paths but not latest archive: {", ".join(rootless_source_paths - set(archive_paths)) or "none"}'
f'{log_prefix}: Paths in source paths but not latest archive: {", ".join(rootless_source_paths - set(archive_paths)) or "none"}'
)
logger.debug(
f'Paths in latest archive but not source paths: {", ".join(set(archive_paths) - rootless_source_paths) or "none"}'
f'{log_prefix}: Paths in latest archive but not source paths: {", ".join(set(archive_paths) - rootless_source_paths) or "none"}'
)
raise ValueError(
f'Spot check failed: {count_delta_percentage:.2f}% file count delta between source paths and latest archive (tolerance is {spot_check_config["count_tolerance_percentage"]}%)'
@ -657,24 +661,25 @@ def spot_check(
global_arguments,
local_path,
remote_path,
log_prefix,
source_paths,
)
# Error if the percentage of failing hashes exceeds the configured tolerance percentage.
logger.debug(f'{len(failing_paths)} non-matching spot check hashes')
logger.debug(f'{log_prefix}: {len(failing_paths)} non-matching spot check hashes')
data_tolerance_percentage = spot_check_config['data_tolerance_percentage']
failing_percentage = (len(failing_paths) / len(source_paths)) * 100
if failing_percentage > data_tolerance_percentage:
logger.debug(
f'Source paths with data not matching the latest archive: {", ".join(failing_paths)}'
f'{log_prefix}: Source paths with data not matching the latest archive: {", ".join(failing_paths)}'
)
raise ValueError(
f'Spot check failed: {failing_percentage:.2f}% of source paths with data not matching the latest archive (tolerance is {data_tolerance_percentage}%)'
)
logger.info(
f'Spot check passed with a {count_delta_percentage:.2f}% file count delta and a {failing_percentage:.2f}% file data delta'
f'{log_prefix}: Spot check passed with a {count_delta_percentage:.2f}% file count delta and a {failing_percentage:.2f}% file data delta'
)
@ -708,7 +713,8 @@ def run_check(
**hook_context,
)
logger.info('Running consistency checks')
log_prefix = repository.get('label', repository['path'])
logger.info(f'{log_prefix}: Running consistency checks')
repository_id = borgmatic.borg.check.get_repository_id(
repository['path'],
@ -761,7 +767,9 @@ def run_check(
write_check_time(make_check_time_path(config, repository_id, 'extract'))
if 'spot' in checks:
with borgmatic.config.paths.Runtime_directory(config) as borgmatic_runtime_directory:
with borgmatic.config.paths.Runtime_directory(
config, log_prefix
) as borgmatic_runtime_directory:
spot_check(
repository,
config,

View file

@ -37,7 +37,9 @@ def run_compact(
**hook_context,
)
if borgmatic.borg.feature.available(borgmatic.borg.feature.Feature.COMPACT, local_borg_version):
logger.info(f'Compacting segments{dry_run_label}')
logger.info(
f'{repository.get("label", repository["path"])}: Compacting segments{dry_run_label}'
)
borgmatic.borg.compact.compact_segments(
global_arguments.dry_run,
repository['path'],
@ -51,8 +53,9 @@ def run_compact(
threshold=compact_arguments.threshold,
)
else: # pragma: nocover
logger.info('Skipping compact (only available/needed in Borg 1.2+)')
logger.info(
f'{repository.get("label", repository["path"])}: Skipping compact (only available/needed in Borg 1.2+)'
)
borgmatic.hooks.command.execute_hook(
config.get('after_compact'),
config.get('umask'),

View file

@ -45,6 +45,7 @@ def get_config_paths(archive_name, bootstrap_arguments, global_arguments, local_
# still want to support reading the manifest from previously created archives as well.
with borgmatic.config.paths.Runtime_directory(
{'user_runtime_directory': bootstrap_arguments.user_runtime_directory},
bootstrap_arguments.repository,
) as borgmatic_runtime_directory:
for base_directory in (
'borgmatic',

View file

@ -36,7 +36,6 @@ def parse_pattern(pattern_line, default_style=borgmatic.borg.pattern.Pattern_sty
path,
borgmatic.borg.pattern.Pattern_type(pattern_type),
borgmatic.borg.pattern.Pattern_style(pattern_style),
source=borgmatic.borg.pattern.Pattern_source.CONFIG,
)
@ -52,9 +51,7 @@ def collect_patterns(config):
try:
return (
tuple(
borgmatic.borg.pattern.Pattern(
source_directory, source=borgmatic.borg.pattern.Pattern_source.CONFIG
)
borgmatic.borg.pattern.Pattern(source_directory)
for source_directory in config.get('source_directories', ())
)
+ tuple(
@ -65,7 +62,7 @@ def collect_patterns(config):
)
+ tuple(
parse_pattern(
f'{borgmatic.borg.pattern.Pattern_type.NO_RECURSE.value} {exclude_line.strip()}',
f'{borgmatic.borg.pattern.Pattern_type.EXCLUDE.value} {exclude_line.strip()}',
borgmatic.borg.pattern.Pattern_style.FNMATCH,
)
for exclude_line in config.get('exclude_patterns', ())
@ -79,7 +76,7 @@ def collect_patterns(config):
)
+ tuple(
parse_pattern(
f'{borgmatic.borg.pattern.Pattern_type.NO_RECURSE.value} {exclude_line.strip()}',
f'{borgmatic.borg.pattern.Pattern_type.EXCLUDE.value} {exclude_line.strip()}',
borgmatic.borg.pattern.Pattern_style.FNMATCH,
)
for filename in config.get('exclude_from', ())
@ -147,7 +144,6 @@ def expand_patterns(patterns, working_directory=None, skip_paths=None):
pattern.type,
pattern.style,
pattern.device,
pattern.source,
)
for expanded_path in expand_directory(pattern.path, working_directory)
)
@ -182,7 +178,6 @@ def device_map_patterns(patterns, working_directory=None):
and os.path.exists(full_path)
else None
),
source=pattern.source,
)
for pattern in patterns
for full_path in (os.path.join(working_directory or '', pattern.path),)
@ -288,13 +283,17 @@ def run_create(
**hook_context,
)
logger.info(f'Creating archive{dry_run_label}')
log_prefix = repository.get('label', repository['path'])
logger.info(f'{log_prefix}: Creating archive{dry_run_label}')
working_directory = borgmatic.config.paths.get_working_directory(config)
with borgmatic.config.paths.Runtime_directory(config) as borgmatic_runtime_directory:
with borgmatic.config.paths.Runtime_directory(
config, log_prefix
) as borgmatic_runtime_directory:
borgmatic.hooks.dispatch.call_hooks_even_if_unconfigured(
'remove_data_source_dumps',
config,
repository['path'],
borgmatic.hooks.dispatch.Hook_type.DATA_SOURCE,
borgmatic_runtime_directory,
global_arguments.dry_run,
@ -303,6 +302,7 @@ def run_create(
active_dumps = borgmatic.hooks.dispatch.call_hooks(
'dump_data_sources',
config,
repository['path'],
borgmatic.hooks.dispatch.Hook_type.DATA_SOURCE,
config_paths,
borgmatic_runtime_directory,
@ -339,6 +339,7 @@ def run_create(
borgmatic.hooks.dispatch.call_hooks_even_if_unconfigured(
'remove_data_source_dumps',
config,
config_filename,
borgmatic.hooks.dispatch.Hook_type.DATA_SOURCE,
borgmatic_runtime_directory,
global_arguments.dry_run,

View file

@ -23,7 +23,7 @@ def run_delete(
if delete_arguments.repository is None or borgmatic.config.validate.repositories_match(
repository, delete_arguments.repository
):
logger.answer('Deleting archives')
logger.answer(f'{repository.get("label", repository["path"])}: Deleting archives')
archive_name = (
borgmatic.borg.repo_list.resolve_archive_name(

View file

@ -21,7 +21,7 @@ def run_export_key(
if export_arguments.repository is None or borgmatic.config.validate.repositories_match(
repository, export_arguments.repository
):
logger.info('Exporting repository key')
logger.info(f'{repository.get("label", repository["path"])}: Exporting repository key')
borgmatic.borg.export_key.export_key(
repository['path'],
config,

View file

@ -22,7 +22,9 @@ def run_export_tar(
if export_tar_arguments.repository is None or borgmatic.config.validate.repositories_match(
repository, export_tar_arguments.repository
):
logger.info(f'Exporting archive {export_tar_arguments.archive} as tar file')
logger.info(
f'{repository["path"]}: Exporting archive {export_tar_arguments.archive} as tar file'
)
borgmatic.borg.export_tar.export_tar_archive(
global_arguments.dry_run,
repository['path'],

View file

@ -33,7 +33,9 @@ def run_extract(
if extract_arguments.repository is None or borgmatic.config.validate.repositories_match(
repository, extract_arguments.repository
):
logger.info(f'Extracting archive {extract_arguments.archive}')
logger.info(
f'{repository.get("label", repository["path"])}: Extracting archive {extract_arguments.archive}'
)
borgmatic.borg.extract.extract_archive(
global_arguments.dry_run,
repository['path'],

View file

@ -27,7 +27,9 @@ def run_info(
repository, info_arguments.repository
):
if not info_arguments.json:
logger.answer('Displaying archive summary information')
logger.answer(
f'{repository.get("label", repository["path"])}: Displaying archive summary information'
)
archive_name = borgmatic.borg.repo_list.resolve_archive_name(
repository['path'],
info_arguments.archive,

View file

@ -27,9 +27,9 @@ def run_list(
):
if not list_arguments.json:
if list_arguments.find_paths: # pragma: no cover
logger.answer('Searching archives')
logger.answer(f'{repository.get("label", repository["path"])}: Searching archives')
elif not list_arguments.archive: # pragma: no cover
logger.answer('Listing archives')
logger.answer(f'{repository.get("label", repository["path"])}: Listing archives')
archive_name = borgmatic.borg.repo_list.resolve_archive_name(
repository['path'],

View file

@ -23,9 +23,11 @@ def run_mount(
repository, mount_arguments.repository
):
if mount_arguments.archive:
logger.info(f'Mounting archive {mount_arguments.archive}')
logger.info(
f'{repository.get("label", repository["path"])}: Mounting archive {mount_arguments.archive}'
)
else: # pragma: nocover
logger.info('Mounting repository')
logger.info(f'{repository.get("label", repository["path"])}: Mounting repository')
borgmatic.borg.mount.mount_archive(
repository['path'],

View file

@ -35,7 +35,7 @@ def run_prune(
global_arguments.dry_run,
**hook_context,
)
logger.info(f'Pruning archives{dry_run_label}')
logger.info(f'{repository.get("label", repository["path"])}: Pruning archives{dry_run_label}')
borgmatic.borg.prune.prune_archives(
global_arguments.dry_run,
repository['path'],

View file

@ -23,7 +23,7 @@ def run_repo_create(
):
return
logger.info('Creating repository')
logger.info(f'{repository.get("label", repository["path"])}: Creating repository')
borgmatic.borg.repo_create.create_repository(
global_arguments.dry_run,
repository['path'],

View file

@ -21,7 +21,8 @@ def run_repo_delete(
repository, repo_delete_arguments.repository
):
logger.answer(
'Deleting repository' + (' cache' if repo_delete_arguments.cache_only else '')
f'{repository.get("label", repository["path"])}: Deleting repository'
+ (' cache' if repo_delete_arguments.cache_only else '')
)
borgmatic.borg.repo_delete.delete_repository(

View file

@ -25,7 +25,9 @@ def run_repo_info(
repository, repo_info_arguments.repository
):
if not repo_info_arguments.json:
logger.answer('Displaying repository summary information')
logger.answer(
f'{repository.get("label", repository["path"])}: Displaying repository summary information'
)
json_output = borgmatic.borg.repo_info.display_repository_info(
repository['path'],

View file

@ -25,7 +25,7 @@ def run_repo_list(
repository, repo_list_arguments.repository
):
if not repo_list_arguments.json:
logger.answer('Listing repository')
logger.answer(f'{repository.get("label", repository["path"])}: Listing repository')
json_output = borgmatic.borg.repo_list.list_repository(
repository['path'],

View file

@ -57,7 +57,7 @@ def render_dump_metadata(dump):
Given a Dump instance, make a display string describing it for use in log messages.
'''
name = 'unspecified' if dump.data_source_name is UNSPECIFIED else dump.data_source_name
hostname = dump.hostname or UNSPECIFIED
hostname = dump.hostname or 'localhost'
port = None if dump.port is UNSPECIFIED else dump.port
if port:
@ -71,10 +71,10 @@ def render_dump_metadata(dump):
return metadata
def get_configured_data_source(config, restore_dump):
def get_configured_data_source(config, restore_dump, log_prefix):
'''
Search in the given configuration dict for dumps corresponding to the given dump to restore. If
there are multiple matches, error.
there are multiple matches, error. Log using the given log prefix.
Return the found data source as a data source configuration dict or None if not found.
'''
@ -91,6 +91,7 @@ def get_configured_data_source(config, restore_dump):
borgmatic.hooks.dispatch.call_hook(
function_name='get_default_port',
config=config,
log_prefix=log_prefix,
hook_name=hook_name,
),
)
@ -172,11 +173,14 @@ def restore_single_dump(
Dump(hook_name, data_source['name'], data_source.get('hostname'), data_source.get('port'))
)
logger.info(f'Restoring data source {dump_metadata}')
logger.info(
f'{repository.get("label", repository["path"])}: Restoring data source {dump_metadata}'
)
dump_patterns = borgmatic.hooks.dispatch.call_hooks(
'make_data_source_dump_patterns',
config,
repository['path'],
borgmatic.hooks.dispatch.Hook_type.DATA_SOURCE,
borgmatic_runtime_directory,
data_source['name'],
@ -223,6 +227,7 @@ def restore_single_dump(
borgmatic.hooks.dispatch.call_hook(
function_name='restore_data_source_dump',
config=config,
log_prefix=repository['path'],
hook_name=hook_name,
data_source=data_source,
dry_run=global_arguments.dry_run,
@ -314,7 +319,7 @@ def collect_dumps_from_archive(
break
else:
logger.warning(
f'Ignoring invalid data source dump path "{dump_path}" in archive {archive}'
f'{repository}: Ignoring invalid data source dump path "{dump_path}" in archive {archive}'
)
return dumps_from_archive
@ -343,15 +348,12 @@ def get_dumps_to_restore(restore_arguments, dumps_from_archive):
else UNSPECIFIED
),
data_source_name=name,
hostname=restore_arguments.original_hostname or UNSPECIFIED,
hostname=restore_arguments.original_hostname or 'localhost',
port=restore_arguments.original_port,
)
for name in restore_arguments.data_sources or (UNSPECIFIED,)
for name in restore_arguments.data_sources
}
if restore_arguments.hook
or restore_arguments.data_sources
or restore_arguments.original_hostname
or restore_arguments.original_port
if restore_arguments.data_sources
else {
Dump(
hook_name=UNSPECIFIED,
@ -442,12 +444,16 @@ def run_restore(
):
return
logger.info(f'Restoring data sources from archive {restore_arguments.archive}')
log_prefix = repository.get('label', repository['path'])
logger.info(f'{log_prefix}: Restoring data sources from archive {restore_arguments.archive}')
with borgmatic.config.paths.Runtime_directory(config) as borgmatic_runtime_directory:
with borgmatic.config.paths.Runtime_directory(
config, log_prefix
) as borgmatic_runtime_directory:
borgmatic.hooks.dispatch.call_hooks_even_if_unconfigured(
'remove_data_source_dumps',
config,
repository['path'],
borgmatic.hooks.dispatch.Hook_type.DATA_SOURCE,
borgmatic_runtime_directory,
global_arguments.dry_run,
@ -488,6 +494,7 @@ def run_restore(
found_data_source = get_configured_data_source(
config,
restore_dump,
log_prefix=repository['path'],
)
# For a dump that wasn't found via an exact match in the configuration, try to fallback
@ -496,6 +503,7 @@ def run_restore(
found_data_source = get_configured_data_source(
config,
Dump(restore_dump.hook_name, 'all', restore_dump.hostname, restore_dump.port),
log_prefix=repository['path'],
)
if not found_data_source:
@ -523,6 +531,7 @@ def run_restore(
borgmatic.hooks.dispatch.call_hooks_even_if_unconfigured(
'remove_data_source_dumps',
config,
repository['path'],
borgmatic.hooks.dispatch.Hook_type.DATA_SOURCE,
borgmatic_runtime_directory,
global_arguments.dry_run,

View file

@ -17,7 +17,9 @@ def run_transfer(
'''
Run the "transfer" action for the given repository.
'''
logger.info('Transferring archives to repository')
logger.info(
f'{repository.get("label", repository["path"])}: Transferring archives to repository'
)
borgmatic.borg.transfer.transfer_archives(
global_arguments.dry_run,
repository['path'],

View file

@ -61,7 +61,7 @@ def run_arbitrary_borg(
tuple(shlex.quote(part) for part in full_command),
output_file=DO_NOT_CAPTURE,
shell=True,
environment=dict(
extra_environment=dict(
(environment.make_environment(config) or {}),
**{
'BORG_REPO': repository_path,

View file

@ -34,9 +34,10 @@ def break_lock(
+ flags.make_repository_flags(repository_path, local_borg_version)
)
borg_environment = environment.make_environment(config)
execute_command(
full_command,
environment=environment.make_environment(config),
extra_environment=borg_environment,
working_directory=borgmatic.config.paths.get_working_directory(config),
borg_local_path=local_path,
borg_exit_codes=config.get('borg_exit_codes'),

View file

@ -41,7 +41,7 @@ def change_passphrase(
)
if global_arguments.dry_run:
logger.info('Skipping change password (dry run)')
logger.info(f'{repository_path}: Skipping change password (dry run)')
return
# If the original passphrase is set programmatically, then Borg won't prompt for a new one! So
@ -56,7 +56,7 @@ def change_passphrase(
full_command,
output_file=borgmatic.execute.DO_NOT_CAPTURE,
output_log_level=logging.ANSWER,
environment=environment.make_environment(config_without_passphrase),
extra_environment=environment.make_environment(config_without_passphrase),
working_directory=borgmatic.config.paths.get_working_directory(config),
borg_local_path=local_path,
borg_exit_codes=config.get('borg_exit_codes'),

View file

@ -64,11 +64,15 @@ def make_check_name_flags(checks, archive_filter_flags):
('--repository-only',)
However, if both "repository" and "archives" are in checks, then omit the "only" flags from the
returned flags because Borg does both checks by default. Note that a "data" check only works
along with an "archives" check.
However, if both "repository" and "archives" are in checks, then omit them from the returned
flags because Borg does both checks by default. If "data" is in checks, that implies "archives".
'''
data_flags = ('--verify-data',) if 'data' in checks else ()
if 'data' in checks:
data_flags = ('--verify-data',)
checks.update({'archives'})
else:
data_flags = ()
common_flags = (archive_filter_flags if 'archives' in checks else ()) + data_flags
if {'repository', 'archives'}.issubset(checks):
@ -138,51 +142,51 @@ def check_archives(
except StopIteration:
repository_check_config = {}
max_duration = check_arguments.max_duration or repository_check_config.get('max_duration')
umask = config.get('umask')
borg_exit_codes = config.get('borg_exit_codes')
working_directory = borgmatic.config.paths.get_working_directory(config)
if 'data' in checks:
checks.add('archives')
grouped_checks = (checks,)
# If max_duration is set, then archives and repository checks need to be run separately, as Borg
# doesn't support --max-duration along with an archives checks.
if max_duration and 'archives' in checks and 'repository' in checks:
checks.remove('repository')
grouped_checks = (checks, {'repository'})
for checks_subset in grouped_checks:
full_command = (
(local_path, 'check')
+ (('--repair',) if check_arguments.repair else ())
+ (
('--max-duration', str(max_duration))
if max_duration and 'repository' in checks_subset
else ()
)
+ make_check_name_flags(checks_subset, archive_filter_flags)
+ (('--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 ())
+ verbosity_flags
+ (('--progress',) if check_arguments.progress else ())
+ (tuple(extra_borg_options.split(' ')) if extra_borg_options else ())
+ flags.make_repository_flags(repository_path, local_borg_version)
if check_arguments.max_duration and 'archives' in checks:
raise ValueError('The archives check cannot run when the --max-duration flag is used')
if repository_check_config.get('max_duration') and 'archives' in checks:
raise ValueError(
'The archives check cannot run when the repository check has the max_duration option set'
)
max_duration = check_arguments.max_duration or repository_check_config.get('max_duration')
umask = config.get('umask')
borg_environment = environment.make_environment(config)
borg_exit_codes = config.get('borg_exit_codes')
full_command = (
(local_path, 'check')
+ (('--repair',) if check_arguments.repair else ())
+ (('--max-duration', str(max_duration)) if max_duration else ())
+ make_check_name_flags(checks, archive_filter_flags)
+ (('--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 ())
+ verbosity_flags
+ (('--progress',) if check_arguments.progress else ())
+ (tuple(extra_borg_options.split(' ')) if extra_borg_options else ())
+ flags.make_repository_flags(repository_path, local_borg_version)
)
working_directory = borgmatic.config.paths.get_working_directory(config)
# The Borg repair option triggers an interactive prompt, which won't work when output is
# captured. And progress messes with the terminal directly.
if check_arguments.repair or check_arguments.progress:
execute_command(
full_command,
# The Borg repair option triggers an interactive prompt, which won't work when output is
# captured. And progress messes with the terminal directly.
output_file=(
DO_NOT_CAPTURE if check_arguments.repair or check_arguments.progress else None
),
environment=environment.make_environment(config),
output_file=DO_NOT_CAPTURE,
extra_environment=borg_environment,
working_directory=working_directory,
borg_local_path=local_path,
borg_exit_codes=borg_exit_codes,
)
else:
execute_command(
full_command,
extra_environment=borg_environment,
working_directory=working_directory,
borg_local_path=local_path,
borg_exit_codes=borg_exit_codes,

View file

@ -43,13 +43,13 @@ def compact_segments(
)
if dry_run:
logging.info('Skipping compact (dry run)')
logging.info(f'{repository_path}: Skipping compact (dry run)')
return
execute_command(
full_command,
output_log_level=logging.INFO,
environment=environment.make_environment(config),
extra_environment=environment.make_environment(config),
working_directory=borgmatic.config.paths.get_working_directory(config),
borg_local_path=local_path,
borg_exit_codes=config.get('borg_exit_codes'),

View file

@ -20,12 +20,14 @@ from borgmatic.execute import (
logger = logging.getLogger(__name__)
def write_patterns_file(patterns, borgmatic_runtime_directory, patterns_file=None):
def write_patterns_file(patterns, borgmatic_runtime_directory, log_prefix, patterns_file=None):
'''
Given a sequence of patterns as borgmatic.borg.pattern.Pattern instances, write them to a named
temporary file in the given borgmatic runtime directory and return the file object so it can
continue to exist on disk as long as the caller needs it.
Use the given log prefix in any logging.
If an optional open pattern file is given, append to it instead of making a new temporary file.
Return None if no patterns are provided.
'''
@ -34,16 +36,14 @@ def write_patterns_file(patterns, borgmatic_runtime_directory, patterns_file=Non
if patterns_file is None:
patterns_file = tempfile.NamedTemporaryFile('w', dir=borgmatic_runtime_directory)
operation_name = 'Writing'
else:
patterns_file.write('\n')
operation_name = 'Appending'
patterns_output = '\n'.join(
f'{pattern.type.value} {pattern.style.value}{":" if pattern.style.value else ""}{pattern.path}'
for pattern in patterns
)
logger.debug(f'{operation_name} patterns to {patterns_file.name}:\n{patterns_output}')
logger.debug(f'{log_prefix}: Writing patterns to {patterns_file.name}:\n{patterns_output}')
patterns_file.write(patterns_output)
patterns_file.flush()
@ -122,63 +122,52 @@ def collect_special_file_paths(
config,
local_path,
working_directory,
borg_environment,
borgmatic_runtime_directory,
):
'''
Given a dry-run flag, a Borg create command as a tuple, a configuration dict, a local Borg path,
a working directory, and the borgmatic runtime directory, collect the paths for any special
files (character devices, block devices, and named pipes / FIFOs) that Borg would encounter
during a create. These are all paths that could cause Borg to hang if its --read-special flag is
used.
a working directory, a dict of environment variables to pass to Borg, and the borgmatic runtime
directory, collect the paths for any special files (character devices, block devices, and named
pipes / FIFOs) that Borg would encounter during a create. These are all paths that could cause
Borg to hang if its --read-special flag is used.
Skip looking for special files in the given borgmatic runtime directory, as borgmatic creates
its own special files there for database dumps and we don't want those omitted.
Additionally, if the borgmatic runtime directory is not contained somewhere in the files Borg
plans to backup, that means the user must have excluded the runtime directory (e.g. via
"exclude_patterns" or similar). Therefore, raise, because this means Borg won't be able to
consume any database dumps and therefore borgmatic will hang when it tries to do so.
its own special files there for database dumps. And if the borgmatic runtime directory is
configured to be excluded from the files Borg backs up, error, because this means Borg won't be
able to consume any database dumps and therefore borgmatic will hang.
'''
# Omit "--exclude-nodump" from the Borg dry run command, because that flag causes Borg to open
# files including any named pipe we've created. And omit "--filter" because that can break the
# paths output parsing below such that path lines no longer start with th expected "- ".
# files including any named pipe we've created.
paths_output = execute_command_and_capture_output(
flags.omit_flag_and_value(flags.omit_flag(create_command, '--exclude-nodump'), '--filter')
tuple(argument for argument in create_command if argument != '--exclude-nodump')
+ ('--dry-run', '--list'),
capture_stderr=True,
working_directory=working_directory,
environment=environment.make_environment(config),
extra_environment=borg_environment,
borg_local_path=local_path,
borg_exit_codes=config.get('borg_exit_codes'),
)
# These are all the individual files that Borg is planning to backup as determined by the Borg
# create dry run above.
paths = tuple(
path_line.split(' ', 1)[1]
for path_line in paths_output.split('\n')
if path_line and path_line.startswith('- ') or path_line.startswith('+ ')
)
# These are the subset of those files that contain the borgmatic runtime directory.
paths_containing_runtime_directory = {}
skip_paths = {}
if os.path.exists(borgmatic_runtime_directory):
paths_containing_runtime_directory = {
skip_paths = {
path for path in paths if any_parent_directories(path, (borgmatic_runtime_directory,))
}
# If no paths to backup contain the runtime directory, it must've been excluded.
if not paths_containing_runtime_directory and not dry_run:
if not skip_paths and not dry_run:
raise ValueError(
f'The runtime directory {os.path.normpath(borgmatic_runtime_directory)} overlaps with the configured excludes or patterns with excludes. Please ensure the runtime directory is not excluded.'
)
return tuple(
path
for path in paths
if special_file(path, working_directory)
if path not in paths_containing_runtime_directory
path for path in paths if special_file(path, working_directory) if path not in skip_paths
)
@ -228,7 +217,9 @@ def make_base_create_command(
if config.get('source_directories_must_exist', False):
check_all_root_patterns_exist(patterns)
patterns_file = write_patterns_file(patterns, borgmatic_runtime_directory)
patterns_file = write_patterns_file(
patterns, borgmatic_runtime_directory, log_prefix=repository_path
)
checkpoint_interval = config.get('checkpoint_interval', None)
checkpoint_volume = config.get('checkpoint_volume', None)
chunker_params = config.get('chunker_params', None)
@ -308,17 +299,19 @@ def make_base_create_command(
# cause Borg to hang. But skip this if the user has explicitly set the "read_special" to True.
if stream_processes and not config.get('read_special'):
logger.warning(
'Ignoring configured "read_special" value of false, as true is needed for database hooks.'
f'{repository_path}: Ignoring configured "read_special" value of false, as true is needed for database hooks.'
)
borg_environment = environment.make_environment(config)
working_directory = borgmatic.config.paths.get_working_directory(config)
logger.debug('Collecting special file paths')
logger.debug(f'{repository_path}: Collecting special file paths')
special_file_paths = collect_special_file_paths(
dry_run,
create_flags + create_positional_arguments,
config,
local_path,
working_directory,
borg_environment,
borgmatic_runtime_directory=borgmatic_runtime_directory,
)
@ -329,19 +322,19 @@ def make_base_create_command(
placeholder=' ...',
)
logger.warning(
f'Excluding special files to prevent Borg from hanging: {truncated_special_file_paths}'
f'{repository_path}: Excluding special files to prevent Borg from hanging: {truncated_special_file_paths}'
)
patterns_file = write_patterns_file(
tuple(
borgmatic.borg.pattern.Pattern(
special_file_path,
borgmatic.borg.pattern.Pattern_type.NO_RECURSE,
borgmatic.borg.pattern.Pattern_type.EXCLUDE,
borgmatic.borg.pattern.Pattern_style.FNMATCH,
source=borgmatic.borg.pattern.Pattern_source.INTERNAL,
)
for special_file_path in special_file_paths
),
borgmatic_runtime_directory,
log_prefix=repository_path,
patterns_file=patterns_file,
)
@ -406,6 +399,8 @@ def create_archive(
# the terminal directly.
output_file = DO_NOT_CAPTURE if progress else None
borg_environment = environment.make_environment(config)
create_flags += (
(('--info',) if logger.getEffectiveLevel() == logging.INFO and not json else ())
+ (('--stats',) if stats and not json and not dry_run else ())
@ -422,7 +417,7 @@ def create_archive(
output_log_level,
output_file,
working_directory=working_directory,
environment=environment.make_environment(config),
extra_environment=borg_environment,
borg_local_path=local_path,
borg_exit_codes=borg_exit_codes,
)
@ -430,7 +425,7 @@ def create_archive(
return execute_command_and_capture_output(
create_flags + create_positional_arguments,
working_directory=working_directory,
environment=environment.make_environment(config),
extra_environment=borg_environment,
borg_local_path=local_path,
borg_exit_codes=borg_exit_codes,
)
@ -440,7 +435,7 @@ def create_archive(
output_log_level,
output_file,
working_directory=working_directory,
environment=environment.make_environment(config),
extra_environment=borg_environment,
borg_local_path=local_path,
borg_exit_codes=borg_exit_codes,
)

View file

@ -128,7 +128,7 @@ def delete_archives(
borgmatic.execute.execute_command(
command,
output_log_level=logging.ANSWER,
environment=borgmatic.borg.environment.make_environment(config),
extra_environment=borgmatic.borg.environment.make_environment(config),
working_directory=borgmatic.config.paths.get_working_directory(config),
borg_local_path=local_path,
borg_exit_codes=config.get('borg_exit_codes'),

View file

@ -1,8 +1,5 @@
import os
import borgmatic.borg.passcommand
import borgmatic.hooks.credential.parse
OPTION_TO_ENVIRONMENT_VARIABLE = {
'borg_base_directory': 'BORG_BASE_DIR',
'borg_config_directory': 'BORG_CONFIG_DIR',
@ -10,6 +7,8 @@ OPTION_TO_ENVIRONMENT_VARIABLE = {
'borg_files_cache_ttl': 'BORG_FILES_CACHE_TTL',
'borg_security_directory': 'BORG_SECURITY_DIR',
'borg_keys_directory': 'BORG_KEYS_DIR',
'encryption_passcommand': 'BORG_PASSCOMMAND',
'encryption_passphrase': 'BORG_PASSPHRASE',
'ssh_command': 'BORG_RSH',
'temporary_directory': 'TMPDIR',
}
@ -26,59 +25,17 @@ DEFAULT_BOOL_OPTION_TO_UPPERCASE_ENVIRONMENT_VARIABLE = {
def make_environment(config):
'''
Given a borgmatic configuration dict, convert it to a Borg environment variable dict, merge it
with a copy of the current environment variables, and return the result.
Do not reuse this environment across multiple Borg invocations, because it can include
references to resources like anonymous pipes for passphrases—which can only be consumed once.
Here's how native Borg precedence works for a few of the environment variables:
1. BORG_PASSPHRASE, if set, is used first.
2. BORG_PASSCOMMAND is used only if BORG_PASSPHRASE isn't set.
3. BORG_PASSPHRASE_FD is used only if neither of the above are set.
In borgmatic, we want to simulate this precedence order, but there are some additional
complications. First, values can come from either configuration or from environment variables
set outside borgmatic; configured options should take precedence. Second, when borgmatic gets a
passphrase—directly from configuration or indirectly via a credential hook or a passcommand—we
want to pass that passphrase to Borg via an anonymous pipe (+ BORG_PASSPHRASE_FD), since that's
more secure than using an environment variable (BORG_PASSPHRASE).
Given a borgmatic configuration dict, return its options converted to a Borg environment
variable dict.
'''
environment = dict(os.environ)
environment = {}
for option_name, environment_variable_name in OPTION_TO_ENVIRONMENT_VARIABLE.items():
value = config.get(option_name)
if value is not None:
if value:
environment[environment_variable_name] = str(value)
if 'encryption_passphrase' in config:
environment.pop('BORG_PASSPHRASE', None)
environment.pop('BORG_PASSCOMMAND', None)
if 'encryption_passcommand' in config:
environment.pop('BORG_PASSCOMMAND', None)
passphrase = borgmatic.hooks.credential.parse.resolve_credential(
config.get('encryption_passphrase'), config
)
if passphrase is None:
passphrase = borgmatic.borg.passcommand.get_passphrase_from_passcommand(config)
# If there's a passphrase (from configuration, from a configured credential, or from a
# configured passcommand), send it to Borg via an anonymous pipe.
if passphrase is not None:
read_file_descriptor, write_file_descriptor = os.pipe()
os.write(write_file_descriptor, passphrase.encode('utf-8'))
os.close(write_file_descriptor)
# This, plus subprocess.Popen(..., close_fds=False) in execute.py, is necessary for the Borg
# child process to inherit the file descriptor.
os.set_inheritable(read_file_descriptor, True)
environment['BORG_PASSPHRASE_FD'] = str(read_file_descriptor)
for (
option_name,
environment_variable_name,

View file

@ -60,14 +60,14 @@ def export_key(
)
if global_arguments.dry_run:
logger.info('Skipping key export (dry run)')
logger.info(f'{repository_path}: Skipping key export (dry run)')
return
execute_command(
full_command,
output_file=output_file,
output_log_level=logging.ANSWER,
environment=environment.make_environment(config),
extra_environment=environment.make_environment(config),
working_directory=working_directory,
borg_local_path=local_path,
borg_exit_codes=config.get('borg_exit_codes'),

View file

@ -63,14 +63,14 @@ def export_tar_archive(
output_log_level = logging.INFO
if dry_run:
logging.info('Skipping export to tar file (dry run)')
logging.info(f'{repository_path}: Skipping export to tar file (dry run)')
return
execute_command(
full_command,
output_file=DO_NOT_CAPTURE if destination_path == '-' else None,
output_log_level=output_log_level,
environment=environment.make_environment(config),
extra_environment=environment.make_environment(config),
working_directory=borgmatic.config.paths.get_working_directory(config),
borg_local_path=local_path,
borg_exit_codes=config.get('borg_exit_codes'),

View file

@ -44,6 +44,7 @@ def extract_last_archive_dry_run(
return
list_flag = ('--list',) if logger.isEnabledFor(logging.DEBUG) else ()
borg_environment = environment.make_environment(config)
full_extract_command = (
(local_path, 'extract', '--dry-run')
+ (('--remote-path', remote_path) if remote_path else ())
@ -58,7 +59,7 @@ def extract_last_archive_dry_run(
execute_command(
full_extract_command,
environment=environment.make_environment(config),
extra_environment=borg_environment,
working_directory=borgmatic.config.paths.get_working_directory(config),
borg_local_path=local_path,
borg_exit_codes=config.get('borg_exit_codes'),
@ -143,6 +144,7 @@ def extract_archive(
+ (tuple(paths) if paths else ())
)
borg_environment = environment.make_environment(config)
borg_exit_codes = config.get('borg_exit_codes')
full_destination_path = (
os.path.join(working_directory or '', destination_path) if destination_path else None
@ -154,7 +156,7 @@ def extract_archive(
return execute_command(
full_command,
output_file=DO_NOT_CAPTURE,
environment=environment.make_environment(config),
extra_environment=borg_environment,
working_directory=full_destination_path,
borg_local_path=local_path,
borg_exit_codes=borg_exit_codes,
@ -166,7 +168,7 @@ def extract_archive(
full_command,
output_file=subprocess.PIPE,
run_to_completion=False,
environment=environment.make_environment(config),
extra_environment=borg_environment,
working_directory=full_destination_path,
borg_local_path=local_path,
borg_exit_codes=borg_exit_codes,
@ -176,7 +178,7 @@ def extract_archive(
# if the restore paths don't exist in the archive.
execute_command(
full_command,
environment=environment.make_environment(config),
extra_environment=borg_environment,
working_directory=full_destination_path,
borg_local_path=local_path,
borg_exit_codes=borg_exit_codes,

View file

@ -156,44 +156,3 @@ def warn_for_aggressive_archive_flags(json_command, json_output):
logger.debug(f'Cannot parse JSON output from archive command: {error}')
except (TypeError, KeyError):
logger.debug('Cannot parse JSON output from archive command: No "archives" key found')
def omit_flag(arguments, flag):
'''
Given a sequence of Borg command-line arguments, return them with the given (valueless) flag
omitted. For instance, if the flag is "--flag" and arguments is:
('borg', 'create', '--flag', '--other-flag')
... then return:
('borg', 'create', '--other-flag')
'''
return tuple(argument for argument in arguments if argument != flag)
def omit_flag_and_value(arguments, flag):
'''
Given a sequence of Borg command-line arguments, return them with the given flag and its
corresponding value omitted. For instance, if the flag is "--flag" and arguments is:
('borg', 'create', '--flag', 'value', '--other-flag')
... or:
('borg', 'create', '--flag=value', '--other-flag')
... then return:
('borg', 'create', '--other-flag')
'''
# This works by zipping together a list of overlapping pairwise arguments. E.g., ('one', 'two',
# 'three', 'four') becomes ((None, 'one'), ('one, 'two'), ('two', 'three'), ('three', 'four')).
# This makes it easy to "look back" at the previous arguments so we can exclude both a flag and
# its value.
return tuple(
argument
for (previous_argument, argument) in zip((None,) + arguments, arguments)
if flag not in (previous_argument, argument)
if not argument.startswith(f'{flag}=')
)

View file

@ -102,7 +102,7 @@ def display_archives_info(
json_info = execute_command_and_capture_output(
json_command,
environment=environment.make_environment(config),
extra_environment=environment.make_environment(config),
working_directory=working_directory,
borg_local_path=local_path,
borg_exit_codes=borg_exit_codes,
@ -116,7 +116,7 @@ def display_archives_info(
execute_command(
main_command,
output_log_level=logging.ANSWER,
environment=environment.make_environment(config),
extra_environment=environment.make_environment(config),
working_directory=working_directory,
borg_local_path=local_path,
borg_exit_codes=borg_exit_codes,

View file

@ -106,6 +106,8 @@ def capture_archive_listing(
format to use for the output, and local and remote Borg paths, capture the
output of listing that archive and return it as a list of file paths.
'''
borg_environment = environment.make_environment(config)
return tuple(
execute_command_and_capture_output(
make_list_command(
@ -124,12 +126,12 @@ def capture_archive_listing(
local_path,
remote_path,
),
environment=environment.make_environment(config),
extra_environment=borg_environment,
working_directory=borgmatic.config.paths.get_working_directory(config),
borg_local_path=local_path,
borg_exit_codes=config.get('borg_exit_codes'),
)
.strip('\0')
.strip('\n')
.split('\0')
)
@ -192,6 +194,7 @@ def list_archive(
'The --json flag on the list action is not supported when using the --archive/--find flags.'
)
borg_environment = environment.make_environment(config)
borg_exit_codes = config.get('borg_exit_codes')
# If there are any paths to find (and there's not a single archive already selected), start by
@ -221,7 +224,7 @@ def list_archive(
local_path,
remote_path,
),
environment=environment.make_environment(config),
extra_environment=borg_environment,
working_directory=borgmatic.config.paths.get_working_directory(config),
borg_local_path=local_path,
borg_exit_codes=borg_exit_codes,
@ -234,7 +237,7 @@ def list_archive(
# For each archive listed by Borg, run list on the contents of that archive.
for archive in archive_lines:
logger.answer(f'Listing archive {archive}')
logger.answer(f'{repository_path}: Listing archive {archive}')
archive_arguments = copy.copy(list_arguments)
archive_arguments.archive = archive
@ -257,7 +260,7 @@ def list_archive(
execute_command(
main_command,
output_log_level=logging.ANSWER,
environment=environment.make_environment(config),
extra_environment=borg_environment,
working_directory=borgmatic.config.paths.get_working_directory(config),
borg_local_path=local_path,
borg_exit_codes=borg_exit_codes,

View file

@ -59,6 +59,7 @@ def mount_archive(
+ (tuple(mount_arguments.paths) if mount_arguments.paths else ())
)
borg_environment = environment.make_environment(config)
working_directory = borgmatic.config.paths.get_working_directory(config)
# Don't capture the output when foreground mode is used so that ctrl-C can work properly.
@ -66,7 +67,7 @@ def mount_archive(
execute_command(
full_command,
output_file=DO_NOT_CAPTURE,
environment=environment.make_environment(config),
extra_environment=borg_environment,
working_directory=working_directory,
borg_local_path=local_path,
borg_exit_codes=config.get('borg_exit_codes'),
@ -75,7 +76,7 @@ def mount_archive(
execute_command(
full_command,
environment=environment.make_environment(config),
extra_environment=borg_environment,
working_directory=working_directory,
borg_local_path=local_path,
borg_exit_codes=config.get('borg_exit_codes'),

View file

@ -1,40 +0,0 @@
import functools
import logging
import shlex
import borgmatic.config.paths
import borgmatic.execute
logger = logging.getLogger(__name__)
@functools.cache
def run_passcommand(passcommand, working_directory):
'''
Run the given passcommand using the given working directory and return the passphrase produced
by the command.
Cache the results so that the passcommand only needs to run—and potentially prompt the user—once
per borgmatic invocation.
'''
return borgmatic.execute.execute_command_and_capture_output(
shlex.split(passcommand),
working_directory=working_directory,
)
def get_passphrase_from_passcommand(config):
'''
Given the configuration dict, call the configured passcommand to produce and return an
encryption passphrase. In effect, we're doing an end-run around Borg by invoking its passcommand
ourselves. This allows us to pass the resulting passphrase to multiple different Borg
invocations without the user having to be prompted multiple times.
If no passcommand is configured, then return None.
'''
passcommand = config.get('encryption_passcommand')
if not passcommand:
return None
return run_passcommand(passcommand, borgmatic.config.paths.get_working_directory(config))

View file

@ -20,31 +20,12 @@ class Pattern_style(enum.Enum):
PATH_FULL_MATCH = 'pf'
class Pattern_source(enum.Enum):
'''
Where the pattern came from within borgmatic. This is important because certain use cases (like
filesystem snapshotting) only want to consider patterns that the user actually put in a
configuration file and not patterns from other sources.
'''
# The pattern is from a borgmatic configuration option, e.g. listed in "source_directories".
CONFIG = 'config'
# The pattern is generated internally within borgmatic, e.g. for special file excludes.
INTERNAL = 'internal'
# The pattern originates from within a borgmatic hook, e.g. a database hook that adds its dump
# directory.
HOOK = 'hook'
Pattern = collections.namedtuple(
'Pattern',
('path', 'type', 'style', 'device', 'source'),
('path', 'type', 'style', 'device'),
defaults=(
Pattern_type.ROOT,
Pattern_style.NONE,
None,
Pattern_source.HOOK,
),
)

View file

@ -96,7 +96,7 @@ def prune_archives(
execute_command(
full_command,
output_log_level=output_log_level,
environment=environment.make_environment(config),
extra_environment=environment.make_environment(config),
working_directory=borgmatic.config.paths.get_working_directory(config),
borg_local_path=local_path,
borg_exit_codes=config.get('borg_exit_codes'),

View file

@ -57,7 +57,7 @@ def create_repository(
f'Requested encryption mode "{encryption_mode}" does not match existing repository encryption mode "{repository_encryption_mode}"'
)
logger.info('Repository already exists. Skipping creation.')
logger.info(f'{repository_path}: Repository already exists. Skipping creation.')
return
except subprocess.CalledProcessError as error:
if error.returncode not in REPO_INFO_REPOSITORY_NOT_FOUND_EXIT_CODES:
@ -91,14 +91,14 @@ def create_repository(
)
if dry_run:
logging.info('Skipping repository creation (dry run)')
logging.info(f'{repository_path}: Skipping repository creation (dry run)')
return
# Do not capture output here, so as to support interactive prompts.
execute_command(
repo_create_command,
output_file=DO_NOT_CAPTURE,
environment=environment.make_environment(config),
extra_environment=environment.make_environment(config),
working_directory=borgmatic.config.paths.get_working_directory(config),
borg_local_path=local_path,
borg_exit_codes=config.get('borg_exit_codes'),

View file

@ -88,7 +88,7 @@ def delete_repository(
if repo_delete_arguments.force or repo_delete_arguments.cache_only
else borgmatic.execute.DO_NOT_CAPTURE
),
environment=borgmatic.borg.environment.make_environment(config),
extra_environment=borgmatic.borg.environment.make_environment(config),
working_directory=borgmatic.config.paths.get_working_directory(config),
borg_local_path=local_path,
borg_exit_codes=config.get('borg_exit_codes'),

View file

@ -50,13 +50,14 @@ def display_repository_info(
+ flags.make_repository_flags(repository_path, local_borg_version)
)
extra_environment = environment.make_environment(config)
working_directory = borgmatic.config.paths.get_working_directory(config)
borg_exit_codes = config.get('borg_exit_codes')
if repo_info_arguments.json:
return execute_command_and_capture_output(
full_command,
environment=environment.make_environment(config),
extra_environment=extra_environment,
working_directory=working_directory,
borg_local_path=local_path,
borg_exit_codes=borg_exit_codes,
@ -65,7 +66,7 @@ def display_repository_info(
execute_command(
full_command,
output_log_level=logging.ANSWER,
environment=environment.make_environment(config),
extra_environment=extra_environment,
working_directory=working_directory,
borg_local_path=local_path,
borg_exit_codes=borg_exit_codes,

View file

@ -49,7 +49,7 @@ def resolve_archive_name(
output = execute_command_and_capture_output(
full_command,
environment=environment.make_environment(config),
extra_environment=environment.make_environment(config),
working_directory=borgmatic.config.paths.get_working_directory(config),
borg_local_path=local_path,
borg_exit_codes=config.get('borg_exit_codes'),
@ -59,7 +59,7 @@ def resolve_archive_name(
except IndexError:
raise ValueError('No archives found in the repository')
logger.debug(f'Latest archive is {latest_archive}')
logger.debug(f'{repository_path}: Latest archive is {latest_archive}')
return latest_archive
@ -140,6 +140,7 @@ def list_repository(
return JSON output).
'''
borgmatic.logger.add_custom_log_levels()
borg_environment = environment.make_environment(config)
main_command = make_repo_list_command(
repository_path,
@ -164,7 +165,7 @@ def list_repository(
json_listing = execute_command_and_capture_output(
json_command,
environment=environment.make_environment(config),
extra_environment=borg_environment,
working_directory=working_directory,
borg_local_path=local_path,
borg_exit_codes=borg_exit_codes,
@ -178,7 +179,7 @@ def list_repository(
execute_command(
main_command,
output_log_level=logging.ANSWER,
environment=environment.make_environment(config),
extra_environment=borg_environment,
working_directory=working_directory,
borg_local_path=local_path,
borg_exit_codes=borg_exit_codes,

View file

@ -57,7 +57,7 @@ def transfer_archives(
full_command,
output_log_level=logging.ANSWER,
output_file=DO_NOT_CAPTURE if transfer_arguments.progress else None,
environment=environment.make_environment(config),
extra_environment=environment.make_environment(config),
working_directory=borgmatic.config.paths.get_working_directory(config),
borg_local_path=local_path,
borg_exit_codes=config.get('borg_exit_codes'),

View file

@ -21,7 +21,7 @@ def local_borg_version(config, local_path='borg'):
)
output = execute_command_and_capture_output(
full_command,
environment=environment.make_environment(config),
extra_environment=environment.make_environment(config),
working_directory=borgmatic.config.paths.get_working_directory(config),
borg_local_path=local_path,
borg_exit_codes=config.get('borg_exit_codes'),

View file

@ -349,12 +349,12 @@ def make_parsers():
global_group.add_argument(
'--log-file-format',
type=str,
help='Python format string used for log messages written to the log file',
help='Log format string used for log messages written to the log file',
)
global_group.add_argument(
'--log-json',
action='store_true',
help='Write Borg log messages and console output as one JSON object per log line instead of formatted text',
help='Write log messages and console output as one JSON object per log line instead of formatted text',
)
global_group.add_argument(
'--override',

View file

@ -39,14 +39,7 @@ from borgmatic.commands.arguments import parse_arguments
from borgmatic.config import checks, collect, validate
from borgmatic.hooks import command, dispatch
from borgmatic.hooks.monitoring import monitor
from borgmatic.logger import (
DISABLED,
Log_prefix,
add_custom_log_levels,
configure_delayed_logging,
configure_logging,
should_do_markup,
)
from borgmatic.logger import DISABLED, add_custom_log_levels, configure_logging, should_do_markup
from borgmatic.signals import configure_signals
from borgmatic.verbosity import verbosity_to_log_level
@ -93,12 +86,12 @@ def run_configuration(config_filename, config, config_paths, arguments):
if skip_actions:
logger.debug(
f"Skipping {'/'.join(skip_actions)} action{'s' if len(skip_actions) > 1 else ''} due to configured skip_actions"
f"{config_filename}: Skipping {'/'.join(skip_actions)} action{'s' if len(skip_actions) > 1 else ''} due to configured skip_actions"
)
try:
local_borg_version = borg_version.local_borg_version(config, local_path)
logger.debug(f'Borg {local_borg_version}')
logger.debug(f'{config_filename}: Borg {local_borg_version}')
except (OSError, CalledProcessError, ValueError) as error:
yield from log_error_records(f'{config_filename}: Error getting local Borg version', error)
return
@ -108,8 +101,8 @@ def run_configuration(config_filename, config, config_paths, arguments):
dispatch.call_hooks(
'initialize_monitor',
config,
dispatch.Hook_type.MONITORING,
config_filename,
dispatch.Hook_type.MONITORING,
monitoring_log_level,
global_arguments.dry_run,
)
@ -117,14 +110,14 @@ def run_configuration(config_filename, config, config_paths, arguments):
dispatch.call_hooks(
'ping_monitor',
config,
dispatch.Hook_type.MONITORING,
config_filename,
dispatch.Hook_type.MONITORING,
monitor.State.START,
monitoring_log_level,
global_arguments.dry_run,
)
except (OSError, CalledProcessError) as error:
if command.considered_soft_failure(error):
if command.considered_soft_failure(config_filename, error):
return
encountered_error = error
@ -139,49 +132,53 @@ def run_configuration(config_filename, config, config_paths, arguments):
while not repo_queue.empty():
repository, retry_num = repo_queue.get()
with Log_prefix(repository.get('label', repository['path'])):
logger.debug('Running actions for repository')
timeout = retry_num * retry_wait
if timeout:
logger.warning(f'Sleeping {timeout}s before next retry')
time.sleep(timeout)
try:
yield from run_actions(
arguments=arguments,
config_filename=config_filename,
config=config,
config_paths=config_paths,
local_path=local_path,
remote_path=remote_path,
local_borg_version=local_borg_version,
repository=repository,
logger.debug(
f'{repository.get("label", repository["path"])}: Running actions for repository'
)
timeout = retry_num * retry_wait
if timeout:
logger.warning(
f'{repository.get("label", repository["path"])}: Sleeping {timeout}s before next retry'
)
time.sleep(timeout)
try:
yield from run_actions(
arguments=arguments,
config_filename=config_filename,
config=config,
config_paths=config_paths,
local_path=local_path,
remote_path=remote_path,
local_borg_version=local_borg_version,
repository=repository,
)
except (OSError, CalledProcessError, ValueError) as error:
if retry_num < retries:
repo_queue.put(
(repository, retry_num + 1),
)
except (OSError, CalledProcessError, ValueError) as error:
if retry_num < retries:
repo_queue.put(
(repository, retry_num + 1),
tuple( # Consume the generator so as to trigger logging.
log_error_records(
f'{repository.get("label", repository["path"])}: Error running actions for repository',
error,
levelno=logging.WARNING,
log_command_error_output=True,
)
tuple( # Consume the generator so as to trigger logging.
log_error_records(
'Error running actions for repository',
error,
levelno=logging.WARNING,
log_command_error_output=True,
)
)
logger.warning(f'Retrying... attempt {retry_num + 1}/{retries}')
continue
if command.considered_soft_failure(error):
continue
yield from log_error_records(
'Error running actions for repository',
error,
)
encountered_error = error
error_repository = repository['path']
logger.warning(
f'{repository.get("label", repository["path"])}: Retrying... attempt {retry_num + 1}/{retries}'
)
continue
if command.considered_soft_failure(config_filename, error):
continue
yield from log_error_records(
f'{repository.get("label", repository["path"])}: Error running actions for repository',
error,
)
encountered_error = error
error_repository = repository['path']
try:
if monitoring_hooks_are_activated:
@ -189,16 +186,16 @@ def run_configuration(config_filename, config, config_paths, arguments):
dispatch.call_hooks(
'ping_monitor',
config,
dispatch.Hook_type.MONITORING,
config_filename,
dispatch.Hook_type.MONITORING,
monitor.State.LOG,
monitoring_log_level,
global_arguments.dry_run,
)
except (OSError, CalledProcessError) as error:
if not command.considered_soft_failure(error):
if not command.considered_soft_failure(config_filename, error):
encountered_error = error
yield from log_error_records('Error pinging monitor', error)
yield from log_error_records(f'{repository["path"]}: Error pinging monitor', error)
if not encountered_error:
try:
@ -206,8 +203,8 @@ def run_configuration(config_filename, config, config_paths, arguments):
dispatch.call_hooks(
'ping_monitor',
config,
dispatch.Hook_type.MONITORING,
config_filename,
dispatch.Hook_type.MONITORING,
monitor.State.FINISH,
monitoring_log_level,
global_arguments.dry_run,
@ -215,12 +212,13 @@ def run_configuration(config_filename, config, config_paths, arguments):
dispatch.call_hooks(
'destroy_monitor',
config,
config_filename,
dispatch.Hook_type.MONITORING,
monitoring_log_level,
global_arguments.dry_run,
)
except (OSError, CalledProcessError) as error:
if command.considered_soft_failure(error):
if command.considered_soft_failure(config_filename, error):
return
encountered_error = error
@ -241,8 +239,8 @@ def run_configuration(config_filename, config, config_paths, arguments):
dispatch.call_hooks(
'ping_monitor',
config,
dispatch.Hook_type.MONITORING,
config_filename,
dispatch.Hook_type.MONITORING,
monitor.State.FAIL,
monitoring_log_level,
global_arguments.dry_run,
@ -250,12 +248,13 @@ def run_configuration(config_filename, config, config_paths, arguments):
dispatch.call_hooks(
'destroy_monitor',
config,
config_filename,
dispatch.Hook_type.MONITORING,
monitoring_log_level,
global_arguments.dry_run,
)
except (OSError, CalledProcessError) as error:
if command.considered_soft_failure(error):
if command.considered_soft_failure(config_filename, error):
return
yield from log_error_records(f'{config_filename}: Error running on-error hook', error)
@ -535,16 +534,13 @@ def run_actions(
def load_configurations(config_filenames, overrides=None, resolve_env=True):
'''
Given a sequence of configuration filenames, a sequence of configuration file override strings
in the form of "option.suboption=value", and whether to resolve environment variables, load and
validate each configuration file. Return the results as a tuple of: dict of configuration
filename to corresponding parsed configuration, a sequence of paths for all loaded configuration
files (including includes), and a sequence of logging.LogRecord instances containing any parse
errors.
Given a sequence of configuration filenames, load and validate each configuration file. Return
the results as a tuple of: dict of configuration filename to corresponding parsed configuration,
a sequence of paths for all loaded configuration files (including includes), and a sequence of
logging.LogRecord instances containing any parse errors.
Log records are returned here instead of being logged directly because logging isn't yet
initialized at this point! (Although with the Delayed_logging_handler now in place, maybe this
approach could change.)
initialized at this point!
'''
# Dict mapping from config filename to corresponding parsed config dict.
configs = collections.OrderedDict()
@ -566,10 +562,7 @@ def load_configurations(config_filenames, overrides=None, resolve_env=True):
)
try:
configs[config_filename], paths, parse_logs = validate.parse_configuration(
config_filename,
validate.schema_filename(),
overrides,
resolve_env,
config_filename, validate.schema_filename(), overrides, resolve_env
)
config_paths.update(paths)
logs.extend(parse_logs)
@ -826,27 +819,23 @@ def collect_configuration_run_summary_logs(configs, config_paths, arguments):
# Execute the actions corresponding to each configuration file.
json_results = []
for config_filename, config in configs.items():
with Log_prefix(config_filename):
results = list(run_configuration(config_filename, config, config_paths, arguments))
error_logs = tuple(
result for result in results if isinstance(result, logging.LogRecord)
)
results = list(run_configuration(config_filename, config, config_paths, arguments))
error_logs = tuple(result for result in results if isinstance(result, logging.LogRecord))
if error_logs:
yield from log_error_records('An error occurred')
yield from error_logs
else:
yield logging.makeLogRecord(
dict(
levelno=logging.INFO,
levelname='INFO',
msg='Successfully ran configuration file',
)
if error_logs:
yield from log_error_records(f'{config_filename}: An error occurred')
yield from error_logs
else:
yield logging.makeLogRecord(
dict(
levelno=logging.INFO,
levelname='INFO',
msg=f'{config_filename}: Successfully ran configuration file',
)
if results:
json_results.extend(results)
)
if results:
json_results.extend(results)
if 'umount' in arguments:
logger.info(f"Unmounting mount point {arguments['umount'].mount_point}")
@ -887,7 +876,6 @@ def exit_with_help_link(): # pragma: no cover
def main(extra_summary_logs=[]): # pragma: no cover
configure_signals()
configure_delayed_logging()
try:
arguments = parse_arguments(*sys.argv[1:])
@ -913,12 +901,9 @@ def main(extra_summary_logs=[]): # pragma: no cover
print(borgmatic.commands.completion.fish.fish_completion())
sys.exit(0)
validate = bool('validate' in arguments)
config_filenames = tuple(collect.collect_config_filenames(global_arguments.config_paths))
configs, config_paths, parse_logs = load_configurations(
config_filenames,
global_arguments.overrides,
resolve_env=global_arguments.resolve_env and not validate,
config_filenames, global_arguments.overrides, global_arguments.resolve_env
)
configuration_parse_errors = (
(max(log.levelno for log in parse_logs) >= logging.CRITICAL) if parse_logs else False

View file

@ -69,7 +69,7 @@ def include_configuration(loader, filename_node, include_directory, config_paths
]
raise ValueError(
'The value given for the !include tag is invalid; use a single filename or a list of filenames instead'
'!include value is not supported; use a single filename or a list of filenames'
)

View file

@ -76,13 +76,14 @@ class Runtime_directory:
automatically gets cleaned up as necessary.
'''
def __init__(self, config):
def __init__(self, config, log_prefix):
'''
Given a configuration dict determine the borgmatic runtime directory, creating a secure,
temporary directory within it if necessary. Defaults to $XDG_RUNTIME_DIR/./borgmatic or
$RUNTIME_DIRECTORY/./borgmatic or $TMPDIR/borgmatic-[random]/./borgmatic or
$TEMP/borgmatic-[random]/./borgmatic or /tmp/borgmatic-[random]/./borgmatic where "[random]"
is a randomly generated string intended to avoid path collisions.
Given a configuration dict and a log prefix, determine the borgmatic runtime directory,
creating a secure, temporary directory within it if necessary. Defaults to
$XDG_RUNTIME_DIR/./borgmatic or $RUNTIME_DIRECTORY/./borgmatic or
$TMPDIR/borgmatic-[random]/./borgmatic or $TEMP/borgmatic-[random]/./borgmatic or
/tmp/borgmatic-[random]/./borgmatic where "[random]" is a randomly generated string intended
to avoid path collisions.
If XDG_RUNTIME_DIR or RUNTIME_DIRECTORY is set and already ends in "/borgmatic", then don't
tack on a second "/borgmatic" path component.
@ -126,7 +127,7 @@ class Runtime_directory:
)
os.makedirs(self.runtime_path, mode=0o700, exist_ok=True)
logger.debug(f'Using runtime directory {os.path.normpath(self.runtime_path)}')
logger.debug(f'{log_prefix}: Using runtime directory {os.path.normpath(self.runtime_path)}')
def __enter__(self):
'''

View file

@ -250,7 +250,7 @@ properties:
repositories that were initialized with passphrase/repokey/keyfile
encryption. Quote the value if it contains punctuation, so it parses
correctly. And backslash any quote or backslash literals as well.
Defaults to not set. Supports the "{credential ...}" syntax.
Defaults to not set.
example: "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"
checkpoint_interval:
type: integer
@ -632,8 +632,8 @@ properties:
long-running repository check into multiple
partial checks. Defaults to no interruption. Only
applies to the "repository" check, does not check
the repository index and is not compatible with
the "--repair" flag.
the repository index, and is not compatible with a
simultaneous "archives" check or "--repair" flag.
example: 3600
- required:
- name
@ -989,15 +989,13 @@ properties:
Username with which to connect to the database. Defaults
to the username of the current user. You probably want
to specify the "postgres" superuser here when the
database name is "all". Supports the "{credential ...}"
syntax.
database name is "all".
example: dbuser
restore_username:
type: string
description: |
Username with which to restore the database. Defaults to
the "username" option. Supports the "{credential ...}"
syntax.
the "username" option.
example: dbuser
password:
type: string
@ -1005,15 +1003,13 @@ properties:
Password with which to connect to the database. Omitting
a password will only work if PostgreSQL is configured to
trust the configured username without a password or you
create a ~/.pgpass file. Supports the "{credential ...}"
syntax.
create a ~/.pgpass file.
example: trustsome1
restore_password:
type: string
description: |
Password with which to connect to the restore database.
Defaults to the "password" option. Supports the
"{credential ...}" syntax.
Defaults to the "password" option.
example: trustsome1
no_owner:
type: boolean
@ -1173,15 +1169,13 @@ properties:
type: string
description: |
Username with which to connect to the database. Defaults
to the username of the current user. Supports the
"{credential ...}" syntax.
to the username of the current user.
example: dbuser
restore_username:
type: string
description: |
Username with which to restore the database. Defaults to
the "username" option. Supports the "{credential ...}"
syntax.
the "username" option.
example: dbuser
password:
type: string
@ -1189,14 +1183,6 @@ properties:
Password with which to connect to the database. Omitting
a password will only work if MariaDB is configured to
trust the configured username without a password.
Supports the "{credential ...}" syntax.
example: trustsome1
restore_password:
type: string
description: |
Password with which to connect to the restore database.
Defaults to the "password" option. Supports the
"{credential ...}" syntax.
example: trustsome1
mariadb_dump_command:
type: string
@ -1215,6 +1201,12 @@ properties:
run a specific mariadb version (e.g., one inside a
running container). Defaults to "mariadb".
example: docker exec mariadb_container mariadb
restore_password:
type: string
description: |
Password with which to connect to the restore database.
Defaults to the "password" option.
example: trustsome1
format:
type: string
enum: ['sql']
@ -1303,15 +1295,13 @@ properties:
type: string
description: |
Username with which to connect to the database. Defaults
to the username of the current user. Supports the
"{credential ...}" syntax.
to the username of the current user.
example: dbuser
restore_username:
type: string
description: |
Username with which to restore the database. Defaults to
the "username" option. Supports the "{credential ...}"
syntax.
the "username" option.
example: dbuser
password:
type: string
@ -1319,14 +1309,12 @@ properties:
Password with which to connect to the database. Omitting
a password will only work if MySQL is configured to
trust the configured username without a password.
Supports the "{credential ...}" syntax.
example: trustsome1
restore_password:
type: string
description: |
Password with which to connect to the restore database.
Defaults to the "password" option. Supports the
"{credential ...}" syntax.
Defaults to the "password" option.
example: trustsome1
mysql_dump_command:
type: string
@ -1463,29 +1451,25 @@ properties:
type: string
description: |
Username with which to connect to the database. Skip it
if no authentication is needed. Supports the
"{credential ...}" syntax.
if no authentication is needed.
example: dbuser
restore_username:
type: string
description: |
Username with which to restore the database. Defaults to
the "username" option. Supports the "{credential ...}"
syntax.
the "username" option.
example: dbuser
password:
type: string
description: |
Password with which to connect to the database. Skip it
if no authentication is needed. Supports the
"{credential ...}" syntax.
if no authentication is needed.
example: trustsome1
restore_password:
type: string
description: |
Password with which to connect to the restore database.
Defaults to the "password" option. Supports the
"{credential ...}" syntax.
Defaults to the "password" option.
example: trustsome1
authentication_database:
type: string
@ -1544,20 +1528,18 @@ properties:
username:
type: string
description: |
The username used for authentication. Supports the
"{credential ...}" syntax.
The username used for authentication.
example: testuser
password:
type: string
description: |
The password used for authentication. Supports the
"{credential ...}" syntax.
The password used for authentication.
example: fakepassword
access_token:
type: string
description: |
An ntfy access token to authenticate with instead of
username/password. Supports the "{credential ...}" syntax.
username/password.
example: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2
start:
type: object
@ -1652,16 +1634,14 @@ properties:
token:
type: string
description: |
Your application's API token. Supports the "{credential
...}" syntax.
Your application's API token.
example: 7ms6TXHpTokTou2P6x4SodDeentHRa
user:
type: string
description: |
Your user/group key (or that of your target user), viewable
when logged into your dashboard: often referred to as
Your user/group key (or that of your target user), viewable
when logged into your dashboard: often referred to as
USER_KEY in Pushover documentation and code examples.
Supports the "{credential ...}" syntax.
example: hwRwoWsXMBWwgrSecfa9EfPey55WSN
start:
type: object
@ -1935,19 +1915,19 @@ properties:
type: string
description: |
The username used for authentication. Not needed if using
an API key. Supports the "{credential ...}" syntax.
an API key.
example: testuser
password:
type: string
description: |
The password used for authentication. Not needed if using
an API key. Supports the "{credential ...}" syntax.
an API key.
example: fakepassword
api_key:
type: string
description: |
The API key used for authentication. Not needed if using an
username/password. Supports the "{credential ...}" syntax.
The API key used for authentication. Not needed if using
an username/password.
example: fakekey
start:
type: object
@ -2227,8 +2207,8 @@ properties:
integration_key:
type: string
description: |
PagerDuty integration key used to notify PagerDuty when a
backup errors. Supports the "{credential ...}" syntax.
PagerDuty integration key used to notify PagerDuty
when a backup errors.
example: a177cad45bd374409f78906a810a3074
description: |
Configuration for a monitoring integration with PagerDuty. Create an
@ -2402,25 +2382,3 @@ properties:
description: |
Configuration for integration with Linux LVM (Logical Volume
Manager).
container:
type: object
additionalProperties: false
properties:
secrets_directory:
type: string
description: |
Secrets directory to use instead of "/run/secrets".
example: /path/to/secrets
description: |
Configuration for integration with Docker or Podman secrets.
keepassxc:
type: object
additionalProperties: false
properties:
keepassxc_cli_command:
type: string
description: |
Command to use instead of "keepassxc-cli".
example: /usr/local/bin/keepassxc-cli
description: |
Configuration for integration with the KeePassXC password manager.

View file

@ -88,9 +88,8 @@ def parse_configuration(config_filename, schema_filename, overrides=None, resolv
'''
Given the path to a config filename in YAML format, the path to a schema filename in a YAML
rendition of JSON Schema format, a sequence of configuration file override strings in the form
of "option.suboption=value", and whether to resolve environment variables, return the parsed
configuration as a data structure of nested dicts and lists corresponding to the schema. Example
return value:
of "option.suboption=value", return the parsed configuration as a data structure of nested dicts
and lists corresponding to the schema. Example return value:
{
'source_directories': ['/home', '/etc'],
@ -125,7 +124,6 @@ def parse_configuration(config_filename, schema_filename, overrides=None, resolv
validator = jsonschema.Draft7Validator(schema)
except AttributeError: # pragma: no cover
validator = jsonschema.Draft4Validator(schema)
validation_errors = tuple(validator.iter_errors(config))
if validation_errors:

View file

@ -1,12 +1,11 @@
import collections
import enum
import logging
import os
import select
import subprocess
import textwrap
import borgmatic.logger
logger = logging.getLogger(__name__)
@ -242,9 +241,6 @@ def mask_command_secrets(full_command):
MAX_LOGGED_COMMAND_LENGTH = 1000
PREFIXES_OF_ENVIRONMENT_VARIABLES_TO_LOG = ('BORG_', 'PG', 'MARIADB_', 'MYSQL_')
def log_command(full_command, input_file=None, output_file=None, environment=None):
'''
Log the given command (a sequence of command/argument strings), along with its input/output file
@ -253,14 +249,7 @@ def log_command(full_command, input_file=None, output_file=None, environment=Non
logger.debug(
textwrap.shorten(
' '.join(
tuple(
f'{key}=***'
for key in (environment or {}).keys()
if any(
key.startswith(prefix)
for prefix in PREFIXES_OF_ENVIRONMENT_VARIABLES_TO_LOG
)
)
tuple(f'{key}=***' for key in (environment or {}).keys())
+ mask_command_secrets(full_command)
),
width=MAX_LOGGED_COMMAND_LENGTH,
@ -283,7 +272,7 @@ def execute_command(
output_file=None,
input_file=None,
shell=False,
environment=None,
extra_environment=None,
working_directory=None,
borg_local_path=None,
borg_exit_codes=None,
@ -293,17 +282,18 @@ def execute_command(
Execute the given command (a sequence of command/argument strings) and log its output at the
given log level. If an open output file object is given, then write stdout to the file and only
log stderr. If an open input file object is given, then read stdin from the file. If shell is
True, execute the command within a shell. If an environment variables dict is given, then pass
it into the command. If a working directory is given, use that as the present working directory
when running the command. If a Borg local path is given, and the command matches it (regardless
of arguments), treat exit code 1 as a warning instead of an error. But if Borg exit codes are
given as a sequence of exit code configuration dicts, then use that configuration to decide
what's an error and what's a warning. If run to completion is False, then return the process for
the command without executing it to completion.
True, execute the command within a shell. If an extra environment dict is given, then use it to
augment the current environment, and pass the result into the command. If a working directory is
given, use that as the present working directory when running the command. If a Borg local path
is given, and the command matches it (regardless of arguments), treat exit code 1 as a warning
instead of an error. But if Borg exit codes are given as a sequence of exit code configuration
dicts, then use that configuration to decide what's an error and what's a warning. If run to
completion is False, then return the process for the command without executing it to completion.
Raise subprocesses.CalledProcessError if an error occurs while running the command.
'''
log_command(full_command, input_file, output_file, environment)
log_command(full_command, input_file, output_file, extra_environment)
environment = {**os.environ, **extra_environment} if extra_environment else None
do_not_capture = bool(output_file is DO_NOT_CAPTURE)
command = ' '.join(full_command) if shell else full_command
@ -315,27 +305,24 @@ def execute_command(
shell=shell,
env=environment,
cwd=working_directory,
# Necessary for the passcommand credential hook to work.
close_fds=not bool((environment or {}).get('BORG_PASSPHRASE_FD')),
)
if not run_to_completion:
return process
with borgmatic.logger.Log_prefix(None): # Log command output without any prefix.
log_outputs(
(process,),
(input_file, output_file),
output_log_level,
borg_local_path,
borg_exit_codes,
)
log_outputs(
(process,),
(input_file, output_file),
output_log_level,
borg_local_path,
borg_exit_codes,
)
def execute_command_and_capture_output(
full_command,
capture_stderr=False,
shell=False,
environment=None,
extra_environment=None,
working_directory=None,
borg_local_path=None,
borg_exit_codes=None,
@ -343,16 +330,18 @@ def execute_command_and_capture_output(
'''
Execute the given command (a sequence of command/argument strings), capturing and returning its
output (stdout). If capture stderr is True, then capture and return stderr in addition to
stdout. If shell is True, execute the command within a shell. If an environment variables dict
is given, then pass it into the command. If a working directory is given, use that as the
present working directory when running the command. If a Borg local path is given, and the
command matches it (regardless of arguments), treat exit code 1 as a warning instead of an
error. But if Borg exit codes are given as a sequence of exit code configuration dicts, then use
that configuration to decide what's an error and what's a warning.
stdout. If shell is True, execute the command within a shell. If an extra environment dict is
given, then use it to augment the current environment, and pass the result into the command. If
a working directory is given, use that as the present working directory when running the
command. If a Borg local path is given, and the command matches it (regardless of arguments),
treat exit code 1 as a warning instead of an error. But if Borg exit codes are given as a
sequence of exit code configuration dicts, then use that configuration to decide what's an error
and what's a warning.
Raise subprocesses.CalledProcessError if an error occurs while running the command.
'''
log_command(full_command, environment=environment)
log_command(full_command, environment=extra_environment)
environment = {**os.environ, **extra_environment} if extra_environment else None
command = ' '.join(full_command) if shell else full_command
try:
@ -362,8 +351,6 @@ def execute_command_and_capture_output(
shell=shell,
env=environment,
cwd=working_directory,
# Necessary for the passcommand credential hook to work.
close_fds=not bool((environment or {}).get('BORG_PASSPHRASE_FD')),
)
except subprocess.CalledProcessError as error:
if (
@ -383,7 +370,7 @@ def execute_command_with_processes(
output_file=None,
input_file=None,
shell=False,
environment=None,
extra_environment=None,
working_directory=None,
borg_local_path=None,
borg_exit_codes=None,
@ -397,17 +384,19 @@ def execute_command_with_processes(
If an open output file object is given, then write stdout to the file and only log stderr. But
if output log level is None, instead suppress logging and return the captured output for (only)
the given command. If an open input file object is given, then read stdin from the file. If
shell is True, execute the command within a shell. If an environment variables dict is given,
then pass it into the command. If a working directory is given, use that as the present working
directory when running the command. If a Borg local path is given, then for any matching command
or process (regardless of arguments), treat exit code 1 as a warning instead of an error. But if
Borg exit codes are given as a sequence of exit code configuration dicts, then use that
configuration to decide what's an error and what's a warning.
shell is True, execute the command within a shell. If an extra environment dict is given, then
use it to augment the current environment, and pass the result into the command. If a working
directory is given, use that as the present working directory when running the command. If a
Borg local path is given, then for any matching command or process (regardless of arguments),
treat exit code 1 as a warning instead of an error. But if Borg exit codes are given as a
sequence of exit code configuration dicts, then use that configuration to decide what's an error
and what's a warning.
Raise subprocesses.CalledProcessError if an error occurs while running the command or in the
upstream process.
'''
log_command(full_command, input_file, output_file, environment)
log_command(full_command, input_file, output_file, extra_environment)
environment = {**os.environ, **extra_environment} if extra_environment else None
do_not_capture = bool(output_file is DO_NOT_CAPTURE)
command = ' '.join(full_command) if shell else full_command
@ -422,8 +411,6 @@ def execute_command_with_processes(
shell=shell,
env=environment,
cwd=working_directory,
# Necessary for the passcommand credential hook to work.
close_fds=not bool((environment or {}).get('BORG_PASSPHRASE_FD')),
)
except (subprocess.CalledProcessError, OSError):
# Something has gone wrong. So vent each process' output buffer to prevent it from hanging.
@ -434,14 +421,13 @@ def execute_command_with_processes(
process.kill()
raise
with borgmatic.logger.Log_prefix(None): # Log command output without any prefix.
captured_outputs = log_outputs(
tuple(processes) + (command_process,),
(input_file, output_file),
output_log_level,
borg_local_path,
borg_exit_codes,
)
captured_outputs = log_outputs(
tuple(processes) + (command_process,),
(input_file, output_file),
output_log_level,
borg_local_path,
borg_exit_codes,
)
if output_log_level is None:
return captured_outputs.get(command_process)

View file

@ -12,7 +12,7 @@ logger = logging.getLogger(__name__)
SOFT_FAIL_EXIT_CODE = 75
def interpolate_context(hook_description, command, context):
def interpolate_context(config_filename, hook_description, command, context):
'''
Given a config filename, a hook description, a single hook command, and a dict of context
names/values, interpolate the values by "{name}" into the command and return the result.
@ -22,7 +22,7 @@ def interpolate_context(hook_description, command, context):
for unsupported_variable in re.findall(r'{\w+}', command):
logger.warning(
f"Variable '{unsupported_variable}' is not supported in {hook_description} hook"
f"{config_filename}: Variable '{unsupported_variable}' is not supported in {hook_description} hook"
)
return command
@ -30,18 +30,16 @@ def interpolate_context(hook_description, command, context):
def make_environment(current_environment, sys_module=sys):
'''
Given the existing system environment as a map from environment variable name to value, return a
copy of it, augmented with any extra environment variables that should be used when running
command hooks.
Given the existing system environment as a map from environment variable name to value, return
(in the same form) any extra environment variables that should be used when running command
hooks.
'''
environment = dict(current_environment)
# Detect whether we're running within a PyInstaller bundle. If so, set or clear LD_LIBRARY_PATH
# based on the value of LD_LIBRARY_PATH_ORIG. This prevents library version information errors.
if getattr(sys_module, 'frozen', False) and hasattr(sys_module, '_MEIPASS'):
environment['LD_LIBRARY_PATH'] = environment.get('LD_LIBRARY_PATH_ORIG', '')
return {'LD_LIBRARY_PATH': current_environment.get('LD_LIBRARY_PATH_ORIG', '')}
return environment
return {}
def execute_hook(commands, umask, config_filename, description, dry_run, **context):
@ -56,24 +54,26 @@ def execute_hook(commands, umask, config_filename, description, dry_run, **conte
Raise subprocesses.CalledProcessError if an error occurs in a hook.
'''
if not commands:
logger.debug(f'No commands to run for {description} hook')
logger.debug(f'{config_filename}: No commands to run for {description} hook')
return
dry_run_label = ' (dry run; not actually running hooks)' if dry_run else ''
context['configuration_filename'] = config_filename
commands = [interpolate_context(description, command, context) for command in commands]
commands = [
interpolate_context(config_filename, description, command, context) for command in commands
]
if len(commands) == 1:
logger.info(f'Running command for {description} hook{dry_run_label}')
logger.info(f'{config_filename}: Running command for {description} hook{dry_run_label}')
else:
logger.info(
f'Running {len(commands)} commands for {description} hook{dry_run_label}',
f'{config_filename}: Running {len(commands)} commands for {description} hook{dry_run_label}',
)
if umask:
parsed_umask = int(str(umask), 8)
logger.debug(f'Set hook umask to {oct(parsed_umask)}')
logger.debug(f'{config_filename}: Set hook umask to {oct(parsed_umask)}')
original_umask = os.umask(parsed_umask)
else:
original_umask = None
@ -87,14 +87,14 @@ def execute_hook(commands, umask, config_filename, description, dry_run, **conte
[command],
output_log_level=(logging.ERROR if description == 'on-error' else logging.WARNING),
shell=True,
environment=make_environment(os.environ),
extra_environment=make_environment(os.environ),
)
finally:
if original_umask:
os.umask(original_umask)
def considered_soft_failure(error):
def considered_soft_failure(config_filename, error):
'''
Given a configuration filename and an exception object, return whether the exception object
represents a subprocess.CalledProcessError with a return code of SOFT_FAIL_EXIT_CODE. If so,
@ -106,7 +106,7 @@ def considered_soft_failure(error):
if exit_code == SOFT_FAIL_EXIT_CODE:
logger.info(
f'Command hook exited with soft failure exit code ({SOFT_FAIL_EXIT_CODE}); skipping remaining repository actions',
f'{config_filename}: Command hook exited with soft failure exit code ({SOFT_FAIL_EXIT_CODE}); skipping remaining repository actions',
)
return True

View file

@ -1,43 +0,0 @@
import logging
import os
import re
logger = logging.getLogger(__name__)
SECRET_NAME_PATTERN = re.compile(r'^\w+$')
DEFAULT_SECRETS_DIRECTORY = '/run/secrets'
def load_credential(hook_config, config, credential_parameters):
'''
Given the hook configuration dict, the configuration dict, and a credential parameters tuple
containing a secret name to load, read the secret from the corresponding container secrets file
and return it.
Raise ValueError if the credential parameters is not one element, the secret name is invalid, or
the secret file cannot be read.
'''
try:
(secret_name,) = credential_parameters
except ValueError:
name = ' '.join(credential_parameters)
raise ValueError(f'Cannot load invalid secret name: "{name}"')
if not SECRET_NAME_PATTERN.match(secret_name):
raise ValueError(f'Cannot load invalid secret name: "{secret_name}"')
try:
with open(
os.path.join(
config.get('working_directory', ''),
(hook_config or {}).get('secrets_directory', DEFAULT_SECRETS_DIRECTORY),
secret_name,
)
) as secret_file:
return secret_file.read().rstrip(os.linesep)
except (FileNotFoundError, OSError) as error:
logger.warning(error)
raise ValueError(f'Cannot load secret "{secret_name}" from file: {error.filename}')

View file

@ -1,30 +0,0 @@
import logging
import os
logger = logging.getLogger(__name__)
def load_credential(hook_config, config, credential_parameters):
'''
Given the hook configuration dict, the configuration dict, and a credential parameters tuple
containing a credential path to load, load the credential from file and return it.
Raise ValueError if the credential parameters is not one element or the secret file cannot be
read.
'''
try:
(credential_path,) = credential_parameters
except ValueError:
name = ' '.join(credential_parameters)
raise ValueError(f'Cannot load invalid credential: "{name}"')
try:
with open(
os.path.join(config.get('working_directory', ''), credential_path)
) as credential_file:
return credential_file.read().rstrip(os.linesep)
except (FileNotFoundError, OSError) as error:
logger.warning(error)
raise ValueError(f'Cannot load credential file: {error.filename}')

View file

@ -1,42 +0,0 @@
import logging
import os
import shlex
import borgmatic.execute
logger = logging.getLogger(__name__)
def load_credential(hook_config, config, credential_parameters):
'''
Given the hook configuration dict, the configuration dict, and a credential parameters tuple
containing a KeePassXC database path and an attribute name to load, run keepassxc-cli to fetch
the corresponidng KeePassXC credential and return it.
Raise ValueError if keepassxc-cli can't retrieve the credential.
'''
try:
(database_path, attribute_name) = credential_parameters
except ValueError:
path_and_name = ' '.join(credential_parameters)
raise ValueError(
f'Cannot load credential with invalid KeePassXC database path and attribute name: "{path_and_name}"'
)
if not os.path.exists(database_path):
raise ValueError(
f'Cannot load credential because KeePassXC database path does not exist: {database_path}'
)
return borgmatic.execute.execute_command_and_capture_output(
tuple(shlex.split((hook_config or {}).get('keepassxc_cli_command', 'keepassxc-cli')))
+ (
'show',
'--show-protected',
'--attributes',
'Password',
database_path,
attribute_name,
)
).rstrip(os.linesep)

View file

@ -1,124 +0,0 @@
import functools
import re
import shlex
import borgmatic.hooks.dispatch
IS_A_HOOK = False
class Hash_adapter:
'''
A Hash_adapter instance wraps an unhashable object and pretends it's hashable. This is intended
for passing to a @functools.cache-decorated function to prevent it from complaining that an
argument is unhashable. It should only be used for arguments that you don't want to actually
impact the cache hashing, because Hash_adapter doesn't actually hash the object's contents.
Example usage:
@functools.cache
def func(a, b):
print(a, b.actual_value)
return a
func(5, Hash_adapter({1: 2, 3: 4})) # Calls func(), prints, and returns.
func(5, Hash_adapter({1: 2, 3: 4})) # Hits the cache and just returns the value.
func(5, Hash_adapter({5: 6, 7: 8})) # Also uses cache, since the Hash_adapter is ignored.
In the above function, the "b" value is one that has been wrapped with Hash_adappter, and
therefore "b.actual_value" is necessary to access the original value.
'''
def __init__(self, actual_value):
self.actual_value = actual_value
def __eq__(self, other):
return True
def __hash__(self):
return 0
UNHASHABLE_TYPES = (dict, list, set)
def cache_ignoring_unhashable_arguments(function):
'''
A function decorator that caches calls to the decorated function but ignores any unhashable
arguments when performing cache lookups. This is intended to be a drop-in replacement for
functools.cache.
Example usage:
@cache_ignoring_unhashable_arguments
def func(a, b):
print(a, b)
return a
func(5, {1: 2, 3: 4}) # Calls func(), prints, and returns.
func(5, {1: 2, 3: 4}) # Hits the cache and just returns the value.
func(5, {5: 6, 7: 8}) # Also uses cache, since the unhashable value (the dict) is ignored.
'''
@functools.cache
def cached_function(*args, **kwargs):
return function(
*(arg.actual_value if isinstance(arg, Hash_adapter) else arg for arg in args),
**{
key: value.actual_value if isinstance(value, Hash_adapter) else value
for (key, value) in kwargs.items()
},
)
@functools.wraps(function)
def wrapper_function(*args, **kwargs):
return cached_function(
*(Hash_adapter(arg) if isinstance(arg, UNHASHABLE_TYPES) else arg for arg in args),
**{
key: Hash_adapter(value) if isinstance(value, UNHASHABLE_TYPES) else value
for (key, value) in kwargs.items()
},
)
wrapper_function.cache_clear = cached_function.cache_clear
return wrapper_function
CREDENTIAL_PATTERN = re.compile(r'\{credential( +(?P<hook_and_parameters>.*))?\}')
@cache_ignoring_unhashable_arguments
def resolve_credential(value, config):
'''
Given a configuration value containing a string like "{credential hookname credentialname}" and
a configuration dict, resolve the credential by calling the relevant hook to get the actual
credential value. If the given value does not actually contain a credential tag, then return it
unchanged.
Cache the value (ignoring the config for purposes of caching), so repeated calls to this
function don't need to load the credential repeatedly.
Raise ValueError if the config could not be parsed or the credential could not be loaded.
'''
if value is None:
return value
matcher = CREDENTIAL_PATTERN.match(value)
if not matcher:
return value
hook_and_parameters = matcher.group('hook_and_parameters')
if not hook_and_parameters:
raise ValueError(f'Cannot load credential with invalid syntax "{value}"')
(hook_name, *credential_parameters) = shlex.split(hook_and_parameters)
if not credential_parameters:
raise ValueError(f'Cannot load credential with invalid syntax "{value}"')
return borgmatic.hooks.dispatch.call_hook(
'load_credential', config, hook_name, tuple(credential_parameters)
)

View file

@ -1,43 +0,0 @@
import logging
import os
import re
logger = logging.getLogger(__name__)
CREDENTIAL_NAME_PATTERN = re.compile(r'^\w+$')
def load_credential(hook_config, config, credential_parameters):
'''
Given the hook configuration dict, the configuration dict, and a credential parameters tuple
containing a credential name to load, read the credential from the corresponding systemd
credential file and return it.
Raise ValueError if the systemd CREDENTIALS_DIRECTORY environment variable is not set, the
credential name is invalid, or the credential file cannot be read.
'''
try:
(credential_name,) = credential_parameters
except ValueError:
name = ' '.join(credential_parameters)
raise ValueError(f'Cannot load invalid credential name: "{name}"')
credentials_directory = os.environ.get('CREDENTIALS_DIRECTORY')
if not credentials_directory:
raise ValueError(
f'Cannot load credential "{credential_name}" because the systemd CREDENTIALS_DIRECTORY environment variable is not set'
)
if not CREDENTIAL_NAME_PATTERN.match(credential_name):
raise ValueError(f'Cannot load invalid credential name "{credential_name}"')
try:
with open(os.path.join(credentials_directory, credential_name)) as credential_file:
return credential_file.read().rstrip(os.linesep)
except (FileNotFoundError, OSError) as error:
logger.warning(error)
raise ValueError(f'Cannot load credential "{credential_name}" from file: {error.filename}')

View file

@ -10,7 +10,7 @@ import borgmatic.config.paths
logger = logging.getLogger(__name__)
def use_streaming(hook_config, config): # pragma: no cover
def use_streaming(hook_config, config, log_prefix): # pragma: no cover
'''
Return whether dump streaming is used for this hook. (Spoiler: It isn't.)
'''
@ -20,17 +20,18 @@ def use_streaming(hook_config, config): # pragma: no cover
def dump_data_sources(
hook_config,
config,
log_prefix,
config_paths,
borgmatic_runtime_directory,
patterns,
dry_run,
):
'''
Given a bootstrap configuration dict, a configuration dict, the borgmatic configuration file
paths, the borgmatic runtime directory, the configured patterns, and whether this is a dry run,
create a borgmatic manifest file to store the paths of the configuration files used to create
the archive. But skip this if the bootstrap store_config_files option is False or if this is a
dry run.
Given a bootstrap configuration dict, a configuration dict, a log prefix, the borgmatic
configuration file paths, the borgmatic runtime directory, the configured patterns, and whether
this is a dry run, create a borgmatic manifest file to store the paths of the configuration
files used to create the archive. But skip this if the bootstrap store_config_files option is
False or if this is a dry run.
Return an empty sequence, since there are no ongoing dump processes from this hook.
'''
@ -55,27 +56,19 @@ def dump_data_sources(
manifest_file,
)
patterns.extend(
borgmatic.borg.pattern.Pattern(
config_path, source=borgmatic.borg.pattern.Pattern_source.HOOK
)
for config_path in config_paths
)
patterns.extend(borgmatic.borg.pattern.Pattern(config_path) for config_path in config_paths)
patterns.append(
borgmatic.borg.pattern.Pattern(
os.path.join(borgmatic_runtime_directory, 'bootstrap'),
source=borgmatic.borg.pattern.Pattern_source.HOOK,
)
borgmatic.borg.pattern.Pattern(os.path.join(borgmatic_runtime_directory, 'bootstrap'))
)
return []
def remove_data_source_dumps(hook_config, config, borgmatic_runtime_directory, dry_run):
def remove_data_source_dumps(hook_config, config, log_prefix, borgmatic_runtime_directory, dry_run):
'''
Given a bootstrap configuration dict, a configuration dict, the borgmatic runtime directory, and
whether this is a dry run, then remove the manifest file created above. If this is a dry run,
then don't actually remove anything.
Given a bootstrap configuration dict, a configuration dict, a log prefix, the borgmatic runtime
directory, and whether this is a dry run, then remove the manifest file created above. If this
is a dry run, then don't actually remove anything.
'''
dry_run_label = ' (dry run; not actually removing anything)' if dry_run else ''
@ -86,12 +79,14 @@ def remove_data_source_dumps(hook_config, config, borgmatic_runtime_directory, d
'bootstrap',
)
logger.debug(
f'Looking for bootstrap manifest files to remove in {manifest_glob}{dry_run_label}'
f'{log_prefix}: Looking for bootstrap manifest files to remove in {manifest_glob}{dry_run_label}'
)
for manifest_directory in glob.glob(manifest_glob):
manifest_file_path = os.path.join(manifest_directory, 'manifest.json')
logger.debug(f'Removing bootstrap manifest at {manifest_file_path}{dry_run_label}')
logger.debug(
f'{log_prefix}: Removing bootstrap manifest at {manifest_file_path}{dry_run_label}'
)
if dry_run:
continue
@ -108,7 +103,7 @@ def remove_data_source_dumps(hook_config, config, borgmatic_runtime_directory, d
def make_data_source_dump_patterns(
hook_config, config, borgmatic_runtime_directory, name=None
hook_config, config, log_prefix, borgmatic_runtime_directory, name=None
): # pragma: no cover
'''
Restores are implemented via the separate, purpose-specific "bootstrap" action rather than the
@ -120,6 +115,7 @@ def make_data_source_dump_patterns(
def restore_data_source_dump(
hook_config,
config,
log_prefix,
data_source,
dry_run,
extract_process,

View file

@ -14,16 +14,16 @@ import borgmatic.hooks.data_source.snapshot
logger = logging.getLogger(__name__)
def use_streaming(hook_config, config): # pragma: no cover
def use_streaming(hook_config, config, log_prefix): # pragma: no cover
'''
Return whether dump streaming is used for this hook. (Spoiler: It isn't.)
'''
return False
def get_subvolume_mount_points(findmnt_command):
def get_filesystem_mount_points(findmnt_command):
'''
Given a findmnt command to run, get all sorted Btrfs subvolume mount points.
Given a findmnt command to run, get all top-level Btrfs filesystem mount points.
'''
findmnt_output = borgmatic.execute.execute_command_and_capture_output(
tuple(findmnt_command.split(' '))
@ -37,7 +37,7 @@ def get_subvolume_mount_points(findmnt_command):
try:
return tuple(
sorted(filesystem['target'] for filesystem in json.loads(findmnt_output)['filesystems'])
filesystem['target'] for filesystem in json.loads(findmnt_output)['filesystems']
)
except json.JSONDecodeError as error:
raise ValueError(f'Invalid {findmnt_command} JSON output: {error}')
@ -45,6 +45,34 @@ def get_subvolume_mount_points(findmnt_command):
raise ValueError(f'Invalid {findmnt_command} output: Missing key "{error}"')
def get_subvolumes_for_filesystem(btrfs_command, filesystem_mount_point):
'''
Given a Btrfs command to run and a Btrfs filesystem mount point, get the sorted subvolumes for
that filesystem. Include the filesystem itself.
'''
btrfs_output = borgmatic.execute.execute_command_and_capture_output(
tuple(btrfs_command.split(' '))
+ (
'subvolume',
'list',
filesystem_mount_point,
)
)
if not filesystem_mount_point.strip():
return ()
return (filesystem_mount_point,) + tuple(
sorted(
subvolume_path
for line in btrfs_output.splitlines()
for subvolume_subpath in (line.rstrip().split(' ')[-1],)
for subvolume_path in (os.path.join(filesystem_mount_point, subvolume_subpath),)
if subvolume_subpath.strip()
)
)
Subvolume = collections.namedtuple('Subvolume', ('path', 'contained_patterns'), defaults=((),))
@ -54,33 +82,30 @@ def get_subvolumes(btrfs_command, findmnt_command, patterns=None):
between the current Btrfs filesystem and subvolume mount points and the paths of any patterns.
The idea is that these pattern paths represent the requested subvolumes to snapshot.
Only include subvolumes that contain at least one root pattern sourced from borgmatic
configuration (as opposed to generated elsewhere in borgmatic). But if patterns is None, then
return all subvolumes instead, sorted by path.
If patterns is None, then return all subvolumes, sorted by path.
Return the result as a sequence of matching subvolume mount points.
'''
candidate_patterns = set(patterns or ())
subvolumes = []
# For each subvolume mount point, match it against the given patterns to find the subvolumes to
# backup. Sort the subvolumes from longest to shortest mount points, so longer mount points get
# a whack at the candidate pattern piñata before their parents do. (Patterns are consumed during
# this process, so no two subvolumes end up with the same contained patterns.)
for mount_point in reversed(get_subvolume_mount_points(findmnt_command)):
# For each filesystem mount point, find its subvolumes and match them against the given patterns
# to find the subvolumes to backup. And within this loop, sort the subvolumes from longest to
# shortest mount points, so longer mount points get a whack at the candidate pattern piñata
# before their parents do. (Patterns are consumed during this process, so no two subvolumes end
# up with the same contained patterns.)
for mount_point in get_filesystem_mount_points(findmnt_command):
subvolumes.extend(
Subvolume(mount_point, contained_patterns)
Subvolume(subvolume_path, contained_patterns)
for subvolume_path in reversed(
get_subvolumes_for_filesystem(btrfs_command, mount_point)
)
for contained_patterns in (
borgmatic.hooks.data_source.snapshot.get_contained_patterns(
mount_point, candidate_patterns
subvolume_path, candidate_patterns
),
)
if patterns is None
or any(
pattern.type == borgmatic.borg.pattern.Pattern_type.ROOT
and pattern.source == borgmatic.borg.pattern.Pattern_source.CONFIG
for pattern in contained_patterns
)
if patterns is None or contained_patterns
)
return tuple(sorted(subvolumes, key=lambda subvolume: subvolume.path))
@ -126,9 +151,8 @@ def make_snapshot_exclude_pattern(subvolume_path): # pragma: no cover
subvolume_path.lstrip(os.path.sep),
snapshot_directory,
),
borgmatic.borg.pattern.Pattern_type.NO_RECURSE,
borgmatic.borg.pattern.Pattern_type.EXCLUDE,
borgmatic.borg.pattern.Pattern_style.FNMATCH,
source=borgmatic.borg.pattern.Pattern_source.HOOK,
)
@ -161,7 +185,6 @@ def make_borg_snapshot_pattern(subvolume_path, pattern):
pattern.type,
pattern.style,
pattern.device,
source=borgmatic.borg.pattern.Pattern_source.HOOK,
)
@ -188,37 +211,38 @@ def snapshot_subvolume(btrfs_command, subvolume_path, snapshot_path): # pragma:
def dump_data_sources(
hook_config,
config,
log_prefix,
config_paths,
borgmatic_runtime_directory,
patterns,
dry_run,
):
'''
Given a Btrfs configuration dict, a configuration dict, the borgmatic configuration file paths,
the borgmatic runtime directory, the configured patterns, and whether this is a dry run,
auto-detect and snapshot any Btrfs subvolume mount points listed in the given patterns. Also
update those patterns, replacing subvolume mount points with corresponding snapshot directories
so they get stored in the Borg archive instead.
Given a Btrfs configuration dict, a configuration dict, a log prefix, the borgmatic
configuration file paths, the borgmatic runtime directory, the configured patterns, and whether
this is a dry run, auto-detect and snapshot any Btrfs subvolume mount points listed in the given
patterns. Also update those patterns, replacing subvolume mount points with corresponding
snapshot directories so they get stored in the Borg archive instead. Use the log prefix in any
log entries.
Return an empty sequence, since there are no ongoing dump processes from this hook.
If this is a dry run, then don't actually snapshot anything.
'''
dry_run_label = ' (dry run; not actually snapshotting anything)' if dry_run else ''
logger.info(f'Snapshotting Btrfs subvolumes{dry_run_label}')
logger.info(f'{log_prefix}: Snapshotting Btrfs subvolumes{dry_run_label}')
# Based on the configured patterns, determine Btrfs subvolumes to backup. Only consider those
# patterns that came from actual user configuration (as opposed to, say, other hooks).
# Based on the configured patterns, determine Btrfs subvolumes to backup.
btrfs_command = hook_config.get('btrfs_command', 'btrfs')
findmnt_command = hook_config.get('findmnt_command', 'findmnt')
subvolumes = get_subvolumes(btrfs_command, findmnt_command, patterns)
if not subvolumes:
logger.warning(f'No Btrfs subvolumes found to snapshot{dry_run_label}')
logger.warning(f'{log_prefix}: No Btrfs subvolumes found to snapshot{dry_run_label}')
# Snapshot each subvolume, rewriting patterns to use their snapshot paths.
for subvolume in subvolumes:
logger.debug(f'Creating Btrfs snapshot for {subvolume.path} subvolume')
logger.debug(f'{log_prefix}: Creating Btrfs snapshot for {subvolume.path} subvolume')
snapshot_path = make_snapshot_path(subvolume.path)
@ -256,11 +280,12 @@ def delete_snapshot(btrfs_command, snapshot_path): # pragma: no cover
)
def remove_data_source_dumps(hook_config, config, borgmatic_runtime_directory, dry_run):
def remove_data_source_dumps(hook_config, config, log_prefix, borgmatic_runtime_directory, dry_run):
'''
Given a Btrfs configuration dict, a configuration dict, the borgmatic runtime directory, and
whether this is a dry run, delete any Btrfs snapshots created by borgmatic. If this is a dry run
or Btrfs isn't configured in borgmatic's configuration, then don't actually remove anything.
Given a Btrfs configuration dict, a configuration dict, a log prefix, the borgmatic runtime
directory, and whether this is a dry run, delete any Btrfs snapshots created by borgmatic. Use
the log prefix in any log entries. If this is a dry run or Btrfs isn't configured in borgmatic's
configuration, then don't actually remove anything.
'''
if hook_config is None:
return
@ -273,10 +298,10 @@ def remove_data_source_dumps(hook_config, config, borgmatic_runtime_directory, d
try:
all_subvolumes = get_subvolumes(btrfs_command, findmnt_command)
except FileNotFoundError as error:
logger.debug(f'Could not find "{error.filename}" command')
logger.debug(f'{log_prefix}: Could not find "{error.filename}" command')
return
except subprocess.CalledProcessError as error:
logger.debug(error)
logger.debug(f'{log_prefix}: {error}')
return
# Reversing the sorted subvolumes ensures that we remove longer mount point paths of child
@ -288,14 +313,14 @@ def remove_data_source_dumps(hook_config, config, borgmatic_runtime_directory, d
)
logger.debug(
f'Looking for snapshots to remove in {subvolume_snapshots_glob}{dry_run_label}'
f'{log_prefix}: Looking for snapshots to remove in {subvolume_snapshots_glob}{dry_run_label}'
)
for snapshot_path in glob.glob(subvolume_snapshots_glob):
if not os.path.isdir(snapshot_path):
continue
logger.debug(f'Deleting Btrfs snapshot {snapshot_path}{dry_run_label}')
logger.debug(f'{log_prefix}: Deleting Btrfs snapshot {snapshot_path}{dry_run_label}')
if dry_run:
continue
@ -303,22 +328,19 @@ def remove_data_source_dumps(hook_config, config, borgmatic_runtime_directory, d
try:
delete_snapshot(btrfs_command, snapshot_path)
except FileNotFoundError:
logger.debug(f'Could not find "{btrfs_command}" command')
logger.debug(f'{log_prefix}: Could not find "{btrfs_command}" command')
return
except subprocess.CalledProcessError as error:
logger.debug(error)
logger.debug(f'{log_prefix}: {error}')
return
# Remove the snapshot parent directory if it still exists. (It might not exist if the
# snapshot was for "/".)
snapshot_parent_dir = snapshot_path.rsplit(subvolume.path, 1)[0]
if os.path.isdir(snapshot_parent_dir):
shutil.rmtree(snapshot_parent_dir)
# Strip off the subvolume path from the end of the snapshot path and then delete the
# resulting directory.
shutil.rmtree(snapshot_path.rsplit(subvolume.path, 1)[0])
def make_data_source_dump_patterns(
hook_config, config, borgmatic_runtime_directory, name=None
hook_config, config, log_prefix, borgmatic_runtime_directory, name=None
): # pragma: no cover
'''
Restores aren't implemented, because stored files can be extracted directly with "extract".
@ -329,6 +351,7 @@ def make_data_source_dump_patterns(
def restore_data_source_dump(
hook_config,
config,
log_prefix,
data_source,
dry_run,
extract_process,

View file

@ -46,14 +46,14 @@ def create_named_pipe_for_dump(dump_path):
os.mkfifo(dump_path, mode=0o600)
def remove_data_source_dumps(dump_path, data_source_type_name, dry_run):
def remove_data_source_dumps(dump_path, data_source_type_name, log_prefix, dry_run):
'''
Remove all data source dumps in the given dump directory path (including the directory itself).
If this is a dry run, then don't actually remove anything.
'''
dry_run_label = ' (dry run; not actually removing anything)' if dry_run else ''
logger.debug(f'Removing {data_source_type_name} data source dumps{dry_run_label}')
logger.debug(f'{log_prefix}: Removing {data_source_type_name} data source dumps{dry_run_label}')
if dry_run:
return

View file

@ -1,6 +1,5 @@
import collections
import glob
import hashlib
import json
import logging
import os
@ -15,7 +14,7 @@ import borgmatic.hooks.data_source.snapshot
logger = logging.getLogger(__name__)
def use_streaming(hook_config, config): # pragma: no cover
def use_streaming(hook_config, config, log_prefix): # pragma: no cover
'''
Return whether dump streaming is used for this hook. (Spoiler: It isn't.)
'''
@ -34,9 +33,7 @@ def get_logical_volumes(lsblk_command, patterns=None):
between the current LVM logical volume mount points and the paths of any patterns. The idea is
that these pattern paths represent the requested logical volumes to snapshot.
Only include logical volumes that contain at least one root pattern sourced from borgmatic
configuration (as opposed to generated elsewhere in borgmatic). But if patterns is None, include
all logical volume mounts points instead, not just those in patterns.
If patterns is None, include all logical volume mounts points, not just those in patterns.
Return the result as a sequence of Logical_volume instances.
'''
@ -75,12 +72,7 @@ def get_logical_volumes(lsblk_command, patterns=None):
device['mountpoint'], candidate_patterns
),
)
if not patterns
or any(
pattern.type == borgmatic.borg.pattern.Pattern_type.ROOT
and pattern.source == borgmatic.borg.pattern.Pattern_source.CONFIG
for pattern in contained_patterns
)
if not patterns or contained_patterns
)
except KeyError as error:
raise ValueError(f'Invalid {lsblk_command} output: Missing key "{error}"')
@ -132,14 +124,10 @@ def mount_snapshot(mount_command, snapshot_device, snapshot_mount_path): # prag
)
MOUNT_POINT_HASH_LENGTH = 10
def make_borg_snapshot_pattern(pattern, logical_volume, normalized_runtime_directory):
def make_borg_snapshot_pattern(pattern, normalized_runtime_directory):
'''
Given a Borg pattern as a borgmatic.borg.pattern.Pattern instance and a Logical_volume
containing it, return a new Pattern with its path rewritten to be in a snapshot directory based
on both the given runtime directory and the given Logical_volume's mount point.
Given a Borg pattern as a borgmatic.borg.pattern.Pattern instance, return a new Pattern with its
path rewritten to be in a snapshot directory based on the given runtime directory.
Move any initial caret in a regular expression pattern path to the beginning, so as not to break
the regular expression.
@ -154,13 +142,6 @@ def make_borg_snapshot_pattern(pattern, logical_volume, normalized_runtime_direc
rewritten_path = initial_caret + os.path.join(
normalized_runtime_directory,
'lvm_snapshots',
# Including this hash prevents conflicts between snapshot patterns for different logical
# volumes. For instance, without this, snapshotting a logical volume at /var and another at
# /var/spool would result in overlapping snapshot patterns and therefore colliding mount
# attempts.
hashlib.shake_256(logical_volume.mount_point.encode('utf-8')).hexdigest(
MOUNT_POINT_HASH_LENGTH
),
'.', # Borg 1.4+ "slashdot" hack.
# Included so that the source directory ends up in the Borg archive at its "original" path.
pattern.path.lstrip('^').lstrip(os.path.sep),
@ -171,7 +152,6 @@ def make_borg_snapshot_pattern(pattern, logical_volume, normalized_runtime_direc
pattern.type,
pattern.style,
pattern.device,
source=borgmatic.borg.pattern.Pattern_source.HOOK,
)
@ -181,27 +161,28 @@ DEFAULT_SNAPSHOT_SIZE = '10%ORIGIN'
def dump_data_sources(
hook_config,
config,
log_prefix,
config_paths,
borgmatic_runtime_directory,
patterns,
dry_run,
):
'''
Given an LVM configuration dict, a configuration dict, the borgmatic configuration file paths,
the borgmatic runtime directory, the configured patterns, and whether this is a dry run,
auto-detect and snapshot any LVM logical volume mount points listed in the given patterns. Also
update those patterns, replacing logical volume mount points with corresponding snapshot
directories so they get stored in the Borg archive instead.
Given an LVM configuration dict, a configuration dict, a log prefix, the borgmatic configuration
file paths, the borgmatic runtime directory, the configured patterns, and whether this is a dry
run, auto-detect and snapshot any LVM logical volume mount points listed in the given patterns.
Also update those patterns, replacing logical volume mount points with corresponding snapshot
directories so they get stored in the Borg archive instead. Use the log prefix in any log
entries.
Return an empty sequence, since there are no ongoing dump processes from this hook.
If this is a dry run, then don't actually snapshot anything.
'''
dry_run_label = ' (dry run; not actually snapshotting anything)' if dry_run else ''
logger.info(f'Snapshotting LVM logical volumes{dry_run_label}')
logger.info(f'{log_prefix}: Snapshotting LVM logical volumes{dry_run_label}')
# List logical volumes to get their mount points, but only consider those patterns that came
# from actual user configuration (as opposed to, say, other hooks).
# List logical volumes to get their mount points.
lsblk_command = hook_config.get('lsblk_command', 'lsblk')
requested_logical_volumes = get_logical_volumes(lsblk_command, patterns)
@ -210,12 +191,12 @@ def dump_data_sources(
normalized_runtime_directory = os.path.normpath(borgmatic_runtime_directory)
if not requested_logical_volumes:
logger.warning(f'No LVM logical volumes found to snapshot{dry_run_label}')
logger.warning(f'{log_prefix}: No LVM logical volumes found to snapshot{dry_run_label}')
for logical_volume in requested_logical_volumes:
snapshot_name = f'{logical_volume.name}_{snapshot_suffix}'
logger.debug(
f'Creating LVM snapshot {snapshot_name} of {logical_volume.mount_point}{dry_run_label}'
f'{log_prefix}: Creating LVM snapshot {snapshot_name} of {logical_volume.mount_point}{dry_run_label}'
)
if not dry_run:
@ -239,14 +220,11 @@ def dump_data_sources(
snapshot_mount_path = os.path.join(
normalized_runtime_directory,
'lvm_snapshots',
hashlib.shake_256(logical_volume.mount_point.encode('utf-8')).hexdigest(
MOUNT_POINT_HASH_LENGTH
),
logical_volume.mount_point.lstrip(os.path.sep),
)
logger.debug(
f'Mounting LVM snapshot {snapshot_name} at {snapshot_mount_path}{dry_run_label}'
f'{log_prefix}: Mounting LVM snapshot {snapshot_name} at {snapshot_mount_path}{dry_run_label}'
)
if dry_run:
@ -257,9 +235,7 @@ def dump_data_sources(
)
for pattern in logical_volume.contained_patterns:
snapshot_pattern = make_borg_snapshot_pattern(
pattern, logical_volume, normalized_runtime_directory
)
snapshot_pattern = make_borg_snapshot_pattern(pattern, normalized_runtime_directory)
# Attempt to update the pattern in place, since pattern order matters to Borg.
try:
@ -336,12 +312,12 @@ def get_snapshots(lvs_command, snapshot_name=None):
raise ValueError(f'Invalid {lvs_command} output: Missing key "{error}"')
def remove_data_source_dumps(hook_config, config, borgmatic_runtime_directory, dry_run):
def remove_data_source_dumps(hook_config, config, log_prefix, borgmatic_runtime_directory, dry_run):
'''
Given an LVM configuration dict, a configuration dict, the borgmatic runtime directory, and
whether this is a dry run, unmount and delete any LVM snapshots created by borgmatic. If this is
a dry run or LVM isn't configured in borgmatic's configuration, then don't actually remove
anything.
Given an LVM configuration dict, a configuration dict, a log prefix, the borgmatic runtime
directory, and whether this is a dry run, unmount and delete any LVM snapshots created by
borgmatic. Use the log prefix in any log entries. If this is a dry run or LVM isn't configured
in borgmatic's configuration, then don't actually remove anything.
'''
if hook_config is None:
return
@ -352,10 +328,10 @@ def remove_data_source_dumps(hook_config, config, borgmatic_runtime_directory, d
try:
logical_volumes = get_logical_volumes(hook_config.get('lsblk_command', 'lsblk'))
except FileNotFoundError as error:
logger.debug(f'Could not find "{error.filename}" command')
logger.debug(f'{log_prefix}: Could not find "{error.filename}" command')
return
except subprocess.CalledProcessError as error:
logger.debug(error)
logger.debug(f'{log_prefix}: {error}')
return
snapshots_glob = os.path.join(
@ -363,9 +339,10 @@ def remove_data_source_dumps(hook_config, config, borgmatic_runtime_directory, d
os.path.normpath(borgmatic_runtime_directory),
),
'lvm_snapshots',
'*',
)
logger.debug(f'Looking for snapshots to remove in {snapshots_glob}{dry_run_label}')
logger.debug(
f'{log_prefix}: Looking for snapshots to remove in {snapshots_glob}{dry_run_label}'
)
umount_command = hook_config.get('umount_command', 'umount')
for snapshots_directory in glob.glob(snapshots_glob):
@ -376,10 +353,7 @@ def remove_data_source_dumps(hook_config, config, borgmatic_runtime_directory, d
snapshot_mount_path = os.path.join(
snapshots_directory, logical_volume.mount_point.lstrip(os.path.sep)
)
# If the snapshot mount path is empty, this is probably just a "shadow" of a nested
# logical volume and therefore there's nothing to unmount.
if not os.path.isdir(snapshot_mount_path) or not os.listdir(snapshot_mount_path):
if not os.path.isdir(snapshot_mount_path):
continue
# This might fail if the directory is already mounted, but we swallow errors here since
@ -392,7 +366,9 @@ def remove_data_source_dumps(hook_config, config, borgmatic_runtime_directory, d
if not os.path.isdir(snapshot_mount_path):
continue
logger.debug(f'Unmounting LVM snapshot at {snapshot_mount_path}{dry_run_label}')
logger.debug(
f'{log_prefix}: Unmounting LVM snapshot at {snapshot_mount_path}{dry_run_label}'
)
if dry_run:
continue
@ -400,11 +376,11 @@ def remove_data_source_dumps(hook_config, config, borgmatic_runtime_directory, d
try:
unmount_snapshot(umount_command, snapshot_mount_path)
except FileNotFoundError:
logger.debug(f'Could not find "{umount_command}" command')
logger.debug(f'{log_prefix}: Could not find "{umount_command}" command')
return
except subprocess.CalledProcessError as error:
logger.debug(error)
continue
logger.debug(f'{log_prefix}: {error}')
return
if not dry_run:
shutil.rmtree(snapshots_directory)
@ -415,10 +391,10 @@ def remove_data_source_dumps(hook_config, config, borgmatic_runtime_directory, d
try:
snapshots = get_snapshots(hook_config.get('lvs_command', 'lvs'))
except FileNotFoundError as error:
logger.debug(f'Could not find "{error.filename}" command')
logger.debug(f'{log_prefix}: Could not find "{error.filename}" command')
return
except subprocess.CalledProcessError as error:
logger.debug(error)
logger.debug(f'{log_prefix}: {error}')
return
for snapshot in snapshots:
@ -426,14 +402,14 @@ def remove_data_source_dumps(hook_config, config, borgmatic_runtime_directory, d
if not snapshot.name.split('_')[-1].startswith(BORGMATIC_SNAPSHOT_PREFIX):
continue
logger.debug(f'Deleting LVM snapshot {snapshot.name}{dry_run_label}')
logger.debug(f'{log_prefix}: Deleting LVM snapshot {snapshot.name}{dry_run_label}')
if not dry_run:
remove_snapshot(lvremove_command, snapshot.device_path)
def make_data_source_dump_patterns(
hook_config, config, borgmatic_runtime_directory, name=None
hook_config, config, log_prefix, borgmatic_runtime_directory, name=None
): # pragma: no cover
'''
Restores aren't implemented, because stored files can be extracted directly with "extract".
@ -444,6 +420,7 @@ def make_data_source_dump_patterns(
def restore_data_source_dump(
hook_config,
config,
log_prefix,
data_source,
dry_run,
extract_process,

View file

@ -5,7 +5,6 @@ import shlex
import borgmatic.borg.pattern
import borgmatic.config.paths
import borgmatic.hooks.credential.parse
from borgmatic.execute import (
execute_command,
execute_command_and_capture_output,
@ -26,11 +25,11 @@ def make_dump_path(base_directory): # pragma: no cover
SYSTEM_DATABASE_NAMES = ('information_schema', 'mysql', 'performance_schema', 'sys')
def database_names_to_dump(database, config, environment, dry_run):
def database_names_to_dump(database, extra_environment, log_prefix, dry_run):
'''
Given a requested database config and a configuration dict, return the corresponding sequence of
database names to dump. In the case of "all", query for the names of databases on the configured
host and return them, excluding any system databases that will cause problems during restore.
Given a requested database config, return the corresponding sequence of database names to dump.
In the case of "all", query for the names of databases on the configured host and return them,
excluding any system databases that will cause problems during restore.
'''
if database['name'] != 'all':
return (database['name'],)
@ -46,19 +45,14 @@ def database_names_to_dump(database, config, environment, dry_run):
+ (('--host', database['hostname']) if 'hostname' in database else ())
+ (('--port', str(database['port'])) if 'port' in database else ())
+ (('--protocol', 'tcp') if 'hostname' in database or 'port' in database else ())
+ (
(
'--user',
borgmatic.hooks.credential.parse.resolve_credential(database['username'], config),
)
if 'username' in database
else ()
)
+ (('--user', database['username']) if 'username' in database else ())
+ ('--skip-column-names', '--batch')
+ ('--execute', 'show schemas')
)
logger.debug('Querying for "all" MariaDB databases to dump')
show_output = execute_command_and_capture_output(show_command, environment=environment)
logger.debug(f'{log_prefix}: Querying for "all" MariaDB databases to dump')
show_output = execute_command_and_capture_output(
show_command, extra_environment=extra_environment
)
return tuple(
show_name
@ -68,11 +62,12 @@ def database_names_to_dump(database, config, environment, dry_run):
def execute_dump_command(
database, config, dump_path, database_names, environment, dry_run, dry_run_label
database, log_prefix, dump_path, database_names, extra_environment, dry_run, dry_run_label
):
'''
Kick off a dump for the given MariaDB database (provided as a configuration dict) to a named
pipe constructed from the given dump path and database name.
pipe constructed from the given dump path and database name. Use the given log prefix in any
log entries.
Return a subprocess.Popen instance for the dump process ready to spew to a named pipe. But if
this is a dry run, then don't actually dump anything and return None.
@ -87,7 +82,7 @@ def execute_dump_command(
if os.path.exists(dump_filename):
logger.warning(
f'Skipping duplicate dump of MariaDB database "{database_name}" to {dump_filename}'
f'{log_prefix}: Skipping duplicate dump of MariaDB database "{database_name}" to {dump_filename}'
)
return None
@ -102,20 +97,15 @@ def execute_dump_command(
+ (('--host', database['hostname']) if 'hostname' in database else ())
+ (('--port', str(database['port'])) if 'port' in database else ())
+ (('--protocol', 'tcp') if 'hostname' in database or 'port' in database else ())
+ (
(
'--user',
borgmatic.hooks.credential.parse.resolve_credential(database['username'], config),
)
if 'username' in database
else ()
)
+ (('--user', database['username']) if 'username' in database else ())
+ ('--databases',)
+ database_names
+ ('--result-file', dump_filename)
)
logger.debug(f'Dumping MariaDB database "{database_name}" to {dump_filename}{dry_run_label}')
logger.debug(
f'{log_prefix}: Dumping MariaDB database "{database_name}" to {dump_filename}{dry_run_label}'
)
if dry_run:
return None
@ -123,19 +113,19 @@ def execute_dump_command(
return execute_command(
dump_command,
environment=environment,
extra_environment=extra_environment,
run_to_completion=False,
)
def get_default_port(databases, config): # pragma: no cover
def get_default_port(databases, config, log_prefix): # pragma: no cover
return 3306
def use_streaming(databases, config):
def use_streaming(databases, config, log_prefix):
'''
Given a sequence of MariaDB database configuration dicts, a configuration dict (ignored), return
whether streaming will be using during dumps.
Given a sequence of MariaDB database configuration dicts, a configuration dict (ignored), and a
log prefix (ignored), return whether streaming will be using during dumps.
'''
return any(databases)
@ -143,6 +133,7 @@ def use_streaming(databases, config):
def dump_data_sources(
databases,
config,
log_prefix,
config_paths,
borgmatic_runtime_directory,
patterns,
@ -151,7 +142,8 @@ def dump_data_sources(
'''
Dump the given MariaDB databases to a named pipe. The databases are supplied as a sequence of
dicts, one dict describing each database as per the configuration schema. Use the given
borgmatic runtime directory to construct the destination path.
borgmatic runtime directory to construct the destination path and the given log prefix in any
log entries.
Return a sequence of subprocess.Popen instances for the dump processes ready to spew to a named
pipe. But if this is a dry run, then don't actually dump anything and return an empty sequence.
@ -161,23 +153,14 @@ def dump_data_sources(
dry_run_label = ' (dry run; not actually dumping anything)' if dry_run else ''
processes = []
logger.info(f'Dumping MariaDB databases{dry_run_label}')
logger.info(f'{log_prefix}: Dumping MariaDB databases{dry_run_label}')
for database in databases:
dump_path = make_dump_path(borgmatic_runtime_directory)
environment = dict(
os.environ,
**(
{
'MYSQL_PWD': borgmatic.hooks.credential.parse.resolve_credential(
database['password'], config
)
}
if 'password' in database
else {}
),
extra_environment = {'MYSQL_PWD': database['password']} if 'password' in database else None
dump_database_names = database_names_to_dump(
database, extra_environment, log_prefix, dry_run
)
dump_database_names = database_names_to_dump(database, config, environment, dry_run)
if not dump_database_names:
if dry_run:
@ -192,10 +175,10 @@ def dump_data_sources(
processes.append(
execute_dump_command(
renamed_database,
config,
log_prefix,
dump_path,
(dump_name,),
environment,
extra_environment,
dry_run,
dry_run_label,
)
@ -204,10 +187,10 @@ def dump_data_sources(
processes.append(
execute_dump_command(
database,
config,
log_prefix,
dump_path,
dump_database_names,
environment,
extra_environment,
dry_run,
dry_run_label,
)
@ -216,8 +199,7 @@ def dump_data_sources(
if not dry_run:
patterns.append(
borgmatic.borg.pattern.Pattern(
os.path.join(borgmatic_runtime_directory, 'mariadb_databases'),
source=borgmatic.borg.pattern.Pattern_source.HOOK,
os.path.join(borgmatic_runtime_directory, 'mariadb_databases')
)
)
@ -225,23 +207,25 @@ def dump_data_sources(
def remove_data_source_dumps(
databases, config, borgmatic_runtime_directory, dry_run
databases, config, log_prefix, borgmatic_runtime_directory, dry_run
): # pragma: no cover
'''
Remove all database dump files for this hook regardless of the given databases. Use the
borgmatic_runtime_directory to construct the destination path. If this is a dry run, then don't
actually remove anything.
borgmatic_runtime_directory to construct the destination path and the log prefix in any log
entries. If this is a dry run, then don't actually remove anything.
'''
dump.remove_data_source_dumps(make_dump_path(borgmatic_runtime_directory), 'MariaDB', dry_run)
dump.remove_data_source_dumps(
make_dump_path(borgmatic_runtime_directory), 'MariaDB', log_prefix, dry_run
)
def make_data_source_dump_patterns(
databases, config, borgmatic_runtime_directory, name=None
databases, config, log_prefix, borgmatic_runtime_directory, name=None
): # pragma: no cover
'''
Given a sequence of configurations dicts, a configuration dict, the borgmatic runtime directory,
and a database name to match, return the corresponding glob patterns to match the database dump
in an archive.
Given a sequence of configurations dicts, a configuration dict, a prefix to log with, the
borgmatic runtime directory, and a database name to match, return the corresponding glob
patterns to match the database dump in an archive.
'''
borgmatic_source_directory = borgmatic.config.paths.get_borgmatic_source_directory(config)
@ -259,6 +243,7 @@ def make_data_source_dump_patterns(
def restore_data_source_dump(
hook_config,
config,
log_prefix,
data_source,
dry_run,
extract_process,
@ -267,9 +252,9 @@ def restore_data_source_dump(
):
'''
Restore a database from the given extract stream. The database is supplied as a data source
configuration dict, but the given hook configuration is ignored. If this is a dry run, then
don't actually restore anything. Trigger the given active extract process (an instance of
subprocess.Popen) to produce output to consume.
configuration dict, but the given hook configuration is ignored. The given log prefix is used
for any log entries. If this is a dry run, then don't actually restore anything. Trigger the
given active extract process (an instance of subprocess.Popen) to produce output to consume.
'''
dry_run_label = ' (dry run; not actually restoring anything)' if dry_run else ''
hostname = connection_params['hostname'] or data_source.get(
@ -278,19 +263,11 @@ def restore_data_source_dump(
port = str(
connection_params['port'] or data_source.get('restore_port', data_source.get('port', ''))
)
username = borgmatic.hooks.credential.parse.resolve_credential(
(
connection_params['username']
or data_source.get('restore_username', data_source.get('username'))
),
config,
username = connection_params['username'] or data_source.get(
'restore_username', data_source.get('username')
)
password = borgmatic.hooks.credential.parse.resolve_credential(
(
connection_params['password']
or data_source.get('restore_password', data_source.get('password'))
),
config,
password = connection_params['password'] or data_source.get(
'restore_password', data_source.get('password')
)
mariadb_restore_command = tuple(
@ -309,9 +286,9 @@ def restore_data_source_dump(
+ (('--protocol', 'tcp') if hostname or port else ())
+ (('--user', username) if username else ())
)
environment = dict(os.environ, **({'MYSQL_PWD': password} if password else {}))
extra_environment = {'MYSQL_PWD': password} if password else None
logger.debug(f"Restoring MariaDB database {data_source['name']}{dry_run_label}")
logger.debug(f"{log_prefix}: Restoring MariaDB database {data_source['name']}{dry_run_label}")
if dry_run:
return
@ -322,5 +299,5 @@ def restore_data_source_dump(
[extract_process],
output_log_level=logging.DEBUG,
input_file=extract_process.stdout,
environment=environment,
extra_environment=extra_environment,
)

View file

@ -4,7 +4,6 @@ import shlex
import borgmatic.borg.pattern
import borgmatic.config.paths
import borgmatic.hooks.credential.parse
from borgmatic.execute import execute_command, execute_command_with_processes
from borgmatic.hooks.data_source import dump
@ -18,14 +17,14 @@ def make_dump_path(base_directory): # pragma: no cover
return dump.make_data_source_dump_path(base_directory, 'mongodb_databases')
def get_default_port(databases, config): # pragma: no cover
def get_default_port(databases, config, log_prefix): # pragma: no cover
return 27017
def use_streaming(databases, config):
def use_streaming(databases, config, log_prefix):
'''
Given a sequence of MongoDB database configuration dicts, a configuration dict (ignored), return
whether streaming will be using during dumps.
Given a sequence of MongoDB database configuration dicts, a configuration dict (ignored), and a
log prefix (ignored), return whether streaming will be using during dumps.
'''
return any(database.get('format') != 'directory' for database in databases)
@ -33,6 +32,7 @@ def use_streaming(databases, config):
def dump_data_sources(
databases,
config,
log_prefix,
config_paths,
borgmatic_runtime_directory,
patterns,
@ -41,7 +41,8 @@ def dump_data_sources(
'''
Dump the given MongoDB databases to a named pipe. The databases are supplied as a sequence of
dicts, one dict describing each database as per the configuration schema. Use the borgmatic
runtime directory to construct the destination path (used for the directory format.
runtime directory to construct the destination path (used for the directory format and the given
log prefix in any log entries.
Return a sequence of subprocess.Popen instances for the dump processes ready to spew to a named
pipe. But if this is a dry run, then don't actually dump anything and return an empty sequence.
@ -50,7 +51,7 @@ def dump_data_sources(
'''
dry_run_label = ' (dry run; not actually dumping anything)' if dry_run else ''
logger.info(f'Dumping MongoDB databases{dry_run_label}')
logger.info(f'{log_prefix}: Dumping MongoDB databases{dry_run_label}')
processes = []
for database in databases:
@ -64,12 +65,12 @@ def dump_data_sources(
dump_format = database.get('format', 'archive')
logger.debug(
f'Dumping MongoDB database {name} to {dump_filename}{dry_run_label}',
f'{log_prefix}: Dumping MongoDB database {name} to {dump_filename}{dry_run_label}',
)
if dry_run:
continue
command = build_dump_command(database, config, dump_filename, dump_format)
command = build_dump_command(database, dump_filename, dump_format)
if dump_format == 'directory':
dump.create_parent_directory_for_dump(dump_filename)
@ -81,15 +82,14 @@ def dump_data_sources(
if not dry_run:
patterns.append(
borgmatic.borg.pattern.Pattern(
os.path.join(borgmatic_runtime_directory, 'mongodb_databases'),
source=borgmatic.borg.pattern.Pattern_source.HOOK,
os.path.join(borgmatic_runtime_directory, 'mongodb_databases')
)
)
return processes
def build_dump_command(database, config, dump_filename, dump_format):
def build_dump_command(database, dump_filename, dump_format):
'''
Return the mongodump command from a single database configuration.
'''
@ -100,30 +100,8 @@ def build_dump_command(database, config, dump_filename, dump_format):
+ (('--out', shlex.quote(dump_filename)) if dump_format == 'directory' else ())
+ (('--host', shlex.quote(database['hostname'])) if 'hostname' in database else ())
+ (('--port', shlex.quote(str(database['port']))) if 'port' in database else ())
+ (
(
'--username',
shlex.quote(
borgmatic.hooks.credential.parse.resolve_credential(
database['username'], config
)
),
)
if 'username' in database
else ()
)
+ (
(
'--password',
shlex.quote(
borgmatic.hooks.credential.parse.resolve_credential(
database['password'], config
)
),
)
if 'password' in database
else ()
)
+ (('--username', shlex.quote(database['username'])) if 'username' in database else ())
+ (('--password', shlex.quote(database['password'])) if 'password' in database else ())
+ (
('--authenticationDatabase', shlex.quote(database['authentication_database']))
if 'authentication_database' in database
@ -140,23 +118,25 @@ def build_dump_command(database, config, dump_filename, dump_format):
def remove_data_source_dumps(
databases, config, borgmatic_runtime_directory, dry_run
databases, config, log_prefix, borgmatic_runtime_directory, dry_run
): # pragma: no cover
'''
Remove all database dump files for this hook regardless of the given databases. Use the
borgmatic_runtime_directory to construct the destination path. If this is a dry run, then don't
actually remove anything.
borgmatic_runtime_directory to construct the destination path and the log prefix in any log
entries. If this is a dry run, then don't actually remove anything.
'''
dump.remove_data_source_dumps(make_dump_path(borgmatic_runtime_directory), 'MongoDB', dry_run)
dump.remove_data_source_dumps(
make_dump_path(borgmatic_runtime_directory), 'MongoDB', log_prefix, dry_run
)
def make_data_source_dump_patterns(
databases, config, borgmatic_runtime_directory, name=None
databases, config, log_prefix, borgmatic_runtime_directory, name=None
): # pragma: no cover
'''
Given a sequence of configurations dicts, a configuration dict, the borgmatic runtime directory,
and a database name to match, return the corresponding glob patterns to match the database dump
in an archive.
Given a sequence of configurations dicts, a configuration dict, a prefix to log with, the
borgmatic runtime directory, and a database name to match, return the corresponding glob
patterns to match the database dump in an archive.
'''
borgmatic_source_directory = borgmatic.config.paths.get_borgmatic_source_directory(config)
@ -174,6 +154,7 @@ def make_data_source_dump_patterns(
def restore_data_source_dump(
hook_config,
config,
log_prefix,
data_source,
dry_run,
extract_process,
@ -183,9 +164,9 @@ def restore_data_source_dump(
'''
Restore a database from the given extract stream. The database is supplied as a data source
configuration dict, but the given hook configuration is ignored. The given configuration dict is
used to construct the destination path. If this is a dry run, then don't actually restore
anything. Trigger the given active extract process (an instance of subprocess.Popen) to produce
output to consume.
used to construct the destination path, and the given log prefix is used for any log entries. If
this is a dry run, then don't actually restore anything. Trigger the given active extract
process (an instance of subprocess.Popen) to produce output to consume.
If the extract process is None, then restore the dump from the filesystem rather than from an
extract stream.
@ -197,10 +178,10 @@ def restore_data_source_dump(
data_source.get('hostname'),
)
restore_command = build_restore_command(
extract_process, data_source, config, dump_filename, connection_params
extract_process, data_source, dump_filename, connection_params
)
logger.debug(f"Restoring MongoDB database {data_source['name']}{dry_run_label}")
logger.debug(f"{log_prefix}: Restoring MongoDB database {data_source['name']}{dry_run_label}")
if dry_run:
return
@ -214,7 +195,7 @@ def restore_data_source_dump(
)
def build_restore_command(extract_process, database, config, dump_filename, connection_params):
def build_restore_command(extract_process, database, dump_filename, connection_params):
'''
Return the mongorestore command from a single database configuration.
'''
@ -222,19 +203,11 @@ def build_restore_command(extract_process, database, config, dump_filename, conn
'restore_hostname', database.get('hostname')
)
port = str(connection_params['port'] or database.get('restore_port', database.get('port', '')))
username = borgmatic.hooks.credential.parse.resolve_credential(
(
connection_params['username']
or database.get('restore_username', database.get('username'))
),
config,
username = connection_params['username'] or database.get(
'restore_username', database.get('username')
)
password = borgmatic.hooks.credential.parse.resolve_credential(
(
connection_params['password']
or database.get('restore_password', database.get('password'))
),
config,
password = connection_params['password'] or database.get(
'restore_password', database.get('password')
)
command = ['mongorestore']

View file

@ -5,7 +5,6 @@ import shlex
import borgmatic.borg.pattern
import borgmatic.config.paths
import borgmatic.hooks.credential.parse
from borgmatic.execute import (
execute_command,
execute_command_and_capture_output,
@ -26,11 +25,11 @@ def make_dump_path(base_directory): # pragma: no cover
SYSTEM_DATABASE_NAMES = ('information_schema', 'mysql', 'performance_schema', 'sys')
def database_names_to_dump(database, config, environment, dry_run):
def database_names_to_dump(database, extra_environment, log_prefix, dry_run):
'''
Given a requested database config and a configuration dict, return the corresponding sequence of
database names to dump. In the case of "all", query for the names of databases on the configured
host and return them, excluding any system databases that will cause problems during restore.
Given a requested database config, return the corresponding sequence of database names to dump.
In the case of "all", query for the names of databases on the configured host and return them,
excluding any system databases that will cause problems during restore.
'''
if database['name'] != 'all':
return (database['name'],)
@ -46,19 +45,14 @@ def database_names_to_dump(database, config, environment, dry_run):
+ (('--host', database['hostname']) if 'hostname' in database else ())
+ (('--port', str(database['port'])) if 'port' in database else ())
+ (('--protocol', 'tcp') if 'hostname' in database or 'port' in database else ())
+ (
(
'--user',
borgmatic.hooks.credential.parse.resolve_credential(database['username'], config),
)
if 'username' in database
else ()
)
+ (('--user', database['username']) if 'username' in database else ())
+ ('--skip-column-names', '--batch')
+ ('--execute', 'show schemas')
)
logger.debug('Querying for "all" MySQL databases to dump')
show_output = execute_command_and_capture_output(show_command, environment=environment)
logger.debug(f'{log_prefix}: Querying for "all" MySQL databases to dump')
show_output = execute_command_and_capture_output(
show_command, extra_environment=extra_environment
)
return tuple(
show_name
@ -68,11 +62,12 @@ def database_names_to_dump(database, config, environment, dry_run):
def execute_dump_command(
database, config, dump_path, database_names, environment, dry_run, dry_run_label
database, log_prefix, dump_path, database_names, extra_environment, dry_run, dry_run_label
):
'''
Kick off a dump for the given MySQL/MariaDB database (provided as a configuration dict) to a
named pipe constructed from the given dump path and database name.
named pipe constructed from the given dump path and database name. Use the given log prefix in
any log entries.
Return a subprocess.Popen instance for the dump process ready to spew to a named pipe. But if
this is a dry run, then don't actually dump anything and return None.
@ -87,7 +82,7 @@ def execute_dump_command(
if os.path.exists(dump_filename):
logger.warning(
f'Skipping duplicate dump of MySQL database "{database_name}" to {dump_filename}'
f'{log_prefix}: Skipping duplicate dump of MySQL database "{database_name}" to {dump_filename}'
)
return None
@ -101,20 +96,15 @@ def execute_dump_command(
+ (('--host', database['hostname']) if 'hostname' in database else ())
+ (('--port', str(database['port'])) if 'port' in database else ())
+ (('--protocol', 'tcp') if 'hostname' in database or 'port' in database else ())
+ (
(
'--user',
borgmatic.hooks.credential.parse.resolve_credential(database['username'], config),
)
if 'username' in database
else ()
)
+ (('--user', database['username']) if 'username' in database else ())
+ ('--databases',)
+ database_names
+ ('--result-file', dump_filename)
)
logger.debug(f'Dumping MySQL database "{database_name}" to {dump_filename}{dry_run_label}')
logger.debug(
f'{log_prefix}: Dumping MySQL database "{database_name}" to {dump_filename}{dry_run_label}'
)
if dry_run:
return None
@ -122,19 +112,19 @@ def execute_dump_command(
return execute_command(
dump_command,
environment=environment,
extra_environment=extra_environment,
run_to_completion=False,
)
def get_default_port(databases, config): # pragma: no cover
def get_default_port(databases, config, log_prefix): # pragma: no cover
return 3306
def use_streaming(databases, config):
def use_streaming(databases, config, log_prefix):
'''
Given a sequence of MySQL database configuration dicts, a configuration dict (ignored), return
whether streaming will be using during dumps.
Given a sequence of MySQL database configuration dicts, a configuration dict (ignored), and a
log prefix (ignored), return whether streaming will be using during dumps.
'''
return any(databases)
@ -142,6 +132,7 @@ def use_streaming(databases, config):
def dump_data_sources(
databases,
config,
log_prefix,
config_paths,
borgmatic_runtime_directory,
patterns,
@ -150,7 +141,8 @@ def dump_data_sources(
'''
Dump the given MySQL/MariaDB databases to a named pipe. The databases are supplied as a sequence
of dicts, one dict describing each database as per the configuration schema. Use the given
borgmatic runtime directory to construct the destination path.
borgmatic runtime directory to construct the destination path and the given log prefix in any
log entries.
Return a sequence of subprocess.Popen instances for the dump processes ready to spew to a named
pipe. But if this is a dry run, then don't actually dump anything and return an empty sequence.
@ -160,23 +152,14 @@ def dump_data_sources(
dry_run_label = ' (dry run; not actually dumping anything)' if dry_run else ''
processes = []
logger.info(f'Dumping MySQL databases{dry_run_label}')
logger.info(f'{log_prefix}: Dumping MySQL databases{dry_run_label}')
for database in databases:
dump_path = make_dump_path(borgmatic_runtime_directory)
environment = dict(
os.environ,
**(
{
'MYSQL_PWD': borgmatic.hooks.credential.parse.resolve_credential(
database['password'], config
)
}
if 'password' in database
else {}
),
extra_environment = {'MYSQL_PWD': database['password']} if 'password' in database else None
dump_database_names = database_names_to_dump(
database, extra_environment, log_prefix, dry_run
)
dump_database_names = database_names_to_dump(database, config, environment, dry_run)
if not dump_database_names:
if dry_run:
@ -191,10 +174,10 @@ def dump_data_sources(
processes.append(
execute_dump_command(
renamed_database,
config,
log_prefix,
dump_path,
(dump_name,),
environment,
extra_environment,
dry_run,
dry_run_label,
)
@ -203,10 +186,10 @@ def dump_data_sources(
processes.append(
execute_dump_command(
database,
config,
log_prefix,
dump_path,
dump_database_names,
environment,
extra_environment,
dry_run,
dry_run_label,
)
@ -215,8 +198,7 @@ def dump_data_sources(
if not dry_run:
patterns.append(
borgmatic.borg.pattern.Pattern(
os.path.join(borgmatic_runtime_directory, 'mysql_databases'),
source=borgmatic.borg.pattern.Pattern_source.HOOK,
os.path.join(borgmatic_runtime_directory, 'mysql_databases')
)
)
@ -224,23 +206,25 @@ def dump_data_sources(
def remove_data_source_dumps(
databases, config, borgmatic_runtime_directory, dry_run
databases, config, log_prefix, borgmatic_runtime_directory, dry_run
): # pragma: no cover
'''
Remove all database dump files for this hook regardless of the given databases. Use the
borgmatic runtime directory to construct the destination path. If this is a dry run, then don't
actually remove anything.
borgmatic runtime directory to construct the destination path and the log prefix in any log
entries. If this is a dry run, then don't actually remove anything.
'''
dump.remove_data_source_dumps(make_dump_path(borgmatic_runtime_directory), 'MySQL', dry_run)
dump.remove_data_source_dumps(
make_dump_path(borgmatic_runtime_directory), 'MySQL', log_prefix, dry_run
)
def make_data_source_dump_patterns(
databases, config, borgmatic_runtime_directory, name=None
databases, config, log_prefix, borgmatic_runtime_directory, name=None
): # pragma: no cover
'''
Given a sequence of configurations dicts, a configuration dict, the borgmatic runtime directory,
and a database name to match, return the corresponding glob patterns to match the database dump
in an archive.
Given a sequence of configurations dicts, a configuration dict, a prefix to log with, the
borgmatic runtime directory, and a database name to match, return the corresponding glob
patterns to match the database dump in an archive.
'''
borgmatic_source_directory = borgmatic.config.paths.get_borgmatic_source_directory(config)
@ -258,6 +242,7 @@ def make_data_source_dump_patterns(
def restore_data_source_dump(
hook_config,
config,
log_prefix,
data_source,
dry_run,
extract_process,
@ -266,9 +251,9 @@ def restore_data_source_dump(
):
'''
Restore a database from the given extract stream. The database is supplied as a data source
configuration dict, but the given hook configuration is ignored. If this is a dry run, then
don't actually restore anything. Trigger the given active extract process (an instance of
subprocess.Popen) to produce output to consume.
configuration dict, but the given hook configuration is ignored. The given log prefix is used
for any log entries. If this is a dry run, then don't actually restore anything. Trigger the
given active extract process (an instance of subprocess.Popen) to produce output to consume.
'''
dry_run_label = ' (dry run; not actually restoring anything)' if dry_run else ''
hostname = connection_params['hostname'] or data_source.get(
@ -277,19 +262,11 @@ def restore_data_source_dump(
port = str(
connection_params['port'] or data_source.get('restore_port', data_source.get('port', ''))
)
username = borgmatic.hooks.credential.parse.resolve_credential(
(
connection_params['username']
or data_source.get('restore_username', data_source.get('username'))
),
config,
username = connection_params['username'] or data_source.get(
'restore_username', data_source.get('username')
)
password = borgmatic.hooks.credential.parse.resolve_credential(
(
connection_params['password']
or data_source.get('restore_password', data_source.get('password'))
),
config,
password = connection_params['password'] or data_source.get(
'restore_password', data_source.get('password')
)
mysql_restore_command = tuple(
@ -308,9 +285,9 @@ def restore_data_source_dump(
+ (('--protocol', 'tcp') if hostname or port else ())
+ (('--user', username) if username else ())
)
environment = dict(os.environ, **({'MYSQL_PWD': password} if password else {}))
extra_environment = {'MYSQL_PWD': password} if password else None
logger.debug(f"Restoring MySQL database {data_source['name']}{dry_run_label}")
logger.debug(f"{log_prefix}: Restoring MySQL database {data_source['name']}{dry_run_label}")
if dry_run:
return
@ -321,5 +298,5 @@ def restore_data_source_dump(
[extract_process],
output_log_level=logging.DEBUG,
input_file=extract_process.stdout,
environment=environment,
extra_environment=extra_environment,
)

View file

@ -7,7 +7,6 @@ import shlex
import borgmatic.borg.pattern
import borgmatic.config.paths
import borgmatic.hooks.credential.parse
from borgmatic.execute import (
execute_command,
execute_command_and_capture_output,
@ -25,52 +24,46 @@ def make_dump_path(base_directory): # pragma: no cover
return dump.make_data_source_dump_path(base_directory, 'postgresql_databases')
def make_environment(database, config, restore_connection_params=None):
def make_extra_environment(database, restore_connection_params=None):
'''
Make an environment dict from the current environment variables and the given database
configuration. If restore connection params are given, this is for a restore operation.
Make the extra_environment dict from the given database configuration. If restore connection
params are given, this is for a restore operation.
'''
environment = dict(os.environ)
extra = dict()
try:
if restore_connection_params:
environment['PGPASSWORD'] = borgmatic.hooks.credential.parse.resolve_credential(
(
restore_connection_params.get('password')
or database.get('restore_password', database['password'])
),
config,
extra['PGPASSWORD'] = restore_connection_params.get('password') or database.get(
'restore_password', database['password']
)
else:
environment['PGPASSWORD'] = borgmatic.hooks.credential.parse.resolve_credential(
database['password'], config
)
extra['PGPASSWORD'] = database['password']
except (AttributeError, KeyError):
pass
if 'ssl_mode' in database:
environment['PGSSLMODE'] = database['ssl_mode']
extra['PGSSLMODE'] = database['ssl_mode']
if 'ssl_cert' in database:
environment['PGSSLCERT'] = database['ssl_cert']
extra['PGSSLCERT'] = database['ssl_cert']
if 'ssl_key' in database:
environment['PGSSLKEY'] = database['ssl_key']
extra['PGSSLKEY'] = database['ssl_key']
if 'ssl_root_cert' in database:
environment['PGSSLROOTCERT'] = database['ssl_root_cert']
extra['PGSSLROOTCERT'] = database['ssl_root_cert']
if 'ssl_crl' in database:
environment['PGSSLCRL'] = database['ssl_crl']
extra['PGSSLCRL'] = database['ssl_crl']
return environment
return extra
EXCLUDED_DATABASE_NAMES = ('template0', 'template1')
def database_names_to_dump(database, config, environment, dry_run):
def database_names_to_dump(database, extra_environment, log_prefix, dry_run):
'''
Given a requested database config and a configuration dict, return the corresponding sequence of
database names to dump. In the case of "all" when a database format is given, query for the
names of databases on the configured host and return them. For "all" without a database format,
just return a sequence containing "all".
Given a requested database config, return the corresponding sequence of database names to dump.
In the case of "all" when a database format is given, query for the names of databases on the
configured host and return them. For "all" without a database format, just return a sequence
containing "all".
'''
requested_name = database['name']
@ -89,18 +82,13 @@ def database_names_to_dump(database, config, environment, dry_run):
+ ('--list', '--no-password', '--no-psqlrc', '--csv', '--tuples-only')
+ (('--host', database['hostname']) if 'hostname' in database else ())
+ (('--port', str(database['port'])) if 'port' in database else ())
+ (
(
'--username',
borgmatic.hooks.credential.parse.resolve_credential(database['username'], config),
)
if 'username' in database
else ()
)
+ (('--username', database['username']) if 'username' in database else ())
+ (tuple(database['list_options'].split(' ')) if 'list_options' in database else ())
)
logger.debug('Querying for "all" PostgreSQL databases to dump')
list_output = execute_command_and_capture_output(list_command, environment=environment)
logger.debug(f'{log_prefix}: Querying for "all" PostgreSQL databases to dump')
list_output = execute_command_and_capture_output(
list_command, extra_environment=extra_environment
)
return tuple(
row[0]
@ -109,14 +97,14 @@ def database_names_to_dump(database, config, environment, dry_run):
)
def get_default_port(databases, config): # pragma: no cover
def get_default_port(databases, config, log_prefix): # pragma: no cover
return 5432
def use_streaming(databases, config):
def use_streaming(databases, config, log_prefix):
'''
Given a sequence of PostgreSQL database configuration dicts, a configuration dict (ignored),
return whether streaming will be using during dumps.
Given a sequence of PostgreSQL database configuration dicts, a configuration dict (ignored), and
a log prefix (ignored), return whether streaming will be using during dumps.
'''
return any(database.get('format') != 'directory' for database in databases)
@ -124,6 +112,7 @@ def use_streaming(databases, config):
def dump_data_sources(
databases,
config,
log_prefix,
config_paths,
borgmatic_runtime_directory,
patterns,
@ -132,7 +121,8 @@ def dump_data_sources(
'''
Dump the given PostgreSQL databases to a named pipe. The databases are supplied as a sequence of
dicts, one dict describing each database as per the configuration schema. Use the given
borgmatic runtime directory to construct the destination path.
borgmatic runtime directory to construct the destination path and the given log prefix in any
log entries.
Return a sequence of subprocess.Popen instances for the dump processes ready to spew to a named
pipe. But if this is a dry run, then don't actually dump anything and return an empty sequence.
@ -144,12 +134,14 @@ def dump_data_sources(
dry_run_label = ' (dry run; not actually dumping anything)' if dry_run else ''
processes = []
logger.info(f'Dumping PostgreSQL databases{dry_run_label}')
logger.info(f'{log_prefix}: Dumping PostgreSQL databases{dry_run_label}')
for database in databases:
environment = make_environment(database, config)
extra_environment = make_extra_environment(database)
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, extra_environment, log_prefix, dry_run
)
if not dump_database_names:
if dry_run:
@ -172,7 +164,7 @@ def dump_data_sources(
)
if os.path.exists(dump_filename):
logger.warning(
f'Skipping duplicate dump of PostgreSQL database "{database_name}" to {dump_filename}'
f'{log_prefix}: Skipping duplicate dump of PostgreSQL database "{database_name}" to {dump_filename}'
)
continue
@ -186,14 +178,7 @@ def dump_data_sources(
+ (('--host', shlex.quote(database['hostname'])) if 'hostname' in database else ())
+ (('--port', shlex.quote(str(database['port']))) if 'port' in database else ())
+ (
(
'--username',
shlex.quote(
borgmatic.hooks.credential.parse.resolve_credential(
database['username'], config
)
),
)
('--username', shlex.quote(database['username']))
if 'username' in database
else ()
)
@ -213,7 +198,7 @@ def dump_data_sources(
)
logger.debug(
f'Dumping PostgreSQL database "{database_name}" to {dump_filename}{dry_run_label}'
f'{log_prefix}: Dumping PostgreSQL database "{database_name}" to {dump_filename}{dry_run_label}'
)
if dry_run:
continue
@ -223,7 +208,7 @@ def dump_data_sources(
execute_command(
command,
shell=True,
environment=environment,
extra_environment=extra_environment,
)
else:
dump.create_named_pipe_for_dump(dump_filename)
@ -231,7 +216,7 @@ def dump_data_sources(
execute_command(
command,
shell=True,
environment=environment,
extra_environment=extra_environment,
run_to_completion=False,
)
)
@ -239,8 +224,7 @@ def dump_data_sources(
if not dry_run:
patterns.append(
borgmatic.borg.pattern.Pattern(
os.path.join(borgmatic_runtime_directory, 'postgresql_databases'),
source=borgmatic.borg.pattern.Pattern_source.HOOK,
os.path.join(borgmatic_runtime_directory, 'postgresql_databases')
)
)
@ -248,25 +232,25 @@ def dump_data_sources(
def remove_data_source_dumps(
databases, config, borgmatic_runtime_directory, dry_run
databases, config, log_prefix, borgmatic_runtime_directory, dry_run
): # pragma: no cover
'''
Remove all database dump files for this hook regardless of the given databases. Use the
borgmatic runtime directory to construct the destination path. If this is a dry run, then don't
actually remove anything.
borgmatic runtime directory to construct the destination path and the log prefix in any log
entries. If this is a dry run, then don't actually remove anything.
'''
dump.remove_data_source_dumps(
make_dump_path(borgmatic_runtime_directory), 'PostgreSQL', dry_run
make_dump_path(borgmatic_runtime_directory), 'PostgreSQL', log_prefix, dry_run
)
def make_data_source_dump_patterns(
databases, config, borgmatic_runtime_directory, name=None
databases, config, log_prefix, borgmatic_runtime_directory, name=None
): # pragma: no cover
'''
Given a sequence of configurations dicts, a configuration dict, the borgmatic runtime directory,
and a database name to match, return the corresponding glob patterns to match the database dump
in an archive.
Given a sequence of configurations dicts, a configuration dict, a prefix to log with, the
borgmatic runtime directory, and a database name to match, return the corresponding glob
patterns to match the database dump in an archive.
'''
borgmatic_source_directory = borgmatic.config.paths.get_borgmatic_source_directory(config)
@ -284,6 +268,7 @@ def make_data_source_dump_patterns(
def restore_data_source_dump(
hook_config,
config,
log_prefix,
data_source,
dry_run,
extract_process,
@ -293,9 +278,10 @@ def restore_data_source_dump(
'''
Restore a database from the given extract stream. The database is supplied as a data source
configuration dict, but the given hook configuration is ignored. The given borgmatic runtime
directory is used to construct the destination path (used for the directory format). If this is
a dry run, then don't actually restore anything. Trigger the given active extract process (an
instance of subprocess.Popen) to produce output to consume.
directory is used to construct the destination path (used for the directory format), and the
given log prefix is used for any log entries. If this is a dry run, then don't actually restore
anything. Trigger the given active extract process (an instance of subprocess.Popen) to produce
output to consume.
If the extract process is None, then restore the dump from the filesystem rather than from an
extract stream.
@ -310,12 +296,8 @@ def restore_data_source_dump(
port = str(
connection_params['port'] or data_source.get('restore_port', data_source.get('port', ''))
)
username = borgmatic.hooks.credential.parse.resolve_credential(
(
connection_params['username']
or data_source.get('restore_username', data_source.get('username'))
),
config,
username = connection_params['username'] or data_source.get(
'restore_username', data_source.get('username')
)
all_databases = bool(data_source['name'] == 'all')
@ -368,9 +350,13 @@ def restore_data_source_dump(
)
)
environment = make_environment(data_source, config, restore_connection_params=connection_params)
extra_environment = make_extra_environment(
data_source, restore_connection_params=connection_params
)
logger.debug(f"Restoring PostgreSQL database {data_source['name']}{dry_run_label}")
logger.debug(
f"{log_prefix}: Restoring PostgreSQL database {data_source['name']}{dry_run_label}"
)
if dry_run:
return
@ -381,6 +367,6 @@ def restore_data_source_dump(
[extract_process] if extract_process else [],
output_log_level=logging.DEBUG,
input_file=extract_process.stdout if extract_process else None,
environment=environment,
extra_environment=extra_environment,
)
execute_command(analyze_command, environment=environment)
execute_command(analyze_command, extra_environment=extra_environment)

View file

@ -11,10 +11,10 @@ def get_contained_patterns(parent_directory, candidate_patterns):
paths, but there's a parent directory (logical volume, dataset, subvolume, etc.) at /var, then
/var is what we want to snapshot.
For this function to work, a candidate pattern path can't have any globs or other non-literal
characters in the initial portion of the path that matches the parent directory. For instance, a
parent directory of /var would match a candidate pattern path of /var/log/*/data, but not a
pattern path like /v*/log/*/data.
For this to work, a candidate pattern path can't have any globs or other non-literal characters
in the initial portion of the path that matches the parent directory. For instance, a parent
directory of /var would match a candidate pattern path of /var/log/*/data, but not a pattern
path like /v*/log/*/data.
The one exception is that if a regular expression pattern path starts with "^", that will get
stripped off for purposes of matching against a parent directory.
@ -31,10 +31,8 @@ def get_contained_patterns(parent_directory, candidate_patterns):
candidate
for candidate in candidate_patterns
for candidate_path in (pathlib.PurePath(candidate.path.lstrip('^')),)
if (
pathlib.PurePath(parent_directory) == candidate_path
or pathlib.PurePath(parent_directory) in candidate_path.parents
)
if pathlib.PurePath(parent_directory) == candidate_path
or pathlib.PurePath(parent_directory) in candidate_path.parents
)
candidate_patterns -= set(contained_patterns)

View file

@ -17,14 +17,14 @@ def make_dump_path(base_directory): # pragma: no cover
return dump.make_data_source_dump_path(base_directory, 'sqlite_databases')
def get_default_port(databases, config): # pragma: no cover
def get_default_port(databases, config, log_prefix): # pragma: no cover
return None # SQLite doesn't use a port.
def use_streaming(databases, config):
def use_streaming(databases, config, log_prefix):
'''
Given a sequence of SQLite database configuration dicts, a configuration dict (ignored), return
whether streaming will be using during dumps.
Given a sequence of SQLite database configuration dicts, a configuration dict (ignored), and a
log prefix (ignored), return whether streaming will be using during dumps.
'''
return any(databases)
@ -32,6 +32,7 @@ def use_streaming(databases, config):
def dump_data_sources(
databases,
config,
log_prefix,
config_paths,
borgmatic_runtime_directory,
patterns,
@ -40,7 +41,7 @@ def dump_data_sources(
'''
Dump the given SQLite databases to a named pipe. The databases are supplied as a sequence of
configuration dicts, as per the configuration schema. Use the given borgmatic runtime directory
to construct the destination path.
to construct the destination path and the given log prefix in any log entries.
Return a sequence of subprocess.Popen instances for the dump processes ready to spew to a named
pipe. But if this is a dry run, then don't actually dump anything and return an empty sequence.
@ -50,7 +51,7 @@ def dump_data_sources(
dry_run_label = ' (dry run; not actually dumping anything)' if dry_run else ''
processes = []
logger.info(f'Dumping SQLite databases{dry_run_label}')
logger.info(f'{log_prefix}: Dumping SQLite databases{dry_run_label}')
for database in databases:
database_path = database['path']
@ -59,7 +60,7 @@ def dump_data_sources(
logger.warning('The "all" database name has no meaning for SQLite databases')
if not os.path.exists(database_path):
logger.warning(
f'No SQLite database at {database_path}; an empty database will be created and dumped'
f'{log_prefix}: No SQLite database at {database_path}; an empty database will be created and dumped'
)
dump_path = make_dump_path(borgmatic_runtime_directory)
@ -67,7 +68,7 @@ def dump_data_sources(
if os.path.exists(dump_filename):
logger.warning(
f'Skipping duplicate dump of SQLite database at {database_path} to {dump_filename}'
f'{log_prefix}: Skipping duplicate dump of SQLite database at {database_path} to {dump_filename}'
)
continue
@ -79,7 +80,7 @@ def dump_data_sources(
shlex.quote(dump_filename),
)
logger.debug(
f'Dumping SQLite database at {database_path} to {dump_filename}{dry_run_label}'
f'{log_prefix}: Dumping SQLite database at {database_path} to {dump_filename}{dry_run_label}'
)
if dry_run:
continue
@ -90,8 +91,7 @@ def dump_data_sources(
if not dry_run:
patterns.append(
borgmatic.borg.pattern.Pattern(
os.path.join(borgmatic_runtime_directory, 'sqlite_databases'),
source=borgmatic.borg.pattern.Pattern_source.HOOK,
os.path.join(borgmatic_runtime_directory, 'sqlite_databases')
)
)
@ -99,23 +99,25 @@ def dump_data_sources(
def remove_data_source_dumps(
databases, config, borgmatic_runtime_directory, dry_run
databases, config, log_prefix, borgmatic_runtime_directory, dry_run
): # pragma: no cover
'''
Remove all database dump files for this hook regardless of the given databases. Use the
borgmatic runtime directory to construct the destination path. If this is a dry run, then don't
actually remove anything.
borgmatic runtime directory to construct the destination path and the log prefix in any log
entries. If this is a dry run, then don't actually remove anything.
'''
dump.remove_data_source_dumps(make_dump_path(borgmatic_runtime_directory), 'SQLite', dry_run)
dump.remove_data_source_dumps(
make_dump_path(borgmatic_runtime_directory), 'SQLite', log_prefix, dry_run
)
def make_data_source_dump_patterns(
databases, config, borgmatic_runtime_directory, name=None
databases, config, log_prefix, borgmatic_runtime_directory, name=None
): # pragma: no cover
'''
Given a sequence of configurations dicts, a configuration dict, the borgmatic runtime directory,
and a database name to match, return the corresponding glob patterns to match the database dump
in an archive.
Given a sequence of configurations dicts, a configuration dict, a prefix to log with, the
borgmatic runtime directory, and a database name to match, return the corresponding glob
patterns to match the database dump in an archive.
'''
borgmatic_source_directory = borgmatic.config.paths.get_borgmatic_source_directory(config)
@ -133,6 +135,7 @@ def make_data_source_dump_patterns(
def restore_data_source_dump(
hook_config,
config,
log_prefix,
data_source,
dry_run,
extract_process,
@ -141,22 +144,22 @@ def restore_data_source_dump(
):
'''
Restore a database from the given extract stream. The database is supplied as a data source
configuration dict, but the given hook configuration is ignored. If this is a dry run, then
don't actually restore anything. Trigger the given active extract process (an instance of
subprocess.Popen) to produce output to consume.
configuration dict, but the given hook configuration is ignored. The given log prefix is used
for any log entries. If this is a dry run, then don't actually restore anything. Trigger the
given active extract process (an instance of subprocess.Popen) to produce output to consume.
'''
dry_run_label = ' (dry run; not actually restoring anything)' if dry_run else ''
database_path = connection_params['restore_path'] or data_source.get(
'restore_path', data_source.get('path')
)
logger.debug(f'Restoring SQLite database at {database_path}{dry_run_label}')
logger.debug(f'{log_prefix}: Restoring SQLite database at {database_path}{dry_run_label}')
if dry_run:
return
try:
os.remove(database_path)
logger.warning(f'Removed existing SQLite database at {database_path}')
logger.warning(f'{log_prefix}: Removed existing SQLite database at {database_path}')
except FileNotFoundError: # pragma: no cover
pass

View file

@ -1,6 +1,5 @@
import collections
import glob
import hashlib
import logging
import os
import shutil
@ -14,7 +13,7 @@ import borgmatic.hooks.data_source.snapshot
logger = logging.getLogger(__name__)
def use_streaming(hook_config, config): # pragma: no cover
def use_streaming(hook_config, config, log_prefix): # pragma: no cover
'''
Return whether dump streaming is used for this hook. (Spoiler: It isn't.)
'''
@ -39,9 +38,6 @@ def get_datasets_to_backup(zfs_command, patterns):
pattern paths represent the requested datasets to snapshot. But also include any datasets tagged
with a borgmatic-specific user property, whether or not they appear in the patterns.
Only include datasets that contain at least one root pattern sourced from borgmatic
configuration (as opposed to generated elsewhere in borgmatic).
Return the result as a sequence of Dataset instances, sorted by mount point.
'''
list_output = borgmatic.execute.execute_command_and_capture_output(
@ -52,7 +48,7 @@ def get_datasets_to_backup(zfs_command, patterns):
'-t',
'filesystem',
'-o',
f'name,mountpoint,canmount,{BORGMATIC_USER_PROPERTY}',
f'name,mountpoint,{BORGMATIC_USER_PROPERTY}',
)
)
@ -64,12 +60,7 @@ def get_datasets_to_backup(zfs_command, patterns):
(
Dataset(dataset_name, mount_point, (user_property_value == 'auto'), ())
for line in list_output.splitlines()
for (dataset_name, mount_point, can_mount, user_property_value) in (
line.rstrip().split('\t'),
)
# Skip datasets that are marked "canmount=off", because mounting their snapshots will
# result in completely empty mount points—thereby preventing us from backing them up.
if can_mount == 'on'
for (dataset_name, mount_point, user_property_value) in (line.rstrip().split('\t'),)
),
key=lambda dataset: dataset.mount_point,
reverse=True,
@ -92,12 +83,7 @@ def get_datasets_to_backup(zfs_command, patterns):
for contained_patterns in (
(
(
(
borgmatic.borg.pattern.Pattern(
dataset.mount_point,
source=borgmatic.borg.pattern.Pattern_source.HOOK,
),
)
(borgmatic.borg.pattern.Pattern(dataset.mount_point),)
if dataset.auto_backup
else ()
)
@ -106,12 +92,7 @@ def get_datasets_to_backup(zfs_command, patterns):
)
),
)
if dataset.auto_backup
or any(
pattern.type == borgmatic.borg.pattern.Pattern_type.ROOT
and pattern.source == borgmatic.borg.pattern.Pattern_source.CONFIG
for pattern in contained_patterns
)
if contained_patterns
),
key=lambda dataset: dataset.mount_point,
)
@ -174,14 +155,10 @@ def mount_snapshot(mount_command, full_snapshot_name, snapshot_mount_path): # p
)
MOUNT_POINT_HASH_LENGTH = 10
def make_borg_snapshot_pattern(pattern, dataset, normalized_runtime_directory):
def make_borg_snapshot_pattern(pattern, normalized_runtime_directory):
'''
Given a Borg pattern as a borgmatic.borg.pattern.Pattern instance and the Dataset containing it,
return a new Pattern with its path rewritten to be in a snapshot directory based on both the
given runtime directory and the given Dataset's mount point.
Given a Borg pattern as a borgmatic.borg.pattern.Pattern instance, return a new Pattern with its
path rewritten to be in a snapshot directory based on the given runtime directory.
Move any initial caret in a regular expression pattern path to the beginning, so as not to break
the regular expression.
@ -196,10 +173,6 @@ def make_borg_snapshot_pattern(pattern, dataset, normalized_runtime_directory):
rewritten_path = initial_caret + os.path.join(
normalized_runtime_directory,
'zfs_snapshots',
# Including this hash prevents conflicts between snapshot patterns for different datasets.
# For instance, without this, snapshotting a dataset at /var and another at /var/spool would
# result in overlapping snapshot patterns and therefore colliding mount attempts.
hashlib.shake_256(dataset.mount_point.encode('utf-8')).hexdigest(MOUNT_POINT_HASH_LENGTH),
'.', # Borg 1.4+ "slashdot" hack.
# Included so that the source directory ends up in the Borg archive at its "original" path.
pattern.path.lstrip('^').lstrip(os.path.sep),
@ -210,35 +183,34 @@ def make_borg_snapshot_pattern(pattern, dataset, normalized_runtime_directory):
pattern.type,
pattern.style,
pattern.device,
source=borgmatic.borg.pattern.Pattern_source.HOOK,
)
def dump_data_sources(
hook_config,
config,
log_prefix,
config_paths,
borgmatic_runtime_directory,
patterns,
dry_run,
):
'''
Given a ZFS configuration dict, a configuration dict, the borgmatic configuration file paths,
the borgmatic runtime directory, the configured patterns, and whether this is a dry run,
auto-detect and snapshot any ZFS dataset mount points listed in the given patterns and any
Given a ZFS configuration dict, a configuration dict, a log prefix, the borgmatic configuration
file paths, the borgmatic runtime directory, the configured patterns, and whether this is a dry
run, auto-detect and snapshot any ZFS dataset mount points listed in the given patterns and any
dataset with a borgmatic-specific user property. Also update those patterns, replacing dataset
mount points with corresponding snapshot directories so they get stored in the Borg archive
instead.
instead. Use the log prefix in any log entries.
Return an empty sequence, since there are no ongoing dump processes from this hook.
If this is a dry run, then don't actually snapshot anything.
'''
dry_run_label = ' (dry run; not actually snapshotting anything)' if dry_run else ''
logger.info(f'Snapshotting ZFS datasets{dry_run_label}')
logger.info(f'{log_prefix}: Snapshotting ZFS datasets{dry_run_label}')
# List ZFS datasets to get their mount points, but only consider those patterns that came from
# actual user configuration (as opposed to, say, other hooks).
# List ZFS datasets to get their mount points.
zfs_command = hook_config.get('zfs_command', 'zfs')
requested_datasets = get_datasets_to_backup(zfs_command, patterns)
@ -247,12 +219,12 @@ def dump_data_sources(
normalized_runtime_directory = os.path.normpath(borgmatic_runtime_directory)
if not requested_datasets:
logger.warning(f'No ZFS datasets found to snapshot{dry_run_label}')
logger.warning(f'{log_prefix}: No ZFS datasets found to snapshot{dry_run_label}')
for dataset in requested_datasets:
full_snapshot_name = f'{dataset.name}@{snapshot_name}'
logger.debug(
f'Creating ZFS snapshot {full_snapshot_name} of {dataset.mount_point}{dry_run_label}'
f'{log_prefix}: Creating ZFS snapshot {full_snapshot_name} of {dataset.mount_point}{dry_run_label}'
)
if not dry_run:
@ -263,14 +235,11 @@ def dump_data_sources(
snapshot_mount_path = os.path.join(
normalized_runtime_directory,
'zfs_snapshots',
hashlib.shake_256(dataset.mount_point.encode('utf-8')).hexdigest(
MOUNT_POINT_HASH_LENGTH
),
dataset.mount_point.lstrip(os.path.sep),
)
logger.debug(
f'Mounting ZFS snapshot {full_snapshot_name} at {snapshot_mount_path}{dry_run_label}'
f'{log_prefix}: Mounting ZFS snapshot {full_snapshot_name} at {snapshot_mount_path}{dry_run_label}'
)
if dry_run:
@ -281,9 +250,7 @@ def dump_data_sources(
)
for pattern in dataset.contained_patterns:
snapshot_pattern = make_borg_snapshot_pattern(
pattern, dataset, normalized_runtime_directory
)
snapshot_pattern = make_borg_snapshot_pattern(pattern, normalized_runtime_directory)
# Attempt to update the pattern in place, since pattern order matters to Borg.
try:
@ -339,12 +306,12 @@ def get_all_snapshots(zfs_command):
return tuple(line.rstrip() for line in list_output.splitlines())
def remove_data_source_dumps(hook_config, config, borgmatic_runtime_directory, dry_run):
def remove_data_source_dumps(hook_config, config, log_prefix, borgmatic_runtime_directory, dry_run):
'''
Given a ZFS configuration dict, a configuration dict, the borgmatic runtime directory, and
whether this is a dry run, unmount and destroy any ZFS snapshots created by borgmatic. If this
is a dry run or ZFS isn't configured in borgmatic's configuration, then don't actually remove
anything.
Given a ZFS configuration dict, a configuration dict, a log prefix, the borgmatic runtime
directory, and whether this is a dry run, unmount and destroy any ZFS snapshots created by
borgmatic. Use the log prefix in any log entries. If this is a dry run or ZFS isn't configured
in borgmatic's configuration, then don't actually remove anything.
'''
if hook_config is None:
return
@ -357,10 +324,10 @@ def remove_data_source_dumps(hook_config, config, borgmatic_runtime_directory, d
try:
dataset_mount_points = get_all_dataset_mount_points(zfs_command)
except FileNotFoundError:
logger.debug(f'Could not find "{zfs_command}" command')
logger.debug(f'{log_prefix}: Could not find "{zfs_command}" command')
return
except subprocess.CalledProcessError as error:
logger.debug(error)
logger.debug(f'{log_prefix}: {error}')
return
snapshots_glob = os.path.join(
@ -368,9 +335,10 @@ def remove_data_source_dumps(hook_config, config, borgmatic_runtime_directory, d
os.path.normpath(borgmatic_runtime_directory),
),
'zfs_snapshots',
'*',
)
logger.debug(f'Looking for snapshots to remove in {snapshots_glob}{dry_run_label}')
logger.debug(
f'{log_prefix}: Looking for snapshots to remove in {snapshots_glob}{dry_run_label}'
)
umount_command = hook_config.get('umount_command', 'umount')
for snapshots_directory in glob.glob(snapshots_glob):
@ -381,10 +349,7 @@ def remove_data_source_dumps(hook_config, config, borgmatic_runtime_directory, d
# child datasets before the shorter mount point paths of parent datasets.
for mount_point in reversed(dataset_mount_points):
snapshot_mount_path = os.path.join(snapshots_directory, mount_point.lstrip(os.path.sep))
# If the snapshot mount path is empty, this is probably just a "shadow" of a nested
# dataset and therefore there's nothing to unmount.
if not os.path.isdir(snapshot_mount_path) or not os.listdir(snapshot_mount_path):
if not os.path.isdir(snapshot_mount_path):
continue
# This might fail if the path is already mounted, but we swallow errors here since we'll
@ -398,17 +363,19 @@ def remove_data_source_dumps(hook_config, config, borgmatic_runtime_directory, d
if not os.path.isdir(snapshot_mount_path):
continue
logger.debug(f'Unmounting ZFS snapshot at {snapshot_mount_path}{dry_run_label}')
logger.debug(
f'{log_prefix}: Unmounting ZFS snapshot at {snapshot_mount_path}{dry_run_label}'
)
if not dry_run:
try:
unmount_snapshot(umount_command, snapshot_mount_path)
except FileNotFoundError:
logger.debug(f'Could not find "{umount_command}" command')
logger.debug(f'{log_prefix}: Could not find "{umount_command}" command')
return
except subprocess.CalledProcessError as error:
logger.debug(error)
continue
logger.debug(f'{log_prefix}: {error}')
return
if not dry_run:
shutil.rmtree(snapshots_directory)
@ -421,14 +388,14 @@ def remove_data_source_dumps(hook_config, config, borgmatic_runtime_directory, d
if not full_snapshot_name.split('@')[-1].startswith(BORGMATIC_SNAPSHOT_PREFIX):
continue
logger.debug(f'Destroying ZFS snapshot {full_snapshot_name}{dry_run_label}')
logger.debug(f'{log_prefix}: Destroying ZFS snapshot {full_snapshot_name}{dry_run_label}')
if not dry_run:
destroy_snapshot(zfs_command, full_snapshot_name)
def make_data_source_dump_patterns(
hook_config, config, borgmatic_runtime_directory, name=None
hook_config, config, log_prefix, borgmatic_runtime_directory, name=None
): # pragma: no cover
'''
Restores aren't implemented, because stored files can be extracted directly with "extract".
@ -439,6 +406,7 @@ def make_data_source_dump_patterns(
def restore_data_source_dump(
hook_config,
config,
log_prefix,
data_source,
dry_run,
extract_process,

View file

@ -3,7 +3,6 @@ import importlib
import logging
import pkgutil
import borgmatic.hooks.credential
import borgmatic.hooks.data_source
import borgmatic.hooks.monitoring
@ -11,7 +10,6 @@ logger = logging.getLogger(__name__)
class Hook_type(enum.Enum):
CREDENTIAL = 'credential'
DATA_SOURCE = 'data_source'
MONITORING = 'monitoring'
@ -23,12 +21,12 @@ def get_submodule_names(parent_module): # pragma: no cover
return tuple(module_info.name for module_info in pkgutil.iter_modules(parent_module.__path__))
def call_hook(function_name, config, hook_name, *args, **kwargs):
def call_hook(function_name, config, log_prefix, hook_name, *args, **kwargs):
'''
Given a configuration dict, call the requested function of the Python module corresponding to
the given hook name. Supply that call with the configuration for this hook (if any) and any
given args and kwargs. Return the return value of that call or None if the module in question is
not a hook.
Given a configuration dict and a prefix to use in log entries, call the requested function of
the Python module corresponding to the given hook name. Supply that call with the configuration
for this hook (if any), the log prefix, and any given args and kwargs. Return the return value
of that call or None if the module in question is not a hook.
Raise ValueError if the hook name is unknown.
Raise AttributeError if the function name is not found in the module.
@ -42,11 +40,7 @@ def call_hook(function_name, config, hook_name, *args, **kwargs):
module_name = hook_name.split('_databases')[0]
# Probe for a data source or monitoring hook module corresponding to the hook name.
for parent_module in (
borgmatic.hooks.credential,
borgmatic.hooks.data_source,
borgmatic.hooks.monitoring,
):
for parent_module in (borgmatic.hooks.data_source, borgmatic.hooks.monitoring):
if module_name not in get_submodule_names(parent_module):
continue
@ -60,16 +54,17 @@ def call_hook(function_name, config, hook_name, *args, **kwargs):
else:
raise ValueError(f'Unknown hook name: {hook_name}')
logger.debug(f'Calling {hook_name} hook function {function_name}')
logger.debug(f'{log_prefix}: Calling {hook_name} hook function {function_name}')
return getattr(module, function_name)(hook_config, config, *args, **kwargs)
return getattr(module, function_name)(hook_config, config, log_prefix, *args, **kwargs)
def call_hooks(function_name, config, hook_type, *args, **kwargs):
def call_hooks(function_name, config, log_prefix, hook_type, *args, **kwargs):
'''
Given a configuration dict, call the requested function of the Python module corresponding to
each hook of the given hook type ("credential", "data_source", or "monitoring"). Supply each
call with the configuration for that hook, and any given args and kwargs.
Given a configuration dict and a prefix to use in log entries, call the requested function of
the Python module corresponding to each hook of the given hook type (either "data_source" or
"monitoring"). Supply each call with the configuration for that hook, the log prefix, and any
given args and kwargs.
Collect any return values into a dict from module name to return value. Note that the module
name is the name of the hook module itself, which might be different from the hook configuration
@ -83,7 +78,7 @@ def call_hooks(function_name, config, hook_type, *args, **kwargs):
Raise anything else that a called function raises. An error stops calls to subsequent functions.
'''
return {
hook_name: call_hook(function_name, config, hook_name, *args, **kwargs)
hook_name: call_hook(function_name, config, log_prefix, hook_name, *args, **kwargs)
for hook_name in get_submodule_names(
importlib.import_module(f'borgmatic.hooks.{hook_type.value}')
)
@ -91,18 +86,18 @@ def call_hooks(function_name, config, hook_type, *args, **kwargs):
}
def call_hooks_even_if_unconfigured(function_name, config, hook_type, *args, **kwargs):
def call_hooks_even_if_unconfigured(function_name, config, log_prefix, hook_type, *args, **kwargs):
'''
Given a configuration dict, call the requested function of the Python module corresponding to
each hook of the given hook type ("credential", "data_source", or "monitoring"). Supply each
call with the configuration for that hook and any given args and kwargs. Collect any return
values into a dict from hook name to return value.
Given a configuration dict and a prefix to use in log entries, call the requested function of
the Python module corresponding to each hook of the given hook type (either "data_source" or
"monitoring"). Supply each call with the configuration for that hook, the log prefix, and any
given args and kwargs. Collect any return values into a dict from hook name to return value.
Raise AttributeError if the function name is not found in the module.
Raise anything else that a called function raises. An error stops calls to subsequent functions.
'''
return {
hook_name: call_hook(function_name, config, hook_name, *args, **kwargs)
hook_name: call_hook(function_name, config, log_prefix, hook_name, *args, **kwargs)
for hook_name in get_submodule_names(
importlib.import_module(f'borgmatic.hooks.{hook_type.value}')
)

View file

@ -66,12 +66,12 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
)
if not hook_config.get('services'):
logger.info('No Apprise services to ping')
logger.info(f'{config_filename}: No Apprise services to ping')
return
dry_run_string = ' (dry run; not actually pinging)' if dry_run else ''
labels_string = ', '.join(map(operator.itemgetter('label'), hook_config.get('services')))
logger.info(f'Pinging Apprise services: {labels_string}{dry_run_string}')
logger.info(f'{config_filename}: Pinging Apprise services: {labels_string}{dry_run_string}')
apprise_object = apprise.Apprise()
apprise_object.add(list(map(operator.itemgetter('url'), hook_config.get('services'))))
@ -100,10 +100,10 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
)
if result is False:
logger.warning('Error sending some Apprise notifications')
logger.warning(f'{config_filename}: Error sending some Apprise notifications')
def destroy_monitor(hook_config, config, monitoring_log_level, dry_run):
def destroy_monitor(hook_config, config, config_filename, monitoring_log_level, dry_run):
'''
Remove the monitor handler that was added to the root logger. This prevents the handler from
getting reused by other instances of this monitor.

View file

@ -28,7 +28,9 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
filename in any log entries. If this is a dry run, then don't actually ping anything.
'''
if state not in MONITOR_STATE_TO_CRONHUB:
logger.debug(f'Ignoring unsupported monitoring {state.name.lower()} in Cronhub hook')
logger.debug(
f'{config_filename}: Ignoring unsupported monitoring {state.name.lower()} in Cronhub hook'
)
return
dry_run_label = ' (dry run; not actually pinging)' if dry_run else ''
@ -39,8 +41,8 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
.replace('/ping/', formatted_state)
)
logger.info(f'Pinging Cronhub {state.name.lower()}{dry_run_label}')
logger.debug(f'Using Cronhub ping URL {ping_url}')
logger.info(f'{config_filename}: Pinging Cronhub {state.name.lower()}{dry_run_label}')
logger.debug(f'{config_filename}: Using Cronhub ping URL {ping_url}')
if not dry_run:
logging.getLogger('urllib3').setLevel(logging.ERROR)
@ -49,10 +51,12 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
if not response.ok:
response.raise_for_status()
except requests.exceptions.RequestException as error:
logger.warning(f'Cronhub error: {error}')
logger.warning(f'{config_filename}: Cronhub error: {error}')
def destroy_monitor(ping_url_or_uuid, config, monitoring_log_level, dry_run): # pragma: no cover
def destroy_monitor(
ping_url_or_uuid, config, config_filename, monitoring_log_level, dry_run
): # pragma: no cover
'''
No destruction is necessary for this monitor.
'''

View file

@ -28,14 +28,16 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
filename in any log entries. If this is a dry run, then don't actually ping anything.
'''
if state not in MONITOR_STATE_TO_CRONITOR:
logger.debug(f'Ignoring unsupported monitoring {state.name.lower()} in Cronitor hook')
logger.debug(
f'{config_filename}: Ignoring unsupported monitoring {state.name.lower()} in Cronitor hook'
)
return
dry_run_label = ' (dry run; not actually pinging)' if dry_run else ''
ping_url = f"{hook_config['ping_url']}/{MONITOR_STATE_TO_CRONITOR[state]}"
logger.info(f'Pinging Cronitor {state.name.lower()}{dry_run_label}')
logger.debug(f'Using Cronitor ping URL {ping_url}')
logger.info(f'{config_filename}: Pinging Cronitor {state.name.lower()}{dry_run_label}')
logger.debug(f'{config_filename}: Using Cronitor ping URL {ping_url}')
if not dry_run:
logging.getLogger('urllib3').setLevel(logging.ERROR)
@ -44,10 +46,12 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
if not response.ok:
response.raise_for_status()
except requests.exceptions.RequestException as error:
logger.warning(f'Cronitor error: {error}')
logger.warning(f'{config_filename}: Cronitor error: {error}')
def destroy_monitor(ping_url_or_uuid, config, monitoring_log_level, dry_run): # pragma: no cover
def destroy_monitor(
ping_url_or_uuid, config, config_filename, monitoring_log_level, dry_run
): # pragma: no cover
'''
No destruction is necessary for this monitor.
'''

View file

@ -55,7 +55,9 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
dry_run_label = ' (dry run; not actually pinging)' if dry_run else ''
if 'states' in hook_config and state.name.lower() not in hook_config['states']:
logger.info(f'Skipping Healthchecks {state.name.lower()} ping due to configured states')
logger.info(
f'{config_filename}: Skipping Healthchecks {state.name.lower()} ping due to configured states'
)
return
ping_url_is_uuid = re.search(r'\w{8}-\w{4}-\w{4}-\w{4}-\w{12}$', ping_url)
@ -66,12 +68,14 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
if hook_config.get('create_slug'):
if ping_url_is_uuid:
logger.warning('Healthchecks UUIDs do not support auto provisionning; ignoring')
logger.warning(
f'{config_filename}: Healthchecks UUIDs do not support auto provisionning; ignoring'
)
else:
ping_url = f'{ping_url}?create=1'
logger.info(f'Pinging Healthchecks {state.name.lower()}{dry_run_label}')
logger.debug(f'Using Healthchecks ping URL {ping_url}')
logger.info(f'{config_filename}: Pinging Healthchecks {state.name.lower()}{dry_run_label}')
logger.debug(f'{config_filename}: Using Healthchecks ping URL {ping_url}')
if state in (monitor.State.FINISH, monitor.State.FAIL, monitor.State.LOG):
payload = borgmatic.hooks.monitoring.logs.format_buffered_logs_for_payload(
@ -89,10 +93,10 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
if not response.ok:
response.raise_for_status()
except requests.exceptions.RequestException as error:
logger.warning(f'Healthchecks error: {error}')
logger.warning(f'{config_filename}: Healthchecks error: {error}')
def destroy_monitor(hook_config, config, monitoring_log_level, dry_run):
def destroy_monitor(hook_config, config, config_filename, monitoring_log_level, dry_run):
'''
Remove the monitor handler that was added to the root logger. This prevents the handler from
getting reused by other instances of this monitor.

View file

@ -139,10 +139,10 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
for handler in tuple(logging.getLogger().handlers):
if isinstance(handler, Loki_log_handler):
if state in MONITOR_STATE_TO_LOKI.keys():
handler.raw(f'{MONITOR_STATE_TO_LOKI[state]} backup')
handler.raw(f'{config_filename}: {MONITOR_STATE_TO_LOKI[state]} backup')
def destroy_monitor(hook_config, config, monitoring_log_level, dry_run):
def destroy_monitor(hook_config, config, config_filename, monitoring_log_level, dry_run):
'''
Remove the monitor handler that was added to the root logger.
'''

View file

@ -2,8 +2,6 @@ import logging
import requests
import borgmatic.hooks.credential.parse
logger = logging.getLogger(__name__)
@ -39,8 +37,8 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
base_url = hook_config.get('server', 'https://ntfy.sh')
topic = hook_config.get('topic')
logger.info(f'Pinging ntfy topic {topic}{dry_run_label}')
logger.debug(f'Using Ntfy ping URL {base_url}/{topic}')
logger.info(f'{config_filename}: Pinging ntfy topic {topic}{dry_run_label}')
logger.debug(f'{config_filename}: Using Ntfy ping URL {base_url}/{topic}')
headers = {
'X-Title': state_config.get('title'),
@ -49,35 +47,28 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
'X-Tags': state_config.get('tags'),
}
try:
username = borgmatic.hooks.credential.parse.resolve_credential(
hook_config.get('username'), config
)
password = borgmatic.hooks.credential.parse.resolve_credential(
hook_config.get('password'), config
)
access_token = borgmatic.hooks.credential.parse.resolve_credential(
hook_config.get('access_token'), config
)
except ValueError as error:
logger.warning(f'Ntfy credential error: {error}')
return
username = hook_config.get('username')
password = hook_config.get('password')
access_token = hook_config.get('access_token')
auth = None
if access_token is not None:
if username or password:
logger.warning(
'ntfy access_token is set but so is username/password, only using access_token'
f'{config_filename}: ntfy access_token is set but so is username/password, only using access_token'
)
auth = requests.auth.HTTPBasicAuth('', access_token)
elif (username and password) is not None:
auth = requests.auth.HTTPBasicAuth(username, password)
logger.info(f'Using basic auth with user {username} for ntfy')
logger.info(f'{config_filename}: Using basic auth with user {username} for ntfy')
elif username is not None:
logger.warning('Password missing for ntfy authentication, defaulting to no auth')
logger.warning(
f'{config_filename}: Password missing for ntfy authentication, defaulting to no auth'
)
elif password is not None:
logger.warning('Username missing for ntfy authentication, defaulting to no auth')
logger.warning(
f'{config_filename}: Username missing for ntfy authentication, defaulting to no auth'
)
if not dry_run:
logging.getLogger('urllib3').setLevel(logging.ERROR)
@ -86,10 +77,12 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
if not response.ok:
response.raise_for_status()
except requests.exceptions.RequestException as error:
logger.warning(f'ntfy error: {error}')
logger.warning(f'{config_filename}: ntfy error: {error}')
def destroy_monitor(ping_url_or_uuid, config, monitoring_log_level, dry_run): # pragma: no cover
def destroy_monitor(
ping_url_or_uuid, config, config_filename, monitoring_log_level, dry_run
): # pragma: no cover
'''
No destruction is necessary for this monitor.
'''

View file

@ -5,7 +5,6 @@ import platform
import requests
import borgmatic.hooks.credential.parse
from borgmatic.hooks.monitoring import monitor
logger = logging.getLogger(__name__)
@ -30,29 +29,21 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
'''
if state != monitor.State.FAIL:
logger.debug(
f'Ignoring unsupported monitoring {state.name.lower()} in PagerDuty hook',
f'{config_filename}: Ignoring unsupported monitoring {state.name.lower()} in PagerDuty hook',
)
return
dry_run_label = ' (dry run; not actually sending)' if dry_run else ''
logger.info(f'Sending failure event to PagerDuty {dry_run_label}')
logger.info(f'{config_filename}: Sending failure event to PagerDuty {dry_run_label}')
if dry_run:
return
try:
integration_key = borgmatic.hooks.credential.parse.resolve_credential(
hook_config.get('integration_key'), config
)
except ValueError as error:
logger.warning(f'PagerDuty credential error: {error}')
return
hostname = platform.node()
local_timestamp = datetime.datetime.now(datetime.timezone.utc).astimezone().isoformat()
payload = json.dumps(
{
'routing_key': integration_key,
'routing_key': hook_config['integration_key'],
'event_action': 'trigger',
'payload': {
'summary': f'backup failed on {hostname}',
@ -70,7 +61,7 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
},
}
)
logger.debug(f'Using PagerDuty payload: {payload}')
logger.debug(f'{config_filename}: Using PagerDuty payload: {payload}')
logging.getLogger('urllib3').setLevel(logging.ERROR)
try:
@ -78,10 +69,12 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
if not response.ok:
response.raise_for_status()
except requests.exceptions.RequestException as error:
logger.warning(f'PagerDuty error: {error}')
logger.warning(f'{config_filename}: PagerDuty error: {error}')
def destroy_monitor(ping_url_or_uuid, config, monitoring_log_level, dry_run): # pragma: no cover
def destroy_monitor(
ping_url_or_uuid, config, config_filename, monitoring_log_level, dry_run
): # pragma: no cover
'''
No destruction is necessary for this monitor.
'''

View file

@ -2,8 +2,6 @@ import logging
import requests
import borgmatic.hooks.credential.parse
logger = logging.getLogger(__name__)
@ -34,23 +32,17 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
state_config = hook_config.get(state.name.lower(), {})
try:
token = borgmatic.hooks.credential.parse.resolve_credential(
hook_config.get('token'), config
)
user = borgmatic.hooks.credential.parse.resolve_credential(hook_config.get('user'), config)
except ValueError as error:
logger.warning(f'Pushover credential error: {error}')
return
token = hook_config.get('token')
user = hook_config.get('user')
logger.info(f'Updating Pushover{dry_run_label}')
logger.info(f'{config_filename}: Updating Pushover{dry_run_label}')
if state_config.get('priority') == EMERGENCY_PRIORITY:
if 'expire' not in state_config:
logger.info('Setting expire to default (10 min)')
logger.info(f'{config_filename}: Setting expire to default (10 min).')
state_config['expire'] = 600
if 'retry' not in state_config:
logger.info('Setting retry to default (30 sec)')
logger.info(f'{config_filename}: Setting retry to default (30 sec).')
state_config['retry'] = 30
else:
if 'expire' in state_config or 'retry' in state_config:
@ -83,10 +75,12 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
if not response.ok:
response.raise_for_status()
except requests.exceptions.RequestException as error:
logger.warning(f'Pushover error: {error}')
logger.warning(f'{config_filename}: Pushover error: {error}')
def destroy_monitor(ping_url_or_uuid, config, monitoring_log_level, dry_run): # pragma: no cover
def destroy_monitor(
ping_url_or_uuid, config, config_filename, monitoring_log_level, dry_run
): # pragma: no cover
'''
No destruction is necessary for this monitor.
'''

View file

@ -16,7 +16,7 @@ def initialize_monitor(
DATA_SOURCE_NAME_URL_PATTERN = re.compile(
'^(?P<protocol>.+)://(?P<username>.+)@(?P<hostname>.+)/(?P<project_id>.+)$'
'^(?P<protocol>.*)://(?P<username>.*)@(?P<hostname>.*)/(?P<project_id>.*)$'
)
@ -38,13 +38,15 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
match = DATA_SOURCE_NAME_URL_PATTERN.match(data_source_name_url)
if not match:
logger.warning(f'Invalid Sentry data source name URL: {data_source_name_url}')
logger.warning(
'f{config_filename}: Invalid Sentry data source name URL: {data_source_name_url}'
)
return
cron_url = f'{match.group("protocol")}://{match.group("hostname")}/api/{match.group("project_id")}/cron/{monitor_slug}/{match.group("username")}/'
logger.info(f'Pinging Sentry {state.name.lower()}{dry_run_label}')
logger.debug(f'Using Sentry cron URL {cron_url}')
logger.info(f'{config_filename}: Pinging Sentry {state.name.lower()}{dry_run_label}')
logger.debug(f'{config_filename}: Using Sentry cron URL {cron_url}')
status = {
'start': 'in_progress',
@ -53,7 +55,7 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
}.get(state.name.lower())
if not status:
logger.warning('Invalid Sentry state')
logger.warning('f{config_filename}: Invalid Sentry state')
return
if dry_run:
@ -65,10 +67,12 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
if not response.ok:
response.raise_for_status()
except requests.exceptions.RequestException as error:
logger.warning(f'Sentry error: {error}')
logger.warning(f'{config_filename}: Sentry error: {error}')
def destroy_monitor(ping_url_or_uuid, config, monitoring_log_level, dry_run): # pragma: no cover
def destroy_monitor(
ping_url_or_uuid, config, config_filename, monitoring_log_level, dry_run
): # pragma: no cover
'''
No destruction is necessary for this monitor.
'''

View file

@ -28,8 +28,10 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
status = 'down' if state.name.lower() == 'fail' else 'up'
push_url = hook_config.get('push_url', 'https://example.uptime.kuma/api/push/abcd1234')
query = f'status={status}&msg={state.name.lower()}'
logger.info(f'Pushing Uptime Kuma push_url {push_url}?{query} {dry_run_label}')
logger.debug(f'Full Uptime Kuma state URL {push_url}?{query}')
logger.info(
f'{config_filename}: Pushing Uptime Kuma push_url {push_url}?{query} {dry_run_label}'
)
logger.debug(f'{config_filename}: Full Uptime Kuma state URL {push_url}?{query}')
if dry_run:
return
@ -41,10 +43,12 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
if not response.ok:
response.raise_for_status()
except requests.exceptions.RequestException as error:
logger.warning(f'Uptime Kuma error: {error}')
logger.warning(f'{config_filename}: Uptime Kuma error: {error}')
def destroy_monitor(push_url_or_uuid, config, monitoring_log_level, dry_run): # pragma: no cover
def destroy_monitor(
push_url_or_uuid, config, config_filename, monitoring_log_level, dry_run
): # pragma: no cover
'''
No destruction is necessary for this monitor.
'''

View file

@ -2,8 +2,6 @@ import logging
import requests
import borgmatic.hooks.credential.parse
logger = logging.getLogger(__name__)
@ -36,37 +34,26 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
},
)
try:
username = borgmatic.hooks.credential.parse.resolve_credential(
hook_config.get('username'), config
)
password = borgmatic.hooks.credential.parse.resolve_credential(
hook_config.get('password'), config
)
api_key = borgmatic.hooks.credential.parse.resolve_credential(
hook_config.get('api_key'), config
)
except ValueError as error:
logger.warning(f'Zabbix credential error: {error}')
return
server = hook_config.get('server')
username = hook_config.get('username')
password = hook_config.get('password')
api_key = hook_config.get('api_key')
itemid = hook_config.get('itemid')
host = hook_config.get('host')
key = hook_config.get('key')
value = state_config.get('value')
headers = {'Content-Type': 'application/json-rpc'}
logger.info(f'Updating Zabbix{dry_run_label}')
logger.debug(f'Using Zabbix URL: {server}')
logger.info(f'{config_filename}: Updating Zabbix{dry_run_label}')
logger.debug(f'{config_filename}: Using Zabbix URL: {server}')
if server is None:
logger.warning('Server missing for Zabbix')
logger.warning(f'{config_filename}: Server missing for Zabbix')
return
# Determine the Zabbix method used to store the value: itemid or host/key
if itemid is not None:
logger.info(f'Updating {itemid} on Zabbix')
logger.info(f'{config_filename}: Updating {itemid} on Zabbix')
data = {
'jsonrpc': '2.0',
'method': 'history.push',
@ -75,7 +62,7 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
}
elif (host and key) is not None:
logger.info(f'Updating Host:{host} and Key:{key} on Zabbix')
logger.info(f'{config_filename}: Updating Host:{host} and Key:{key} on Zabbix')
data = {
'jsonrpc': '2.0',
'method': 'history.push',
@ -84,23 +71,23 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
}
elif host is not None:
logger.warning('Key missing for Zabbix')
logger.warning(f'{config_filename}: Key missing for Zabbix')
return
elif key is not None:
logger.warning('Host missing for Zabbix')
logger.warning(f'{config_filename}: Host missing for Zabbix.')
return
else:
logger.warning('No Zabbix itemid or host/key provided')
logger.warning(f'{config_filename}: No zabbix itemid or host/key provided.')
return
# Determine the authentication method: API key or username/password
if api_key is not None:
logger.info('Using API key auth for Zabbix')
logger.info(f'{config_filename}: Using API key auth for Zabbix')
headers['Authorization'] = 'Bearer ' + api_key
elif (username and password) is not None:
logger.info('Using user/pass auth with user {username} for Zabbix')
logger.info(f'{config_filename}: Using user/pass auth with user {username} for Zabbix')
auth_data = {
'jsonrpc': '2.0',
'method': 'user.login',
@ -115,18 +102,18 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
if not response.ok:
response.raise_for_status()
except requests.exceptions.RequestException as error:
logger.warning(f'Zabbix error: {error}')
logger.warning(f'{config_filename}: Zabbix error: {error}')
return
elif username is not None:
logger.warning('Password missing for Zabbix authentication')
logger.warning(f'{config_filename}: Password missing for Zabbix authentication')
return
elif password is not None:
logger.warning('Username missing for Zabbix authentication')
logger.warning(f'{config_filename}: Username missing for Zabbix authentication')
return
else:
logger.warning('Authentication data missing for Zabbix')
logger.warning(f'{config_filename}: Authentication data missing for Zabbix')
return
if not dry_run:
@ -136,10 +123,12 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
if not response.ok:
response.raise_for_status()
except requests.exceptions.RequestException as error:
logger.warning(f'Zabbix error: {error}')
logger.warning(f'{config_filename}: Zabbix error: {error}')
def destroy_monitor(ping_url_or_uuid, config, monitoring_log_level, dry_run): # pragma: no cover
def destroy_monitor(
ping_url_or_uuid, config, config_filename, monitoring_log_level, dry_run
): # pragma: no cover
'''
No destruction is necessary for this monitor.
'''

View file

@ -87,16 +87,9 @@ class Multi_stream_handler(logging.Handler):
handler.setLevel(level)
class Log_prefix_formatter(logging.Formatter):
def __init__(self, fmt='{prefix}{message}', style='{', *args, **kwargs): # pragma: no cover
self.prefix = None
super(Log_prefix_formatter, self).__init__(fmt=fmt, style=style, *args, **kwargs)
class Console_no_color_formatter(logging.Formatter):
def format(self, record): # pragma: no cover
record.prefix = f'{self.prefix}: ' if self.prefix else ''
return super(Log_prefix_formatter, self).format(record)
return record.msg
class Color(enum.Enum):
@ -109,12 +102,6 @@ class Color(enum.Enum):
class Console_color_formatter(logging.Formatter):
def __init__(self, *args, **kwargs):
self.prefix = None
super(Console_color_formatter, self).__init__(
'{prefix}{message}', style='{', *args, **kwargs
)
def format(self, record):
add_custom_log_levels()
@ -130,9 +117,8 @@ class Console_color_formatter(logging.Formatter):
.get(record.levelno)
.value
)
record.prefix = f'{self.prefix}: ' if self.prefix else ''
return color_text(color, super(Console_color_formatter, self).format(record))
return color_text(color, record.msg)
def ansi_escape_code(color): # pragma: no cover
@ -193,137 +179,6 @@ def add_custom_log_levels(): # pragma: no cover
add_logging_level('DISABLED', DISABLED)
def get_log_prefix():
'''
Return the current log prefix set by set_log_prefix(). Return None if no such prefix exists.
It would be a whole lot easier to use logger.Formatter(defaults=...) instead, but that argument
doesn't exist until Python 3.10+.
'''
try:
formatter = next(
handler.formatter
for handler in logging.getLogger().handlers
if handler.formatter
if hasattr(handler.formatter, 'prefix')
)
except StopIteration:
return None
return formatter.prefix
def set_log_prefix(prefix):
'''
Given a log prefix as a string, set it into the each handler's formatter so that it can inject
the prefix into each logged record.
'''
for handler in logging.getLogger().handlers:
if handler.formatter and hasattr(handler.formatter, 'prefix'):
handler.formatter.prefix = prefix
class Log_prefix:
'''
A Python context manager for setting a log prefix so that it shows up in every subsequent
logging message for the duration of the contex