mirror of
https://github.com/borgmatic-collective/borgmatic.git
synced 2026-07-25 11:23:00 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50096296da | ||
|
|
3bc14ba364 | ||
|
|
c9c6913547 | ||
|
|
779f51f40a | ||
|
|
24b846e9ca | ||
|
|
73fe29b055 | ||
|
|
775385e688 | ||
|
|
efdbee934a | ||
|
|
49719dc309 | ||
|
|
b7e3ee8277 | ||
|
|
97fe1a2c50 | ||
|
|
66abf38b39 | ||
|
|
5baf091853 | ||
|
|
c5abcc1fdf | ||
|
|
9a9a8fd1c6 | ||
|
|
ab9e8d06ee | ||
|
|
5a2cd1b261 | ||
|
|
ffaa99ba15 | ||
|
|
5dc0b08f22 | ||
|
|
23009e22aa | ||
|
|
6cfa10fb7e | ||
|
|
d29d0bc1c6 | ||
|
|
c3f4f94190 | ||
|
|
b2d61ade4e | ||
|
|
cca9039863 | ||
|
|
afcf253318 | ||
|
|
76533c7db5 | ||
|
|
0073366dfc | ||
|
|
13acaa47e4 | ||
|
|
cf326a98a5 | ||
|
|
355eef186e | ||
|
|
c392e4914c | ||
|
|
8fed8e0695 | ||
|
|
52189490a2 | ||
|
|
26b44699ba | ||
|
|
09933c3dc7 | ||
|
|
c702dca8da | ||
|
|
62003c58ea | ||
|
|
67c22e464a | ||
|
|
5a9066940f | ||
|
|
61f0987051 | ||
|
|
63c39be55f | ||
|
|
7e344e6e0a | ||
|
|
b02ff8b6e5 | ||
|
|
b6ff242d3a | ||
|
|
71f1819f05 | ||
|
|
31b6e21139 | ||
|
|
7d56641f56 | ||
|
|
1ad6be2077 | ||
|
|
803361b850 | ||
|
|
e0059de711 | ||
|
|
b9ec9bb873 | ||
|
|
8c5db19490 | ||
|
|
cc7e01be68 | ||
|
|
1232ba8045 | ||
|
|
90c1161a8c | ||
|
|
02451a8b30 |
@@ -1,3 +1,33 @@
|
||||
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
|
||||
|
||||
@@ -56,6 +56,8 @@ 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>
|
||||
@@ -65,6 +67,11 @@ 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>
|
||||
@@ -76,7 +83,11 @@ 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>
|
||||
<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>
|
||||
|
||||
|
||||
### Credentials
|
||||
|
||||
<a href="https://systemd.io/"><img src="docs/static/systemd.png" alt="Sentry" height="40px" style="margin-bottom:20px; margin-right:20px;"></a>
|
||||
|
||||
|
||||
## Getting started
|
||||
|
||||
@@ -22,9 +22,7 @@ def run_borg(
|
||||
if borg_arguments.repository is None or borgmatic.config.validate.repositories_match(
|
||||
repository, borg_arguments.repository
|
||||
):
|
||||
logger.info(
|
||||
f'{repository.get("label", repository["path"])}: Running arbitrary Borg command'
|
||||
)
|
||||
logger.info('Running arbitrary Borg command')
|
||||
archive_name = borgmatic.borg.repo_list.resolve_archive_name(
|
||||
repository['path'],
|
||||
borg_arguments.archive,
|
||||
|
||||
@@ -21,9 +21,7 @@ def run_break_lock(
|
||||
if break_lock_arguments.repository is None or borgmatic.config.validate.repositories_match(
|
||||
repository, break_lock_arguments.repository
|
||||
):
|
||||
logger.info(
|
||||
f'{repository.get("label", repository["path"])}: Breaking repository and cache locks'
|
||||
)
|
||||
logger.info('Breaking repository and cache locks')
|
||||
borgmatic.borg.break_lock.break_lock(
|
||||
repository['path'],
|
||||
config,
|
||||
|
||||
@@ -24,9 +24,7 @@ def run_change_passphrase(
|
||||
repository, change_passphrase_arguments.repository
|
||||
)
|
||||
):
|
||||
logger.info(
|
||||
f'{repository.get("label", repository["path"])}: Changing repository passphrase'
|
||||
)
|
||||
logger.info('Changing repository passphrase')
|
||||
borgmatic.borg.change_passphrase.change_passphrase(
|
||||
repository['path'],
|
||||
config,
|
||||
|
||||
+19
-27
@@ -363,7 +363,6 @@ def collect_spot_check_source_paths(
|
||||
borgmatic.hooks.dispatch.call_hooks(
|
||||
'use_streaming',
|
||||
config,
|
||||
repository['path'],
|
||||
borgmatic.hooks.dispatch.Hook_type.DATA_SOURCE,
|
||||
).values()
|
||||
)
|
||||
@@ -387,13 +386,12 @@ 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,
|
||||
extra_environment=borg_environment,
|
||||
extra_environment=borgmatic.borg.environment.make_environment(config),
|
||||
working_directory=working_directory,
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=config.get('borg_exit_codes'),
|
||||
@@ -443,7 +441,7 @@ def collect_spot_check_archive_paths(
|
||||
config,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
path_format='{type} {path}{NL}', # noqa: FS003
|
||||
path_format='{type} {path}{NUL}', # noqa: FS003
|
||||
local_path=local_path,
|
||||
remote_path=remote_path,
|
||||
)
|
||||
@@ -468,15 +466,14 @@ 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, 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.
|
||||
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.
|
||||
'''
|
||||
# Based on the configured sample percentage, come up with a list of random sample files from the
|
||||
# source directories.
|
||||
@@ -492,7 +489,7 @@ def compare_spot_check_hashes(
|
||||
if os.path.exists(os.path.join(working_directory or '', source_path))
|
||||
}
|
||||
logger.debug(
|
||||
f'{log_prefix}: Sampling {sample_count} source paths (~{spot_check_config["data_sample_percentage"]}%) for spot check'
|
||||
f'Sampling {sample_count} source paths (~{spot_check_config["data_sample_percentage"]}%) for spot check'
|
||||
)
|
||||
|
||||
source_sample_paths_iterator = iter(source_sample_paths)
|
||||
@@ -540,7 +537,7 @@ def compare_spot_check_hashes(
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
list_paths=source_sample_paths_subset,
|
||||
path_format='{xxh64} {path}{NL}', # noqa: FS003
|
||||
path_format='{xxh64} {path}{NUL}', # noqa: FS003
|
||||
local_path=local_path,
|
||||
remote_path=remote_path,
|
||||
)
|
||||
@@ -580,8 +577,7 @@ def spot_check(
|
||||
disk to those stored in the latest archive. If any differences are beyond configured tolerances,
|
||||
then the check fails.
|
||||
'''
|
||||
log_prefix = f'{repository.get("label", repository["path"])}'
|
||||
logger.debug(f'{log_prefix}: Running spot check')
|
||||
logger.debug('Running spot check')
|
||||
|
||||
try:
|
||||
spot_check_config = next(
|
||||
@@ -604,7 +600,7 @@ def spot_check(
|
||||
remote_path,
|
||||
borgmatic_runtime_directory,
|
||||
)
|
||||
logger.debug(f'{log_prefix}: {len(source_paths)} total source paths for spot check')
|
||||
logger.debug(f'{len(source_paths)} total source paths for spot check')
|
||||
|
||||
archive = borgmatic.borg.repo_list.resolve_archive_name(
|
||||
repository['path'],
|
||||
@@ -615,7 +611,7 @@ def spot_check(
|
||||
local_path,
|
||||
remote_path,
|
||||
)
|
||||
logger.debug(f'{log_prefix}: Using archive {archive} for spot check')
|
||||
logger.debug(f'Using archive {archive} for spot check')
|
||||
|
||||
archive_paths = collect_spot_check_archive_paths(
|
||||
repository,
|
||||
@@ -627,11 +623,11 @@ def spot_check(
|
||||
remote_path,
|
||||
borgmatic_runtime_directory,
|
||||
)
|
||||
logger.debug(f'{log_prefix}: {len(archive_paths)} total archive paths for spot check')
|
||||
logger.debug(f'{len(archive_paths)} total archive paths for spot check')
|
||||
|
||||
if len(source_paths) == 0:
|
||||
logger.debug(
|
||||
f'{log_prefix}: Paths in latest archive but not source paths: {", ".join(set(archive_paths)) or "none"}'
|
||||
f'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'
|
||||
@@ -644,10 +640,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'{log_prefix}: Paths in source paths but not latest archive: {", ".join(rootless_source_paths - set(archive_paths)) or "none"}'
|
||||
f'Paths in source paths but not latest archive: {", ".join(rootless_source_paths - set(archive_paths)) or "none"}'
|
||||
)
|
||||
logger.debug(
|
||||
f'{log_prefix}: Paths in latest archive but not source paths: {", ".join(set(archive_paths) - rootless_source_paths) or "none"}'
|
||||
f'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"]}%)'
|
||||
@@ -661,25 +657,24 @@ 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'{log_prefix}: {len(failing_paths)} non-matching spot check hashes')
|
||||
logger.debug(f'{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'{log_prefix}: Source paths with data not matching the latest archive: {", ".join(failing_paths)}'
|
||||
f'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'{log_prefix}: Spot check passed with a {count_delta_percentage:.2f}% file count delta and a {failing_percentage:.2f}% file data delta'
|
||||
f'Spot check passed with a {count_delta_percentage:.2f}% file count delta and a {failing_percentage:.2f}% file data delta'
|
||||
)
|
||||
|
||||
|
||||
@@ -713,8 +708,7 @@ def run_check(
|
||||
**hook_context,
|
||||
)
|
||||
|
||||
log_prefix = repository.get('label', repository['path'])
|
||||
logger.info(f'{log_prefix}: Running consistency checks')
|
||||
logger.info('Running consistency checks')
|
||||
|
||||
repository_id = borgmatic.borg.check.get_repository_id(
|
||||
repository['path'],
|
||||
@@ -767,9 +761,7 @@ 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, log_prefix
|
||||
) as borgmatic_runtime_directory:
|
||||
with borgmatic.config.paths.Runtime_directory(config) as borgmatic_runtime_directory:
|
||||
spot_check(
|
||||
repository,
|
||||
config,
|
||||
|
||||
@@ -37,9 +37,7 @@ def run_compact(
|
||||
**hook_context,
|
||||
)
|
||||
if borgmatic.borg.feature.available(borgmatic.borg.feature.Feature.COMPACT, local_borg_version):
|
||||
logger.info(
|
||||
f'{repository.get("label", repository["path"])}: Compacting segments{dry_run_label}'
|
||||
)
|
||||
logger.info(f'Compacting segments{dry_run_label}')
|
||||
borgmatic.borg.compact.compact_segments(
|
||||
global_arguments.dry_run,
|
||||
repository['path'],
|
||||
@@ -53,9 +51,8 @@ def run_compact(
|
||||
threshold=compact_arguments.threshold,
|
||||
)
|
||||
else: # pragma: nocover
|
||||
logger.info(
|
||||
f'{repository.get("label", repository["path"])}: Skipping compact (only available/needed in Borg 1.2+)'
|
||||
)
|
||||
logger.info('Skipping compact (only available/needed in Borg 1.2+)')
|
||||
|
||||
borgmatic.hooks.command.execute_hook(
|
||||
config.get('after_compact'),
|
||||
config.get('umask'),
|
||||
|
||||
@@ -45,7 +45,6 @@ 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',
|
||||
|
||||
@@ -62,7 +62,7 @@ def collect_patterns(config):
|
||||
)
|
||||
+ tuple(
|
||||
parse_pattern(
|
||||
f'{borgmatic.borg.pattern.Pattern_type.EXCLUDE.value} {exclude_line.strip()}',
|
||||
f'{borgmatic.borg.pattern.Pattern_type.NO_RECURSE.value} {exclude_line.strip()}',
|
||||
borgmatic.borg.pattern.Pattern_style.FNMATCH,
|
||||
)
|
||||
for exclude_line in config.get('exclude_patterns', ())
|
||||
@@ -76,7 +76,7 @@ def collect_patterns(config):
|
||||
)
|
||||
+ tuple(
|
||||
parse_pattern(
|
||||
f'{borgmatic.borg.pattern.Pattern_type.EXCLUDE.value} {exclude_line.strip()}',
|
||||
f'{borgmatic.borg.pattern.Pattern_type.NO_RECURSE.value} {exclude_line.strip()}',
|
||||
borgmatic.borg.pattern.Pattern_style.FNMATCH,
|
||||
)
|
||||
for filename in config.get('exclude_from', ())
|
||||
@@ -283,17 +283,13 @@ def run_create(
|
||||
**hook_context,
|
||||
)
|
||||
|
||||
log_prefix = repository.get('label', repository['path'])
|
||||
logger.info(f'{log_prefix}: Creating archive{dry_run_label}')
|
||||
logger.info(f'Creating archive{dry_run_label}')
|
||||
working_directory = borgmatic.config.paths.get_working_directory(config)
|
||||
|
||||
with borgmatic.config.paths.Runtime_directory(
|
||||
config, log_prefix
|
||||
) as borgmatic_runtime_directory:
|
||||
with borgmatic.config.paths.Runtime_directory(config) 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,
|
||||
@@ -302,7 +298,6 @@ 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,7 +334,6 @@ 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,
|
||||
|
||||
@@ -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(f'{repository.get("label", repository["path"])}: Deleting archives')
|
||||
logger.answer('Deleting archives')
|
||||
|
||||
archive_name = (
|
||||
borgmatic.borg.repo_list.resolve_archive_name(
|
||||
|
||||
@@ -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(f'{repository.get("label", repository["path"])}: Exporting repository key')
|
||||
logger.info('Exporting repository key')
|
||||
borgmatic.borg.export_key.export_key(
|
||||
repository['path'],
|
||||
config,
|
||||
|
||||
@@ -22,9 +22,7 @@ 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'{repository["path"]}: Exporting archive {export_tar_arguments.archive} as tar file'
|
||||
)
|
||||
logger.info(f'Exporting archive {export_tar_arguments.archive} as tar file')
|
||||
borgmatic.borg.export_tar.export_tar_archive(
|
||||
global_arguments.dry_run,
|
||||
repository['path'],
|
||||
|
||||
@@ -33,9 +33,7 @@ def run_extract(
|
||||
if extract_arguments.repository is None or borgmatic.config.validate.repositories_match(
|
||||
repository, extract_arguments.repository
|
||||
):
|
||||
logger.info(
|
||||
f'{repository.get("label", repository["path"])}: Extracting archive {extract_arguments.archive}'
|
||||
)
|
||||
logger.info(f'Extracting archive {extract_arguments.archive}')
|
||||
borgmatic.borg.extract.extract_archive(
|
||||
global_arguments.dry_run,
|
||||
repository['path'],
|
||||
|
||||
@@ -27,9 +27,7 @@ def run_info(
|
||||
repository, info_arguments.repository
|
||||
):
|
||||
if not info_arguments.json:
|
||||
logger.answer(
|
||||
f'{repository.get("label", repository["path"])}: Displaying archive summary information'
|
||||
)
|
||||
logger.answer('Displaying archive summary information')
|
||||
archive_name = borgmatic.borg.repo_list.resolve_archive_name(
|
||||
repository['path'],
|
||||
info_arguments.archive,
|
||||
|
||||
@@ -27,9 +27,9 @@ def run_list(
|
||||
):
|
||||
if not list_arguments.json:
|
||||
if list_arguments.find_paths: # pragma: no cover
|
||||
logger.answer(f'{repository.get("label", repository["path"])}: Searching archives')
|
||||
logger.answer('Searching archives')
|
||||
elif not list_arguments.archive: # pragma: no cover
|
||||
logger.answer(f'{repository.get("label", repository["path"])}: Listing archives')
|
||||
logger.answer('Listing archives')
|
||||
|
||||
archive_name = borgmatic.borg.repo_list.resolve_archive_name(
|
||||
repository['path'],
|
||||
|
||||
@@ -23,11 +23,9 @@ def run_mount(
|
||||
repository, mount_arguments.repository
|
||||
):
|
||||
if mount_arguments.archive:
|
||||
logger.info(
|
||||
f'{repository.get("label", repository["path"])}: Mounting archive {mount_arguments.archive}'
|
||||
)
|
||||
logger.info(f'Mounting archive {mount_arguments.archive}')
|
||||
else: # pragma: nocover
|
||||
logger.info(f'{repository.get("label", repository["path"])}: Mounting repository')
|
||||
logger.info('Mounting repository')
|
||||
|
||||
borgmatic.borg.mount.mount_archive(
|
||||
repository['path'],
|
||||
|
||||
@@ -35,7 +35,7 @@ def run_prune(
|
||||
global_arguments.dry_run,
|
||||
**hook_context,
|
||||
)
|
||||
logger.info(f'{repository.get("label", repository["path"])}: Pruning archives{dry_run_label}')
|
||||
logger.info(f'Pruning archives{dry_run_label}')
|
||||
borgmatic.borg.prune.prune_archives(
|
||||
global_arguments.dry_run,
|
||||
repository['path'],
|
||||
|
||||
@@ -23,7 +23,7 @@ def run_repo_create(
|
||||
):
|
||||
return
|
||||
|
||||
logger.info(f'{repository.get("label", repository["path"])}: Creating repository')
|
||||
logger.info('Creating repository')
|
||||
borgmatic.borg.repo_create.create_repository(
|
||||
global_arguments.dry_run,
|
||||
repository['path'],
|
||||
|
||||
@@ -21,8 +21,7 @@ def run_repo_delete(
|
||||
repository, repo_delete_arguments.repository
|
||||
):
|
||||
logger.answer(
|
||||
f'{repository.get("label", repository["path"])}: Deleting repository'
|
||||
+ (' cache' if repo_delete_arguments.cache_only else '')
|
||||
'Deleting repository' + (' cache' if repo_delete_arguments.cache_only else '')
|
||||
)
|
||||
|
||||
borgmatic.borg.repo_delete.delete_repository(
|
||||
|
||||
@@ -25,9 +25,7 @@ def run_repo_info(
|
||||
repository, repo_info_arguments.repository
|
||||
):
|
||||
if not repo_info_arguments.json:
|
||||
logger.answer(
|
||||
f'{repository.get("label", repository["path"])}: Displaying repository summary information'
|
||||
)
|
||||
logger.answer('Displaying repository summary information')
|
||||
|
||||
json_output = borgmatic.borg.repo_info.display_repository_info(
|
||||
repository['path'],
|
||||
|
||||
@@ -25,7 +25,7 @@ def run_repo_list(
|
||||
repository, repo_list_arguments.repository
|
||||
):
|
||||
if not repo_list_arguments.json:
|
||||
logger.answer(f'{repository.get("label", repository["path"])}: Listing repository')
|
||||
logger.answer('Listing repository')
|
||||
|
||||
json_output = borgmatic.borg.repo_list.list_repository(
|
||||
repository['path'],
|
||||
|
||||
@@ -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 'localhost'
|
||||
hostname = dump.hostname or UNSPECIFIED
|
||||
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, log_prefix):
|
||||
def get_configured_data_source(config, restore_dump):
|
||||
'''
|
||||
Search in the given configuration dict for dumps corresponding to the given dump to restore. If
|
||||
there are multiple matches, error. Log using the given log prefix.
|
||||
there are multiple matches, error.
|
||||
|
||||
Return the found data source as a data source configuration dict or None if not found.
|
||||
'''
|
||||
@@ -91,7 +91,6 @@ def get_configured_data_source(config, restore_dump, log_prefix):
|
||||
borgmatic.hooks.dispatch.call_hook(
|
||||
function_name='get_default_port',
|
||||
config=config,
|
||||
log_prefix=log_prefix,
|
||||
hook_name=hook_name,
|
||||
),
|
||||
)
|
||||
@@ -173,14 +172,11 @@ def restore_single_dump(
|
||||
Dump(hook_name, data_source['name'], data_source.get('hostname'), data_source.get('port'))
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f'{repository.get("label", repository["path"])}: Restoring data source {dump_metadata}'
|
||||
)
|
||||
logger.info(f'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'],
|
||||
@@ -227,7 +223,6 @@ 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,
|
||||
@@ -319,7 +314,7 @@ def collect_dumps_from_archive(
|
||||
break
|
||||
else:
|
||||
logger.warning(
|
||||
f'{repository}: Ignoring invalid data source dump path "{dump_path}" in archive {archive}'
|
||||
f'Ignoring invalid data source dump path "{dump_path}" in archive {archive}'
|
||||
)
|
||||
|
||||
return dumps_from_archive
|
||||
@@ -348,12 +343,15 @@ def get_dumps_to_restore(restore_arguments, dumps_from_archive):
|
||||
else UNSPECIFIED
|
||||
),
|
||||
data_source_name=name,
|
||||
hostname=restore_arguments.original_hostname or 'localhost',
|
||||
hostname=restore_arguments.original_hostname or UNSPECIFIED,
|
||||
port=restore_arguments.original_port,
|
||||
)
|
||||
for name in restore_arguments.data_sources
|
||||
for name in restore_arguments.data_sources or (UNSPECIFIED,)
|
||||
}
|
||||
if restore_arguments.data_sources
|
||||
if restore_arguments.hook
|
||||
or restore_arguments.data_sources
|
||||
or restore_arguments.original_hostname
|
||||
or restore_arguments.original_port
|
||||
else {
|
||||
Dump(
|
||||
hook_name=UNSPECIFIED,
|
||||
@@ -444,16 +442,12 @@ def run_restore(
|
||||
):
|
||||
return
|
||||
|
||||
log_prefix = repository.get('label', repository['path'])
|
||||
logger.info(f'{log_prefix}: Restoring data sources from archive {restore_arguments.archive}')
|
||||
logger.info(f'Restoring data sources from archive {restore_arguments.archive}')
|
||||
|
||||
with borgmatic.config.paths.Runtime_directory(
|
||||
config, log_prefix
|
||||
) as borgmatic_runtime_directory:
|
||||
with borgmatic.config.paths.Runtime_directory(config) 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,
|
||||
@@ -494,7 +488,6 @@ 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
|
||||
@@ -503,7 +496,6 @@ 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:
|
||||
@@ -531,7 +523,6 @@ 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,
|
||||
|
||||
@@ -17,9 +17,7 @@ def run_transfer(
|
||||
'''
|
||||
Run the "transfer" action for the given repository.
|
||||
'''
|
||||
logger.info(
|
||||
f'{repository.get("label", repository["path"])}: Transferring archives to repository'
|
||||
)
|
||||
logger.info('Transferring archives to repository')
|
||||
borgmatic.borg.transfer.transfer_archives(
|
||||
global_arguments.dry_run,
|
||||
repository['path'],
|
||||
|
||||
@@ -34,10 +34,9 @@ def break_lock(
|
||||
+ flags.make_repository_flags(repository_path, local_borg_version)
|
||||
)
|
||||
|
||||
borg_environment = environment.make_environment(config)
|
||||
execute_command(
|
||||
full_command,
|
||||
extra_environment=borg_environment,
|
||||
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'),
|
||||
|
||||
@@ -41,7 +41,7 @@ def change_passphrase(
|
||||
)
|
||||
|
||||
if global_arguments.dry_run:
|
||||
logger.info(f'{repository_path}: Skipping change password (dry run)')
|
||||
logger.info('Skipping change password (dry run)')
|
||||
return
|
||||
|
||||
# If the original passphrase is set programmatically, then Borg won't prompt for a new one! So
|
||||
|
||||
+42
-46
@@ -64,15 +64,11 @@ def make_check_name_flags(checks, archive_filter_flags):
|
||||
|
||||
('--repository-only',)
|
||||
|
||||
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".
|
||||
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.
|
||||
'''
|
||||
if 'data' in checks:
|
||||
data_flags = ('--verify-data',)
|
||||
checks.update({'archives'})
|
||||
else:
|
||||
data_flags = ()
|
||||
|
||||
data_flags = ('--verify-data',) if 'data' in checks else ()
|
||||
common_flags = (archive_filter_flags if 'archives' in checks else ()) + data_flags
|
||||
|
||||
if {'repository', 'archives'}.issubset(checks):
|
||||
@@ -142,51 +138,51 @@ def check_archives(
|
||||
except StopIteration:
|
||||
repository_check_config = {}
|
||||
|
||||
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:
|
||||
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)
|
||||
)
|
||||
|
||||
execute_command(
|
||||
full_command,
|
||||
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,
|
||||
# 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
|
||||
),
|
||||
extra_environment=environment.make_environment(config),
|
||||
working_directory=working_directory,
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=borg_exit_codes,
|
||||
|
||||
@@ -43,7 +43,7 @@ def compact_segments(
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
logging.info(f'{repository_path}: Skipping compact (dry run)')
|
||||
logging.info('Skipping compact (dry run)')
|
||||
return
|
||||
|
||||
execute_command(
|
||||
|
||||
+17
-25
@@ -20,14 +20,12 @@ from borgmatic.execute import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def write_patterns_file(patterns, borgmatic_runtime_directory, log_prefix, patterns_file=None):
|
||||
def write_patterns_file(patterns, borgmatic_runtime_directory, 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.
|
||||
'''
|
||||
@@ -36,14 +34,16 @@ def write_patterns_file(patterns, borgmatic_runtime_directory, log_prefix, patte
|
||||
|
||||
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'{log_prefix}: Writing patterns to {patterns_file.name}:\n{patterns_output}')
|
||||
logger.debug(f'{operation_name} patterns to {patterns_file.name}:\n{patterns_output}')
|
||||
|
||||
patterns_file.write(patterns_output)
|
||||
patterns_file.flush()
|
||||
@@ -122,15 +122,14 @@ 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, 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.
|
||||
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.
|
||||
|
||||
Skip looking for special files in the given borgmatic runtime directory, as borgmatic creates
|
||||
its own special files there for database dumps. And if the borgmatic runtime directory is
|
||||
@@ -144,7 +143,7 @@ def collect_special_file_paths(
|
||||
+ ('--dry-run', '--list'),
|
||||
capture_stderr=True,
|
||||
working_directory=working_directory,
|
||||
extra_environment=borg_environment,
|
||||
extra_environment=environment.make_environment(config),
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=config.get('borg_exit_codes'),
|
||||
)
|
||||
@@ -217,9 +216,7 @@ 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, log_prefix=repository_path
|
||||
)
|
||||
patterns_file = write_patterns_file(patterns, borgmatic_runtime_directory)
|
||||
checkpoint_interval = config.get('checkpoint_interval', None)
|
||||
checkpoint_volume = config.get('checkpoint_volume', None)
|
||||
chunker_params = config.get('chunker_params', None)
|
||||
@@ -299,19 +296,17 @@ 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(
|
||||
f'{repository_path}: Ignoring configured "read_special" value of false, as true is needed for database hooks.'
|
||||
'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(f'{repository_path}: Collecting special file paths')
|
||||
logger.debug('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,
|
||||
)
|
||||
|
||||
@@ -322,19 +317,18 @@ def make_base_create_command(
|
||||
placeholder=' ...',
|
||||
)
|
||||
logger.warning(
|
||||
f'{repository_path}: Excluding special files to prevent Borg from hanging: {truncated_special_file_paths}'
|
||||
f'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.EXCLUDE,
|
||||
borgmatic.borg.pattern.Pattern_type.NO_RECURSE,
|
||||
borgmatic.borg.pattern.Pattern_style.FNMATCH,
|
||||
)
|
||||
for special_file_path in special_file_paths
|
||||
),
|
||||
borgmatic_runtime_directory,
|
||||
log_prefix=repository_path,
|
||||
patterns_file=patterns_file,
|
||||
)
|
||||
|
||||
@@ -399,8 +393,6 @@ 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 ())
|
||||
@@ -417,7 +409,7 @@ def create_archive(
|
||||
output_log_level,
|
||||
output_file,
|
||||
working_directory=working_directory,
|
||||
extra_environment=borg_environment,
|
||||
extra_environment=environment.make_environment(config),
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=borg_exit_codes,
|
||||
)
|
||||
@@ -425,7 +417,7 @@ def create_archive(
|
||||
return execute_command_and_capture_output(
|
||||
create_flags + create_positional_arguments,
|
||||
working_directory=working_directory,
|
||||
extra_environment=borg_environment,
|
||||
extra_environment=environment.make_environment(config),
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=borg_exit_codes,
|
||||
)
|
||||
@@ -435,7 +427,7 @@ def create_archive(
|
||||
output_log_level,
|
||||
output_file,
|
||||
working_directory=working_directory,
|
||||
extra_environment=borg_environment,
|
||||
extra_environment=environment.make_environment(config),
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=borg_exit_codes,
|
||||
)
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
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',
|
||||
@@ -7,12 +10,13 @@ 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',
|
||||
}
|
||||
|
||||
CREDENTIAL_OPTIONS = {'encryption_passphrase'}
|
||||
|
||||
DEFAULT_BOOL_OPTION_TO_DOWNCASE_ENVIRONMENT_VARIABLE = {
|
||||
'relocated_repo_access_is_ok': 'BORG_RELOCATED_REPO_ACCESS_IS_OK',
|
||||
'unknown_unencrypted_repo_access_is_ok': 'BORG_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK',
|
||||
@@ -27,15 +31,34 @@ def make_environment(config):
|
||||
'''
|
||||
Given a borgmatic configuration dict, return its options converted to a Borg environment
|
||||
variable dict.
|
||||
|
||||
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.
|
||||
'''
|
||||
environment = {}
|
||||
|
||||
for option_name, environment_variable_name in OPTION_TO_ENVIRONMENT_VARIABLE.items():
|
||||
value = config.get(option_name)
|
||||
|
||||
if value:
|
||||
if option_name in CREDENTIAL_OPTIONS and value is not None:
|
||||
value = borgmatic.hooks.credential.parse.resolve_credential(value)
|
||||
|
||||
if value is not None:
|
||||
environment[environment_variable_name] = str(value)
|
||||
|
||||
passphrase = borgmatic.borg.passcommand.get_passphrase_from_passcommand(config)
|
||||
|
||||
# If the passcommand produced a passphrase, send it to Borg via an anonymous pipe.
|
||||
if passphrase:
|
||||
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,
|
||||
|
||||
@@ -60,7 +60,7 @@ def export_key(
|
||||
)
|
||||
|
||||
if global_arguments.dry_run:
|
||||
logger.info(f'{repository_path}: Skipping key export (dry run)')
|
||||
logger.info('Skipping key export (dry run)')
|
||||
return
|
||||
|
||||
execute_command(
|
||||
|
||||
@@ -63,7 +63,7 @@ def export_tar_archive(
|
||||
output_log_level = logging.INFO
|
||||
|
||||
if dry_run:
|
||||
logging.info(f'{repository_path}: Skipping export to tar file (dry run)')
|
||||
logging.info('Skipping export to tar file (dry run)')
|
||||
return
|
||||
|
||||
execute_command(
|
||||
|
||||
@@ -44,7 +44,6 @@ 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 ())
|
||||
@@ -59,7 +58,7 @@ def extract_last_archive_dry_run(
|
||||
|
||||
execute_command(
|
||||
full_extract_command,
|
||||
extra_environment=borg_environment,
|
||||
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'),
|
||||
@@ -144,7 +143,6 @@ 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
|
||||
@@ -156,7 +154,7 @@ def extract_archive(
|
||||
return execute_command(
|
||||
full_command,
|
||||
output_file=DO_NOT_CAPTURE,
|
||||
extra_environment=borg_environment,
|
||||
extra_environment=environment.make_environment(config),
|
||||
working_directory=full_destination_path,
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=borg_exit_codes,
|
||||
@@ -168,7 +166,7 @@ def extract_archive(
|
||||
full_command,
|
||||
output_file=subprocess.PIPE,
|
||||
run_to_completion=False,
|
||||
extra_environment=borg_environment,
|
||||
extra_environment=environment.make_environment(config),
|
||||
working_directory=full_destination_path,
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=borg_exit_codes,
|
||||
@@ -178,7 +176,7 @@ def extract_archive(
|
||||
# if the restore paths don't exist in the archive.
|
||||
execute_command(
|
||||
full_command,
|
||||
extra_environment=borg_environment,
|
||||
extra_environment=environment.make_environment(config),
|
||||
working_directory=full_destination_path,
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=borg_exit_codes,
|
||||
|
||||
@@ -106,8 +106,6 @@ 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(
|
||||
@@ -126,12 +124,12 @@ def capture_archive_listing(
|
||||
local_path,
|
||||
remote_path,
|
||||
),
|
||||
extra_environment=borg_environment,
|
||||
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'),
|
||||
)
|
||||
.strip('\n')
|
||||
.strip('\0')
|
||||
.split('\0')
|
||||
)
|
||||
|
||||
@@ -194,7 +192,6 @@ 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
|
||||
@@ -224,7 +221,7 @@ def list_archive(
|
||||
local_path,
|
||||
remote_path,
|
||||
),
|
||||
extra_environment=borg_environment,
|
||||
extra_environment=environment.make_environment(config),
|
||||
working_directory=borgmatic.config.paths.get_working_directory(config),
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=borg_exit_codes,
|
||||
@@ -237,7 +234,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'{repository_path}: Listing archive {archive}')
|
||||
logger.answer(f'Listing archive {archive}')
|
||||
|
||||
archive_arguments = copy.copy(list_arguments)
|
||||
archive_arguments.archive = archive
|
||||
@@ -260,7 +257,7 @@ def list_archive(
|
||||
execute_command(
|
||||
main_command,
|
||||
output_log_level=logging.ANSWER,
|
||||
extra_environment=borg_environment,
|
||||
extra_environment=environment.make_environment(config),
|
||||
working_directory=borgmatic.config.paths.get_working_directory(config),
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=borg_exit_codes,
|
||||
|
||||
@@ -59,7 +59,6 @@ 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.
|
||||
@@ -67,7 +66,7 @@ def mount_archive(
|
||||
execute_command(
|
||||
full_command,
|
||||
output_file=DO_NOT_CAPTURE,
|
||||
extra_environment=borg_environment,
|
||||
extra_environment=environment.make_environment(config),
|
||||
working_directory=working_directory,
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=config.get('borg_exit_codes'),
|
||||
@@ -76,7 +75,7 @@ def mount_archive(
|
||||
|
||||
execute_command(
|
||||
full_command,
|
||||
extra_environment=borg_environment,
|
||||
extra_environment=environment.make_environment(config),
|
||||
working_directory=working_directory,
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=config.get('borg_exit_codes'),
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import functools
|
||||
import logging
|
||||
import shlex
|
||||
|
||||
import borgmatic.config.paths
|
||||
import borgmatic.execute
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@functools.cache
|
||||
def run_passcommand(passcommand, passphrase_configured, working_directory):
|
||||
'''
|
||||
Run the given passcommand using the given working directory and return the passphrase produced
|
||||
by the command. But bail first if a passphrase is already configured; this mimics Borg's
|
||||
behavior.
|
||||
|
||||
Cache the results so that the passcommand only needs to run—and potentially prompt the user—once
|
||||
per borgmatic invocation.
|
||||
'''
|
||||
if passcommand and passphrase_configured:
|
||||
logger.warning(
|
||||
'Ignoring the "encryption_passcommand" option because "encryption_passphrase" is set'
|
||||
)
|
||||
return None
|
||||
|
||||
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
|
||||
|
||||
passphrase = config.get('encryption_passphrase')
|
||||
working_directory = borgmatic.config.paths.get_working_directory(config)
|
||||
|
||||
return run_passcommand(passcommand, bool(passphrase is not None), working_directory)
|
||||
@@ -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(f'{repository_path}: Repository already exists. Skipping creation.')
|
||||
logger.info('Repository already exists. Skipping creation.')
|
||||
return
|
||||
except subprocess.CalledProcessError as error:
|
||||
if error.returncode not in REPO_INFO_REPOSITORY_NOT_FOUND_EXIT_CODES:
|
||||
@@ -91,7 +91,7 @@ def create_repository(
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
logging.info(f'{repository_path}: Skipping repository creation (dry run)')
|
||||
logging.info('Skipping repository creation (dry run)')
|
||||
return
|
||||
|
||||
# Do not capture output here, so as to support interactive prompts.
|
||||
|
||||
@@ -50,14 +50,13 @@ 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,
|
||||
extra_environment=extra_environment,
|
||||
extra_environment=environment.make_environment(config),
|
||||
working_directory=working_directory,
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=borg_exit_codes,
|
||||
@@ -66,7 +65,7 @@ def display_repository_info(
|
||||
execute_command(
|
||||
full_command,
|
||||
output_log_level=logging.ANSWER,
|
||||
extra_environment=extra_environment,
|
||||
extra_environment=environment.make_environment(config),
|
||||
working_directory=working_directory,
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=borg_exit_codes,
|
||||
|
||||
@@ -59,7 +59,7 @@ def resolve_archive_name(
|
||||
except IndexError:
|
||||
raise ValueError('No archives found in the repository')
|
||||
|
||||
logger.debug(f'{repository_path}: Latest archive is {latest_archive}')
|
||||
logger.debug(f'Latest archive is {latest_archive}')
|
||||
|
||||
return latest_archive
|
||||
|
||||
@@ -140,7 +140,6 @@ 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,
|
||||
@@ -165,7 +164,7 @@ def list_repository(
|
||||
|
||||
json_listing = execute_command_and_capture_output(
|
||||
json_command,
|
||||
extra_environment=borg_environment,
|
||||
extra_environment=environment.make_environment(config),
|
||||
working_directory=working_directory,
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=borg_exit_codes,
|
||||
@@ -179,7 +178,7 @@ def list_repository(
|
||||
execute_command(
|
||||
main_command,
|
||||
output_log_level=logging.ANSWER,
|
||||
extra_environment=borg_environment,
|
||||
extra_environment=environment.make_environment(config),
|
||||
working_directory=working_directory,
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=borg_exit_codes,
|
||||
|
||||
@@ -349,12 +349,12 @@ def make_parsers():
|
||||
global_group.add_argument(
|
||||
'--log-file-format',
|
||||
type=str,
|
||||
help='Log format string used for log messages written to the log file',
|
||||
help='Python format string used for log messages written to the log file',
|
||||
)
|
||||
global_group.add_argument(
|
||||
'--log-json',
|
||||
action='store_true',
|
||||
help='Write log messages and console output as one JSON object per log line instead of formatted text',
|
||||
help='Write Borg log messages and console output as one JSON object per log line instead of formatted text',
|
||||
)
|
||||
global_group.add_argument(
|
||||
'--override',
|
||||
|
||||
@@ -39,7 +39,14 @@ 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, add_custom_log_levels, configure_logging, should_do_markup
|
||||
from borgmatic.logger import (
|
||||
DISABLED,
|
||||
Log_prefix,
|
||||
add_custom_log_levels,
|
||||
configure_delayed_logging,
|
||||
configure_logging,
|
||||
should_do_markup,
|
||||
)
|
||||
from borgmatic.signals import configure_signals
|
||||
from borgmatic.verbosity import verbosity_to_log_level
|
||||
|
||||
@@ -86,12 +93,12 @@ def run_configuration(config_filename, config, config_paths, arguments):
|
||||
|
||||
if skip_actions:
|
||||
logger.debug(
|
||||
f"{config_filename}: Skipping {'/'.join(skip_actions)} action{'s' if len(skip_actions) > 1 else ''} due to configured skip_actions"
|
||||
f"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'{config_filename}: Borg {local_borg_version}')
|
||||
logger.debug(f'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
|
||||
@@ -101,8 +108,8 @@ def run_configuration(config_filename, config, config_paths, arguments):
|
||||
dispatch.call_hooks(
|
||||
'initialize_monitor',
|
||||
config,
|
||||
config_filename,
|
||||
dispatch.Hook_type.MONITORING,
|
||||
config_filename,
|
||||
monitoring_log_level,
|
||||
global_arguments.dry_run,
|
||||
)
|
||||
@@ -110,14 +117,14 @@ def run_configuration(config_filename, config, config_paths, arguments):
|
||||
dispatch.call_hooks(
|
||||
'ping_monitor',
|
||||
config,
|
||||
config_filename,
|
||||
dispatch.Hook_type.MONITORING,
|
||||
config_filename,
|
||||
monitor.State.START,
|
||||
monitoring_log_level,
|
||||
global_arguments.dry_run,
|
||||
)
|
||||
except (OSError, CalledProcessError) as error:
|
||||
if command.considered_soft_failure(config_filename, error):
|
||||
if command.considered_soft_failure(error):
|
||||
return
|
||||
|
||||
encountered_error = error
|
||||
@@ -132,53 +139,49 @@ def run_configuration(config_filename, config, config_paths, arguments):
|
||||
|
||||
while not repo_queue.empty():
|
||||
repository, retry_num = repo_queue.get()
|
||||
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),
|
||||
|
||||
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,
|
||||
)
|
||||
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,
|
||||
except (OSError, CalledProcessError, ValueError) as error:
|
||||
if retry_num < retries:
|
||||
repo_queue.put(
|
||||
(repository, retry_num + 1),
|
||||
)
|
||||
)
|
||||
logger.warning(
|
||||
f'{repository.get("label", repository["path"])}: Retrying... attempt {retry_num + 1}/{retries}'
|
||||
)
|
||||
continue
|
||||
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(config_filename, error):
|
||||
continue
|
||||
if command.considered_soft_failure(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']
|
||||
yield from log_error_records(
|
||||
'Error running actions for repository',
|
||||
error,
|
||||
)
|
||||
encountered_error = error
|
||||
error_repository = repository['path']
|
||||
|
||||
try:
|
||||
if monitoring_hooks_are_activated:
|
||||
@@ -186,16 +189,16 @@ def run_configuration(config_filename, config, config_paths, arguments):
|
||||
dispatch.call_hooks(
|
||||
'ping_monitor',
|
||||
config,
|
||||
config_filename,
|
||||
dispatch.Hook_type.MONITORING,
|
||||
config_filename,
|
||||
monitor.State.LOG,
|
||||
monitoring_log_level,
|
||||
global_arguments.dry_run,
|
||||
)
|
||||
except (OSError, CalledProcessError) as error:
|
||||
if not command.considered_soft_failure(config_filename, error):
|
||||
if not command.considered_soft_failure(error):
|
||||
encountered_error = error
|
||||
yield from log_error_records(f'{repository["path"]}: Error pinging monitor', error)
|
||||
yield from log_error_records('Error pinging monitor', error)
|
||||
|
||||
if not encountered_error:
|
||||
try:
|
||||
@@ -203,8 +206,8 @@ def run_configuration(config_filename, config, config_paths, arguments):
|
||||
dispatch.call_hooks(
|
||||
'ping_monitor',
|
||||
config,
|
||||
config_filename,
|
||||
dispatch.Hook_type.MONITORING,
|
||||
config_filename,
|
||||
monitor.State.FINISH,
|
||||
monitoring_log_level,
|
||||
global_arguments.dry_run,
|
||||
@@ -212,13 +215,12 @@ 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(config_filename, error):
|
||||
if command.considered_soft_failure(error):
|
||||
return
|
||||
|
||||
encountered_error = error
|
||||
@@ -239,8 +241,8 @@ def run_configuration(config_filename, config, config_paths, arguments):
|
||||
dispatch.call_hooks(
|
||||
'ping_monitor',
|
||||
config,
|
||||
config_filename,
|
||||
dispatch.Hook_type.MONITORING,
|
||||
config_filename,
|
||||
monitor.State.FAIL,
|
||||
monitoring_log_level,
|
||||
global_arguments.dry_run,
|
||||
@@ -248,13 +250,12 @@ 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(config_filename, error):
|
||||
if command.considered_soft_failure(error):
|
||||
return
|
||||
|
||||
yield from log_error_records(f'{config_filename}: Error running on-error hook', error)
|
||||
@@ -534,13 +535,16 @@ def run_actions(
|
||||
|
||||
def load_configurations(config_filenames, overrides=None, resolve_env=True):
|
||||
'''
|
||||
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.
|
||||
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.
|
||||
|
||||
Log records are returned here instead of being logged directly because logging isn't yet
|
||||
initialized at this point!
|
||||
initialized at this point! (Although with the Delayed_logging_handler now in place, maybe this
|
||||
approach could change.)
|
||||
'''
|
||||
# Dict mapping from config filename to corresponding parsed config dict.
|
||||
configs = collections.OrderedDict()
|
||||
@@ -562,7 +566,10 @@ 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)
|
||||
@@ -819,23 +826,27 @@ 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():
|
||||
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(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',
|
||||
)
|
||||
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)
|
||||
)
|
||||
if results:
|
||||
json_results.extend(results)
|
||||
|
||||
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 results:
|
||||
json_results.extend(results)
|
||||
|
||||
if 'umount' in arguments:
|
||||
logger.info(f"Unmounting mount point {arguments['umount'].mount_point}")
|
||||
@@ -876,6 +887,7 @@ 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:])
|
||||
@@ -901,9 +913,12 @@ 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, global_arguments.resolve_env
|
||||
config_filenames,
|
||||
global_arguments.overrides,
|
||||
resolve_env=global_arguments.resolve_env and not validate,
|
||||
)
|
||||
configuration_parse_errors = (
|
||||
(max(log.levelno for log in parse_logs) >= logging.CRITICAL) if parse_logs else False
|
||||
|
||||
@@ -69,7 +69,7 @@ def include_configuration(loader, filename_node, include_directory, config_paths
|
||||
]
|
||||
|
||||
raise ValueError(
|
||||
'!include value is not supported; use a single filename or a list of filenames'
|
||||
'The value given for the !include tag is invalid; use a single filename or a list of filenames instead'
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -76,14 +76,13 @@ class Runtime_directory:
|
||||
automatically gets cleaned up as necessary.
|
||||
'''
|
||||
|
||||
def __init__(self, config, log_prefix):
|
||||
def __init__(self, config):
|
||||
'''
|
||||
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.
|
||||
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.
|
||||
|
||||
If XDG_RUNTIME_DIR or RUNTIME_DIRECTORY is set and already ends in "/borgmatic", then don't
|
||||
tack on a second "/borgmatic" path component.
|
||||
@@ -127,7 +126,7 @@ class Runtime_directory:
|
||||
)
|
||||
os.makedirs(self.runtime_path, mode=0o700, exist_ok=True)
|
||||
|
||||
logger.debug(f'{log_prefix}: Using runtime directory {os.path.normpath(self.runtime_path)}')
|
||||
logger.debug(f'Using runtime directory {os.path.normpath(self.runtime_path)}')
|
||||
|
||||
def __enter__(self):
|
||||
'''
|
||||
|
||||
@@ -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.
|
||||
Defaults to not set. Supports the "{credential ...}" syntax.
|
||||
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 a
|
||||
simultaneous "archives" check or "--repair" flag.
|
||||
the repository index and is not compatible with
|
||||
the "--repair" flag.
|
||||
example: 3600
|
||||
- required:
|
||||
- name
|
||||
@@ -989,13 +989,15 @@ 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".
|
||||
database name is "all". Supports the "{credential ...}"
|
||||
syntax.
|
||||
example: dbuser
|
||||
restore_username:
|
||||
type: string
|
||||
description: |
|
||||
Username with which to restore the database. Defaults to
|
||||
the "username" option.
|
||||
the "username" option. Supports the "{credential ...}"
|
||||
syntax.
|
||||
example: dbuser
|
||||
password:
|
||||
type: string
|
||||
@@ -1003,13 +1005,15 @@ 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.
|
||||
create a ~/.pgpass file. 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.
|
||||
Defaults to the "password" option. Supports the
|
||||
"{credential ...}" syntax.
|
||||
example: trustsome1
|
||||
no_owner:
|
||||
type: boolean
|
||||
@@ -1169,13 +1173,15 @@ properties:
|
||||
type: string
|
||||
description: |
|
||||
Username with which to connect to the database. Defaults
|
||||
to the username of the current user.
|
||||
to the username of the current user. Supports the
|
||||
"{credential ...}" syntax.
|
||||
example: dbuser
|
||||
restore_username:
|
||||
type: string
|
||||
description: |
|
||||
Username with which to restore the database. Defaults to
|
||||
the "username" option.
|
||||
the "username" option. Supports the "{credential ...}"
|
||||
syntax.
|
||||
example: dbuser
|
||||
password:
|
||||
type: string
|
||||
@@ -1183,6 +1189,14 @@ 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
|
||||
@@ -1201,12 +1215,6 @@ 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']
|
||||
@@ -1295,13 +1303,15 @@ properties:
|
||||
type: string
|
||||
description: |
|
||||
Username with which to connect to the database. Defaults
|
||||
to the username of the current user.
|
||||
to the username of the current user. Supports the
|
||||
"{credential ...}" syntax.
|
||||
example: dbuser
|
||||
restore_username:
|
||||
type: string
|
||||
description: |
|
||||
Username with which to restore the database. Defaults to
|
||||
the "username" option.
|
||||
the "username" option. Supports the "{credential ...}"
|
||||
syntax.
|
||||
example: dbuser
|
||||
password:
|
||||
type: string
|
||||
@@ -1309,12 +1319,14 @@ 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.
|
||||
Defaults to the "password" option. Supports the
|
||||
"{credential ...}" syntax.
|
||||
example: trustsome1
|
||||
mysql_dump_command:
|
||||
type: string
|
||||
@@ -1451,25 +1463,29 @@ properties:
|
||||
type: string
|
||||
description: |
|
||||
Username with which to connect to the database. Skip it
|
||||
if no authentication is needed.
|
||||
if no authentication is needed. Supports the
|
||||
"{credential ...}" syntax.
|
||||
example: dbuser
|
||||
restore_username:
|
||||
type: string
|
||||
description: |
|
||||
Username with which to restore the database. Defaults to
|
||||
the "username" option.
|
||||
the "username" option. Supports the "{credential ...}"
|
||||
syntax.
|
||||
example: dbuser
|
||||
password:
|
||||
type: string
|
||||
description: |
|
||||
Password with which to connect to the database. Skip it
|
||||
if no authentication is needed.
|
||||
if no authentication is needed. 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.
|
||||
Defaults to the "password" option. Supports the
|
||||
"{credential ...}" syntax.
|
||||
example: trustsome1
|
||||
authentication_database:
|
||||
type: string
|
||||
@@ -1528,18 +1544,20 @@ properties:
|
||||
username:
|
||||
type: string
|
||||
description: |
|
||||
The username used for authentication.
|
||||
The username used for authentication. Supports the
|
||||
"{credential ...}" syntax.
|
||||
example: testuser
|
||||
password:
|
||||
type: string
|
||||
description: |
|
||||
The password used for authentication.
|
||||
The password used for authentication. Supports the
|
||||
"{credential ...}" syntax.
|
||||
example: fakepassword
|
||||
access_token:
|
||||
type: string
|
||||
description: |
|
||||
An ntfy access token to authenticate with instead of
|
||||
username/password.
|
||||
username/password. Supports the "{credential ...}" syntax.
|
||||
example: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2
|
||||
start:
|
||||
type: object
|
||||
@@ -1634,14 +1652,16 @@ properties:
|
||||
token:
|
||||
type: string
|
||||
description: |
|
||||
Your application's API token.
|
||||
Your application's API token. Supports the "{credential
|
||||
...}" syntax.
|
||||
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
|
||||
@@ -1915,19 +1935,19 @@ properties:
|
||||
type: string
|
||||
description: |
|
||||
The username used for authentication. Not needed if using
|
||||
an API key.
|
||||
an API key. Supports the "{credential ...}" syntax.
|
||||
example: testuser
|
||||
password:
|
||||
type: string
|
||||
description: |
|
||||
The password used for authentication. Not needed if using
|
||||
an API key.
|
||||
an API key. Supports the "{credential ...}" syntax.
|
||||
example: fakepassword
|
||||
api_key:
|
||||
type: string
|
||||
description: |
|
||||
The API key used for authentication. Not needed if using
|
||||
an username/password.
|
||||
The API key used for authentication. Not needed if using an
|
||||
username/password. Supports the "{credential ...}" syntax.
|
||||
example: fakekey
|
||||
start:
|
||||
type: object
|
||||
@@ -2207,8 +2227,8 @@ properties:
|
||||
integration_key:
|
||||
type: string
|
||||
description: |
|
||||
PagerDuty integration key used to notify PagerDuty
|
||||
when a backup errors.
|
||||
PagerDuty integration key used to notify PagerDuty when a
|
||||
backup errors. Supports the "{credential ...}" syntax.
|
||||
example: a177cad45bd374409f78906a810a3074
|
||||
description: |
|
||||
Configuration for a monitoring integration with PagerDuty. Create an
|
||||
|
||||
@@ -88,8 +88,9 @@ 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", return the parsed configuration as a data structure of nested dicts
|
||||
and lists corresponding to the schema. Example return value:
|
||||
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:
|
||||
|
||||
{
|
||||
'source_directories': ['/home', '/etc'],
|
||||
@@ -124,6 +125,7 @@ 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:
|
||||
|
||||
+16
-7
@@ -6,6 +6,8 @@ import select
|
||||
import subprocess
|
||||
import textwrap
|
||||
|
||||
import borgmatic.logger
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -305,17 +307,20 @@ def execute_command(
|
||||
shell=shell,
|
||||
env=environment,
|
||||
cwd=working_directory,
|
||||
# Necessary for the passcommand credential hook to work.
|
||||
close_fds=not bool((extra_environment or {}).get('BORG_PASSPHRASE_FD')),
|
||||
)
|
||||
if not run_to_completion:
|
||||
return process
|
||||
|
||||
log_outputs(
|
||||
(process,),
|
||||
(input_file, output_file),
|
||||
output_log_level,
|
||||
borg_local_path,
|
||||
borg_exit_codes,
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
def execute_command_and_capture_output(
|
||||
@@ -351,6 +356,8 @@ 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((extra_environment or {}).get('BORG_PASSPHRASE_FD')),
|
||||
)
|
||||
except subprocess.CalledProcessError as error:
|
||||
if (
|
||||
@@ -411,6 +418,8 @@ def execute_command_with_processes(
|
||||
shell=shell,
|
||||
env=environment,
|
||||
cwd=working_directory,
|
||||
# Necessary for the passcommand credential hook to work.
|
||||
close_fds=not bool((extra_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.
|
||||
|
||||
@@ -12,7 +12,7 @@ logger = logging.getLogger(__name__)
|
||||
SOFT_FAIL_EXIT_CODE = 75
|
||||
|
||||
|
||||
def interpolate_context(config_filename, hook_description, command, context):
|
||||
def interpolate_context(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(config_filename, hook_description, command, context):
|
||||
|
||||
for unsupported_variable in re.findall(r'{\w+}', command):
|
||||
logger.warning(
|
||||
f"{config_filename}: Variable '{unsupported_variable}' is not supported in {hook_description} hook"
|
||||
f"Variable '{unsupported_variable}' is not supported in {hook_description} hook"
|
||||
)
|
||||
|
||||
return command
|
||||
@@ -54,26 +54,24 @@ 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'{config_filename}: No commands to run for {description} hook')
|
||||
logger.debug(f'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(config_filename, description, command, context) for command in commands
|
||||
]
|
||||
commands = [interpolate_context(description, command, context) for command in commands]
|
||||
|
||||
if len(commands) == 1:
|
||||
logger.info(f'{config_filename}: Running command for {description} hook{dry_run_label}')
|
||||
logger.info(f'Running command for {description} hook{dry_run_label}')
|
||||
else:
|
||||
logger.info(
|
||||
f'{config_filename}: Running {len(commands)} commands for {description} hook{dry_run_label}',
|
||||
f'Running {len(commands)} commands for {description} hook{dry_run_label}',
|
||||
)
|
||||
|
||||
if umask:
|
||||
parsed_umask = int(str(umask), 8)
|
||||
logger.debug(f'{config_filename}: Set hook umask to {oct(parsed_umask)}')
|
||||
logger.debug(f'Set hook umask to {oct(parsed_umask)}')
|
||||
original_umask = os.umask(parsed_umask)
|
||||
else:
|
||||
original_umask = None
|
||||
@@ -94,7 +92,7 @@ def execute_hook(commands, umask, config_filename, description, dry_run, **conte
|
||||
os.umask(original_umask)
|
||||
|
||||
|
||||
def considered_soft_failure(config_filename, error):
|
||||
def considered_soft_failure(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 +104,7 @@ def considered_soft_failure(config_filename, error):
|
||||
|
||||
if exit_code == SOFT_FAIL_EXIT_CODE:
|
||||
logger.info(
|
||||
f'{config_filename}: Command hook exited with soft failure exit code ({SOFT_FAIL_EXIT_CODE}); skipping remaining repository actions',
|
||||
f'Command hook exited with soft failure exit code ({SOFT_FAIL_EXIT_CODE}); skipping remaining repository actions',
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import functools
|
||||
import re
|
||||
|
||||
import borgmatic.hooks.dispatch
|
||||
|
||||
IS_A_HOOK = False
|
||||
|
||||
|
||||
CREDENTIAL_PATTERN = re.compile(
|
||||
r'\{credential +(?P<hook_name>[A-Za-z0-9_]+) +(?P<credential_name>[A-Za-z0-9_]+)\}'
|
||||
)
|
||||
|
||||
GENERAL_CREDENTIAL_PATTERN = re.compile(r'\{credential( +[^}]*)?\}')
|
||||
|
||||
|
||||
@functools.cache
|
||||
def resolve_credential(value):
|
||||
'''
|
||||
Given a configuration value containing a string like "{credential hookname credentialname}", resolve it 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 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
|
||||
|
||||
result = CREDENTIAL_PATTERN.sub(
|
||||
lambda matcher: borgmatic.hooks.dispatch.call_hook(
|
||||
'load_credential', {}, matcher.group('hook_name'), matcher.group('credential_name')
|
||||
),
|
||||
value,
|
||||
)
|
||||
|
||||
# If we've tried to parse the credential, but the parsed result still looks kind of like a
|
||||
# credential, it means it's invalid syntax.
|
||||
if GENERAL_CREDENTIAL_PATTERN.match(result):
|
||||
raise ValueError(f'Cannot load credential with invalid syntax "{value}"')
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,35 @@
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
CREDENTIAL_NAME_PATTERN = re.compile(r'^\w+$')
|
||||
|
||||
|
||||
def load_credential(hook_config, config, credential_name):
|
||||
'''
|
||||
Given the hook configuration dict, the configuration dict, and 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.
|
||||
'''
|
||||
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}')
|
||||
@@ -10,7 +10,7 @@ import borgmatic.config.paths
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def use_streaming(hook_config, config, log_prefix): # pragma: no cover
|
||||
def use_streaming(hook_config, config): # pragma: no cover
|
||||
'''
|
||||
Return whether dump streaming is used for this hook. (Spoiler: It isn't.)
|
||||
'''
|
||||
@@ -20,18 +20,17 @@ def use_streaming(hook_config, config, log_prefix): # 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, 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.
|
||||
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.
|
||||
|
||||
Return an empty sequence, since there are no ongoing dump processes from this hook.
|
||||
'''
|
||||
@@ -64,11 +63,11 @@ def dump_data_sources(
|
||||
return []
|
||||
|
||||
|
||||
def remove_data_source_dumps(hook_config, config, log_prefix, borgmatic_runtime_directory, dry_run):
|
||||
def remove_data_source_dumps(hook_config, config, borgmatic_runtime_directory, dry_run):
|
||||
'''
|
||||
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.
|
||||
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.
|
||||
'''
|
||||
dry_run_label = ' (dry run; not actually removing anything)' if dry_run else ''
|
||||
|
||||
@@ -79,14 +78,12 @@ def remove_data_source_dumps(hook_config, config, log_prefix, borgmatic_runtime_
|
||||
'bootstrap',
|
||||
)
|
||||
logger.debug(
|
||||
f'{log_prefix}: Looking for bootstrap manifest files to remove in {manifest_glob}{dry_run_label}'
|
||||
f'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'{log_prefix}: Removing bootstrap manifest at {manifest_file_path}{dry_run_label}'
|
||||
)
|
||||
logger.debug(f'Removing bootstrap manifest at {manifest_file_path}{dry_run_label}')
|
||||
|
||||
if dry_run:
|
||||
continue
|
||||
@@ -103,7 +100,7 @@ def remove_data_source_dumps(hook_config, config, log_prefix, borgmatic_runtime_
|
||||
|
||||
|
||||
def make_data_source_dump_patterns(
|
||||
hook_config, config, log_prefix, borgmatic_runtime_directory, name=None
|
||||
hook_config, config, borgmatic_runtime_directory, name=None
|
||||
): # pragma: no cover
|
||||
'''
|
||||
Restores are implemented via the separate, purpose-specific "bootstrap" action rather than the
|
||||
@@ -115,7 +112,6 @@ def make_data_source_dump_patterns(
|
||||
def restore_data_source_dump(
|
||||
hook_config,
|
||||
config,
|
||||
log_prefix,
|
||||
data_source,
|
||||
dry_run,
|
||||
extract_process,
|
||||
|
||||
@@ -14,16 +14,16 @@ import borgmatic.hooks.data_source.snapshot
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def use_streaming(hook_config, config, log_prefix): # pragma: no cover
|
||||
def use_streaming(hook_config, config): # pragma: no cover
|
||||
'''
|
||||
Return whether dump streaming is used for this hook. (Spoiler: It isn't.)
|
||||
'''
|
||||
return False
|
||||
|
||||
|
||||
def get_filesystem_mount_points(findmnt_command):
|
||||
def get_subvolume_mount_points(findmnt_command):
|
||||
'''
|
||||
Given a findmnt command to run, get all top-level Btrfs filesystem mount points.
|
||||
Given a findmnt command to run, get all sorted Btrfs subvolume mount points.
|
||||
'''
|
||||
findmnt_output = borgmatic.execute.execute_command_and_capture_output(
|
||||
tuple(findmnt_command.split(' '))
|
||||
@@ -37,7 +37,7 @@ def get_filesystem_mount_points(findmnt_command):
|
||||
|
||||
try:
|
||||
return tuple(
|
||||
filesystem['target'] for filesystem in json.loads(findmnt_output)['filesystems']
|
||||
sorted(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,34 +45,6 @@ def get_filesystem_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=((),))
|
||||
|
||||
|
||||
@@ -89,20 +61,16 @@ def get_subvolumes(btrfs_command, findmnt_command, patterns=None):
|
||||
candidate_patterns = set(patterns or ())
|
||||
subvolumes = []
|
||||
|
||||
# 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):
|
||||
# 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)):
|
||||
subvolumes.extend(
|
||||
Subvolume(subvolume_path, contained_patterns)
|
||||
for subvolume_path in reversed(
|
||||
get_subvolumes_for_filesystem(btrfs_command, mount_point)
|
||||
)
|
||||
Subvolume(mount_point, contained_patterns)
|
||||
for contained_patterns in (
|
||||
borgmatic.hooks.data_source.snapshot.get_contained_patterns(
|
||||
subvolume_path, candidate_patterns
|
||||
mount_point, candidate_patterns
|
||||
),
|
||||
)
|
||||
if patterns is None or contained_patterns
|
||||
@@ -151,7 +119,7 @@ def make_snapshot_exclude_pattern(subvolume_path): # pragma: no cover
|
||||
subvolume_path.lstrip(os.path.sep),
|
||||
snapshot_directory,
|
||||
),
|
||||
borgmatic.borg.pattern.Pattern_type.EXCLUDE,
|
||||
borgmatic.borg.pattern.Pattern_type.NO_RECURSE,
|
||||
borgmatic.borg.pattern.Pattern_style.FNMATCH,
|
||||
)
|
||||
|
||||
@@ -211,26 +179,24 @@ 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, 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.
|
||||
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.
|
||||
|
||||
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'{log_prefix}: Snapshotting Btrfs subvolumes{dry_run_label}')
|
||||
logger.info(f'Snapshotting Btrfs subvolumes{dry_run_label}')
|
||||
|
||||
# Based on the configured patterns, determine Btrfs subvolumes to backup.
|
||||
btrfs_command = hook_config.get('btrfs_command', 'btrfs')
|
||||
@@ -238,11 +204,11 @@ def dump_data_sources(
|
||||
subvolumes = get_subvolumes(btrfs_command, findmnt_command, patterns)
|
||||
|
||||
if not subvolumes:
|
||||
logger.warning(f'{log_prefix}: No Btrfs subvolumes found to snapshot{dry_run_label}')
|
||||
logger.warning(f'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'{log_prefix}: Creating Btrfs snapshot for {subvolume.path} subvolume')
|
||||
logger.debug(f'Creating Btrfs snapshot for {subvolume.path} subvolume')
|
||||
|
||||
snapshot_path = make_snapshot_path(subvolume.path)
|
||||
|
||||
@@ -280,12 +246,11 @@ def delete_snapshot(btrfs_command, snapshot_path): # pragma: no cover
|
||||
)
|
||||
|
||||
|
||||
def remove_data_source_dumps(hook_config, config, log_prefix, borgmatic_runtime_directory, dry_run):
|
||||
def remove_data_source_dumps(hook_config, config, borgmatic_runtime_directory, dry_run):
|
||||
'''
|
||||
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.
|
||||
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.
|
||||
'''
|
||||
if hook_config is None:
|
||||
return
|
||||
@@ -298,10 +263,10 @@ def remove_data_source_dumps(hook_config, config, log_prefix, borgmatic_runtime_
|
||||
try:
|
||||
all_subvolumes = get_subvolumes(btrfs_command, findmnt_command)
|
||||
except FileNotFoundError as error:
|
||||
logger.debug(f'{log_prefix}: Could not find "{error.filename}" command')
|
||||
logger.debug(f'Could not find "{error.filename}" command')
|
||||
return
|
||||
except subprocess.CalledProcessError as error:
|
||||
logger.debug(f'{log_prefix}: {error}')
|
||||
logger.debug(error)
|
||||
return
|
||||
|
||||
# Reversing the sorted subvolumes ensures that we remove longer mount point paths of child
|
||||
@@ -313,14 +278,14 @@ def remove_data_source_dumps(hook_config, config, log_prefix, borgmatic_runtime_
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f'{log_prefix}: Looking for snapshots to remove in {subvolume_snapshots_glob}{dry_run_label}'
|
||||
f'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'{log_prefix}: Deleting Btrfs snapshot {snapshot_path}{dry_run_label}')
|
||||
logger.debug(f'Deleting Btrfs snapshot {snapshot_path}{dry_run_label}')
|
||||
|
||||
if dry_run:
|
||||
continue
|
||||
@@ -328,10 +293,10 @@ def remove_data_source_dumps(hook_config, config, log_prefix, borgmatic_runtime_
|
||||
try:
|
||||
delete_snapshot(btrfs_command, snapshot_path)
|
||||
except FileNotFoundError:
|
||||
logger.debug(f'{log_prefix}: Could not find "{btrfs_command}" command')
|
||||
logger.debug(f'Could not find "{btrfs_command}" command')
|
||||
return
|
||||
except subprocess.CalledProcessError as error:
|
||||
logger.debug(f'{log_prefix}: {error}')
|
||||
logger.debug(error)
|
||||
return
|
||||
|
||||
# Strip off the subvolume path from the end of the snapshot path and then delete the
|
||||
@@ -340,7 +305,7 @@ def remove_data_source_dumps(hook_config, config, log_prefix, borgmatic_runtime_
|
||||
|
||||
|
||||
def make_data_source_dump_patterns(
|
||||
hook_config, config, log_prefix, borgmatic_runtime_directory, name=None
|
||||
hook_config, config, borgmatic_runtime_directory, name=None
|
||||
): # pragma: no cover
|
||||
'''
|
||||
Restores aren't implemented, because stored files can be extracted directly with "extract".
|
||||
@@ -351,7 +316,6 @@ def make_data_source_dump_patterns(
|
||||
def restore_data_source_dump(
|
||||
hook_config,
|
||||
config,
|
||||
log_prefix,
|
||||
data_source,
|
||||
dry_run,
|
||||
extract_process,
|
||||
|
||||
@@ -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, log_prefix, dry_run):
|
||||
def remove_data_source_dumps(dump_path, data_source_type_name, 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'{log_prefix}: Removing {data_source_type_name} data source dumps{dry_run_label}')
|
||||
logger.debug(f'Removing {data_source_type_name} data source dumps{dry_run_label}')
|
||||
|
||||
if dry_run:
|
||||
return
|
||||
|
||||
@@ -14,7 +14,7 @@ import borgmatic.hooks.data_source.snapshot
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def use_streaming(hook_config, config, log_prefix): # pragma: no cover
|
||||
def use_streaming(hook_config, config): # pragma: no cover
|
||||
'''
|
||||
Return whether dump streaming is used for this hook. (Spoiler: It isn't.)
|
||||
'''
|
||||
@@ -161,26 +161,24 @@ 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, 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.
|
||||
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.
|
||||
|
||||
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'{log_prefix}: Snapshotting LVM logical volumes{dry_run_label}')
|
||||
logger.info(f'Snapshotting LVM logical volumes{dry_run_label}')
|
||||
|
||||
# List logical volumes to get their mount points.
|
||||
lsblk_command = hook_config.get('lsblk_command', 'lsblk')
|
||||
@@ -191,12 +189,12 @@ def dump_data_sources(
|
||||
normalized_runtime_directory = os.path.normpath(borgmatic_runtime_directory)
|
||||
|
||||
if not requested_logical_volumes:
|
||||
logger.warning(f'{log_prefix}: No LVM logical volumes found to snapshot{dry_run_label}')
|
||||
logger.warning(f'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'{log_prefix}: Creating LVM snapshot {snapshot_name} of {logical_volume.mount_point}{dry_run_label}'
|
||||
f'Creating LVM snapshot {snapshot_name} of {logical_volume.mount_point}{dry_run_label}'
|
||||
)
|
||||
|
||||
if not dry_run:
|
||||
@@ -224,7 +222,7 @@ def dump_data_sources(
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f'{log_prefix}: Mounting LVM snapshot {snapshot_name} at {snapshot_mount_path}{dry_run_label}'
|
||||
f'Mounting LVM snapshot {snapshot_name} at {snapshot_mount_path}{dry_run_label}'
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
@@ -312,12 +310,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, log_prefix, borgmatic_runtime_directory, dry_run):
|
||||
def remove_data_source_dumps(hook_config, config, borgmatic_runtime_directory, dry_run):
|
||||
'''
|
||||
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.
|
||||
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.
|
||||
'''
|
||||
if hook_config is None:
|
||||
return
|
||||
@@ -328,10 +326,10 @@ def remove_data_source_dumps(hook_config, config, log_prefix, borgmatic_runtime_
|
||||
try:
|
||||
logical_volumes = get_logical_volumes(hook_config.get('lsblk_command', 'lsblk'))
|
||||
except FileNotFoundError as error:
|
||||
logger.debug(f'{log_prefix}: Could not find "{error.filename}" command')
|
||||
logger.debug(f'Could not find "{error.filename}" command')
|
||||
return
|
||||
except subprocess.CalledProcessError as error:
|
||||
logger.debug(f'{log_prefix}: {error}')
|
||||
logger.debug(error)
|
||||
return
|
||||
|
||||
snapshots_glob = os.path.join(
|
||||
@@ -340,9 +338,7 @@ def remove_data_source_dumps(hook_config, config, log_prefix, borgmatic_runtime_
|
||||
),
|
||||
'lvm_snapshots',
|
||||
)
|
||||
logger.debug(
|
||||
f'{log_prefix}: Looking for snapshots to remove in {snapshots_glob}{dry_run_label}'
|
||||
)
|
||||
logger.debug(f'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):
|
||||
@@ -366,9 +362,7 @@ def remove_data_source_dumps(hook_config, config, log_prefix, borgmatic_runtime_
|
||||
if not os.path.isdir(snapshot_mount_path):
|
||||
continue
|
||||
|
||||
logger.debug(
|
||||
f'{log_prefix}: Unmounting LVM snapshot at {snapshot_mount_path}{dry_run_label}'
|
||||
)
|
||||
logger.debug(f'Unmounting LVM snapshot at {snapshot_mount_path}{dry_run_label}')
|
||||
|
||||
if dry_run:
|
||||
continue
|
||||
@@ -376,10 +370,10 @@ def remove_data_source_dumps(hook_config, config, log_prefix, borgmatic_runtime_
|
||||
try:
|
||||
unmount_snapshot(umount_command, snapshot_mount_path)
|
||||
except FileNotFoundError:
|
||||
logger.debug(f'{log_prefix}: Could not find "{umount_command}" command')
|
||||
logger.debug(f'Could not find "{umount_command}" command')
|
||||
return
|
||||
except subprocess.CalledProcessError as error:
|
||||
logger.debug(f'{log_prefix}: {error}')
|
||||
logger.debug(error)
|
||||
return
|
||||
|
||||
if not dry_run:
|
||||
@@ -391,10 +385,10 @@ def remove_data_source_dumps(hook_config, config, log_prefix, borgmatic_runtime_
|
||||
try:
|
||||
snapshots = get_snapshots(hook_config.get('lvs_command', 'lvs'))
|
||||
except FileNotFoundError as error:
|
||||
logger.debug(f'{log_prefix}: Could not find "{error.filename}" command')
|
||||
logger.debug(f'Could not find "{error.filename}" command')
|
||||
return
|
||||
except subprocess.CalledProcessError as error:
|
||||
logger.debug(f'{log_prefix}: {error}')
|
||||
logger.debug(error)
|
||||
return
|
||||
|
||||
for snapshot in snapshots:
|
||||
@@ -402,14 +396,14 @@ def remove_data_source_dumps(hook_config, config, log_prefix, borgmatic_runtime_
|
||||
if not snapshot.name.split('_')[-1].startswith(BORGMATIC_SNAPSHOT_PREFIX):
|
||||
continue
|
||||
|
||||
logger.debug(f'{log_prefix}: Deleting LVM snapshot {snapshot.name}{dry_run_label}')
|
||||
logger.debug(f'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, log_prefix, borgmatic_runtime_directory, name=None
|
||||
hook_config, config, borgmatic_runtime_directory, name=None
|
||||
): # pragma: no cover
|
||||
'''
|
||||
Restores aren't implemented, because stored files can be extracted directly with "extract".
|
||||
@@ -420,7 +414,6 @@ def make_data_source_dump_patterns(
|
||||
def restore_data_source_dump(
|
||||
hook_config,
|
||||
config,
|
||||
log_prefix,
|
||||
data_source,
|
||||
dry_run,
|
||||
extract_process,
|
||||
|
||||
@@ -5,6 +5,7 @@ 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,7 +26,7 @@ def make_dump_path(base_directory): # pragma: no cover
|
||||
SYSTEM_DATABASE_NAMES = ('information_schema', 'mysql', 'performance_schema', 'sys')
|
||||
|
||||
|
||||
def database_names_to_dump(database, extra_environment, log_prefix, dry_run):
|
||||
def database_names_to_dump(database, extra_environment, dry_run):
|
||||
'''
|
||||
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,
|
||||
@@ -45,11 +46,15 @@ def database_names_to_dump(database, extra_environment, log_prefix, 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', database['username']) if 'username' in database else ())
|
||||
+ (
|
||||
('--user', borgmatic.hooks.credential.parse.resolve_credential(database['username']))
|
||||
if 'username' in database
|
||||
else ()
|
||||
)
|
||||
+ ('--skip-column-names', '--batch')
|
||||
+ ('--execute', 'show schemas')
|
||||
)
|
||||
logger.debug(f'{log_prefix}: Querying for "all" MariaDB databases to dump')
|
||||
logger.debug('Querying for "all" MariaDB databases to dump')
|
||||
show_output = execute_command_and_capture_output(
|
||||
show_command, extra_environment=extra_environment
|
||||
)
|
||||
@@ -62,12 +67,11 @@ def database_names_to_dump(database, extra_environment, log_prefix, dry_run):
|
||||
|
||||
|
||||
def execute_dump_command(
|
||||
database, log_prefix, dump_path, database_names, extra_environment, dry_run, dry_run_label
|
||||
database, 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. Use the given log prefix in any
|
||||
log entries.
|
||||
pipe constructed from the given dump path and database name.
|
||||
|
||||
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.
|
||||
@@ -82,7 +86,7 @@ def execute_dump_command(
|
||||
|
||||
if os.path.exists(dump_filename):
|
||||
logger.warning(
|
||||
f'{log_prefix}: Skipping duplicate dump of MariaDB database "{database_name}" to {dump_filename}'
|
||||
f'Skipping duplicate dump of MariaDB database "{database_name}" to {dump_filename}'
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -97,15 +101,17 @@ 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', database['username']) if 'username' in database else ())
|
||||
+ (
|
||||
('--user', borgmatic.hooks.credential.parse.resolve_credential(database['username']))
|
||||
if 'username' in database
|
||||
else ()
|
||||
)
|
||||
+ ('--databases',)
|
||||
+ database_names
|
||||
+ ('--result-file', dump_filename)
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f'{log_prefix}: Dumping MariaDB database "{database_name}" to {dump_filename}{dry_run_label}'
|
||||
)
|
||||
logger.debug(f'Dumping MariaDB database "{database_name}" to {dump_filename}{dry_run_label}')
|
||||
if dry_run:
|
||||
return None
|
||||
|
||||
@@ -118,14 +124,14 @@ def execute_dump_command(
|
||||
)
|
||||
|
||||
|
||||
def get_default_port(databases, config, log_prefix): # pragma: no cover
|
||||
def get_default_port(databases, config): # pragma: no cover
|
||||
return 3306
|
||||
|
||||
|
||||
def use_streaming(databases, config, log_prefix):
|
||||
def use_streaming(databases, config):
|
||||
'''
|
||||
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.
|
||||
Given a sequence of MariaDB database configuration dicts, a configuration dict (ignored), return
|
||||
whether streaming will be using during dumps.
|
||||
'''
|
||||
return any(databases)
|
||||
|
||||
@@ -133,7 +139,6 @@ def use_streaming(databases, config, log_prefix):
|
||||
def dump_data_sources(
|
||||
databases,
|
||||
config,
|
||||
log_prefix,
|
||||
config_paths,
|
||||
borgmatic_runtime_directory,
|
||||
patterns,
|
||||
@@ -142,8 +147,7 @@ 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 and the given log prefix in any
|
||||
log entries.
|
||||
borgmatic runtime directory to construct the destination path.
|
||||
|
||||
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.
|
||||
@@ -153,14 +157,16 @@ def dump_data_sources(
|
||||
dry_run_label = ' (dry run; not actually dumping anything)' if dry_run else ''
|
||||
processes = []
|
||||
|
||||
logger.info(f'{log_prefix}: Dumping MariaDB databases{dry_run_label}')
|
||||
logger.info(f'Dumping MariaDB databases{dry_run_label}')
|
||||
|
||||
for database in databases:
|
||||
dump_path = make_dump_path(borgmatic_runtime_directory)
|
||||
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
|
||||
extra_environment = (
|
||||
{'MYSQL_PWD': borgmatic.hooks.credential.parse.resolve_credential(database['password'])}
|
||||
if 'password' in database
|
||||
else None
|
||||
)
|
||||
dump_database_names = database_names_to_dump(database, extra_environment, dry_run)
|
||||
|
||||
if not dump_database_names:
|
||||
if dry_run:
|
||||
@@ -175,7 +181,6 @@ def dump_data_sources(
|
||||
processes.append(
|
||||
execute_dump_command(
|
||||
renamed_database,
|
||||
log_prefix,
|
||||
dump_path,
|
||||
(dump_name,),
|
||||
extra_environment,
|
||||
@@ -187,7 +192,6 @@ def dump_data_sources(
|
||||
processes.append(
|
||||
execute_dump_command(
|
||||
database,
|
||||
log_prefix,
|
||||
dump_path,
|
||||
dump_database_names,
|
||||
extra_environment,
|
||||
@@ -207,25 +211,23 @@ def dump_data_sources(
|
||||
|
||||
|
||||
def remove_data_source_dumps(
|
||||
databases, config, log_prefix, borgmatic_runtime_directory, dry_run
|
||||
databases, config, 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 and the log prefix in any log
|
||||
entries. If this is a dry run, then don't actually remove anything.
|
||||
borgmatic_runtime_directory to construct the destination path. If this is a dry run, then don't
|
||||
actually remove anything.
|
||||
'''
|
||||
dump.remove_data_source_dumps(
|
||||
make_dump_path(borgmatic_runtime_directory), 'MariaDB', log_prefix, dry_run
|
||||
)
|
||||
dump.remove_data_source_dumps(make_dump_path(borgmatic_runtime_directory), 'MariaDB', dry_run)
|
||||
|
||||
|
||||
def make_data_source_dump_patterns(
|
||||
databases, config, log_prefix, borgmatic_runtime_directory, name=None
|
||||
databases, config, borgmatic_runtime_directory, name=None
|
||||
): # pragma: no cover
|
||||
'''
|
||||
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.
|
||||
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.
|
||||
'''
|
||||
borgmatic_source_directory = borgmatic.config.paths.get_borgmatic_source_directory(config)
|
||||
|
||||
@@ -243,7 +245,6 @@ def make_data_source_dump_patterns(
|
||||
def restore_data_source_dump(
|
||||
hook_config,
|
||||
config,
|
||||
log_prefix,
|
||||
data_source,
|
||||
dry_run,
|
||||
extract_process,
|
||||
@@ -252,9 +253,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 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.
|
||||
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.
|
||||
'''
|
||||
dry_run_label = ' (dry run; not actually restoring anything)' if dry_run else ''
|
||||
hostname = connection_params['hostname'] or data_source.get(
|
||||
@@ -263,11 +264,13 @@ def restore_data_source_dump(
|
||||
port = str(
|
||||
connection_params['port'] or data_source.get('restore_port', data_source.get('port', ''))
|
||||
)
|
||||
username = connection_params['username'] or data_source.get(
|
||||
'restore_username', data_source.get('username')
|
||||
username = borgmatic.hooks.credential.parse.resolve_credential(
|
||||
connection_params['username']
|
||||
or data_source.get('restore_username', data_source.get('username'))
|
||||
)
|
||||
password = connection_params['password'] or data_source.get(
|
||||
'restore_password', data_source.get('password')
|
||||
password = borgmatic.hooks.credential.parse.resolve_credential(
|
||||
connection_params['password']
|
||||
or data_source.get('restore_password', data_source.get('password'))
|
||||
)
|
||||
|
||||
mariadb_restore_command = tuple(
|
||||
@@ -288,7 +291,7 @@ def restore_data_source_dump(
|
||||
)
|
||||
extra_environment = {'MYSQL_PWD': password} if password else None
|
||||
|
||||
logger.debug(f"{log_prefix}: Restoring MariaDB database {data_source['name']}{dry_run_label}")
|
||||
logger.debug(f"Restoring MariaDB database {data_source['name']}{dry_run_label}")
|
||||
if dry_run:
|
||||
return
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ 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
|
||||
|
||||
@@ -17,14 +18,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, log_prefix): # pragma: no cover
|
||||
def get_default_port(databases, config): # pragma: no cover
|
||||
return 27017
|
||||
|
||||
|
||||
def use_streaming(databases, config, log_prefix):
|
||||
def use_streaming(databases, config):
|
||||
'''
|
||||
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.
|
||||
Given a sequence of MongoDB database configuration dicts, a configuration dict (ignored), return
|
||||
whether streaming will be using during dumps.
|
||||
'''
|
||||
return any(database.get('format') != 'directory' for database in databases)
|
||||
|
||||
@@ -32,7 +33,6 @@ def use_streaming(databases, config, log_prefix):
|
||||
def dump_data_sources(
|
||||
databases,
|
||||
config,
|
||||
log_prefix,
|
||||
config_paths,
|
||||
borgmatic_runtime_directory,
|
||||
patterns,
|
||||
@@ -41,8 +41,7 @@ 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 and the given
|
||||
log prefix in any log entries.
|
||||
runtime directory to construct the destination path (used for the directory format.
|
||||
|
||||
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.
|
||||
@@ -51,7 +50,7 @@ def dump_data_sources(
|
||||
'''
|
||||
dry_run_label = ' (dry run; not actually dumping anything)' if dry_run else ''
|
||||
|
||||
logger.info(f'{log_prefix}: Dumping MongoDB databases{dry_run_label}')
|
||||
logger.info(f'Dumping MongoDB databases{dry_run_label}')
|
||||
|
||||
processes = []
|
||||
for database in databases:
|
||||
@@ -65,7 +64,7 @@ def dump_data_sources(
|
||||
dump_format = database.get('format', 'archive')
|
||||
|
||||
logger.debug(
|
||||
f'{log_prefix}: Dumping MongoDB database {name} to {dump_filename}{dry_run_label}',
|
||||
f'Dumping MongoDB database {name} to {dump_filename}{dry_run_label}',
|
||||
)
|
||||
if dry_run:
|
||||
continue
|
||||
@@ -100,8 +99,26 @@ def build_dump_command(database, 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(database['username'])) if 'username' in database else ())
|
||||
+ (('--password', shlex.quote(database['password'])) if 'password' in database else ())
|
||||
+ (
|
||||
(
|
||||
'--username',
|
||||
shlex.quote(
|
||||
borgmatic.hooks.credential.parse.resolve_credential(database['username'])
|
||||
),
|
||||
)
|
||||
if 'username' in database
|
||||
else ()
|
||||
)
|
||||
+ (
|
||||
(
|
||||
'--password',
|
||||
shlex.quote(
|
||||
borgmatic.hooks.credential.parse.resolve_credential(database['password'])
|
||||
),
|
||||
)
|
||||
if 'password' in database
|
||||
else ()
|
||||
)
|
||||
+ (
|
||||
('--authenticationDatabase', shlex.quote(database['authentication_database']))
|
||||
if 'authentication_database' in database
|
||||
@@ -118,25 +135,23 @@ def build_dump_command(database, dump_filename, dump_format):
|
||||
|
||||
|
||||
def remove_data_source_dumps(
|
||||
databases, config, log_prefix, borgmatic_runtime_directory, dry_run
|
||||
databases, config, 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 and the log prefix in any log
|
||||
entries. If this is a dry run, then don't actually remove anything.
|
||||
borgmatic_runtime_directory to construct the destination path. If this is a dry run, then don't
|
||||
actually remove anything.
|
||||
'''
|
||||
dump.remove_data_source_dumps(
|
||||
make_dump_path(borgmatic_runtime_directory), 'MongoDB', log_prefix, dry_run
|
||||
)
|
||||
dump.remove_data_source_dumps(make_dump_path(borgmatic_runtime_directory), 'MongoDB', dry_run)
|
||||
|
||||
|
||||
def make_data_source_dump_patterns(
|
||||
databases, config, log_prefix, borgmatic_runtime_directory, name=None
|
||||
databases, config, borgmatic_runtime_directory, name=None
|
||||
): # pragma: no cover
|
||||
'''
|
||||
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.
|
||||
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.
|
||||
'''
|
||||
borgmatic_source_directory = borgmatic.config.paths.get_borgmatic_source_directory(config)
|
||||
|
||||
@@ -154,7 +169,6 @@ def make_data_source_dump_patterns(
|
||||
def restore_data_source_dump(
|
||||
hook_config,
|
||||
config,
|
||||
log_prefix,
|
||||
data_source,
|
||||
dry_run,
|
||||
extract_process,
|
||||
@@ -164,9 +178,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, 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.
|
||||
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.
|
||||
|
||||
If the extract process is None, then restore the dump from the filesystem rather than from an
|
||||
extract stream.
|
||||
@@ -181,7 +195,7 @@ def restore_data_source_dump(
|
||||
extract_process, data_source, dump_filename, connection_params
|
||||
)
|
||||
|
||||
logger.debug(f"{log_prefix}: Restoring MongoDB database {data_source['name']}{dry_run_label}")
|
||||
logger.debug(f"Restoring MongoDB database {data_source['name']}{dry_run_label}")
|
||||
if dry_run:
|
||||
return
|
||||
|
||||
@@ -203,11 +217,11 @@ def build_restore_command(extract_process, database, dump_filename, connection_p
|
||||
'restore_hostname', database.get('hostname')
|
||||
)
|
||||
port = str(connection_params['port'] or database.get('restore_port', database.get('port', '')))
|
||||
username = connection_params['username'] or database.get(
|
||||
'restore_username', database.get('username')
|
||||
username = borgmatic.hooks.credential.parse.resolve_credential(
|
||||
connection_params['username'] or database.get('restore_username', database.get('username'))
|
||||
)
|
||||
password = connection_params['password'] or database.get(
|
||||
'restore_password', database.get('password')
|
||||
password = borgmatic.hooks.credential.parse.resolve_credential(
|
||||
connection_params['password'] or database.get('restore_password', database.get('password'))
|
||||
)
|
||||
|
||||
command = ['mongorestore']
|
||||
|
||||
@@ -5,6 +5,7 @@ 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,7 +26,7 @@ def make_dump_path(base_directory): # pragma: no cover
|
||||
SYSTEM_DATABASE_NAMES = ('information_schema', 'mysql', 'performance_schema', 'sys')
|
||||
|
||||
|
||||
def database_names_to_dump(database, extra_environment, log_prefix, dry_run):
|
||||
def database_names_to_dump(database, extra_environment, dry_run):
|
||||
'''
|
||||
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,
|
||||
@@ -45,11 +46,15 @@ def database_names_to_dump(database, extra_environment, log_prefix, 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', database['username']) if 'username' in database else ())
|
||||
+ (
|
||||
('--user', borgmatic.hooks.credential.parse.resolve_credential(database['username']))
|
||||
if 'username' in database
|
||||
else ()
|
||||
)
|
||||
+ ('--skip-column-names', '--batch')
|
||||
+ ('--execute', 'show schemas')
|
||||
)
|
||||
logger.debug(f'{log_prefix}: Querying for "all" MySQL databases to dump')
|
||||
logger.debug('Querying for "all" MySQL databases to dump')
|
||||
show_output = execute_command_and_capture_output(
|
||||
show_command, extra_environment=extra_environment
|
||||
)
|
||||
@@ -62,12 +67,11 @@ def database_names_to_dump(database, extra_environment, log_prefix, dry_run):
|
||||
|
||||
|
||||
def execute_dump_command(
|
||||
database, log_prefix, dump_path, database_names, extra_environment, dry_run, dry_run_label
|
||||
database, 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. Use the given log prefix in
|
||||
any log entries.
|
||||
named pipe constructed from the given dump path and database name.
|
||||
|
||||
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.
|
||||
@@ -82,7 +86,7 @@ def execute_dump_command(
|
||||
|
||||
if os.path.exists(dump_filename):
|
||||
logger.warning(
|
||||
f'{log_prefix}: Skipping duplicate dump of MySQL database "{database_name}" to {dump_filename}'
|
||||
f'Skipping duplicate dump of MySQL database "{database_name}" to {dump_filename}'
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -96,15 +100,17 @@ 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', database['username']) if 'username' in database else ())
|
||||
+ (
|
||||
('--user', borgmatic.hooks.credential.parse.resolve_credential(database['username']))
|
||||
if 'username' in database
|
||||
else ()
|
||||
)
|
||||
+ ('--databases',)
|
||||
+ database_names
|
||||
+ ('--result-file', dump_filename)
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f'{log_prefix}: Dumping MySQL database "{database_name}" to {dump_filename}{dry_run_label}'
|
||||
)
|
||||
logger.debug(f'Dumping MySQL database "{database_name}" to {dump_filename}{dry_run_label}')
|
||||
if dry_run:
|
||||
return None
|
||||
|
||||
@@ -117,14 +123,14 @@ def execute_dump_command(
|
||||
)
|
||||
|
||||
|
||||
def get_default_port(databases, config, log_prefix): # pragma: no cover
|
||||
def get_default_port(databases, config): # pragma: no cover
|
||||
return 3306
|
||||
|
||||
|
||||
def use_streaming(databases, config, log_prefix):
|
||||
def use_streaming(databases, config):
|
||||
'''
|
||||
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.
|
||||
Given a sequence of MySQL database configuration dicts, a configuration dict (ignored), return
|
||||
whether streaming will be using during dumps.
|
||||
'''
|
||||
return any(databases)
|
||||
|
||||
@@ -132,7 +138,6 @@ def use_streaming(databases, config, log_prefix):
|
||||
def dump_data_sources(
|
||||
databases,
|
||||
config,
|
||||
log_prefix,
|
||||
config_paths,
|
||||
borgmatic_runtime_directory,
|
||||
patterns,
|
||||
@@ -141,8 +146,7 @@ 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 and the given log prefix in any
|
||||
log entries.
|
||||
borgmatic runtime directory to construct the destination path.
|
||||
|
||||
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.
|
||||
@@ -152,14 +156,16 @@ def dump_data_sources(
|
||||
dry_run_label = ' (dry run; not actually dumping anything)' if dry_run else ''
|
||||
processes = []
|
||||
|
||||
logger.info(f'{log_prefix}: Dumping MySQL databases{dry_run_label}')
|
||||
logger.info(f'Dumping MySQL databases{dry_run_label}')
|
||||
|
||||
for database in databases:
|
||||
dump_path = make_dump_path(borgmatic_runtime_directory)
|
||||
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
|
||||
extra_environment = (
|
||||
{'MYSQL_PWD': borgmatic.hooks.credential.parse.resolve_credential(database['password'])}
|
||||
if 'password' in database
|
||||
else None
|
||||
)
|
||||
dump_database_names = database_names_to_dump(database, extra_environment, dry_run)
|
||||
|
||||
if not dump_database_names:
|
||||
if dry_run:
|
||||
@@ -174,7 +180,6 @@ def dump_data_sources(
|
||||
processes.append(
|
||||
execute_dump_command(
|
||||
renamed_database,
|
||||
log_prefix,
|
||||
dump_path,
|
||||
(dump_name,),
|
||||
extra_environment,
|
||||
@@ -186,7 +191,6 @@ def dump_data_sources(
|
||||
processes.append(
|
||||
execute_dump_command(
|
||||
database,
|
||||
log_prefix,
|
||||
dump_path,
|
||||
dump_database_names,
|
||||
extra_environment,
|
||||
@@ -206,25 +210,23 @@ def dump_data_sources(
|
||||
|
||||
|
||||
def remove_data_source_dumps(
|
||||
databases, config, log_prefix, borgmatic_runtime_directory, dry_run
|
||||
databases, config, 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 and the log prefix in any log
|
||||
entries. If this is a dry run, then don't actually remove anything.
|
||||
borgmatic runtime directory to construct the destination path. If this is a dry run, then don't
|
||||
actually remove anything.
|
||||
'''
|
||||
dump.remove_data_source_dumps(
|
||||
make_dump_path(borgmatic_runtime_directory), 'MySQL', log_prefix, dry_run
|
||||
)
|
||||
dump.remove_data_source_dumps(make_dump_path(borgmatic_runtime_directory), 'MySQL', dry_run)
|
||||
|
||||
|
||||
def make_data_source_dump_patterns(
|
||||
databases, config, log_prefix, borgmatic_runtime_directory, name=None
|
||||
databases, config, borgmatic_runtime_directory, name=None
|
||||
): # pragma: no cover
|
||||
'''
|
||||
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.
|
||||
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.
|
||||
'''
|
||||
borgmatic_source_directory = borgmatic.config.paths.get_borgmatic_source_directory(config)
|
||||
|
||||
@@ -242,7 +244,6 @@ def make_data_source_dump_patterns(
|
||||
def restore_data_source_dump(
|
||||
hook_config,
|
||||
config,
|
||||
log_prefix,
|
||||
data_source,
|
||||
dry_run,
|
||||
extract_process,
|
||||
@@ -251,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. 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.
|
||||
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.
|
||||
'''
|
||||
dry_run_label = ' (dry run; not actually restoring anything)' if dry_run else ''
|
||||
hostname = connection_params['hostname'] or data_source.get(
|
||||
@@ -262,11 +263,13 @@ def restore_data_source_dump(
|
||||
port = str(
|
||||
connection_params['port'] or data_source.get('restore_port', data_source.get('port', ''))
|
||||
)
|
||||
username = connection_params['username'] or data_source.get(
|
||||
'restore_username', data_source.get('username')
|
||||
username = borgmatic.hooks.credential.parse.resolve_credential(
|
||||
connection_params['username']
|
||||
or data_source.get('restore_username', data_source.get('username'))
|
||||
)
|
||||
password = connection_params['password'] or data_source.get(
|
||||
'restore_password', data_source.get('password')
|
||||
password = borgmatic.hooks.credential.parse.resolve_credential(
|
||||
connection_params['password']
|
||||
or data_source.get('restore_password', data_source.get('password'))
|
||||
)
|
||||
|
||||
mysql_restore_command = tuple(
|
||||
@@ -287,7 +290,7 @@ def restore_data_source_dump(
|
||||
)
|
||||
extra_environment = {'MYSQL_PWD': password} if password else None
|
||||
|
||||
logger.debug(f"{log_prefix}: Restoring MySQL database {data_source['name']}{dry_run_label}")
|
||||
logger.debug(f"Restoring MySQL database {data_source['name']}{dry_run_label}")
|
||||
if dry_run:
|
||||
return
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ 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,
|
||||
@@ -33,11 +34,14 @@ def make_extra_environment(database, restore_connection_params=None):
|
||||
|
||||
try:
|
||||
if restore_connection_params:
|
||||
extra['PGPASSWORD'] = restore_connection_params.get('password') or database.get(
|
||||
'restore_password', database['password']
|
||||
extra['PGPASSWORD'] = borgmatic.hooks.credential.parse.resolve_credential(
|
||||
restore_connection_params.get('password')
|
||||
or database.get('restore_password', database['password'])
|
||||
)
|
||||
else:
|
||||
extra['PGPASSWORD'] = database['password']
|
||||
extra['PGPASSWORD'] = borgmatic.hooks.credential.parse.resolve_credential(
|
||||
database['password']
|
||||
)
|
||||
except (AttributeError, KeyError):
|
||||
pass
|
||||
|
||||
@@ -58,7 +62,7 @@ def make_extra_environment(database, restore_connection_params=None):
|
||||
EXCLUDED_DATABASE_NAMES = ('template0', 'template1')
|
||||
|
||||
|
||||
def database_names_to_dump(database, extra_environment, log_prefix, dry_run):
|
||||
def database_names_to_dump(database, extra_environment, dry_run):
|
||||
'''
|
||||
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
|
||||
@@ -82,10 +86,17 @@ def database_names_to_dump(database, extra_environment, log_prefix, 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', database['username']) if 'username' in database else ())
|
||||
+ (
|
||||
(
|
||||
'--username',
|
||||
borgmatic.hooks.credential.parse.resolve_credential(database['username']),
|
||||
)
|
||||
if 'username' in database
|
||||
else ()
|
||||
)
|
||||
+ (tuple(database['list_options'].split(' ')) if 'list_options' in database else ())
|
||||
)
|
||||
logger.debug(f'{log_prefix}: Querying for "all" PostgreSQL databases to dump')
|
||||
logger.debug('Querying for "all" PostgreSQL databases to dump')
|
||||
list_output = execute_command_and_capture_output(
|
||||
list_command, extra_environment=extra_environment
|
||||
)
|
||||
@@ -97,14 +108,14 @@ def database_names_to_dump(database, extra_environment, log_prefix, dry_run):
|
||||
)
|
||||
|
||||
|
||||
def get_default_port(databases, config, log_prefix): # pragma: no cover
|
||||
def get_default_port(databases, config): # pragma: no cover
|
||||
return 5432
|
||||
|
||||
|
||||
def use_streaming(databases, config, log_prefix):
|
||||
def use_streaming(databases, config):
|
||||
'''
|
||||
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.
|
||||
Given a sequence of PostgreSQL database configuration dicts, a configuration dict (ignored),
|
||||
return whether streaming will be using during dumps.
|
||||
'''
|
||||
return any(database.get('format') != 'directory' for database in databases)
|
||||
|
||||
@@ -112,7 +123,6 @@ def use_streaming(databases, config, log_prefix):
|
||||
def dump_data_sources(
|
||||
databases,
|
||||
config,
|
||||
log_prefix,
|
||||
config_paths,
|
||||
borgmatic_runtime_directory,
|
||||
patterns,
|
||||
@@ -121,8 +131,7 @@ 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 and the given log prefix in any
|
||||
log entries.
|
||||
borgmatic runtime directory to construct the destination path.
|
||||
|
||||
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.
|
||||
@@ -134,14 +143,12 @@ def dump_data_sources(
|
||||
dry_run_label = ' (dry run; not actually dumping anything)' if dry_run else ''
|
||||
processes = []
|
||||
|
||||
logger.info(f'{log_prefix}: Dumping PostgreSQL databases{dry_run_label}')
|
||||
logger.info(f'Dumping PostgreSQL databases{dry_run_label}')
|
||||
|
||||
for database in databases:
|
||||
extra_environment = make_extra_environment(database)
|
||||
dump_path = make_dump_path(borgmatic_runtime_directory)
|
||||
dump_database_names = database_names_to_dump(
|
||||
database, extra_environment, log_prefix, dry_run
|
||||
)
|
||||
dump_database_names = database_names_to_dump(database, extra_environment, dry_run)
|
||||
|
||||
if not dump_database_names:
|
||||
if dry_run:
|
||||
@@ -164,7 +171,7 @@ def dump_data_sources(
|
||||
)
|
||||
if os.path.exists(dump_filename):
|
||||
logger.warning(
|
||||
f'{log_prefix}: Skipping duplicate dump of PostgreSQL database "{database_name}" to {dump_filename}'
|
||||
f'Skipping duplicate dump of PostgreSQL database "{database_name}" to {dump_filename}'
|
||||
)
|
||||
continue
|
||||
|
||||
@@ -178,7 +185,14 @@ 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(database['username']))
|
||||
(
|
||||
'--username',
|
||||
shlex.quote(
|
||||
borgmatic.hooks.credential.parse.resolve_credential(
|
||||
database['username']
|
||||
)
|
||||
),
|
||||
)
|
||||
if 'username' in database
|
||||
else ()
|
||||
)
|
||||
@@ -198,7 +212,7 @@ def dump_data_sources(
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f'{log_prefix}: Dumping PostgreSQL database "{database_name}" to {dump_filename}{dry_run_label}'
|
||||
f'Dumping PostgreSQL database "{database_name}" to {dump_filename}{dry_run_label}'
|
||||
)
|
||||
if dry_run:
|
||||
continue
|
||||
@@ -232,25 +246,25 @@ def dump_data_sources(
|
||||
|
||||
|
||||
def remove_data_source_dumps(
|
||||
databases, config, log_prefix, borgmatic_runtime_directory, dry_run
|
||||
databases, config, 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 and the log prefix in any log
|
||||
entries. If this is a dry run, then don't actually remove anything.
|
||||
borgmatic runtime directory to construct the destination path. If this is a dry run, then don't
|
||||
actually remove anything.
|
||||
'''
|
||||
dump.remove_data_source_dumps(
|
||||
make_dump_path(borgmatic_runtime_directory), 'PostgreSQL', log_prefix, dry_run
|
||||
make_dump_path(borgmatic_runtime_directory), 'PostgreSQL', dry_run
|
||||
)
|
||||
|
||||
|
||||
def make_data_source_dump_patterns(
|
||||
databases, config, log_prefix, borgmatic_runtime_directory, name=None
|
||||
databases, config, borgmatic_runtime_directory, name=None
|
||||
): # pragma: no cover
|
||||
'''
|
||||
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.
|
||||
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.
|
||||
'''
|
||||
borgmatic_source_directory = borgmatic.config.paths.get_borgmatic_source_directory(config)
|
||||
|
||||
@@ -268,7 +282,6 @@ def make_data_source_dump_patterns(
|
||||
def restore_data_source_dump(
|
||||
hook_config,
|
||||
config,
|
||||
log_prefix,
|
||||
data_source,
|
||||
dry_run,
|
||||
extract_process,
|
||||
@@ -278,10 +291,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 borgmatic runtime
|
||||
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.
|
||||
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.
|
||||
|
||||
If the extract process is None, then restore the dump from the filesystem rather than from an
|
||||
extract stream.
|
||||
@@ -296,8 +308,9 @@ def restore_data_source_dump(
|
||||
port = str(
|
||||
connection_params['port'] or data_source.get('restore_port', data_source.get('port', ''))
|
||||
)
|
||||
username = connection_params['username'] or data_source.get(
|
||||
'restore_username', data_source.get('username')
|
||||
username = borgmatic.hooks.credential.parse.resolve_credential(
|
||||
connection_params['username']
|
||||
or data_source.get('restore_username', data_source.get('username'))
|
||||
)
|
||||
|
||||
all_databases = bool(data_source['name'] == 'all')
|
||||
@@ -354,9 +367,7 @@ def restore_data_source_dump(
|
||||
data_source, restore_connection_params=connection_params
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"{log_prefix}: Restoring PostgreSQL database {data_source['name']}{dry_run_label}"
|
||||
)
|
||||
logger.debug(f"Restoring PostgreSQL database {data_source['name']}{dry_run_label}")
|
||||
if dry_run:
|
||||
return
|
||||
|
||||
|
||||
@@ -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, log_prefix): # pragma: no cover
|
||||
def get_default_port(databases, config): # pragma: no cover
|
||||
return None # SQLite doesn't use a port.
|
||||
|
||||
|
||||
def use_streaming(databases, config, log_prefix):
|
||||
def use_streaming(databases, config):
|
||||
'''
|
||||
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.
|
||||
Given a sequence of SQLite database configuration dicts, a configuration dict (ignored), return
|
||||
whether streaming will be using during dumps.
|
||||
'''
|
||||
return any(databases)
|
||||
|
||||
@@ -32,7 +32,6 @@ def use_streaming(databases, config, log_prefix):
|
||||
def dump_data_sources(
|
||||
databases,
|
||||
config,
|
||||
log_prefix,
|
||||
config_paths,
|
||||
borgmatic_runtime_directory,
|
||||
patterns,
|
||||
@@ -41,7 +40,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 and the given log prefix in any log entries.
|
||||
to construct the destination path.
|
||||
|
||||
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.
|
||||
@@ -51,7 +50,7 @@ def dump_data_sources(
|
||||
dry_run_label = ' (dry run; not actually dumping anything)' if dry_run else ''
|
||||
processes = []
|
||||
|
||||
logger.info(f'{log_prefix}: Dumping SQLite databases{dry_run_label}')
|
||||
logger.info(f'Dumping SQLite databases{dry_run_label}')
|
||||
|
||||
for database in databases:
|
||||
database_path = database['path']
|
||||
@@ -60,7 +59,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'{log_prefix}: No SQLite database at {database_path}; an empty database will be created and dumped'
|
||||
f'No SQLite database at {database_path}; an empty database will be created and dumped'
|
||||
)
|
||||
|
||||
dump_path = make_dump_path(borgmatic_runtime_directory)
|
||||
@@ -68,7 +67,7 @@ def dump_data_sources(
|
||||
|
||||
if os.path.exists(dump_filename):
|
||||
logger.warning(
|
||||
f'{log_prefix}: Skipping duplicate dump of SQLite database at {database_path} to {dump_filename}'
|
||||
f'Skipping duplicate dump of SQLite database at {database_path} to {dump_filename}'
|
||||
)
|
||||
continue
|
||||
|
||||
@@ -80,7 +79,7 @@ def dump_data_sources(
|
||||
shlex.quote(dump_filename),
|
||||
)
|
||||
logger.debug(
|
||||
f'{log_prefix}: Dumping SQLite database at {database_path} to {dump_filename}{dry_run_label}'
|
||||
f'Dumping SQLite database at {database_path} to {dump_filename}{dry_run_label}'
|
||||
)
|
||||
if dry_run:
|
||||
continue
|
||||
@@ -99,25 +98,23 @@ def dump_data_sources(
|
||||
|
||||
|
||||
def remove_data_source_dumps(
|
||||
databases, config, log_prefix, borgmatic_runtime_directory, dry_run
|
||||
databases, config, 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 and the log prefix in any log
|
||||
entries. If this is a dry run, then don't actually remove anything.
|
||||
borgmatic runtime directory to construct the destination path. If this is a dry run, then don't
|
||||
actually remove anything.
|
||||
'''
|
||||
dump.remove_data_source_dumps(
|
||||
make_dump_path(borgmatic_runtime_directory), 'SQLite', log_prefix, dry_run
|
||||
)
|
||||
dump.remove_data_source_dumps(make_dump_path(borgmatic_runtime_directory), 'SQLite', dry_run)
|
||||
|
||||
|
||||
def make_data_source_dump_patterns(
|
||||
databases, config, log_prefix, borgmatic_runtime_directory, name=None
|
||||
databases, config, borgmatic_runtime_directory, name=None
|
||||
): # pragma: no cover
|
||||
'''
|
||||
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.
|
||||
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.
|
||||
'''
|
||||
borgmatic_source_directory = borgmatic.config.paths.get_borgmatic_source_directory(config)
|
||||
|
||||
@@ -135,7 +132,6 @@ def make_data_source_dump_patterns(
|
||||
def restore_data_source_dump(
|
||||
hook_config,
|
||||
config,
|
||||
log_prefix,
|
||||
data_source,
|
||||
dry_run,
|
||||
extract_process,
|
||||
@@ -144,22 +140,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. 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.
|
||||
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.
|
||||
'''
|
||||
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'{log_prefix}: Restoring SQLite database at {database_path}{dry_run_label}')
|
||||
logger.debug(f'Restoring SQLite database at {database_path}{dry_run_label}')
|
||||
if dry_run:
|
||||
return
|
||||
|
||||
try:
|
||||
os.remove(database_path)
|
||||
logger.warning(f'{log_prefix}: Removed existing SQLite database at {database_path}')
|
||||
logger.warning(f'Removed existing SQLite database at {database_path}')
|
||||
except FileNotFoundError: # pragma: no cover
|
||||
pass
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import borgmatic.hooks.data_source.snapshot
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def use_streaming(hook_config, config, log_prefix): # pragma: no cover
|
||||
def use_streaming(hook_config, config): # pragma: no cover
|
||||
'''
|
||||
Return whether dump streaming is used for this hook. (Spoiler: It isn't.)
|
||||
'''
|
||||
@@ -189,26 +189,25 @@ def make_borg_snapshot_pattern(pattern, normalized_runtime_directory):
|
||||
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, 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
|
||||
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
|
||||
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. Use the log prefix in any log entries.
|
||||
instead.
|
||||
|
||||
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'{log_prefix}: Snapshotting ZFS datasets{dry_run_label}')
|
||||
logger.info(f'Snapshotting ZFS datasets{dry_run_label}')
|
||||
|
||||
# List ZFS datasets to get their mount points.
|
||||
zfs_command = hook_config.get('zfs_command', 'zfs')
|
||||
@@ -219,12 +218,12 @@ def dump_data_sources(
|
||||
normalized_runtime_directory = os.path.normpath(borgmatic_runtime_directory)
|
||||
|
||||
if not requested_datasets:
|
||||
logger.warning(f'{log_prefix}: No ZFS datasets found to snapshot{dry_run_label}')
|
||||
logger.warning(f'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'{log_prefix}: Creating ZFS snapshot {full_snapshot_name} of {dataset.mount_point}{dry_run_label}'
|
||||
f'Creating ZFS snapshot {full_snapshot_name} of {dataset.mount_point}{dry_run_label}'
|
||||
)
|
||||
|
||||
if not dry_run:
|
||||
@@ -239,7 +238,7 @@ def dump_data_sources(
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f'{log_prefix}: Mounting ZFS snapshot {full_snapshot_name} at {snapshot_mount_path}{dry_run_label}'
|
||||
f'Mounting ZFS snapshot {full_snapshot_name} at {snapshot_mount_path}{dry_run_label}'
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
@@ -306,12 +305,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, log_prefix, borgmatic_runtime_directory, dry_run):
|
||||
def remove_data_source_dumps(hook_config, config, borgmatic_runtime_directory, dry_run):
|
||||
'''
|
||||
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.
|
||||
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.
|
||||
'''
|
||||
if hook_config is None:
|
||||
return
|
||||
@@ -324,10 +323,10 @@ def remove_data_source_dumps(hook_config, config, log_prefix, borgmatic_runtime_
|
||||
try:
|
||||
dataset_mount_points = get_all_dataset_mount_points(zfs_command)
|
||||
except FileNotFoundError:
|
||||
logger.debug(f'{log_prefix}: Could not find "{zfs_command}" command')
|
||||
logger.debug(f'Could not find "{zfs_command}" command')
|
||||
return
|
||||
except subprocess.CalledProcessError as error:
|
||||
logger.debug(f'{log_prefix}: {error}')
|
||||
logger.debug(error)
|
||||
return
|
||||
|
||||
snapshots_glob = os.path.join(
|
||||
@@ -336,9 +335,7 @@ def remove_data_source_dumps(hook_config, config, log_prefix, borgmatic_runtime_
|
||||
),
|
||||
'zfs_snapshots',
|
||||
)
|
||||
logger.debug(
|
||||
f'{log_prefix}: Looking for snapshots to remove in {snapshots_glob}{dry_run_label}'
|
||||
)
|
||||
logger.debug(f'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):
|
||||
@@ -363,18 +360,16 @@ def remove_data_source_dumps(hook_config, config, log_prefix, borgmatic_runtime_
|
||||
if not os.path.isdir(snapshot_mount_path):
|
||||
continue
|
||||
|
||||
logger.debug(
|
||||
f'{log_prefix}: Unmounting ZFS snapshot at {snapshot_mount_path}{dry_run_label}'
|
||||
)
|
||||
logger.debug(f'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'{log_prefix}: Could not find "{umount_command}" command')
|
||||
logger.debug(f'Could not find "{umount_command}" command')
|
||||
return
|
||||
except subprocess.CalledProcessError as error:
|
||||
logger.debug(f'{log_prefix}: {error}')
|
||||
logger.debug(error)
|
||||
return
|
||||
|
||||
if not dry_run:
|
||||
@@ -388,14 +383,14 @@ def remove_data_source_dumps(hook_config, config, log_prefix, borgmatic_runtime_
|
||||
if not full_snapshot_name.split('@')[-1].startswith(BORGMATIC_SNAPSHOT_PREFIX):
|
||||
continue
|
||||
|
||||
logger.debug(f'{log_prefix}: Destroying ZFS snapshot {full_snapshot_name}{dry_run_label}')
|
||||
logger.debug(f'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, log_prefix, borgmatic_runtime_directory, name=None
|
||||
hook_config, config, borgmatic_runtime_directory, name=None
|
||||
): # pragma: no cover
|
||||
'''
|
||||
Restores aren't implemented, because stored files can be extracted directly with "extract".
|
||||
@@ -406,7 +401,6 @@ def make_data_source_dump_patterns(
|
||||
def restore_data_source_dump(
|
||||
hook_config,
|
||||
config,
|
||||
log_prefix,
|
||||
data_source,
|
||||
dry_run,
|
||||
extract_process,
|
||||
|
||||
+25
-20
@@ -3,6 +3,7 @@ import importlib
|
||||
import logging
|
||||
import pkgutil
|
||||
|
||||
import borgmatic.hooks.credential
|
||||
import borgmatic.hooks.data_source
|
||||
import borgmatic.hooks.monitoring
|
||||
|
||||
@@ -10,6 +11,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Hook_type(enum.Enum):
|
||||
CREDENTIAL = 'credential'
|
||||
DATA_SOURCE = 'data_source'
|
||||
MONITORING = 'monitoring'
|
||||
|
||||
@@ -21,12 +23,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, log_prefix, hook_name, *args, **kwargs):
|
||||
def call_hook(function_name, config, hook_name, *args, **kwargs):
|
||||
'''
|
||||
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.
|
||||
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.
|
||||
|
||||
Raise ValueError if the hook name is unknown.
|
||||
Raise AttributeError if the function name is not found in the module.
|
||||
@@ -40,7 +42,11 @@ def call_hook(function_name, config, log_prefix, 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.data_source, borgmatic.hooks.monitoring):
|
||||
for parent_module in (
|
||||
borgmatic.hooks.credential,
|
||||
borgmatic.hooks.data_source,
|
||||
borgmatic.hooks.monitoring,
|
||||
):
|
||||
if module_name not in get_submodule_names(parent_module):
|
||||
continue
|
||||
|
||||
@@ -54,17 +60,16 @@ def call_hook(function_name, config, log_prefix, hook_name, *args, **kwargs):
|
||||
else:
|
||||
raise ValueError(f'Unknown hook name: {hook_name}')
|
||||
|
||||
logger.debug(f'{log_prefix}: Calling {hook_name} hook function {function_name}')
|
||||
logger.debug(f'Calling {hook_name} hook function {function_name}')
|
||||
|
||||
return getattr(module, function_name)(hook_config, config, log_prefix, *args, **kwargs)
|
||||
return getattr(module, function_name)(hook_config, config, *args, **kwargs)
|
||||
|
||||
|
||||
def call_hooks(function_name, config, log_prefix, hook_type, *args, **kwargs):
|
||||
def call_hooks(function_name, config, hook_type, *args, **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.
|
||||
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 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
|
||||
@@ -78,7 +83,7 @@ def call_hooks(function_name, config, log_prefix, 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, log_prefix, hook_name, *args, **kwargs)
|
||||
hook_name: call_hook(function_name, config, hook_name, *args, **kwargs)
|
||||
for hook_name in get_submodule_names(
|
||||
importlib.import_module(f'borgmatic.hooks.{hook_type.value}')
|
||||
)
|
||||
@@ -86,18 +91,18 @@ def call_hooks(function_name, config, log_prefix, hook_type, *args, **kwargs):
|
||||
}
|
||||
|
||||
|
||||
def call_hooks_even_if_unconfigured(function_name, config, log_prefix, hook_type, *args, **kwargs):
|
||||
def call_hooks_even_if_unconfigured(function_name, config, hook_type, *args, **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 hook name to return value.
|
||||
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.
|
||||
|
||||
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, log_prefix, hook_name, *args, **kwargs)
|
||||
hook_name: call_hook(function_name, config, hook_name, *args, **kwargs)
|
||||
for hook_name in get_submodule_names(
|
||||
importlib.import_module(f'borgmatic.hooks.{hook_type.value}')
|
||||
)
|
||||
|
||||
@@ -66,12 +66,12 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
|
||||
)
|
||||
|
||||
if not hook_config.get('services'):
|
||||
logger.info(f'{config_filename}: No Apprise services to ping')
|
||||
logger.info('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'{config_filename}: Pinging Apprise services: {labels_string}{dry_run_string}')
|
||||
logger.info(f'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(f'{config_filename}: Error sending some Apprise notifications')
|
||||
logger.warning('Error sending some Apprise notifications')
|
||||
|
||||
|
||||
def destroy_monitor(hook_config, config, config_filename, monitoring_log_level, dry_run):
|
||||
def destroy_monitor(hook_config, config, 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.
|
||||
|
||||
@@ -28,9 +28,7 @@ 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'{config_filename}: Ignoring unsupported monitoring {state.name.lower()} in Cronhub hook'
|
||||
)
|
||||
logger.debug(f'Ignoring unsupported monitoring {state.name.lower()} in Cronhub hook')
|
||||
return
|
||||
|
||||
dry_run_label = ' (dry run; not actually pinging)' if dry_run else ''
|
||||
@@ -41,8 +39,8 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
|
||||
.replace('/ping/', formatted_state)
|
||||
)
|
||||
|
||||
logger.info(f'{config_filename}: Pinging Cronhub {state.name.lower()}{dry_run_label}')
|
||||
logger.debug(f'{config_filename}: Using Cronhub ping URL {ping_url}')
|
||||
logger.info(f'Pinging Cronhub {state.name.lower()}{dry_run_label}')
|
||||
logger.debug(f'Using Cronhub ping URL {ping_url}')
|
||||
|
||||
if not dry_run:
|
||||
logging.getLogger('urllib3').setLevel(logging.ERROR)
|
||||
@@ -51,12 +49,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'{config_filename}: Cronhub error: {error}')
|
||||
logger.warning(f'Cronhub error: {error}')
|
||||
|
||||
|
||||
def destroy_monitor(
|
||||
ping_url_or_uuid, config, config_filename, monitoring_log_level, dry_run
|
||||
): # pragma: no cover
|
||||
def destroy_monitor(ping_url_or_uuid, config, monitoring_log_level, dry_run): # pragma: no cover
|
||||
'''
|
||||
No destruction is necessary for this monitor.
|
||||
'''
|
||||
|
||||
@@ -28,16 +28,14 @@ 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'{config_filename}: Ignoring unsupported monitoring {state.name.lower()} in Cronitor hook'
|
||||
)
|
||||
logger.debug(f'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'{config_filename}: Pinging Cronitor {state.name.lower()}{dry_run_label}')
|
||||
logger.debug(f'{config_filename}: Using Cronitor ping URL {ping_url}')
|
||||
logger.info(f'Pinging Cronitor {state.name.lower()}{dry_run_label}')
|
||||
logger.debug(f'Using Cronitor ping URL {ping_url}')
|
||||
|
||||
if not dry_run:
|
||||
logging.getLogger('urllib3').setLevel(logging.ERROR)
|
||||
@@ -46,12 +44,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'{config_filename}: Cronitor error: {error}')
|
||||
logger.warning(f'Cronitor error: {error}')
|
||||
|
||||
|
||||
def destroy_monitor(
|
||||
ping_url_or_uuid, config, config_filename, monitoring_log_level, dry_run
|
||||
): # pragma: no cover
|
||||
def destroy_monitor(ping_url_or_uuid, config, monitoring_log_level, dry_run): # pragma: no cover
|
||||
'''
|
||||
No destruction is necessary for this monitor.
|
||||
'''
|
||||
|
||||
@@ -55,9 +55,7 @@ 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'{config_filename}: Skipping Healthchecks {state.name.lower()} ping due to configured states'
|
||||
)
|
||||
logger.info(f'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)
|
||||
@@ -68,14 +66,12 @@ 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(
|
||||
f'{config_filename}: Healthchecks UUIDs do not support auto provisionning; ignoring'
|
||||
)
|
||||
logger.warning('Healthchecks UUIDs do not support auto provisionning; ignoring')
|
||||
else:
|
||||
ping_url = f'{ping_url}?create=1'
|
||||
|
||||
logger.info(f'{config_filename}: Pinging Healthchecks {state.name.lower()}{dry_run_label}')
|
||||
logger.debug(f'{config_filename}: Using Healthchecks ping URL {ping_url}')
|
||||
logger.info(f'Pinging Healthchecks {state.name.lower()}{dry_run_label}')
|
||||
logger.debug(f'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(
|
||||
@@ -93,10 +89,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'{config_filename}: Healthchecks error: {error}')
|
||||
logger.warning(f'Healthchecks error: {error}')
|
||||
|
||||
|
||||
def destroy_monitor(hook_config, config, config_filename, monitoring_log_level, dry_run):
|
||||
def destroy_monitor(hook_config, config, 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.
|
||||
|
||||
@@ -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'{config_filename}: {MONITOR_STATE_TO_LOKI[state]} backup')
|
||||
handler.raw(f'{MONITOR_STATE_TO_LOKI[state]} backup')
|
||||
|
||||
|
||||
def destroy_monitor(hook_config, config, config_filename, monitoring_log_level, dry_run):
|
||||
def destroy_monitor(hook_config, config, monitoring_log_level, dry_run):
|
||||
'''
|
||||
Remove the monitor handler that was added to the root logger.
|
||||
'''
|
||||
|
||||
@@ -2,6 +2,8 @@ import logging
|
||||
|
||||
import requests
|
||||
|
||||
import borgmatic.hooks.credential.parse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -37,8 +39,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'{config_filename}: Pinging ntfy topic {topic}{dry_run_label}')
|
||||
logger.debug(f'{config_filename}: Using Ntfy ping URL {base_url}/{topic}')
|
||||
logger.info(f'Pinging ntfy topic {topic}{dry_run_label}')
|
||||
logger.debug(f'Using Ntfy ping URL {base_url}/{topic}')
|
||||
|
||||
headers = {
|
||||
'X-Title': state_config.get('title'),
|
||||
@@ -47,28 +49,35 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
|
||||
'X-Tags': state_config.get('tags'),
|
||||
}
|
||||
|
||||
username = hook_config.get('username')
|
||||
password = hook_config.get('password')
|
||||
access_token = hook_config.get('access_token')
|
||||
try:
|
||||
username = borgmatic.hooks.credential.parse.resolve_credential(
|
||||
hook_config.get('username')
|
||||
)
|
||||
password = borgmatic.hooks.credential.parse.resolve_credential(
|
||||
hook_config.get('password')
|
||||
)
|
||||
access_token = borgmatic.hooks.credential.parse.resolve_credential(
|
||||
hook_config.get('access_token')
|
||||
)
|
||||
except ValueError as error:
|
||||
logger.warning(f'Ntfy credential error: {error}')
|
||||
return
|
||||
|
||||
auth = None
|
||||
|
||||
if access_token is not None:
|
||||
if username or password:
|
||||
logger.warning(
|
||||
f'{config_filename}: ntfy access_token is set but so is username/password, only using access_token'
|
||||
'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'{config_filename}: Using basic auth with user {username} for ntfy')
|
||||
logger.info(f'Using basic auth with user {username} for ntfy')
|
||||
elif username is not None:
|
||||
logger.warning(
|
||||
f'{config_filename}: Password missing for ntfy authentication, defaulting to no auth'
|
||||
)
|
||||
logger.warning('Password missing for ntfy authentication, defaulting to no auth')
|
||||
elif password is not None:
|
||||
logger.warning(
|
||||
f'{config_filename}: Username missing for ntfy authentication, defaulting to no auth'
|
||||
)
|
||||
logger.warning('Username missing for ntfy authentication, defaulting to no auth')
|
||||
|
||||
if not dry_run:
|
||||
logging.getLogger('urllib3').setLevel(logging.ERROR)
|
||||
@@ -77,12 +86,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'{config_filename}: ntfy error: {error}')
|
||||
logger.warning(f'ntfy error: {error}')
|
||||
|
||||
|
||||
def destroy_monitor(
|
||||
ping_url_or_uuid, config, config_filename, monitoring_log_level, dry_run
|
||||
): # pragma: no cover
|
||||
def destroy_monitor(ping_url_or_uuid, config, monitoring_log_level, dry_run): # pragma: no cover
|
||||
'''
|
||||
No destruction is necessary for this monitor.
|
||||
'''
|
||||
|
||||
@@ -5,6 +5,7 @@ import platform
|
||||
|
||||
import requests
|
||||
|
||||
import borgmatic.hooks.credential.parse
|
||||
from borgmatic.hooks.monitoring import monitor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -29,21 +30,29 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
|
||||
'''
|
||||
if state != monitor.State.FAIL:
|
||||
logger.debug(
|
||||
f'{config_filename}: Ignoring unsupported monitoring {state.name.lower()} in PagerDuty hook',
|
||||
f'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'{config_filename}: Sending failure event to PagerDuty {dry_run_label}')
|
||||
logger.info(f'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')
|
||||
)
|
||||
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': hook_config['integration_key'],
|
||||
'routing_key': integration_key,
|
||||
'event_action': 'trigger',
|
||||
'payload': {
|
||||
'summary': f'backup failed on {hostname}',
|
||||
@@ -61,7 +70,7 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
|
||||
},
|
||||
}
|
||||
)
|
||||
logger.debug(f'{config_filename}: Using PagerDuty payload: {payload}')
|
||||
logger.debug(f'Using PagerDuty payload: {payload}')
|
||||
|
||||
logging.getLogger('urllib3').setLevel(logging.ERROR)
|
||||
try:
|
||||
@@ -69,12 +78,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'{config_filename}: PagerDuty error: {error}')
|
||||
logger.warning(f'PagerDuty error: {error}')
|
||||
|
||||
|
||||
def destroy_monitor(
|
||||
ping_url_or_uuid, config, config_filename, monitoring_log_level, dry_run
|
||||
): # pragma: no cover
|
||||
def destroy_monitor(ping_url_or_uuid, config, monitoring_log_level, dry_run): # pragma: no cover
|
||||
'''
|
||||
No destruction is necessary for this monitor.
|
||||
'''
|
||||
|
||||
@@ -2,6 +2,8 @@ import logging
|
||||
|
||||
import requests
|
||||
|
||||
import borgmatic.hooks.credential.parse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -32,17 +34,21 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
|
||||
|
||||
state_config = hook_config.get(state.name.lower(), {})
|
||||
|
||||
token = hook_config.get('token')
|
||||
user = hook_config.get('user')
|
||||
try:
|
||||
token = borgmatic.hooks.credential.parse.resolve_credential(hook_config.get('token'))
|
||||
user = borgmatic.hooks.credential.parse.resolve_credential(hook_config.get('user'))
|
||||
except ValueError as error:
|
||||
logger.warning(f'Pushover credential error: {error}')
|
||||
return
|
||||
|
||||
logger.info(f'{config_filename}: Updating Pushover{dry_run_label}')
|
||||
logger.info(f'Updating Pushover{dry_run_label}')
|
||||
|
||||
if state_config.get('priority') == EMERGENCY_PRIORITY:
|
||||
if 'expire' not in state_config:
|
||||
logger.info(f'{config_filename}: Setting expire to default (10 min).')
|
||||
logger.info('Setting expire to default (10 min)')
|
||||
state_config['expire'] = 600
|
||||
if 'retry' not in state_config:
|
||||
logger.info(f'{config_filename}: Setting retry to default (30 sec).')
|
||||
logger.info('Setting retry to default (30 sec)')
|
||||
state_config['retry'] = 30
|
||||
else:
|
||||
if 'expire' in state_config or 'retry' in state_config:
|
||||
@@ -75,12 +81,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'{config_filename}: Pushover error: {error}')
|
||||
logger.warning(f'Pushover error: {error}')
|
||||
|
||||
|
||||
def destroy_monitor(
|
||||
ping_url_or_uuid, config, config_filename, monitoring_log_level, dry_run
|
||||
): # pragma: no cover
|
||||
def destroy_monitor(ping_url_or_uuid, config, monitoring_log_level, dry_run): # pragma: no cover
|
||||
'''
|
||||
No destruction is necessary for this monitor.
|
||||
'''
|
||||
|
||||
@@ -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,15 +38,13 @@ 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{config_filename}: Invalid Sentry data source name URL: {data_source_name_url}'
|
||||
)
|
||||
logger.warning(f'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'{config_filename}: Pinging Sentry {state.name.lower()}{dry_run_label}')
|
||||
logger.debug(f'{config_filename}: Using Sentry cron URL {cron_url}')
|
||||
logger.info(f'Pinging Sentry {state.name.lower()}{dry_run_label}')
|
||||
logger.debug(f'Using Sentry cron URL {cron_url}')
|
||||
|
||||
status = {
|
||||
'start': 'in_progress',
|
||||
@@ -55,7 +53,7 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
|
||||
}.get(state.name.lower())
|
||||
|
||||
if not status:
|
||||
logger.warning('f{config_filename}: Invalid Sentry state')
|
||||
logger.warning('Invalid Sentry state')
|
||||
return
|
||||
|
||||
if dry_run:
|
||||
@@ -67,12 +65,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'{config_filename}: Sentry error: {error}')
|
||||
logger.warning(f'Sentry error: {error}')
|
||||
|
||||
|
||||
def destroy_monitor(
|
||||
ping_url_or_uuid, config, config_filename, monitoring_log_level, dry_run
|
||||
): # pragma: no cover
|
||||
def destroy_monitor(ping_url_or_uuid, config, monitoring_log_level, dry_run): # pragma: no cover
|
||||
'''
|
||||
No destruction is necessary for this monitor.
|
||||
'''
|
||||
|
||||
@@ -28,10 +28,8 @@ 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'{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}')
|
||||
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}')
|
||||
|
||||
if dry_run:
|
||||
return
|
||||
@@ -43,12 +41,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'{config_filename}: Uptime Kuma error: {error}')
|
||||
logger.warning(f'Uptime Kuma error: {error}')
|
||||
|
||||
|
||||
def destroy_monitor(
|
||||
push_url_or_uuid, config, config_filename, monitoring_log_level, dry_run
|
||||
): # pragma: no cover
|
||||
def destroy_monitor(push_url_or_uuid, config, monitoring_log_level, dry_run): # pragma: no cover
|
||||
'''
|
||||
No destruction is necessary for this monitor.
|
||||
'''
|
||||
|
||||
@@ -2,6 +2,8 @@ import logging
|
||||
|
||||
import requests
|
||||
|
||||
import borgmatic.hooks.credential.parse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -34,26 +36,31 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
username = borgmatic.hooks.credential.parse.resolve_credential(hook_config.get('username'))
|
||||
password = borgmatic.hooks.credential.parse.resolve_credential(hook_config.get('password'))
|
||||
api_key = borgmatic.hooks.credential.parse.resolve_credential(hook_config.get('api_key'))
|
||||
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'{config_filename}: Updating Zabbix{dry_run_label}')
|
||||
logger.debug(f'{config_filename}: Using Zabbix URL: {server}')
|
||||
logger.info(f'Updating Zabbix{dry_run_label}')
|
||||
logger.debug(f'Using Zabbix URL: {server}')
|
||||
|
||||
if server is None:
|
||||
logger.warning(f'{config_filename}: Server missing for Zabbix')
|
||||
logger.warning('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'{config_filename}: Updating {itemid} on Zabbix')
|
||||
logger.info(f'Updating {itemid} on Zabbix')
|
||||
data = {
|
||||
'jsonrpc': '2.0',
|
||||
'method': 'history.push',
|
||||
@@ -62,7 +69,7 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
|
||||
}
|
||||
|
||||
elif (host and key) is not None:
|
||||
logger.info(f'{config_filename}: Updating Host:{host} and Key:{key} on Zabbix')
|
||||
logger.info(f'Updating Host:{host} and Key:{key} on Zabbix')
|
||||
data = {
|
||||
'jsonrpc': '2.0',
|
||||
'method': 'history.push',
|
||||
@@ -71,23 +78,23 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
|
||||
}
|
||||
|
||||
elif host is not None:
|
||||
logger.warning(f'{config_filename}: Key missing for Zabbix')
|
||||
logger.warning('Key missing for Zabbix')
|
||||
return
|
||||
|
||||
elif key is not None:
|
||||
logger.warning(f'{config_filename}: Host missing for Zabbix.')
|
||||
logger.warning('Host missing for Zabbix')
|
||||
return
|
||||
else:
|
||||
logger.warning(f'{config_filename}: No zabbix itemid or host/key provided.')
|
||||
logger.warning('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(f'{config_filename}: Using API key auth for Zabbix')
|
||||
logger.info('Using API key auth for Zabbix')
|
||||
headers['Authorization'] = 'Bearer ' + api_key
|
||||
|
||||
elif (username and password) is not None:
|
||||
logger.info(f'{config_filename}: Using user/pass auth with user {username} for Zabbix')
|
||||
logger.info('Using user/pass auth with user {username} for Zabbix')
|
||||
auth_data = {
|
||||
'jsonrpc': '2.0',
|
||||
'method': 'user.login',
|
||||
@@ -102,18 +109,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'{config_filename}: Zabbix error: {error}')
|
||||
logger.warning(f'Zabbix error: {error}')
|
||||
return
|
||||
|
||||
elif username is not None:
|
||||
logger.warning(f'{config_filename}: Password missing for Zabbix authentication')
|
||||
logger.warning('Password missing for Zabbix authentication')
|
||||
return
|
||||
|
||||
elif password is not None:
|
||||
logger.warning(f'{config_filename}: Username missing for Zabbix authentication')
|
||||
logger.warning('Username missing for Zabbix authentication')
|
||||
return
|
||||
else:
|
||||
logger.warning(f'{config_filename}: Authentication data missing for Zabbix')
|
||||
logger.warning('Authentication data missing for Zabbix')
|
||||
return
|
||||
|
||||
if not dry_run:
|
||||
@@ -123,12 +130,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'{config_filename}: Zabbix error: {error}')
|
||||
logger.warning(f'Zabbix error: {error}')
|
||||
|
||||
|
||||
def destroy_monitor(
|
||||
ping_url_or_uuid, config, config_filename, monitoring_log_level, dry_run
|
||||
): # pragma: no cover
|
||||
def destroy_monitor(ping_url_or_uuid, config, monitoring_log_level, dry_run): # pragma: no cover
|
||||
'''
|
||||
No destruction is necessary for this monitor.
|
||||
'''
|
||||
|
||||
+156
-8
@@ -87,9 +87,16 @@ class Multi_stream_handler(logging.Handler):
|
||||
handler.setLevel(level)
|
||||
|
||||
|
||||
class Console_no_color_formatter(logging.Formatter):
|
||||
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)
|
||||
|
||||
def format(self, record): # pragma: no cover
|
||||
return record.msg
|
||||
record.prefix = f'{self.prefix}: ' if self.prefix else ''
|
||||
|
||||
return super(Log_prefix_formatter, self).format(record)
|
||||
|
||||
|
||||
class Color(enum.Enum):
|
||||
@@ -102,6 +109,12 @@ 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()
|
||||
|
||||
@@ -117,8 +130,9 @@ class Console_color_formatter(logging.Formatter):
|
||||
.get(record.levelno)
|
||||
.value
|
||||
)
|
||||
record.prefix = f'{self.prefix}: ' if self.prefix else ''
|
||||
|
||||
return color_text(color, record.msg)
|
||||
return color_text(color, super(Console_color_formatter, self).format(record))
|
||||
|
||||
|
||||
def ansi_escape_code(color): # pragma: no cover
|
||||
@@ -179,6 +193,137 @@ 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 context manager. For this to work, it relies on each
|
||||
logging formatter to be initialized with "{prefix}" somewhere in its logging format.
|
||||
|
||||
Example use as a context manager:
|
||||
|
||||
|
||||
with borgmatic.logger.Log_prefix('myprefix'):
|
||||
do_something_that_logs()
|
||||
|
||||
For the scope of that "with" statement, any logs created are prefixed with "myprefix: ".
|
||||
Afterwards, the prefix gets restored to whatever it was prior to the context manager.
|
||||
'''
|
||||
|
||||
def __init__(self, prefix):
|
||||
'''
|
||||
Given the desired log prefix, save it for use below. Set prefix to None to disable any
|
||||
prefix from getting logged.
|
||||
'''
|
||||
self.prefix = prefix
|
||||
self.original_prefix = None
|
||||
|
||||
def __enter__(self):
|
||||
'''
|
||||
Set the prefix onto the formatter defaults for every logging handler so that the prefix ends
|
||||
up in every log message. But first, save off any original prefix so that it can be restored
|
||||
below.
|
||||
'''
|
||||
self.original_prefix = get_log_prefix()
|
||||
set_log_prefix(self.prefix)
|
||||
|
||||
def __exit__(self, exception, value, traceback):
|
||||
'''
|
||||
Restore any original prefix.
|
||||
'''
|
||||
set_log_prefix(self.original_prefix)
|
||||
|
||||
|
||||
class Delayed_logging_handler(logging.handlers.BufferingHandler):
|
||||
'''
|
||||
A logging handler that buffers logs and doesn't flush them until explicitly flushed (after
|
||||
target handlers are actually set). It's useful for holding onto messages logged before logging
|
||||
is configured, ensuring those records eventually make their way to the relevant logging
|
||||
handlers.
|
||||
|
||||
When flushing, don't forward log records to a target handler if the record's log level is below
|
||||
that of the handler. This recreates the standard logging behavior of, say, logging.DEBUG records
|
||||
getting suppressed if a handler's level is only set to logging.INFO.
|
||||
'''
|
||||
|
||||
def __init__(self):
|
||||
super(Delayed_logging_handler, self).__init__(capacity=0)
|
||||
|
||||
self.targets = None
|
||||
|
||||
def shouldFlush(self, record):
|
||||
return self.targets is not None
|
||||
|
||||
def flush(self):
|
||||
self.acquire()
|
||||
|
||||
try:
|
||||
if not self.targets:
|
||||
return
|
||||
|
||||
for record in self.buffer:
|
||||
for target in self.targets:
|
||||
if record.levelno >= target.level:
|
||||
target.handle(record)
|
||||
|
||||
self.buffer.clear()
|
||||
finally:
|
||||
self.release()
|
||||
|
||||
|
||||
def configure_delayed_logging(): # pragma: no cover
|
||||
'''
|
||||
Configure a delayed logging handler to buffer anything that gets logged until we're ready to
|
||||
deal with it.
|
||||
'''
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG,
|
||||
handlers=[Delayed_logging_handler()],
|
||||
)
|
||||
|
||||
|
||||
def flush_delayed_logging(target_handlers):
|
||||
'''
|
||||
Flush any previously buffered logs to our "real" logging handlers.
|
||||
'''
|
||||
root_logger = logging.getLogger()
|
||||
|
||||
if root_logger.handlers and isinstance(root_logger.handlers[0], Delayed_logging_handler):
|
||||
delayed_handler = root_logger.handlers[0]
|
||||
delayed_handler.targets = target_handlers
|
||||
delayed_handler.flush()
|
||||
root_logger.removeHandler(delayed_handler)
|
||||
|
||||
|
||||
def configure_logging(
|
||||
console_log_level,
|
||||
syslog_log_level=None,
|
||||
@@ -223,10 +368,9 @@ def configure_logging(
|
||||
if color_enabled:
|
||||
console_handler.setFormatter(Console_color_formatter())
|
||||
else:
|
||||
console_handler.setFormatter(Console_no_color_formatter())
|
||||
console_handler.setFormatter(Log_prefix_formatter())
|
||||
|
||||
console_handler.setLevel(console_log_level)
|
||||
|
||||
handlers = [console_handler]
|
||||
|
||||
if syslog_log_level != logging.DISABLED:
|
||||
@@ -242,7 +386,9 @@ def configure_logging(
|
||||
if syslog_path:
|
||||
syslog_handler = logging.handlers.SysLogHandler(address=syslog_path)
|
||||
syslog_handler.setFormatter(
|
||||
logging.Formatter('borgmatic: {levelname} {message}', style='{') # noqa: FS003
|
||||
Log_prefix_formatter(
|
||||
'borgmatic: {levelname} {prefix}{message}', # noqa: FS003
|
||||
)
|
||||
)
|
||||
syslog_handler.setLevel(syslog_log_level)
|
||||
handlers.append(syslog_handler)
|
||||
@@ -250,13 +396,15 @@ def configure_logging(
|
||||
if log_file and log_file_log_level != logging.DISABLED:
|
||||
file_handler = logging.handlers.WatchedFileHandler(log_file)
|
||||
file_handler.setFormatter(
|
||||
logging.Formatter(
|
||||
log_file_format or '[{asctime}] {levelname}: {message}', style='{' # noqa: FS003
|
||||
Log_prefix_formatter(
|
||||
log_file_format or '[{asctime}] {levelname}: {prefix}{message}', # noqa: FS003
|
||||
)
|
||||
)
|
||||
file_handler.setLevel(log_file_log_level)
|
||||
handlers.append(file_handler)
|
||||
|
||||
flush_delayed_logging(handlers)
|
||||
|
||||
logging.basicConfig(
|
||||
level=min(handler.level for handler in handlers),
|
||||
handlers=handlers,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="generator" content="{{ eleventy.generator }}">
|
||||
<link rel="icon" href="docs/static/borgmatic.png" type="image/x-icon">
|
||||
<link rel="icon" href="https://torsion.org/borgmatic/docs/static/borgmatic.png" type="image/x-icon">
|
||||
<title>{{ subtitle + ' - ' if subtitle}}{{ title }}</title>
|
||||
{%- set css %}
|
||||
{% include 'index.css' %}
|
||||
|
||||
@@ -58,7 +58,7 @@ mariadb_databases:
|
||||
|
||||
As part of each backup, borgmatic streams a database dump for each configured
|
||||
database directly to Borg, so it's included in the backup without consuming
|
||||
additional disk space. (The exceptions are the PostgreSQL/MongoDB "directory"
|
||||
additional disk space. (The exceptions are the PostgreSQL/MongoDB `directory`
|
||||
dump formats, which can't stream and therefore do consume temporary disk
|
||||
space. Additionally, prior to borgmatic 1.5.3, all database dumps consumed
|
||||
temporary disk space.)
|
||||
@@ -261,10 +261,13 @@ hooks:
|
||||
|
||||
... where `my_pg_container` is the name of your database container. In this
|
||||
example, you'd also need to set the `pg_restore_command` and `psql_command`
|
||||
options. If you choose to use the `pg_dump` command within the container
|
||||
though, note that it will output the database dump to a file inside the
|
||||
container. So you'll have to mount the [runtime directory](#runtime-directory)
|
||||
from your host into the container using the same directory structure.
|
||||
options.
|
||||
|
||||
If you choose to use the `pg_dump` command within the container, and you're
|
||||
using the `directory` format in particular, you'll also need to mount the
|
||||
[runtime directory](#runtime-directory) from your host into the container at the
|
||||
same path. Otherwise, the `directory` format dump will remain locked away inside
|
||||
the database container where Borg can't read it.
|
||||
|
||||
For example, with Docker Compose and a runtime directory located at
|
||||
`/run/user/1000`:
|
||||
|
||||
@@ -478,22 +478,18 @@ your Loki API push URL. Here's an example:
|
||||
```yaml
|
||||
loki:
|
||||
url: http://localhost:3100/loki/api/v1/push
|
||||
|
||||
labels:
|
||||
app: borgmatic
|
||||
hostname: example.org
|
||||
```
|
||||
|
||||
With this configuration, borgmatic sends its logs to your Loki instance as any
|
||||
of the `create`, `prune`, `compact`, or `check` actions are run. Then, after
|
||||
the actions complete, borgmatic notifies Loki of success or failure.
|
||||
|
||||
This hook supports sending arbitrary labels to Loki. For instance:
|
||||
|
||||
```yaml
|
||||
loki:
|
||||
url: http://localhost:3100/loki/api/v1/push
|
||||
|
||||
labels:
|
||||
app: borgmatic
|
||||
hostname: example.org
|
||||
```
|
||||
This hook supports sending arbitrary labels to Loki. At least one label is
|
||||
required.
|
||||
|
||||
There are also a few placeholders you can optionally use as label values:
|
||||
|
||||
|
||||
@@ -24,59 +24,127 @@ or security reasons, read on.
|
||||
borgmatic supports calling another application such as a password manager to
|
||||
obtain the Borg passphrase to a repository.
|
||||
|
||||
For example, to ask the *Pass* password manager to provide the passphrase:
|
||||
For example, to ask the [Pass](https://www.passwordstore.org/) password manager
|
||||
to provide the passphrase:
|
||||
|
||||
```yaml
|
||||
encryption_passcommand: pass path/to/borg-repokey
|
||||
encryption_passcommand: pass path/to/borg-passphrase
|
||||
```
|
||||
|
||||
Another example for [KeePassXC](https://keepassxc.org/):
|
||||
|
||||
```yaml
|
||||
encryption_passcommand: keepassxc-cli show --show-protected --attributes Password credentials.kdbx borg_passphrase
|
||||
```
|
||||
|
||||
... where `borg_passphrase` is the title of the KeePassXC entry containing your
|
||||
Borg encryption passphrase in its `Password` field.
|
||||
|
||||
<span class="minilink minilink-addedin">New in version 1.9.9</span> Instead of
|
||||
letting Borg run the passcommand—potentially multiple times since borgmatic runs
|
||||
Borg multiple times—borgmatic now runs the passcommand itself and passes the
|
||||
resulting passprhase securely to Borg via an anonymous pipe. This means you
|
||||
should only ever get prompted for your password manager's passphrase at most
|
||||
once per borgmatic run.
|
||||
|
||||
|
||||
### Using systemd service credentials
|
||||
|
||||
Borgmatic supports using encrypted [credentials](https://systemd.io/CREDENTIALS/).
|
||||
borgmatic supports using encrypted [systemd
|
||||
credentials](https://systemd.io/CREDENTIALS/). To use this feature, start by
|
||||
saving your password as an encrypted credential to
|
||||
`/etc/credstore.encrypted/borgmatic.pw`, e.g.,
|
||||
|
||||
Save your password as an encrypted credential to `/etc/credstore.encrypted/borgmatic.pw`, e.g.,
|
||||
|
||||
```
|
||||
# systemd-ask-password -n | systemd-creds encrypt - /etc/credstore.encrypted/borgmatic.pw
|
||||
```bash
|
||||
systemd-ask-password -n | systemd-creds encrypt - /etc/credstore.encrypted/borgmatic.pw
|
||||
```
|
||||
|
||||
Then uncomment or use the following in your configuration file:
|
||||
Then use the following in your configuration file:
|
||||
|
||||
```yaml
|
||||
encryption_passcommand: "cat ${CREDENTIALS_DIRECTORY}/borgmatic.pw"
|
||||
encryption_passphrase: "{credential systemd borgmatic.pw}"
|
||||
```
|
||||
|
||||
<span class="minilink minilink-addedin">Prior to version 1.9.10</span> You can
|
||||
accomplish the same thing with this configuration:
|
||||
|
||||
```yaml
|
||||
encryption_passcommand: cat ${CREDENTIALS_DIRECTORY}/borgmatic.pw
|
||||
```
|
||||
|
||||
Note that the name `borgmatic.pw` is hardcoded in the systemd service file.
|
||||
|
||||
To use multiple different passwords, save them as encrypted credentials to `/etc/credstore.encrypted/borgmatic/`, e.g.,
|
||||
The `{credential ...}` syntax works for several different options in a borgmatic
|
||||
configuration file besides just `encryption_passphrase`. For instance, the
|
||||
username, password, and API token options within database and monitoring hooks
|
||||
support `{credential ...}`:
|
||||
|
||||
```yaml
|
||||
postgresql_databases:
|
||||
- name: invoices
|
||||
username: postgres
|
||||
password: "{credential systemd borgmatic_db1}"
|
||||
```
|
||||
# mkdir /etc/credstore.encrypted/borgmatic
|
||||
# systemd-ask-password -n | systemd-creds encrypt --name=borgmatic_backupserver1 - /etc/credstore.encrypted/borgmatic/backupserver1
|
||||
# systemd-ask-password -n | systemd-creds encrypt --name=borgmatic_pw2 - /etc/credstore.encrypted/borgmatic/pw2
|
||||
|
||||
For specifics about which options are supported, see the
|
||||
[configuration
|
||||
reference](https://torsion.org/borgmatic/docs/reference/configuration/).
|
||||
|
||||
To use these credentials, you'll need to modify the borgmatic systemd service
|
||||
file to support loading multiple credentials (assuming you need to load more
|
||||
than one or anything not named `borgmatic.pw`).
|
||||
|
||||
Start by saving each encrypted credentials to
|
||||
`/etc/credstore.encrypted/borgmatic/`. E.g.,
|
||||
|
||||
```bash
|
||||
mkdir /etc/credstore.encrypted/borgmatic
|
||||
systemd-ask-password -n | systemd-creds encrypt --name=borgmatic_backupserver1 - /etc/credstore.encrypted/borgmatic/backupserver1
|
||||
systemd-ask-password -n | systemd-creds encrypt --name=borgmatic_pw2 - /etc/credstore.encrypted/borgmatic/pw2
|
||||
...
|
||||
```
|
||||
|
||||
Ensure that the file names, (e.g. `backupserver1`) match the corresponding part of
|
||||
the `--name` option *after* the underscore (_), and that the part *before*
|
||||
Ensure that the file names, (e.g. `backupserver1`) match the corresponding part
|
||||
of the `--name` option *after* the underscore (_), and that the part *before*
|
||||
the underscore matches the directory name (e.g. `borgmatic`).
|
||||
|
||||
Then, uncomment the appropriate line in the systemd service file:
|
||||
|
||||
```
|
||||
# systemctl edit borgmatic.service
|
||||
systemctl edit borgmatic.service
|
||||
...
|
||||
# Load multiple encrypted credentials.
|
||||
LoadCredentialEncrypted=borgmatic:/etc/credstore.encrypted/borgmatic/
|
||||
```
|
||||
|
||||
Finally, use the following in your configuration file:
|
||||
Finally, use something like the following in your borgmatic configuration file
|
||||
for each option value you'd like to load from systemd:
|
||||
|
||||
```
|
||||
encryption_passcommand: "cat ${CREDENTIALS_DIRECTORY}/borgmatic_backupserver1"
|
||||
```yaml
|
||||
encryption_passphrase: "{credential systemd borgmatic_backupserver1}"
|
||||
```
|
||||
|
||||
Adjust `borgmatic_backupserver1` according to the name given to the credential
|
||||
and the directory set in the service file.
|
||||
<span class="minilink minilink-addedin">Prior to version 1.9.10</span> Use the
|
||||
following instead, but only for the `encryption_passcommand` option and
|
||||
not other options:
|
||||
|
||||
```yaml
|
||||
encryption_passcommand: cat ${CREDENTIALS_DIRECTORY}/borgmatic_backupserver1
|
||||
```
|
||||
|
||||
Adjust `borgmatic_backupserver1` according to the name of the credential and the
|
||||
directory set in the service file.
|
||||
|
||||
Be aware that when using this systemd `{credential ...}` feature, you may no
|
||||
longer be able to run certain borgmatic actions outside of the systemd service,
|
||||
as the credentials are only available from within the context of that service.
|
||||
So for instance, `borgmatic list` necessarily relies on the
|
||||
`encryption_passphrase` in order to access the Borg repository, but `list`
|
||||
shouldn't need to load any credentials for your database or monitoring hooks.
|
||||
|
||||
The one exception is `borgmatic config validate`, which doesn't actually load
|
||||
any credentials and should continue working anywhere.
|
||||
|
||||
|
||||
### Environment variable interpolation
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ you get started. Starting at the top level, we have:
|
||||
* [commands](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/commands): Looking to add a new flag or action? Start here. This contains borgmatic's entry point, argument parsing, and shell completion.
|
||||
* [config](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/config): Code responsible for loading, normalizing, and validating borgmatic's configuration. Interested in adding a new configuration option? Check out `schema.yaml` here.
|
||||
* [hooks](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/hooks): Looking to add a new database, filesystem, or monitoring integration? Start here.
|
||||
* [credential](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/hooks/credential): Credential hooks—for loading passphrases and secrets from external providers.
|
||||
* [data_source](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/hooks/data_source): Database and filesystem hooks—anything that produces data or files to go into a backup archive.
|
||||
* [monitoring](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/hooks/monitoring): Monitoring hooks—integrations with third-party or self-hosted monitoring services.
|
||||
* [docs](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/docs): How-to and reference documentation, including the document you're reading now.
|
||||
|
||||
Vendored
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 9.5 KiB |
Vendored
BIN
Binary file not shown.
|
After Width: | Height: | Size: 9.7 KiB |
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "borgmatic"
|
||||
version = "1.9.8"
|
||||
version = "1.9.10"
|
||||
authors = [
|
||||
{ name="Dan Helfman", email="witten@torsion.org" },
|
||||
]
|
||||
|
||||
@@ -18,8 +18,7 @@ if [ -z "$TEST_CONTAINER" ]; then
|
||||
fi
|
||||
|
||||
apk add --no-cache python3 py3-pip borgbackup postgresql17-client mariadb-client mongodb-tools \
|
||||
py3-mongo py3-regex py3-ruamel.yaml py3-ruamel.yaml.clib py3-tox py3-typed-ast py3-yaml bash \
|
||||
sqlite fish
|
||||
py3-mongo py3-regex py3-ruamel.yaml py3-ruamel.yaml.clib py3-tox py3-yaml bash sqlite fish
|
||||
export COVERAGE_FILE=/tmp/.coverage
|
||||
|
||||
tox --workdir /tmp/.tox --sitepackages
|
||||
|
||||
@@ -30,4 +30,3 @@ regex
|
||||
requests==2.32.2
|
||||
ruamel.yaml>0.15.0
|
||||
toml==0.10.2
|
||||
typed-ast
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
|
||||
def generate_configuration(config_path, repository_path):
|
||||
'''
|
||||
Generate borgmatic configuration into a file at the config path, and update the defaults so as
|
||||
to work for testing, including updating the source directories, injecting the given repository
|
||||
path, and tacking on an encryption passphrase loaded from systemd.
|
||||
'''
|
||||
subprocess.check_call(f'borgmatic config generate --destination {config_path}'.split(' '))
|
||||
config = (
|
||||
open(config_path)
|
||||
.read()
|
||||
.replace('ssh://user@backupserver/./sourcehostname.borg', repository_path)
|
||||
.replace('- path: /mnt/backup', '')
|
||||
.replace('label: local', '')
|
||||
.replace('- /home/user/path with spaces', '')
|
||||
.replace('- /home', f'- {config_path}')
|
||||
.replace('- /etc', '')
|
||||
.replace('- /var/log/syslog*', '')
|
||||
+ '\nencryption_passphrase: "{credential systemd mycredential}"'
|
||||
)
|
||||
config_file = open(config_path, 'w')
|
||||
config_file.write(config)
|
||||
config_file.close()
|
||||
|
||||
|
||||
def test_borgmatic_command():
|
||||
# Create a Borg repository.
|
||||
temporary_directory = tempfile.mkdtemp()
|
||||
repository_path = os.path.join(temporary_directory, 'test.borg')
|
||||
extract_path = os.path.join(temporary_directory, 'extract')
|
||||
|
||||
original_working_directory = os.getcwd()
|
||||
os.mkdir(extract_path)
|
||||
os.chdir(extract_path)
|
||||
|
||||
try:
|
||||
config_path = os.path.join(temporary_directory, 'test.yaml')
|
||||
generate_configuration(config_path, repository_path)
|
||||
|
||||
credential_path = os.path.join(temporary_directory, 'mycredential')
|
||||
with open(credential_path, 'w') as credential_file:
|
||||
credential_file.write('test')
|
||||
|
||||
subprocess.check_call(
|
||||
f'borgmatic -v 2 --config {config_path} repo-create --encryption repokey'.split(' '),
|
||||
env=dict(os.environ, **{'CREDENTIALS_DIRECTORY': temporary_directory}),
|
||||
)
|
||||
|
||||
# Run borgmatic to generate a backup archive, and then list it to make sure it exists.
|
||||
subprocess.check_call(
|
||||
f'borgmatic --config {config_path}'.split(' '),
|
||||
env=dict(os.environ, **{'CREDENTIALS_DIRECTORY': temporary_directory}),
|
||||
)
|
||||
output = subprocess.check_output(
|
||||
f'borgmatic --config {config_path} list --json'.split(' '),
|
||||
env=dict(os.environ, **{'CREDENTIALS_DIRECTORY': temporary_directory}),
|
||||
).decode(sys.stdout.encoding)
|
||||
parsed_output = json.loads(output)
|
||||
|
||||
assert len(parsed_output) == 1
|
||||
assert len(parsed_output[0]['archives']) == 1
|
||||
finally:
|
||||
os.chdir(original_working_directory)
|
||||
shutil.rmtree(temporary_directory)
|
||||
@@ -0,0 +1,143 @@
|
||||
import http.server
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def generate_configuration(config_path, repository_path, monitoring_hook_configuration):
|
||||
'''
|
||||
Generate borgmatic configuration into a file at the config path, and update the defaults so as
|
||||
to work for testing, including updating the source directories, injecting the given repository
|
||||
path, and tacking on an encryption passphrase.
|
||||
'''
|
||||
subprocess.check_call(f'borgmatic config generate --destination {config_path}'.split(' '))
|
||||
config = (
|
||||
open(config_path)
|
||||
.read()
|
||||
.replace('ssh://user@backupserver/./sourcehostname.borg', repository_path)
|
||||
.replace('- path: /mnt/backup', '')
|
||||
.replace('label: local', '')
|
||||
.replace('- /home/user/path with spaces', '')
|
||||
.replace('- /home', f'- {config_path}')
|
||||
.replace('- /etc', '')
|
||||
.replace('- /var/log/syslog*', '')
|
||||
+ '\nencryption_passphrase: "test"'
|
||||
+ f'\n{monitoring_hook_configuration}'
|
||||
)
|
||||
config_file = open(config_path, 'w')
|
||||
config_file.write(config)
|
||||
config_file.close()
|
||||
|
||||
|
||||
class Web_server(http.server.BaseHTTPRequestHandler):
|
||||
def handle_method(self):
|
||||
self.send_response(http.HTTPStatus.OK)
|
||||
self.send_header('Content-type', 'text/html')
|
||||
self.end_headers()
|
||||
self.wfile.write(''.encode('utf-8'))
|
||||
|
||||
def do_GET(self):
|
||||
self.handle_method()
|
||||
|
||||
def do_POST(self):
|
||||
self.handle_method()
|
||||
|
||||
|
||||
def serve_web_request(count):
|
||||
for index in range(0, count):
|
||||
with http.server.HTTPServer(('localhost', 12345), Web_server) as server:
|
||||
server.handle_request()
|
||||
|
||||
|
||||
class Background_web_server:
|
||||
def __init__(self, expected_request_count):
|
||||
self.expected_request_count = expected_request_count
|
||||
|
||||
def __enter__(self):
|
||||
self.thread = threading.Thread(
|
||||
target=lambda: serve_web_request(count=self.expected_request_count)
|
||||
)
|
||||
self.thread.start()
|
||||
|
||||
def __exit__(self, exception, value, traceback):
|
||||
self.thread.join()
|
||||
|
||||
|
||||
START_AND_FINISH = 2
|
||||
START_LOG_AND_FINISH = 3
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'monitoring_hook_configuration,expected_request_count',
|
||||
(
|
||||
(
|
||||
'cronhub:\n ping_url: http://localhost:12345/start/1f5e3410-254c-11e8-b61d-55875966d031',
|
||||
START_AND_FINISH,
|
||||
),
|
||||
(
|
||||
'cronitor:\n ping_url: http://localhost:12345/d3x0c1',
|
||||
START_AND_FINISH,
|
||||
),
|
||||
(
|
||||
'healthchecks:\n ping_url: http://localhost:12345/addffa72-da17-40ae-be9c-ff591afb942a',
|
||||
START_LOG_AND_FINISH,
|
||||
),
|
||||
(
|
||||
'loki:\n url: http://localhost:12345/loki/api/v1/push\n labels:\n app: borgmatic',
|
||||
START_AND_FINISH,
|
||||
),
|
||||
(
|
||||
'ntfy:\n topic: my-unique-topic\n server: http://localhost:12345\n states: [start, finish]',
|
||||
START_AND_FINISH,
|
||||
),
|
||||
(
|
||||
'sentry:\n data_source_name_url: http://5f80ec@localhost:12345/203069\n monitor_slug: mymonitor',
|
||||
START_AND_FINISH,
|
||||
),
|
||||
(
|
||||
'uptime_kuma:\n push_url: http://localhost:12345/api/push/abcd1234',
|
||||
START_AND_FINISH,
|
||||
),
|
||||
(
|
||||
'zabbix:\n itemid: 1\n server: http://localhost:12345/zabbix/api_jsonrpc.php\n api_key: mykey\n states: [start, finish]',
|
||||
START_AND_FINISH,
|
||||
),
|
||||
),
|
||||
)
|
||||
def test_borgmatic_command(monitoring_hook_configuration, expected_request_count):
|
||||
# Create a Borg repository.
|
||||
temporary_directory = tempfile.mkdtemp()
|
||||
repository_path = os.path.join(temporary_directory, 'test.borg')
|
||||
extract_path = os.path.join(temporary_directory, 'extract')
|
||||
|
||||
original_working_directory = os.getcwd()
|
||||
os.mkdir(extract_path)
|
||||
os.chdir(extract_path)
|
||||
|
||||
try:
|
||||
config_path = os.path.join(temporary_directory, 'test.yaml')
|
||||
generate_configuration(config_path, repository_path, monitoring_hook_configuration)
|
||||
|
||||
subprocess.check_call(
|
||||
f'borgmatic -v 2 --config {config_path} repo-create --encryption repokey'.split(' ')
|
||||
)
|
||||
|
||||
with Background_web_server(expected_request_count):
|
||||
# Run borgmatic to generate a backup archive, and then list it to make sure it exists.
|
||||
subprocess.check_call(f'borgmatic -v 2 --config {config_path}'.split(' '))
|
||||
output = subprocess.check_output(
|
||||
f'borgmatic --config {config_path} list --json'.split(' ')
|
||||
).decode(sys.stdout.encoding)
|
||||
parsed_output = json.loads(output)
|
||||
|
||||
assert len(parsed_output) == 1
|
||||
assert len(parsed_output[0]['archives']) == 1
|
||||
finally:
|
||||
os.chdir(original_working_directory)
|
||||
shutil.rmtree(temporary_directory)
|
||||
@@ -28,7 +28,7 @@ def generate_configuration(config_path, repository_path):
|
||||
config_file.close()
|
||||
|
||||
|
||||
def test_override_get_normalized():
|
||||
def test_override_gets_normalized():
|
||||
temporary_directory = tempfile.mkdtemp()
|
||||
repository_path = os.path.join(temporary_directory, 'test.borg')
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
|
||||
def generate_configuration(config_path, repository_path):
|
||||
'''
|
||||
Generate borgmatic configuration into a file at the config path, and update the defaults so as
|
||||
to work for testing, including updating the source directories, injecting the given repository
|
||||
path, and tacking on an encryption passcommand.
|
||||
'''
|
||||
subprocess.check_call(f'borgmatic config generate --destination {config_path}'.split(' '))
|
||||
config = (
|
||||
open(config_path)
|
||||
.read()
|
||||
.replace('ssh://user@backupserver/./sourcehostname.borg', repository_path)
|
||||
.replace('- path: /mnt/backup', '')
|
||||
.replace('label: local', '')
|
||||
.replace('- /home/user/path with spaces', '')
|
||||
.replace('- /home', f'- {config_path}')
|
||||
.replace('- /etc', '')
|
||||
.replace('- /var/log/syslog*', '')
|
||||
+ '\nencryption_passcommand: "echo test"'
|
||||
)
|
||||
config_file = open(config_path, 'w')
|
||||
config_file.write(config)
|
||||
config_file.close()
|
||||
|
||||
|
||||
def test_borgmatic_command():
|
||||
# Create a Borg repository.
|
||||
temporary_directory = tempfile.mkdtemp()
|
||||
repository_path = os.path.join(temporary_directory, 'test.borg')
|
||||
extract_path = os.path.join(temporary_directory, 'extract')
|
||||
|
||||
original_working_directory = os.getcwd()
|
||||
os.mkdir(extract_path)
|
||||
os.chdir(extract_path)
|
||||
|
||||
try:
|
||||
config_path = os.path.join(temporary_directory, 'test.yaml')
|
||||
generate_configuration(config_path, repository_path)
|
||||
|
||||
subprocess.check_call(
|
||||
f'borgmatic -v 2 --config {config_path} repo-create --encryption repokey'.split(' ')
|
||||
)
|
||||
|
||||
# Run borgmatic to generate a backup archive, and then list it to make sure it exists.
|
||||
subprocess.check_call(f'borgmatic -v 2 --config {config_path}'.split(' '))
|
||||
output = subprocess.check_output(
|
||||
f'borgmatic --config {config_path} list --json'.split(' ')
|
||||
).decode(sys.stdout.encoding)
|
||||
parsed_output = json.loads(output)
|
||||
|
||||
assert len(parsed_output) == 1
|
||||
assert len(parsed_output[0]['archives']) == 1
|
||||
finally:
|
||||
os.chdir(original_working_directory)
|
||||
shutil.rmtree(temporary_directory)
|
||||
@@ -14,7 +14,7 @@ def test_destroy_monitor_removes_apprise_handler():
|
||||
)
|
||||
)
|
||||
|
||||
module.destroy_monitor(flexmock(), flexmock(), flexmock(), flexmock(), flexmock())
|
||||
module.destroy_monitor(flexmock(), flexmock(), flexmock(), flexmock())
|
||||
|
||||
assert logger.handlers == original_handlers
|
||||
|
||||
@@ -23,6 +23,6 @@ def test_destroy_monitor_without_apprise_handler_does_not_raise():
|
||||
logger = logging.getLogger()
|
||||
original_handlers = list(logger.handlers)
|
||||
|
||||
module.destroy_monitor(flexmock(), flexmock(), flexmock(), flexmock(), flexmock())
|
||||
module.destroy_monitor(flexmock(), flexmock(), flexmock(), flexmock())
|
||||
|
||||
assert logger.handlers == original_handlers
|
||||
|
||||
@@ -14,7 +14,7 @@ def test_destroy_monitor_removes_healthchecks_handler():
|
||||
)
|
||||
)
|
||||
|
||||
module.destroy_monitor(flexmock(), flexmock(), flexmock(), flexmock(), flexmock())
|
||||
module.destroy_monitor(flexmock(), flexmock(), flexmock(), flexmock())
|
||||
|
||||
assert logger.handlers == original_handlers
|
||||
|
||||
@@ -23,6 +23,6 @@ def test_destroy_monitor_without_healthchecks_handler_does_not_raise():
|
||||
logger = logging.getLogger()
|
||||
original_handlers = list(logger.handlers)
|
||||
|
||||
module.destroy_monitor(flexmock(), flexmock(), flexmock(), flexmock(), flexmock())
|
||||
module.destroy_monitor(flexmock(), flexmock(), flexmock(), flexmock())
|
||||
|
||||
assert logger.handlers == original_handlers
|
||||
|
||||
@@ -65,8 +65,8 @@ def test_ping_monitor_adds_log_message():
|
||||
assert any(
|
||||
map(
|
||||
lambda log: log
|
||||
== f'{config_filename}: {module.MONITOR_STATE_TO_LOKI[module.monitor.State.FINISH]} backup',
|
||||
map(lambda x: x[1], handler.buffer.root['streams'][0]['values']),
|
||||
== f'{module.MONITOR_STATE_TO_LOKI[module.monitor.State.FINISH]} backup',
|
||||
map(lambda value: value[1], handler.buffer.root['streams'][0]['values']),
|
||||
)
|
||||
)
|
||||
return
|
||||
@@ -82,7 +82,7 @@ def test_destroy_monitor_removes_log_handler():
|
||||
config_filename = 'test.yaml'
|
||||
dry_run = True
|
||||
module.initialize_monitor(hook_config, flexmock(), config_filename, flexmock(), dry_run)
|
||||
module.destroy_monitor(hook_config, flexmock(), config_filename, flexmock(), dry_run)
|
||||
module.destroy_monitor(hook_config, flexmock(), flexmock(), dry_run)
|
||||
|
||||
for handler in tuple(logging.getLogger().handlers):
|
||||
if isinstance(handler, module.Loki_log_handler):
|
||||
|
||||
@@ -931,7 +931,6 @@ def test_compare_spot_check_hashes_returns_paths_having_failing_hashes():
|
||||
global_arguments=flexmock(),
|
||||
local_path=flexmock(),
|
||||
remote_path=flexmock(),
|
||||
log_prefix='repo',
|
||||
source_paths=('/foo', '/bar', '/baz', '/quux'),
|
||||
) == ('/bar',)
|
||||
|
||||
@@ -972,7 +971,6 @@ def test_compare_spot_check_hashes_returns_relative_paths_having_failing_hashes(
|
||||
global_arguments=flexmock(),
|
||||
local_path=flexmock(),
|
||||
remote_path=flexmock(),
|
||||
log_prefix='repo',
|
||||
source_paths=('foo', 'bar', 'baz', 'quux'),
|
||||
) == ('bar',)
|
||||
|
||||
@@ -1013,7 +1011,6 @@ def test_compare_spot_check_hashes_handles_data_sample_percentage_above_100():
|
||||
global_arguments=flexmock(),
|
||||
local_path=flexmock(),
|
||||
remote_path=flexmock(),
|
||||
log_prefix='repo',
|
||||
source_paths=('/foo', '/bar'),
|
||||
) == ('/foo', '/bar')
|
||||
|
||||
@@ -1051,7 +1048,6 @@ def test_compare_spot_check_hashes_uses_xxh64sum_command_option():
|
||||
global_arguments=flexmock(),
|
||||
local_path=flexmock(),
|
||||
remote_path=flexmock(),
|
||||
log_prefix='repo',
|
||||
source_paths=('/foo', '/bar', '/baz', '/quux'),
|
||||
) == ('/bar',)
|
||||
|
||||
@@ -1088,7 +1084,6 @@ def test_compare_spot_check_hashes_considers_path_missing_from_archive_as_not_ma
|
||||
global_arguments=flexmock(),
|
||||
local_path=flexmock(),
|
||||
remote_path=flexmock(),
|
||||
log_prefix='repo',
|
||||
source_paths=('/foo', '/bar', '/baz', '/quux'),
|
||||
) == ('/bar',)
|
||||
|
||||
@@ -1124,7 +1119,6 @@ def test_compare_spot_check_hashes_considers_non_existent_path_as_not_matching()
|
||||
global_arguments=flexmock(),
|
||||
local_path=flexmock(),
|
||||
remote_path=flexmock(),
|
||||
log_prefix='repo',
|
||||
source_paths=('/foo', '/bar', '/baz', '/quux'),
|
||||
) == ('/bar',)
|
||||
|
||||
@@ -1171,7 +1165,6 @@ def test_compare_spot_check_hashes_with_too_many_paths_feeds_them_to_commands_in
|
||||
global_arguments=flexmock(),
|
||||
local_path=flexmock(),
|
||||
remote_path=flexmock(),
|
||||
log_prefix='repo',
|
||||
source_paths=('/foo', '/bar', '/baz', '/quux'),
|
||||
) == ('/quux',)
|
||||
|
||||
@@ -1214,7 +1207,6 @@ def test_compare_spot_check_hashes_uses_working_directory_to_access_source_paths
|
||||
global_arguments=flexmock(),
|
||||
local_path=flexmock(),
|
||||
remote_path=flexmock(),
|
||||
log_prefix='repo',
|
||||
source_paths=('foo', 'bar', 'baz', 'quux'),
|
||||
) == ('bar',)
|
||||
|
||||
|
||||
@@ -48,9 +48,9 @@ def test_collect_patterns_parses_config_patterns():
|
||||
|
||||
def test_collect_patterns_converts_exclude_patterns():
|
||||
assert module.collect_patterns({'exclude_patterns': ['/foo', '/bar', 'sh:**/baz']}) == (
|
||||
Pattern('/foo', Pattern_type.EXCLUDE, Pattern_style.FNMATCH),
|
||||
Pattern('/bar', Pattern_type.EXCLUDE, Pattern_style.FNMATCH),
|
||||
Pattern('**/baz', Pattern_type.EXCLUDE, Pattern_style.SHELL),
|
||||
Pattern('/foo', Pattern_type.NO_RECURSE, Pattern_style.FNMATCH),
|
||||
Pattern('/bar', Pattern_type.NO_RECURSE, Pattern_style.FNMATCH),
|
||||
Pattern('**/baz', Pattern_type.NO_RECURSE, Pattern_style.SHELL),
|
||||
)
|
||||
|
||||
|
||||
@@ -90,22 +90,22 @@ def test_collect_patterns_reads_config_exclude_from_file():
|
||||
io.StringIO('/bar\n# comment\n\n \n/baz')
|
||||
)
|
||||
flexmock(module).should_receive('parse_pattern').with_args(
|
||||
'- /foo', default_style=Pattern_style.FNMATCH
|
||||
).and_return(Pattern('/foo', Pattern_type.EXCLUDE, Pattern_style.FNMATCH))
|
||||
'! /foo', default_style=Pattern_style.FNMATCH
|
||||
).and_return(Pattern('/foo', Pattern_type.NO_RECURSE, Pattern_style.FNMATCH))
|
||||
flexmock(module).should_receive('parse_pattern').with_args(
|
||||
'- /bar', default_style=Pattern_style.FNMATCH
|
||||
).and_return(Pattern('/bar', Pattern_type.EXCLUDE, Pattern_style.FNMATCH))
|
||||
'! /bar', default_style=Pattern_style.FNMATCH
|
||||
).and_return(Pattern('/bar', Pattern_type.NO_RECURSE, Pattern_style.FNMATCH))
|
||||
flexmock(module).should_receive('parse_pattern').with_args('# comment').never()
|
||||
flexmock(module).should_receive('parse_pattern').with_args('').never()
|
||||
flexmock(module).should_receive('parse_pattern').with_args(' ').never()
|
||||
flexmock(module).should_receive('parse_pattern').with_args(
|
||||
'- /baz', default_style=Pattern_style.FNMATCH
|
||||
).and_return(Pattern('/baz', Pattern_type.EXCLUDE, Pattern_style.FNMATCH))
|
||||
'! /baz', default_style=Pattern_style.FNMATCH
|
||||
).and_return(Pattern('/baz', Pattern_type.NO_RECURSE, Pattern_style.FNMATCH))
|
||||
|
||||
assert module.collect_patterns({'exclude_from': ['file1.txt', 'file2.txt']}) == (
|
||||
Pattern('/foo', Pattern_type.EXCLUDE, Pattern_style.FNMATCH),
|
||||
Pattern('/bar', Pattern_type.EXCLUDE, Pattern_style.FNMATCH),
|
||||
Pattern('/baz', Pattern_type.EXCLUDE, Pattern_style.FNMATCH),
|
||||
Pattern('/foo', Pattern_type.NO_RECURSE, Pattern_style.FNMATCH),
|
||||
Pattern('/bar', Pattern_type.NO_RECURSE, Pattern_style.FNMATCH),
|
||||
Pattern('/baz', Pattern_type.NO_RECURSE, Pattern_style.FNMATCH),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -194,7 +194,6 @@ def test_get_configured_data_source_matches_data_source_with_restore_dump():
|
||||
'postgresql_databases': [{'name': 'foo'}, {'name': 'bar'}],
|
||||
},
|
||||
restore_dump=module.Dump('postgresql_databases', 'bar'),
|
||||
log_prefix='test',
|
||||
) == {'name': 'bar'}
|
||||
|
||||
|
||||
@@ -206,7 +205,6 @@ def test_get_configured_data_source_matches_nothing_when_nothing_configured():
|
||||
module.get_configured_data_source(
|
||||
config={},
|
||||
restore_dump=module.Dump('postgresql_databases', 'quux'),
|
||||
log_prefix='test',
|
||||
)
|
||||
is None
|
||||
)
|
||||
@@ -222,7 +220,6 @@ def test_get_configured_data_source_matches_nothing_when_restore_dump_does_not_m
|
||||
'postgresql_databases': [{'name': 'foo'}],
|
||||
},
|
||||
restore_dump=module.Dump('postgresql_databases', 'quux'),
|
||||
log_prefix='test',
|
||||
)
|
||||
is None
|
||||
)
|
||||
@@ -250,7 +247,6 @@ def test_get_configured_data_source_with_multiple_matching_data_sources_errors()
|
||||
],
|
||||
},
|
||||
restore_dump=module.Dump('postgresql_databases', 'bar'),
|
||||
log_prefix='test',
|
||||
)
|
||||
|
||||
|
||||
@@ -277,7 +273,7 @@ def test_strip_path_prefix_from_extracted_dump_destination_renames_first_matchin
|
||||
def test_restore_single_dump_extracts_and_restores_single_file_dump():
|
||||
flexmock(module).should_receive('render_dump_metadata').and_return('test')
|
||||
flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hooks').with_args(
|
||||
'make_data_source_dump_patterns', object, object, object, object, object
|
||||
'make_data_source_dump_patterns', object, object, object, object
|
||||
).and_return({'postgresql': flexmock()})
|
||||
flexmock(module.tempfile).should_receive('mkdtemp').never()
|
||||
flexmock(module.borgmatic.hooks.data_source.dump).should_receive(
|
||||
@@ -291,7 +287,6 @@ def test_restore_single_dump_extracts_and_restores_single_file_dump():
|
||||
flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hook').with_args(
|
||||
function_name='restore_data_source_dump',
|
||||
config=object,
|
||||
log_prefix=object,
|
||||
hook_name=object,
|
||||
data_source=object,
|
||||
dry_run=object,
|
||||
@@ -318,7 +313,7 @@ def test_restore_single_dump_extracts_and_restores_single_file_dump():
|
||||
def test_restore_single_dump_extracts_and_restores_directory_dump():
|
||||
flexmock(module).should_receive('render_dump_metadata').and_return('test')
|
||||
flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hooks').with_args(
|
||||
'make_data_source_dump_patterns', object, object, object, object, object
|
||||
'make_data_source_dump_patterns', object, object, object, object
|
||||
).and_return({'postgresql': flexmock()})
|
||||
flexmock(module.tempfile).should_receive('mkdtemp').once().and_return(
|
||||
'/run/user/0/borgmatic/tmp1234'
|
||||
@@ -334,7 +329,6 @@ def test_restore_single_dump_extracts_and_restores_directory_dump():
|
||||
flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hook').with_args(
|
||||
function_name='restore_data_source_dump',
|
||||
config=object,
|
||||
log_prefix=object,
|
||||
hook_name=object,
|
||||
data_source=object,
|
||||
dry_run=object,
|
||||
@@ -361,7 +355,7 @@ def test_restore_single_dump_extracts_and_restores_directory_dump():
|
||||
def test_restore_single_dump_with_directory_dump_error_cleans_up_temporary_directory():
|
||||
flexmock(module).should_receive('render_dump_metadata').and_return('test')
|
||||
flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hooks').with_args(
|
||||
'make_data_source_dump_patterns', object, object, object, object, object
|
||||
'make_data_source_dump_patterns', object, object, object, object
|
||||
).and_return({'postgresql': flexmock()})
|
||||
flexmock(module.tempfile).should_receive('mkdtemp').once().and_return(
|
||||
'/run/user/0/borgmatic/tmp1234'
|
||||
@@ -377,7 +371,6 @@ def test_restore_single_dump_with_directory_dump_error_cleans_up_temporary_direc
|
||||
flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hook').with_args(
|
||||
function_name='restore_data_source_dump',
|
||||
config=object,
|
||||
log_prefix=object,
|
||||
hook_name=object,
|
||||
data_source=object,
|
||||
dry_run=object,
|
||||
@@ -405,7 +398,7 @@ def test_restore_single_dump_with_directory_dump_error_cleans_up_temporary_direc
|
||||
def test_restore_single_dump_with_directory_dump_and_dry_run_skips_directory_move_and_cleanup():
|
||||
flexmock(module).should_receive('render_dump_metadata').and_return('test')
|
||||
flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hooks').with_args(
|
||||
'make_data_source_dump_patterns', object, object, object, object, object
|
||||
'make_data_source_dump_patterns', object, object, object, object
|
||||
).and_return({'postgresql': flexmock()})
|
||||
flexmock(module.tempfile).should_receive('mkdtemp').once().and_return('/run/borgmatic/tmp1234')
|
||||
flexmock(module.borgmatic.hooks.data_source.dump).should_receive(
|
||||
@@ -419,7 +412,6 @@ def test_restore_single_dump_with_directory_dump_and_dry_run_skips_directory_mov
|
||||
flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hook').with_args(
|
||||
function_name='restore_data_source_dump',
|
||||
config=object,
|
||||
log_prefix=object,
|
||||
hook_name=object,
|
||||
data_source=object,
|
||||
dry_run=object,
|
||||
@@ -583,11 +575,11 @@ def test_get_dumps_to_restore_gets_requested_dumps_found_in_archive():
|
||||
}
|
||||
flexmock(module).should_receive('dumps_match').and_return(False)
|
||||
flexmock(module).should_receive('dumps_match').with_args(
|
||||
module.Dump(module.UNSPECIFIED, 'foo'),
|
||||
module.Dump(module.UNSPECIFIED, 'foo', hostname=module.UNSPECIFIED),
|
||||
module.Dump('postgresql_databases', 'foo'),
|
||||
).and_return(True)
|
||||
flexmock(module).should_receive('dumps_match').with_args(
|
||||
module.Dump(module.UNSPECIFIED, 'bar'),
|
||||
module.Dump(module.UNSPECIFIED, 'bar', hostname=module.UNSPECIFIED),
|
||||
module.Dump('postgresql_databases', 'bar'),
|
||||
).and_return(True)
|
||||
|
||||
@@ -652,11 +644,11 @@ def test_get_dumps_to_restore_with_all_in_requested_dumps_finds_all_archive_dump
|
||||
}
|
||||
flexmock(module).should_receive('dumps_match').and_return(False)
|
||||
flexmock(module).should_receive('dumps_match').with_args(
|
||||
module.Dump(module.UNSPECIFIED, 'foo'),
|
||||
module.Dump(module.UNSPECIFIED, 'foo', hostname=module.UNSPECIFIED),
|
||||
module.Dump('postgresql_databases', 'foo'),
|
||||
).and_return(True)
|
||||
flexmock(module).should_receive('dumps_match').with_args(
|
||||
module.Dump(module.UNSPECIFIED, 'bar'),
|
||||
module.Dump(module.UNSPECIFIED, 'bar', hostname=module.UNSPECIFIED),
|
||||
module.Dump('postgresql_databases', 'bar'),
|
||||
).and_return(True)
|
||||
|
||||
@@ -681,11 +673,11 @@ def test_get_dumps_to_restore_with_all_in_requested_dumps_plus_additional_reques
|
||||
}
|
||||
flexmock(module).should_receive('dumps_match').and_return(False)
|
||||
flexmock(module).should_receive('dumps_match').with_args(
|
||||
module.Dump(module.UNSPECIFIED, 'foo'),
|
||||
module.Dump(module.UNSPECIFIED, 'foo', hostname=module.UNSPECIFIED),
|
||||
module.Dump('postgresql_databases', 'foo'),
|
||||
).and_return(True)
|
||||
flexmock(module).should_receive('dumps_match').with_args(
|
||||
module.Dump(module.UNSPECIFIED, 'bar'),
|
||||
module.Dump(module.UNSPECIFIED, 'bar', hostname=module.UNSPECIFIED),
|
||||
module.Dump('postgresql_databases', 'bar'),
|
||||
).and_return(True)
|
||||
|
||||
@@ -706,11 +698,11 @@ def test_get_dumps_to_restore_with_all_in_requested_dumps_plus_additional_reques
|
||||
def test_get_dumps_to_restore_raises_for_multiple_matching_dumps_in_archive():
|
||||
flexmock(module).should_receive('dumps_match').and_return(False)
|
||||
flexmock(module).should_receive('dumps_match').with_args(
|
||||
module.Dump(module.UNSPECIFIED, 'foo'),
|
||||
module.Dump(module.UNSPECIFIED, 'foo', hostname=module.UNSPECIFIED),
|
||||
module.Dump('postgresql_databases', 'foo'),
|
||||
).and_return(True)
|
||||
flexmock(module).should_receive('dumps_match').with_args(
|
||||
module.Dump(module.UNSPECIFIED, 'foo'),
|
||||
module.Dump(module.UNSPECIFIED, 'foo', hostname=module.UNSPECIFIED),
|
||||
module.Dump('mariadb_databases', 'foo'),
|
||||
).and_return(True)
|
||||
flexmock(module).should_receive('render_dump_metadata').and_return('test')
|
||||
@@ -733,7 +725,7 @@ def test_get_dumps_to_restore_raises_for_multiple_matching_dumps_in_archive():
|
||||
def test_get_dumps_to_restore_raises_for_all_in_requested_dumps_and_requested_dumps_missing_from_archive():
|
||||
flexmock(module).should_receive('dumps_match').and_return(False)
|
||||
flexmock(module).should_receive('dumps_match').with_args(
|
||||
module.Dump(module.UNSPECIFIED, 'foo'),
|
||||
module.Dump(module.UNSPECIFIED, 'foo', hostname=module.UNSPECIFIED),
|
||||
module.Dump('postgresql_databases', 'foo'),
|
||||
).and_return(True)
|
||||
flexmock(module).should_receive('render_dump_metadata').and_return('test')
|
||||
@@ -758,7 +750,7 @@ def test_get_dumps_to_restore_with_requested_hook_name_filters_dumps_found_in_ar
|
||||
}
|
||||
flexmock(module).should_receive('dumps_match').and_return(False)
|
||||
flexmock(module).should_receive('dumps_match').with_args(
|
||||
module.Dump('postgresql_databases', 'foo'),
|
||||
module.Dump('postgresql_databases', 'foo', hostname=module.UNSPECIFIED),
|
||||
module.Dump('postgresql_databases', 'foo'),
|
||||
).and_return(True)
|
||||
|
||||
@@ -783,7 +775,7 @@ def test_get_dumps_to_restore_with_requested_shortened_hook_name_filters_dumps_f
|
||||
}
|
||||
flexmock(module).should_receive('dumps_match').and_return(False)
|
||||
flexmock(module).should_receive('dumps_match').with_args(
|
||||
module.Dump('postgresql_databases', 'foo'),
|
||||
module.Dump('postgresql_databases', 'foo', hostname=module.UNSPECIFIED),
|
||||
module.Dump('postgresql_databases', 'foo'),
|
||||
).and_return(True)
|
||||
|
||||
@@ -1064,17 +1056,14 @@ def test_run_restore_restores_data_source_configured_with_all_name():
|
||||
flexmock(module).should_receive('get_configured_data_source').with_args(
|
||||
config=object,
|
||||
restore_dump=module.Dump(hook_name='postgresql_databases', data_source_name='foo'),
|
||||
log_prefix=object,
|
||||
).and_return({'name': 'foo'})
|
||||
flexmock(module).should_receive('get_configured_data_source').with_args(
|
||||
config=object,
|
||||
restore_dump=module.Dump(hook_name='postgresql_databases', data_source_name='bar'),
|
||||
log_prefix=object,
|
||||
).and_return(None)
|
||||
flexmock(module).should_receive('get_configured_data_source').with_args(
|
||||
config=object,
|
||||
restore_dump=module.Dump(hook_name='postgresql_databases', data_source_name='all'),
|
||||
log_prefix=object,
|
||||
).and_return({'name': 'bar'})
|
||||
flexmock(module).should_receive('restore_single_dump').with_args(
|
||||
repository=object,
|
||||
@@ -1148,17 +1137,14 @@ def test_run_restore_skips_missing_data_source():
|
||||
flexmock(module).should_receive('get_configured_data_source').with_args(
|
||||
config=object,
|
||||
restore_dump=module.Dump(hook_name='postgresql_databases', data_source_name='foo'),
|
||||
log_prefix=object,
|
||||
).and_return({'name': 'foo'})
|
||||
flexmock(module).should_receive('get_configured_data_source').with_args(
|
||||
config=object,
|
||||
restore_dump=module.Dump(hook_name='postgresql_databases', data_source_name='bar'),
|
||||
log_prefix=object,
|
||||
).and_return(None)
|
||||
flexmock(module).should_receive('get_configured_data_source').with_args(
|
||||
config=object,
|
||||
restore_dump=module.Dump(hook_name='postgresql_databases', data_source_name='all'),
|
||||
log_prefix=object,
|
||||
).and_return(None)
|
||||
flexmock(module).should_receive('restore_single_dump').with_args(
|
||||
repository=object,
|
||||
@@ -1232,12 +1218,10 @@ def test_run_restore_restores_data_sources_from_different_hooks():
|
||||
flexmock(module).should_receive('get_configured_data_source').with_args(
|
||||
config=object,
|
||||
restore_dump=module.Dump(hook_name='postgresql_databases', data_source_name='foo'),
|
||||
log_prefix=object,
|
||||
).and_return({'name': 'foo'})
|
||||
flexmock(module).should_receive('get_configured_data_source').with_args(
|
||||
config=object,
|
||||
restore_dump=module.Dump(hook_name='mysql_databases', data_source_name='foo'),
|
||||
log_prefix=object,
|
||||
).and_return({'name': 'bar'})
|
||||
flexmock(module).should_receive('restore_single_dump').with_args(
|
||||
repository=object,
|
||||
|
||||
+201
-74
@@ -8,13 +8,16 @@ from borgmatic.borg import check as module
|
||||
from ..test_verbosity import insert_logging_mock
|
||||
|
||||
|
||||
def insert_execute_command_mock(command, working_directory=None, borg_exit_codes=None):
|
||||
def insert_execute_command_mock(
|
||||
command, output_file=None, working_directory=None, borg_exit_codes=None
|
||||
):
|
||||
flexmock(module.environment).should_receive('make_environment')
|
||||
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(
|
||||
working_directory,
|
||||
)
|
||||
flexmock(module).should_receive('execute_command').with_args(
|
||||
command,
|
||||
output_file=output_file,
|
||||
extra_environment=None,
|
||||
working_directory=working_directory,
|
||||
borg_local_path=command[0],
|
||||
@@ -250,11 +253,11 @@ def test_make_check_name_flags_without_archives_check_and_with_archive_filter_fl
|
||||
assert flags == ('--repository-only',)
|
||||
|
||||
|
||||
def test_make_check_name_flags_with_data_check_returns_flag_and_implies_archives():
|
||||
def test_make_check_name_flags_with_archives_and_data_check_returns_verify_data_flag():
|
||||
flexmock(module.feature).should_receive('available').and_return(True)
|
||||
flexmock(module.flags).should_receive('make_match_archives_flags').and_return(())
|
||||
|
||||
flags = module.make_check_name_flags({'data'}, ())
|
||||
flags = module.make_check_name_flags({'archives', 'data'}, ())
|
||||
|
||||
assert flags == (
|
||||
'--archives-only',
|
||||
@@ -262,6 +265,15 @@ def test_make_check_name_flags_with_data_check_returns_flag_and_implies_archives
|
||||
)
|
||||
|
||||
|
||||
def test_make_check_name_flags_with_repository_and_data_check_returns_verify_data_flag():
|
||||
flexmock(module.feature).should_receive('available').and_return(True)
|
||||
flexmock(module.flags).should_receive('make_match_archives_flags').and_return(())
|
||||
|
||||
flags = module.make_check_name_flags({'archives', 'data', 'repository'}, ())
|
||||
|
||||
assert flags == ('--verify-data',)
|
||||
|
||||
|
||||
def test_make_check_name_flags_with_extract_omits_extract_flag():
|
||||
flexmock(module.feature).should_receive('available').and_return(True)
|
||||
flexmock(module.flags).should_receive('make_match_archives_flags').and_return(())
|
||||
@@ -271,21 +283,6 @@ def test_make_check_name_flags_with_extract_omits_extract_flag():
|
||||
assert flags == ()
|
||||
|
||||
|
||||
def test_make_check_name_flags_with_repository_and_data_checks_does_not_return_repository_only():
|
||||
flexmock(module.feature).should_receive('available').and_return(True)
|
||||
flexmock(module.flags).should_receive('make_match_archives_flags').and_return(())
|
||||
|
||||
flags = module.make_check_name_flags(
|
||||
{
|
||||
'repository',
|
||||
'data',
|
||||
},
|
||||
(),
|
||||
)
|
||||
|
||||
assert flags == ('--verify-data',)
|
||||
|
||||
|
||||
def test_get_repository_id_with_valid_json_does_not_raise():
|
||||
config = {}
|
||||
flexmock(module.repo_info).should_receive('display_repository_info').and_return(
|
||||
@@ -336,7 +333,9 @@ def test_get_repository_id_with_missing_json_keys_raises():
|
||||
|
||||
def test_check_archives_with_progress_passes_through_to_borg():
|
||||
config = {}
|
||||
flexmock(module).should_receive('make_check_name_flags').and_return(())
|
||||
flexmock(module).should_receive('make_check_name_flags').with_args(
|
||||
{'repository'}, ()
|
||||
).and_return(())
|
||||
flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',))
|
||||
flexmock(module.environment).should_receive('make_environment')
|
||||
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
|
||||
@@ -369,7 +368,9 @@ def test_check_archives_with_progress_passes_through_to_borg():
|
||||
|
||||
def test_check_archives_with_repair_passes_through_to_borg():
|
||||
config = {}
|
||||
flexmock(module).should_receive('make_check_name_flags').and_return(())
|
||||
flexmock(module).should_receive('make_check_name_flags').with_args(
|
||||
{'repository'}, ()
|
||||
).and_return(())
|
||||
flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',))
|
||||
flexmock(module.environment).should_receive('make_environment')
|
||||
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
|
||||
@@ -402,12 +403,15 @@ def test_check_archives_with_repair_passes_through_to_borg():
|
||||
|
||||
def test_check_archives_with_max_duration_flag_passes_through_to_borg():
|
||||
config = {}
|
||||
flexmock(module).should_receive('make_check_name_flags').and_return(())
|
||||
flexmock(module).should_receive('make_check_name_flags').with_args(
|
||||
{'repository'}, ()
|
||||
).and_return(())
|
||||
flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',))
|
||||
flexmock(module.environment).should_receive('make_environment')
|
||||
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
|
||||
flexmock(module).should_receive('execute_command').with_args(
|
||||
('borg', 'check', '--max-duration', '33', 'repo'),
|
||||
output_file=None,
|
||||
extra_environment=None,
|
||||
working_directory=None,
|
||||
borg_local_path='borg',
|
||||
@@ -432,37 +436,17 @@ def test_check_archives_with_max_duration_flag_passes_through_to_borg():
|
||||
)
|
||||
|
||||
|
||||
def test_check_archives_with_max_duration_flag_and_archives_check_errors():
|
||||
config = {}
|
||||
flexmock(module).should_receive('execute_command').never()
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
module.check_archives(
|
||||
repository_path='repo',
|
||||
config=config,
|
||||
local_borg_version='1.2.3',
|
||||
check_arguments=flexmock(
|
||||
progress=None,
|
||||
repair=None,
|
||||
only_checks=None,
|
||||
force=None,
|
||||
match_archives=None,
|
||||
max_duration=33,
|
||||
),
|
||||
global_arguments=flexmock(log_json=False),
|
||||
checks={'repository', 'archives'},
|
||||
archive_filter_flags=(),
|
||||
)
|
||||
|
||||
|
||||
def test_check_archives_with_max_duration_option_passes_through_to_borg():
|
||||
config = {'checks': [{'name': 'repository', 'max_duration': 33}]}
|
||||
flexmock(module).should_receive('make_check_name_flags').and_return(())
|
||||
flexmock(module).should_receive('make_check_name_flags').with_args(
|
||||
{'repository'}, ()
|
||||
).and_return(())
|
||||
flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',))
|
||||
flexmock(module.environment).should_receive('make_environment')
|
||||
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
|
||||
flexmock(module).should_receive('execute_command').with_args(
|
||||
('borg', 'check', '--max-duration', '33', 'repo'),
|
||||
output_file=None,
|
||||
extra_environment=None,
|
||||
working_directory=None,
|
||||
borg_local_path='borg',
|
||||
@@ -487,37 +471,145 @@ def test_check_archives_with_max_duration_option_passes_through_to_borg():
|
||||
)
|
||||
|
||||
|
||||
def test_check_archives_with_max_duration_option_and_archives_check_errors():
|
||||
config = {'checks': [{'name': 'repository', 'max_duration': 33}]}
|
||||
flexmock(module).should_receive('execute_command').never()
|
||||
def test_check_archives_with_max_duration_option_and_archives_check_runs_repository_check_separately():
|
||||
config = {'checks': [{'name': 'repository', 'max_duration': 33}, {'name': 'archives'}]}
|
||||
flexmock(module).should_receive('make_check_name_flags').with_args({'archives'}, ()).and_return(
|
||||
('--archives-only',)
|
||||
)
|
||||
flexmock(module).should_receive('make_check_name_flags').with_args(
|
||||
{'repository'}, ()
|
||||
).and_return(('--repository-only',))
|
||||
flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',))
|
||||
insert_execute_command_mock(('borg', 'check', '--archives-only', 'repo'))
|
||||
insert_execute_command_mock(
|
||||
('borg', 'check', '--max-duration', '33', '--repository-only', 'repo')
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
module.check_archives(
|
||||
repository_path='repo',
|
||||
config=config,
|
||||
local_borg_version='1.2.3',
|
||||
check_arguments=flexmock(
|
||||
progress=None,
|
||||
repair=None,
|
||||
only_checks=None,
|
||||
force=None,
|
||||
match_archives=None,
|
||||
max_duration=None,
|
||||
),
|
||||
global_arguments=flexmock(log_json=False),
|
||||
checks={'repository', 'archives'},
|
||||
archive_filter_flags=(),
|
||||
)
|
||||
module.check_archives(
|
||||
repository_path='repo',
|
||||
config=config,
|
||||
local_borg_version='1.2.3',
|
||||
check_arguments=flexmock(
|
||||
progress=None,
|
||||
repair=None,
|
||||
only_checks=None,
|
||||
force=None,
|
||||
match_archives=None,
|
||||
max_duration=None,
|
||||
),
|
||||
global_arguments=flexmock(log_json=False),
|
||||
checks={'repository', 'archives'},
|
||||
archive_filter_flags=(),
|
||||
)
|
||||
|
||||
|
||||
def test_check_archives_with_max_duration_flag_and_archives_check_runs_repository_check_separately():
|
||||
config = {'checks': [{'name': 'repository'}, {'name': 'archives'}]}
|
||||
flexmock(module).should_receive('make_check_name_flags').with_args({'archives'}, ()).and_return(
|
||||
('--archives-only',)
|
||||
)
|
||||
flexmock(module).should_receive('make_check_name_flags').with_args(
|
||||
{'repository'}, ()
|
||||
).and_return(('--repository-only',))
|
||||
flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',))
|
||||
insert_execute_command_mock(('borg', 'check', '--archives-only', 'repo'))
|
||||
insert_execute_command_mock(
|
||||
('borg', 'check', '--max-duration', '33', '--repository-only', 'repo')
|
||||
)
|
||||
|
||||
module.check_archives(
|
||||
repository_path='repo',
|
||||
config=config,
|
||||
local_borg_version='1.2.3',
|
||||
check_arguments=flexmock(
|
||||
progress=None,
|
||||
repair=None,
|
||||
only_checks=None,
|
||||
force=None,
|
||||
match_archives=None,
|
||||
max_duration=33,
|
||||
),
|
||||
global_arguments=flexmock(log_json=False),
|
||||
checks={'repository', 'archives'},
|
||||
archive_filter_flags=(),
|
||||
)
|
||||
|
||||
|
||||
def test_check_archives_with_max_duration_option_and_data_check_runs_repository_check_separately():
|
||||
config = {'checks': [{'name': 'repository', 'max_duration': 33}, {'name': 'data'}]}
|
||||
flexmock(module).should_receive('make_check_name_flags').with_args(
|
||||
{'data', 'archives'}, ()
|
||||
).and_return(('--archives-only', '--verify-data'))
|
||||
flexmock(module).should_receive('make_check_name_flags').with_args(
|
||||
{'repository'}, ()
|
||||
).and_return(('--repository-only',))
|
||||
flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',))
|
||||
insert_execute_command_mock(('borg', 'check', '--archives-only', '--verify-data', 'repo'))
|
||||
insert_execute_command_mock(
|
||||
('borg', 'check', '--max-duration', '33', '--repository-only', 'repo')
|
||||
)
|
||||
|
||||
module.check_archives(
|
||||
repository_path='repo',
|
||||
config=config,
|
||||
local_borg_version='1.2.3',
|
||||
check_arguments=flexmock(
|
||||
progress=None,
|
||||
repair=None,
|
||||
only_checks=None,
|
||||
force=None,
|
||||
match_archives=None,
|
||||
max_duration=None,
|
||||
),
|
||||
global_arguments=flexmock(log_json=False),
|
||||
checks={'repository', 'data'},
|
||||
archive_filter_flags=(),
|
||||
)
|
||||
|
||||
|
||||
def test_check_archives_with_max_duration_flag_and_data_check_runs_repository_check_separately():
|
||||
config = {'checks': [{'name': 'repository'}, {'name': 'data'}]}
|
||||
flexmock(module).should_receive('make_check_name_flags').with_args(
|
||||
{'data', 'archives'}, ()
|
||||
).and_return(('--archives-only', '--verify-data'))
|
||||
flexmock(module).should_receive('make_check_name_flags').with_args(
|
||||
{'repository'}, ()
|
||||
).and_return(('--repository-only',))
|
||||
flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',))
|
||||
insert_execute_command_mock(('borg', 'check', '--archives-only', '--verify-data', 'repo'))
|
||||
insert_execute_command_mock(
|
||||
('borg', 'check', '--max-duration', '33', '--repository-only', 'repo')
|
||||
)
|
||||
|
||||
module.check_archives(
|
||||
repository_path='repo',
|
||||
config=config,
|
||||
local_borg_version='1.2.3',
|
||||
check_arguments=flexmock(
|
||||
progress=None,
|
||||
repair=None,
|
||||
only_checks=None,
|
||||
force=None,
|
||||
match_archives=None,
|
||||
max_duration=33,
|
||||
),
|
||||
global_arguments=flexmock(log_json=False),
|
||||
checks={'repository', 'data'},
|
||||
archive_filter_flags=(),
|
||||
)
|
||||
|
||||
|
||||
def test_check_archives_with_max_duration_flag_overrides_max_duration_option():
|
||||
config = {'checks': [{'name': 'repository', 'max_duration': 33}]}
|
||||
flexmock(module).should_receive('make_check_name_flags').and_return(())
|
||||
flexmock(module).should_receive('make_check_name_flags').with_args(
|
||||
{'repository'}, ()
|
||||
).and_return(())
|
||||
flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',))
|
||||
flexmock(module.environment).should_receive('make_environment')
|
||||
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
|
||||
flexmock(module).should_receive('execute_command').with_args(
|
||||
('borg', 'check', '--max-duration', '44', 'repo'),
|
||||
output_file=None,
|
||||
extra_environment=None,
|
||||
working_directory=None,
|
||||
borg_local_path='borg',
|
||||
@@ -575,9 +667,37 @@ def test_check_archives_calls_borg_with_parameters(checks):
|
||||
)
|
||||
|
||||
|
||||
def test_check_archives_with_data_check_implies_archives_check_calls_borg_with_parameters():
|
||||
config = {}
|
||||
flexmock(module).should_receive('make_check_name_flags').with_args(
|
||||
{'data', 'archives'}, ()
|
||||
).and_return(())
|
||||
flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',))
|
||||
insert_execute_command_mock(('borg', 'check', 'repo'))
|
||||
|
||||
module.check_archives(
|
||||
repository_path='repo',
|
||||
config=config,
|
||||
local_borg_version='1.2.3',
|
||||
check_arguments=flexmock(
|
||||
progress=None,
|
||||
repair=None,
|
||||
only_checks=None,
|
||||
force=None,
|
||||
match_archives=None,
|
||||
max_duration=None,
|
||||
),
|
||||
global_arguments=flexmock(log_json=False),
|
||||
checks={'data'},
|
||||
archive_filter_flags=(),
|
||||
)
|
||||
|
||||
|
||||
def test_check_archives_with_log_info_passes_through_to_borg():
|
||||
config = {}
|
||||
flexmock(module).should_receive('make_check_name_flags').and_return(())
|
||||
flexmock(module).should_receive('make_check_name_flags').with_args(
|
||||
{'repository'}, ()
|
||||
).and_return(())
|
||||
flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',))
|
||||
insert_logging_mock(logging.INFO)
|
||||
insert_execute_command_mock(('borg', 'check', '--info', 'repo'))
|
||||
@@ -602,7 +722,9 @@ def test_check_archives_with_log_info_passes_through_to_borg():
|
||||
|
||||
def test_check_archives_with_log_debug_passes_through_to_borg():
|
||||
config = {}
|
||||
flexmock(module).should_receive('make_check_name_flags').and_return(())
|
||||
flexmock(module).should_receive('make_check_name_flags').with_args(
|
||||
{'repository'}, ()
|
||||
).and_return(())
|
||||
flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',))
|
||||
insert_logging_mock(logging.DEBUG)
|
||||
insert_execute_command_mock(('borg', 'check', '--debug', '--show-rc', 'repo'))
|
||||
@@ -806,7 +928,9 @@ def test_check_archives_with_retention_prefix():
|
||||
|
||||
def test_check_archives_with_extra_borg_options_passes_through_to_borg():
|
||||
config = {'extra_borg_options': {'check': '--extra --options'}}
|
||||
flexmock(module).should_receive('make_check_name_flags').and_return(())
|
||||
flexmock(module).should_receive('make_check_name_flags').with_args(
|
||||
{'repository'}, ()
|
||||
).and_return(())
|
||||
flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',))
|
||||
insert_execute_command_mock(('borg', 'check', '--extra', '--options', 'repo'))
|
||||
|
||||
@@ -829,15 +953,16 @@ def test_check_archives_with_extra_borg_options_passes_through_to_borg():
|
||||
|
||||
|
||||
def test_check_archives_with_match_archives_passes_through_to_borg():
|
||||
config = {}
|
||||
flexmock(module).should_receive('make_check_name_flags').and_return(
|
||||
('--match-archives', 'foo-*')
|
||||
)
|
||||
config = {'checks': [{'name': 'archives'}]}
|
||||
flexmock(module).should_receive('make_check_name_flags').with_args(
|
||||
{'archives'}, object
|
||||
).and_return(('--match-archives', 'foo-*'))
|
||||
flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',))
|
||||
flexmock(module.environment).should_receive('make_environment')
|
||||
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
|
||||
flexmock(module).should_receive('execute_command').with_args(
|
||||
('borg', 'check', '--match-archives', 'foo-*', 'repo'),
|
||||
output_file=None,
|
||||
extra_environment=None,
|
||||
working_directory=None,
|
||||
borg_local_path='borg',
|
||||
@@ -864,7 +989,9 @@ def test_check_archives_with_match_archives_passes_through_to_borg():
|
||||
|
||||
def test_check_archives_calls_borg_with_working_directory():
|
||||
config = {'working_directory': '/working/dir'}
|
||||
flexmock(module).should_receive('make_check_name_flags').and_return(())
|
||||
flexmock(module).should_receive('make_check_name_flags').with_args(
|
||||
{'repository'}, ()
|
||||
).and_return(())
|
||||
flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',))
|
||||
flexmock(module.environment).should_receive('make_environment')
|
||||
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user