Compare commits

..
87 Commits
Author SHA1 Message Date
Dan Helfman d29d0bc1c6 NEWS wording tweaks for clarity. 2025-02-03 11:22:54 -08:00
Dan Helfman c3f4f94190 Bump version for release. 2025-02-03 11:20:13 -08:00
Dan Helfman b2d61ade4e Change the default value for the "--original-hostname" flag from "localhost" to no host specified (#985). 2025-02-03 11:17:21 -08:00
Dan Helfman cca9039863 Move the passcommand logic out of a hook to prevent future security issues (e.g., passphrase exfiltration attacks) if a user invokes a credential hook from an arbitrary configuration value (#961). 2025-01-31 22:15:53 -08:00
Dan Helfman afcf253318 Fix flake errors (#961). 2025-01-31 10:27:20 -08:00
Dan Helfman 76533c7db5 Add a clarifying comment to the NEWS entry (#961). 2025-01-31 10:26:05 -08:00
Dan Helfman 0073366dfc Add a passcommand hook so borgmatic can collect the encryption passphrase once and pass it to Borg multiple times (#961).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/984
2025-01-31 18:13:38 +00:00
Dan Helfman 13acaa47e4 Add an end-to-end test for the passcommand hook (#961). 2025-01-30 22:50:13 -08:00
Dan Helfman cf326a98a5 Add test coverage for new code (#961). 2025-01-30 21:29:52 -08:00
Dan Helfman 355eef186e Get existing tests passing again (#961). 2025-01-30 20:18:03 -08:00
Dan Helfman c392e4914c Add documentation (#961). 2025-01-30 10:20:24 -08:00
Dan Helfman 8fed8e0695 Add a passcommand hook to NEWS (#961). 2025-01-30 09:55:32 -08:00
Dan Helfman 52189490a2 Docstring typo (#961). 2025-01-30 09:48:55 -08:00
Dan Helfman 26b44699ba Add a passphrase hook so borgmatic can collect the encryption passphrase once and pass it to Borg multiple times (#961). 2025-01-30 09:35:20 -08:00
Dan Helfman 09933c3dc7 Log the repository path or label on every relevant log message, not just some logs (#635).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/980
2025-01-29 18:39:49 +00:00
Dan Helfman c702dca8da Merge branch 'main' into log-repository-everywhere 2025-01-29 10:31:30 -08:00
Dan Helfman 62003c58ea Fix the Btrfs hook to support subvolumes with names like "@home", different from their mount points (#983). 2025-01-29 09:46:39 -08:00
Dan Helfman 67c22e464a Code formatting (#635). 2025-01-29 08:00:42 -08:00
Dan Helfman 5a9066940f Add monitoring end-to-end tests (#635). 2025-01-28 23:06:22 -08:00
Dan Helfman 61f0987051 Merge branch 'main' into log-repository-everywhere 2025-01-27 22:03:35 -08:00
Dan Helfman 63c39be55f Fix flaking issues (#635). 2025-01-27 12:28:36 -08:00
Dan Helfman 7e344e6e0a Complete test coverage for new code (#635). 2025-01-27 12:25:28 -08:00
Dan Helfman b02ff8b6e5 Fix "spot" check file count delta error (#981). 2025-01-27 10:51:06 -08:00
Dan Helfman b6ff242d3a Fix for borgmatic "exclude_patterns" and "exclude_from" recursing into excluded subdirectories (#982). 2025-01-27 10:07:19 -08:00
Dan Helfman 71f1819f05 Some additional test coverage (#635). 2025-01-27 09:27:12 -08:00
Dan Helfman 31b6e21139 Fix end-to-end tests and update more log messages (#635). 2025-01-26 19:03:40 -08:00
Dan Helfman 7d56641f56 Get existing unit tests passing (#635). 2025-01-26 12:13:29 -08:00
Dan Helfman 1ad6be2077 Add missing test coverage and fix incorrect test expectations (#855). 2025-01-26 09:29:54 -08:00
Dan Helfman 803361b850 Some text fixes (#635). 2025-01-26 09:12:18 -08:00
Dan Helfman e0059de711 Add log prefix context manager to make prefix cleanup/restoration easier (#635). 2025-01-25 21:56:41 -08:00
Dan Helfman b9ec9bb873 Don't prefix command output (like Borg output) with the global log prefix (#635). 2025-01-25 14:49:39 -08:00
Dan Helfman 8c5db19490 Code formatting (#635). 2025-01-25 14:14:48 -08:00
Dan Helfman cc7e01be68 Log the repository path or label on every relevant log message, not just some logs (#635). 2025-01-25 14:01:25 -08:00
Dan Helfman 1232ba8045 Revert "Log the repository path or label on every relevant log message, not just some logs (#635)."
This reverts commit 90c1161a8c.
2025-01-25 13:57:56 -08:00
Dan Helfman 90c1161a8c Log the repository path or label on every relevant log message, not just some logs (#635). 2025-01-25 13:55:58 -08:00
Dan Helfman 02451a8b30 Further database container dump documentation clarifications (#978). 2025-01-25 09:17:13 -08:00
Dan Helfman 730350b31a Fix incorrect option name within schema description. 2025-01-25 08:04:13 -08:00
Dan Helfman 203e1f4e99 Bump version for release. 2025-01-25 08:01:34 -08:00
Dan Helfman 4c35a564ef Fix root patterns so they don't have an invalid "sh:" prefix before getting passed to Borg (#979). 2025-01-25 07:59:53 -08:00
Dan Helfman 7551810ea6 Clarify/correct documentation about dumping databases when using containers (#978). 2025-01-24 14:31:38 -08:00
Dan Helfman ce523eeed6 Add a blurb about recent contributors. 2025-01-23 15:11:54 -08:00
Dan Helfman 3c0def6d6d Expand the recent contributors documentation section to ticket submitters. 2025-01-23 14:41:26 -08:00
Dan Helfman f08014e3be Code formatting. 2025-01-23 12:11:27 -08:00
Dan Helfman 86ad93676d Bump version for release. 2025-01-23 12:09:20 -08:00
Dan Helfman e1825d2bcb Add #977 to NEWS. 2025-01-23 12:08:34 -08:00
Dan Helfman 92b8c0230e Fix exclude patterns parsing to support pattern styles (#977).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/976
2025-01-23 20:06:11 +00:00
Pavel Andreev 73c196aa70 Fix according to review comments 2025-01-23 19:49:10 +00:00
Pavel Andreev 5d390d7953 Fix patterns parsing 2025-01-23 15:58:43 +00:00
Dan Helfman ffb342780b Link to Sentry's DSN documentation (#855). 2025-01-21 17:28:32 -08:00
Dan Helfman 9871267f97 Add a Sentry monitoring hook (#855). 2025-01-21 17:23:56 -08:00
Dan Helfman 914c2b17e9 Add a Sentry monitoring hook (#855). 2025-01-21 17:23:18 -08:00
Dan Helfman 804455ac9f Fix for "exclude_from" files being completely ignored (#971). 2025-01-19 10:27:13 -08:00
Dan Helfman 4fe0fd1576 Fix version number in NEWS. 2025-01-18 09:55:03 -08:00
Dan Helfman e3d40125cb Fix for a "spot" check error when a filename in the most recent archive contains a newline (#968). 2025-01-18 09:54:30 -08:00
Dan Helfman e66df22a6e Fix for an error when a blank line occurs in the configured patterns or excludes (#970). 2025-01-18 09:25:29 -08:00
Dan Helfman e789de0851 Bump version for release. 2025-01-17 13:50:22 -08:00
Dan Helfman f1cac95b9c Fix the "restore" action to work on database dumps without a port when a default port is configured (#969). 2025-01-17 13:46:18 -08:00
Dan Helfman f183800009 For the LVM hook, add support for nested logical volumes (#962). 2025-01-17 09:38:49 -08:00
Dan Helfman b7362bfbac Apply snapshot path rewriting to excludes and patterns, not just source directories (#962).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/964
2025-01-17 03:23:41 +00:00
Dan Helfman 2467518d4e Add even more missing test coverage (#962). 2025-01-16 15:11:59 -08:00
Dan Helfman 3bda843139 Fix the "spot" check to have a nicer error when there are no source paths to compare. 2025-01-15 19:48:08 -08:00
Dan Helfman 44efca2be9 Update patterns schema comment (#962). 2025-01-15 12:37:44 -08:00
Dan Helfman cfeeb87bbe Fix pattern expansion/normalization bug with working directory (#962). 2025-01-15 11:26:26 -08:00
Dan Helfman bb2e986c9d Fix end-to-end tests (#962). 2025-01-15 10:52:09 -08:00
Dan Helfman 67ac70354b Merge branch 'main' into snapshot-excludes-and-patterns 2025-01-15 10:37:36 -08:00
Dan Helfman 8c1d5dbfe1 Revert end-to-end script change. 2025-01-15 10:37:04 -08:00
Dan Helfman a3aeb36159 Merge branch 'main' into snapshot-excludes-and-patterns 2025-01-15 10:35:45 -08:00
Dan Helfman c702a988bd Add a basic end-to-end test for patterns (#962). 2025-01-15 10:33:38 -08:00
Dan Helfman bbf1c3d55e Add test coverage for new code. 2025-01-14 23:01:39 -08:00
Dan Helfman 0b17fb2d3f Get all existing tests passing (#962). 2025-01-14 13:48:20 -08:00
Dan Helfman ca54da1067 Getting additional tests passing (#962). 2025-01-13 20:51:27 -08:00
Dan Helfman 661041da04 Fix check tests (#962). 2025-01-13 10:22:32 -08:00
Dan Helfman ad14ff3ee5 Fix tests for remaining hooks (#962). 2025-01-13 10:07:25 -08:00
Dan Helfman b72b9aaf13 Fix several tests (#962). 2025-01-12 22:42:58 -08:00
Dan Helfman a70fd30cb1 Merge branch 'main' into snapshot-excludes-and-patterns 2025-01-12 11:38:50 -08:00
Dan Helfman 5560f30aa6 Fix a borgmatic runtime directory error when running the "spot" check with a database hook enabled (#965). 2025-01-12 09:36:24 -08:00
Dan Helfman 256ed4170b Minor comment clarifications (#962). 2025-01-10 22:20:49 -08:00
Dan Helfman 071d8d945a Strip comments from patterns (#962). 2025-01-10 12:33:25 -08:00
Dan Helfman 926c26315a Add documentation for patterns and snapshot hooks (#962). 2025-01-10 12:22:37 -08:00
Dan Helfman 120a29ab4d Initial work on applying snapshot path rewriting to excludes and patterns (#962). 2025-01-10 10:38:27 -08:00
Dan Helfman 8573660ff0 Clarify error message to mention patterns, not just excludes (#947). 2025-01-10 08:41:56 -08:00
Dan Helfman 0b9f3ae8a1 Fix comment typo. 2025-01-04 20:18:18 -08:00
Dan Helfman 2c70ad81ec Fix the "spot" check to support relative source directory paths (#960). Fix the "spot" check to no longer consider pipe files within an archive for file comparisons. Fix auto-excluding of special files (when databases are configured) to support relative source directory paths. 2025-01-01 15:00:36 -08:00
Dan Helfman d6c3ec05aa Reduce duplication (#960). 2024-12-29 22:00:25 -08:00
Dan Helfman a4954cc7a3 Fix for archives storing relative source directory paths such that they contain the working directory (#960). 2024-12-29 20:09:13 -08:00
Dan Helfman a6b6dd32c1 Upgrade dependencies and containers for end-to-end tests. 2024-12-29 09:33:25 -08:00
Dan Helfman d3409df84c Fix an error in the Btrfs hook when a "/" subvolume is configured in borgmatic's source directories (#959). 2024-12-28 09:57:33 -08:00
115 changed files with 4013 additions and 2451 deletions
+51
View File
@@ -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.
+5
View File
@@ -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 %}
+1 -3
View File
@@ -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,
+1 -3
View File
@@ -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,
+1 -3
View File
@@ -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
View File
@@ -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,
+3 -6
View File
@@ -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'),
+1 -2
View File
@@ -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
View File
@@ -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 residesor 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,
+1 -1
View File
@@ -23,7 +23,7 @@ def run_delete(
if delete_arguments.repository is None or borgmatic.config.validate.repositories_match(
repository, delete_arguments.repository
):
logger.answer(f'{repository.get("label", repository["path"])}: Deleting archives')
logger.answer('Deleting archives')
archive_name = (
borgmatic.borg.repo_list.resolve_archive_name(
+1 -1
View File
@@ -21,7 +21,7 @@ def run_export_key(
if export_arguments.repository is None or borgmatic.config.validate.repositories_match(
repository, export_arguments.repository
):
logger.info(f'{repository.get("label", repository["path"])}: Exporting repository key')
logger.info('Exporting repository key')
borgmatic.borg.export_key.export_key(
repository['path'],
config,
+1 -3
View File
@@ -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'],
+1 -3
View File
@@ -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'],
+1 -3
View File
@@ -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,
+2 -2
View File
@@ -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'],
+2 -4
View File
@@ -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'],
+1 -1
View File
@@ -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'],
+1 -1
View File
@@ -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'],
+1 -2
View File
@@ -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(
+1 -3
View File
@@ -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'],
+1 -1
View File
@@ -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'],
+30 -21
View File
@@ -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,
+1 -3
View File
@@ -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'],
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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:
+15 -1
View File
@@ -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,
+1 -1
View File
@@ -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(
+1 -1
View File
@@ -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(
+5 -5
View File
@@ -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
+50
View File
@@ -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)
+31
View File
@@ -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,
),
)
+2 -2
View File
@@ -57,7 +57,7 @@ def create_repository(
f'Requested encryption mode "{encryption_mode}" does not match existing repository encryption mode "{repository_encryption_mode}"'
)
logger.info(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.
+1 -1
View File
@@ -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
+77 -73
View File
@@ -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}")
+7 -8
View File
@@ -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):
'''
+46 -10
View File
@@ -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
View File
@@ -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.
+9 -11
View File
@@ -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
+19 -20
View File
@@ -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,
+98 -116
View File
@@ -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,
+2 -2
View File
@@ -46,14 +46,14 @@ def create_named_pipe_for_dump(dump_path):
os.mkfifo(dump_path, mode=0o600)
def remove_data_source_dumps(dump_path, data_source_type_name, 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
+85 -64
View File
@@ -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,
+37 -39
View File
@@ -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
+31 -26
View File
@@ -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
+37 -39
View File
@@ -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
+35 -33
View File
@@ -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
+26 -17
View File
@@ -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
+34 -28
View File
@@ -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
+85 -64
View File
@@ -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
View File
@@ -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}')
)
+4 -4
View File
@@ -66,12 +66,12 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
)
if not hook_config.get('services'):
logger.info(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.
+5 -9
View File
@@ -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.
'''
+5 -9
View File
@@ -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.
'''
+6 -10
View File
@@ -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.
+2 -2
View File
@@ -139,10 +139,10 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
for handler in tuple(logging.getLogger().handlers):
if isinstance(handler, Loki_log_handler):
if state in MONITOR_STATE_TO_LOKI.keys():
handler.raw(f'{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.
'''
+8 -15
View File
@@ -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.
'''
+5 -7
View File
@@ -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.
'''
+5 -7
View File
@@ -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.
'''
+75
View File
@@ -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
+4 -8
View File
@@ -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.
'''
+16 -18
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+15 -8
View File
@@ -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 -11
View File
@@ -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:
+21 -2
View File
@@ -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/).
+40 -6
View File
@@ -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
+1
View File
@@ -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.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+2 -2
View File
@@ -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",
+3 -6
View File
@@ -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
+9 -9
View File
@@ -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:
View File
@@ -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)
+42 -5
View File
@@ -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')
+1 -1
View File
@@ -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')
+62
View File
@@ -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):
+148 -54
View File
@@ -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
View File
@@ -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(),
+70 -24
View File
@@ -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)
File diff suppressed because it is too large Load Diff
+47 -2
View File
@@ -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'
+55
View File
@@ -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
)
+48 -18
View File
@@ -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']
)
+15 -17
View File
@@ -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'
+3 -10
View File
@@ -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,
)
+228 -180
View File
@@ -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,
)
+3 -3
View File
@@ -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