mirror of
https://github.com/borgmatic-collective/borgmatic.git
synced 2026-07-24 19:03:02 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
730350b31a | ||
|
|
203e1f4e99 | ||
|
|
4c35a564ef | ||
|
|
7551810ea6 | ||
|
|
ce523eeed6 | ||
|
|
3c0def6d6d | ||
|
|
f08014e3be | ||
|
|
86ad93676d | ||
|
|
e1825d2bcb | ||
|
|
92b8c0230e | ||
|
|
73c196aa70 | ||
|
|
5d390d7953 | ||
|
|
ffb342780b | ||
|
|
9871267f97 | ||
|
|
914c2b17e9 | ||
|
|
804455ac9f | ||
|
|
4fe0fd1576 | ||
|
|
e3d40125cb | ||
|
|
e66df22a6e | ||
|
|
e789de0851 | ||
|
|
f1cac95b9c | ||
|
|
f183800009 | ||
|
|
b7362bfbac | ||
|
|
2467518d4e | ||
|
|
3bda843139 | ||
|
|
44efca2be9 | ||
|
|
cfeeb87bbe | ||
|
|
bb2e986c9d | ||
|
|
67ac70354b | ||
|
|
8c1d5dbfe1 | ||
|
|
a3aeb36159 | ||
|
|
c702a988bd | ||
|
|
bbf1c3d55e | ||
|
|
0b17fb2d3f | ||
|
|
ca54da1067 | ||
|
|
661041da04 | ||
|
|
ad14ff3ee5 | ||
|
|
b72b9aaf13 | ||
|
|
a70fd30cb1 | ||
|
|
5560f30aa6 | ||
|
|
256ed4170b | ||
|
|
071d8d945a | ||
|
|
926c26315a | ||
|
|
120a29ab4d | ||
|
|
8573660ff0 | ||
|
|
0b9f3ae8a1 | ||
|
|
2c70ad81ec | ||
|
|
d6c3ec05aa | ||
|
|
a4954cc7a3 | ||
|
|
a6b6dd32c1 | ||
|
|
d3409df84c |
@@ -1,3 +1,54 @@
|
||||
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
|
||||
contributors—because there are multiple ways to contribute to the project! See:
|
||||
https://torsion.org/borgmatic/#recent-contributors
|
||||
|
||||
1.9.7
|
||||
* #855: Add a Sentry monitoring hook. See the documentation for more information:
|
||||
https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#sentry-hook
|
||||
* #968: Fix for a "spot" check error when a filename in the most recent archive contains a newline.
|
||||
* #970: Fix for an error when there's a blank line in the configured patterns or excludes.
|
||||
* #971: Fix for "exclude_from" files being completely ignored.
|
||||
* #977: Fix for "exclude_patterns" and "exclude_from" not supporting explicit pattern styles (e.g.,
|
||||
"sh:" or "re:").
|
||||
|
||||
1.9.6
|
||||
* #959: Fix an error in the Btrfs hook when a subvolume mounted at "/" is configured in borgmatic's
|
||||
source directories.
|
||||
* #960: Fix for archives storing relative source directory paths such that they contain the working
|
||||
directory.
|
||||
* #960: Fix the "spot" check to support relative source directory paths.
|
||||
* #962: For the ZFS, Btrfs, and LVM hooks, perform path rewriting for excludes and patterns in
|
||||
addition to the existing source directories rewriting.
|
||||
* #962: Under the hood, merge all configured source directories, excludes, and patterns into a
|
||||
unified temporary patterns file for passing to Borg. The borgmatic configuration options remain
|
||||
unchanged.
|
||||
* #962: For the LVM hook, add support for nested logical volumes.
|
||||
* #965: Fix a borgmatic runtime directory error when running the "spot" check with a database hook
|
||||
enabled.
|
||||
* #969: Fix the "restore" action to work on database dumps without a port when a default port is
|
||||
present in configuration.
|
||||
* Fix the "spot" check to no longer consider pipe files within an archive for file comparisons.
|
||||
* Fix the "spot" check to have a nicer error when there are no source paths to compare.
|
||||
* Fix auto-excluding of special files (when databases are configured) to support relative source
|
||||
directory paths.
|
||||
* Drop support for Python 3.8, which has been end-of-lifed.
|
||||
|
||||
1.9.5
|
||||
* #418: Backup and restore databases that have the same name but with different ports, hostnames,
|
||||
or hooks.
|
||||
|
||||
@@ -75,6 +75,7 @@ borgmatic is powered by [Borg Backup](https://www.borgbackup.org/).
|
||||
<a href="https://grafana.com/oss/loki/"><img src="docs/static/loki.png" alt="Loki" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
|
||||
<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>
|
||||
|
||||
|
||||
@@ -164,4 +165,8 @@ info on cloning source code, running tests, etc.
|
||||
|
||||
### Recent contributors
|
||||
|
||||
Thanks to all borgmatic contributors! There are multiple ways to contribute to
|
||||
this project, so the following includes those who have fixed bugs, contributed
|
||||
features, *or* filed tickets.
|
||||
|
||||
{% include borgmatic/contributors.html %}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+43
-33
@@ -363,18 +363,19 @@ def collect_spot_check_source_paths(
|
||||
borgmatic.hooks.dispatch.call_hooks(
|
||||
'use_streaming',
|
||||
config,
|
||||
repository['path'],
|
||||
borgmatic.hooks.dispatch.Hook_type.DATA_SOURCE,
|
||||
).values()
|
||||
)
|
||||
working_directory = borgmatic.config.paths.get_working_directory(config)
|
||||
|
||||
(create_flags, create_positional_arguments, pattern_file, exclude_file) = (
|
||||
(create_flags, create_positional_arguments, pattern_file) = (
|
||||
borgmatic.borg.create.make_base_create_command(
|
||||
dry_run=True,
|
||||
repository_path=repository['path'],
|
||||
config=config,
|
||||
source_directories=borgmatic.actions.create.process_source_directories(
|
||||
config,
|
||||
patterns=borgmatic.actions.create.process_patterns(
|
||||
borgmatic.actions.create.collect_patterns(config),
|
||||
working_directory,
|
||||
),
|
||||
local_borg_version=local_borg_version,
|
||||
global_arguments=global_arguments,
|
||||
@@ -399,7 +400,7 @@ def collect_spot_check_source_paths(
|
||||
|
||||
paths = tuple(
|
||||
path_line.split(' ', 1)[1]
|
||||
for path_line in paths_output.split('\n')
|
||||
for path_line in paths_output.splitlines()
|
||||
if path_line and path_line.startswith('- ') or path_line.startswith('+ ')
|
||||
)
|
||||
|
||||
@@ -409,6 +410,7 @@ def collect_spot_check_source_paths(
|
||||
|
||||
|
||||
BORG_DIRECTORY_FILE_TYPE = 'd'
|
||||
BORG_PIPE_FILE_TYPE = 'p'
|
||||
|
||||
|
||||
def collect_spot_check_archive_paths(
|
||||
@@ -426,6 +428,9 @@ def collect_spot_check_archive_paths(
|
||||
local Borg version, global arguments as an argparse.Namespace instance, the local Borg path, the
|
||||
remote Borg path, and the borgmatic runtime directory, collect the paths from the given archive
|
||||
(but only include files and symlinks and exclude borgmatic runtime directories).
|
||||
|
||||
These paths do not have a leading slash, as that's how Borg stores them. As a result, we don't
|
||||
know whether they came from absolute or relative source directories.
|
||||
'''
|
||||
borgmatic_source_directory = borgmatic.config.paths.get_borgmatic_source_directory(config)
|
||||
|
||||
@@ -437,15 +442,17 @@ 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,
|
||||
)
|
||||
for (file_type, path) in (line.split(' ', 1),)
|
||||
if file_type != BORG_DIRECTORY_FILE_TYPE
|
||||
if pathlib.Path('/borgmatic') not in pathlib.Path(path).parents
|
||||
if pathlib.Path(borgmatic_source_directory) not in pathlib.Path(path).parents
|
||||
if pathlib.Path(borgmatic_runtime_directory) not in pathlib.Path(path).parents
|
||||
if file_type not in (BORG_DIRECTORY_FILE_TYPE, BORG_PIPE_FILE_TYPE)
|
||||
if pathlib.Path('borgmatic') not in pathlib.Path(path).parents
|
||||
if pathlib.Path(borgmatic_source_directory.lstrip(os.path.sep))
|
||||
not in pathlib.Path(path).parents
|
||||
if pathlib.Path(borgmatic_runtime_directory.lstrip(os.path.sep))
|
||||
not in pathlib.Path(path).parents
|
||||
)
|
||||
|
||||
|
||||
@@ -460,15 +467,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.
|
||||
@@ -484,7 +490,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)
|
||||
@@ -532,7 +538,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,
|
||||
)
|
||||
@@ -544,7 +550,7 @@ def compare_spot_check_hashes(
|
||||
failing_paths = []
|
||||
|
||||
for path, source_hash in source_hashes.items():
|
||||
archive_hash = archive_hashes.get(path)
|
||||
archive_hash = archive_hashes.get(path.lstrip(os.path.sep))
|
||||
|
||||
if archive_hash is not None and archive_hash == source_hash:
|
||||
continue
|
||||
@@ -572,8 +578,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(
|
||||
@@ -596,7 +601,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'],
|
||||
@@ -607,7 +612,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,
|
||||
@@ -619,18 +624,27 @@ 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'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'
|
||||
)
|
||||
|
||||
# Calculate the percentage delta between the source paths count and the archive paths count, and
|
||||
# compare that delta to the configured count tolerance percentage.
|
||||
count_delta_percentage = abs(len(source_paths) - len(archive_paths)) / len(source_paths) * 100
|
||||
|
||||
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(set(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) - set(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"]}%)'
|
||||
@@ -644,25 +658,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'
|
||||
)
|
||||
|
||||
|
||||
@@ -696,8 +709,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'],
|
||||
@@ -750,9 +762,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'),
|
||||
|
||||
@@ -41,11 +41,10 @@ def get_config_paths(archive_name, bootstrap_arguments, global_arguments, local_
|
||||
config = make_bootstrap_config(bootstrap_arguments)
|
||||
|
||||
# Probe for the manifest file in multiple locations, as the default location has moved to the
|
||||
# borgmatic runtime directory (which get stored as just "/borgmatic" with Borg 1.4+). But we
|
||||
# borgmatic runtime directory (which gets stored as just "/borgmatic" with Borg 1.4+). But we
|
||||
# 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',
|
||||
|
||||
+206
-103
@@ -6,6 +6,7 @@ import pathlib
|
||||
|
||||
import borgmatic.actions.json
|
||||
import borgmatic.borg.create
|
||||
import borgmatic.borg.pattern
|
||||
import borgmatic.config.paths
|
||||
import borgmatic.config.validate
|
||||
import borgmatic.hooks.command
|
||||
@@ -14,132 +15,239 @@ import borgmatic.hooks.dispatch
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def parse_pattern(pattern_line, default_style=borgmatic.borg.pattern.Pattern_style.NONE):
|
||||
'''
|
||||
Given a Borg pattern as a string, parse it into a borgmatic.borg.pattern.Pattern instance and
|
||||
return it.
|
||||
'''
|
||||
try:
|
||||
(pattern_type, remainder) = pattern_line.split(' ', maxsplit=1)
|
||||
except ValueError:
|
||||
raise ValueError(f'Invalid pattern: {pattern_line}')
|
||||
|
||||
try:
|
||||
(parsed_pattern_style, path) = remainder.split(':', maxsplit=1)
|
||||
pattern_style = borgmatic.borg.pattern.Pattern_style(parsed_pattern_style)
|
||||
except ValueError:
|
||||
pattern_style = default_style
|
||||
path = remainder
|
||||
|
||||
return borgmatic.borg.pattern.Pattern(
|
||||
path,
|
||||
borgmatic.borg.pattern.Pattern_type(pattern_type),
|
||||
borgmatic.borg.pattern.Pattern_style(pattern_style),
|
||||
)
|
||||
|
||||
|
||||
def collect_patterns(config):
|
||||
'''
|
||||
Given a configuration dict, produce a single sequence of patterns comprised of the configured
|
||||
source directories, patterns, excludes, pattern files, and exclude files.
|
||||
|
||||
The idea is that Borg has all these different ways of specifying includes, excludes, source
|
||||
directories, etc., but we'd like to collapse them all down to one common format (patterns) for
|
||||
ease of manipulation within borgmatic.
|
||||
'''
|
||||
try:
|
||||
return (
|
||||
tuple(
|
||||
borgmatic.borg.pattern.Pattern(source_directory)
|
||||
for source_directory in config.get('source_directories', ())
|
||||
)
|
||||
+ tuple(
|
||||
parse_pattern(pattern_line.strip())
|
||||
for pattern_line in config.get('patterns', ())
|
||||
if not pattern_line.lstrip().startswith('#')
|
||||
if pattern_line.strip()
|
||||
)
|
||||
+ tuple(
|
||||
parse_pattern(
|
||||
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', ())
|
||||
)
|
||||
+ tuple(
|
||||
parse_pattern(pattern_line.strip())
|
||||
for filename in config.get('patterns_from', ())
|
||||
for pattern_line in open(filename).readlines()
|
||||
if not pattern_line.lstrip().startswith('#')
|
||||
if pattern_line.strip()
|
||||
)
|
||||
+ tuple(
|
||||
parse_pattern(
|
||||
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', ())
|
||||
for exclude_line in open(filename).readlines()
|
||||
if not exclude_line.lstrip().startswith('#')
|
||||
if exclude_line.strip()
|
||||
)
|
||||
)
|
||||
except (FileNotFoundError, OSError) as error:
|
||||
logger.debug(error)
|
||||
|
||||
raise ValueError(f'Cannot read patterns_from/exclude_from file: {error.filename}')
|
||||
|
||||
|
||||
def expand_directory(directory, working_directory):
|
||||
'''
|
||||
Given a directory path, expand any tilde (representing a user's home directory) and any globs
|
||||
therein. Return a list of one or more resulting paths.
|
||||
'''
|
||||
expanded_directory = os.path.join(working_directory or '', os.path.expanduser(directory))
|
||||
|
||||
return glob.glob(expanded_directory) or [expanded_directory]
|
||||
Take into account the given working directory so that relative paths are supported.
|
||||
'''
|
||||
expanded_directory = os.path.expanduser(directory)
|
||||
|
||||
# This would be a lot easier to do with glob(..., root_dir=working_directory), but root_dir is
|
||||
# only available in Python 3.10+.
|
||||
normalized_directory = os.path.join(working_directory or '', expanded_directory)
|
||||
glob_paths = glob.glob(normalized_directory)
|
||||
|
||||
if not glob_paths:
|
||||
return [expanded_directory]
|
||||
|
||||
working_directory_prefix = os.path.join(working_directory or '', '')
|
||||
|
||||
return [
|
||||
(
|
||||
glob_path
|
||||
# If these are equal, that means we didn't add any working directory prefix above.
|
||||
if normalized_directory == expanded_directory
|
||||
# Remove the working directory prefix that we added above in order to make glob() work.
|
||||
# We can't use os.path.relpath() here because it collapses any use of Borg's slashdot
|
||||
# hack.
|
||||
else glob_path.removeprefix(working_directory_prefix)
|
||||
)
|
||||
for glob_path in glob_paths
|
||||
]
|
||||
|
||||
|
||||
def expand_directories(directories, working_directory=None):
|
||||
def expand_patterns(patterns, working_directory=None, skip_paths=None):
|
||||
'''
|
||||
Given a sequence of directory paths and an optional working directory, expand tildes and globs
|
||||
in each one. Return all the resulting directories as a single flattened tuple.
|
||||
Given a sequence of borgmatic.borg.pattern.Pattern instances and an optional working directory,
|
||||
expand tildes and globs in each root pattern. Return all the resulting patterns (not just the
|
||||
root patterns) as a tuple.
|
||||
|
||||
If a set of paths are given to skip, then don't expand any patterns matching them.
|
||||
'''
|
||||
if directories is None:
|
||||
if patterns is None:
|
||||
return ()
|
||||
|
||||
return tuple(
|
||||
itertools.chain.from_iterable(
|
||||
expand_directory(directory, working_directory) for directory in directories
|
||||
(
|
||||
(
|
||||
borgmatic.borg.pattern.Pattern(
|
||||
expanded_path,
|
||||
pattern.type,
|
||||
pattern.style,
|
||||
pattern.device,
|
||||
)
|
||||
for expanded_path in expand_directory(pattern.path, working_directory)
|
||||
)
|
||||
if pattern.type == borgmatic.borg.pattern.Pattern_type.ROOT
|
||||
and pattern.path not in (skip_paths or ())
|
||||
else (pattern,)
|
||||
)
|
||||
for pattern in patterns
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def map_directories_to_devices(directories, working_directory=None):
|
||||
def device_map_patterns(patterns, working_directory=None):
|
||||
'''
|
||||
Given a sequence of directories and an optional working directory, return a map from directory
|
||||
to an identifier for the device on which that directory resides or None if the path doesn't
|
||||
exist.
|
||||
Given a sequence of borgmatic.borg.pattern.Pattern instances and an optional working directory,
|
||||
determine the identifier for the device on which the pattern's path resides—or None if the path
|
||||
doesn't exist or is from a non-root pattern. Return an updated sequence of patterns with the
|
||||
device field populated. But if the device field is already set, don't bother setting it again.
|
||||
|
||||
This is handy for determining whether two different directories are on the same filesystem (have
|
||||
the same device identifier).
|
||||
This is handy for determining whether two different pattern paths are on the same filesystem
|
||||
(have the same device identifier).
|
||||
'''
|
||||
return {
|
||||
directory: os.stat(full_directory).st_dev if os.path.exists(full_directory) else None
|
||||
for directory in directories
|
||||
for full_directory in (os.path.join(working_directory or '', directory),)
|
||||
}
|
||||
return tuple(
|
||||
borgmatic.borg.pattern.Pattern(
|
||||
pattern.path,
|
||||
pattern.type,
|
||||
pattern.style,
|
||||
device=pattern.device
|
||||
or (
|
||||
os.stat(full_path).st_dev
|
||||
if pattern.type == borgmatic.borg.pattern.Pattern_type.ROOT
|
||||
and os.path.exists(full_path)
|
||||
else None
|
||||
),
|
||||
)
|
||||
for pattern in patterns
|
||||
for full_path in (os.path.join(working_directory or '', pattern.path),)
|
||||
)
|
||||
|
||||
|
||||
def deduplicate_directories(directory_devices, additional_directory_devices):
|
||||
def deduplicate_patterns(patterns):
|
||||
'''
|
||||
Given a map from directory to the identifier for the device on which that directory resides,
|
||||
return the directories as a sorted sequence with all duplicate child directories removed. For
|
||||
instance, if paths is ['/foo', '/foo/bar'], return just: ['/foo']
|
||||
Given a sequence of borgmatic.borg.pattern.Pattern instances, return them with all duplicate
|
||||
root child patterns removed. For instance, if two root patterns are given with paths "/foo" and
|
||||
"/foo/bar", return just the one with "/foo". Non-root patterns are passed through without
|
||||
modification.
|
||||
|
||||
The one exception to this rule is if two paths are on different filesystems (devices). In that
|
||||
case, they won't get de-duplicated in case they both need to be passed to Borg (e.g. the
|
||||
location.one_file_system option is true).
|
||||
The one exception to deduplication is two paths are on different filesystems (devices). In that
|
||||
case, they won't get deduplicated, in case they both need to be passed to Borg (e.g. the
|
||||
one_file_system option is true).
|
||||
|
||||
The idea is that if Borg is given a parent directory, then it doesn't also need to be given
|
||||
child directories, because it will naturally spider the contents of the parent directory. And
|
||||
The idea is that if Borg is given a root parent pattern, then it doesn't also need to be given
|
||||
child patterns, because it will naturally spider the contents of the parent pattern's path. And
|
||||
there are cases where Borg coming across the same file twice will result in duplicate reads and
|
||||
even hangs, e.g. when a database hook is using a named pipe for streaming database dumps to
|
||||
Borg.
|
||||
|
||||
If any additional directory devices are given, also deduplicate against them, but don't include
|
||||
them in the returned directories.
|
||||
'''
|
||||
deduplicated = set()
|
||||
directories = sorted(directory_devices.keys())
|
||||
additional_directories = sorted(additional_directory_devices.keys())
|
||||
all_devices = {**directory_devices, **additional_directory_devices}
|
||||
deduplicated = {} # Use just the keys as an ordered set.
|
||||
|
||||
for directory in directories:
|
||||
deduplicated.add(directory)
|
||||
parents = pathlib.PurePath(directory).parents
|
||||
for pattern in patterns:
|
||||
if pattern.type != borgmatic.borg.pattern.Pattern_type.ROOT:
|
||||
deduplicated[pattern] = True
|
||||
continue
|
||||
|
||||
# If another directory in the given list (or the additional list) is a parent of current
|
||||
# directory (even n levels up) and both are on the same filesystem, then the current
|
||||
# directory is a duplicate.
|
||||
for other_directory in directories + additional_directories:
|
||||
for parent in parents:
|
||||
if (
|
||||
pathlib.PurePath(other_directory) == parent
|
||||
and all_devices[directory] is not None
|
||||
and all_devices[other_directory] == all_devices[directory]
|
||||
):
|
||||
if directory in deduplicated:
|
||||
deduplicated.remove(directory)
|
||||
break
|
||||
parents = pathlib.PurePath(pattern.path).parents
|
||||
|
||||
return sorted(deduplicated)
|
||||
# If another directory in the given list is a parent of current directory (even n levels up)
|
||||
# and both are on the same filesystem, then the current directory is a duplicate.
|
||||
for other_pattern in patterns:
|
||||
if other_pattern.type != borgmatic.borg.pattern.Pattern_type.ROOT:
|
||||
continue
|
||||
|
||||
if any(
|
||||
pathlib.PurePath(other_pattern.path) == parent
|
||||
and pattern.device is not None
|
||||
and other_pattern.device == pattern.device
|
||||
for parent in parents
|
||||
):
|
||||
break
|
||||
else:
|
||||
deduplicated[pattern] = True
|
||||
|
||||
return tuple(deduplicated.keys())
|
||||
|
||||
|
||||
ROOT_PATTERN_PREFIX = 'R '
|
||||
|
||||
|
||||
def pattern_root_directories(patterns=None):
|
||||
def process_patterns(patterns, working_directory, skip_expand_paths=None):
|
||||
'''
|
||||
Given a sequence of patterns, parse out and return just the root directories.
|
||||
Given a sequence of Borg patterns and a configured working directory, expand and deduplicate any
|
||||
"root" patterns, returning the resulting root and non-root patterns as a list.
|
||||
|
||||
If any paths are given to skip, don't expand them.
|
||||
'''
|
||||
if not patterns:
|
||||
return []
|
||||
skip_paths = set(skip_expand_paths or ())
|
||||
|
||||
return [
|
||||
pattern.split(ROOT_PATTERN_PREFIX, maxsplit=1)[1]
|
||||
for pattern in patterns
|
||||
if pattern.startswith(ROOT_PATTERN_PREFIX)
|
||||
]
|
||||
|
||||
|
||||
def process_source_directories(config, source_directories=None):
|
||||
'''
|
||||
Given a sequence of source directories (either in the source_directories argument or, lacking
|
||||
that, from config), expand and deduplicate the source directories, returning the result.
|
||||
'''
|
||||
working_directory = borgmatic.config.paths.get_working_directory(config)
|
||||
|
||||
if source_directories is None:
|
||||
source_directories = tuple(config.get('source_directories', ()))
|
||||
|
||||
return deduplicate_directories(
|
||||
map_directories_to_devices(
|
||||
expand_directories(
|
||||
tuple(source_directories),
|
||||
working_directory=working_directory,
|
||||
return list(
|
||||
deduplicate_patterns(
|
||||
device_map_patterns(
|
||||
expand_patterns(
|
||||
patterns,
|
||||
working_directory=working_directory,
|
||||
skip_paths=skip_paths,
|
||||
)
|
||||
)
|
||||
),
|
||||
additional_directory_devices=map_directories_to_devices(
|
||||
expand_directories(
|
||||
pattern_root_directories(config.get('patterns')),
|
||||
working_directory=working_directory,
|
||||
)
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -175,43 +283,39 @@ 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,
|
||||
)
|
||||
source_directories = process_source_directories(config)
|
||||
patterns = process_patterns(collect_patterns(config), working_directory)
|
||||
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,
|
||||
source_directories,
|
||||
patterns,
|
||||
global_arguments.dry_run,
|
||||
)
|
||||
|
||||
# Process source directories again in case any data source hooks updated them. Without this
|
||||
# step, we could end up with duplicate paths that cause Borg to hang when it tries to read
|
||||
# from the same named pipe twice.
|
||||
source_directories = process_source_directories(config, source_directories)
|
||||
# Process the patterns again in case any data source hooks updated them. Without this step,
|
||||
# we could end up with duplicate paths that cause Borg to hang when it tries to read from
|
||||
# the same named pipe twice.
|
||||
patterns = process_patterns(patterns, working_directory, skip_expand_paths=config_paths)
|
||||
stream_processes = [process for processes in active_dumps.values() for process in processes]
|
||||
|
||||
json_output = borgmatic.borg.create.create_archive(
|
||||
global_arguments.dry_run,
|
||||
repository['path'],
|
||||
config,
|
||||
source_directories,
|
||||
patterns,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
borgmatic_runtime_directory,
|
||||
@@ -230,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'],
|
||||
|
||||
@@ -27,15 +27,22 @@ Dump = collections.namedtuple(
|
||||
)
|
||||
|
||||
|
||||
def dumps_match(first, second):
|
||||
def dumps_match(first, second, default_port=None):
|
||||
'''
|
||||
Compare two Dump instances for equality while supporting a field value of UNSPECIFIED, which
|
||||
indicates that the field should match any value.
|
||||
indicates that the field should match any value. If a default port is given, then consider any
|
||||
dump having that port to match with a dump having a None port.
|
||||
'''
|
||||
for field_name in first._fields:
|
||||
first_value = getattr(first, field_name)
|
||||
second_value = getattr(second, field_name)
|
||||
|
||||
if default_port is not None and field_name == 'port':
|
||||
if first_value == default_port and second_value is None:
|
||||
continue
|
||||
if second_value == default_port and first_value is None:
|
||||
continue
|
||||
|
||||
if first_value == UNSPECIFIED or second_value == UNSPECIFIED:
|
||||
continue
|
||||
|
||||
@@ -50,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:
|
||||
@@ -78,8 +85,15 @@ def get_configured_data_source(config, restore_dump):
|
||||
|
||||
matching_dumps = tuple(
|
||||
hook_data_source
|
||||
for (hook_name, hook) in hooks_to_search.items()
|
||||
for hook_data_source in hook
|
||||
for (hook_name, hook_config) in hooks_to_search.items()
|
||||
for hook_data_source in hook_config
|
||||
for default_port in (
|
||||
borgmatic.hooks.dispatch.call_hook(
|
||||
function_name='get_default_port',
|
||||
config=config,
|
||||
hook_name=hook_name,
|
||||
),
|
||||
)
|
||||
if dumps_match(
|
||||
Dump(
|
||||
hook_name,
|
||||
@@ -88,6 +102,7 @@ def get_configured_data_source(config, restore_dump):
|
||||
hook_data_source.get('port'),
|
||||
),
|
||||
restore_dump,
|
||||
default_port,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -157,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'],
|
||||
@@ -211,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,
|
||||
@@ -303,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
|
||||
@@ -332,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,
|
||||
@@ -428,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,
|
||||
@@ -513,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'],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
+97
-133
@@ -6,6 +6,7 @@ import stat
|
||||
import tempfile
|
||||
import textwrap
|
||||
|
||||
import borgmatic.borg.pattern
|
||||
import borgmatic.config.paths
|
||||
import borgmatic.logger
|
||||
from borgmatic.borg import environment, feature, flags
|
||||
@@ -19,81 +20,42 @@ from borgmatic.execute import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def expand_home_directories(directories):
|
||||
def write_patterns_file(patterns, borgmatic_runtime_directory, patterns_file=None):
|
||||
'''
|
||||
Given a sequence of directory paths, expand tildes in each one. Do not perform any globbing.
|
||||
Return the results as a tuple.
|
||||
'''
|
||||
if directories is None:
|
||||
return ()
|
||||
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.
|
||||
|
||||
return tuple(os.path.expanduser(directory) for directory in directories)
|
||||
|
||||
|
||||
def write_pattern_file(patterns=None, sources=None, pattern_file=None):
|
||||
'''
|
||||
Given a sequence of patterns and an optional sequence of source directories, write them to a
|
||||
named temporary file (with the source directories as additional roots) and return the file.
|
||||
If an optional open pattern file is given, overwrite it instead of making a new temporary file.
|
||||
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.
|
||||
'''
|
||||
if not patterns and not sources:
|
||||
if not patterns:
|
||||
return None
|
||||
|
||||
if pattern_file is None:
|
||||
pattern_file = tempfile.NamedTemporaryFile('w')
|
||||
if patterns_file is None:
|
||||
patterns_file = tempfile.NamedTemporaryFile('w', dir=borgmatic_runtime_directory)
|
||||
operation_name = 'Writing'
|
||||
else:
|
||||
pattern_file.seek(0)
|
||||
patterns_file.write('\n')
|
||||
operation_name = 'Appending'
|
||||
|
||||
pattern_file.write(
|
||||
'\n'.join(tuple(patterns or ()) + tuple(f'R {source}' for source in (sources or [])))
|
||||
patterns_output = '\n'.join(
|
||||
f'{pattern.type.value} {pattern.style.value}{":" if pattern.style.value else ""}{pattern.path}'
|
||||
for pattern in patterns
|
||||
)
|
||||
pattern_file.flush()
|
||||
logger.debug(f'{operation_name} patterns to {patterns_file.name}:\n{patterns_output}')
|
||||
|
||||
return pattern_file
|
||||
patterns_file.write(patterns_output)
|
||||
patterns_file.flush()
|
||||
|
||||
return patterns_file
|
||||
|
||||
|
||||
def ensure_files_readable(*filename_lists):
|
||||
def make_exclude_flags(config):
|
||||
'''
|
||||
Given a sequence of filename sequences, ensure that each filename is openable. This prevents
|
||||
unreadable files from being passed to Borg, which in certain situations only warns instead of
|
||||
erroring.
|
||||
Given a configuration dict with various exclude options, return the corresponding Borg flags as
|
||||
a tuple.
|
||||
'''
|
||||
for file_object in itertools.chain.from_iterable(
|
||||
filename_list for filename_list in filename_lists if filename_list
|
||||
):
|
||||
open(file_object).close()
|
||||
|
||||
|
||||
def make_pattern_flags(config, pattern_filename=None):
|
||||
'''
|
||||
Given a configuration dict with a potential patterns_from option, and a filename containing any
|
||||
additional patterns, return the corresponding Borg flags for those files as a tuple.
|
||||
'''
|
||||
pattern_filenames = tuple(config.get('patterns_from') or ()) + (
|
||||
(pattern_filename,) if pattern_filename else ()
|
||||
)
|
||||
|
||||
return tuple(
|
||||
itertools.chain.from_iterable(
|
||||
('--patterns-from', pattern_filename) for pattern_filename in pattern_filenames
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def make_exclude_flags(config, exclude_filename=None):
|
||||
'''
|
||||
Given a configuration dict with various exclude options, and a filename containing any exclude
|
||||
patterns, return the corresponding Borg flags as a tuple.
|
||||
'''
|
||||
exclude_filenames = tuple(config.get('exclude_from') or ()) + (
|
||||
(exclude_filename,) if exclude_filename else ()
|
||||
)
|
||||
exclude_from_flags = tuple(
|
||||
itertools.chain.from_iterable(
|
||||
('--exclude-from', exclude_filename) for exclude_filename in exclude_filenames
|
||||
)
|
||||
)
|
||||
caches_flag = ('--exclude-caches',) if config.get('exclude_caches') else ()
|
||||
if_present_flags = tuple(
|
||||
itertools.chain.from_iterable(
|
||||
@@ -104,13 +66,7 @@ def make_exclude_flags(config, exclude_filename=None):
|
||||
keep_exclude_tags_flags = ('--keep-exclude-tags',) if config.get('keep_exclude_tags') else ()
|
||||
exclude_nodump_flags = ('--exclude-nodump',) if config.get('exclude_nodump') else ()
|
||||
|
||||
return (
|
||||
exclude_from_flags
|
||||
+ caches_flag
|
||||
+ if_present_flags
|
||||
+ keep_exclude_tags_flags
|
||||
+ exclude_nodump_flags
|
||||
)
|
||||
return caches_flag + if_present_flags + keep_exclude_tags_flags + exclude_nodump_flags
|
||||
|
||||
|
||||
def make_list_filter_flags(local_borg_version, dry_run):
|
||||
@@ -134,13 +90,14 @@ def make_list_filter_flags(local_borg_version, dry_run):
|
||||
return f'{base_flags}-'
|
||||
|
||||
|
||||
def special_file(path):
|
||||
def special_file(path, working_directory=None):
|
||||
'''
|
||||
Return whether the given path is a special file (character device, block device, or named pipe
|
||||
/ FIFO).
|
||||
/ FIFO). If a working directory is given, take it into account when making the full path to
|
||||
check.
|
||||
'''
|
||||
try:
|
||||
mode = os.stat(path).st_mode
|
||||
mode = os.stat(os.path.join(working_directory or '', path)).st_mode
|
||||
except (FileNotFoundError, OSError):
|
||||
return False
|
||||
|
||||
@@ -160,6 +117,7 @@ def any_parent_directories(path, candidate_parents):
|
||||
|
||||
|
||||
def collect_special_file_paths(
|
||||
dry_run,
|
||||
create_command,
|
||||
config,
|
||||
local_path,
|
||||
@@ -168,11 +126,11 @@ def collect_special_file_paths(
|
||||
borgmatic_runtime_directory,
|
||||
):
|
||||
'''
|
||||
Given 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.
|
||||
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.
|
||||
|
||||
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
|
||||
@@ -203,26 +161,32 @@ def collect_special_file_paths(
|
||||
path for path in paths if any_parent_directories(path, (borgmatic_runtime_directory,))
|
||||
}
|
||||
|
||||
if not skip_paths:
|
||||
if not skip_paths and not dry_run:
|
||||
raise ValueError(
|
||||
f'The runtime directory {os.path.normpath(borgmatic_runtime_directory)} overlaps with the configured excludes. Please remove it from excludes or change the runtime directory.'
|
||||
f'The runtime directory {os.path.normpath(borgmatic_runtime_directory)} overlaps with the configured excludes or patterns with excludes. Please ensure the runtime directory is not excluded.'
|
||||
)
|
||||
|
||||
return tuple(path for path in paths if special_file(path) if path not in skip_paths)
|
||||
return tuple(
|
||||
path for path in paths if special_file(path, working_directory) if path not in skip_paths
|
||||
)
|
||||
|
||||
|
||||
def check_all_source_directories_exist(source_directories):
|
||||
def check_all_root_patterns_exist(patterns):
|
||||
'''
|
||||
Given a sequence of source directories, check that the source directories all exist. If any do
|
||||
not, raise an exception.
|
||||
Given a sequence of borgmatic.borg.pattern.Pattern instances, check that all root pattern
|
||||
paths exist. If any don't, raise an exception.
|
||||
'''
|
||||
missing_directories = [
|
||||
source_directory
|
||||
for source_directory in source_directories
|
||||
if not os.path.exists(source_directory)
|
||||
missing_paths = [
|
||||
pattern.path
|
||||
for pattern in patterns
|
||||
if pattern.type == borgmatic.borg.pattern.Pattern_type.ROOT
|
||||
if not os.path.exists(pattern.path)
|
||||
]
|
||||
if missing_directories:
|
||||
raise ValueError(f"Source directories do not exist: {', '.join(missing_directories)}")
|
||||
|
||||
if missing_paths:
|
||||
raise ValueError(
|
||||
f"Source directories / root pattern paths do not exist: {', '.join(missing_paths)}"
|
||||
)
|
||||
|
||||
|
||||
MAX_SPECIAL_FILE_PATHS_LENGTH = 1000
|
||||
@@ -232,7 +196,7 @@ def make_base_create_command(
|
||||
dry_run,
|
||||
repository_path,
|
||||
config,
|
||||
source_directories,
|
||||
patterns,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
borgmatic_runtime_directory,
|
||||
@@ -244,23 +208,16 @@ def make_base_create_command(
|
||||
stream_processes=None,
|
||||
):
|
||||
'''
|
||||
Given vebosity/dry-run flags, a local or remote repository path, a configuration dict, a
|
||||
sequence of loaded configuration paths, the local Borg version, global arguments as an
|
||||
argparse.Namespace instance, and a sequence of borgmatic source directories, return a tuple of
|
||||
(base Borg create command flags, Borg create command positional arguments, open pattern file
|
||||
handle, open exclude file handle).
|
||||
Given verbosity/dry-run flags, a local or remote repository path, a configuration dict, a
|
||||
sequence of patterns as borgmatic.borg.pattern.Pattern instances, the local Borg version,
|
||||
global arguments as an argparse.Namespace instance, and a sequence of borgmatic source
|
||||
directories, return a tuple of (base Borg create command flags, Borg create command positional
|
||||
arguments, open pattern file handle).
|
||||
'''
|
||||
if config.get('source_directories_must_exist', False):
|
||||
check_all_source_directories_exist(source_directories)
|
||||
check_all_root_patterns_exist(patterns)
|
||||
|
||||
ensure_files_readable(config.get('patterns_from'), config.get('exclude_from'))
|
||||
|
||||
pattern_file = (
|
||||
write_pattern_file(config.get('patterns'), source_directories)
|
||||
if config.get('patterns') or config.get('patterns_from')
|
||||
else None
|
||||
)
|
||||
exclude_file = write_pattern_file(expand_home_directories(config.get('exclude_patterns')))
|
||||
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)
|
||||
@@ -303,8 +260,8 @@ def make_base_create_command(
|
||||
create_flags = (
|
||||
tuple(local_path.split(' '))
|
||||
+ ('create',)
|
||||
+ make_pattern_flags(config, pattern_file.name if pattern_file else None)
|
||||
+ make_exclude_flags(config, exclude_file.name if exclude_file else None)
|
||||
+ (('--patterns-from', patterns_file.name) if patterns_file else ())
|
||||
+ make_exclude_flags(config)
|
||||
+ (('--checkpoint-interval', str(checkpoint_interval)) if checkpoint_interval else ())
|
||||
+ (('--checkpoint-volume', str(checkpoint_volume)) if checkpoint_volume else ())
|
||||
+ (('--chunker-params', chunker_params) if chunker_params else ())
|
||||
@@ -334,19 +291,20 @@ def make_base_create_command(
|
||||
|
||||
create_positional_arguments = flags.make_repository_archive_flags(
|
||||
repository_path, archive_name_format, local_borg_version
|
||||
) + (tuple(source_directories) if not pattern_file else ())
|
||||
)
|
||||
|
||||
# If database hooks are enabled (as indicated by streaming processes), exclude files that might
|
||||
# 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,
|
||||
@@ -362,24 +320,32 @@ 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}'
|
||||
)
|
||||
exclude_file = write_pattern_file(
|
||||
expand_home_directories(
|
||||
tuple(config.get('exclude_patterns') or ()) + special_file_paths
|
||||
patterns_file = write_patterns_file(
|
||||
tuple(
|
||||
borgmatic.borg.pattern.Pattern(
|
||||
special_file_path,
|
||||
borgmatic.borg.pattern.Pattern_type.NO_RECURSE,
|
||||
borgmatic.borg.pattern.Pattern_style.FNMATCH,
|
||||
)
|
||||
for special_file_path in special_file_paths
|
||||
),
|
||||
pattern_file=exclude_file,
|
||||
borgmatic_runtime_directory,
|
||||
patterns_file=patterns_file,
|
||||
)
|
||||
create_flags += make_exclude_flags(config, exclude_file.name)
|
||||
|
||||
return (create_flags, create_positional_arguments, pattern_file, exclude_file)
|
||||
if '--patterns-from' not in create_flags:
|
||||
create_flags += ('--patterns-from', patterns_file.name)
|
||||
|
||||
return (create_flags, create_positional_arguments, patterns_file)
|
||||
|
||||
|
||||
def create_archive(
|
||||
dry_run,
|
||||
repository_path,
|
||||
config,
|
||||
source_directories,
|
||||
patterns,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
borgmatic_runtime_directory,
|
||||
@@ -392,7 +358,7 @@ def create_archive(
|
||||
stream_processes=None,
|
||||
):
|
||||
'''
|
||||
Given vebosity/dry-run flags, a local or remote repository path, a configuration dict, a
|
||||
Given verbosity/dry-run flags, a local or remote repository path, a configuration dict, a
|
||||
sequence of loaded configuration paths, the local Borg version, and global arguments as an
|
||||
argparse.Namespace instance, create a Borg archive and return Borg's JSON output (if any).
|
||||
|
||||
@@ -403,22 +369,20 @@ def create_archive(
|
||||
|
||||
working_directory = borgmatic.config.paths.get_working_directory(config)
|
||||
|
||||
(create_flags, create_positional_arguments, pattern_file, exclude_file) = (
|
||||
make_base_create_command(
|
||||
dry_run,
|
||||
repository_path,
|
||||
config,
|
||||
source_directories,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
borgmatic_runtime_directory,
|
||||
local_path,
|
||||
remote_path,
|
||||
progress,
|
||||
json,
|
||||
list_files,
|
||||
stream_processes,
|
||||
)
|
||||
(create_flags, create_positional_arguments, patterns_file) = make_base_create_command(
|
||||
dry_run,
|
||||
repository_path,
|
||||
config,
|
||||
patterns,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
borgmatic_runtime_directory,
|
||||
local_path,
|
||||
remote_path,
|
||||
progress,
|
||||
json,
|
||||
list_files,
|
||||
stream_processes,
|
||||
)
|
||||
|
||||
if json:
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import os
|
||||
|
||||
import borgmatic.borg.passcommand
|
||||
|
||||
OPTION_TO_ENVIRONMENT_VARIABLE = {
|
||||
'borg_base_directory': 'BORG_BASE_DIR',
|
||||
'borg_config_directory': 'BORG_CONFIG_DIR',
|
||||
@@ -7,7 +9,6 @@ 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',
|
||||
@@ -36,6 +37,19 @@ def make_environment(config):
|
||||
if value:
|
||||
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(
|
||||
|
||||
@@ -120,7 +120,7 @@ def capture_archive_listing(
|
||||
paths=[path for path in list_paths] if list_paths else None,
|
||||
find_paths=None,
|
||||
json=None,
|
||||
format=path_format or '{path}{NL}', # noqa: FS003
|
||||
format=path_format or '{path}{NUL}', # noqa: FS003
|
||||
),
|
||||
global_arguments,
|
||||
local_path,
|
||||
@@ -131,8 +131,8 @@ def capture_archive_listing(
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=config.get('borg_exit_codes'),
|
||||
)
|
||||
.strip('\n')
|
||||
.split('\n')
|
||||
.strip('\0')
|
||||
.split('\0')
|
||||
)
|
||||
|
||||
|
||||
@@ -230,14 +230,14 @@ def list_archive(
|
||||
borg_exit_codes=borg_exit_codes,
|
||||
)
|
||||
.strip('\n')
|
||||
.split('\n')
|
||||
.splitlines()
|
||||
)
|
||||
else:
|
||||
archive_lines = (list_arguments.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
|
||||
|
||||
@@ -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), working_directory)
|
||||
@@ -0,0 +1,31 @@
|
||||
import collections
|
||||
import enum
|
||||
|
||||
|
||||
# See https://borgbackup.readthedocs.io/en/stable/usage/help.html#borg-help-patterns
|
||||
class Pattern_type(enum.Enum):
|
||||
ROOT = 'R' # A ROOT pattern always has a NONE pattern style.
|
||||
PATTERN_STYLE = 'P'
|
||||
EXCLUDE = '-'
|
||||
NO_RECURSE = '!'
|
||||
INCLUDE = '+'
|
||||
|
||||
|
||||
class Pattern_style(enum.Enum):
|
||||
NONE = ''
|
||||
FNMATCH = 'fm'
|
||||
SHELL = 'sh'
|
||||
REGULAR_EXPRESSION = 're'
|
||||
PATH_PREFIX = 'pp'
|
||||
PATH_FULL_MATCH = 'pf'
|
||||
|
||||
|
||||
Pattern = collections.namedtuple(
|
||||
'Pattern',
|
||||
('path', 'type', 'style', 'device'),
|
||||
defaults=(
|
||||
Pattern_type.ROOT,
|
||||
Pattern_style.NONE,
|
||||
None,
|
||||
),
|
||||
)
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -39,7 +39,13 @@ 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_logging,
|
||||
should_do_markup,
|
||||
)
|
||||
from borgmatic.signals import configure_signals
|
||||
from borgmatic.verbosity import verbosity_to_log_level
|
||||
|
||||
@@ -86,12 +92,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 +107,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 +116,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 +138,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 +188,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 +205,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 +214,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 +240,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 +249,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)
|
||||
@@ -819,23 +819,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}")
|
||||
|
||||
@@ -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):
|
||||
'''
|
||||
|
||||
@@ -131,8 +131,7 @@ properties:
|
||||
Any paths matching these patterns are included/excluded from
|
||||
backups. Globs are expanded. (Tildes are not.) See the output of
|
||||
"borg help patterns" for more details. Quote any value if it
|
||||
contains leading punctuation, so it parses correctly. Note that only
|
||||
one of "patterns" and "source_directories" may be used.
|
||||
contains leading punctuation, so it parses correctly.
|
||||
example:
|
||||
- 'R /'
|
||||
- '- /home/*/.cache'
|
||||
@@ -144,9 +143,8 @@ properties:
|
||||
type: string
|
||||
description: |
|
||||
Read include/exclude patterns from one or more separate named files,
|
||||
one pattern per line. Note that Borg considers this option
|
||||
experimental. See the output of "borg help patterns" for more
|
||||
details.
|
||||
one pattern per line. See the output of "borg help patterns" for
|
||||
more details.
|
||||
example:
|
||||
- /etc/borgmatic/patterns
|
||||
exclude_patterns:
|
||||
@@ -207,8 +205,8 @@ properties:
|
||||
description: |
|
||||
Deprecated. Only used for locating database dumps and bootstrap
|
||||
metadata within backup archives created prior to deprecation.
|
||||
Replaced by borgmatic_runtime_directory and
|
||||
borgmatic_state_directory. Defaults to ~/.borgmatic
|
||||
Replaced by user_runtime_directory and user_state_directory.
|
||||
Defaults to ~/.borgmatic
|
||||
example: /tmp/borgmatic
|
||||
user_runtime_directory:
|
||||
type: string
|
||||
@@ -230,8 +228,8 @@ properties:
|
||||
source_directories_must_exist:
|
||||
type: boolean
|
||||
description: |
|
||||
If true, then source directories must exist, otherwise an error is
|
||||
raised. Defaults to false.
|
||||
If true, then source directories (and root pattern paths) must
|
||||
exist. If they don't, an error is raised. Defaults to false.
|
||||
example: true
|
||||
encryption_passcommand:
|
||||
type: string
|
||||
@@ -2261,7 +2259,45 @@ properties:
|
||||
can send the logs to a self-hosted instance or create an account at
|
||||
https://grafana.com/auth/sign-up/create-user. See borgmatic
|
||||
monitoring documentation for details.
|
||||
|
||||
sentry:
|
||||
type: object
|
||||
required: ['data_source_name_url', 'monitor_slug']
|
||||
additionalProperties: false
|
||||
properties:
|
||||
data_source_name_url:
|
||||
type: string
|
||||
description: |
|
||||
Sentry Data Source Name (DSN) URL, associated with a
|
||||
particular Sentry project. Used to construct a cron URL,
|
||||
notified when a backup begins, ends, or errors.
|
||||
example: https://5f80ec@o294220.ingest.us.sentry.io/203069
|
||||
monitor_slug:
|
||||
type: string
|
||||
description: |
|
||||
Sentry monitor slug, associated with a particular Sentry
|
||||
project monitor. Used along with the data source name URL to
|
||||
construct a cron URL.
|
||||
example: mymonitor
|
||||
states:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- start
|
||||
- finish
|
||||
- fail
|
||||
uniqueItems: true
|
||||
description: |
|
||||
List of one or more monitoring states to ping for: "start",
|
||||
"finish", and/or "fail". Defaults to pinging for all states.
|
||||
example:
|
||||
- start
|
||||
- finish
|
||||
description: |
|
||||
Configuration for a monitoring integration with Sentry. You can use
|
||||
a self-hosted instance via https://develop.sentry.dev/self-hosted/
|
||||
or create a cloud-hosted account at https://sentry.io. See borgmatic
|
||||
monitoring documentation for details.
|
||||
zfs:
|
||||
type: ["object", "null"]
|
||||
additionalProperties: false
|
||||
|
||||
+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
|
||||
|
||||
|
||||
@@ -4,12 +4,13 @@ import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
import borgmatic.borg.pattern
|
||||
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.)
|
||||
'''
|
||||
@@ -19,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,
|
||||
source_directories,
|
||||
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 source directories,
|
||||
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.
|
||||
'''
|
||||
@@ -55,17 +55,19 @@ def dump_data_sources(
|
||||
manifest_file,
|
||||
)
|
||||
|
||||
source_directories.extend(config_paths)
|
||||
source_directories.append(os.path.join(borgmatic_runtime_directory, 'bootstrap'))
|
||||
patterns.extend(borgmatic.borg.pattern.Pattern(config_path) for config_path in config_paths)
|
||||
patterns.append(
|
||||
borgmatic.borg.pattern.Pattern(os.path.join(borgmatic_runtime_directory, 'bootstrap'))
|
||||
)
|
||||
|
||||
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 ''
|
||||
|
||||
@@ -76,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
|
||||
@@ -100,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
|
||||
@@ -112,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,
|
||||
|
||||
@@ -6,6 +6,7 @@ import os
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
import borgmatic.borg.pattern
|
||||
import borgmatic.config.paths
|
||||
import borgmatic.execute
|
||||
import borgmatic.hooks.data_source.snapshot
|
||||
@@ -13,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(' '))
|
||||
@@ -36,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}')
|
||||
@@ -44,69 +45,35 @@ 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):
|
||||
Subvolume = collections.namedtuple('Subvolume', ('path', 'contained_patterns'), defaults=((),))
|
||||
|
||||
|
||||
def get_subvolumes(btrfs_command, findmnt_command, patterns=None):
|
||||
'''
|
||||
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,
|
||||
)
|
||||
)
|
||||
Given a Btrfs command to run and a sequence of configured patterns, find the intersection
|
||||
between the current Btrfs filesystem and subvolume mount points and the paths of any patterns.
|
||||
The idea is that these pattern paths represent the requested subvolumes to snapshot.
|
||||
|
||||
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_source_directories'), defaults=((),)
|
||||
)
|
||||
|
||||
|
||||
def get_subvolumes(btrfs_command, findmnt_command, source_directories=None):
|
||||
'''
|
||||
Given a Btrfs command to run and a sequence of configured source directories, find the
|
||||
intersection between the current Btrfs filesystem and subvolume mount points and the configured
|
||||
borgmatic source directories. The idea is that these are the requested subvolumes to snapshot.
|
||||
|
||||
If the source directories is None, then return all subvolumes, sorted by path.
|
||||
If patterns is None, then return all subvolumes, sorted by path.
|
||||
|
||||
Return the result as a sequence of matching subvolume mount points.
|
||||
'''
|
||||
candidate_source_directories = set(source_directories or ())
|
||||
candidate_patterns = set(patterns or ())
|
||||
subvolumes = []
|
||||
|
||||
# For each filesystem mount point, find its subvolumes and match them again the given source
|
||||
# directories 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 source
|
||||
# directory piñata before their parents do. (Source directories are consumed during this
|
||||
# process, so no two datasets get the same contained source directories.)
|
||||
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_source_directories)
|
||||
for subvolume_path in reversed(
|
||||
get_subvolumes_for_filesystem(btrfs_command, mount_point)
|
||||
)
|
||||
for contained_source_directories in (
|
||||
borgmatic.hooks.data_source.snapshot.get_contained_directories(
|
||||
subvolume_path, candidate_source_directories
|
||||
Subvolume(mount_point, contained_patterns)
|
||||
for contained_patterns in (
|
||||
borgmatic.hooks.data_source.snapshot.get_contained_patterns(
|
||||
mount_point, candidate_patterns
|
||||
),
|
||||
)
|
||||
if source_directories is None or contained_source_directories
|
||||
if patterns is None or contained_patterns
|
||||
)
|
||||
|
||||
return tuple(sorted(subvolumes, key=lambda subvolume: subvolume.path))
|
||||
@@ -115,26 +82,24 @@ def get_subvolumes(btrfs_command, findmnt_command, source_directories=None):
|
||||
BORGMATIC_SNAPSHOT_PREFIX = '.borgmatic-snapshot-'
|
||||
|
||||
|
||||
def make_snapshot_path(subvolume_path): # pragma: no cover
|
||||
def make_snapshot_path(subvolume_path):
|
||||
'''
|
||||
Given the path to a subvolume, make a corresponding snapshot path for it.
|
||||
'''
|
||||
return os.path.join(
|
||||
subvolume_path,
|
||||
f'{BORGMATIC_SNAPSHOT_PREFIX}{os.getpid()}',
|
||||
# Included so that the snapshot ends up in the Borg archive at the "original" subvolume
|
||||
# path.
|
||||
subvolume_path.lstrip(os.path.sep),
|
||||
)
|
||||
# Included so that the snapshot ends up in the Borg archive at the "original" subvolume path.
|
||||
) + subvolume_path.rstrip(os.path.sep)
|
||||
|
||||
|
||||
def make_snapshot_exclude_path(subvolume_path): # pragma: no cover
|
||||
def make_snapshot_exclude_pattern(subvolume_path): # pragma: no cover
|
||||
'''
|
||||
Given the path to a subvolume, make a corresponding exclude path for its embedded snapshot path.
|
||||
This is to work around a quirk of Btrfs: If you make a snapshot path as a child directory of a
|
||||
subvolume, then the snapshot's own initial directory component shows up as an empty directory
|
||||
within the snapshot itself. For instance, if you have a Btrfs subvolume at /mnt and make a
|
||||
snapshot of it at:
|
||||
Given the path to a subvolume, make a corresponding exclude pattern for its embedded snapshot
|
||||
path. This is to work around a quirk of Btrfs: If you make a snapshot path as a child directory
|
||||
of a subvolume, then the snapshot's own initial directory component shows up as an empty
|
||||
directory within the snapshot itself. For instance, if you have a Btrfs subvolume at /mnt and
|
||||
make a snapshot of it at:
|
||||
|
||||
/mnt/.borgmatic-snapshot-1234/mnt
|
||||
|
||||
@@ -142,30 +107,52 @@ def make_snapshot_exclude_path(subvolume_path): # pragma: no cover
|
||||
|
||||
/mnt/.borgmatic-snapshot-1234/mnt/.borgmatic-snapshot-1234
|
||||
|
||||
So to prevent that from ending up in the Borg archive, this function produces its path for
|
||||
exclusion.
|
||||
So to prevent that from ending up in the Borg archive, this function produces an exclude pattern
|
||||
to exclude that path.
|
||||
'''
|
||||
snapshot_directory = f'{BORGMATIC_SNAPSHOT_PREFIX}{os.getpid()}'
|
||||
|
||||
return os.path.join(
|
||||
subvolume_path,
|
||||
snapshot_directory,
|
||||
subvolume_path.lstrip(os.path.sep),
|
||||
snapshot_directory,
|
||||
return borgmatic.borg.pattern.Pattern(
|
||||
os.path.join(
|
||||
subvolume_path,
|
||||
snapshot_directory,
|
||||
subvolume_path.lstrip(os.path.sep),
|
||||
snapshot_directory,
|
||||
),
|
||||
borgmatic.borg.pattern.Pattern_type.NO_RECURSE,
|
||||
borgmatic.borg.pattern.Pattern_style.FNMATCH,
|
||||
)
|
||||
|
||||
|
||||
def make_borg_source_directory_path(subvolume_path, source_directory): # pragma: no cover
|
||||
def make_borg_snapshot_pattern(subvolume_path, pattern):
|
||||
'''
|
||||
Given the path to a subvolume and a source directory inside it, make a corresponding path for
|
||||
the source directory within a snapshot path intended for giving to Borg.
|
||||
Given the path to a subvolume and a pattern as a borgmatic.borg.pattern.Pattern instance whose
|
||||
path is inside the subvolume, return a new Pattern with its path rewritten to be in a snapshot
|
||||
path intended for giving to Borg.
|
||||
|
||||
Move any initial caret in a regular expression pattern path to the beginning, so as not to break
|
||||
the regular expression.
|
||||
'''
|
||||
return os.path.join(
|
||||
initial_caret = (
|
||||
'^'
|
||||
if pattern.style == borgmatic.borg.pattern.Pattern_style.REGULAR_EXPRESSION
|
||||
and pattern.path.startswith('^')
|
||||
else ''
|
||||
)
|
||||
|
||||
rewritten_path = initial_caret + os.path.join(
|
||||
subvolume_path,
|
||||
f'{BORGMATIC_SNAPSHOT_PREFIX}{os.getpid()}',
|
||||
'.', # Borg 1.4+ "slashdot" hack.
|
||||
# Included so that the source directory ends up in the Borg archive at its "original" path.
|
||||
source_directory.lstrip(os.path.sep),
|
||||
pattern.path.lstrip('^').lstrip(os.path.sep),
|
||||
)
|
||||
|
||||
return borgmatic.borg.pattern.Pattern(
|
||||
rewritten_path,
|
||||
pattern.type,
|
||||
pattern.style,
|
||||
pattern.device,
|
||||
)
|
||||
|
||||
|
||||
@@ -181,7 +168,7 @@ def snapshot_subvolume(btrfs_command, subvolume_path, snapshot_path): # pragma:
|
||||
+ (
|
||||
'subvolume',
|
||||
'snapshot',
|
||||
'-r', # Read-only,
|
||||
'-r', # Read-only.
|
||||
subvolume_path,
|
||||
snapshot_path,
|
||||
),
|
||||
@@ -192,38 +179,36 @@ def snapshot_subvolume(btrfs_command, subvolume_path, snapshot_path): # pragma:
|
||||
def dump_data_sources(
|
||||
hook_config,
|
||||
config,
|
||||
log_prefix,
|
||||
config_paths,
|
||||
borgmatic_runtime_directory,
|
||||
source_directories,
|
||||
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 source directories,
|
||||
and whether this is a dry run, auto-detect and snapshot any Btrfs subvolume mount points listed
|
||||
in the given source directories. Also update those source directories, 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 source directories, determine Btrfs subvolumes to backup.
|
||||
# Based on the configured patterns, determine Btrfs subvolumes to backup.
|
||||
btrfs_command = hook_config.get('btrfs_command', 'btrfs')
|
||||
findmnt_command = hook_config.get('findmnt_command', 'findmnt')
|
||||
subvolumes = get_subvolumes(btrfs_command, findmnt_command, source_directories)
|
||||
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 source directories to use their snapshot paths.
|
||||
# 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)
|
||||
|
||||
@@ -232,17 +217,16 @@ def dump_data_sources(
|
||||
|
||||
snapshot_subvolume(btrfs_command, subvolume.path, snapshot_path)
|
||||
|
||||
for source_directory in subvolume.contained_source_directories:
|
||||
for pattern in subvolume.contained_patterns:
|
||||
snapshot_pattern = make_borg_snapshot_pattern(subvolume.path, pattern)
|
||||
|
||||
# Attempt to update the pattern in place, since pattern order matters to Borg.
|
||||
try:
|
||||
source_directories.remove(source_directory)
|
||||
patterns[patterns.index(pattern)] = snapshot_pattern
|
||||
except ValueError:
|
||||
pass
|
||||
patterns.append(snapshot_pattern)
|
||||
|
||||
source_directories.append(
|
||||
make_borg_source_directory_path(subvolume.path, source_directory)
|
||||
)
|
||||
|
||||
config.setdefault('exclude_patterns', []).append(make_snapshot_exclude_path(subvolume.path))
|
||||
patterns.append(make_snapshot_exclude_pattern(subvolume.path))
|
||||
|
||||
return []
|
||||
|
||||
@@ -262,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
|
||||
@@ -280,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
|
||||
@@ -295,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
|
||||
@@ -310,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
|
||||
@@ -322,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".
|
||||
@@ -333,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
|
||||
|
||||
@@ -6,6 +6,7 @@ import os
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
import borgmatic.borg.pattern
|
||||
import borgmatic.config.paths
|
||||
import borgmatic.execute
|
||||
import borgmatic.hooks.data_source.snapshot
|
||||
@@ -13,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.)
|
||||
'''
|
||||
@@ -22,18 +23,17 @@ def use_streaming(hook_config, config, log_prefix): # pragma: no cover
|
||||
|
||||
BORGMATIC_SNAPSHOT_PREFIX = 'borgmatic-'
|
||||
Logical_volume = collections.namedtuple(
|
||||
'Logical_volume', ('name', 'device_path', 'mount_point', 'contained_source_directories')
|
||||
'Logical_volume', ('name', 'device_path', 'mount_point', 'contained_patterns')
|
||||
)
|
||||
|
||||
|
||||
def get_logical_volumes(lsblk_command, source_directories=None):
|
||||
def get_logical_volumes(lsblk_command, patterns=None):
|
||||
'''
|
||||
Given an lsblk command to run and a sequence of configured source directories, find the
|
||||
intersection between the current LVM logical volume mount points and the configured borgmatic
|
||||
source directories. The idea is that these are the requested logical volumes to snapshot.
|
||||
Given an lsblk command to run and a sequence of configured patterns, find the intersection
|
||||
between the current LVM logical volume mount points and the paths of any patterns. The idea is
|
||||
that these pattern paths represent the requested logical volumes to snapshot.
|
||||
|
||||
If source directories is None, include all logical volume mounts points, not just those in
|
||||
source directories.
|
||||
If patterns is None, include all logical volume mounts points, not just those in patterns.
|
||||
|
||||
Return the result as a sequence of Logical_volume instances.
|
||||
'''
|
||||
@@ -53,21 +53,26 @@ def get_logical_volumes(lsblk_command, source_directories=None):
|
||||
except json.JSONDecodeError as error:
|
||||
raise ValueError(f'Invalid {lsblk_command} JSON output: {error}')
|
||||
|
||||
candidate_source_directories = set(source_directories or ())
|
||||
candidate_patterns = set(patterns or ())
|
||||
|
||||
try:
|
||||
# Sort 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 below, so no two
|
||||
# logical volumes end up with the same contained patterns.)
|
||||
return tuple(
|
||||
Logical_volume(
|
||||
device['name'], device['path'], device['mountpoint'], contained_source_directories
|
||||
Logical_volume(device['name'], device['path'], device['mountpoint'], contained_patterns)
|
||||
for device in sorted(
|
||||
devices_info['blockdevices'],
|
||||
key=lambda device: device['mountpoint'] or '',
|
||||
reverse=True,
|
||||
)
|
||||
for device in devices_info['blockdevices']
|
||||
if device['mountpoint'] and device['type'] == 'lvm'
|
||||
for contained_source_directories in (
|
||||
borgmatic.hooks.data_source.snapshot.get_contained_directories(
|
||||
device['mountpoint'], candidate_source_directories
|
||||
for contained_patterns in (
|
||||
borgmatic.hooks.data_source.snapshot.get_contained_patterns(
|
||||
device['mountpoint'], candidate_patterns
|
||||
),
|
||||
)
|
||||
if not source_directories or contained_source_directories
|
||||
if not patterns or contained_patterns
|
||||
)
|
||||
except KeyError as error:
|
||||
raise ValueError(f'Invalid {lsblk_command} output: Missing key "{error}"')
|
||||
@@ -119,48 +124,77 @@ def mount_snapshot(mount_command, snapshot_device, snapshot_mount_path): # prag
|
||||
)
|
||||
|
||||
|
||||
def make_borg_snapshot_pattern(pattern, normalized_runtime_directory):
|
||||
'''
|
||||
Given a Borg pattern as a borgmatic.borg.pattern.Pattern instance, return a new Pattern with its
|
||||
path rewritten to be in a snapshot directory based on the given runtime directory.
|
||||
|
||||
Move any initial caret in a regular expression pattern path to the beginning, so as not to break
|
||||
the regular expression.
|
||||
'''
|
||||
initial_caret = (
|
||||
'^'
|
||||
if pattern.style == borgmatic.borg.pattern.Pattern_style.REGULAR_EXPRESSION
|
||||
and pattern.path.startswith('^')
|
||||
else ''
|
||||
)
|
||||
|
||||
rewritten_path = initial_caret + os.path.join(
|
||||
normalized_runtime_directory,
|
||||
'lvm_snapshots',
|
||||
'.', # Borg 1.4+ "slashdot" hack.
|
||||
# Included so that the source directory ends up in the Borg archive at its "original" path.
|
||||
pattern.path.lstrip('^').lstrip(os.path.sep),
|
||||
)
|
||||
|
||||
return borgmatic.borg.pattern.Pattern(
|
||||
rewritten_path,
|
||||
pattern.type,
|
||||
pattern.style,
|
||||
pattern.device,
|
||||
)
|
||||
|
||||
|
||||
DEFAULT_SNAPSHOT_SIZE = '10%ORIGIN'
|
||||
|
||||
|
||||
def dump_data_sources(
|
||||
hook_config,
|
||||
config,
|
||||
log_prefix,
|
||||
config_paths,
|
||||
borgmatic_runtime_directory,
|
||||
source_directories,
|
||||
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 source directories, and whether this
|
||||
is a dry run, auto-detect and snapshot any LVM logical volume mount points listed in the given
|
||||
source directories. Also update those source directories, 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')
|
||||
requested_logical_volumes = get_logical_volumes(lsblk_command, source_directories)
|
||||
requested_logical_volumes = get_logical_volumes(lsblk_command, patterns)
|
||||
|
||||
# Snapshot each logical volume, rewriting source directories to use the snapshot paths.
|
||||
snapshot_suffix = f'{BORGMATIC_SNAPSHOT_PREFIX}{os.getpid()}'
|
||||
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:
|
||||
@@ -188,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:
|
||||
@@ -198,22 +232,14 @@ def dump_data_sources(
|
||||
hook_config.get('mount_command', 'mount'), snapshot.device_path, snapshot_mount_path
|
||||
)
|
||||
|
||||
# Update the path for each contained source directory, so Borg sees it within the
|
||||
# mounted snapshot.
|
||||
for source_directory in logical_volume.contained_source_directories:
|
||||
try:
|
||||
source_directories.remove(source_directory)
|
||||
except ValueError:
|
||||
pass
|
||||
for pattern in logical_volume.contained_patterns:
|
||||
snapshot_pattern = make_borg_snapshot_pattern(pattern, normalized_runtime_directory)
|
||||
|
||||
source_directories.append(
|
||||
os.path.join(
|
||||
normalized_runtime_directory,
|
||||
'lvm_snapshots',
|
||||
'.', # Borg 1.4+ "slashdot" hack.
|
||||
source_directory.lstrip(os.path.sep),
|
||||
)
|
||||
)
|
||||
# Attempt to update the pattern in place, since pattern order matters to Borg.
|
||||
try:
|
||||
patterns[patterns.index(pattern)] = snapshot_pattern
|
||||
except ValueError:
|
||||
patterns.append(snapshot_pattern)
|
||||
|
||||
return []
|
||||
|
||||
@@ -284,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
|
||||
@@ -300,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(
|
||||
@@ -312,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):
|
||||
@@ -338,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
|
||||
@@ -348,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:
|
||||
@@ -363,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:
|
||||
@@ -374,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".
|
||||
@@ -392,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,
|
||||
|
||||
@@ -3,6 +3,7 @@ import logging
|
||||
import os
|
||||
import shlex
|
||||
|
||||
import borgmatic.borg.pattern
|
||||
import borgmatic.config.paths
|
||||
from borgmatic.execute import (
|
||||
execute_command,
|
||||
@@ -24,7 +25,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,
|
||||
@@ -48,7 +49,7 @@ def database_names_to_dump(database, extra_environment, log_prefix, dry_run):
|
||||
+ ('--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
|
||||
)
|
||||
@@ -61,12 +62,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.
|
||||
@@ -81,7 +81,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
|
||||
|
||||
@@ -102,9 +102,7 @@ def execute_dump_command(
|
||||
+ ('--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
|
||||
|
||||
@@ -117,10 +115,14 @@ def execute_dump_command(
|
||||
)
|
||||
|
||||
|
||||
def use_streaming(databases, config, log_prefix):
|
||||
def get_default_port(databases, config): # pragma: no cover
|
||||
return 3306
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@@ -128,33 +130,30 @@ def use_streaming(databases, config, log_prefix):
|
||||
def dump_data_sources(
|
||||
databases,
|
||||
config,
|
||||
log_prefix,
|
||||
config_paths,
|
||||
borgmatic_runtime_directory,
|
||||
source_directories,
|
||||
patterns,
|
||||
dry_run,
|
||||
):
|
||||
'''
|
||||
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.
|
||||
Also append the given source directories with the parent directory of the database dumps.
|
||||
Also append the the parent directory of the database dumps to the given patterns list, so the
|
||||
dumps actually get backed up.
|
||||
'''
|
||||
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
|
||||
)
|
||||
dump_database_names = database_names_to_dump(database, extra_environment, dry_run)
|
||||
|
||||
if not dump_database_names:
|
||||
if dry_run:
|
||||
@@ -169,7 +168,6 @@ def dump_data_sources(
|
||||
processes.append(
|
||||
execute_dump_command(
|
||||
renamed_database,
|
||||
log_prefix,
|
||||
dump_path,
|
||||
(dump_name,),
|
||||
extra_environment,
|
||||
@@ -181,7 +179,6 @@ def dump_data_sources(
|
||||
processes.append(
|
||||
execute_dump_command(
|
||||
database,
|
||||
log_prefix,
|
||||
dump_path,
|
||||
dump_database_names,
|
||||
extra_environment,
|
||||
@@ -191,31 +188,33 @@ def dump_data_sources(
|
||||
)
|
||||
|
||||
if not dry_run:
|
||||
source_directories.append(os.path.join(borgmatic_runtime_directory, 'mariadb_databases'))
|
||||
patterns.append(
|
||||
borgmatic.borg.pattern.Pattern(
|
||||
os.path.join(borgmatic_runtime_directory, 'mariadb_databases')
|
||||
)
|
||||
)
|
||||
|
||||
return [process for process in processes if process]
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@@ -233,7 +232,6 @@ def make_data_source_dump_patterns(
|
||||
def restore_data_source_dump(
|
||||
hook_config,
|
||||
config,
|
||||
log_prefix,
|
||||
data_source,
|
||||
dry_run,
|
||||
extract_process,
|
||||
@@ -242,9 +240,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(
|
||||
@@ -278,7 +276,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
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import logging
|
||||
import os
|
||||
import shlex
|
||||
|
||||
import borgmatic.borg.pattern
|
||||
import borgmatic.config.paths
|
||||
from borgmatic.execute import execute_command, execute_command_with_processes
|
||||
from borgmatic.hooks.data_source import dump
|
||||
@@ -16,10 +17,14 @@ def make_dump_path(base_directory): # pragma: no cover
|
||||
return dump.make_data_source_dump_path(base_directory, 'mongodb_databases')
|
||||
|
||||
|
||||
def use_streaming(databases, config, log_prefix):
|
||||
def get_default_port(databases, config): # pragma: no cover
|
||||
return 27017
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@@ -27,25 +32,24 @@ def use_streaming(databases, config, log_prefix):
|
||||
def dump_data_sources(
|
||||
databases,
|
||||
config,
|
||||
log_prefix,
|
||||
config_paths,
|
||||
borgmatic_runtime_directory,
|
||||
source_directories,
|
||||
patterns,
|
||||
dry_run,
|
||||
):
|
||||
'''
|
||||
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.
|
||||
Also append the given source directories with the parent directory of the database dumps.
|
||||
Also append the the parent directory of the database dumps to the given patterns list, so the
|
||||
dumps actually get backed up.
|
||||
'''
|
||||
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:
|
||||
@@ -59,7 +63,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
|
||||
@@ -74,7 +78,11 @@ def dump_data_sources(
|
||||
processes.append(execute_command(command, shell=True, run_to_completion=False))
|
||||
|
||||
if not dry_run:
|
||||
source_directories.append(os.path.join(borgmatic_runtime_directory, 'mongodb_databases'))
|
||||
patterns.append(
|
||||
borgmatic.borg.pattern.Pattern(
|
||||
os.path.join(borgmatic_runtime_directory, 'mongodb_databases')
|
||||
)
|
||||
)
|
||||
|
||||
return processes
|
||||
|
||||
@@ -108,25 +116,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)
|
||||
|
||||
@@ -144,7 +150,6 @@ def make_data_source_dump_patterns(
|
||||
def restore_data_source_dump(
|
||||
hook_config,
|
||||
config,
|
||||
log_prefix,
|
||||
data_source,
|
||||
dry_run,
|
||||
extract_process,
|
||||
@@ -154,9 +159,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.
|
||||
@@ -171,7 +176,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
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import logging
|
||||
import os
|
||||
import shlex
|
||||
|
||||
import borgmatic.borg.pattern
|
||||
import borgmatic.config.paths
|
||||
from borgmatic.execute import (
|
||||
execute_command,
|
||||
@@ -24,7 +25,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,
|
||||
@@ -48,7 +49,7 @@ def database_names_to_dump(database, extra_environment, log_prefix, dry_run):
|
||||
+ ('--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
|
||||
)
|
||||
@@ -61,12 +62,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.
|
||||
@@ -81,7 +81,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
|
||||
|
||||
@@ -101,9 +101,7 @@ def execute_dump_command(
|
||||
+ ('--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
|
||||
|
||||
@@ -116,10 +114,14 @@ def execute_dump_command(
|
||||
)
|
||||
|
||||
|
||||
def use_streaming(databases, config, log_prefix):
|
||||
def get_default_port(databases, config): # pragma: no cover
|
||||
return 3306
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@@ -127,33 +129,30 @@ def use_streaming(databases, config, log_prefix):
|
||||
def dump_data_sources(
|
||||
databases,
|
||||
config,
|
||||
log_prefix,
|
||||
config_paths,
|
||||
borgmatic_runtime_directory,
|
||||
source_directories,
|
||||
patterns,
|
||||
dry_run,
|
||||
):
|
||||
'''
|
||||
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.
|
||||
Also append the given source directories with the parent directory of the database dumps.
|
||||
Also append the the parent directory of the database dumps to the given patterns list, so the
|
||||
dumps actually get backed up.
|
||||
'''
|
||||
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
|
||||
)
|
||||
dump_database_names = database_names_to_dump(database, extra_environment, dry_run)
|
||||
|
||||
if not dump_database_names:
|
||||
if dry_run:
|
||||
@@ -168,7 +167,6 @@ def dump_data_sources(
|
||||
processes.append(
|
||||
execute_dump_command(
|
||||
renamed_database,
|
||||
log_prefix,
|
||||
dump_path,
|
||||
(dump_name,),
|
||||
extra_environment,
|
||||
@@ -180,7 +178,6 @@ def dump_data_sources(
|
||||
processes.append(
|
||||
execute_dump_command(
|
||||
database,
|
||||
log_prefix,
|
||||
dump_path,
|
||||
dump_database_names,
|
||||
extra_environment,
|
||||
@@ -190,31 +187,33 @@ def dump_data_sources(
|
||||
)
|
||||
|
||||
if not dry_run:
|
||||
source_directories.append(os.path.join(borgmatic_runtime_directory, 'mysql_databases'))
|
||||
patterns.append(
|
||||
borgmatic.borg.pattern.Pattern(
|
||||
os.path.join(borgmatic_runtime_directory, 'mysql_databases')
|
||||
)
|
||||
)
|
||||
|
||||
return [process for process in processes if process]
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@@ -232,7 +231,6 @@ def make_data_source_dump_patterns(
|
||||
def restore_data_source_dump(
|
||||
hook_config,
|
||||
config,
|
||||
log_prefix,
|
||||
data_source,
|
||||
dry_run,
|
||||
extract_process,
|
||||
@@ -241,9 +239,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(
|
||||
@@ -277,7 +275,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
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import os
|
||||
import pathlib
|
||||
import shlex
|
||||
|
||||
import borgmatic.borg.pattern
|
||||
import borgmatic.config.paths
|
||||
from borgmatic.execute import (
|
||||
execute_command,
|
||||
@@ -57,7 +58,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
|
||||
@@ -84,7 +85,7 @@ def database_names_to_dump(database, extra_environment, log_prefix, dry_run):
|
||||
+ (('--username', 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
|
||||
)
|
||||
@@ -96,10 +97,14 @@ def database_names_to_dump(database, extra_environment, log_prefix, dry_run):
|
||||
)
|
||||
|
||||
|
||||
def use_streaming(databases, config, log_prefix):
|
||||
def get_default_port(databases, config): # pragma: no cover
|
||||
return 5432
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@@ -107,35 +112,32 @@ def use_streaming(databases, config, log_prefix):
|
||||
def dump_data_sources(
|
||||
databases,
|
||||
config,
|
||||
log_prefix,
|
||||
config_paths,
|
||||
borgmatic_runtime_directory,
|
||||
source_directories,
|
||||
patterns,
|
||||
dry_run,
|
||||
):
|
||||
'''
|
||||
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.
|
||||
Also append the given source directories with the parent directory of the database dumps.
|
||||
Also append the the parent directory of the database dumps to the given patterns list, so the
|
||||
dumps actually get backed up.
|
||||
|
||||
Raise ValueError if the databases to dump cannot be determined.
|
||||
'''
|
||||
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:
|
||||
@@ -158,7 +160,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
|
||||
|
||||
@@ -192,7 +194,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
|
||||
@@ -216,31 +218,35 @@ def dump_data_sources(
|
||||
)
|
||||
|
||||
if not dry_run:
|
||||
source_directories.append(os.path.join(borgmatic_runtime_directory, 'postgresql_databases'))
|
||||
patterns.append(
|
||||
borgmatic.borg.pattern.Pattern(
|
||||
os.path.join(borgmatic_runtime_directory, 'postgresql_databases')
|
||||
)
|
||||
)
|
||||
|
||||
return processes
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@@ -258,7 +264,6 @@ def make_data_source_dump_patterns(
|
||||
def restore_data_source_dump(
|
||||
hook_config,
|
||||
config,
|
||||
log_prefix,
|
||||
data_source,
|
||||
dry_run,
|
||||
extract_process,
|
||||
@@ -268,10 +273,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.
|
||||
@@ -344,9 +348,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
|
||||
|
||||
|
||||
@@ -3,28 +3,37 @@ import pathlib
|
||||
IS_A_HOOK = False
|
||||
|
||||
|
||||
def get_contained_directories(parent_directory, candidate_contained_directories):
|
||||
def get_contained_patterns(parent_directory, candidate_patterns):
|
||||
'''
|
||||
Given a parent directory and a set of candidate directories potentially inside it, get the
|
||||
subset of contained directories for which the parent directory is actually the parent, a
|
||||
grandparent, the very same directory, etc. The idea is if, say, /var/log and /var/lib are
|
||||
candidate contained directories, but there's a parent directory (logical volume, dataset,
|
||||
subvolume, etc.) at /var, then /var is what we want to snapshot.
|
||||
Given a parent directory and a set of candidate patterns potentially inside it, get the subset
|
||||
of contained patterns for which the parent directory is actually the parent, a grandparent, the
|
||||
very same directory, etc. The idea is if, say, /var/log and /var/lib are candidate pattern
|
||||
paths, but there's a parent directory (logical volume, dataset, subvolume, etc.) at /var, then
|
||||
/var is what we want to snapshot.
|
||||
|
||||
Also mutate the given set of candidate contained directories to remove any actually contained
|
||||
directories from it. That way, this function can be called multiple times, successively
|
||||
processing candidate directories until none are left—and avoiding assigning any candidate
|
||||
directory to more than one parent directory.
|
||||
For this to work, a candidate pattern path can't have any globs or other non-literal characters
|
||||
in the initial portion of the path that matches the parent directory. For instance, a parent
|
||||
directory of /var would match a candidate pattern path of /var/log/*/data, but not a pattern
|
||||
path like /v*/log/*/data.
|
||||
|
||||
The one exception is that if a regular expression pattern path starts with "^", that will get
|
||||
stripped off for purposes of matching against a parent directory.
|
||||
|
||||
As part of this, also mutate the given set of candidate patterns to remove any actually
|
||||
contained patterns from it. That way, this function can be called multiple times, successively
|
||||
processing candidate patterns until none are left—and avoiding assigning any candidate pattern
|
||||
to more than one parent directory.
|
||||
'''
|
||||
if not candidate_contained_directories:
|
||||
if not candidate_patterns:
|
||||
return ()
|
||||
|
||||
contained = tuple(
|
||||
contained_patterns = tuple(
|
||||
candidate
|
||||
for candidate in candidate_contained_directories
|
||||
if pathlib.PurePath(parent_directory) == pathlib.PurePath(candidate)
|
||||
or pathlib.PurePath(parent_directory) in pathlib.PurePath(candidate).parents
|
||||
for candidate in candidate_patterns
|
||||
for candidate_path in (pathlib.PurePath(candidate.path.lstrip('^')),)
|
||||
if pathlib.PurePath(parent_directory) == candidate_path
|
||||
or pathlib.PurePath(parent_directory) in candidate_path.parents
|
||||
)
|
||||
candidate_contained_directories -= set(contained)
|
||||
candidate_patterns -= set(contained_patterns)
|
||||
|
||||
return contained
|
||||
return contained_patterns
|
||||
|
||||
@@ -2,6 +2,7 @@ import logging
|
||||
import os
|
||||
import shlex
|
||||
|
||||
import borgmatic.borg.pattern
|
||||
import borgmatic.config.paths
|
||||
from borgmatic.execute import execute_command, execute_command_with_processes
|
||||
from borgmatic.hooks.data_source import dump
|
||||
@@ -16,10 +17,14 @@ def make_dump_path(base_directory): # pragma: no cover
|
||||
return dump.make_data_source_dump_path(base_directory, 'sqlite_databases')
|
||||
|
||||
|
||||
def use_streaming(databases, config, log_prefix):
|
||||
def get_default_port(databases, config): # pragma: no cover
|
||||
return None # SQLite doesn't use a port.
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@@ -27,25 +32,25 @@ def use_streaming(databases, config, log_prefix):
|
||||
def dump_data_sources(
|
||||
databases,
|
||||
config,
|
||||
log_prefix,
|
||||
config_paths,
|
||||
borgmatic_runtime_directory,
|
||||
source_directories,
|
||||
patterns,
|
||||
dry_run,
|
||||
):
|
||||
'''
|
||||
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.
|
||||
Also append the given source directories with the parent directory of the database dumps.
|
||||
Also append the the parent directory of the database dumps to the given patterns list, so the
|
||||
dumps actually get backed up.
|
||||
'''
|
||||
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']
|
||||
@@ -54,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)
|
||||
@@ -62,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
|
||||
|
||||
@@ -74,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
|
||||
@@ -83,31 +88,33 @@ def dump_data_sources(
|
||||
processes.append(execute_command(command, shell=True, run_to_completion=False))
|
||||
|
||||
if not dry_run:
|
||||
source_directories.append(os.path.join(borgmatic_runtime_directory, 'sqlite_databases'))
|
||||
patterns.append(
|
||||
borgmatic.borg.pattern.Pattern(
|
||||
os.path.join(borgmatic_runtime_directory, 'sqlite_databases')
|
||||
)
|
||||
)
|
||||
|
||||
return processes
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@@ -125,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,
|
||||
@@ -134,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
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import os
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
import borgmatic.borg.pattern
|
||||
import borgmatic.config.paths
|
||||
import borgmatic.execute
|
||||
import borgmatic.hooks.data_source.snapshot
|
||||
@@ -12,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.)
|
||||
'''
|
||||
@@ -25,18 +26,17 @@ BORGMATIC_USER_PROPERTY = 'org.torsion.borgmatic:backup'
|
||||
|
||||
Dataset = collections.namedtuple(
|
||||
'Dataset',
|
||||
('name', 'mount_point', 'auto_backup', 'contained_source_directories'),
|
||||
('name', 'mount_point', 'auto_backup', 'contained_patterns'),
|
||||
defaults=(False, ()),
|
||||
)
|
||||
|
||||
|
||||
def get_datasets_to_backup(zfs_command, source_directories):
|
||||
def get_datasets_to_backup(zfs_command, patterns):
|
||||
'''
|
||||
Given a ZFS command to run and a sequence of configured source directories, find the
|
||||
intersection between the current ZFS dataset mount points and the configured borgmatic source
|
||||
directories. The idea is that these are the requested datasets to snapshot. But also include any
|
||||
datasets tagged with a borgmatic-specific user property, whether or not they appear in source
|
||||
directories.
|
||||
Given a ZFS command to run and a sequence of configured patterns, find the intersection between
|
||||
the current ZFS dataset mount points and the paths of any patterns. The idea is that these
|
||||
pattern paths represent the requested datasets to snapshot. But also include any datasets tagged
|
||||
with a borgmatic-specific user property, whether or not they appear in the patterns.
|
||||
|
||||
Return the result as a sequence of Dataset instances, sorted by mount point.
|
||||
'''
|
||||
@@ -54,9 +54,8 @@ def get_datasets_to_backup(zfs_command, source_directories):
|
||||
|
||||
try:
|
||||
# Sort from longest to shortest mount points, so longer mount points get a whack at the
|
||||
# candidate source directory piñata before their parents do. (Source directories are
|
||||
# consumed during the second loop below, so no two datasets get the same contained source
|
||||
# directories.)
|
||||
# candidate pattern piñata before their parents do. (Patterns are consumed during the second
|
||||
# loop below, so no two datasets end up with the same contained patterns.)
|
||||
datasets = sorted(
|
||||
(
|
||||
Dataset(dataset_name, mount_point, (user_property_value == 'auto'), ())
|
||||
@@ -69,7 +68,7 @@ def get_datasets_to_backup(zfs_command, source_directories):
|
||||
except ValueError:
|
||||
raise ValueError(f'Invalid {zfs_command} list output')
|
||||
|
||||
candidate_source_directories = set(source_directories)
|
||||
candidate_patterns = set(patterns)
|
||||
|
||||
return tuple(
|
||||
sorted(
|
||||
@@ -78,19 +77,22 @@ def get_datasets_to_backup(zfs_command, source_directories):
|
||||
dataset.name,
|
||||
dataset.mount_point,
|
||||
dataset.auto_backup,
|
||||
contained_source_directories,
|
||||
contained_patterns,
|
||||
)
|
||||
for dataset in datasets
|
||||
for contained_source_directories in (
|
||||
for contained_patterns in (
|
||||
(
|
||||
(dataset.mount_point,)
|
||||
if dataset.auto_backup
|
||||
else borgmatic.hooks.data_source.snapshot.get_contained_directories(
|
||||
dataset.mount_point, candidate_source_directories
|
||||
(
|
||||
(borgmatic.borg.pattern.Pattern(dataset.mount_point),)
|
||||
if dataset.auto_backup
|
||||
else ()
|
||||
)
|
||||
+ borgmatic.hooks.data_source.snapshot.get_contained_patterns(
|
||||
dataset.mount_point, candidate_patterns
|
||||
)
|
||||
),
|
||||
)
|
||||
if contained_source_directories
|
||||
if contained_patterns
|
||||
),
|
||||
key=lambda dataset: dataset.mount_point,
|
||||
)
|
||||
@@ -153,45 +155,75 @@ def mount_snapshot(mount_command, full_snapshot_name, snapshot_mount_path): # p
|
||||
)
|
||||
|
||||
|
||||
def make_borg_snapshot_pattern(pattern, normalized_runtime_directory):
|
||||
'''
|
||||
Given a Borg pattern as a borgmatic.borg.pattern.Pattern instance, return a new Pattern with its
|
||||
path rewritten to be in a snapshot directory based on the given runtime directory.
|
||||
|
||||
Move any initial caret in a regular expression pattern path to the beginning, so as not to break
|
||||
the regular expression.
|
||||
'''
|
||||
initial_caret = (
|
||||
'^'
|
||||
if pattern.style == borgmatic.borg.pattern.Pattern_style.REGULAR_EXPRESSION
|
||||
and pattern.path.startswith('^')
|
||||
else ''
|
||||
)
|
||||
|
||||
rewritten_path = initial_caret + os.path.join(
|
||||
normalized_runtime_directory,
|
||||
'zfs_snapshots',
|
||||
'.', # Borg 1.4+ "slashdot" hack.
|
||||
# Included so that the source directory ends up in the Borg archive at its "original" path.
|
||||
pattern.path.lstrip('^').lstrip(os.path.sep),
|
||||
)
|
||||
|
||||
return borgmatic.borg.pattern.Pattern(
|
||||
rewritten_path,
|
||||
pattern.type,
|
||||
pattern.style,
|
||||
pattern.device,
|
||||
)
|
||||
|
||||
|
||||
def dump_data_sources(
|
||||
hook_config,
|
||||
config,
|
||||
log_prefix,
|
||||
config_paths,
|
||||
borgmatic_runtime_directory,
|
||||
source_directories,
|
||||
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 source directories, and whether this
|
||||
is a dry run, auto-detect and snapshot any ZFS dataset mount points listed in the given source
|
||||
directories and any dataset with a borgmatic-specific user property. Also update those source
|
||||
directories, 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.
|
||||
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.
|
||||
|
||||
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')
|
||||
requested_datasets = get_datasets_to_backup(zfs_command, source_directories)
|
||||
requested_datasets = get_datasets_to_backup(zfs_command, patterns)
|
||||
|
||||
# Snapshot each dataset, rewriting source directories to use the snapshot paths.
|
||||
# Snapshot each dataset, rewriting patterns to use the snapshot paths.
|
||||
snapshot_name = f'{BORGMATIC_SNAPSHOT_PREFIX}{os.getpid()}'
|
||||
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:
|
||||
@@ -206,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:
|
||||
@@ -216,20 +248,14 @@ def dump_data_sources(
|
||||
hook_config.get('mount_command', 'mount'), full_snapshot_name, snapshot_mount_path
|
||||
)
|
||||
|
||||
for source_directory in dataset.contained_source_directories:
|
||||
try:
|
||||
source_directories.remove(source_directory)
|
||||
except ValueError:
|
||||
pass
|
||||
for pattern in dataset.contained_patterns:
|
||||
snapshot_pattern = make_borg_snapshot_pattern(pattern, normalized_runtime_directory)
|
||||
|
||||
source_directories.append(
|
||||
os.path.join(
|
||||
normalized_runtime_directory,
|
||||
'zfs_snapshots',
|
||||
'.', # Borg 1.4+ "slashdot" hack.
|
||||
source_directory.lstrip(os.path.sep),
|
||||
)
|
||||
)
|
||||
# Attempt to update the pattern in place, since pattern order matters to Borg.
|
||||
try:
|
||||
patterns[patterns.index(pattern)] = snapshot_pattern
|
||||
except ValueError:
|
||||
patterns.append(snapshot_pattern)
|
||||
|
||||
return []
|
||||
|
||||
@@ -279,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
|
||||
@@ -297,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(
|
||||
@@ -309,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):
|
||||
@@ -336,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:
|
||||
@@ -361,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".
|
||||
@@ -379,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.
|
||||
'''
|
||||
|
||||
@@ -19,7 +19,6 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
|
||||
Ping the configured Ntfy topic. Use the given configuration filename in any log entries.
|
||||
If this is a dry run, then don't actually ping anything.
|
||||
'''
|
||||
|
||||
run_states = hook_config.get('states', ['fail'])
|
||||
|
||||
if state.name.lower() in run_states:
|
||||
@@ -38,8 +37,8 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
|
||||
base_url = hook_config.get('server', 'https://ntfy.sh')
|
||||
topic = hook_config.get('topic')
|
||||
|
||||
logger.info(f'{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'),
|
||||
@@ -56,20 +55,16 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
|
||||
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)
|
||||
@@ -78,12 +73,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.
|
||||
'''
|
||||
|
||||
@@ -29,12 +29,12 @@ 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
|
||||
@@ -61,7 +61,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 +69,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.
|
||||
'''
|
||||
|
||||
@@ -35,14 +35,14 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
|
||||
token = hook_config.get('token')
|
||||
user = hook_config.get('user')
|
||||
|
||||
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 +75,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.
|
||||
'''
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import logging
|
||||
import re
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def initialize_monitor(
|
||||
ping_url, config, config_filename, monitoring_log_level, dry_run
|
||||
): # pragma: no cover
|
||||
'''
|
||||
No initialization is necessary for this monitor.
|
||||
'''
|
||||
pass
|
||||
|
||||
|
||||
DATA_SOURCE_NAME_URL_PATTERN = re.compile(
|
||||
'^(?P<protocol>.+)://(?P<username>.+)@(?P<hostname>.+)/(?P<project_id>.+)$'
|
||||
)
|
||||
|
||||
|
||||
def ping_monitor(hook_config, config, config_filename, state, monitoring_log_level, dry_run):
|
||||
'''
|
||||
Construct and ping a Sentry cron URL, based on the configured DSN URL and monitor slug. Use the
|
||||
given configuration filename in any log entries. If this is a dry run, then don't actually ping
|
||||
anything.
|
||||
'''
|
||||
run_states = hook_config.get('states', ['start', 'finish', 'fail'])
|
||||
|
||||
if not state.name.lower() in run_states:
|
||||
return
|
||||
|
||||
dry_run_label = ' (dry run; not actually pinging)' if dry_run else ''
|
||||
|
||||
data_source_name_url = hook_config.get('data_source_name_url')
|
||||
monitor_slug = hook_config.get('monitor_slug')
|
||||
match = DATA_SOURCE_NAME_URL_PATTERN.match(data_source_name_url)
|
||||
|
||||
if not match:
|
||||
logger.warning(f'Invalid Sentry data source name URL: {data_source_name_url}')
|
||||
return
|
||||
|
||||
cron_url = f'{match.group("protocol")}://{match.group("hostname")}/api/{match.group("project_id")}/cron/{monitor_slug}/{match.group("username")}/'
|
||||
|
||||
logger.info(f'Pinging Sentry {state.name.lower()}{dry_run_label}')
|
||||
logger.debug(f'Using Sentry cron URL {cron_url}')
|
||||
|
||||
status = {
|
||||
'start': 'in_progress',
|
||||
'finish': 'ok',
|
||||
'fail': 'error',
|
||||
}.get(state.name.lower())
|
||||
|
||||
if not status:
|
||||
logger.warning('Invalid Sentry state')
|
||||
return
|
||||
|
||||
if dry_run:
|
||||
return
|
||||
|
||||
logging.getLogger('urllib3').setLevel(logging.ERROR)
|
||||
try:
|
||||
response = requests.post(f'{cron_url}?status={status}')
|
||||
if not response.ok:
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.RequestException as error:
|
||||
logger.warning(f'Sentry error: {error}')
|
||||
|
||||
|
||||
def destroy_monitor(ping_url_or_uuid, config, monitoring_log_level, dry_run): # pragma: no cover
|
||||
'''
|
||||
No destruction is necessary for this monitor.
|
||||
'''
|
||||
pass
|
||||
@@ -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.
|
||||
'''
|
||||
|
||||
@@ -44,16 +44,16 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
|
||||
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 +62,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 +71,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 +102,18 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
|
||||
if not response.ok:
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.RequestException as error:
|
||||
logger.warning(f'{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 +123,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.
|
||||
'''
|
||||
|
||||
+84
-5
@@ -88,8 +88,10 @@ class Multi_stream_handler(logging.Handler):
|
||||
|
||||
|
||||
class Console_no_color_formatter(logging.Formatter):
|
||||
def format(self, record): # pragma: no cover
|
||||
return record.msg
|
||||
def __init__(self, *args, **kwargs): # pragma: no cover
|
||||
super(Console_no_color_formatter, self).__init__(
|
||||
'{prefix}{message}', style='{', defaults={'prefix': ''}, *args, **kwargs
|
||||
)
|
||||
|
||||
|
||||
class Color(enum.Enum):
|
||||
@@ -102,6 +104,11 @@ class Color(enum.Enum):
|
||||
|
||||
|
||||
class Console_color_formatter(logging.Formatter):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(Console_color_formatter, self).__init__(
|
||||
'{prefix}{message}', style='{', defaults={'prefix': ''}, *args, **kwargs
|
||||
)
|
||||
|
||||
def format(self, record):
|
||||
add_custom_log_levels()
|
||||
|
||||
@@ -118,7 +125,7 @@ class Console_color_formatter(logging.Formatter):
|
||||
.value
|
||||
)
|
||||
|
||||
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 +186,72 @@ def add_custom_log_levels(): # pragma: no cover
|
||||
add_logging_level('DISABLED', DISABLED)
|
||||
|
||||
|
||||
def get_log_prefix():
|
||||
'''
|
||||
Return the current log prefix from the defaults for the formatter on the first logging handler,
|
||||
set by set_log_prefix(). Return None if no such prefix exists.
|
||||
'''
|
||||
try:
|
||||
return next(
|
||||
handler.formatter._style._defaults.get('prefix').rstrip().rstrip(':')
|
||||
for handler in logging.getLogger().handlers
|
||||
)
|
||||
except (StopIteration, AttributeError):
|
||||
return None
|
||||
|
||||
|
||||
def set_log_prefix(prefix):
|
||||
'''
|
||||
Given a log prefix as a string, set it into the defaults for the formatters on all logging
|
||||
handlers. Note that this overwrites any existing defaults.
|
||||
'''
|
||||
for handler in logging.getLogger().handlers:
|
||||
try:
|
||||
handler.formatter._style._defaults = {'prefix': f'{prefix}: ' if prefix else ''}
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def configure_logging(
|
||||
console_log_level,
|
||||
syslog_log_level=None,
|
||||
@@ -242,7 +315,11 @@ 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
|
||||
logging.Formatter(
|
||||
'borgmatic: {levelname} {prefix}{message}', # noqa: FS003
|
||||
style='{',
|
||||
defaults={'prefix': ''},
|
||||
)
|
||||
)
|
||||
syslog_handler.setLevel(syslog_log_level)
|
||||
handlers.append(syslog_handler)
|
||||
@@ -251,7 +328,9 @@ def configure_logging(
|
||||
file_handler = logging.handlers.WatchedFileHandler(log_file)
|
||||
file_handler.setFormatter(
|
||||
logging.Formatter(
|
||||
log_file_format or '[{asctime}] {levelname}: {message}', style='{' # noqa: FS003
|
||||
log_file_format or '[{asctime}] {levelname}: {prefix}{message}', # noqa: FS003
|
||||
style='{',
|
||||
defaults={'prefix': ''},
|
||||
)
|
||||
)
|
||||
file_handler.setLevel(log_file_log_level)
|
||||
|
||||
+23
-7
@@ -25,27 +25,43 @@ def list_merged_pulls(url):
|
||||
return tuple(pull for pull in response.json() if pull.get('merged_at'))
|
||||
|
||||
|
||||
API_ENDPOINT_URLS = (
|
||||
def list_contributing_issues(url):
|
||||
# labels = bug, design finalized, etc.
|
||||
response = requests.get(f'{url}?labels=19,20,22,23,32,52,53,54', headers={'Accept': 'application/json', 'Content-Type': 'application/json'})
|
||||
|
||||
if not response.ok:
|
||||
response.raise_for_status()
|
||||
|
||||
return tuple(response.json())
|
||||
|
||||
|
||||
PULLS_API_ENDPOINT_URLS = (
|
||||
'https://projects.torsion.org/api/v1/repos/borgmatic-collective/borgmatic/pulls',
|
||||
'https://api.github.com/repos/borgmatic-collective/borgmatic/pulls',
|
||||
)
|
||||
ISSUES_API_ENDPOINT_URL = 'https://projects.torsion.org/api/v1/repos/borgmatic-collective/borgmatic/issues'
|
||||
RECENT_CONTRIBUTORS_CUTOFF_DAYS = 365
|
||||
|
||||
|
||||
def get_item_timestamp(item):
|
||||
return item.get('merged_at') or item.get('created_at')
|
||||
|
||||
|
||||
def print_contributors():
|
||||
'''
|
||||
Display the recent contributors as a row of avatars in an HTML fragment.
|
||||
'''
|
||||
pulls = tuple(itertools.chain.from_iterable(list_merged_pulls(url) for url in API_ENDPOINT_URLS))
|
||||
pulls = tuple(itertools.chain.from_iterable(list_merged_pulls(url) for url in PULLS_API_ENDPOINT_URLS))
|
||||
issues = list_contributing_issues(ISSUES_API_ENDPOINT_URL)
|
||||
seen_user_ids = set()
|
||||
|
||||
print('<p>')
|
||||
|
||||
for pull in sorted(pulls, key=operator.itemgetter('merged_at'), reverse=True):
|
||||
merged_at = pull.get('merged_at')
|
||||
user = pull.get('user')
|
||||
for item in sorted(pulls + issues, key=get_item_timestamp, reverse=True):
|
||||
timestamp = get_item_timestamp(item)
|
||||
user = item.get('user')
|
||||
|
||||
if not merged_at or not user:
|
||||
if not timestamp or not user:
|
||||
continue
|
||||
|
||||
user_id = user.get('id')
|
||||
@@ -53,7 +69,7 @@ def print_contributors():
|
||||
if not user_id or user_id in seen_user_ids:
|
||||
continue
|
||||
|
||||
if datetime.datetime.fromisoformat(merged_at) < datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=RECENT_CONTRIBUTORS_CUTOFF_DAYS):
|
||||
if datetime.datetime.fromisoformat(timestamp) < datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=RECENT_CONTRIBUTORS_CUTOFF_DAYS):
|
||||
continue
|
||||
|
||||
seen_user_ids.add(user_id)
|
||||
|
||||
@@ -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.)
|
||||
@@ -135,6 +135,9 @@ temporary file storage, probing the following locations (in order) to find it:
|
||||
Hard-coded `/tmp`. <span class="minilink minilink-addedin">Prior to
|
||||
version 1.9.2</span>This was instead hard-coded to `/run/user/$UID`.
|
||||
|
||||
You can see the runtime directory path that borgmatic selects by running with
|
||||
`--verbosity 2` and looking for "Using runtime directory" in the output.
|
||||
|
||||
Regardless of the runtime directory selected, borgmatic stores its files
|
||||
within a `borgmatic` subdirectory of the runtime directory. Additionally, in
|
||||
the case of `TMPDIR`, `TEMP`, and the hard-coded `/tmp`, borgmatic creates a
|
||||
@@ -258,19 +261,23 @@ 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 `.borgmatic` folder from your host's
|
||||
home folder into the container using the same directory structure.
|
||||
options.
|
||||
|
||||
See the following Docker compose file an as example:
|
||||
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`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
db:
|
||||
image: postgres
|
||||
volumes:
|
||||
- /home/USERNAME/.borgmatic:/home/USERNAME/.borgmatic
|
||||
- /run/user/1000:/run/user/1000
|
||||
```
|
||||
|
||||
Similar command override options are available for (some of) the other
|
||||
@@ -547,7 +554,7 @@ extraction destination path. For example, if you're extracting to `/tmp`, then
|
||||
the dump will be in `/tmp/borgmatic/`.
|
||||
|
||||
<span class="minilink minilink-addedin">Prior to version 1.9.0</span> borgmatic
|
||||
extracts the dump file into the *`username`*`/.borgmatic/` directory within the
|
||||
extracted the dump file into the *`username`*`/.borgmatic/` directory within the
|
||||
extraction destination path, where *`username`* is the user that created the
|
||||
backup. For example, if you created the backup with the `root` user and you're
|
||||
extracting to `/tmp`, then the dump will be in `/tmp/root/.borgmatic`.
|
||||
|
||||
@@ -47,6 +47,7 @@ them as backups happen:
|
||||
* [ntfy](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#ntfy-hook)
|
||||
* [PagerDuty](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#pagerduty-hook)
|
||||
* [Pushover](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#pushover-hook)
|
||||
* [Sentry](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#sentry-hook)
|
||||
* [Uptime Kuma](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#uptime-kuma-hook)
|
||||
* [Zabbix](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#zabbix-hook)
|
||||
|
||||
@@ -361,10 +362,49 @@ pushover:
|
||||
```
|
||||
|
||||
|
||||
## Sentry hook
|
||||
|
||||
<span class="minilink minilink-addedin">New in version 1.9.7</span>
|
||||
[Sentry](https://sentry.io/) is an application monitoring service that
|
||||
includes cron-style monitoring (either cloud-hosted or
|
||||
[self-hosted](https://develop.sentry.dev/self-hosted/)).
|
||||
|
||||
To get started, create a [Sentry cron
|
||||
monitor](https://docs.sentry.io/product/crons/) in the Sentry UI. Under
|
||||
"Instrument your monitor," select "Sentry CLI" and copy the URL value for the
|
||||
displayed
|
||||
[`SENTRY_DSN`](https://docs.sentry.io/concepts/key-terms/dsn-explainer/)
|
||||
environment variable into borgmatic's Sentry `data_source_name_url`
|
||||
configuration option. For example:
|
||||
|
||||
```
|
||||
sentry:
|
||||
data_source_name_url: https://5f80ec@o294220.ingest.us.sentry.io/203069
|
||||
monitor_slug: mymonitor
|
||||
```
|
||||
|
||||
The `monitor_slug` value comes from the "Monitor Slug" under "Cron Details" on
|
||||
the same Sentry monitor page.
|
||||
|
||||
With this configuration, borgmatic pings Sentry whenever borgmatic starts,
|
||||
finishes, or fails, but only when any of the `create`, `prune`, `compact`, or
|
||||
`check` actions are run. You can optionally override the start/finish/fail
|
||||
behavior with the `states` configuration option. For instance, to only ping
|
||||
Sentry on failure:
|
||||
|
||||
```
|
||||
sentry:
|
||||
data_source_name_url: https://5f80ec@o294220.ingest.us.sentry.io/203069
|
||||
monitor_slug: mymonitor
|
||||
states:
|
||||
- fail
|
||||
```
|
||||
|
||||
|
||||
## ntfy hook
|
||||
|
||||
<span class="minilink minilink-addedin">New in version 1.6.3</span>
|
||||
[ntfy](https://ntfy.sh) is a free, simple, service (either hosted or
|
||||
[ntfy](https://ntfy.sh) is a free, simple, service (either cloud-hosted or
|
||||
self-hosted) which offers simple pub/sub push notifications to multiple
|
||||
platforms including [web](https://ntfy.sh/stats),
|
||||
[Android](https://play.google.com/store/apps/details?id=io.heckel.ntfy) and
|
||||
@@ -438,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,11 +24,30 @@ 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/).
|
||||
|
||||
@@ -53,6 +53,9 @@ You have a couple of options for borgmatic to find and backup your ZFS datasets:
|
||||
|
||||
* For any dataset you'd like backed up, add its mount point to borgmatic's
|
||||
`source_directories` option.
|
||||
* <span class="minilink minilink-addedin">New in version 1.9.6</span> Or
|
||||
include the mount point with borgmatic's `patterns` or `patterns_from`
|
||||
options.
|
||||
* Or set the borgmatic-specific user property
|
||||
`org.torsion.borgmatic:backup=auto` onto your dataset, e.g. by running `zfs
|
||||
set org.torsion.borgmatic:backup=auto datasetname`. Then borgmatic can find
|
||||
@@ -84,6 +87,14 @@ let's say you add `/var/log` and `/var/lib` to your source directories, but
|
||||
accordingly. This also works even with nested datasets; borgmatic selects
|
||||
the dataset that's the "closest" parent to your source directories.
|
||||
|
||||
<span class="minilink minilink-addedin">New in version 1.9.6</span> When using
|
||||
[patterns](https://borgbackup.readthedocs.io/en/stable/usage/help.html#borg-help-patterns),
|
||||
the initial portion of a pattern's path that you intend borgmatic to match
|
||||
against a dataset can't have globs or other non-literal characters in it—or it
|
||||
won't actually match. For instance, a mount point of `/var` would match a
|
||||
pattern of `+ fm:/var/*/data`, but borgmatic isn't currently smart enough to
|
||||
match `/var` to a pattern like `+ fm:/v*/lib/data`.
|
||||
|
||||
<span class="minilink minilink-addedin">With Borg version 1.2 and
|
||||
earlier</span>Snapshotted files are instead stored at a path dependent on the
|
||||
[runtime
|
||||
@@ -133,10 +144,14 @@ feedback](https://torsion.org/borgmatic/#issues) you have on this feature.
|
||||
#### Subvolume discovery
|
||||
|
||||
For any subvolume you'd like backed up, add its path to borgmatic's
|
||||
`source_directories` option. During a backup, borgmatic snapshots these
|
||||
subvolumes (non-recursively) and includes the snapshotted files in the paths
|
||||
sent to Borg. borgmatic is also responsible for cleaning up (deleting) these
|
||||
snapshots after a backup completes.
|
||||
`source_directories` option.
|
||||
|
||||
<span class="minilink minilink-addedin">New in version 1.9.6</span> Or include
|
||||
the mount point with borgmatic's `patterns` or `patterns_from` options.
|
||||
|
||||
During a backup, borgmatic snapshots these subvolumes (non-recursively) and
|
||||
includes the snapshotted files in the paths sent to Borg. borgmatic is also
|
||||
responsible for cleaning up (deleting) these snapshots after a backup completes.
|
||||
|
||||
borgmatic is smart enough to look at the parent (and grandparent, etc.)
|
||||
directories of each of your `source_directories` to discover any subvolumes.
|
||||
@@ -146,6 +161,14 @@ snapshot `/var` accordingly. This also works even with nested subvolumes;
|
||||
borgmatic selects the subvolume that's the "closest" parent to your source
|
||||
directories.
|
||||
|
||||
<span class="minilink minilink-addedin">New in version 1.9.6</span> When using
|
||||
[patterns](https://borgbackup.readthedocs.io/en/stable/usage/help.html#borg-help-patterns),
|
||||
the initial portion of a pattern's path that you intend borgmatic to match
|
||||
against a subvolume can't have globs or other non-literal characters in it—or it
|
||||
won't actually match. For instance, a subvolume of `/var` would match a pattern
|
||||
of `+ fm:/var/*/data`, but borgmatic isn't currently smart enough to match
|
||||
`/var` to a pattern like `+ fm:/v*/lib/data`.
|
||||
|
||||
Additionally, borgmatic rewrites the snapshot file paths so that they appear
|
||||
at their original subvolume locations in a Borg archive. For instance, if your
|
||||
subvolume exists at `/var/subvolume`, then the snapshotted files will appear
|
||||
@@ -228,8 +251,11 @@ more information about possible values here. (Under the hood, borgmatic uses
|
||||
For any logical volume you'd like backed up, add its mount point to
|
||||
borgmatic's `source_directories` option.
|
||||
|
||||
During a backup, borgmatic automatically snapshots these discovered logical volumes, temporarily
|
||||
mounts the snapshots within its [runtime
|
||||
<span class="minilink minilink-addedin">New in version 1.9.6</span> Or include
|
||||
the mount point with borgmatic's `patterns` or `patterns_from` options.
|
||||
|
||||
During a backup, borgmatic automatically snapshots these discovered logical volumes
|
||||
(non-recursively), temporarily mounts the snapshots within its [runtime
|
||||
directory](https://torsion.org/borgmatic/docs/how-to/backup-your-databases/#runtime-directory), and
|
||||
includes the snapshotted files in the paths sent to Borg. borgmatic is also responsible for cleaning
|
||||
up (deleting) these snapshots after a backup completes.
|
||||
@@ -240,6 +266,14 @@ volumes. For instance, let's say you add `/var/log` and `/var/lib` to your
|
||||
source directories, but `/var` is a logical volume. borgmatic will discover
|
||||
that and snapshot `/var` accordingly.
|
||||
|
||||
<span class="minilink minilink-addedin">New in version 1.9.6</span> When using
|
||||
[patterns](https://borgbackup.readthedocs.io/en/stable/usage/help.html#borg-help-patterns),
|
||||
the initial portion of a pattern's path that you intend borgmatic to match
|
||||
against a logical volume can't have globs or other non-literal characters in
|
||||
it—or it won't actually match. For instance, a logical volume of `/var` would
|
||||
match a pattern of `+ fm:/var/*/data`, but borgmatic isn't currently smart
|
||||
enough to match `/var` to a pattern like `+ fm:/v*/lib/data`.
|
||||
|
||||
Additionally, borgmatic rewrites the snapshot file paths so that they appear
|
||||
at their original logical volume locations in a Borg archive. For instance, if
|
||||
your logical volume is mounted at `/var/lvolume`, then the snapshotted files
|
||||
|
||||
@@ -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.
|
After Width: | Height: | Size: 14 KiB |
+2
-2
@@ -1,12 +1,12 @@
|
||||
[project]
|
||||
name = "borgmatic"
|
||||
version = "1.9.5"
|
||||
version = "1.9.9"
|
||||
authors = [
|
||||
{ name="Dan Helfman", email="witten@torsion.org" },
|
||||
]
|
||||
description = "Simple, configuration-driven backup software for servers and workstations"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.8"
|
||||
requires-python = ">=3.9"
|
||||
classifiers=[
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Environment :: Console",
|
||||
|
||||
@@ -17,12 +17,9 @@ if [ -z "$TEST_CONTAINER" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
apk add --no-cache python3 py3-pip borgbackup postgresql-client mariadb-client mongodb-tools \
|
||||
py3-ruamel.yaml py3-ruamel.yaml.clib py3-yaml bash sqlite fish
|
||||
# If certain dependencies of black are available in this version of Alpine, install them.
|
||||
apk add --no-cache py3-typed-ast py3-regex || true
|
||||
python3 -m pip install --no-cache --upgrade pip==24.2 setuptools==72.1.0
|
||||
pip3 install --ignore-installed tox==4.11.3
|
||||
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
|
||||
export COVERAGE_FILE=/tmp/.coverage
|
||||
|
||||
tox --workdir /tmp/.tox --sitepackages
|
||||
|
||||
@@ -1,50 +1,50 @@
|
||||
services:
|
||||
postgresql:
|
||||
image: docker.io/postgres:13.1-alpine
|
||||
image: docker.io/postgres:17.2-alpine
|
||||
environment:
|
||||
POSTGRES_PASSWORD: test
|
||||
POSTGRES_DB: test
|
||||
postgresql2:
|
||||
image: docker.io/postgres:13.1-alpine
|
||||
image: docker.io/postgres:17.2-alpine
|
||||
environment:
|
||||
POSTGRES_PASSWORD: test2
|
||||
POSTGRES_DB: test
|
||||
command: docker-entrypoint.sh -p 5433
|
||||
mariadb:
|
||||
image: docker.io/mariadb:10.11.4
|
||||
image: docker.io/mariadb:11.4.4
|
||||
environment:
|
||||
MARIADB_ROOT_PASSWORD: test
|
||||
MARIADB_DATABASE: test
|
||||
mariadb2:
|
||||
image: docker.io/mariadb:10.11.4
|
||||
image: docker.io/mariadb:11.4.4
|
||||
environment:
|
||||
MARIADB_ROOT_PASSWORD: test2
|
||||
MARIADB_DATABASE: test
|
||||
command: docker-entrypoint.sh --port=3307
|
||||
not-actually-mysql:
|
||||
image: docker.io/mariadb:10.11.4
|
||||
image: docker.io/mariadb:11.4.4
|
||||
environment:
|
||||
MARIADB_ROOT_PASSWORD: test
|
||||
MARIADB_DATABASE: test
|
||||
not-actually-mysql2:
|
||||
image: docker.io/mariadb:10.11.4
|
||||
image: docker.io/mariadb:11.4.4
|
||||
environment:
|
||||
MARIADB_ROOT_PASSWORD: test2
|
||||
MARIADB_DATABASE: test
|
||||
command: docker-entrypoint.sh --port=3307
|
||||
mongodb:
|
||||
image: docker.io/mongo:5.0.5
|
||||
image: docker.io/mongo:7.0.16
|
||||
environment:
|
||||
MONGO_INITDB_ROOT_USERNAME: root
|
||||
MONGO_INITDB_ROOT_PASSWORD: test
|
||||
mongodb2:
|
||||
image: docker.io/mongo:5.0.5
|
||||
image: docker.io/mongo:7.0.16
|
||||
environment:
|
||||
MONGO_INITDB_ROOT_USERNAME: root2
|
||||
MONGO_INITDB_ROOT_PASSWORD: test2
|
||||
command: docker-entrypoint.sh --port=27018
|
||||
tests:
|
||||
image: docker.io/alpine:3.13
|
||||
image: docker.io/alpine:3.21
|
||||
environment:
|
||||
TEST_CONTAINER: true
|
||||
volumes:
|
||||
|
||||
@@ -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)
|
||||
@@ -5,12 +5,14 @@ import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
def generate_configuration(config_path, repository_path):
|
||||
|
||||
def generate_configuration_with_source_directories(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 injecting the given repository path and tacking on an encryption
|
||||
passphrase).
|
||||
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 = (
|
||||
@@ -19,17 +21,52 @@ def generate_configuration(config_path, repository_path):
|
||||
.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*', '')
|
||||
+ 'encryption_passphrase: "test"'
|
||||
+ '\nencryption_passphrase: "test"'
|
||||
# Disable automatic storage of config files so we can test storage and extraction manually.
|
||||
+ '\nbootstrap:\n store_config_files: false'
|
||||
)
|
||||
config_file = open(config_path, 'w')
|
||||
config_file.write(config)
|
||||
config_file.close()
|
||||
|
||||
|
||||
def test_borgmatic_command():
|
||||
def generate_configuration_with_patterns(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 adding patterns, 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('source_directories:', '')
|
||||
.replace('- /home/user/path with spaces', '')
|
||||
.replace('- /home', '')
|
||||
.replace('- /etc', '')
|
||||
.replace('- /var/log/syslog*', '')
|
||||
+ f'\npatterns: ["R {config_path}"]'
|
||||
+ '\nencryption_passphrase: "test"'
|
||||
# Disable automatic storage of config files so we can test storage and extraction manually.
|
||||
+ '\nbootstrap:\n store_config_files: false'
|
||||
)
|
||||
config_file = open(config_path, 'w')
|
||||
config_file.write(config)
|
||||
config_file.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'generate_configuration',
|
||||
(generate_configuration_with_source_directories, generate_configuration_with_patterns),
|
||||
)
|
||||
def test_borgmatic_command(generate_configuration):
|
||||
# Create a Borg repository.
|
||||
temporary_directory = tempfile.mkdtemp()
|
||||
repository_path = os.path.join(temporary_directory, 'test.borg')
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -2,6 +2,7 @@ import pytest
|
||||
from flexmock import flexmock
|
||||
|
||||
from borgmatic.actions import check as module
|
||||
from borgmatic.borg.pattern import Pattern
|
||||
|
||||
|
||||
def test_parse_checks_returns_them_as_tuple():
|
||||
@@ -557,14 +558,20 @@ def test_collect_spot_check_source_paths_parses_borg_output():
|
||||
flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hooks').and_return(
|
||||
{'hook1': False, 'hook2': True}
|
||||
)
|
||||
flexmock(module.borgmatic.actions.create).should_receive(
|
||||
'process_source_directories'
|
||||
).and_return(['foo', 'bar'])
|
||||
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(
|
||||
flexmock()
|
||||
)
|
||||
flexmock(module.borgmatic.actions.create).should_receive('collect_patterns').and_return(
|
||||
flexmock()
|
||||
)
|
||||
flexmock(module.borgmatic.actions.create).should_receive('process_patterns').and_return(
|
||||
[Pattern('foo'), Pattern('bar')]
|
||||
)
|
||||
flexmock(module.borgmatic.borg.create).should_receive('make_base_create_command').with_args(
|
||||
dry_run=True,
|
||||
repository_path='repo',
|
||||
config=object,
|
||||
source_directories=['foo', 'bar'],
|
||||
patterns=[Pattern('foo'), Pattern('bar')],
|
||||
local_borg_version=object,
|
||||
global_arguments=object,
|
||||
borgmatic_runtime_directory='/run/borgmatic',
|
||||
@@ -572,7 +579,7 @@ def test_collect_spot_check_source_paths_parses_borg_output():
|
||||
remote_path=object,
|
||||
list_files=True,
|
||||
stream_processes=True,
|
||||
).and_return((('borg', 'create'), ('repo::archive',), flexmock(), flexmock()))
|
||||
).and_return((('borg', 'create'), ('repo::archive',), flexmock()))
|
||||
flexmock(module.borgmatic.borg.environment).should_receive('make_environment').and_return(
|
||||
flexmock()
|
||||
)
|
||||
@@ -599,14 +606,20 @@ def test_collect_spot_check_source_paths_passes_through_stream_processes_false()
|
||||
flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hooks').and_return(
|
||||
{'hook1': False, 'hook2': False}
|
||||
)
|
||||
flexmock(module.borgmatic.actions.create).should_receive(
|
||||
'process_source_directories'
|
||||
).and_return(['foo', 'bar'])
|
||||
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(
|
||||
flexmock()
|
||||
)
|
||||
flexmock(module.borgmatic.actions.create).should_receive('collect_patterns').and_return(
|
||||
flexmock()
|
||||
)
|
||||
flexmock(module.borgmatic.actions.create).should_receive('process_patterns').and_return(
|
||||
[Pattern('foo'), Pattern('bar')]
|
||||
)
|
||||
flexmock(module.borgmatic.borg.create).should_receive('make_base_create_command').with_args(
|
||||
dry_run=True,
|
||||
repository_path='repo',
|
||||
config=object,
|
||||
source_directories=['foo', 'bar'],
|
||||
patterns=[Pattern('foo'), Pattern('bar')],
|
||||
local_borg_version=object,
|
||||
global_arguments=object,
|
||||
borgmatic_runtime_directory='/run/borgmatic',
|
||||
@@ -614,7 +627,7 @@ def test_collect_spot_check_source_paths_passes_through_stream_processes_false()
|
||||
remote_path=object,
|
||||
list_files=True,
|
||||
stream_processes=False,
|
||||
).and_return((('borg', 'create'), ('repo::archive',), flexmock(), flexmock()))
|
||||
).and_return((('borg', 'create'), ('repo::archive',), flexmock()))
|
||||
flexmock(module.borgmatic.borg.environment).should_receive('make_environment').and_return(
|
||||
flexmock()
|
||||
)
|
||||
@@ -641,14 +654,20 @@ def test_collect_spot_check_source_paths_without_working_directory_parses_borg_o
|
||||
flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hooks').and_return(
|
||||
{'hook1': False, 'hook2': True}
|
||||
)
|
||||
flexmock(module.borgmatic.actions.create).should_receive(
|
||||
'process_source_directories'
|
||||
).and_return(['foo', 'bar'])
|
||||
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(
|
||||
flexmock()
|
||||
)
|
||||
flexmock(module.borgmatic.actions.create).should_receive('collect_patterns').and_return(
|
||||
flexmock()
|
||||
)
|
||||
flexmock(module.borgmatic.actions.create).should_receive('process_patterns').and_return(
|
||||
[Pattern('foo'), Pattern('bar')]
|
||||
)
|
||||
flexmock(module.borgmatic.borg.create).should_receive('make_base_create_command').with_args(
|
||||
dry_run=True,
|
||||
repository_path='repo',
|
||||
config=object,
|
||||
source_directories=['foo', 'bar'],
|
||||
patterns=[Pattern('foo'), Pattern('bar')],
|
||||
local_borg_version=object,
|
||||
global_arguments=object,
|
||||
borgmatic_runtime_directory='/run/borgmatic',
|
||||
@@ -656,7 +675,7 @@ def test_collect_spot_check_source_paths_without_working_directory_parses_borg_o
|
||||
remote_path=object,
|
||||
list_files=True,
|
||||
stream_processes=True,
|
||||
).and_return((('borg', 'create'), ('repo::archive',), flexmock(), flexmock()))
|
||||
).and_return((('borg', 'create'), ('repo::archive',), flexmock()))
|
||||
flexmock(module.borgmatic.borg.environment).should_receive('make_environment').and_return(
|
||||
flexmock()
|
||||
)
|
||||
@@ -683,14 +702,20 @@ def test_collect_spot_check_source_paths_skips_directories():
|
||||
flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hooks').and_return(
|
||||
{'hook1': False, 'hook2': True}
|
||||
)
|
||||
flexmock(module.borgmatic.actions.create).should_receive(
|
||||
'process_source_directories'
|
||||
).and_return(['foo', 'bar'])
|
||||
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(
|
||||
flexmock()
|
||||
)
|
||||
flexmock(module.borgmatic.actions.create).should_receive('collect_patterns').and_return(
|
||||
flexmock()
|
||||
)
|
||||
flexmock(module.borgmatic.actions.create).should_receive('process_patterns').and_return(
|
||||
[Pattern('foo'), Pattern('bar')]
|
||||
)
|
||||
flexmock(module.borgmatic.borg.create).should_receive('make_base_create_command').with_args(
|
||||
dry_run=True,
|
||||
repository_path='repo',
|
||||
config=object,
|
||||
source_directories=['foo', 'bar'],
|
||||
patterns=[Pattern('foo'), Pattern('bar')],
|
||||
local_borg_version=object,
|
||||
global_arguments=object,
|
||||
borgmatic_runtime_directory='/run/borgmatic',
|
||||
@@ -698,7 +723,7 @@ def test_collect_spot_check_source_paths_skips_directories():
|
||||
remote_path=object,
|
||||
list_files=True,
|
||||
stream_processes=True,
|
||||
).and_return((('borg', 'create'), ('repo::archive',), flexmock(), flexmock()))
|
||||
).and_return((('borg', 'create'), ('repo::archive',), flexmock()))
|
||||
flexmock(module.borgmatic.borg.environment).should_receive('make_environment').and_return(
|
||||
flexmock()
|
||||
)
|
||||
@@ -725,15 +750,16 @@ def test_collect_spot_check_source_paths_skips_directories():
|
||||
)
|
||||
|
||||
|
||||
def test_collect_spot_check_archive_paths_excludes_directories():
|
||||
def test_collect_spot_check_archive_paths_excludes_directories_and_pipes():
|
||||
flexmock(module.borgmatic.config.paths).should_receive(
|
||||
'get_borgmatic_source_directory'
|
||||
).and_return('/home/user/.borgmatic')
|
||||
flexmock(module.borgmatic.borg.list).should_receive('capture_archive_listing').and_return(
|
||||
(
|
||||
'f /etc/path',
|
||||
'f /etc/other',
|
||||
'd /etc/dir',
|
||||
'f etc/path',
|
||||
'p var/pipe',
|
||||
'f etc/other',
|
||||
'd etc/dir',
|
||||
)
|
||||
)
|
||||
|
||||
@@ -746,7 +772,7 @@ def test_collect_spot_check_archive_paths_excludes_directories():
|
||||
local_path=flexmock(),
|
||||
remote_path=flexmock(),
|
||||
borgmatic_runtime_directory='/run/user/1001/borgmatic',
|
||||
) == ('/etc/path', '/etc/other')
|
||||
) == ('etc/path', 'etc/other')
|
||||
|
||||
|
||||
def test_collect_spot_check_archive_paths_excludes_file_in_borgmatic_runtime_directory_as_stored_with_prefix_truncation():
|
||||
@@ -755,8 +781,8 @@ def test_collect_spot_check_archive_paths_excludes_file_in_borgmatic_runtime_dir
|
||||
).and_return('/root/.borgmatic')
|
||||
flexmock(module.borgmatic.borg.list).should_receive('capture_archive_listing').and_return(
|
||||
(
|
||||
'f /etc/path',
|
||||
'f /borgmatic/some/thing',
|
||||
'f etc/path',
|
||||
'f borgmatic/some/thing',
|
||||
)
|
||||
)
|
||||
|
||||
@@ -769,7 +795,7 @@ def test_collect_spot_check_archive_paths_excludes_file_in_borgmatic_runtime_dir
|
||||
local_path=flexmock(),
|
||||
remote_path=flexmock(),
|
||||
borgmatic_runtime_directory='/run/user/0/borgmatic',
|
||||
) == ('/etc/path',)
|
||||
) == ('etc/path',)
|
||||
|
||||
|
||||
def test_collect_spot_check_archive_paths_excludes_file_in_borgmatic_source_directory():
|
||||
@@ -778,8 +804,8 @@ def test_collect_spot_check_archive_paths_excludes_file_in_borgmatic_source_dire
|
||||
).and_return('/root/.borgmatic')
|
||||
flexmock(module.borgmatic.borg.list).should_receive('capture_archive_listing').and_return(
|
||||
(
|
||||
'f /etc/path',
|
||||
'f /root/.borgmatic/some/thing',
|
||||
'f etc/path',
|
||||
'f root/.borgmatic/some/thing',
|
||||
)
|
||||
)
|
||||
|
||||
@@ -792,7 +818,7 @@ def test_collect_spot_check_archive_paths_excludes_file_in_borgmatic_source_dire
|
||||
local_path=flexmock(),
|
||||
remote_path=flexmock(),
|
||||
borgmatic_runtime_directory='/run/user/0/borgmatic',
|
||||
) == ('/etc/path',)
|
||||
) == ('etc/path',)
|
||||
|
||||
|
||||
def test_collect_spot_check_archive_paths_excludes_file_in_borgmatic_runtime_directory():
|
||||
@@ -801,8 +827,8 @@ def test_collect_spot_check_archive_paths_excludes_file_in_borgmatic_runtime_dir
|
||||
).and_return('/root.borgmatic')
|
||||
flexmock(module.borgmatic.borg.list).should_receive('capture_archive_listing').and_return(
|
||||
(
|
||||
'f /etc/path',
|
||||
'f /run/user/0/borgmatic/some/thing',
|
||||
'f etc/path',
|
||||
'f run/user/0/borgmatic/some/thing',
|
||||
)
|
||||
)
|
||||
|
||||
@@ -815,21 +841,27 @@ def test_collect_spot_check_archive_paths_excludes_file_in_borgmatic_runtime_dir
|
||||
local_path=flexmock(),
|
||||
remote_path=flexmock(),
|
||||
borgmatic_runtime_directory='/run/user/0/borgmatic',
|
||||
) == ('/etc/path',)
|
||||
) == ('etc/path',)
|
||||
|
||||
|
||||
def test_collect_spot_check_source_paths_uses_working_directory():
|
||||
flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hooks').and_return(
|
||||
{'hook1': False, 'hook2': True}
|
||||
)
|
||||
flexmock(module.borgmatic.actions.create).should_receive(
|
||||
'process_source_directories'
|
||||
).and_return(['foo', 'bar'])
|
||||
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(
|
||||
flexmock()
|
||||
)
|
||||
flexmock(module.borgmatic.actions.create).should_receive('collect_patterns').and_return(
|
||||
flexmock()
|
||||
)
|
||||
flexmock(module.borgmatic.actions.create).should_receive('process_patterns').and_return(
|
||||
[Pattern('foo'), Pattern('bar')]
|
||||
)
|
||||
flexmock(module.borgmatic.borg.create).should_receive('make_base_create_command').with_args(
|
||||
dry_run=True,
|
||||
repository_path='repo',
|
||||
config=object,
|
||||
source_directories=['foo', 'bar'],
|
||||
patterns=[Pattern('foo'), Pattern('bar')],
|
||||
local_borg_version=object,
|
||||
global_arguments=object,
|
||||
borgmatic_runtime_directory='/run/borgmatic',
|
||||
@@ -837,7 +869,7 @@ def test_collect_spot_check_source_paths_uses_working_directory():
|
||||
remote_path=object,
|
||||
list_files=True,
|
||||
stream_processes=True,
|
||||
).and_return((('borg', 'create'), ('repo::archive',), flexmock(), flexmock()))
|
||||
).and_return((('borg', 'create'), ('repo::archive',), flexmock()))
|
||||
flexmock(module.borgmatic.borg.environment).should_receive('make_environment').and_return(
|
||||
flexmock()
|
||||
)
|
||||
@@ -877,7 +909,7 @@ def test_compare_spot_check_hashes_returns_paths_having_failing_hashes():
|
||||
'hash1 /foo\nhash2 /bar'
|
||||
)
|
||||
flexmock(module.borgmatic.borg.list).should_receive('capture_archive_listing').and_return(
|
||||
['hash1 /foo', 'nothash2 /bar']
|
||||
['hash1 foo', 'nothash2 bar']
|
||||
)
|
||||
|
||||
assert module.compare_spot_check_hashes(
|
||||
@@ -899,11 +931,50 @@ 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',)
|
||||
|
||||
|
||||
def test_compare_spot_check_hashes_returns_relative_paths_having_failing_hashes():
|
||||
flexmock(module.random).should_receive('sample').replace_with(
|
||||
lambda population, count: population[:count]
|
||||
)
|
||||
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(
|
||||
None,
|
||||
)
|
||||
flexmock(module.os.path).should_receive('exists').and_return(True)
|
||||
flexmock(module.borgmatic.execute).should_receive(
|
||||
'execute_command_and_capture_output'
|
||||
).with_args(('xxh64sum', 'foo', 'bar'), working_directory=None).and_return(
|
||||
'hash1 foo\nhash2 bar'
|
||||
)
|
||||
flexmock(module.borgmatic.borg.list).should_receive('capture_archive_listing').and_return(
|
||||
['hash1 foo', 'nothash2 bar']
|
||||
)
|
||||
|
||||
assert module.compare_spot_check_hashes(
|
||||
repository={'path': 'repo'},
|
||||
archive='archive',
|
||||
config={
|
||||
'checks': [
|
||||
{
|
||||
'name': 'archives',
|
||||
'frequency': '2 weeks',
|
||||
},
|
||||
{
|
||||
'name': 'spot',
|
||||
'data_sample_percentage': 50,
|
||||
},
|
||||
]
|
||||
},
|
||||
local_borg_version=flexmock(),
|
||||
global_arguments=flexmock(),
|
||||
local_path=flexmock(),
|
||||
remote_path=flexmock(),
|
||||
source_paths=('foo', 'bar', 'baz', 'quux'),
|
||||
) == ('bar',)
|
||||
|
||||
|
||||
def test_compare_spot_check_hashes_handles_data_sample_percentage_above_100():
|
||||
flexmock(module.random).should_receive('sample').replace_with(
|
||||
lambda population, count: population[:count]
|
||||
@@ -918,7 +989,7 @@ def test_compare_spot_check_hashes_handles_data_sample_percentage_above_100():
|
||||
'hash1 /foo\nhash2 /bar'
|
||||
)
|
||||
flexmock(module.borgmatic.borg.list).should_receive('capture_archive_listing').and_return(
|
||||
['nothash1 /foo', 'nothash2 /bar']
|
||||
['nothash1 foo', 'nothash2 bar']
|
||||
)
|
||||
|
||||
assert module.compare_spot_check_hashes(
|
||||
@@ -940,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')
|
||||
|
||||
@@ -959,7 +1029,7 @@ def test_compare_spot_check_hashes_uses_xxh64sum_command_option():
|
||||
'hash1 /foo\nhash2 /bar'
|
||||
)
|
||||
flexmock(module.borgmatic.borg.list).should_receive('capture_archive_listing').and_return(
|
||||
['hash1 /foo', 'nothash2 /bar']
|
||||
['hash1 foo', 'nothash2 bar']
|
||||
)
|
||||
|
||||
assert module.compare_spot_check_hashes(
|
||||
@@ -978,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',)
|
||||
|
||||
@@ -997,7 +1066,7 @@ def test_compare_spot_check_hashes_considers_path_missing_from_archive_as_not_ma
|
||||
'hash1 /foo\nhash2 /bar'
|
||||
)
|
||||
flexmock(module.borgmatic.borg.list).should_receive('capture_archive_listing').and_return(
|
||||
['hash1 /foo']
|
||||
['hash1 foo']
|
||||
)
|
||||
|
||||
assert module.compare_spot_check_hashes(
|
||||
@@ -1015,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',)
|
||||
|
||||
@@ -1033,7 +1101,7 @@ def test_compare_spot_check_hashes_considers_non_existent_path_as_not_matching()
|
||||
'execute_command_and_capture_output'
|
||||
).with_args(('xxh64sum', '/foo'), working_directory=None).and_return('hash1 /foo')
|
||||
flexmock(module.borgmatic.borg.list).should_receive('capture_archive_listing').and_return(
|
||||
['hash1 /foo', 'hash2 /bar']
|
||||
['hash1 foo', 'hash2 bar']
|
||||
)
|
||||
|
||||
assert module.compare_spot_check_hashes(
|
||||
@@ -1051,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',)
|
||||
|
||||
@@ -1076,8 +1143,8 @@ def test_compare_spot_check_hashes_with_too_many_paths_feeds_them_to_commands_in
|
||||
'hash3 /baz\nhash4 /quux'
|
||||
)
|
||||
flexmock(module.borgmatic.borg.list).should_receive('capture_archive_listing').and_return(
|
||||
['hash1 /foo', 'hash2 /bar']
|
||||
).and_return(['hash3 /baz', 'nothash4 /quux'])
|
||||
['hash1 foo', 'hash2 bar']
|
||||
).and_return(['hash3 baz', 'nothash4 quux'])
|
||||
|
||||
assert module.compare_spot_check_hashes(
|
||||
repository={'path': 'repo'},
|
||||
@@ -1098,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',)
|
||||
|
||||
@@ -1141,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',)
|
||||
|
||||
@@ -1178,7 +1243,7 @@ def test_spot_check_without_any_configuration_errors():
|
||||
)
|
||||
|
||||
|
||||
def test_spot_check_data_tolerance_percenatge_greater_than_data_sample_percentage_errors():
|
||||
def test_spot_check_data_tolerance_percentage_greater_than_data_sample_percentage_errors():
|
||||
with pytest.raises(ValueError):
|
||||
module.spot_check(
|
||||
repository={'path': 'repo'},
|
||||
@@ -1296,6 +1361,35 @@ def test_spot_check_with_high_enough_tolerances_does_not_raise():
|
||||
)
|
||||
|
||||
|
||||
def test_spot_check_without_any_source_paths_errors():
|
||||
flexmock(module).should_receive('collect_spot_check_source_paths').and_return(())
|
||||
flexmock(module.borgmatic.borg.repo_list).should_receive('resolve_archive_name').and_return(
|
||||
'archive'
|
||||
)
|
||||
flexmock(module).should_receive('collect_spot_check_archive_paths').and_return(('/foo', '/bar'))
|
||||
flexmock(module).should_receive('compare_spot_check_hashes').never()
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
module.spot_check(
|
||||
repository={'path': 'repo'},
|
||||
config={
|
||||
'checks': [
|
||||
{
|
||||
'name': 'spot',
|
||||
'count_tolerance_percentage': 10,
|
||||
'data_tolerance_percentage': 40,
|
||||
'data_sample_percentage': 50,
|
||||
},
|
||||
]
|
||||
},
|
||||
local_borg_version=flexmock(),
|
||||
global_arguments=flexmock(),
|
||||
local_path=flexmock(),
|
||||
remote_path=flexmock(),
|
||||
borgmatic_runtime_directory='/run/borgmatic',
|
||||
)
|
||||
|
||||
|
||||
def test_run_check_checks_archives_for_configured_repository():
|
||||
flexmock(module.logger).answer = lambda message: None
|
||||
flexmock(module.borgmatic.config.validate).should_receive('repositories_match').never()
|
||||
|
||||
+316
-101
@@ -1,7 +1,121 @@
|
||||
import io
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
from flexmock import flexmock
|
||||
|
||||
from borgmatic.actions import create as module
|
||||
from borgmatic.borg.pattern import Pattern, Pattern_style, Pattern_type
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'pattern_line,expected_pattern',
|
||||
(
|
||||
('R /foo', Pattern('/foo')),
|
||||
('P sh', Pattern('sh', Pattern_type.PATTERN_STYLE)),
|
||||
('+ /foo*', Pattern('/foo*', Pattern_type.INCLUDE)),
|
||||
('+ sh:/foo*', Pattern('/foo*', Pattern_type.INCLUDE, Pattern_style.SHELL)),
|
||||
),
|
||||
)
|
||||
def test_parse_pattern_transforms_pattern_line_to_instance(pattern_line, expected_pattern):
|
||||
module.parse_pattern(pattern_line) == expected_pattern
|
||||
|
||||
|
||||
def test_parse_pattern_with_invalid_pattern_line_errors():
|
||||
with pytest.raises(ValueError):
|
||||
module.parse_pattern('/foo')
|
||||
|
||||
|
||||
def test_collect_patterns_converts_source_directories():
|
||||
assert module.collect_patterns({'source_directories': ['/foo', '/bar']}) == (
|
||||
Pattern('/foo'),
|
||||
Pattern('/bar'),
|
||||
)
|
||||
|
||||
|
||||
def test_collect_patterns_parses_config_patterns():
|
||||
flexmock(module).should_receive('parse_pattern').with_args('R /foo').and_return(Pattern('/foo'))
|
||||
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('R /bar').and_return(Pattern('/bar'))
|
||||
|
||||
assert module.collect_patterns({'patterns': ['R /foo', '# comment', '', ' ', 'R /bar']}) == (
|
||||
Pattern('/foo'),
|
||||
Pattern('/bar'),
|
||||
)
|
||||
|
||||
|
||||
def test_collect_patterns_converts_exclude_patterns():
|
||||
assert module.collect_patterns({'exclude_patterns': ['/foo', '/bar', 'sh:**/baz']}) == (
|
||||
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),
|
||||
)
|
||||
|
||||
|
||||
def test_collect_patterns_reads_config_patterns_from_file():
|
||||
builtins = flexmock(sys.modules['builtins'])
|
||||
builtins.should_receive('open').with_args('file1.txt').and_return(io.StringIO('R /foo'))
|
||||
builtins.should_receive('open').with_args('file2.txt').and_return(
|
||||
io.StringIO('R /bar\n# comment\n\n \nR /baz')
|
||||
)
|
||||
flexmock(module).should_receive('parse_pattern').with_args('R /foo').and_return(Pattern('/foo'))
|
||||
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('R /bar').and_return(Pattern('/bar'))
|
||||
flexmock(module).should_receive('parse_pattern').with_args('R /baz').and_return(Pattern('/baz'))
|
||||
|
||||
assert module.collect_patterns({'patterns_from': ['file1.txt', 'file2.txt']}) == (
|
||||
Pattern('/foo'),
|
||||
Pattern('/bar'),
|
||||
Pattern('/baz'),
|
||||
)
|
||||
|
||||
|
||||
def test_collect_patterns_errors_on_missing_config_patterns_from_file():
|
||||
builtins = flexmock(sys.modules['builtins'])
|
||||
builtins.should_receive('open').with_args('file1.txt').and_raise(FileNotFoundError)
|
||||
flexmock(module).should_receive('parse_pattern').never()
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
module.collect_patterns({'patterns_from': ['file1.txt', 'file2.txt']})
|
||||
|
||||
|
||||
def test_collect_patterns_reads_config_exclude_from_file():
|
||||
builtins = flexmock(sys.modules['builtins'])
|
||||
builtins.should_receive('open').with_args('file1.txt').and_return(io.StringIO('/foo'))
|
||||
builtins.should_receive('open').with_args('file2.txt').and_return(
|
||||
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.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.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.NO_RECURSE, Pattern_style.FNMATCH))
|
||||
|
||||
assert module.collect_patterns({'exclude_from': ['file1.txt', 'file2.txt']}) == (
|
||||
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),
|
||||
)
|
||||
|
||||
|
||||
def test_collect_patterns_errors_on_missing_config_exclude_from_file():
|
||||
builtins = flexmock(sys.modules['builtins'])
|
||||
builtins.should_receive('open').with_args('file1.txt').and_raise(OSError)
|
||||
flexmock(module).should_receive('parse_pattern').never()
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
module.collect_patterns({'exclude_from': ['file1.txt', 'file2.txt']})
|
||||
|
||||
|
||||
def test_expand_directory_with_basic_path_passes_it_through():
|
||||
@@ -22,16 +136,16 @@ def test_expand_directory_with_glob_expands():
|
||||
assert paths == ['foo', 'food']
|
||||
|
||||
|
||||
def test_expand_directory_with_working_directory_passes_it_through():
|
||||
def test_expand_directory_strips_off_working_directory():
|
||||
flexmock(module.os.path).should_receive('expanduser').and_return('foo')
|
||||
flexmock(module.glob).should_receive('glob').with_args('/working/dir/foo').and_return([]).once()
|
||||
|
||||
paths = module.expand_directory('foo', working_directory='/working/dir')
|
||||
|
||||
assert paths == ['/working/dir/foo']
|
||||
assert paths == ['foo']
|
||||
|
||||
|
||||
def test_expand_directory_with_glob_passes_through_working_directory():
|
||||
def test_expand_directory_globs_working_directory_and_strips_it_off():
|
||||
flexmock(module.os.path).should_receive('expanduser').and_return('foo*')
|
||||
flexmock(module.glob).should_receive('glob').with_args('/working/dir/foo*').and_return(
|
||||
['/working/dir/foo', '/working/dir/food']
|
||||
@@ -39,10 +153,32 @@ def test_expand_directory_with_glob_passes_through_working_directory():
|
||||
|
||||
paths = module.expand_directory('foo*', working_directory='/working/dir')
|
||||
|
||||
assert paths == ['/working/dir/foo', '/working/dir/food']
|
||||
assert paths == ['foo', 'food']
|
||||
|
||||
|
||||
def test_expand_directories_flattens_expanded_directories():
|
||||
def test_expand_directory_with_slashdot_hack_globs_working_directory_and_strips_it_off():
|
||||
flexmock(module.os.path).should_receive('expanduser').and_return('./foo*')
|
||||
flexmock(module.glob).should_receive('glob').with_args('/working/dir/./foo*').and_return(
|
||||
['/working/dir/./foo', '/working/dir/./food']
|
||||
).once()
|
||||
|
||||
paths = module.expand_directory('./foo*', working_directory='/working/dir')
|
||||
|
||||
assert paths == ['./foo', './food']
|
||||
|
||||
|
||||
def test_expand_directory_with_working_directory_matching_start_of_directory_does_not_strip_it_off():
|
||||
flexmock(module.os.path).should_receive('expanduser').and_return('/working/dir/foo')
|
||||
flexmock(module.glob).should_receive('glob').with_args('/working/dir/foo').and_return(
|
||||
['/working/dir/foo']
|
||||
).once()
|
||||
|
||||
paths = module.expand_directory('/working/dir/foo', working_directory='/working/dir')
|
||||
|
||||
assert paths == ['/working/dir/foo']
|
||||
|
||||
|
||||
def test_expand_patterns_flattens_expanded_directories():
|
||||
flexmock(module).should_receive('expand_directory').with_args('~/foo', None).and_return(
|
||||
['/root/foo']
|
||||
)
|
||||
@@ -50,148 +186,224 @@ def test_expand_directories_flattens_expanded_directories():
|
||||
['bar', 'barf']
|
||||
)
|
||||
|
||||
paths = module.expand_directories(('~/foo', 'bar*'))
|
||||
paths = module.expand_patterns((Pattern('~/foo'), Pattern('bar*')))
|
||||
|
||||
assert paths == ('/root/foo', 'bar', 'barf')
|
||||
assert paths == (Pattern('/root/foo'), Pattern('bar'), Pattern('barf'))
|
||||
|
||||
|
||||
def test_expand_directories_with_working_directory_passes_it_through():
|
||||
def test_expand_patterns_with_working_directory_passes_it_through():
|
||||
flexmock(module).should_receive('expand_directory').with_args('foo', '/working/dir').and_return(
|
||||
['/working/dir/foo']
|
||||
)
|
||||
|
||||
paths = module.expand_directories(('foo',), working_directory='/working/dir')
|
||||
patterns = module.expand_patterns((Pattern('foo'),), working_directory='/working/dir')
|
||||
|
||||
assert paths == ('/working/dir/foo',)
|
||||
assert patterns == (Pattern('/working/dir/foo'),)
|
||||
|
||||
|
||||
def test_expand_directories_considers_none_as_no_directories():
|
||||
paths = module.expand_directories(None, None)
|
||||
def test_expand_patterns_does_not_expand_skip_paths():
|
||||
flexmock(module).should_receive('expand_directory').with_args('/foo', None).and_return(['/foo'])
|
||||
flexmock(module).should_receive('expand_directory').with_args('/bar*', None).never()
|
||||
|
||||
assert paths == ()
|
||||
patterns = module.expand_patterns((Pattern('/foo'), Pattern('/bar*')), skip_paths=('/bar*',))
|
||||
|
||||
assert patterns == (Pattern('/foo'), Pattern('/bar*'))
|
||||
|
||||
|
||||
def test_map_directories_to_devices_gives_device_id_per_path():
|
||||
def test_expand_patterns_considers_none_as_no_patterns():
|
||||
assert module.expand_patterns(None) == ()
|
||||
|
||||
|
||||
def test_expand_patterns_only_considers_root_patterns():
|
||||
flexmock(module).should_receive('expand_directory').with_args('~/foo', None).and_return(
|
||||
['/root/foo']
|
||||
)
|
||||
flexmock(module).should_receive('expand_directory').with_args('bar*', None).never()
|
||||
|
||||
paths = module.expand_patterns((Pattern('~/foo'), Pattern('bar*', Pattern_type.INCLUDE)))
|
||||
|
||||
assert paths == (Pattern('/root/foo'), Pattern('bar*', Pattern_type.INCLUDE))
|
||||
|
||||
|
||||
def test_device_map_patterns_gives_device_id_per_path():
|
||||
flexmock(module.os.path).should_receive('exists').and_return(True)
|
||||
flexmock(module.os).should_receive('stat').with_args('/foo').and_return(flexmock(st_dev=55))
|
||||
flexmock(module.os).should_receive('stat').with_args('/bar').and_return(flexmock(st_dev=66))
|
||||
|
||||
device_map = module.map_directories_to_devices(('/foo', '/bar'))
|
||||
device_map = module.device_map_patterns((Pattern('/foo'), Pattern('/bar')))
|
||||
|
||||
assert device_map == {
|
||||
'/foo': 55,
|
||||
'/bar': 66,
|
||||
}
|
||||
assert device_map == (
|
||||
Pattern('/foo', device=55),
|
||||
Pattern('/bar', device=66),
|
||||
)
|
||||
|
||||
|
||||
def test_map_directories_to_devices_with_missing_path_does_not_error():
|
||||
def test_device_map_patterns_only_considers_root_patterns():
|
||||
flexmock(module.os.path).should_receive('exists').and_return(True)
|
||||
flexmock(module.os).should_receive('stat').with_args('/foo').and_return(flexmock(st_dev=55))
|
||||
flexmock(module.os).should_receive('stat').with_args('/bar*').never()
|
||||
|
||||
device_map = module.device_map_patterns(
|
||||
(Pattern('/foo'), Pattern('/bar*', Pattern_type.INCLUDE))
|
||||
)
|
||||
|
||||
assert device_map == (
|
||||
Pattern('/foo', device=55),
|
||||
Pattern('/bar*', Pattern_type.INCLUDE),
|
||||
)
|
||||
|
||||
|
||||
def test_device_map_patterns_with_missing_path_does_not_error():
|
||||
flexmock(module.os.path).should_receive('exists').and_return(True).and_return(False)
|
||||
flexmock(module.os).should_receive('stat').with_args('/foo').and_return(flexmock(st_dev=55))
|
||||
flexmock(module.os).should_receive('stat').with_args('/bar').never()
|
||||
|
||||
device_map = module.map_directories_to_devices(('/foo', '/bar'))
|
||||
device_map = module.device_map_patterns((Pattern('/foo'), Pattern('/bar')))
|
||||
|
||||
assert device_map == {
|
||||
'/foo': 55,
|
||||
'/bar': None,
|
||||
}
|
||||
assert device_map == (
|
||||
Pattern('/foo', device=55),
|
||||
Pattern('/bar'),
|
||||
)
|
||||
|
||||
|
||||
def test_map_directories_to_devices_uses_working_directory_to_construct_path():
|
||||
def test_device_map_patterns_uses_working_directory_to_construct_path():
|
||||
flexmock(module.os.path).should_receive('exists').and_return(True)
|
||||
flexmock(module.os).should_receive('stat').with_args('/foo').and_return(flexmock(st_dev=55))
|
||||
flexmock(module.os).should_receive('stat').with_args('/working/dir/bar').and_return(
|
||||
flexmock(st_dev=66)
|
||||
)
|
||||
|
||||
device_map = module.map_directories_to_devices(
|
||||
('/foo', 'bar'), working_directory='/working/dir'
|
||||
device_map = module.device_map_patterns(
|
||||
(Pattern('/foo'), Pattern('bar')), working_directory='/working/dir'
|
||||
)
|
||||
|
||||
assert device_map == {
|
||||
'/foo': 55,
|
||||
'bar': 66,
|
||||
}
|
||||
assert device_map == (
|
||||
Pattern('/foo', device=55),
|
||||
Pattern('bar', device=66),
|
||||
)
|
||||
|
||||
|
||||
def test_device_map_patterns_with_existing_device_id_does_not_overwrite_it():
|
||||
flexmock(module.os.path).should_receive('exists').and_return(True)
|
||||
flexmock(module.os).should_receive('stat').with_args('/foo').and_return(flexmock(st_dev=55))
|
||||
flexmock(module.os).should_receive('stat').with_args('/bar').and_return(flexmock(st_dev=100))
|
||||
|
||||
device_map = module.device_map_patterns((Pattern('/foo'), Pattern('/bar', device=66)))
|
||||
|
||||
assert device_map == (
|
||||
Pattern('/foo', device=55),
|
||||
Pattern('/bar', device=66),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'directories,additional_directories,expected_directories',
|
||||
'patterns,expected_patterns',
|
||||
(
|
||||
({'/': 1, '/root': 1}, {}, ['/']),
|
||||
({'/': 1, '/root/': 1}, {}, ['/']),
|
||||
({'/': 1, '/root': 2}, {}, ['/', '/root']),
|
||||
({'/root': 1, '/': 1}, {}, ['/']),
|
||||
({'/root': 1, '/root/foo': 1}, {}, ['/root']),
|
||||
({'/root/': 1, '/root/foo': 1}, {}, ['/root/']),
|
||||
({'/root': 1, '/root/foo/': 1}, {}, ['/root']),
|
||||
({'/root': 1, '/root/foo': 2}, {}, ['/root', '/root/foo']),
|
||||
({'/root/foo': 1, '/root': 1}, {}, ['/root']),
|
||||
({'/root': None, '/root/foo': None}, {}, ['/root', '/root/foo']),
|
||||
({'/root': 1, '/etc': 1, '/root/foo/bar': 1}, {}, ['/etc', '/root']),
|
||||
({'/root': 1, '/root/foo': 1, '/root/foo/bar': 1}, {}, ['/root']),
|
||||
({'/dup': 1, '/dup': 1}, {}, ['/dup']),
|
||||
({'/foo': 1, '/bar': 1}, {}, ['/bar', '/foo']),
|
||||
({'/foo': 1, '/bar': 2}, {}, ['/bar', '/foo']),
|
||||
({'/root/foo': 1}, {'/root': 1}, []),
|
||||
({'/root/foo': 1}, {'/root': 2}, ['/root/foo']),
|
||||
({'/root/foo': 1}, {}, ['/root/foo']),
|
||||
((Pattern('/', device=1), Pattern('/root', device=1)), (Pattern('/', device=1),)),
|
||||
((Pattern('/', device=1), Pattern('/root/', device=1)), (Pattern('/', device=1),)),
|
||||
(
|
||||
(Pattern('/', device=1), Pattern('/root', device=2)),
|
||||
(Pattern('/', device=1), Pattern('/root', device=2)),
|
||||
),
|
||||
((Pattern('/root', device=1), Pattern('/', device=1)), (Pattern('/', device=1),)),
|
||||
(
|
||||
(Pattern('/root', device=1), Pattern('/root/foo', device=1)),
|
||||
(Pattern('/root', device=1),),
|
||||
),
|
||||
(
|
||||
(Pattern('/root/', device=1), Pattern('/root/foo', device=1)),
|
||||
(Pattern('/root/', device=1),),
|
||||
),
|
||||
(
|
||||
(Pattern('/root', device=1), Pattern('/root/foo/', device=1)),
|
||||
(Pattern('/root', device=1),),
|
||||
),
|
||||
(
|
||||
(Pattern('/root', device=1), Pattern('/root/foo', device=2)),
|
||||
(Pattern('/root', device=1), Pattern('/root/foo', device=2)),
|
||||
),
|
||||
(
|
||||
(Pattern('/root/foo', device=1), Pattern('/root', device=1)),
|
||||
(Pattern('/root', device=1),),
|
||||
),
|
||||
(
|
||||
(Pattern('/root', device=None), Pattern('/root/foo', device=None)),
|
||||
(Pattern('/root'), Pattern('/root/foo')),
|
||||
),
|
||||
(
|
||||
(
|
||||
Pattern('/root', device=1),
|
||||
Pattern('/etc', device=1),
|
||||
Pattern('/root/foo/bar', device=1),
|
||||
),
|
||||
(Pattern('/root', device=1), Pattern('/etc', device=1)),
|
||||
),
|
||||
(
|
||||
(
|
||||
Pattern('/root', device=1),
|
||||
Pattern('/root/foo', device=1),
|
||||
Pattern('/root/foo/bar', device=1),
|
||||
),
|
||||
(Pattern('/root', device=1),),
|
||||
),
|
||||
((Pattern('/dup', device=1), Pattern('/dup', device=1)), (Pattern('/dup', device=1),)),
|
||||
(
|
||||
(Pattern('/foo', device=1), Pattern('/bar', device=1)),
|
||||
(Pattern('/foo', device=1), Pattern('/bar', device=1)),
|
||||
),
|
||||
(
|
||||
(Pattern('/foo', device=1), Pattern('/bar', device=2)),
|
||||
(Pattern('/foo', device=1), Pattern('/bar', device=2)),
|
||||
),
|
||||
((Pattern('/root/foo', device=1),), (Pattern('/root/foo', device=1),)),
|
||||
(
|
||||
(Pattern('/', device=1), Pattern('/root', Pattern_type.INCLUDE, device=1)),
|
||||
(Pattern('/', device=1), Pattern('/root', Pattern_type.INCLUDE, device=1)),
|
||||
),
|
||||
(
|
||||
(Pattern('/root', Pattern_type.INCLUDE, device=1), Pattern('/', device=1)),
|
||||
(Pattern('/root', Pattern_type.INCLUDE, device=1), Pattern('/', device=1)),
|
||||
),
|
||||
),
|
||||
)
|
||||
def test_deduplicate_directories_removes_child_paths_on_the_same_filesystem(
|
||||
directories, additional_directories, expected_directories
|
||||
):
|
||||
assert (
|
||||
module.deduplicate_directories(directories, additional_directories) == expected_directories
|
||||
def test_deduplicate_patterns_omits_child_paths_on_the_same_filesystem(patterns, expected_patterns):
|
||||
assert module.deduplicate_patterns(patterns) == expected_patterns
|
||||
|
||||
|
||||
def test_process_patterns_includes_patterns():
|
||||
flexmock(module).should_receive('deduplicate_patterns').and_return(
|
||||
(Pattern('foo'), Pattern('bar'))
|
||||
)
|
||||
|
||||
|
||||
def test_pattern_root_directories_deals_with_none_patterns():
|
||||
assert module.pattern_root_directories(patterns=None) == []
|
||||
|
||||
|
||||
def test_pattern_root_directories_parses_roots_and_ignores_others():
|
||||
assert module.pattern_root_directories(
|
||||
['R /root', '+ /root/foo', '- /root/foo/bar', 'R /baz']
|
||||
) == ['/root', '/baz']
|
||||
|
||||
|
||||
def test_process_source_directories_includes_source_directories():
|
||||
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(
|
||||
'/working'
|
||||
)
|
||||
flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar'))
|
||||
flexmock(module).should_receive('map_directories_to_devices').and_return({})
|
||||
flexmock(module).should_receive('expand_directories').with_args(
|
||||
('foo', 'bar'), working_directory='/working'
|
||||
flexmock(module).should_receive('device_map_patterns').and_return({})
|
||||
flexmock(module).should_receive('expand_patterns').with_args(
|
||||
(Pattern('foo'), Pattern('bar')),
|
||||
working_directory='/working',
|
||||
skip_paths=set(),
|
||||
).and_return(()).once()
|
||||
flexmock(module).should_receive('pattern_root_directories').and_return(())
|
||||
flexmock(module).should_receive('expand_directories').with_args(
|
||||
(), working_directory='/working'
|
||||
).and_return(())
|
||||
|
||||
assert module.process_source_directories(
|
||||
config={'source_directories': ['foo', 'bar']},
|
||||
) == ('foo', 'bar')
|
||||
assert module.process_patterns(
|
||||
(Pattern('foo'), Pattern('bar')),
|
||||
working_directory='/working',
|
||||
) == [Pattern('foo'), Pattern('bar')]
|
||||
|
||||
|
||||
def test_process_source_directories_prefers_source_directory_argument_to_config():
|
||||
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(
|
||||
'/working'
|
||||
def test_process_patterns_skips_expand_for_requested_paths():
|
||||
skip_paths = {flexmock()}
|
||||
flexmock(module).should_receive('deduplicate_patterns').and_return(
|
||||
(Pattern('foo'), Pattern('bar'))
|
||||
)
|
||||
flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar'))
|
||||
flexmock(module).should_receive('map_directories_to_devices').and_return({})
|
||||
flexmock(module).should_receive('expand_directories').with_args(
|
||||
('foo', 'bar'), working_directory='/working'
|
||||
flexmock(module).should_receive('device_map_patterns').and_return({})
|
||||
flexmock(module).should_receive('expand_patterns').with_args(
|
||||
(Pattern('foo'), Pattern('bar')),
|
||||
working_directory='/working',
|
||||
skip_paths=skip_paths,
|
||||
).and_return(()).once()
|
||||
flexmock(module).should_receive('pattern_root_directories').and_return(())
|
||||
flexmock(module).should_receive('expand_directories').with_args(
|
||||
(), working_directory='/working'
|
||||
).and_return(())
|
||||
|
||||
assert module.process_source_directories(
|
||||
config={'source_directories': ['nope']},
|
||||
source_directories=['foo', 'bar'],
|
||||
) == ('foo', 'bar')
|
||||
assert module.process_patterns(
|
||||
(Pattern('foo'), Pattern('bar')),
|
||||
working_directory='/working',
|
||||
skip_expand_paths=skip_paths,
|
||||
) == [Pattern('foo'), Pattern('bar')]
|
||||
|
||||
|
||||
def test_run_create_executes_and_calls_hooks_for_configured_repository():
|
||||
@@ -206,7 +418,8 @@ def test_run_create_executes_and_calls_hooks_for_configured_repository():
|
||||
flexmock(module.borgmatic.hooks.dispatch).should_receive(
|
||||
'call_hooks_even_if_unconfigured'
|
||||
).and_return({})
|
||||
flexmock(module).should_receive('process_source_directories').and_return([])
|
||||
flexmock(module).should_receive('collect_patterns').and_return(())
|
||||
flexmock(module).should_receive('process_patterns').and_return([])
|
||||
flexmock(module.os.path).should_receive('join').and_return('/run/borgmatic/bootstrap')
|
||||
create_arguments = flexmock(
|
||||
repository=None,
|
||||
@@ -248,7 +461,8 @@ def test_run_create_runs_with_selected_repository():
|
||||
flexmock(module.borgmatic.hooks.dispatch).should_receive(
|
||||
'call_hooks_even_if_unconfigured'
|
||||
).and_return({})
|
||||
flexmock(module).should_receive('process_source_directories').and_return([])
|
||||
flexmock(module).should_receive('collect_patterns').and_return(())
|
||||
flexmock(module).should_receive('process_patterns').and_return([])
|
||||
flexmock(module.os.path).should_receive('join').and_return('/run/borgmatic/bootstrap')
|
||||
create_arguments = flexmock(
|
||||
repository=flexmock(),
|
||||
@@ -327,7 +541,8 @@ def test_run_create_produces_json():
|
||||
flexmock(module.borgmatic.hooks.dispatch).should_receive(
|
||||
'call_hooks_even_if_unconfigured'
|
||||
).and_return({})
|
||||
flexmock(module).should_receive('process_source_directories').and_return([])
|
||||
flexmock(module).should_receive('collect_patterns').and_return(())
|
||||
flexmock(module).should_receive('process_patterns').and_return([])
|
||||
flexmock(module.os.path).should_receive('join').and_return('/run/borgmatic/bootstrap')
|
||||
create_arguments = flexmock(
|
||||
repository=flexmock(),
|
||||
|
||||
@@ -5,73 +5,96 @@ import borgmatic.actions.restore as module
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'first_dump,second_dump,expected_result',
|
||||
'first_dump,second_dump,default_port,expected_result',
|
||||
(
|
||||
(
|
||||
module.Dump('postgresql_databases', 'foo'),
|
||||
module.Dump('postgresql_databases', 'foo'),
|
||||
None,
|
||||
True,
|
||||
),
|
||||
(
|
||||
module.Dump('postgresql_databases', 'foo'),
|
||||
module.Dump('postgresql_databases', 'bar'),
|
||||
None,
|
||||
False,
|
||||
),
|
||||
(
|
||||
module.Dump('postgresql_databases', 'foo'),
|
||||
module.Dump('mariadb_databases', 'foo'),
|
||||
None,
|
||||
False,
|
||||
),
|
||||
(
|
||||
module.Dump('postgresql_databases', 'foo'),
|
||||
module.Dump(module.UNSPECIFIED, 'foo'),
|
||||
None,
|
||||
True,
|
||||
),
|
||||
(
|
||||
module.Dump('postgresql_databases', 'foo'),
|
||||
module.Dump(module.UNSPECIFIED, 'bar'),
|
||||
None,
|
||||
False,
|
||||
),
|
||||
(module.Dump('postgresql_databases', 'foo'), module.Dump(module.UNSPECIFIED, 'foo'), True),
|
||||
(module.Dump('postgresql_databases', 'foo'), module.Dump(module.UNSPECIFIED, 'bar'), False),
|
||||
(
|
||||
module.Dump('postgresql_databases', module.UNSPECIFIED),
|
||||
module.Dump('postgresql_databases', 'foo'),
|
||||
None,
|
||||
True,
|
||||
),
|
||||
(
|
||||
module.Dump('postgresql_databases', module.UNSPECIFIED),
|
||||
module.Dump('mariadb_databases', 'foo'),
|
||||
None,
|
||||
False,
|
||||
),
|
||||
(
|
||||
module.Dump('postgresql_databases', 'foo', 'myhost'),
|
||||
module.Dump('postgresql_databases', 'foo', 'myhost'),
|
||||
None,
|
||||
True,
|
||||
),
|
||||
(
|
||||
module.Dump('postgresql_databases', 'foo', 'myhost'),
|
||||
module.Dump('postgresql_databases', 'foo', 'otherhost'),
|
||||
None,
|
||||
False,
|
||||
),
|
||||
(
|
||||
module.Dump('postgresql_databases', 'foo', 'myhost'),
|
||||
module.Dump('postgresql_databases', 'foo', module.UNSPECIFIED),
|
||||
None,
|
||||
True,
|
||||
),
|
||||
(
|
||||
module.Dump('postgresql_databases', 'foo', 'myhost'),
|
||||
module.Dump('postgresql_databases', 'bar', module.UNSPECIFIED),
|
||||
None,
|
||||
False,
|
||||
),
|
||||
(
|
||||
module.Dump('postgresql_databases', 'foo', 'myhost', 1234),
|
||||
module.Dump('postgresql_databases', 'foo', 'myhost', 1234),
|
||||
None,
|
||||
True,
|
||||
),
|
||||
(
|
||||
module.Dump('postgresql_databases', 'foo', 'myhost', 1234),
|
||||
module.Dump('postgresql_databases', 'foo', 'myhost', 4321),
|
||||
None,
|
||||
False,
|
||||
),
|
||||
(
|
||||
module.Dump('postgresql_databases', 'foo', 'myhost', module.UNSPECIFIED),
|
||||
module.Dump('postgresql_databases', 'foo', 'myhost', 1234),
|
||||
None,
|
||||
True,
|
||||
),
|
||||
(
|
||||
module.Dump('postgresql_databases', 'foo', 'myhost', module.UNSPECIFIED),
|
||||
module.Dump('postgresql_databases', 'foo', 'otherhost', 1234),
|
||||
None,
|
||||
False,
|
||||
),
|
||||
(
|
||||
@@ -79,14 +102,33 @@ import borgmatic.actions.restore as module
|
||||
module.UNSPECIFIED, module.UNSPECIFIED, module.UNSPECIFIED, module.UNSPECIFIED
|
||||
),
|
||||
module.Dump('postgresql_databases', 'foo', 'myhost', 1234),
|
||||
None,
|
||||
True,
|
||||
),
|
||||
(
|
||||
module.Dump('postgresql_databases', 'foo', 'myhost', 5432),
|
||||
module.Dump('postgresql_databases', 'foo', 'myhost', None),
|
||||
5432,
|
||||
True,
|
||||
),
|
||||
(
|
||||
module.Dump('postgresql_databases', 'foo', 'myhost', None),
|
||||
module.Dump('postgresql_databases', 'foo', 'myhost', 5432),
|
||||
5432,
|
||||
True,
|
||||
),
|
||||
(
|
||||
module.Dump('postgresql_databases', 'foo', 'myhost', 5433),
|
||||
module.Dump('postgresql_databases', 'foo', 'myhost', None),
|
||||
5432,
|
||||
False,
|
||||
),
|
||||
),
|
||||
)
|
||||
def test_dumps_match_compares_two_dumps_while_respecting_unspecified_values(
|
||||
first_dump, second_dump, expected_result
|
||||
first_dump, second_dump, default_port, expected_result
|
||||
):
|
||||
assert module.dumps_match(first_dump, second_dump) == expected_result
|
||||
assert module.dumps_match(first_dump, second_dump, default_port) == expected_result
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -137,10 +179,13 @@ def test_render_dump_metadata_renders_dump_values_into_string(dump, expected_res
|
||||
|
||||
|
||||
def test_get_configured_data_source_matches_data_source_with_restore_dump():
|
||||
default_port = flexmock()
|
||||
flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hook').and_return(default_port)
|
||||
flexmock(module).should_receive('dumps_match').and_return(False)
|
||||
flexmock(module).should_receive('dumps_match').with_args(
|
||||
module.Dump('postgresql_databases', 'bar'),
|
||||
module.Dump('postgresql_databases', 'bar'),
|
||||
default_port=default_port,
|
||||
).and_return(True)
|
||||
|
||||
assert module.get_configured_data_source(
|
||||
@@ -153,6 +198,7 @@ def test_get_configured_data_source_matches_data_source_with_restore_dump():
|
||||
|
||||
|
||||
def test_get_configured_data_source_matches_nothing_when_nothing_configured():
|
||||
flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hook').and_return(flexmock())
|
||||
flexmock(module).should_receive('dumps_match').and_return(False)
|
||||
|
||||
assert (
|
||||
@@ -165,6 +211,7 @@ def test_get_configured_data_source_matches_nothing_when_nothing_configured():
|
||||
|
||||
|
||||
def test_get_configured_data_source_matches_nothing_when_restore_dump_does_not_match_configuration():
|
||||
flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hook').and_return(flexmock())
|
||||
flexmock(module).should_receive('dumps_match').and_return(False)
|
||||
|
||||
assert (
|
||||
@@ -179,10 +226,13 @@ def test_get_configured_data_source_matches_nothing_when_restore_dump_does_not_m
|
||||
|
||||
|
||||
def test_get_configured_data_source_with_multiple_matching_data_sources_errors():
|
||||
default_port = flexmock()
|
||||
flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hook').and_return(default_port)
|
||||
flexmock(module).should_receive('dumps_match').and_return(False)
|
||||
flexmock(module).should_receive('dumps_match').with_args(
|
||||
module.Dump('postgresql_databases', 'bar'),
|
||||
module.Dump('postgresql_databases', 'bar'),
|
||||
default_port=default_port,
|
||||
).and_return(True)
|
||||
flexmock(module).should_receive('render_dump_metadata').and_return('test')
|
||||
|
||||
@@ -223,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(
|
||||
@@ -237,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,
|
||||
@@ -264,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'
|
||||
@@ -280,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,
|
||||
@@ -307,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'
|
||||
@@ -323,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,
|
||||
@@ -351,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(
|
||||
@@ -365,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,
|
||||
@@ -529,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)
|
||||
|
||||
@@ -598,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)
|
||||
|
||||
@@ -627,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)
|
||||
|
||||
@@ -652,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')
|
||||
@@ -679,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')
|
||||
@@ -704,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)
|
||||
|
||||
@@ -729,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)
|
||||
|
||||
|
||||
+411
-542
File diff suppressed because it is too large
Load Diff
@@ -3,13 +3,26 @@ from flexmock import flexmock
|
||||
from borgmatic.borg import environment as module
|
||||
|
||||
|
||||
def test_make_environment_with_passcommand_should_set_environment():
|
||||
def test_make_environment_with_passcommand_should_call_it_and_set_passphrase_file_descriptor_in_environment():
|
||||
flexmock(module.borgmatic.borg.passcommand).should_receive(
|
||||
'get_passphrase_from_passcommand'
|
||||
).and_return('passphrase')
|
||||
flexmock(module.os).should_receive('pipe').and_return((3, 4))
|
||||
flexmock(module.os).should_receive('write')
|
||||
flexmock(module.os).should_receive('close')
|
||||
flexmock(module.os).should_receive('set_inheritable')
|
||||
|
||||
environment = module.make_environment({'encryption_passcommand': 'command'})
|
||||
|
||||
assert environment.get('BORG_PASSCOMMAND') == 'command'
|
||||
assert not environment.get('BORG_PASSCOMMAND')
|
||||
assert environment.get('BORG_PASSPHRASE_FD') == '3'
|
||||
|
||||
|
||||
def test_make_environment_with_passphrase_should_set_environment():
|
||||
flexmock(module.borgmatic.borg.passcommand).should_receive(
|
||||
'get_passphrase_from_passcommand'
|
||||
).and_return(None)
|
||||
flexmock(module.os).should_receive('pipe').never()
|
||||
flexmock(module.os.environ).should_receive('get').and_return(None)
|
||||
environment = module.make_environment({'encryption_passphrase': 'pass'})
|
||||
|
||||
@@ -17,6 +30,10 @@ def test_make_environment_with_passphrase_should_set_environment():
|
||||
|
||||
|
||||
def test_make_environment_with_ssh_command_should_set_environment():
|
||||
flexmock(module.borgmatic.borg.passcommand).should_receive(
|
||||
'get_passphrase_from_passcommand'
|
||||
).and_return(None)
|
||||
flexmock(module.os).should_receive('pipe').never()
|
||||
flexmock(module.os.environ).should_receive('get').and_return(None)
|
||||
environment = module.make_environment({'ssh_command': 'ssh -C'})
|
||||
|
||||
@@ -24,6 +41,10 @@ def test_make_environment_with_ssh_command_should_set_environment():
|
||||
|
||||
|
||||
def test_make_environment_without_configuration_sets_certain_environment_variables():
|
||||
flexmock(module.borgmatic.borg.passcommand).should_receive(
|
||||
'get_passphrase_from_passcommand'
|
||||
).and_return(None)
|
||||
flexmock(module.os).should_receive('pipe').never()
|
||||
flexmock(module.os.environ).should_receive('get').and_return(None)
|
||||
environment = module.make_environment({})
|
||||
|
||||
@@ -36,6 +57,10 @@ def test_make_environment_without_configuration_sets_certain_environment_variabl
|
||||
|
||||
|
||||
def test_make_environment_without_configuration_does_not_set_certain_environment_variables_if_already_set():
|
||||
flexmock(module.borgmatic.borg.passcommand).should_receive(
|
||||
'get_passphrase_from_passcommand'
|
||||
).and_return(None)
|
||||
flexmock(module.os).should_receive('pipe').never()
|
||||
flexmock(module.os.environ).should_receive('get').with_args(
|
||||
'BORG_RELOCATED_REPO_ACCESS_IS_OK'
|
||||
).and_return('yup')
|
||||
@@ -48,6 +73,10 @@ def test_make_environment_without_configuration_does_not_set_certain_environment
|
||||
|
||||
|
||||
def test_make_environment_with_relocated_repo_access_true_should_set_environment_yes():
|
||||
flexmock(module.borgmatic.borg.passcommand).should_receive(
|
||||
'get_passphrase_from_passcommand'
|
||||
).and_return(None)
|
||||
flexmock(module.os).should_receive('pipe').never()
|
||||
flexmock(module.os.environ).should_receive('get').and_return(None)
|
||||
environment = module.make_environment({'relocated_repo_access_is_ok': True})
|
||||
|
||||
@@ -55,6 +84,10 @@ def test_make_environment_with_relocated_repo_access_true_should_set_environment
|
||||
|
||||
|
||||
def test_make_environment_with_relocated_repo_access_false_should_set_environment_no():
|
||||
flexmock(module.borgmatic.borg.passcommand).should_receive(
|
||||
'get_passphrase_from_passcommand'
|
||||
).and_return(None)
|
||||
flexmock(module.os).should_receive('pipe').never()
|
||||
flexmock(module.os.environ).should_receive('get').and_return(None)
|
||||
environment = module.make_environment({'relocated_repo_access_is_ok': False})
|
||||
|
||||
@@ -62,6 +95,10 @@ def test_make_environment_with_relocated_repo_access_false_should_set_environmen
|
||||
|
||||
|
||||
def test_make_environment_check_i_know_what_i_am_doing_true_should_set_environment_YES():
|
||||
flexmock(module.borgmatic.borg.passcommand).should_receive(
|
||||
'get_passphrase_from_passcommand'
|
||||
).and_return(None)
|
||||
flexmock(module.os).should_receive('pipe').never()
|
||||
flexmock(module.os.environ).should_receive('get').and_return(None)
|
||||
environment = module.make_environment({'check_i_know_what_i_am_doing': True})
|
||||
|
||||
@@ -69,6 +106,10 @@ def test_make_environment_check_i_know_what_i_am_doing_true_should_set_environme
|
||||
|
||||
|
||||
def test_make_environment_check_i_know_what_i_am_doing_false_should_set_environment_NO():
|
||||
flexmock(module.borgmatic.borg.passcommand).should_receive(
|
||||
'get_passphrase_from_passcommand'
|
||||
).and_return(None)
|
||||
flexmock(module.os).should_receive('pipe').never()
|
||||
flexmock(module.os.environ).should_receive('get').and_return(None)
|
||||
environment = module.make_environment({'check_i_know_what_i_am_doing': False})
|
||||
|
||||
@@ -76,6 +117,10 @@ def test_make_environment_check_i_know_what_i_am_doing_false_should_set_environm
|
||||
|
||||
|
||||
def test_make_environment_with_integer_variable_value():
|
||||
flexmock(module.borgmatic.borg.passcommand).should_receive(
|
||||
'get_passphrase_from_passcommand'
|
||||
).and_return(None)
|
||||
flexmock(module.os).should_receive('pipe').never()
|
||||
flexmock(module.os.environ).should_receive('get').and_return(None)
|
||||
environment = module.make_environment({'borg_files_cache_ttl': 40})
|
||||
assert environment.get('BORG_FILES_CACHE_TTL') == '40'
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
from flexmock import flexmock
|
||||
|
||||
from borgmatic.borg import passcommand as module
|
||||
|
||||
|
||||
def test_run_passcommand_with_passphrase_configured_bails():
|
||||
flexmock(module.borgmatic.execute).should_receive('execute_command_and_capture_output').never()
|
||||
|
||||
assert (
|
||||
module.run_passcommand('passcommand', passphrase_configured=True, working_directory=None)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_run_passcommand_without_passphrase_configured_executes_passcommand():
|
||||
flexmock(module.borgmatic.execute).should_receive(
|
||||
'execute_command_and_capture_output'
|
||||
).and_return('passphrase').once()
|
||||
|
||||
assert (
|
||||
module.run_passcommand('passcommand', passphrase_configured=False, working_directory=None)
|
||||
== 'passphrase'
|
||||
)
|
||||
|
||||
|
||||
def test_get_passphrase_from_passcommand_with_configured_passcommand_runs_it():
|
||||
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(
|
||||
'/working'
|
||||
)
|
||||
flexmock(module).should_receive('run_passcommand').with_args(
|
||||
'command', False, '/working'
|
||||
).and_return('passphrase').once()
|
||||
|
||||
assert (
|
||||
module.get_passphrase_from_passcommand(
|
||||
{'encryption_passcommand': 'command'},
|
||||
)
|
||||
== 'passphrase'
|
||||
)
|
||||
|
||||
|
||||
def test_get_passphrase_from_passcommand_with_configured_passphrase_and_passcommand_detects_passphrase():
|
||||
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(
|
||||
'/working'
|
||||
)
|
||||
flexmock(module).should_receive('run_passcommand').with_args(
|
||||
'command', True, '/working'
|
||||
).and_return(None).once()
|
||||
|
||||
assert (
|
||||
module.get_passphrase_from_passcommand(
|
||||
{'encryption_passphrase': 'passphrase', 'encryption_passcommand': 'command'},
|
||||
)
|
||||
is None
|
||||
)
|
||||
@@ -32,6 +32,7 @@ def test_run_configuration_runs_actions_for_each_repository():
|
||||
flexmock(module).should_receive('get_skip_actions').and_return([])
|
||||
flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
|
||||
expected_results = [flexmock(), flexmock()]
|
||||
flexmock(module).should_receive('Log_prefix').and_return(flexmock())
|
||||
flexmock(module).should_receive('run_actions').and_return(expected_results[:1]).and_return(
|
||||
expected_results[1:]
|
||||
)
|
||||
@@ -47,6 +48,7 @@ def test_run_configuration_with_skip_actions_does_not_raise():
|
||||
flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO)
|
||||
flexmock(module).should_receive('get_skip_actions').and_return(['compact'])
|
||||
flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
|
||||
flexmock(module).should_receive('Log_prefix').and_return(flexmock())
|
||||
flexmock(module).should_receive('run_actions').and_return(flexmock()).and_return(flexmock())
|
||||
config = {'repositories': [{'path': 'foo'}, {'path': 'bar'}], 'skip_actions': ['compact']}
|
||||
arguments = {'global': flexmock(monitoring_verbosity=1)}
|
||||
@@ -60,6 +62,7 @@ def test_run_configuration_with_invalid_borg_version_errors():
|
||||
flexmock(module.borg_version).should_receive('local_borg_version').and_raise(ValueError)
|
||||
flexmock(module.command).should_receive('execute_hook').never()
|
||||
flexmock(module.dispatch).should_receive('call_hooks').never()
|
||||
flexmock(module).should_receive('Log_prefix').and_return(flexmock())
|
||||
flexmock(module).should_receive('run_actions').never()
|
||||
config = {'repositories': [{'path': 'foo'}]}
|
||||
arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'prune': flexmock()}
|
||||
@@ -76,6 +79,7 @@ def test_run_configuration_logs_monitor_start_error():
|
||||
).and_return(None).and_return(None)
|
||||
expected_results = [flexmock()]
|
||||
flexmock(module).should_receive('log_error_records').and_return(expected_results)
|
||||
flexmock(module).should_receive('Log_prefix').and_return(flexmock())
|
||||
flexmock(module).should_receive('run_actions').never()
|
||||
config = {'repositories': [{'path': 'foo'}]}
|
||||
arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()}
|
||||
@@ -92,6 +96,7 @@ def test_run_configuration_bails_for_monitor_start_soft_failure():
|
||||
error = subprocess.CalledProcessError(borgmatic.hooks.command.SOFT_FAIL_EXIT_CODE, 'try again')
|
||||
flexmock(module.dispatch).should_receive('call_hooks').and_raise(error).and_return(None)
|
||||
flexmock(module).should_receive('log_error_records').never()
|
||||
flexmock(module).should_receive('Log_prefix').and_return(flexmock())
|
||||
flexmock(module).should_receive('run_actions').never()
|
||||
config = {'repositories': [{'path': 'foo'}, {'path': 'bar'}]}
|
||||
arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()}
|
||||
@@ -109,6 +114,7 @@ def test_run_configuration_logs_actions_error():
|
||||
flexmock(module.dispatch).should_receive('call_hooks')
|
||||
expected_results = [flexmock()]
|
||||
flexmock(module).should_receive('log_error_records').and_return(expected_results)
|
||||
flexmock(module).should_receive('Log_prefix').and_return(flexmock())
|
||||
flexmock(module).should_receive('run_actions').and_raise(OSError)
|
||||
config = {'repositories': [{'path': 'foo'}]}
|
||||
arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False)}
|
||||
@@ -125,6 +131,7 @@ def test_run_configuration_skips_remaining_actions_for_actions_soft_failure_but_
|
||||
flexmock(module.dispatch).should_receive('call_hooks').times(5)
|
||||
error = subprocess.CalledProcessError(borgmatic.hooks.command.SOFT_FAIL_EXIT_CODE, 'try again')
|
||||
log = flexmock()
|
||||
flexmock(module).should_receive('Log_prefix').and_return(flexmock())
|
||||
flexmock(module).should_receive('run_actions').twice().and_raise(error).and_yield(log)
|
||||
flexmock(module).should_receive('log_error_records').never()
|
||||
flexmock(module.command).should_receive('considered_soft_failure').and_return(True)
|
||||
@@ -145,6 +152,7 @@ def test_run_configuration_logs_monitor_log_error():
|
||||
).and_raise(OSError)
|
||||
expected_results = [flexmock()]
|
||||
flexmock(module).should_receive('log_error_records').and_return(expected_results)
|
||||
flexmock(module).should_receive('Log_prefix').and_return(flexmock())
|
||||
flexmock(module).should_receive('run_actions').and_return([])
|
||||
config = {'repositories': [{'path': 'foo'}]}
|
||||
arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()}
|
||||
@@ -163,6 +171,7 @@ def test_run_configuration_still_pings_monitor_for_monitor_log_soft_failure():
|
||||
None
|
||||
).and_raise(error).and_return(None).and_return(None).times(5)
|
||||
flexmock(module).should_receive('log_error_records').never()
|
||||
flexmock(module).should_receive('Log_prefix').and_return(flexmock())
|
||||
flexmock(module).should_receive('run_actions').and_return([])
|
||||
flexmock(module.command).should_receive('considered_soft_failure').and_return(True)
|
||||
config = {'repositories': [{'path': 'foo'}]}
|
||||
@@ -182,6 +191,7 @@ def test_run_configuration_logs_monitor_finish_error():
|
||||
).and_return(None).and_raise(OSError)
|
||||
expected_results = [flexmock()]
|
||||
flexmock(module).should_receive('log_error_records').and_return(expected_results)
|
||||
flexmock(module).should_receive('Log_prefix').and_return(flexmock())
|
||||
flexmock(module).should_receive('run_actions').and_return([])
|
||||
config = {'repositories': [{'path': 'foo'}]}
|
||||
arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()}
|
||||
@@ -200,6 +210,7 @@ def test_run_configuration_bails_for_monitor_finish_soft_failure():
|
||||
None
|
||||
).and_raise(None).and_raise(error)
|
||||
flexmock(module).should_receive('log_error_records').never()
|
||||
flexmock(module).should_receive('Log_prefix').and_return(flexmock())
|
||||
flexmock(module).should_receive('run_actions').and_return([])
|
||||
flexmock(module.command).should_receive('considered_soft_failure').and_return(True)
|
||||
config = {'repositories': [{'path': 'foo'}]}
|
||||
@@ -216,6 +227,7 @@ def test_run_configuration_does_not_call_monitoring_hooks_if_monitoring_hooks_ar
|
||||
flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
|
||||
|
||||
flexmock(module.dispatch).should_receive('call_hooks').never()
|
||||
flexmock(module).should_receive('Log_prefix').and_return(flexmock())
|
||||
flexmock(module).should_receive('run_actions').and_return([])
|
||||
|
||||
config = {'repositories': [{'path': 'foo'}]}
|
||||
@@ -233,6 +245,7 @@ def test_run_configuration_logs_on_error_hook_error():
|
||||
flexmock(module).should_receive('log_error_records').and_return(
|
||||
expected_results[:1]
|
||||
).and_return(expected_results[1:])
|
||||
flexmock(module).should_receive('Log_prefix').and_return(flexmock())
|
||||
flexmock(module).should_receive('run_actions').and_raise(OSError)
|
||||
config = {'repositories': [{'path': 'foo'}]}
|
||||
arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()}
|
||||
@@ -250,6 +263,7 @@ def test_run_configuration_bails_for_on_error_hook_soft_failure():
|
||||
flexmock(module.command).should_receive('execute_hook').and_raise(error)
|
||||
expected_results = [flexmock()]
|
||||
flexmock(module).should_receive('log_error_records').and_return(expected_results)
|
||||
flexmock(module).should_receive('Log_prefix').and_return(flexmock())
|
||||
flexmock(module).should_receive('run_actions').and_raise(OSError)
|
||||
config = {'repositories': [{'path': 'foo'}]}
|
||||
arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()}
|
||||
@@ -265,6 +279,7 @@ def test_run_configuration_retries_soft_error():
|
||||
flexmock(module).should_receive('get_skip_actions').and_return([])
|
||||
flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
|
||||
flexmock(module.command).should_receive('execute_hook')
|
||||
flexmock(module).should_receive('Log_prefix').and_return(flexmock())
|
||||
flexmock(module).should_receive('run_actions').and_raise(OSError).and_return([])
|
||||
flexmock(module).should_receive('log_error_records').and_return([flexmock()]).once()
|
||||
config = {'repositories': [{'path': 'foo'}], 'retries': 1}
|
||||
@@ -279,16 +294,17 @@ def test_run_configuration_retries_hard_error():
|
||||
flexmock(module).should_receive('get_skip_actions').and_return([])
|
||||
flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
|
||||
flexmock(module.command).should_receive('execute_hook')
|
||||
flexmock(module).should_receive('Log_prefix').and_return(flexmock())
|
||||
flexmock(module).should_receive('run_actions').and_raise(OSError).times(2)
|
||||
flexmock(module).should_receive('log_error_records').with_args(
|
||||
'foo: Error running actions for repository',
|
||||
'Error running actions for repository',
|
||||
OSError,
|
||||
levelno=logging.WARNING,
|
||||
log_command_error_output=True,
|
||||
).and_return([flexmock()])
|
||||
error_logs = [flexmock()]
|
||||
flexmock(module).should_receive('log_error_records').with_args(
|
||||
'foo: Error running actions for repository',
|
||||
'Error running actions for repository',
|
||||
OSError,
|
||||
).and_return(error_logs)
|
||||
config = {'repositories': [{'path': 'foo'}], 'retries': 1}
|
||||
@@ -302,13 +318,14 @@ def test_run_configuration_repos_ordered():
|
||||
flexmock(module).should_receive('get_skip_actions').and_return([])
|
||||
flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
|
||||
flexmock(module.command).should_receive('execute_hook')
|
||||
flexmock(module).should_receive('Log_prefix').and_return(flexmock())
|
||||
flexmock(module).should_receive('run_actions').and_raise(OSError).times(2)
|
||||
expected_results = [flexmock(), flexmock()]
|
||||
flexmock(module).should_receive('log_error_records').with_args(
|
||||
'foo: Error running actions for repository', OSError
|
||||
'Error running actions for repository', OSError
|
||||
).and_return(expected_results[:1]).ordered()
|
||||
flexmock(module).should_receive('log_error_records').with_args(
|
||||
'bar: Error running actions for repository', OSError
|
||||
'Error running actions for repository', OSError
|
||||
).and_return(expected_results[1:]).ordered()
|
||||
config = {'repositories': [{'path': 'foo'}, {'path': 'bar'}]}
|
||||
arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()}
|
||||
@@ -321,26 +338,27 @@ def test_run_configuration_retries_round_robin():
|
||||
flexmock(module).should_receive('get_skip_actions').and_return([])
|
||||
flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
|
||||
flexmock(module.command).should_receive('execute_hook')
|
||||
flexmock(module).should_receive('Log_prefix').and_return(flexmock())
|
||||
flexmock(module).should_receive('run_actions').and_raise(OSError).times(4)
|
||||
flexmock(module).should_receive('log_error_records').with_args(
|
||||
'foo: Error running actions for repository',
|
||||
'Error running actions for repository',
|
||||
OSError,
|
||||
levelno=logging.WARNING,
|
||||
log_command_error_output=True,
|
||||
).and_return([flexmock()]).ordered()
|
||||
flexmock(module).should_receive('log_error_records').with_args(
|
||||
'bar: Error running actions for repository',
|
||||
'Error running actions for repository',
|
||||
OSError,
|
||||
levelno=logging.WARNING,
|
||||
log_command_error_output=True,
|
||||
).and_return([flexmock()]).ordered()
|
||||
foo_error_logs = [flexmock()]
|
||||
flexmock(module).should_receive('log_error_records').with_args(
|
||||
'foo: Error running actions for repository', OSError
|
||||
'Error running actions for repository', OSError
|
||||
).and_return(foo_error_logs).ordered()
|
||||
bar_error_logs = [flexmock()]
|
||||
flexmock(module).should_receive('log_error_records').with_args(
|
||||
'bar: Error running actions for repository', OSError
|
||||
'Error running actions for repository', OSError
|
||||
).and_return(bar_error_logs).ordered()
|
||||
config = {
|
||||
'repositories': [{'path': 'foo'}, {'path': 'bar'}],
|
||||
@@ -356,24 +374,25 @@ def test_run_configuration_retries_one_passes():
|
||||
flexmock(module).should_receive('get_skip_actions').and_return([])
|
||||
flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
|
||||
flexmock(module.command).should_receive('execute_hook')
|
||||
flexmock(module).should_receive('Log_prefix').and_return(flexmock())
|
||||
flexmock(module).should_receive('run_actions').and_raise(OSError).and_raise(OSError).and_return(
|
||||
[]
|
||||
).and_raise(OSError).times(4)
|
||||
flexmock(module).should_receive('log_error_records').with_args(
|
||||
'foo: Error running actions for repository',
|
||||
'Error running actions for repository',
|
||||
OSError,
|
||||
levelno=logging.WARNING,
|
||||
log_command_error_output=True,
|
||||
).and_return([flexmock()]).ordered()
|
||||
flexmock(module).should_receive('log_error_records').with_args(
|
||||
'bar: Error running actions for repository',
|
||||
'Error running actions for repository',
|
||||
OSError,
|
||||
levelno=logging.WARNING,
|
||||
log_command_error_output=True,
|
||||
).and_return(flexmock()).ordered()
|
||||
error_logs = [flexmock()]
|
||||
flexmock(module).should_receive('log_error_records').with_args(
|
||||
'bar: Error running actions for repository', OSError
|
||||
'Error running actions for repository', OSError
|
||||
).and_return(error_logs).ordered()
|
||||
config = {
|
||||
'repositories': [{'path': 'foo'}, {'path': 'bar'}],
|
||||
@@ -389,9 +408,10 @@ def test_run_configuration_retry_wait():
|
||||
flexmock(module).should_receive('get_skip_actions').and_return([])
|
||||
flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
|
||||
flexmock(module.command).should_receive('execute_hook')
|
||||
flexmock(module).should_receive('Log_prefix').and_return(flexmock())
|
||||
flexmock(module).should_receive('run_actions').and_raise(OSError).times(4)
|
||||
flexmock(module).should_receive('log_error_records').with_args(
|
||||
'foo: Error running actions for repository',
|
||||
'Error running actions for repository',
|
||||
OSError,
|
||||
levelno=logging.WARNING,
|
||||
log_command_error_output=True,
|
||||
@@ -399,7 +419,7 @@ def test_run_configuration_retry_wait():
|
||||
|
||||
flexmock(time).should_receive('sleep').with_args(10).and_return().ordered()
|
||||
flexmock(module).should_receive('log_error_records').with_args(
|
||||
'foo: Error running actions for repository',
|
||||
'Error running actions for repository',
|
||||
OSError,
|
||||
levelno=logging.WARNING,
|
||||
log_command_error_output=True,
|
||||
@@ -407,7 +427,7 @@ def test_run_configuration_retry_wait():
|
||||
|
||||
flexmock(time).should_receive('sleep').with_args(20).and_return().ordered()
|
||||
flexmock(module).should_receive('log_error_records').with_args(
|
||||
'foo: Error running actions for repository',
|
||||
'Error running actions for repository',
|
||||
OSError,
|
||||
levelno=logging.WARNING,
|
||||
log_command_error_output=True,
|
||||
@@ -416,7 +436,7 @@ def test_run_configuration_retry_wait():
|
||||
flexmock(time).should_receive('sleep').with_args(30).and_return().ordered()
|
||||
error_logs = [flexmock()]
|
||||
flexmock(module).should_receive('log_error_records').with_args(
|
||||
'foo: Error running actions for repository', OSError
|
||||
'Error running actions for repository', OSError
|
||||
).and_return(error_logs).ordered()
|
||||
config = {
|
||||
'repositories': [{'path': 'foo'}],
|
||||
@@ -433,17 +453,18 @@ def test_run_configuration_retries_timeout_multiple_repos():
|
||||
flexmock(module).should_receive('get_skip_actions').and_return([])
|
||||
flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
|
||||
flexmock(module.command).should_receive('execute_hook')
|
||||
flexmock(module).should_receive('Log_prefix').and_return(flexmock())
|
||||
flexmock(module).should_receive('run_actions').and_raise(OSError).and_raise(OSError).and_return(
|
||||
[]
|
||||
).and_raise(OSError).times(4)
|
||||
flexmock(module).should_receive('log_error_records').with_args(
|
||||
'foo: Error running actions for repository',
|
||||
'Error running actions for repository',
|
||||
OSError,
|
||||
levelno=logging.WARNING,
|
||||
log_command_error_output=True,
|
||||
).and_return([flexmock()]).ordered()
|
||||
flexmock(module).should_receive('log_error_records').with_args(
|
||||
'bar: Error running actions for repository',
|
||||
'Error running actions for repository',
|
||||
OSError,
|
||||
levelno=logging.WARNING,
|
||||
log_command_error_output=True,
|
||||
@@ -456,7 +477,7 @@ def test_run_configuration_retries_timeout_multiple_repos():
|
||||
flexmock(time).should_receive('sleep').with_args(10).and_return().ordered()
|
||||
error_logs = [flexmock()]
|
||||
flexmock(module).should_receive('log_error_records').with_args(
|
||||
'bar: Error running actions for repository', OSError
|
||||
'Error running actions for repository', OSError
|
||||
).and_return(error_logs).ordered()
|
||||
config = {
|
||||
'repositories': [{'path': 'foo'}, {'path': 'bar'}],
|
||||
@@ -1370,6 +1391,7 @@ def test_collect_highlander_action_summary_logs_error_on_run_validate_failure():
|
||||
def test_collect_configuration_run_summary_logs_info_for_success():
|
||||
flexmock(module.command).should_receive('execute_hook').never()
|
||||
flexmock(module.validate).should_receive('guard_configuration_contains_repository')
|
||||
flexmock(module).should_receive('Log_prefix').and_return(flexmock())
|
||||
flexmock(module).should_receive('run_configuration').and_return([])
|
||||
arguments = {}
|
||||
|
||||
@@ -1384,6 +1406,7 @@ def test_collect_configuration_run_summary_logs_info_for_success():
|
||||
|
||||
def test_collect_configuration_run_summary_executes_hooks_for_create():
|
||||
flexmock(module.validate).should_receive('guard_configuration_contains_repository')
|
||||
flexmock(module).should_receive('Log_prefix').and_return(flexmock())
|
||||
flexmock(module).should_receive('run_configuration').and_return([])
|
||||
arguments = {'create': flexmock(), 'global': flexmock(monitoring_verbosity=1, dry_run=False)}
|
||||
|
||||
@@ -1398,6 +1421,7 @@ def test_collect_configuration_run_summary_executes_hooks_for_create():
|
||||
|
||||
def test_collect_configuration_run_summary_logs_info_for_success_with_extract():
|
||||
flexmock(module.validate).should_receive('guard_configuration_contains_repository')
|
||||
flexmock(module).should_receive('Log_prefix').and_return(flexmock())
|
||||
flexmock(module).should_receive('run_configuration').and_return([])
|
||||
arguments = {'extract': flexmock(repository='repo')}
|
||||
|
||||
@@ -1429,6 +1453,7 @@ def test_collect_configuration_run_summary_logs_extract_with_repository_error():
|
||||
|
||||
def test_collect_configuration_run_summary_logs_info_for_success_with_mount():
|
||||
flexmock(module.validate).should_receive('guard_configuration_contains_repository')
|
||||
flexmock(module).should_receive('Log_prefix').and_return(flexmock())
|
||||
flexmock(module).should_receive('run_configuration').and_return([])
|
||||
arguments = {'mount': flexmock(repository='repo')}
|
||||
|
||||
@@ -1488,6 +1513,7 @@ def test_collect_configuration_run_summary_logs_pre_hook_error():
|
||||
def test_collect_configuration_run_summary_logs_post_hook_error():
|
||||
flexmock(module.command).should_receive('execute_hook').and_return(None).and_raise(ValueError)
|
||||
flexmock(module.validate).should_receive('guard_configuration_contains_repository')
|
||||
flexmock(module).should_receive('Log_prefix').and_return(flexmock())
|
||||
flexmock(module).should_receive('run_configuration').and_return([])
|
||||
expected_logs = (flexmock(),)
|
||||
flexmock(module).should_receive('log_error_records').and_return(expected_logs)
|
||||
@@ -1521,6 +1547,7 @@ def test_collect_configuration_run_summary_logs_for_list_with_archive_and_reposi
|
||||
|
||||
def test_collect_configuration_run_summary_logs_info_for_success_with_list():
|
||||
flexmock(module.validate).should_receive('guard_configuration_contains_repository')
|
||||
flexmock(module).should_receive('Log_prefix').and_return(flexmock())
|
||||
flexmock(module).should_receive('run_configuration').and_return([])
|
||||
arguments = {'list': flexmock(repository='repo', archive=None)}
|
||||
|
||||
@@ -1535,6 +1562,7 @@ def test_collect_configuration_run_summary_logs_info_for_success_with_list():
|
||||
|
||||
def test_collect_configuration_run_summary_logs_run_configuration_error():
|
||||
flexmock(module.validate).should_receive('guard_configuration_contains_repository')
|
||||
flexmock(module).should_receive('Log_prefix').and_return(flexmock())
|
||||
flexmock(module).should_receive('run_configuration').and_return(
|
||||
[logging.makeLogRecord(dict(levelno=logging.CRITICAL, levelname='CRITICAL', msg='Error'))]
|
||||
)
|
||||
@@ -1552,6 +1580,7 @@ def test_collect_configuration_run_summary_logs_run_configuration_error():
|
||||
|
||||
def test_collect_configuration_run_summary_logs_run_umount_error():
|
||||
flexmock(module.validate).should_receive('guard_configuration_contains_repository')
|
||||
flexmock(module).should_receive('Log_prefix').and_return(flexmock())
|
||||
flexmock(module).should_receive('run_configuration').and_return([])
|
||||
flexmock(module.borg_umount).should_receive('unmount_archive').and_raise(OSError)
|
||||
flexmock(module).should_receive('log_error_records').and_return(
|
||||
@@ -1570,6 +1599,7 @@ def test_collect_configuration_run_summary_logs_run_umount_error():
|
||||
|
||||
def test_collect_configuration_run_summary_logs_outputs_merged_json_results():
|
||||
flexmock(module.validate).should_receive('guard_configuration_contains_repository')
|
||||
flexmock(module).should_receive('Log_prefix').and_return(flexmock())
|
||||
flexmock(module).should_receive('run_configuration').and_return(['foo', 'bar']).and_return(
|
||||
['baz']
|
||||
)
|
||||
|
||||
@@ -66,7 +66,7 @@ def test_runtime_directory_uses_config_option():
|
||||
flexmock(module.os).should_receive('makedirs')
|
||||
config = {'user_runtime_directory': '/run', 'borgmatic_source_directory': '/nope'}
|
||||
|
||||
with module.Runtime_directory(config, 'prefix') as borgmatic_runtime_directory:
|
||||
with module.Runtime_directory(config) as borgmatic_runtime_directory:
|
||||
assert borgmatic_runtime_directory == '/run/./borgmatic'
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ def test_runtime_directory_uses_config_option_without_adding_duplicate_borgmatic
|
||||
flexmock(module.os).should_receive('makedirs')
|
||||
config = {'user_runtime_directory': '/run/borgmatic', 'borgmatic_source_directory': '/nope'}
|
||||
|
||||
with module.Runtime_directory(config, 'prefix') as borgmatic_runtime_directory:
|
||||
with module.Runtime_directory(config) as borgmatic_runtime_directory:
|
||||
assert borgmatic_runtime_directory == '/run/./borgmatic'
|
||||
|
||||
|
||||
@@ -84,9 +84,7 @@ def test_runtime_directory_with_relative_config_option_errors():
|
||||
config = {'user_runtime_directory': 'run', 'borgmatic_source_directory': '/nope'}
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
with module.Runtime_directory(
|
||||
config, 'prefix'
|
||||
) as borgmatic_runtime_directory: # noqa: F841
|
||||
with module.Runtime_directory(config) as borgmatic_runtime_directory: # noqa: F841
|
||||
pass
|
||||
|
||||
|
||||
@@ -97,7 +95,7 @@ def test_runtime_directory_falls_back_to_xdg_runtime_dir():
|
||||
)
|
||||
flexmock(module.os).should_receive('makedirs')
|
||||
|
||||
with module.Runtime_directory({}, 'prefix') as borgmatic_runtime_directory:
|
||||
with module.Runtime_directory({}) as borgmatic_runtime_directory:
|
||||
assert borgmatic_runtime_directory == '/run/./borgmatic'
|
||||
|
||||
|
||||
@@ -108,7 +106,7 @@ def test_runtime_directory_falls_back_to_xdg_runtime_dir_without_adding_duplicat
|
||||
)
|
||||
flexmock(module.os).should_receive('makedirs')
|
||||
|
||||
with module.Runtime_directory({}, 'prefix') as borgmatic_runtime_directory:
|
||||
with module.Runtime_directory({}) as borgmatic_runtime_directory:
|
||||
assert borgmatic_runtime_directory == '/run/./borgmatic'
|
||||
|
||||
|
||||
@@ -117,7 +115,7 @@ def test_runtime_directory_with_relative_xdg_runtime_dir_errors():
|
||||
flexmock(module.os).should_receive('makedirs').never()
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
with module.Runtime_directory({}, 'prefix') as borgmatic_runtime_directory: # noqa: F841
|
||||
with module.Runtime_directory({}) as borgmatic_runtime_directory: # noqa: F841
|
||||
pass
|
||||
|
||||
|
||||
@@ -129,7 +127,7 @@ def test_runtime_directory_falls_back_to_runtime_directory():
|
||||
)
|
||||
flexmock(module.os).should_receive('makedirs')
|
||||
|
||||
with module.Runtime_directory({}, 'prefix') as borgmatic_runtime_directory:
|
||||
with module.Runtime_directory({}) as borgmatic_runtime_directory:
|
||||
assert borgmatic_runtime_directory == '/run/./borgmatic'
|
||||
|
||||
|
||||
@@ -141,7 +139,7 @@ def test_runtime_directory_falls_back_to_runtime_directory_without_adding_duplic
|
||||
)
|
||||
flexmock(module.os).should_receive('makedirs')
|
||||
|
||||
with module.Runtime_directory({}, 'prefix') as borgmatic_runtime_directory:
|
||||
with module.Runtime_directory({}) as borgmatic_runtime_directory:
|
||||
assert borgmatic_runtime_directory == '/run/./borgmatic'
|
||||
|
||||
|
||||
@@ -153,7 +151,7 @@ def test_runtime_directory_with_relative_runtime_directory_errors():
|
||||
flexmock(module.os).should_receive('makedirs').never()
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
with module.Runtime_directory({}, 'prefix') as borgmatic_runtime_directory: # noqa: F841
|
||||
with module.Runtime_directory({}) as borgmatic_runtime_directory: # noqa: F841
|
||||
pass
|
||||
|
||||
|
||||
@@ -171,7 +169,7 @@ def test_runtime_directory_falls_back_to_tmpdir_and_adds_temporary_subdirectory_
|
||||
).and_return(temporary_directory)
|
||||
flexmock(module.os).should_receive('makedirs')
|
||||
|
||||
with module.Runtime_directory({}, 'prefix') as borgmatic_runtime_directory:
|
||||
with module.Runtime_directory({}) as borgmatic_runtime_directory:
|
||||
assert borgmatic_runtime_directory == '/run/borgmatic-1234/./borgmatic'
|
||||
|
||||
|
||||
@@ -185,7 +183,7 @@ def test_runtime_directory_with_relative_tmpdir_errors():
|
||||
flexmock(module.os).should_receive('makedirs').never()
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
with module.Runtime_directory({}, 'prefix') as borgmatic_runtime_directory: # noqa: F841
|
||||
with module.Runtime_directory({}) as borgmatic_runtime_directory: # noqa: F841
|
||||
pass
|
||||
|
||||
|
||||
@@ -204,7 +202,7 @@ def test_runtime_directory_falls_back_to_temp_and_adds_temporary_subdirectory_th
|
||||
).and_return(temporary_directory)
|
||||
flexmock(module.os).should_receive('makedirs')
|
||||
|
||||
with module.Runtime_directory({}, 'prefix') as borgmatic_runtime_directory:
|
||||
with module.Runtime_directory({}) as borgmatic_runtime_directory:
|
||||
assert borgmatic_runtime_directory == '/run/borgmatic-1234/./borgmatic'
|
||||
|
||||
|
||||
@@ -219,7 +217,7 @@ def test_runtime_directory_with_relative_temp_errors():
|
||||
flexmock(module.os).should_receive('makedirs')
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
with module.Runtime_directory({}, 'prefix') as borgmatic_runtime_directory: # noqa: F841
|
||||
with module.Runtime_directory({}) as borgmatic_runtime_directory: # noqa: F841
|
||||
pass
|
||||
|
||||
|
||||
@@ -238,7 +236,7 @@ def test_runtime_directory_falls_back_to_hard_coded_tmp_path_and_adds_temporary_
|
||||
).and_return(temporary_directory)
|
||||
flexmock(module.os).should_receive('makedirs')
|
||||
|
||||
with module.Runtime_directory({}, 'prefix') as borgmatic_runtime_directory:
|
||||
with module.Runtime_directory({}) as borgmatic_runtime_directory:
|
||||
assert borgmatic_runtime_directory == '/tmp/borgmatic-1234/./borgmatic'
|
||||
|
||||
|
||||
@@ -257,7 +255,7 @@ def test_runtime_directory_with_erroring_cleanup_does_not_raise():
|
||||
).and_return(temporary_directory)
|
||||
flexmock(module.os).should_receive('makedirs')
|
||||
|
||||
with module.Runtime_directory({}, 'prefix') as borgmatic_runtime_directory:
|
||||
with module.Runtime_directory({}) as borgmatic_runtime_directory:
|
||||
assert borgmatic_runtime_directory == '/tmp/borgmatic-1234/./borgmatic'
|
||||
|
||||
|
||||
|
||||
@@ -24,10 +24,9 @@ def test_dump_data_sources_creates_manifest_file():
|
||||
module.dump_data_sources(
|
||||
hook_config=None,
|
||||
config={},
|
||||
log_prefix='test',
|
||||
config_paths=('test.yaml',),
|
||||
borgmatic_runtime_directory='/run/borgmatic',
|
||||
source_directories=[],
|
||||
patterns=[],
|
||||
dry_run=False,
|
||||
)
|
||||
|
||||
@@ -40,10 +39,9 @@ def test_dump_data_sources_with_store_config_files_false_does_not_create_manifes
|
||||
module.dump_data_sources(
|
||||
hook_config=hook_config,
|
||||
config={'bootstrap': hook_config},
|
||||
log_prefix='test',
|
||||
config_paths=('test.yaml',),
|
||||
borgmatic_runtime_directory='/run/borgmatic',
|
||||
source_directories=[],
|
||||
patterns=[],
|
||||
dry_run=True,
|
||||
)
|
||||
|
||||
@@ -55,10 +53,9 @@ def test_dump_data_sources_with_dry_run_does_not_create_manifest_file():
|
||||
module.dump_data_sources(
|
||||
hook_config=None,
|
||||
config={},
|
||||
log_prefix='test',
|
||||
config_paths=('test.yaml',),
|
||||
borgmatic_runtime_directory='/run/borgmatic',
|
||||
source_directories=[],
|
||||
patterns=[],
|
||||
dry_run=True,
|
||||
)
|
||||
|
||||
@@ -76,7 +73,6 @@ def test_remove_data_source_dumps_deletes_manifest_and_parent_directory():
|
||||
module.remove_data_source_dumps(
|
||||
hook_config=None,
|
||||
config={},
|
||||
log_prefix='test',
|
||||
borgmatic_runtime_directory='/run/borgmatic',
|
||||
dry_run=False,
|
||||
)
|
||||
@@ -93,7 +89,6 @@ def test_remove_data_source_dumps_with_dry_run_bails():
|
||||
module.remove_data_source_dumps(
|
||||
hook_config=None,
|
||||
config={},
|
||||
log_prefix='test',
|
||||
borgmatic_runtime_directory='/run/borgmatic',
|
||||
dry_run=True,
|
||||
)
|
||||
@@ -112,7 +107,6 @@ def test_remove_data_source_dumps_swallows_manifest_file_not_found_error():
|
||||
module.remove_data_source_dumps(
|
||||
hook_config=None,
|
||||
config={},
|
||||
log_prefix='test',
|
||||
borgmatic_runtime_directory='/run/borgmatic',
|
||||
dry_run=False,
|
||||
)
|
||||
@@ -133,7 +127,6 @@ def test_remove_data_source_dumps_swallows_manifest_parent_directory_not_found_e
|
||||
module.remove_data_source_dumps(
|
||||
hook_config=None,
|
||||
config={},
|
||||
log_prefix='test',
|
||||
borgmatic_runtime_directory='/run/borgmatic',
|
||||
dry_run=False,
|
||||
)
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import pytest
|
||||
from flexmock import flexmock
|
||||
|
||||
from borgmatic.borg.pattern import Pattern, Pattern_style, Pattern_type
|
||||
from borgmatic.hooks.data_source import btrfs as module
|
||||
|
||||
|
||||
def test_get_filesystem_mount_points_parses_findmnt_output():
|
||||
def test_get_subvolume_mount_points_parses_findmnt_output():
|
||||
flexmock(module.borgmatic.execute).should_receive(
|
||||
'execute_command_and_capture_output'
|
||||
).and_return(
|
||||
@@ -27,114 +28,124 @@ def test_get_filesystem_mount_points_parses_findmnt_output():
|
||||
'''
|
||||
)
|
||||
|
||||
assert module.get_filesystem_mount_points('findmnt') == ('/mnt0', '/mnt1')
|
||||
assert module.get_subvolume_mount_points('findmnt') == ('/mnt0', '/mnt1')
|
||||
|
||||
|
||||
def test_get_filesystem_mount_points_with_invalid_findmnt_json_errors():
|
||||
def test_get_subvolume_mount_points_with_invalid_findmnt_json_errors():
|
||||
flexmock(module.borgmatic.execute).should_receive(
|
||||
'execute_command_and_capture_output'
|
||||
).and_return('{')
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
module.get_filesystem_mount_points('findmnt')
|
||||
module.get_subvolume_mount_points('findmnt')
|
||||
|
||||
|
||||
def test_get_filesystem_mount_points_with_findmnt_json_missing_filesystems_errors():
|
||||
def test_get_subvolume_mount_points_with_findmnt_json_missing_filesystems_errors():
|
||||
flexmock(module.borgmatic.execute).should_receive(
|
||||
'execute_command_and_capture_output'
|
||||
).and_return('{"wtf": "something is wrong here"}')
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
module.get_filesystem_mount_points('findmnt')
|
||||
module.get_subvolume_mount_points('findmnt')
|
||||
|
||||
|
||||
def test_get_subvolumes_for_filesystem_parses_subvolume_list_output():
|
||||
flexmock(module.borgmatic.execute).should_receive(
|
||||
'execute_command_and_capture_output'
|
||||
).and_return(
|
||||
'ID 270 gen 107 top level 5 path subvol1\nID 272 gen 74 top level 5 path subvol2\n'
|
||||
)
|
||||
def test_get_subvolumes_collects_subvolumes_matching_patterns():
|
||||
flexmock(module).should_receive('get_subvolume_mount_points').and_return(('/mnt1', '/mnt2'))
|
||||
|
||||
assert module.get_subvolumes_for_filesystem('btrfs', '/mnt') == (
|
||||
'/mnt',
|
||||
'/mnt/subvol1',
|
||||
'/mnt/subvol2',
|
||||
)
|
||||
|
||||
|
||||
def test_get_subvolumes_for_filesystem_skips_empty_subvolume_paths():
|
||||
flexmock(module.borgmatic.execute).should_receive(
|
||||
'execute_command_and_capture_output'
|
||||
).and_return('\n \nID 272 gen 74 top level 5 path subvol2\n')
|
||||
|
||||
assert module.get_subvolumes_for_filesystem('btrfs', '/mnt') == ('/mnt', '/mnt/subvol2')
|
||||
|
||||
|
||||
def test_get_subvolumes_for_filesystem_skips_empty_filesystem_mount_points():
|
||||
flexmock(module.borgmatic.execute).should_receive(
|
||||
'execute_command_and_capture_output'
|
||||
).and_return(
|
||||
'ID 270 gen 107 top level 5 path subvol1\nID 272 gen 74 top level 5 path subvol2\n'
|
||||
)
|
||||
|
||||
assert module.get_subvolumes_for_filesystem('btrfs', ' ') == ()
|
||||
|
||||
|
||||
def test_get_subvolumes_collects_subvolumes_matching_source_directories_from_all_filesystems():
|
||||
flexmock(module).should_receive('get_filesystem_mount_points').and_return(('/mnt1', '/mnt2'))
|
||||
flexmock(module).should_receive('get_subvolumes_for_filesystem').with_args(
|
||||
'btrfs', '/mnt1'
|
||||
).and_return(('/one', '/two'))
|
||||
flexmock(module).should_receive('get_subvolumes_for_filesystem').with_args(
|
||||
'btrfs', '/mnt2'
|
||||
).and_return(('/three', '/four'))
|
||||
|
||||
for path in ('/one', '/four'):
|
||||
flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive(
|
||||
'get_contained_directories'
|
||||
).with_args(path, object).and_return((path,))
|
||||
for path in ('/two', '/three'):
|
||||
flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive(
|
||||
'get_contained_directories'
|
||||
).with_args(path, object).and_return(())
|
||||
flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive(
|
||||
'get_contained_patterns'
|
||||
).with_args('/mnt1', object).and_return((Pattern('/mnt1'),))
|
||||
flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive(
|
||||
'get_contained_patterns'
|
||||
).with_args('/mnt2', object).and_return(())
|
||||
|
||||
assert module.get_subvolumes(
|
||||
'btrfs', 'findmnt', source_directories=['/one', '/four', '/five', '/six', '/mnt2', '/mnt3']
|
||||
) == (
|
||||
module.Subvolume('/four', contained_source_directories=('/four',)),
|
||||
module.Subvolume('/one', contained_source_directories=('/one',)),
|
||||
)
|
||||
'btrfs',
|
||||
'findmnt',
|
||||
patterns=[
|
||||
Pattern('/mnt1'),
|
||||
Pattern('/mnt3'),
|
||||
],
|
||||
) == (module.Subvolume('/mnt1', contained_patterns=(Pattern('/mnt1'),)),)
|
||||
|
||||
|
||||
def test_get_subvolumes_without_source_directories_collects_all_subvolumes_from_all_filesystems():
|
||||
flexmock(module).should_receive('get_filesystem_mount_points').and_return(('/mnt1', '/mnt2'))
|
||||
flexmock(module).should_receive('get_subvolumes_for_filesystem').with_args(
|
||||
'btrfs', '/mnt1'
|
||||
).and_return(('/one', '/two'))
|
||||
flexmock(module).should_receive('get_subvolumes_for_filesystem').with_args(
|
||||
'btrfs', '/mnt2'
|
||||
).and_return(('/three', '/four'))
|
||||
def test_get_subvolumes_without_patterns_collects_all_subvolumes():
|
||||
flexmock(module).should_receive('get_subvolume_mount_points').and_return(('/mnt1', '/mnt2'))
|
||||
|
||||
for path in ('/one', '/two', '/three', '/four'):
|
||||
flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive(
|
||||
'get_contained_directories'
|
||||
).with_args(path, object).and_return((path,))
|
||||
flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive(
|
||||
'get_contained_patterns'
|
||||
).with_args('/mnt1', object).and_return((Pattern('/mnt1'),))
|
||||
flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive(
|
||||
'get_contained_patterns'
|
||||
).with_args('/mnt2', object).and_return((Pattern('/mnt2'),))
|
||||
|
||||
assert module.get_subvolumes('btrfs', 'findmnt') == (
|
||||
module.Subvolume('/four', contained_source_directories=('/four',)),
|
||||
module.Subvolume('/one', contained_source_directories=('/one',)),
|
||||
module.Subvolume('/three', contained_source_directories=('/three',)),
|
||||
module.Subvolume('/two', contained_source_directories=('/two',)),
|
||||
module.Subvolume('/mnt1', contained_patterns=(Pattern('/mnt1'),)),
|
||||
module.Subvolume('/mnt2', contained_patterns=(Pattern('/mnt2'),)),
|
||||
)
|
||||
|
||||
|
||||
def test_dump_data_sources_snapshots_each_subvolume_and_updates_source_directories():
|
||||
source_directories = ['/foo', '/mnt/subvol1']
|
||||
@pytest.mark.parametrize(
|
||||
'subvolume_path,expected_snapshot_path',
|
||||
(
|
||||
('/foo/bar', '/foo/bar/.borgmatic-snapshot-1234/foo/bar'),
|
||||
('/', '/.borgmatic-snapshot-1234'),
|
||||
),
|
||||
)
|
||||
def test_make_snapshot_path_includes_stripped_subvolume_path(
|
||||
subvolume_path, expected_snapshot_path
|
||||
):
|
||||
flexmock(module.os).should_receive('getpid').and_return(1234)
|
||||
|
||||
assert module.make_snapshot_path(subvolume_path) == expected_snapshot_path
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'subvolume_path,pattern,expected_pattern',
|
||||
(
|
||||
(
|
||||
'/foo/bar',
|
||||
Pattern('/foo/bar/baz'),
|
||||
Pattern('/foo/bar/.borgmatic-snapshot-1234/./foo/bar/baz'),
|
||||
),
|
||||
('/foo/bar', Pattern('/foo/bar'), Pattern('/foo/bar/.borgmatic-snapshot-1234/./foo/bar')),
|
||||
(
|
||||
'/foo/bar',
|
||||
Pattern('^/foo/bar', Pattern_type.INCLUDE, Pattern_style.REGULAR_EXPRESSION),
|
||||
Pattern(
|
||||
'^/foo/bar/.borgmatic-snapshot-1234/./foo/bar',
|
||||
Pattern_type.INCLUDE,
|
||||
Pattern_style.REGULAR_EXPRESSION,
|
||||
),
|
||||
),
|
||||
(
|
||||
'/foo/bar',
|
||||
Pattern('/foo/bar', Pattern_type.INCLUDE, Pattern_style.REGULAR_EXPRESSION),
|
||||
Pattern(
|
||||
'/foo/bar/.borgmatic-snapshot-1234/./foo/bar',
|
||||
Pattern_type.INCLUDE,
|
||||
Pattern_style.REGULAR_EXPRESSION,
|
||||
),
|
||||
),
|
||||
('/', Pattern('/foo'), Pattern('/.borgmatic-snapshot-1234/./foo')),
|
||||
('/', Pattern('/'), Pattern('/.borgmatic-snapshot-1234/./')),
|
||||
),
|
||||
)
|
||||
def test_make_borg_snapshot_pattern_includes_slashdot_hack_and_stripped_pattern_path(
|
||||
subvolume_path, pattern, expected_pattern
|
||||
):
|
||||
flexmock(module.os).should_receive('getpid').and_return(1234)
|
||||
|
||||
assert module.make_borg_snapshot_pattern(subvolume_path, pattern) == expected_pattern
|
||||
|
||||
|
||||
def test_dump_data_sources_snapshots_each_subvolume_and_updates_patterns():
|
||||
patterns = [Pattern('/foo'), Pattern('/mnt/subvol1')]
|
||||
config = {'btrfs': {}}
|
||||
flexmock(module).should_receive('get_subvolumes').and_return(
|
||||
(
|
||||
module.Subvolume('/mnt/subvol1', contained_source_directories=('/mnt/subvol1',)),
|
||||
module.Subvolume('/mnt/subvol2', contained_source_directories=('/mnt/subvol2',)),
|
||||
module.Subvolume('/mnt/subvol1', contained_patterns=(Pattern('/mnt/subvol1'),)),
|
||||
module.Subvolume('/mnt/subvol2', contained_patterns=(Pattern('/mnt/subvol2'),)),
|
||||
)
|
||||
)
|
||||
flexmock(module).should_receive('make_snapshot_path').with_args('/mnt/subvol1').and_return(
|
||||
@@ -149,51 +160,68 @@ def test_dump_data_sources_snapshots_each_subvolume_and_updates_source_directori
|
||||
flexmock(module).should_receive('snapshot_subvolume').with_args(
|
||||
'btrfs', '/mnt/subvol2', '/mnt/subvol2/.borgmatic-1234/mnt/subvol2'
|
||||
).once()
|
||||
flexmock(module).should_receive('make_snapshot_exclude_path').with_args(
|
||||
flexmock(module).should_receive('make_snapshot_exclude_pattern').with_args(
|
||||
'/mnt/subvol1'
|
||||
).and_return('/mnt/subvol1/.borgmatic-1234/mnt/subvol1/.borgmatic-1234')
|
||||
flexmock(module).should_receive('make_snapshot_exclude_path').with_args(
|
||||
).and_return(
|
||||
Pattern(
|
||||
'/mnt/subvol1/.borgmatic-1234/mnt/subvol1/.borgmatic-1234',
|
||||
Pattern_type.NO_RECURSE,
|
||||
Pattern_style.FNMATCH,
|
||||
)
|
||||
)
|
||||
flexmock(module).should_receive('make_snapshot_exclude_pattern').with_args(
|
||||
'/mnt/subvol2'
|
||||
).and_return('/mnt/subvol2/.borgmatic-1234/mnt/subvol2/.borgmatic-1234')
|
||||
flexmock(module).should_receive('make_borg_source_directory_path').with_args(
|
||||
).and_return(
|
||||
Pattern(
|
||||
'/mnt/subvol2/.borgmatic-1234/mnt/subvol2/.borgmatic-1234',
|
||||
Pattern_type.NO_RECURSE,
|
||||
Pattern_style.FNMATCH,
|
||||
)
|
||||
)
|
||||
flexmock(module).should_receive('make_borg_snapshot_pattern').with_args(
|
||||
'/mnt/subvol1', object
|
||||
).and_return('/mnt/subvol1/.borgmatic-1234/mnt/subvol1')
|
||||
flexmock(module).should_receive('make_borg_source_directory_path').with_args(
|
||||
).and_return(Pattern('/mnt/subvol1/.borgmatic-1234/mnt/subvol1'))
|
||||
flexmock(module).should_receive('make_borg_snapshot_pattern').with_args(
|
||||
'/mnt/subvol2', object
|
||||
).and_return('/mnt/subvol2/.borgmatic-1234/mnt/subvol2')
|
||||
).and_return(Pattern('/mnt/subvol2/.borgmatic-1234/mnt/subvol2'))
|
||||
|
||||
assert (
|
||||
module.dump_data_sources(
|
||||
hook_config=config['btrfs'],
|
||||
config=config,
|
||||
log_prefix='test',
|
||||
config_paths=('test.yaml',),
|
||||
borgmatic_runtime_directory='/run/borgmatic',
|
||||
source_directories=source_directories,
|
||||
patterns=patterns,
|
||||
dry_run=False,
|
||||
)
|
||||
== []
|
||||
)
|
||||
|
||||
assert source_directories == [
|
||||
'/foo',
|
||||
'/mnt/subvol1/.borgmatic-1234/mnt/subvol1',
|
||||
'/mnt/subvol2/.borgmatic-1234/mnt/subvol2',
|
||||
assert patterns == [
|
||||
Pattern('/foo'),
|
||||
Pattern('/mnt/subvol1/.borgmatic-1234/mnt/subvol1'),
|
||||
Pattern(
|
||||
'/mnt/subvol1/.borgmatic-1234/mnt/subvol1/.borgmatic-1234',
|
||||
Pattern_type.NO_RECURSE,
|
||||
Pattern_style.FNMATCH,
|
||||
),
|
||||
Pattern('/mnt/subvol2/.borgmatic-1234/mnt/subvol2'),
|
||||
Pattern(
|
||||
'/mnt/subvol2/.borgmatic-1234/mnt/subvol2/.borgmatic-1234',
|
||||
Pattern_type.NO_RECURSE,
|
||||
Pattern_style.FNMATCH,
|
||||
),
|
||||
]
|
||||
assert config == {
|
||||
'btrfs': {},
|
||||
'exclude_patterns': [
|
||||
'/mnt/subvol1/.borgmatic-1234/mnt/subvol1/.borgmatic-1234',
|
||||
'/mnt/subvol2/.borgmatic-1234/mnt/subvol2/.borgmatic-1234',
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_dump_data_sources_uses_custom_btrfs_command_in_commands():
|
||||
source_directories = ['/foo', '/mnt/subvol1']
|
||||
patterns = [Pattern('/foo'), Pattern('/mnt/subvol1')]
|
||||
config = {'btrfs': {'btrfs_command': '/usr/local/bin/btrfs'}}
|
||||
flexmock(module).should_receive('get_subvolumes').and_return(
|
||||
(module.Subvolume('/mnt/subvol1', contained_source_directories=('/mnt/subvol1',)),)
|
||||
(module.Subvolume('/mnt/subvol1', contained_patterns=(Pattern('/mnt/subvol1'),)),)
|
||||
)
|
||||
flexmock(module).should_receive('make_snapshot_path').with_args('/mnt/subvol1').and_return(
|
||||
'/mnt/subvol1/.borgmatic-1234/mnt/subvol1'
|
||||
@@ -201,47 +229,54 @@ def test_dump_data_sources_uses_custom_btrfs_command_in_commands():
|
||||
flexmock(module).should_receive('snapshot_subvolume').with_args(
|
||||
'/usr/local/bin/btrfs', '/mnt/subvol1', '/mnt/subvol1/.borgmatic-1234/mnt/subvol1'
|
||||
).once()
|
||||
flexmock(module).should_receive('make_snapshot_exclude_path').with_args(
|
||||
flexmock(module).should_receive('make_snapshot_exclude_pattern').with_args(
|
||||
'/mnt/subvol1'
|
||||
).and_return('/mnt/subvol1/.borgmatic-1234/mnt/subvol1/.borgmatic-1234')
|
||||
flexmock(module).should_receive('make_borg_source_directory_path').with_args(
|
||||
).and_return(
|
||||
Pattern(
|
||||
'/mnt/subvol1/.borgmatic-1234/mnt/subvol1/.borgmatic-1234',
|
||||
Pattern_type.NO_RECURSE,
|
||||
Pattern_style.FNMATCH,
|
||||
)
|
||||
)
|
||||
flexmock(module).should_receive('make_borg_snapshot_pattern').with_args(
|
||||
'/mnt/subvol1', object
|
||||
).and_return('/mnt/subvol1/.borgmatic-1234/mnt/subvol1')
|
||||
).and_return(Pattern('/mnt/subvol1/.borgmatic-1234/mnt/subvol1'))
|
||||
|
||||
assert (
|
||||
module.dump_data_sources(
|
||||
hook_config=config['btrfs'],
|
||||
config=config,
|
||||
log_prefix='test',
|
||||
config_paths=('test.yaml',),
|
||||
borgmatic_runtime_directory='/run/borgmatic',
|
||||
source_directories=source_directories,
|
||||
patterns=patterns,
|
||||
dry_run=False,
|
||||
)
|
||||
== []
|
||||
)
|
||||
|
||||
assert source_directories == [
|
||||
'/foo',
|
||||
'/mnt/subvol1/.borgmatic-1234/mnt/subvol1',
|
||||
assert patterns == [
|
||||
Pattern('/foo'),
|
||||
Pattern('/mnt/subvol1/.borgmatic-1234/mnt/subvol1'),
|
||||
Pattern(
|
||||
'/mnt/subvol1/.borgmatic-1234/mnt/subvol1/.borgmatic-1234',
|
||||
Pattern_type.NO_RECURSE,
|
||||
Pattern_style.FNMATCH,
|
||||
),
|
||||
]
|
||||
assert config == {
|
||||
'btrfs': {
|
||||
'btrfs_command': '/usr/local/bin/btrfs',
|
||||
},
|
||||
'exclude_patterns': [
|
||||
'/mnt/subvol1/.borgmatic-1234/mnt/subvol1/.borgmatic-1234',
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_dump_data_sources_uses_custom_findmnt_command_in_commands():
|
||||
source_directories = ['/foo', '/mnt/subvol1']
|
||||
patterns = [Pattern('/foo'), Pattern('/mnt/subvol1')]
|
||||
config = {'btrfs': {'findmnt_command': '/usr/local/bin/findmnt'}}
|
||||
flexmock(module).should_receive('get_subvolumes').with_args(
|
||||
'btrfs', '/usr/local/bin/findmnt', source_directories
|
||||
'btrfs', '/usr/local/bin/findmnt', patterns
|
||||
).and_return(
|
||||
(module.Subvolume('/mnt/subvol1', contained_source_directories=('/mnt/subvol1',)),)
|
||||
(module.Subvolume('/mnt/subvol1', contained_patterns=(Pattern('/mnt/subvol1'),)),)
|
||||
).once()
|
||||
flexmock(module).should_receive('make_snapshot_path').with_args('/mnt/subvol1').and_return(
|
||||
'/mnt/subvol1/.borgmatic-1234/mnt/subvol1'
|
||||
@@ -249,101 +284,106 @@ def test_dump_data_sources_uses_custom_findmnt_command_in_commands():
|
||||
flexmock(module).should_receive('snapshot_subvolume').with_args(
|
||||
'btrfs', '/mnt/subvol1', '/mnt/subvol1/.borgmatic-1234/mnt/subvol1'
|
||||
).once()
|
||||
flexmock(module).should_receive('make_snapshot_exclude_path').with_args(
|
||||
flexmock(module).should_receive('make_snapshot_exclude_pattern').with_args(
|
||||
'/mnt/subvol1'
|
||||
).and_return('/mnt/subvol1/.borgmatic-1234/mnt/subvol1/.borgmatic-1234')
|
||||
flexmock(module).should_receive('make_borg_source_directory_path').with_args(
|
||||
).and_return(
|
||||
Pattern(
|
||||
'/mnt/subvol1/.borgmatic-1234/mnt/subvol1/.borgmatic-1234',
|
||||
Pattern_type.NO_RECURSE,
|
||||
Pattern_style.FNMATCH,
|
||||
)
|
||||
)
|
||||
flexmock(module).should_receive('make_borg_snapshot_pattern').with_args(
|
||||
'/mnt/subvol1', object
|
||||
).and_return('/mnt/subvol1/.borgmatic-1234/mnt/subvol1')
|
||||
).and_return(Pattern('/mnt/subvol1/.borgmatic-1234/mnt/subvol1'))
|
||||
|
||||
assert (
|
||||
module.dump_data_sources(
|
||||
hook_config=config['btrfs'],
|
||||
config=config,
|
||||
log_prefix='test',
|
||||
config_paths=('test.yaml',),
|
||||
borgmatic_runtime_directory='/run/borgmatic',
|
||||
source_directories=source_directories,
|
||||
patterns=patterns,
|
||||
dry_run=False,
|
||||
)
|
||||
== []
|
||||
)
|
||||
|
||||
assert source_directories == [
|
||||
'/foo',
|
||||
'/mnt/subvol1/.borgmatic-1234/mnt/subvol1',
|
||||
assert patterns == [
|
||||
Pattern('/foo'),
|
||||
Pattern('/mnt/subvol1/.borgmatic-1234/mnt/subvol1'),
|
||||
Pattern(
|
||||
'/mnt/subvol1/.borgmatic-1234/mnt/subvol1/.borgmatic-1234',
|
||||
Pattern_type.NO_RECURSE,
|
||||
Pattern_style.FNMATCH,
|
||||
),
|
||||
]
|
||||
assert config == {
|
||||
'btrfs': {
|
||||
'findmnt_command': '/usr/local/bin/findmnt',
|
||||
},
|
||||
'exclude_patterns': [
|
||||
'/mnt/subvol1/.borgmatic-1234/mnt/subvol1/.borgmatic-1234',
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_dump_data_sources_with_dry_run_skips_snapshot_and_source_directories_update():
|
||||
source_directories = ['/foo', '/mnt/subvol1']
|
||||
def test_dump_data_sources_with_dry_run_skips_snapshot_and_patterns_update():
|
||||
patterns = [Pattern('/foo'), Pattern('/mnt/subvol1')]
|
||||
config = {'btrfs': {}}
|
||||
flexmock(module).should_receive('get_subvolumes').and_return(
|
||||
(module.Subvolume('/mnt/subvol1', contained_source_directories=('/mnt/subvol1',)),)
|
||||
(module.Subvolume('/mnt/subvol1', contained_patterns=(Pattern('/mnt/subvol1'),)),)
|
||||
)
|
||||
flexmock(module).should_receive('make_snapshot_path').with_args('/mnt/subvol1').and_return(
|
||||
'/mnt/subvol1/.borgmatic-1234/mnt/subvol1'
|
||||
)
|
||||
flexmock(module).should_receive('snapshot_subvolume').never()
|
||||
flexmock(module).should_receive('make_snapshot_exclude_path').never()
|
||||
flexmock(module).should_receive('make_snapshot_exclude_pattern').never()
|
||||
|
||||
assert (
|
||||
module.dump_data_sources(
|
||||
hook_config=config['btrfs'],
|
||||
config=config,
|
||||
log_prefix='test',
|
||||
config_paths=('test.yaml',),
|
||||
borgmatic_runtime_directory='/run/borgmatic',
|
||||
source_directories=source_directories,
|
||||
patterns=patterns,
|
||||
dry_run=True,
|
||||
)
|
||||
== []
|
||||
)
|
||||
|
||||
assert source_directories == ['/foo', '/mnt/subvol1']
|
||||
assert patterns == [Pattern('/foo'), Pattern('/mnt/subvol1')]
|
||||
assert config == {'btrfs': {}}
|
||||
|
||||
|
||||
def test_dump_data_sources_without_matching_subvolumes_skips_snapshot_and_source_directories_update():
|
||||
source_directories = ['/foo', '/mnt/subvol1']
|
||||
def test_dump_data_sources_without_matching_subvolumes_skips_snapshot_and_patterns_update():
|
||||
patterns = [Pattern('/foo'), Pattern('/mnt/subvol1')]
|
||||
config = {'btrfs': {}}
|
||||
flexmock(module).should_receive('get_subvolumes').and_return(())
|
||||
flexmock(module).should_receive('make_snapshot_path').never()
|
||||
flexmock(module).should_receive('snapshot_subvolume').never()
|
||||
flexmock(module).should_receive('make_snapshot_exclude_path').never()
|
||||
flexmock(module).should_receive('make_snapshot_exclude_pattern').never()
|
||||
|
||||
assert (
|
||||
module.dump_data_sources(
|
||||
hook_config=config['btrfs'],
|
||||
config=config,
|
||||
log_prefix='test',
|
||||
config_paths=('test.yaml',),
|
||||
borgmatic_runtime_directory='/run/borgmatic',
|
||||
source_directories=source_directories,
|
||||
patterns=patterns,
|
||||
dry_run=False,
|
||||
)
|
||||
== []
|
||||
)
|
||||
|
||||
assert source_directories == ['/foo', '/mnt/subvol1']
|
||||
assert patterns == [Pattern('/foo'), Pattern('/mnt/subvol1')]
|
||||
assert config == {'btrfs': {}}
|
||||
|
||||
|
||||
def test_dump_data_sources_snapshots_adds_to_existing_exclude_patterns():
|
||||
source_directories = ['/foo', '/mnt/subvol1']
|
||||
patterns = [Pattern('/foo'), Pattern('/mnt/subvol1')]
|
||||
config = {'btrfs': {}, 'exclude_patterns': ['/bar']}
|
||||
flexmock(module).should_receive('get_subvolumes').and_return(
|
||||
(
|
||||
module.Subvolume('/mnt/subvol1', contained_source_directories=('/mnt/subvol1',)),
|
||||
module.Subvolume('/mnt/subvol2', contained_source_directories=('/mnt/subvol2',)),
|
||||
module.Subvolume('/mnt/subvol1', contained_patterns=(Pattern('/mnt/subvol1'),)),
|
||||
module.Subvolume('/mnt/subvol2', contained_patterns=(Pattern('/mnt/subvol2'),)),
|
||||
)
|
||||
)
|
||||
flexmock(module).should_receive('make_snapshot_path').with_args('/mnt/subvol1').and_return(
|
||||
@@ -358,44 +398,61 @@ def test_dump_data_sources_snapshots_adds_to_existing_exclude_patterns():
|
||||
flexmock(module).should_receive('snapshot_subvolume').with_args(
|
||||
'btrfs', '/mnt/subvol2', '/mnt/subvol2/.borgmatic-1234/mnt/subvol2'
|
||||
).once()
|
||||
flexmock(module).should_receive('make_snapshot_exclude_path').with_args(
|
||||
flexmock(module).should_receive('make_snapshot_exclude_pattern').with_args(
|
||||
'/mnt/subvol1'
|
||||
).and_return('/mnt/subvol1/.borgmatic-1234/mnt/subvol1/.borgmatic-1234')
|
||||
flexmock(module).should_receive('make_snapshot_exclude_path').with_args(
|
||||
).and_return(
|
||||
Pattern(
|
||||
'/mnt/subvol1/.borgmatic-1234/mnt/subvol1/.borgmatic-1234',
|
||||
Pattern_type.NO_RECURSE,
|
||||
Pattern_style.FNMATCH,
|
||||
)
|
||||
)
|
||||
flexmock(module).should_receive('make_snapshot_exclude_pattern').with_args(
|
||||
'/mnt/subvol2'
|
||||
).and_return('/mnt/subvol2/.borgmatic-1234/mnt/subvol2/.borgmatic-1234')
|
||||
flexmock(module).should_receive('make_borg_source_directory_path').with_args(
|
||||
).and_return(
|
||||
Pattern(
|
||||
'/mnt/subvol2/.borgmatic-1234/mnt/subvol2/.borgmatic-1234',
|
||||
Pattern_type.NO_RECURSE,
|
||||
Pattern_style.FNMATCH,
|
||||
)
|
||||
)
|
||||
flexmock(module).should_receive('make_borg_snapshot_pattern').with_args(
|
||||
'/mnt/subvol1', object
|
||||
).and_return('/mnt/subvol1/.borgmatic-1234/mnt/subvol1')
|
||||
flexmock(module).should_receive('make_borg_source_directory_path').with_args(
|
||||
).and_return(Pattern('/mnt/subvol1/.borgmatic-1234/mnt/subvol1'))
|
||||
flexmock(module).should_receive('make_borg_snapshot_pattern').with_args(
|
||||
'/mnt/subvol2', object
|
||||
).and_return('/mnt/subvol2/.borgmatic-1234/mnt/subvol2')
|
||||
).and_return(Pattern('/mnt/subvol2/.borgmatic-1234/mnt/subvol2'))
|
||||
|
||||
assert (
|
||||
module.dump_data_sources(
|
||||
hook_config=config['btrfs'],
|
||||
config=config,
|
||||
log_prefix='test',
|
||||
config_paths=('test.yaml',),
|
||||
borgmatic_runtime_directory='/run/borgmatic',
|
||||
source_directories=source_directories,
|
||||
patterns=patterns,
|
||||
dry_run=False,
|
||||
)
|
||||
== []
|
||||
)
|
||||
|
||||
assert source_directories == [
|
||||
'/foo',
|
||||
'/mnt/subvol1/.borgmatic-1234/mnt/subvol1',
|
||||
'/mnt/subvol2/.borgmatic-1234/mnt/subvol2',
|
||||
assert patterns == [
|
||||
Pattern('/foo'),
|
||||
Pattern('/mnt/subvol1/.borgmatic-1234/mnt/subvol1'),
|
||||
Pattern(
|
||||
'/mnt/subvol1/.borgmatic-1234/mnt/subvol1/.borgmatic-1234',
|
||||
Pattern_type.NO_RECURSE,
|
||||
Pattern_style.FNMATCH,
|
||||
),
|
||||
Pattern('/mnt/subvol2/.borgmatic-1234/mnt/subvol2'),
|
||||
Pattern(
|
||||
'/mnt/subvol2/.borgmatic-1234/mnt/subvol2/.borgmatic-1234',
|
||||
Pattern_type.NO_RECURSE,
|
||||
Pattern_style.FNMATCH,
|
||||
),
|
||||
]
|
||||
assert config == {
|
||||
'btrfs': {},
|
||||
'exclude_patterns': [
|
||||
'/bar',
|
||||
'/mnt/subvol1/.borgmatic-1234/mnt/subvol1/.borgmatic-1234',
|
||||
'/mnt/subvol2/.borgmatic-1234/mnt/subvol2/.borgmatic-1234',
|
||||
],
|
||||
'exclude_patterns': ['/bar'],
|
||||
}
|
||||
|
||||
|
||||
@@ -403,8 +460,8 @@ def test_remove_data_source_dumps_deletes_snapshots():
|
||||
config = {'btrfs': {}}
|
||||
flexmock(module).should_receive('get_subvolumes').and_return(
|
||||
(
|
||||
module.Subvolume('/mnt/subvol1', contained_source_directories=('/mnt/subvol1',)),
|
||||
module.Subvolume('/mnt/subvol2', contained_source_directories=('/mnt/subvol2',)),
|
||||
module.Subvolume('/mnt/subvol1', contained_patterns=(Pattern('/mnt/subvol1'),)),
|
||||
module.Subvolume('/mnt/subvol2', contained_patterns=(Pattern('/mnt/subvol2'),)),
|
||||
)
|
||||
)
|
||||
flexmock(module).should_receive('make_snapshot_path').with_args('/mnt/subvol1').and_return(
|
||||
@@ -479,7 +536,6 @@ def test_remove_data_source_dumps_deletes_snapshots():
|
||||
module.remove_data_source_dumps(
|
||||
hook_config=config['btrfs'],
|
||||
config=config,
|
||||
log_prefix='test',
|
||||
borgmatic_runtime_directory='/run/borgmatic',
|
||||
dry_run=False,
|
||||
)
|
||||
@@ -497,7 +553,6 @@ def test_remove_data_source_dumps_without_hook_configuration_bails():
|
||||
module.remove_data_source_dumps(
|
||||
hook_config=None,
|
||||
config={'source_directories': '/mnt/subvolume'},
|
||||
log_prefix='test',
|
||||
borgmatic_runtime_directory='/run/borgmatic',
|
||||
dry_run=False,
|
||||
)
|
||||
@@ -516,7 +571,6 @@ def test_remove_data_source_dumps_with_get_subvolumes_file_not_found_error_bails
|
||||
module.remove_data_source_dumps(
|
||||
hook_config=config['btrfs'],
|
||||
config=config,
|
||||
log_prefix='test',
|
||||
borgmatic_runtime_directory='/run/borgmatic',
|
||||
dry_run=False,
|
||||
)
|
||||
@@ -537,7 +591,6 @@ def test_remove_data_source_dumps_with_get_subvolumes_called_process_error_bails
|
||||
module.remove_data_source_dumps(
|
||||
hook_config=config['btrfs'],
|
||||
config=config,
|
||||
log_prefix='test',
|
||||
borgmatic_runtime_directory='/run/borgmatic',
|
||||
dry_run=False,
|
||||
)
|
||||
@@ -547,8 +600,8 @@ def test_remove_data_source_dumps_with_dry_run_skips_deletes():
|
||||
config = {'btrfs': {}}
|
||||
flexmock(module).should_receive('get_subvolumes').and_return(
|
||||
(
|
||||
module.Subvolume('/mnt/subvol1', contained_source_directories=('/mnt/subvol1',)),
|
||||
module.Subvolume('/mnt/subvol2', contained_source_directories=('/mnt/subvol2',)),
|
||||
module.Subvolume('/mnt/subvol1', contained_patterns=(Pattern('/mnt/subvol1'),)),
|
||||
module.Subvolume('/mnt/subvol2', contained_patterns=(Pattern('/mnt/subvol2'),)),
|
||||
)
|
||||
)
|
||||
flexmock(module).should_receive('make_snapshot_path').with_args('/mnt/subvol1').and_return(
|
||||
@@ -601,7 +654,6 @@ def test_remove_data_source_dumps_with_dry_run_skips_deletes():
|
||||
module.remove_data_source_dumps(
|
||||
hook_config=config['btrfs'],
|
||||
config=config,
|
||||
log_prefix='test',
|
||||
borgmatic_runtime_directory='/run/borgmatic',
|
||||
dry_run=True,
|
||||
)
|
||||
@@ -620,7 +672,6 @@ def test_remove_data_source_dumps_without_subvolumes_skips_deletes():
|
||||
module.remove_data_source_dumps(
|
||||
hook_config=config['btrfs'],
|
||||
config=config,
|
||||
log_prefix='test',
|
||||
borgmatic_runtime_directory='/run/borgmatic',
|
||||
dry_run=False,
|
||||
)
|
||||
@@ -630,8 +681,8 @@ def test_remove_data_source_without_snapshots_skips_deletes():
|
||||
config = {'btrfs': {}}
|
||||
flexmock(module).should_receive('get_subvolumes').and_return(
|
||||
(
|
||||
module.Subvolume('/mnt/subvol1', contained_source_directories=('/mnt/subvol1',)),
|
||||
module.Subvolume('/mnt/subvol2', contained_source_directories=('/mnt/subvol2',)),
|
||||
module.Subvolume('/mnt/subvol1', contained_patterns=(Pattern('/mnt/subvol1'),)),
|
||||
module.Subvolume('/mnt/subvol2', contained_patterns=(Pattern('/mnt/subvol2'),)),
|
||||
)
|
||||
)
|
||||
flexmock(module).should_receive('make_snapshot_path').with_args('/mnt/subvol1').and_return(
|
||||
@@ -664,7 +715,6 @@ def test_remove_data_source_without_snapshots_skips_deletes():
|
||||
module.remove_data_source_dumps(
|
||||
hook_config=config['btrfs'],
|
||||
config=config,
|
||||
log_prefix='test',
|
||||
borgmatic_runtime_directory='/run/borgmatic',
|
||||
dry_run=False,
|
||||
)
|
||||
@@ -674,8 +724,8 @@ def test_remove_data_source_dumps_with_delete_snapshot_file_not_found_error_bail
|
||||
config = {'btrfs': {}}
|
||||
flexmock(module).should_receive('get_subvolumes').and_return(
|
||||
(
|
||||
module.Subvolume('/mnt/subvol1', contained_source_directories=('/mnt/subvol1',)),
|
||||
module.Subvolume('/mnt/subvol2', contained_source_directories=('/mnt/subvol2',)),
|
||||
module.Subvolume('/mnt/subvol1', contained_patterns=(Pattern('/mnt/subvol1'),)),
|
||||
module.Subvolume('/mnt/subvol2', contained_patterns=(Pattern('/mnt/subvol2'),)),
|
||||
)
|
||||
)
|
||||
flexmock(module).should_receive('make_snapshot_path').with_args('/mnt/subvol1').and_return(
|
||||
@@ -728,7 +778,6 @@ def test_remove_data_source_dumps_with_delete_snapshot_file_not_found_error_bail
|
||||
module.remove_data_source_dumps(
|
||||
hook_config=config['btrfs'],
|
||||
config=config,
|
||||
log_prefix='test',
|
||||
borgmatic_runtime_directory='/run/borgmatic',
|
||||
dry_run=False,
|
||||
)
|
||||
@@ -738,8 +787,8 @@ def test_remove_data_source_dumps_with_delete_snapshot_called_process_error_bail
|
||||
config = {'btrfs': {}}
|
||||
flexmock(module).should_receive('get_subvolumes').and_return(
|
||||
(
|
||||
module.Subvolume('/mnt/subvol1', contained_source_directories=('/mnt/subvol1',)),
|
||||
module.Subvolume('/mnt/subvol2', contained_source_directories=('/mnt/subvol2',)),
|
||||
module.Subvolume('/mnt/subvol1', contained_patterns=(Pattern('/mnt/subvol1'),)),
|
||||
module.Subvolume('/mnt/subvol2', contained_patterns=(Pattern('/mnt/subvol2'),)),
|
||||
)
|
||||
)
|
||||
flexmock(module).should_receive('make_snapshot_path').with_args('/mnt/subvol1').and_return(
|
||||
@@ -794,7 +843,6 @@ def test_remove_data_source_dumps_with_delete_snapshot_called_process_error_bail
|
||||
module.remove_data_source_dumps(
|
||||
hook_config=config['btrfs'],
|
||||
config=config,
|
||||
log_prefix='test',
|
||||
borgmatic_runtime_directory='/run/borgmatic',
|
||||
dry_run=False,
|
||||
)
|
||||
|
||||
@@ -48,21 +48,21 @@ def test_remove_data_source_dumps_removes_dump_path():
|
||||
flexmock(module.os.path).should_receive('exists').and_return(True)
|
||||
flexmock(module.shutil).should_receive('rmtree').with_args('databases').once()
|
||||
|
||||
module.remove_data_source_dumps('databases', 'SuperDB', 'test.yaml', dry_run=False)
|
||||
module.remove_data_source_dumps('databases', 'SuperDB', dry_run=False)
|
||||
|
||||
|
||||
def test_remove_data_source_dumps_with_dry_run_skips_removal():
|
||||
flexmock(module.os.path).should_receive('exists').never()
|
||||
flexmock(module.shutil).should_receive('rmtree').never()
|
||||
|
||||
module.remove_data_source_dumps('databases', 'SuperDB', 'test.yaml', dry_run=True)
|
||||
module.remove_data_source_dumps('databases', 'SuperDB', dry_run=True)
|
||||
|
||||
|
||||
def test_remove_data_source_dumps_without_dump_path_present_skips_removal():
|
||||
flexmock(module.os.path).should_receive('exists').and_return(False)
|
||||
flexmock(module.shutil).should_receive('rmtree').never()
|
||||
|
||||
module.remove_data_source_dumps('databases', 'SuperDB', 'test.yaml', dry_run=False)
|
||||
module.remove_data_source_dumps('databases', 'SuperDB', dry_run=False)
|
||||
|
||||
|
||||
def test_convert_glob_patterns_to_borg_pattern_makes_multipart_regular_expression():
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user