Compare commits

...
127 Commits
Author SHA1 Message Date
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
Dan Helfman 87e77ff2b7 Bump version for release. 2024-12-27 08:54:53 -08:00
Dan Helfman 3517d9d4f3 Indentation tweak. 2024-12-26 19:11:45 -08:00
Dan Helfman d3c7279dad Backup and restore databases that have the same name but with different ports, hostnames, or hooks (#418).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/952
2024-12-26 23:17:58 +00:00
Dan Helfman a99c48c115 Documentation / CLI help clarifications around "--original-port" (#418). 2024-12-26 15:16:29 -08:00
Dan Helfman 94cedd4cf8 Merge branch 'main' into same-named-databases 2024-12-25 23:04:45 -08:00
Dan Helfman a4baf4623b Drop colorama as a library dependency (#958). 2024-12-25 23:02:38 -08:00
Dan Helfman 77df425bd1 Minor edits and clarifying comments (#418). 2024-12-25 21:59:10 -08:00
Dan Helfman 69476a4fab Documentation clarifications (#418). 2024-12-24 23:25:26 -08:00
Dan Helfman be6b865a81 Add even more missing test coverage (#418). 2024-12-24 23:09:44 -08:00
Dan Helfman b58a52e03f Merge branch 'main' into same-named-databases 2024-12-24 15:25:57 -08:00
Dan Helfman 9b85c5bc61 Add missing restore test coverage (#418). 2024-12-24 15:24:09 -08:00
Dan Helfman b8041f5c39 Fix end-to-end tests broken by new database config checks during restore (#418). 2024-12-24 09:13:42 -08:00
Dan Helfman d9d6d3f7f2 Simplify logic to get configured data sources during restoration (#418). 2024-12-23 22:12:47 -08:00
Dan Helfman 0844cd0d4f Fix the printing of a color reset code at exit even when color is disabled (#956). 2024-12-23 19:53:57 -08:00
Dan Helfman d4705602fa Handle more edge cases by erroring (#418). 2024-12-22 22:02:53 -08:00
Dan Helfman 5174a78109 Get existing tests passing (#418). 2024-12-21 13:35:00 -08:00
Dan Helfman 3db79b4352 Simplified dump metadata comparison logic and got a few tests passing (#418). 2024-12-20 22:40:20 -08:00
Dan Helfman d6732d9abb Merge branch 'main' into same-named-databases 2024-12-19 21:07:44 -08:00
Dan Helfman 267af5b372 To avoid a hang in the database hooks, error and exit when the borgmatic runtime directory overlaps with the configured excludes (#947). 2024-12-19 20:59:57 -08:00
Dan Helfman d53ea09adb In tests, account for some function renames (#418). 2024-12-17 16:28:59 -08:00
Dan Helfman 8696cbfa22 Clarify some comments (#418). 2024-12-17 12:02:31 -08:00
Dan Helfman 48dca28c74 When the ZFS, Btrfs, or LVM hooks aren't configured, don't try to cleanup snapshots for them. 2024-12-17 11:00:19 -08:00
Dan Helfman 36bcbd0592 Documentation about restoring datebases with the same name (#418). 2024-12-17 08:51:04 -08:00
Dan Helfman ebb3bca4b3 Fix findmnt command error in the Btrfs hook by switching to parsing JSON output (#954). 2024-12-12 11:58:18 -08:00
Dan Helfman b1e343f15c Initial work on supporting same-named database with different ports, hosts, or hooks (#418). 2024-12-09 08:48:34 -08:00
Dan Helfman cb7f98192c Updates to out-of-date documentation on database dumps. 2024-12-07 12:25:39 -08:00
Dan Helfman 3ceb4f554f Fix out-of-date schema comments about databases and one_file_system. 2024-12-07 11:42:41 -08:00
Dan Helfman 4b18c0bc81 Make LVM snapshots read-only. 2024-12-07 09:41:50 -08:00
Dan Helfman 2ce09dbf82 Snapshot documentation clarifications. 2024-12-07 09:10:52 -08:00
Dan Helfman 8a4f3b8f1a Add word missing from docs (#80). 2024-12-06 20:39:50 -08:00
Dan Helfman 81cd03cbbf Bump version for release. 2024-12-06 20:29:16 -08:00
Dan Helfman f2455527fc Fix spelling in comment (#80). 2024-12-06 20:22:45 -08:00
Dan Helfman 62d67cde0a LVM snapshots + ZFS and Btrfs improvements (#80).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/949
2024-12-07 04:21:22 +00:00
Dan Helfman ae8a9db27d Fix flake issues (#80). 2024-12-06 16:12:01 -08:00
Dan Helfman 8979f8918d Organize imports (#80). 2024-12-06 16:05:46 -08:00
Dan Helfman eb97708092 Completed tests for LVM (#80). 2024-12-06 16:02:33 -08:00
Dan Helfman f2d93b85b4 Lots of LVM unit tests + code formatting (#80). 2024-12-06 13:59:38 -08:00
Dan Helfman b999d2dc4d Add some missing test coverage (#80). 2024-12-06 10:27:47 -08:00
Dan Helfman 7f2e38d061 Fix file permissions (#80). 2024-12-06 09:40:32 -08:00
Dan Helfman 140fc248b6 Fix LVM end-to-end tests (#80). 2024-12-06 09:39:24 -08:00
Dan Helfman ec9e1a8223 LVM hook end-to-end tests, not quite working yet (#80). 2024-12-05 22:46:50 -08:00
Dan Helfman 03bbe77dd9 Add an end-to-end test for the Btrfs hook using a fake Btrfs binary (#80). 2024-12-05 17:35:44 -08:00
Dan Helfman f1c5f11422 Add an end-to-end test for the ZFS hook using a fake ZFS binary (#80). 2024-12-05 11:18:53 -08:00
Dan Helfman f8df06fb92 Remove divison by zero (#80). 2024-12-04 20:33:59 -08:00
Dan Helfman d95707ff9b Get existing tests passing (#80). 2024-12-04 20:22:59 -08:00
Dan Helfman 51a7f50e3a Add ZFS snapshot unmount error fix to NEWS (#950). 2024-12-04 15:43:05 -08:00
Dan Helfman 49b8b693af Don't try to unmount a ZFS snapshot if it's already deleted (#80). 2024-12-04 15:39:04 -08:00
Dan Helfman d0e92493f6 Fix broken ZFS tests (#80). 2024-12-04 14:48:13 -08:00
Dan Helfman 9afdaca985 Before unmounting, remove the snapshot mount path instead of the parent snapshot directory (#80). 2024-12-03 19:19:22 -08:00
Dan Helfman cc11ed78e0 Put LVM snapshots into a data structure for convenience (#80). 2024-12-03 19:12:41 -08:00
Dan Helfman 87f3746881 Fix a ZFS edge case in which the hook tries to unmounted a non-mounted directory (#80). 2024-12-03 15:56:03 -08:00
Dan Helfman 347a4c3dd5 Fix breakage of ZFS user property auto-backup (#80). 2024-12-03 15:43:50 -08:00
Dan Helfman 399bb6ef68 Add recent LVM and ZFS work to NEWS (#80). 2024-12-03 12:22:43 -08:00
Dan Helfman 9b9ecad299 Port the parent directory discovery logic from LVM to Btrfs (#80). 2024-12-03 12:15:34 -08:00
Dan Helfman 8c4b899a13 Use a namedtuple for logical volume metadata (#80). 2024-12-03 11:12:27 -08:00
Dan Helfman 9b77de3d66 Port the parent directory discovery logic from LVM to ZFS (#80). 2024-12-03 11:05:45 -08:00
Dan Helfman bfeea5d394 Code formatting (#80). 2024-12-03 08:52:05 -08:00
Dan Helfman 8a6225b7c2 Factor out logic for finding contained source directories in a parent directory (#80). 2024-12-03 08:51:10 -08:00
Dan Helfman 9aaa3c925f Code formatting (#80). 2024-12-02 21:01:34 -08:00
Dan Helfman 88fd1ae454 Discover parent/grandparent/etc. logical volumes of source directories (#80). 2024-12-02 20:58:50 -08:00
Dan Helfman 27305ec2bf Clarify the path rewriting for LVM (but also ZFS + Btrfs) (#80). 2024-12-02 12:01:04 -08:00
Dan Helfman 4453c2d49c Add LVM logo to integrations docs. 2024-12-02 11:28:57 -08:00
Dan Helfman 6367a00013 Add snapshot_size option (#80). 2024-12-02 11:09:07 -08:00
Dan Helfman cd654cbb57 Fix a few docstring typos (#80). 2024-12-01 21:00:11 -08:00
Dan Helfman 1e8f73779f Fix typo in schema comment (#80). 2024-12-01 20:25:16 -08:00
Dan Helfman 27d167b071 LVM snapshots WIP (#80). 2024-12-01 20:13:02 -08:00
Dan Helfman cfff6c6855 Btrfs snapshotting (#251).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/946
2024-11-30 19:19:09 +00:00
Dan Helfman 37efaeae88 Warn if Btrfs is configured but there are no Btrfs subvolumes detected (#251). 2024-11-30 10:55:30 -08:00
Dan Helfman 0978c669ad A little more Btrfs error handling (#251). 2024-11-30 10:25:01 -08:00
Dan Helfman 1366269586 Add a couple of missing tests (#251). 2024-11-30 09:44:55 -08:00
Dan Helfman a9a0910817 Add Btrfs logo to integrations docs (#251). 2024-11-30 09:36:52 -08:00
Dan Helfman 5bcc7b60c8 Tests for Btrfs (#251). 2024-11-30 09:32:50 -08:00
Dan Helfman 84a0552277 Improve Btrfs hook factoring/organization (#251). 2024-11-29 09:36:46 -08:00
Dan Helfman d4a02f73b5 Create Btrfs snapshots as read-only (#251). 2024-11-28 22:18:44 -08:00
Dan Helfman 3f901c0a52 Btrfs hook documentation (#251). 2024-11-28 20:32:12 -08:00
Dan Helfman b5b5c1fafa Initial work on a Btrfs hook (#251). 2024-11-28 18:47:15 -08:00
Dan Helfman 86e5085acc Fix incorrect documentation links to source. 2024-11-27 08:54:19 -08:00
Dan Helfman 08a5e8717b Merge branch 'main' of ssh://projects.torsion.org:3022/borgmatic-collective/borgmatic 2024-11-27 08:51:00 -08:00
Dan Helfman 6b2f2b2ac4 Reorganize data source and monitoring hooks to make developing new hooks easier. 2024-11-27 08:50:34 -08:00
Dan Helfman a07cf9e699 Revert temporary reversion of 1.9.4.dev0.
revert Temporary revert of 1.9.4.dev0 changeset so we can re-build 1.9.3 (which never actually got built).

revert Fix library error when running within a PyInstaller bundle (#926).
2024-11-26 16:20:06 +00:00
Dan Helfman bf40b01077 Temporary revert of 1.9.4.dev0 changeset so we can re-build 1.9.3 (which never actually got built).
revert Fix library error when running within a PyInstaller bundle (#926).
2024-11-26 16:13:39 +00:00
Dan Helfman a5c6a2fe1c Fix library error when running within a PyInstaller bundle (#926). 2024-11-25 20:14:18 -08:00
108 changed files with 7917 additions and 2024 deletions
+54
View File
@@ -1,3 +1,57 @@
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.
* #947: To avoid a hang in the database hooks, error and exit when the borgmatic runtime
directory overlaps with the configured excludes.
* #954: Fix a findmnt command error in the Btrfs hook by switching to parsing JSON output.
* #956: Fix the printing of a color reset code even when color is disabled.
* #958: Drop colorama as a library dependency.
* When the ZFS, Btrfs, or LVM hooks aren't configured, don't try to cleanup snapshots for them.
1.9.4
* #80 (beta): Add an LVM hook for snapshotting and backing up LVM logical volumes. See the
documentation for more information:
https://torsion.org/borgmatic/docs/how-to/snapshot-your-filesystems/
* #251 (beta): Add a Btrfs hook for snapshotting and backing up Btrfs subvolumes. See the
documentation for more information:
https://torsion.org/borgmatic/docs/how-to/snapshot-your-filesystems/
* #926: Fix a library error when running within a PyInstaller bundle.
* #950: Fix a snapshot unmount error in the ZFS hook when using nested datasets.
* Update the ZFS hook to discover and snapshot ZFS datasets even if they are parent/grandparent
directories of your source directories.
* Reorganize data source and monitoring hooks to make developing new hooks easier.
1.9.3
* #261 (beta): Add a ZFS hook for snapshotting and backing up ZFS datasets. See the documentation
for more information: https://torsion.org/borgmatic/docs/how-to/snapshot-your-filesystems/
+3
View File
@@ -62,6 +62,8 @@ borgmatic is powered by [Borg Backup](https://www.borgbackup.org/).
<a href="https://www.mongodb.com/"><img src="docs/static/mongodb.png" alt="MongoDB" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://sqlite.org/"><img src="docs/static/sqlite.png" alt="SQLite" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://openzfs.org/"><img src="docs/static/openzfs.png" alt="OpenZFS" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://btrfs.readthedocs.io/"><img src="docs/static/btrfs.png" alt="Btrfs" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://sourceware.org/lvm2/"><img src="docs/static/lvm.png" alt="LVM" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://rclone.org"><img src="docs/static/rclone.png" alt="rclone" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://healthchecks.io/"><img src="docs/static/healthchecks.png" alt="Healthchecks" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://uptime.kuma.pet/"><img src="docs/static/uptimekuma.png" alt="Uptime Kuma" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
@@ -73,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>
+31 -14
View File
@@ -364,17 +364,19 @@ def collect_spot_check_source_paths(
'use_streaming',
config,
repository['path'],
borgmatic.hooks.dump.DATA_SOURCE_HOOK_NAMES,
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 +401,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 +411,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 +429,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 +443,17 @@ def collect_spot_check_archive_paths(
config,
local_borg_version,
global_arguments,
path_format='{type} /{path}{NL}', # noqa: FS003
path_format='{type} {path}{NL}', # 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
)
@@ -532,7 +540,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}{NL}', # noqa: FS003
local_path=local_path,
remote_path=remote_path,
)
@@ -544,7 +552,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
@@ -621,16 +629,25 @@ def spot_check(
)
logger.debug(f'{log_prefix}: {len(archive_paths)} total archive paths for spot check')
if len(source_paths) == 0:
logger.debug(
f'{log_prefix}: Paths in latest archive but not source paths: {", ".join(set(archive_paths)) or "none"}'
)
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'{log_prefix}: 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'{log_prefix}: Paths in latest archive but not source paths: {", ".join(set(archive_paths) - rootless_source_paths) or "none"}'
)
raise ValueError(
f'Spot check failed: {count_delta_percentage:.2f}% file count delta between source paths and latest archive (tolerance is {spot_check_config["count_tolerance_percentage"]}%)'
+1 -1
View File
@@ -41,7 +41,7 @@ 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},
+207 -99
View File
@@ -6,141 +6,248 @@ 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
import borgmatic.hooks.dispatch
import borgmatic.hooks.dump
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(), borgmatic.borg.pattern.Pattern_style.SHELL)
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.EXCLUDE.value} {exclude_line.strip()}',
borgmatic.borg.pattern.Pattern_style.FNMATCH,
)
for exclude_line in config.get('exclude_patterns', ())
)
+ tuple(
parse_pattern(pattern_line.strip(), borgmatic.borg.pattern.Pattern_style.SHELL)
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.EXCLUDE.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,
)
),
)
)
@@ -178,6 +285,7 @@ def run_create(
log_prefix = repository.get('label', repository['path'])
logger.info(f'{log_prefix}: Creating archive{dry_run_label}')
working_directory = borgmatic.config.paths.get_working_directory(config)
with borgmatic.config.paths.Runtime_directory(
config, log_prefix
@@ -186,33 +294,33 @@ def run_create(
'remove_data_source_dumps',
config,
repository['path'],
borgmatic.hooks.dump.DATA_SOURCE_HOOK_NAMES,
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.dump.DATA_SOURCE_HOOK_NAMES,
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,
@@ -232,7 +340,7 @@ def run_create(
'remove_data_source_dumps',
config,
config_filename,
borgmatic.hooks.dump.DATA_SOURCE_HOOK_NAMES,
borgmatic.hooks.dispatch.Hook_type.DATA_SOURCE,
borgmatic_runtime_directory,
global_arguments.dry_run,
)
+259 -178
View File
@@ -1,4 +1,4 @@
import copy
import collections
import logging
import os
import pathlib
@@ -11,58 +11,112 @@ import borgmatic.borg.mount
import borgmatic.borg.repo_list
import borgmatic.config.paths
import borgmatic.config.validate
import borgmatic.hooks.data_source.dump
import borgmatic.hooks.dispatch
import borgmatic.hooks.dump
logger = logging.getLogger(__name__)
UNSPECIFIED_HOOK = object()
UNSPECIFIED = object()
def get_configured_data_source(
config,
archive_data_source_names,
hook_name,
data_source_name,
configuration_data_source_name=None,
):
Dump = collections.namedtuple(
'Dump',
('hook_name', 'data_source_name', 'hostname', 'port'),
defaults=('localhost', None),
)
def dumps_match(first, second, default_port=None):
'''
Find the first data source with the given hook name and data source name in the configuration
dict and the given archive data source names dict (from hook name to data source names contained
in a particular backup archive). If UNSPECIFIED_HOOK is given as the hook name, search all data
source hooks for the named data source. If a configuration data source name is given, use that
instead of the data source name to lookup the data source in the given hooks configuration.
Return the found data source as a tuple of (found hook name, data source configuration dict) or
(None, None) if not found.
Compare two Dump instances for equality while supporting a field value of UNSPECIFIED, which
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.
'''
if not configuration_data_source_name:
configuration_data_source_name = data_source_name
for field_name in first._fields:
first_value = getattr(first, field_name)
second_value = getattr(second, field_name)
if hook_name == UNSPECIFIED_HOOK:
hooks_to_search = {
hook_name: value
for (hook_name, value) in config.items()
if hook_name in borgmatic.hooks.dump.DATA_SOURCE_HOOK_NAMES
}
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
if first_value != second_value:
return False
return True
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'
port = None if dump.port is UNSPECIFIED else dump.port
if port:
metadata = f'{name}@:{port}' if hostname is UNSPECIFIED else f'{name}@{hostname}:{port}'
else:
try:
hooks_to_search = {hook_name: config[hook_name]}
except KeyError:
return (None, None)
metadata = f'{name}' if hostname is UNSPECIFIED else f'{name}@{hostname}'
return next(
(
(name, hook_data_source)
for (name, hook) in hooks_to_search.items()
for hook_data_source in hook
if hook_data_source['name'] == configuration_data_source_name
and data_source_name in archive_data_source_names.get(name, [])
),
(None, None),
if dump.hook_name not in (None, UNSPECIFIED):
return f'{metadata} ({dump.hook_name})'
return metadata
def get_configured_data_source(config, restore_dump, log_prefix):
'''
Search in the given configuration dict for dumps corresponding to the given dump to restore. If
there are multiple matches, error. Log using the given log prefix.
Return the found data source as a data source configuration dict or None if not found.
'''
try:
hooks_to_search = {restore_dump.hook_name: config[restore_dump.hook_name]}
except KeyError:
return None
matching_dumps = tuple(
hook_data_source
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,
log_prefix=log_prefix,
hook_name=hook_name,
),
)
if dumps_match(
Dump(
hook_name,
hook_data_source.get('name'),
hook_data_source.get('hostname', 'localhost'),
hook_data_source.get('port'),
),
restore_dump,
default_port,
)
)
if not matching_dumps:
return None
if len(matching_dumps) > 1:
raise ValueError(
f'Cannot restore data source {render_dump_metadata(restore_dump)} because there are multiple matching data sources configured'
)
return matching_dumps[0]
def strip_path_prefix_from_extracted_dump_destination(
destination_path, borgmatic_runtime_directory
@@ -97,7 +151,7 @@ def strip_path_prefix_from_extracted_dump_destination(
break
def restore_single_data_source(
def restore_single_dump(
repository,
config,
local_borg_version,
@@ -115,18 +169,22 @@ def restore_single_data_source(
username/password as connection params, and a configured data source configuration dict, restore
that data source from the archive.
'''
dump_metadata = render_dump_metadata(
Dump(hook_name, data_source['name'], data_source.get('hostname'), data_source.get('port'))
)
logger.info(
f'{repository.get("label", repository["path"])}: Restoring data source {data_source["name"]}'
f'{repository.get("label", repository["path"])}: Restoring data source {dump_metadata}'
)
dump_patterns = borgmatic.hooks.dispatch.call_hooks(
'make_data_source_dump_patterns',
config,
repository['path'],
borgmatic.hooks.dump.DATA_SOURCE_HOOK_NAMES,
borgmatic.hooks.dispatch.Hook_type.DATA_SOURCE,
borgmatic_runtime_directory,
data_source['name'],
)[hook_name]
)[hook_name.split('_databases', 1)[0]]
destination_path = (
tempfile.mkdtemp(dir=borgmatic_runtime_directory)
@@ -141,7 +199,11 @@ def restore_single_data_source(
dry_run=global_arguments.dry_run,
repository=repository['path'],
archive=archive_name,
paths=[borgmatic.hooks.dump.convert_glob_patterns_to_borg_pattern(dump_patterns)],
paths=[
borgmatic.hooks.data_source.dump.convert_glob_patterns_to_borg_pattern(
dump_patterns
)
],
config=config,
local_borg_version=local_borg_version,
global_arguments=global_arguments,
@@ -162,11 +224,11 @@ def restore_single_data_source(
shutil.rmtree(destination_path, ignore_errors=True)
# Run a single data source restore, consuming the extract stdout (if any).
borgmatic.hooks.dispatch.call_hooks(
borgmatic.hooks.dispatch.call_hook(
function_name='restore_data_source_dump',
config=config,
log_prefix=repository['path'],
hook_names=[hook_name],
hook_name=hook_name,
data_source=data_source,
dry_run=global_arguments.dry_run,
extract_process=extract_process,
@@ -175,7 +237,7 @@ def restore_single_data_source(
)
def collect_archive_data_source_names(
def collect_dumps_from_archive(
repository,
archive,
config,
@@ -187,17 +249,17 @@ def collect_archive_data_source_names(
):
'''
Given a local or remote repository path, a resolved archive name, a configuration dict, the
local Borg version, global_arguments an argparse.Namespace, local and remote Borg paths, and the
borgmatic runtime directory, query the archive for the names of data sources it contains as
dumps and return them as a dict from hook name to a sequence of data source names.
local Borg version, global arguments an argparse.Namespace, local and remote Borg paths, and the
borgmatic runtime directory, query the archive for the names of data sources dumps it contains
and return them as a set of Dump instances.
'''
borgmatic_source_directory = str(
pathlib.Path(borgmatic.config.paths.get_borgmatic_source_directory(config))
)
# Probe for the data source dumps 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
# still want to support reading dumps from previously created archives as well.
# the borgmatic runtime directory (which gets stored as just "/borgmatic" with Borg 1.4+). But
# we still want to support reading dumps from previously created archives as well.
dump_paths = borgmatic.borg.list.capture_archive_listing(
repository,
archive,
@@ -206,7 +268,9 @@ def collect_archive_data_source_names(
global_arguments,
list_paths=[
'sh:'
+ borgmatic.hooks.dump.make_data_source_dump_path(base_directory, '*_databases/*/*')
+ borgmatic.hooks.data_source.dump.make_data_source_dump_path(
base_directory, '*_databases/*/*'
)
for base_directory in (
'borgmatic',
borgmatic.config.paths.make_runtime_directory_glob(borgmatic_runtime_directory),
@@ -217,110 +281,145 @@ def collect_archive_data_source_names(
remote_path=remote_path,
)
# Determine the data source names corresponding to the dumps found in the archive and
# add them to restore_names.
archive_data_source_names = {}
# Parse the paths of dumps found in the archive to get their respective dump metadata.
dumps_from_archive = set()
for dump_path in dump_paths:
if not dump_path:
continue
# Probe to find the base directory that's at the start of the dump path.
for base_directory in (
'borgmatic',
borgmatic_runtime_directory,
borgmatic_source_directory,
):
try:
(hook_name, _, data_source_name) = dump_path.split(base_directory + os.path.sep, 1)[
1
].split(os.path.sep)[0:3]
(hook_name, host_and_port, data_source_name) = dump_path.split(
base_directory + os.path.sep, 1
)[1].split(os.path.sep)[0:3]
except (ValueError, IndexError):
pass
else:
if data_source_name not in archive_data_source_names.get(hook_name, []):
archive_data_source_names.setdefault(hook_name, []).extend([data_source_name])
break
continue
parts = host_and_port.split(':', 1)
if len(parts) == 1:
parts += (None,)
(hostname, port) = parts
try:
port = int(port)
except (ValueError, TypeError):
port = None
dumps_from_archive.add(Dump(hook_name, data_source_name, hostname, port))
# We've successfully parsed the dump path, so need to probe any further.
break
else:
logger.warning(
f'{repository}: Ignoring invalid data source dump path "{dump_path}" in archive {archive}'
)
return archive_data_source_names
return dumps_from_archive
def find_data_sources_to_restore(requested_data_source_names, archive_data_source_names):
def get_dumps_to_restore(restore_arguments, dumps_from_archive):
'''
Given a sequence of requested data source names to restore and a dict of hook name to the names
of data sources found in an archive, return an expanded sequence of data source names to
restore, replacing "all" with actual data source names as appropriate.
Given restore arguments as an argparse.Namespace instance indicating which dumps to restore and
a set of Dump instances representing the dumps found in an archive, return a set of specific
Dump instances from the archive to restore. As part of this, replace any Dump having a data
source name of "all" with multiple named Dump instances as appropriate.
Raise ValueError if any of the requested data source names cannot be found in the archive.
Raise ValueError if any of the requested data source names cannot be found in the archive or if
there are multiple archive dump matches for a given requested dump.
'''
# A map from data source hook name to the data source names to restore for that hook.
restore_names = (
{UNSPECIFIED_HOOK: requested_data_source_names}
if requested_data_source_names
else {UNSPECIFIED_HOOK: ['all']}
requested_dumps = (
{
Dump(
hook_name=(
(
restore_arguments.hook
if restore_arguments.hook.endswith('_databases')
else f'{restore_arguments.hook}_databases'
)
if restore_arguments.hook
else UNSPECIFIED
),
data_source_name=name,
hostname=restore_arguments.original_hostname or 'localhost',
port=restore_arguments.original_port,
)
for name in restore_arguments.data_sources
}
if restore_arguments.data_sources
else {
Dump(
hook_name=UNSPECIFIED,
data_source_name='all',
hostname=UNSPECIFIED,
port=UNSPECIFIED,
)
}
)
missing_dumps = set()
dumps_to_restore = set()
# If "all" is in restore_names, then replace it with the names of dumps found within the
# archive.
if 'all' in restore_names[UNSPECIFIED_HOOK]:
restore_names[UNSPECIFIED_HOOK].remove('all')
# If there's a requested "all" dump, add every dump from the archive to the dumps to restore.
if any(dump for dump in requested_dumps if dump.data_source_name == 'all'):
dumps_to_restore.update(dumps_from_archive)
for hook_name, data_source_names in archive_data_source_names.items():
restore_names.setdefault(hook_name, []).extend(data_source_names)
# If any archive dump matches a requested dump, add the archive dump to the dumps to restore.
for requested_dump in requested_dumps:
if requested_dump.data_source_name == 'all':
continue
# If a data source is to be restored as part of "all", then remove it from restore names
# so it doesn't get restored twice.
for data_source_name in data_source_names:
if data_source_name in restore_names[UNSPECIFIED_HOOK]:
restore_names[UNSPECIFIED_HOOK].remove(data_source_name)
if not restore_names[UNSPECIFIED_HOOK]:
restore_names.pop(UNSPECIFIED_HOOK)
combined_restore_names = set(
name for data_source_names in restore_names.values() for name in data_source_names
)
combined_archive_data_source_names = set(
name
for data_source_names in archive_data_source_names.values()
for name in data_source_names
)
missing_names = sorted(set(combined_restore_names) - combined_archive_data_source_names)
if missing_names:
joined_names = ', '.join(f'"{name}"' for name in missing_names)
raise ValueError(
f"Cannot restore data source{'s' if len(missing_names) > 1 else ''} {joined_names} missing from archive"
matching_dumps = tuple(
archive_dump
for archive_dump in dumps_from_archive
if dumps_match(requested_dump, archive_dump)
)
return restore_names
if len(matching_dumps) == 0:
missing_dumps.add(requested_dump)
elif len(matching_dumps) == 1:
dumps_to_restore.add(matching_dumps[0])
else:
raise ValueError(
f'Cannot restore data source {render_dump_metadata(requested_dump)} because there are multiple matching dumps in the archive. Try adding flags to disambiguate.'
)
if missing_dumps:
rendered_dumps = ', '.join(
f'{render_dump_metadata(dump)}' for dump in sorted(missing_dumps)
)
raise ValueError(
f"Cannot restore data source dump{'s' if len(missing_dumps) > 1 else ''} {rendered_dumps} missing from archive"
)
return dumps_to_restore
def ensure_data_sources_found(restore_names, remaining_restore_names, found_names):
def ensure_requested_dumps_restored(dumps_to_restore, dumps_actually_restored):
'''
Given a dict from hook name to data source names to restore, a dict from hook name to remaining
data source names to restore, and a sequence of found (actually restored) data source names,
raise ValueError if requested data source to restore were missing from the archive and/or
Given a set of requested dumps to restore and a set of dumps actually restored, raise ValueError
if any requested dumps to restore weren't restored, indicating that they were missing from the
configuration.
'''
combined_restore_names = set(
name
for data_source_names in tuple(restore_names.values())
+ tuple(remaining_restore_names.values())
for name in data_source_names
)
if not combined_restore_names and not found_names:
if not dumps_actually_restored:
raise ValueError('No data source dumps were found to restore')
missing_names = sorted(set(combined_restore_names) - set(found_names))
if missing_names:
joined_names = ', '.join(f'"{name}"' for name in missing_names)
missing_dumps = sorted(
dumps_to_restore - dumps_actually_restored, key=lambda dump: dump.data_source_name
)
if missing_dumps:
rendered_dumps = ', '.join(f'{render_dump_metadata(dump)}' for dump in missing_dumps)
raise ValueError(
f"Cannot restore data source{'s' if len(missing_names) > 1 else ''} {joined_names} missing from borgmatic's configuration"
f"Cannot restore data source{'s' if len(missing_dumps) > 1 else ''} {rendered_dumps} missing from borgmatic's configuration"
)
@@ -337,7 +436,8 @@ def run_restore(
Run the "restore" action for the given repository, but only if the repository matches the
requested repository in restore arguments.
Raise ValueError if a configured data source could not be found to restore.
Raise ValueError if a configured data source could not be found to restore or there's no
matching dump in the archive.
'''
if restore_arguments.repository and not borgmatic.config.validate.repositories_match(
repository, restore_arguments.repository
@@ -354,7 +454,7 @@ def run_restore(
'remove_data_source_dumps',
config,
repository['path'],
borgmatic.hooks.dump.DATA_SOURCE_HOOK_NAMES,
borgmatic.hooks.dispatch.Hook_type.DATA_SOURCE,
borgmatic_runtime_directory,
global_arguments.dry_run,
)
@@ -368,7 +468,7 @@ def run_restore(
local_path,
remote_path,
)
archive_data_source_names = collect_archive_data_source_names(
dumps_from_archive = collect_dumps_from_archive(
repository['path'],
archive_name,
config,
@@ -378,11 +478,9 @@ def run_restore(
remote_path,
borgmatic_runtime_directory,
)
restore_names = find_data_sources_to_restore(
restore_arguments.data_sources, archive_data_source_names
)
found_names = set()
remaining_restore_names = {}
dumps_to_restore = get_dumps_to_restore(restore_arguments, dumps_from_archive)
dumps_actually_restored = set()
connection_params = {
'hostname': restore_arguments.hostname,
'port': restore_arguments.port,
@@ -391,69 +489,52 @@ def run_restore(
'restore_path': restore_arguments.restore_path,
}
for hook_name, data_source_names in restore_names.items():
for data_source_name in data_source_names:
found_hook_name, found_data_source = get_configured_data_source(
config, archive_data_source_names, hook_name, data_source_name
)
# Restore each dump.
for restore_dump in dumps_to_restore:
found_data_source = get_configured_data_source(
config,
restore_dump,
log_prefix=repository['path'],
)
if not found_data_source:
remaining_restore_names.setdefault(found_hook_name or hook_name, []).append(
data_source_name
)
continue
found_names.add(data_source_name)
restore_single_data_source(
repository,
# For a dump that wasn't found via an exact match in the configuration, try to fallback
# to an "all" data source.
if not found_data_source:
found_data_source = get_configured_data_source(
config,
local_borg_version,
global_arguments,
local_path,
remote_path,
archive_name,
found_hook_name or hook_name,
dict(found_data_source, **{'schemas': restore_arguments.schemas}),
connection_params,
borgmatic_runtime_directory,
)
# For any data sources that weren't found via exact matches in the configuration, try to
# fallback to "all" entries.
for hook_name, data_source_names in remaining_restore_names.items():
for data_source_name in data_source_names:
found_hook_name, found_data_source = get_configured_data_source(
config, archive_data_source_names, hook_name, data_source_name, 'all'
Dump(restore_dump.hook_name, 'all', restore_dump.hostname, restore_dump.port),
log_prefix=repository['path'],
)
if not found_data_source:
continue
found_names.add(data_source_name)
data_source = copy.copy(found_data_source)
data_source['name'] = data_source_name
found_data_source = dict(found_data_source)
found_data_source['name'] = restore_dump.data_source_name
restore_single_data_source(
repository,
config,
local_borg_version,
global_arguments,
local_path,
remote_path,
archive_name,
found_hook_name or hook_name,
dict(data_source, **{'schemas': restore_arguments.schemas}),
connection_params,
borgmatic_runtime_directory,
)
dumps_actually_restored.add(restore_dump)
restore_single_dump(
repository,
config,
local_borg_version,
global_arguments,
local_path,
remote_path,
archive_name,
restore_dump.hook_name,
dict(found_data_source, **{'schemas': restore_arguments.schemas}),
connection_params,
borgmatic_runtime_directory,
)
borgmatic.hooks.dispatch.call_hooks_even_if_unconfigured(
'remove_data_source_dumps',
config,
repository['path'],
borgmatic.hooks.dump.DATA_SOURCE_HOOK_NAMES,
borgmatic.hooks.dispatch.Hook_type.DATA_SOURCE,
borgmatic_runtime_directory,
global_arguments.dry_run,
)
ensure_data_sources_found(restore_names, remaining_restore_names, found_names)
ensure_requested_dumps_restored(dumps_to_restore, dumps_actually_restored)
+112 -130
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, log_prefix, 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)
Use the given log prefix in any logging.
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)
else:
pattern_file.seek(0)
patterns_file.write('\n')
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'{log_prefix}: Writing 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,14 +117,25 @@ def any_parent_directories(path, candidate_parents):
def collect_special_file_paths(
create_command, config, local_path, working_directory, borg_environment, skip_directories
dry_run,
create_command,
config,
local_path,
working_directory,
borg_environment,
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 a sequence of parent directories
to skip, collect the paths for any special files (character devices, block devices, and named
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
configured to be excluded from the files Borg backs up, error, because this means Borg won't be
able to consume any database dumps and therefore borgmatic will hang.
'''
# Omit "--exclude-nodump" from the Borg dry run command, because that flag causes Borg to open
# files including any named pipe we've created.
@@ -186,26 +154,39 @@ def collect_special_file_paths(
for path_line in paths_output.split('\n')
if path_line and path_line.startswith('- ') or path_line.startswith('+ ')
)
skip_paths = {}
if os.path.exists(borgmatic_runtime_directory):
skip_paths = {
path for path in paths if any_parent_directories(path, (borgmatic_runtime_directory,))
}
if not skip_paths and not dry_run:
raise ValueError(
f'The runtime directory {os.path.normpath(borgmatic_runtime_directory)} overlaps with the configured excludes or patterns with excludes. Please ensure the runtime directory is not excluded.'
)
return tuple(
path
for path in paths
if special_file(path) and not any_parent_directories(path, skip_directories)
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
@@ -215,7 +196,7 @@ def make_base_create_command(
dry_run,
repository_path,
config,
source_directories,
patterns,
local_borg_version,
global_arguments,
borgmatic_runtime_directory,
@@ -227,23 +208,18 @@ 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
patterns_file = write_patterns_file(
patterns, borgmatic_runtime_directory, log_prefix=repository_path
)
exclude_file = write_pattern_file(expand_home_directories(config.get('exclude_patterns')))
checkpoint_interval = config.get('checkpoint_interval', None)
checkpoint_volume = config.get('checkpoint_volume', None)
chunker_params = config.get('chunker_params', None)
@@ -286,8 +262,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 ())
@@ -317,7 +293,7 @@ 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.
@@ -330,14 +306,13 @@ def make_base_create_command(
logger.debug(f'{repository_path}: Collecting special file paths')
special_file_paths = collect_special_file_paths(
dry_run,
create_flags + create_positional_arguments,
config,
local_path,
working_directory,
borg_environment,
skip_directories=(
[borgmatic_runtime_directory] if os.path.exists(borgmatic_runtime_directory) else []
),
borgmatic_runtime_directory=borgmatic_runtime_directory,
)
if special_file_paths:
@@ -349,22 +324,31 @@ def make_base_create_command(
logger.warning(
f'{repository_path}: 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.EXCLUDE,
borgmatic.borg.pattern.Pattern_style.FNMATCH,
)
for special_file_path in special_file_paths
),
pattern_file=exclude_file,
borgmatic_runtime_directory,
log_prefix=repository_path,
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,
@@ -377,7 +361,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).
@@ -388,22 +372,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:
+3 -3
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,
@@ -132,7 +132,7 @@ def capture_archive_listing(
borg_exit_codes=config.get('borg_exit_codes'),
)
.strip('\n')
.split('\n')
.split('\0')
)
@@ -230,7 +230,7 @@ def list_archive(
borg_exit_codes=borg_exit_codes,
)
.strip('\n')
.split('\n')
.splitlines()
)
else:
archive_lines = (list_arguments.archive,)
+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,
),
)
+14 -1
View File
@@ -1153,7 +1153,7 @@ def make_parsers():
metavar='NAME',
dest='data_sources',
action='append',
help="Name of data source (e.g. database) to restore from archive, must be defined in borgmatic's configuration, can specify flag multiple times, defaults to all data sources in the archive",
help="Name of data source (e.g. database) to restore from the archive, must be defined in borgmatic's configuration, can specify the flag multiple times, defaults to all data sources in the archive",
)
restore_group.add_argument(
'--schema',
@@ -1182,6 +1182,19 @@ def make_parsers():
'--restore-path',
help='Path to restore SQLite database dumps to. Defaults to the "restore_path" option in borgmatic\'s configuration',
)
restore_group.add_argument(
'--original-hostname',
help='The hostname where the dump to restore came from, only necessary if you need to disambiguate dumps',
)
restore_group.add_argument(
'--original-port',
type=int,
help="The port where the dump to restore came from (if that port is in borgmatic's configuration), only necessary if you need to disambiguate dumps",
)
restore_group.add_argument(
'--hook',
help='The name of the data source hook for the dump to restore, only necessary if you need to disambiguate dumps',
)
restore_group.add_argument(
'-h', '--help', action='help', help='Show this help message and exit'
)
+10 -11
View File
@@ -8,8 +8,6 @@ import time
from queue import Queue
from subprocess import CalledProcessError
import colorama
import borgmatic.actions.borg
import borgmatic.actions.break_lock
import borgmatic.actions.change_passphrase
@@ -39,7 +37,8 @@ from borgmatic.borg import umount as borg_umount
from borgmatic.borg import version as borg_version
from borgmatic.commands.arguments import parse_arguments
from borgmatic.config import checks, collect, validate
from borgmatic.hooks import command, dispatch, monitor
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.signals import configure_signals
from borgmatic.verbosity import verbosity_to_log_level
@@ -103,7 +102,7 @@ def run_configuration(config_filename, config, config_paths, arguments):
'initialize_monitor',
config,
config_filename,
monitor.MONITOR_HOOK_NAMES,
dispatch.Hook_type.MONITORING,
monitoring_log_level,
global_arguments.dry_run,
)
@@ -112,7 +111,7 @@ def run_configuration(config_filename, config, config_paths, arguments):
'ping_monitor',
config,
config_filename,
monitor.MONITOR_HOOK_NAMES,
dispatch.Hook_type.MONITORING,
monitor.State.START,
monitoring_log_level,
global_arguments.dry_run,
@@ -188,7 +187,7 @@ def run_configuration(config_filename, config, config_paths, arguments):
'ping_monitor',
config,
config_filename,
monitor.MONITOR_HOOK_NAMES,
dispatch.Hook_type.MONITORING,
monitor.State.LOG,
monitoring_log_level,
global_arguments.dry_run,
@@ -205,7 +204,7 @@ def run_configuration(config_filename, config, config_paths, arguments):
'ping_monitor',
config,
config_filename,
monitor.MONITOR_HOOK_NAMES,
dispatch.Hook_type.MONITORING,
monitor.State.FINISH,
monitoring_log_level,
global_arguments.dry_run,
@@ -214,7 +213,7 @@ def run_configuration(config_filename, config, config_paths, arguments):
'destroy_monitor',
config,
config_filename,
monitor.MONITOR_HOOK_NAMES,
dispatch.Hook_type.MONITORING,
monitoring_log_level,
global_arguments.dry_run,
)
@@ -241,7 +240,7 @@ def run_configuration(config_filename, config, config_paths, arguments):
'ping_monitor',
config,
config_filename,
monitor.MONITOR_HOOK_NAMES,
dispatch.Hook_type.MONITORING,
monitor.State.FAIL,
monitoring_log_level,
global_arguments.dry_run,
@@ -250,7 +249,7 @@ def run_configuration(config_filename, config, config_paths, arguments):
'destroy_monitor',
config,
config_filename,
monitor.MONITOR_HOOK_NAMES,
dispatch.Hook_type.MONITORING,
monitoring_log_level,
global_arguments.dry_run,
)
@@ -914,7 +913,7 @@ def main(extra_summary_logs=[]): # pragma: no cover
getattr(sub_arguments, 'json', False) for sub_arguments in arguments.values()
)
color_enabled = should_do_markup(global_arguments.no_color or any_json_flags, configs)
colorama.init(autoreset=color_enabled, strip=not color_enabled)
try:
configure_logging(
verbosity_to_log_level(global_arguments.verbosity),
+8 -5
View File
@@ -33,10 +33,13 @@ def get_borgmatic_source_directory(config):
TEMPORARY_DIRECTORY_PREFIX = 'borgmatic-'
def replace_temporary_subdirectory_with_glob(path):
def replace_temporary_subdirectory_with_glob(
path, temporary_directory_prefix=TEMPORARY_DIRECTORY_PREFIX
):
'''
Given an absolute temporary directory path, look for a subdirectory within it starting with the
temporary directory prefix and replace it with an appropriate glob. For instance, given:
Given an absolute temporary directory path and an optional temporary directory prefix, look for
a subdirectory within it starting with the temporary directory prefix (or a default) and replace
it with an appropriate glob. For instance, given:
/tmp/borgmatic-aet8kn93/borgmatic
@@ -50,8 +53,8 @@ def replace_temporary_subdirectory_with_glob(path):
'/',
*(
(
f'{TEMPORARY_DIRECTORY_PREFIX}*'
if subdirectory.startswith(TEMPORARY_DIRECTORY_PREFIX)
f'{temporary_directory_prefix}*'
if subdirectory.startswith(temporary_directory_prefix)
else subdirectory
)
for subdirectory in path.split(os.path.sep)
+119 -25
View File
@@ -68,9 +68,7 @@ properties:
type: boolean
description: |
Stay in same file system; do not cross mount points beyond the given
source directories. Defaults to false. But when a database hook is
used, the setting here is ignored and one_file_system is considered
true.
source directories. Defaults to false.
example: true
numeric_ids:
type: boolean
@@ -133,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'
@@ -146,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:
@@ -232,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
@@ -963,8 +959,8 @@ properties:
dump all databases on the host. (Also set the "format"
to dump each database to a separate file instead of one
combined file.) Note that using this database hook
implicitly enables both read_special and one_file_system
(see above) to support dump and restore streaming.
implicitly enables read_special (see above) to support
dump and restore streaming.
example: users
hostname:
type: string
@@ -1145,9 +1141,8 @@ properties:
description: |
Database name (required if using this hook). Or "all" to
dump all databases on the host. Note that using this
database hook implicitly enables both read_special and
one_file_system (see above) to support dump and restore
streaming.
database hook implicitly enables read_special (see
above) to support dump and restore streaming.
example: users
hostname:
type: string
@@ -1272,9 +1267,8 @@ properties:
description: |
Database name (required if using this hook). Or "all" to
dump all databases on the host. Note that using this
database hook implicitly enables both read_special and
one_file_system (see above) to support dump and restore
streaming.
database hook implicitly enables read_special (see
above) to support dump and restore streaming.
example: users
hostname:
type: string
@@ -1407,9 +1401,9 @@ properties:
description: |
Path to the SQLite database file to dump. If relative,
it is relative to the current working directory. Note
that using this database hook implicitly enables both
read_special and one_file_system (see above) to support
dump and restore streaming.
that using this database hook implicitly enables
read_special (see above) to support dump and restore
streaming.
example: /var/lib/sqlite/users.db
restore_path:
type: string
@@ -1429,9 +1423,8 @@ properties:
description: |
Database name (required if using this hook). Or "all" to
dump all databases on the host. Note that using this
database hook implicitly enables both read_special and
one_file_system (see above) to support dump and restore
streaming.
database hook implicitly enables read_special (see
above) to support dump and restore streaming.
example: users
hostname:
type: string
@@ -2266,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
@@ -2288,3 +2319,66 @@ properties:
example: /usr/local/bin/umount
description: |
Configuration for integration with the ZFS filesystem.
btrfs:
type: ["object", "null"]
additionalProperties: false
properties:
btrfs_command:
type: string
description: |
Command to use instead of "btrfs".
example: /usr/local/bin/btrfs
findmnt_command:
type: string
description: |
Command to use instead of "findmnt".
example: /usr/local/bin/findmnt
description: |
Configuration for integration with the Btrfs filesystem.
lvm:
type: ["object", "null"]
additionalProperties: false
properties:
snapshot_size:
type: string
description: |
Size to allocate for each snapshot taken, including the
units to use for that size. Defaults to "10%ORIGIN" (10%
of the size of logical volume being snapshotted). See the
lvcreate "--size" and "--extents" documentation for more
information:
https://www.man7.org/linux/man-pages/man8/lvcreate.8.html
example: 5GB
lvcreate_command:
type: string
description: |
Command to use instead of "lvcreate".
example: /usr/local/bin/lvcreate
lvremove_command:
type: string
description: |
Command to use instead of "lvremove".
example: /usr/local/bin/lvremove
lvs_command:
type: string
description: |
Command to use instead of "lvs".
example: /usr/local/bin/lvs
lsblk_command:
type: string
description: |
Command to use instead of "lsblk".
example: /usr/local/bin/lsblk
mount_command:
type: string
description: |
Command to use instead of "mount".
example: /usr/local/bin/mount
umount_command:
type: string
description: |
Command to use instead of "umount".
example: /usr/local/bin/umount
description: |
Configuration for integration with Linux LVM (Logical Volume
Manager).
+25 -9
View File
@@ -2,8 +2,9 @@ import logging
import os
import re
import shlex
import sys
from borgmatic import execute
import borgmatic.execute
logger = logging.getLogger(__name__)
@@ -27,6 +28,20 @@ def interpolate_context(config_filename, hook_description, command, context):
return command
def make_environment(current_environment, sys_module=sys):
'''
Given the existing system environment as a map from environment variable name to value, return
(in the same form) any extra environment variables that should be used when running command
hooks.
'''
# Detect whether we're running within a PyInstaller bundle. If so, set or clear LD_LIBRARY_PATH
# based on the value of LD_LIBRARY_PATH_ORIG. This prevents library version information errors.
if getattr(sys_module, 'frozen', False) and hasattr(sys_module, '_MEIPASS'):
return {'LD_LIBRARY_PATH': current_environment.get('LD_LIBRARY_PATH_ORIG', '')}
return {}
def execute_hook(commands, umask, config_filename, description, dry_run, **context):
'''
Given a list of hook commands to execute, a umask to execute with (or None), a config filename,
@@ -65,14 +80,15 @@ def execute_hook(commands, umask, config_filename, description, dry_run, **conte
try:
for command in commands:
if not dry_run:
execute.execute_command(
[command],
output_log_level=(
logging.ERROR if description == 'on-error' else logging.WARNING
),
shell=True,
)
if dry_run:
continue
borgmatic.execute.execute_command(
[command],
output_log_level=(logging.ERROR if description == 'on-error' else logging.WARNING),
shell=True,
extra_environment=make_environment(os.environ),
)
finally:
if original_umask:
os.umask(original_umask)
@@ -4,6 +4,7 @@ import json
import logging
import os
import borgmatic.borg.pattern
import borgmatic.config.paths
logger = logging.getLogger(__name__)
@@ -22,19 +23,19 @@ def dump_data_sources(
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.
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.
'''
if hook_config.get('store_config_files') is False:
if hook_config and hook_config.get('store_config_files') is False:
return []
borgmatic_manifest_path = os.path.join(
@@ -55,8 +56,10 @@ 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 []
+364
View File
@@ -0,0 +1,364 @@
import collections
import glob
import json
import logging
import os
import shutil
import subprocess
import borgmatic.borg.pattern
import borgmatic.config.paths
import borgmatic.execute
import borgmatic.hooks.data_source.snapshot
logger = logging.getLogger(__name__)
def use_streaming(hook_config, config, log_prefix): # pragma: no cover
'''
Return whether dump streaming is used for this hook. (Spoiler: It isn't.)
'''
return False
def get_filesystem_mount_points(findmnt_command):
'''
Given a findmnt command to run, get all top-level Btrfs filesystem mount points.
'''
findmnt_output = borgmatic.execute.execute_command_and_capture_output(
tuple(findmnt_command.split(' '))
+ (
'-t', # Filesystem type.
'btrfs',
'--json',
'--list', # Request a flat list instead of a nested subvolume hierarchy.
)
)
try:
return tuple(
filesystem['target'] for filesystem in json.loads(findmnt_output)['filesystems']
)
except json.JSONDecodeError as error:
raise ValueError(f'Invalid {findmnt_command} JSON output: {error}')
except KeyError as error:
raise ValueError(f'Invalid {findmnt_command} output: Missing key "{error}"')
def get_subvolumes_for_filesystem(btrfs_command, filesystem_mount_point):
'''
Given a Btrfs command to run and a Btrfs filesystem mount point, get the sorted subvolumes for
that filesystem. Include the filesystem itself.
'''
btrfs_output = borgmatic.execute.execute_command_and_capture_output(
tuple(btrfs_command.split(' '))
+ (
'subvolume',
'list',
filesystem_mount_point,
)
)
if not filesystem_mount_point.strip():
return ()
return (filesystem_mount_point,) + tuple(
sorted(
subvolume_path
for line in btrfs_output.splitlines()
for subvolume_subpath in (line.rstrip().split(' ')[-1],)
for subvolume_path in (os.path.join(filesystem_mount_point, subvolume_subpath),)
if subvolume_subpath.strip()
)
)
Subvolume = collections.namedtuple('Subvolume', ('path', 'contained_patterns'), defaults=((),))
def get_subvolumes(btrfs_command, findmnt_command, patterns=None):
'''
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 patterns is None, then return all subvolumes, sorted by path.
Return the result as a sequence of matching subvolume mount points.
'''
candidate_patterns = set(patterns or ())
subvolumes = []
# For each filesystem mount point, find its subvolumes and match them against the given patterns
# to find the subvolumes to backup. And within this loop, sort the subvolumes from longest to
# shortest mount points, so longer mount points get a whack at the candidate pattern piñata
# before their parents do. (Patterns are consumed during this process, so no two subvolumes end
# up with the same contained patterns.)
for mount_point in get_filesystem_mount_points(findmnt_command):
subvolumes.extend(
Subvolume(subvolume_path, contained_patterns)
for subvolume_path in reversed(
get_subvolumes_for_filesystem(btrfs_command, mount_point)
)
for contained_patterns in (
borgmatic.hooks.data_source.snapshot.get_contained_patterns(
subvolume_path, candidate_patterns
),
)
if patterns is None or contained_patterns
)
return tuple(sorted(subvolumes, key=lambda subvolume: subvolume.path))
BORGMATIC_SNAPSHOT_PREFIX = '.borgmatic-snapshot-'
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.rstrip(os.path.sep)
def make_snapshot_exclude_pattern(subvolume_path): # pragma: no cover
'''
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
... then the snapshot itself will have an empty directory at:
/mnt/.borgmatic-snapshot-1234/mnt/.borgmatic-snapshot-1234
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 borgmatic.borg.pattern.Pattern(
os.path.join(
subvolume_path,
snapshot_directory,
subvolume_path.lstrip(os.path.sep),
snapshot_directory,
),
borgmatic.borg.pattern.Pattern_type.EXCLUDE,
borgmatic.borg.pattern.Pattern_style.FNMATCH,
)
def make_borg_snapshot_pattern(subvolume_path, pattern):
'''
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.
'''
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.
pattern.path.lstrip('^').lstrip(os.path.sep),
)
return borgmatic.borg.pattern.Pattern(
rewritten_path,
pattern.type,
pattern.style,
pattern.device,
)
def snapshot_subvolume(btrfs_command, subvolume_path, snapshot_path): # pragma: no cover
'''
Given a Btrfs command to run, the path to a subvolume, and the path for a snapshot, create a new
Btrfs snapshot of the subvolume.
'''
os.makedirs(os.path.dirname(snapshot_path), mode=0o700, exist_ok=True)
borgmatic.execute.execute_command(
tuple(btrfs_command.split(' '))
+ (
'subvolume',
'snapshot',
'-r', # Read-only.
subvolume_path,
snapshot_path,
),
output_log_level=logging.DEBUG,
)
def dump_data_sources(
hook_config,
config,
log_prefix,
config_paths,
borgmatic_runtime_directory,
patterns,
dry_run,
):
'''
Given a Btrfs configuration dict, a configuration dict, a log prefix, the borgmatic
configuration file paths, the borgmatic runtime directory, the configured patterns, and whether
this is a dry run, auto-detect and snapshot any Btrfs subvolume mount points listed in the given
patterns. Also update those patterns, replacing subvolume mount points with corresponding
snapshot directories so they get stored in the Borg archive instead. Use the log prefix in any
log entries.
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}')
# Based on the configured patterns, determine Btrfs subvolumes to backup.
btrfs_command = hook_config.get('btrfs_command', 'btrfs')
findmnt_command = hook_config.get('findmnt_command', 'findmnt')
subvolumes = get_subvolumes(btrfs_command, findmnt_command, patterns)
if not subvolumes:
logger.warning(f'{log_prefix}: No Btrfs subvolumes found to snapshot{dry_run_label}')
# Snapshot each subvolume, rewriting patterns to use their snapshot paths.
for subvolume in subvolumes:
logger.debug(f'{log_prefix}: Creating Btrfs snapshot for {subvolume.path} subvolume')
snapshot_path = make_snapshot_path(subvolume.path)
if dry_run:
continue
snapshot_subvolume(btrfs_command, subvolume.path, snapshot_path)
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:
patterns[patterns.index(pattern)] = snapshot_pattern
except ValueError:
patterns.append(snapshot_pattern)
patterns.append(make_snapshot_exclude_pattern(subvolume.path))
return []
def delete_snapshot(btrfs_command, snapshot_path): # pragma: no cover
'''
Given a Btrfs command to run and the name of a snapshot path, delete it.
'''
borgmatic.execute.execute_command(
tuple(btrfs_command.split(' '))
+ (
'subvolume',
'delete',
snapshot_path,
),
output_log_level=logging.DEBUG,
)
def remove_data_source_dumps(hook_config, config, log_prefix, 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.
'''
if hook_config is None:
return
dry_run_label = ' (dry run; not actually removing anything)' if dry_run else ''
btrfs_command = hook_config.get('btrfs_command', 'btrfs')
findmnt_command = hook_config.get('findmnt_command', 'findmnt')
try:
all_subvolumes = get_subvolumes(btrfs_command, findmnt_command)
except FileNotFoundError as error:
logger.debug(f'{log_prefix}: Could not find "{error.filename}" command')
return
except subprocess.CalledProcessError as error:
logger.debug(f'{log_prefix}: {error}')
return
# Reversing the sorted subvolumes ensures that we remove longer mount point paths of child
# subvolumes before the shorter mount point paths of parent subvolumes.
for subvolume in reversed(all_subvolumes):
subvolume_snapshots_glob = borgmatic.config.paths.replace_temporary_subdirectory_with_glob(
os.path.normpath(make_snapshot_path(subvolume.path)),
temporary_directory_prefix=BORGMATIC_SNAPSHOT_PREFIX,
)
logger.debug(
f'{log_prefix}: Looking for snapshots to remove in {subvolume_snapshots_glob}{dry_run_label}'
)
for snapshot_path in glob.glob(subvolume_snapshots_glob):
if not os.path.isdir(snapshot_path):
continue
logger.debug(f'{log_prefix}: Deleting Btrfs snapshot {snapshot_path}{dry_run_label}')
if dry_run:
continue
try:
delete_snapshot(btrfs_command, snapshot_path)
except FileNotFoundError:
logger.debug(f'{log_prefix}: Could not find "{btrfs_command}" command')
return
except subprocess.CalledProcessError as error:
logger.debug(f'{log_prefix}: {error}')
return
# Strip off the subvolume path from the end of the snapshot path and then delete the
# resulting directory.
shutil.rmtree(snapshot_path.rsplit(subvolume.path, 1)[0])
def make_data_source_dump_patterns(
hook_config, config, log_prefix, borgmatic_runtime_directory, name=None
): # pragma: no cover
'''
Restores aren't implemented, because stored files can be extracted directly with "extract".
'''
return ()
def restore_data_source_dump(
hook_config,
config,
log_prefix,
data_source,
dry_run,
extract_process,
connection_params,
borgmatic_runtime_directory,
): # pragma: no cover
'''
Restores aren't implemented, because stored files can be extracted directly with "extract".
'''
raise NotImplementedError()
@@ -5,15 +5,7 @@ import shutil
logger = logging.getLogger(__name__)
DATA_SOURCE_HOOK_NAMES = (
'bootstrap',
'mariadb_databases',
'mysql_databases',
'mongodb_databases',
'postgresql_databases',
'sqlite_databases',
'zfs',
)
IS_A_HOOK = False
def make_data_source_dump_path(borgmatic_runtime_directory, data_source_hook_name):
@@ -24,17 +16,19 @@ def make_data_source_dump_path(borgmatic_runtime_directory, data_source_hook_nam
return os.path.join(borgmatic_runtime_directory, data_source_hook_name)
def make_data_source_dump_filename(dump_path, name, hostname=None):
def make_data_source_dump_filename(dump_path, name, hostname=None, port=None):
'''
Based on the given dump directory path, data source name, and hostname, return a filename to use
for the data source dump. The hostname defaults to localhost.
Based on the given dump directory path, data source name, hostname, and port, return a filename
to use for the data source dump. The hostname defaults to localhost.
Raise ValueError if the data source name is invalid.
'''
if os.path.sep in name:
raise ValueError(f'Invalid data source name {name}')
return os.path.join(dump_path, hostname or 'localhost', name)
return os.path.join(
dump_path, (hostname or 'localhost') + ('' if port is None else f':{port}'), name
)
def create_parent_directory_for_dump(dump_path):
+433
View File
@@ -0,0 +1,433 @@
import collections
import glob
import json
import logging
import os
import shutil
import subprocess
import borgmatic.borg.pattern
import borgmatic.config.paths
import borgmatic.execute
import borgmatic.hooks.data_source.snapshot
logger = logging.getLogger(__name__)
def use_streaming(hook_config, config, log_prefix): # pragma: no cover
'''
Return whether dump streaming is used for this hook. (Spoiler: It isn't.)
'''
return False
BORGMATIC_SNAPSHOT_PREFIX = 'borgmatic-'
Logical_volume = collections.namedtuple(
'Logical_volume', ('name', 'device_path', 'mount_point', 'contained_patterns')
)
def get_logical_volumes(lsblk_command, patterns=None):
'''
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 patterns is None, include all logical volume mounts points, not just those in patterns.
Return the result as a sequence of Logical_volume instances.
'''
try:
devices_info = json.loads(
borgmatic.execute.execute_command_and_capture_output(
# Use lsblk instead of lvs here because lvs can't show active mounts.
tuple(lsblk_command.split(' '))
+ (
'--output',
'name,path,mountpoint,type',
'--json',
'--list',
)
)
)
except json.JSONDecodeError as error:
raise ValueError(f'Invalid {lsblk_command} JSON output: {error}')
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_patterns)
for device in sorted(
devices_info['blockdevices'],
key=lambda device: device['mountpoint'] or '',
reverse=True,
)
if device['mountpoint'] and device['type'] == 'lvm'
for contained_patterns in (
borgmatic.hooks.data_source.snapshot.get_contained_patterns(
device['mountpoint'], candidate_patterns
),
)
if not patterns or contained_patterns
)
except KeyError as error:
raise ValueError(f'Invalid {lsblk_command} output: Missing key "{error}"')
def snapshot_logical_volume(
lvcreate_command,
snapshot_name,
logical_volume_device,
snapshot_size,
):
'''
Given an lvcreate command to run, a snapshot name, the path to the logical volume device to
snapshot, and a snapshot size string, create a new LVM snapshot.
'''
borgmatic.execute.execute_command(
tuple(lvcreate_command.split(' '))
+ (
'--snapshot',
('--extents' if '%' in snapshot_size else '--size'),
snapshot_size,
'--permission',
'r', # Read-only.
'--name',
snapshot_name,
logical_volume_device,
),
output_log_level=logging.DEBUG,
)
def mount_snapshot(mount_command, snapshot_device, snapshot_mount_path): # pragma: no cover
'''
Given a mount command to run, the device path for an existing snapshot, and the path where the
snapshot should be mounted, mount the snapshot as read-only (making any necessary directories
first).
'''
os.makedirs(snapshot_mount_path, mode=0o700, exist_ok=True)
borgmatic.execute.execute_command(
tuple(mount_command.split(' '))
+ (
'-o',
'ro',
snapshot_device,
snapshot_mount_path,
),
output_log_level=logging.DEBUG,
)
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,
patterns,
dry_run,
):
'''
Given an LVM configuration dict, a configuration dict, a log prefix, the borgmatic configuration
file paths, the borgmatic runtime directory, the configured patterns, and whether this is a dry
run, auto-detect and snapshot any LVM logical volume mount points listed in the given patterns.
Also update those patterns, replacing logical volume mount points with corresponding snapshot
directories so they get stored in the Borg archive instead. Use the log prefix in any log
entries.
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}')
# List logical volumes to get their mount points.
lsblk_command = hook_config.get('lsblk_command', 'lsblk')
requested_logical_volumes = get_logical_volumes(lsblk_command, patterns)
# 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}')
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}'
)
if not dry_run:
snapshot_logical_volume(
hook_config.get('lvcreate_command', 'lvcreate'),
snapshot_name,
logical_volume.device_path,
hook_config.get('snapshot_size', DEFAULT_SNAPSHOT_SIZE),
)
# Get the device path for the snapshot we just created.
try:
snapshot = get_snapshots(
hook_config.get('lvs_command', 'lvs'), snapshot_name=snapshot_name
)[0]
except IndexError:
raise ValueError(f'Cannot find LVM snapshot {snapshot_name}')
# Mount the snapshot into a particular named temporary directory so that the snapshot ends
# up in the Borg archive at the "original" logical volume mount point path.
snapshot_mount_path = os.path.join(
normalized_runtime_directory,
'lvm_snapshots',
logical_volume.mount_point.lstrip(os.path.sep),
)
logger.debug(
f'{log_prefix}: Mounting LVM snapshot {snapshot_name} at {snapshot_mount_path}{dry_run_label}'
)
if dry_run:
continue
mount_snapshot(
hook_config.get('mount_command', 'mount'), snapshot.device_path, snapshot_mount_path
)
for pattern in logical_volume.contained_patterns:
snapshot_pattern = make_borg_snapshot_pattern(pattern, normalized_runtime_directory)
# 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 []
def unmount_snapshot(umount_command, snapshot_mount_path): # pragma: no cover
'''
Given a umount command to run and the mount path of a snapshot, unmount it.
'''
borgmatic.execute.execute_command(
tuple(umount_command.split(' ')) + (snapshot_mount_path,),
output_log_level=logging.DEBUG,
)
def remove_snapshot(lvremove_command, snapshot_device_path): # pragma: no cover
'''
Given an lvremove command to run and the device path of a snapshot, remove it it.
'''
borgmatic.execute.execute_command(
tuple(lvremove_command.split(' '))
+ (
'--force', # Suppress an interactive "are you sure?" type prompt.
snapshot_device_path,
),
output_log_level=logging.DEBUG,
)
Snapshot = collections.namedtuple(
'Snapshot',
('name', 'device_path'),
)
def get_snapshots(lvs_command, snapshot_name=None):
'''
Given an lvs command to run, return all LVM snapshots as a sequence of Snapshot instances.
If a snapshot name is given, filter the results to that snapshot.
'''
try:
snapshot_info = json.loads(
borgmatic.execute.execute_command_and_capture_output(
# Use lvs instead of lsblk here because lsblk can't filter to just snapshots.
tuple(lvs_command.split(' '))
+ (
'--report-format',
'json',
'--options',
'lv_name,lv_path',
'--select',
'lv_attr =~ ^s', # Filter to just snapshots.
)
)
)
except json.JSONDecodeError as error:
raise ValueError(f'Invalid {lvs_command} JSON output: {error}')
try:
return tuple(
Snapshot(snapshot['lv_name'], snapshot['lv_path'])
for snapshot in snapshot_info['report'][0]['lv']
if snapshot_name is None or snapshot['lv_name'] == snapshot_name
)
except IndexError:
raise ValueError(f'Invalid {lvs_command} output: Missing report data')
except KeyError as error:
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):
'''
Given an LVM configuration dict, a configuration dict, a log prefix, the borgmatic runtime
directory, and whether this is a dry run, unmount and delete any LVM snapshots created by
borgmatic. Use the log prefix in any log entries. If this is a dry run or LVM isn't configured
in borgmatic's configuration, then don't actually remove anything.
'''
if hook_config is None:
return
dry_run_label = ' (dry run; not actually removing anything)' if dry_run else ''
# Unmount snapshots.
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')
return
except subprocess.CalledProcessError as error:
logger.debug(f'{log_prefix}: {error}')
return
snapshots_glob = os.path.join(
borgmatic.config.paths.replace_temporary_subdirectory_with_glob(
os.path.normpath(borgmatic_runtime_directory),
),
'lvm_snapshots',
)
logger.debug(
f'{log_prefix}: Looking for snapshots to remove in {snapshots_glob}{dry_run_label}'
)
umount_command = hook_config.get('umount_command', 'umount')
for snapshots_directory in glob.glob(snapshots_glob):
if not os.path.isdir(snapshots_directory):
continue
for logical_volume in logical_volumes:
snapshot_mount_path = os.path.join(
snapshots_directory, logical_volume.mount_point.lstrip(os.path.sep)
)
if not os.path.isdir(snapshot_mount_path):
continue
# This might fail if the directory is already mounted, but we swallow errors here since
# we'll do another recursive delete below. The point of doing it here is that we don't
# want to try to unmount a non-mounted directory (which *will* fail).
if not dry_run:
shutil.rmtree(snapshot_mount_path, ignore_errors=True)
# If the delete was successful, that means there's nothing to unmount.
if not os.path.isdir(snapshot_mount_path):
continue
logger.debug(
f'{log_prefix}: Unmounting LVM snapshot at {snapshot_mount_path}{dry_run_label}'
)
if dry_run:
continue
try:
unmount_snapshot(umount_command, snapshot_mount_path)
except FileNotFoundError:
logger.debug(f'{log_prefix}: Could not find "{umount_command}" command')
return
except subprocess.CalledProcessError as error:
logger.debug(f'{log_prefix}: {error}')
return
if not dry_run:
shutil.rmtree(snapshots_directory)
# Delete snapshots.
lvremove_command = hook_config.get('lvremove_command', 'lvremove')
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')
return
except subprocess.CalledProcessError as error:
logger.debug(f'{log_prefix}: {error}')
return
for snapshot in snapshots:
# Only delete snapshots that borgmatic actually created!
if not snapshot.name.split('_')[-1].startswith(BORGMATIC_SNAPSHOT_PREFIX):
continue
logger.debug(f'{log_prefix}: Deleting LVM snapshot {snapshot.name}{dry_run_label}')
if not dry_run:
remove_snapshot(lvremove_command, snapshot.device_path)
def make_data_source_dump_patterns(
hook_config, config, log_prefix, borgmatic_runtime_directory, name=None
): # pragma: no cover
'''
Restores aren't implemented, because stored files can be extracted directly with "extract".
'''
return ()
def restore_data_source_dump(
hook_config,
config,
log_prefix,
data_source,
dry_run,
extract_process,
connection_params,
borgmatic_runtime_directory,
): # pragma: no cover
'''
Restores aren't implemented, because stored files can be extracted directly with "extract".
'''
raise NotImplementedError()
@@ -3,13 +3,14 @@ import logging
import os
import shlex
import borgmatic.borg.pattern
import borgmatic.config.paths
from borgmatic.execute import (
execute_command,
execute_command_and_capture_output,
execute_command_with_processes,
)
from borgmatic.hooks import dump
from borgmatic.hooks.data_source import dump
logger = logging.getLogger(__name__)
@@ -73,7 +74,10 @@ def execute_dump_command(
'''
database_name = database['name']
dump_filename = dump.make_data_source_dump_filename(
dump_path, database['name'], database.get('hostname')
dump_path,
database['name'],
database.get('hostname'),
database.get('port'),
)
if os.path.exists(dump_filename):
@@ -114,6 +118,10 @@ def execute_dump_command(
)
def get_default_port(databases, config, log_prefix): # pragma: no cover
return 3306
def use_streaming(databases, config, log_prefix):
'''
Given a sequence of MariaDB database configuration dicts, a configuration dict (ignored), and a
@@ -128,7 +136,7 @@ def dump_data_sources(
log_prefix,
config_paths,
borgmatic_runtime_directory,
source_directories,
patterns,
dry_run,
):
'''
@@ -139,7 +147,8 @@ def dump_data_sources(
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 = []
@@ -188,7 +197,11 @@ 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]
@@ -2,9 +2,10 @@ 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 import dump
from borgmatic.hooks.data_source import dump
logger = logging.getLogger(__name__)
@@ -16,6 +17,10 @@ def make_dump_path(base_directory): # pragma: no cover
return dump.make_data_source_dump_path(base_directory, 'mongodb_databases')
def get_default_port(databases, config, log_prefix): # pragma: no cover
return 27017
def use_streaming(databases, config, log_prefix):
'''
Given a sequence of MongoDB database configuration dicts, a configuration dict (ignored), and a
@@ -30,7 +35,7 @@ def dump_data_sources(
log_prefix,
config_paths,
borgmatic_runtime_directory,
source_directories,
patterns,
dry_run,
):
'''
@@ -41,7 +46,8 @@ def dump_data_sources(
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 ''
@@ -51,7 +57,10 @@ def dump_data_sources(
for database in databases:
name = database['name']
dump_filename = dump.make_data_source_dump_filename(
make_dump_path(borgmatic_runtime_directory), name, database.get('hostname')
make_dump_path(borgmatic_runtime_directory),
name,
database.get('hostname'),
database.get('port'),
)
dump_format = database.get('format', 'archive')
@@ -71,7 +80,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
@@ -3,13 +3,14 @@ import logging
import os
import shlex
import borgmatic.borg.pattern
import borgmatic.config.paths
from borgmatic.execute import (
execute_command,
execute_command_and_capture_output,
execute_command_with_processes,
)
from borgmatic.hooks import dump
from borgmatic.hooks.data_source import dump
logger = logging.getLogger(__name__)
@@ -73,7 +74,10 @@ def execute_dump_command(
'''
database_name = database['name']
dump_filename = dump.make_data_source_dump_filename(
dump_path, database['name'], database.get('hostname')
dump_path,
database['name'],
database.get('hostname'),
database.get('port'),
)
if os.path.exists(dump_filename):
@@ -113,6 +117,10 @@ def execute_dump_command(
)
def get_default_port(databases, config, log_prefix): # pragma: no cover
return 3306
def use_streaming(databases, config, log_prefix):
'''
Given a sequence of MySQL database configuration dicts, a configuration dict (ignored), and a
@@ -127,7 +135,7 @@ def dump_data_sources(
log_prefix,
config_paths,
borgmatic_runtime_directory,
source_directories,
patterns,
dry_run,
):
'''
@@ -138,7 +146,8 @@ def dump_data_sources(
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 = []
@@ -187,7 +196,11 @@ 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]
@@ -5,13 +5,14 @@ import os
import pathlib
import shlex
import borgmatic.borg.pattern
import borgmatic.config.paths
from borgmatic.execute import (
execute_command,
execute_command_and_capture_output,
execute_command_with_processes,
)
from borgmatic.hooks import dump
from borgmatic.hooks.data_source import dump
logger = logging.getLogger(__name__)
@@ -96,6 +97,10 @@ def database_names_to_dump(database, extra_environment, log_prefix, dry_run):
)
def get_default_port(databases, config, log_prefix): # pragma: no cover
return 5432
def use_streaming(databases, config, log_prefix):
'''
Given a sequence of PostgreSQL database configuration dicts, a configuration dict (ignored), and
@@ -110,7 +115,7 @@ def dump_data_sources(
log_prefix,
config_paths,
borgmatic_runtime_directory,
source_directories,
patterns,
dry_run,
):
'''
@@ -121,7 +126,8 @@ def dump_data_sources(
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.
'''
@@ -151,7 +157,10 @@ def dump_data_sources(
for part in shlex.split(database.get('pg_dump_command') or default_dump_command)
)
dump_filename = dump.make_data_source_dump_filename(
dump_path, database_name, database.get('hostname')
dump_path,
database_name,
database.get('hostname'),
database.get('port'),
)
if os.path.exists(dump_filename):
logger.warning(
@@ -213,7 +222,11 @@ 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
+39
View File
@@ -0,0 +1,39 @@
import pathlib
IS_A_HOOK = False
def get_contained_patterns(parent_directory, candidate_patterns):
'''
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.
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_patterns:
return ()
contained_patterns = tuple(
candidate
for candidate in candidate_patterns
for candidate_path in (pathlib.PurePath(candidate.path.lstrip('^')),)
if pathlib.PurePath(parent_directory) == candidate_path
or pathlib.PurePath(parent_directory) in candidate_path.parents
)
candidate_patterns -= set(contained_patterns)
return contained_patterns
@@ -2,9 +2,10 @@ 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 import dump
from borgmatic.hooks.data_source import dump
logger = logging.getLogger(__name__)
@@ -16,6 +17,10 @@ def make_dump_path(base_directory): # pragma: no cover
return dump.make_data_source_dump_path(base_directory, 'sqlite_databases')
def get_default_port(databases, config, log_prefix): # pragma: no cover
return None # SQLite doesn't use a port.
def use_streaming(databases, config, log_prefix):
'''
Given a sequence of SQLite database configuration dicts, a configuration dict (ignored), and a
@@ -30,7 +35,7 @@ def dump_data_sources(
log_prefix,
config_paths,
borgmatic_runtime_directory,
source_directories,
patterns,
dry_run,
):
'''
@@ -40,7 +45,8 @@ def dump_data_sources(
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 = []
@@ -83,7 +89,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, 'sqlite_databases'))
patterns.append(
borgmatic.borg.pattern.Pattern(
os.path.join(borgmatic_runtime_directory, 'sqlite_databases')
)
)
return processes
@@ -1,11 +1,14 @@
import collections
import glob
import logging
import os
import shutil
import subprocess
import borgmatic.borg.pattern
import borgmatic.config.paths
import borgmatic.execute
import borgmatic.hooks.data_source.snapshot
logger = logging.getLogger(__name__)
@@ -21,19 +24,25 @@ BORGMATIC_SNAPSHOT_PREFIX = 'borgmatic-'
BORGMATIC_USER_PROPERTY = 'org.torsion.borgmatic:backup'
def get_datasets_to_backup(zfs_command, source_directories):
'''
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.
Dataset = collections.namedtuple(
'Dataset',
('name', 'mount_point', 'auto_backup', 'contained_patterns'),
defaults=(False, ()),
)
Return the result as a sequence of (dataset name, mount point) pairs.
def get_datasets_to_backup(zfs_command, patterns):
'''
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.
'''
list_output = borgmatic.execute.execute_command_and_capture_output(
(
zfs_command,
tuple(zfs_command.split(' '))
+ (
'list',
'-H',
'-t',
@@ -42,44 +51,71 @@ def get_datasets_to_backup(zfs_command, source_directories):
f'name,mountpoint,{BORGMATIC_USER_PROPERTY}',
)
)
source_directories_set = set(source_directories)
try:
return tuple(
(dataset_name, mount_point)
for line in list_output.splitlines()
for (dataset_name, mount_point, user_property_value) in (line.rstrip().split('\t'),)
if mount_point in source_directories_set or user_property_value == 'auto'
# 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 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'), ())
for line in list_output.splitlines()
for (dataset_name, mount_point, user_property_value) in (line.rstrip().split('\t'),)
),
key=lambda dataset: dataset.mount_point,
reverse=True,
)
except ValueError:
raise ValueError('Invalid {zfs_command} list output')
raise ValueError(f'Invalid {zfs_command} list output')
candidate_patterns = set(patterns)
return tuple(
sorted(
(
Dataset(
dataset.name,
dataset.mount_point,
dataset.auto_backup,
contained_patterns,
)
for dataset in datasets
for contained_patterns in (
(
(
(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_patterns
),
key=lambda dataset: dataset.mount_point,
)
)
def get_all_datasets(zfs_command):
def get_all_dataset_mount_points(zfs_command):
'''
Given a ZFS command to run, return all ZFS datasets as a sequence of (dataset name, mount point)
pairs.
Given a ZFS command to run, return all ZFS datasets as a sequence of sorted mount points.
'''
list_output = borgmatic.execute.execute_command_and_capture_output(
(
zfs_command,
tuple(zfs_command.split(' '))
+ (
'list',
'-H',
'-t',
'filesystem',
'-o',
'name,mountpoint',
'mountpoint',
)
)
try:
return tuple(
(dataset_name, mount_point)
for line in list_output.splitlines()
for (dataset_name, mount_point) in (line.rstrip().split('\t'),)
)
except ValueError:
raise ValueError('Invalid {zfs_command} list output')
return tuple(sorted(line.rstrip() for line in list_output.splitlines()))
def snapshot_dataset(zfs_command, full_snapshot_name): # pragma: no cover
@@ -88,8 +124,8 @@ def snapshot_dataset(zfs_command, full_snapshot_name): # pragma: no cover
snapshot.
'''
borgmatic.execute.execute_command(
(
zfs_command,
tuple(zfs_command.split(' '))
+ (
'snapshot',
full_snapshot_name,
),
@@ -104,11 +140,14 @@ def mount_snapshot(mount_command, full_snapshot_name, snapshot_mount_path): # p
first).
'''
os.makedirs(snapshot_mount_path, mode=0o700, exist_ok=True)
borgmatic.execute.execute_command(
(
mount_command,
tuple(mount_command.split(' '))
+ (
'-t',
'zfs',
'-o',
'ro',
full_snapshot_name,
snapshot_mount_path,
),
@@ -116,23 +155,53 @@ 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 of the dataset mount points. Use the log prefix in any log
entries.
file paths, the borgmatic runtime directory, the configured patterns, and whether this is a dry
run, auto-detect and snapshot any ZFS dataset mount points listed in the given patterns and any
dataset with a borgmatic-specific user property. Also update those patterns, replacing dataset
mount points with corresponding snapshot directories so they get stored in the Borg archive
instead. Use the log prefix in any log entries.
Return an empty sequence, since there are no ongoing dump processes from this hook.
@@ -143,40 +212,51 @@ def dump_data_sources(
# 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)
for dataset_name, mount_point in requested_datasets:
full_snapshot_name = f'{dataset_name}@{snapshot_name}'
logger.debug(f'{log_prefix}: Creating ZFS snapshot {full_snapshot_name}{dry_run_label}')
if not requested_datasets:
logger.warning(f'{log_prefix}: No ZFS datasets found to snapshot{dry_run_label}')
for dataset in requested_datasets:
full_snapshot_name = f'{dataset.name}@{snapshot_name}'
logger.debug(
f'{log_prefix}: Creating ZFS snapshot {full_snapshot_name} of {dataset.mount_point}{dry_run_label}'
)
if not dry_run:
snapshot_dataset(zfs_command, full_snapshot_name)
# Mount the snapshot into a particular named temporary directory so that the snapshot ends
# up in the Borg archive at the "original" dataset mount point path.
snapshot_mount_path_for_borg = os.path.join(
os.path.normpath(borgmatic_runtime_directory),
snapshot_mount_path = os.path.join(
normalized_runtime_directory,
'zfs_snapshots',
'.', # Borg 1.4+ "slashdot" hack.
mount_point.lstrip(os.path.sep),
dataset.mount_point.lstrip(os.path.sep),
)
snapshot_mount_path = os.path.normpath(snapshot_mount_path_for_borg)
logger.debug(
f'{log_prefix}: Mounting ZFS snapshot {full_snapshot_name} at {snapshot_mount_path}{dry_run_label}'
)
if not dry_run:
mount_snapshot(
hook_config.get('mount_command', 'mount'), full_snapshot_name, snapshot_mount_path
)
if dry_run:
continue
if mount_point in source_directories:
source_directories.remove(mount_point)
mount_snapshot(
hook_config.get('mount_command', 'mount'), full_snapshot_name, snapshot_mount_path
)
source_directories.append(snapshot_mount_path_for_borg)
for pattern in dataset.contained_patterns:
snapshot_pattern = make_borg_snapshot_pattern(pattern, normalized_runtime_directory)
# 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 []
@@ -186,10 +266,7 @@ def unmount_snapshot(umount_command, snapshot_mount_path): # pragma: no cover
Given a umount command to run and the mount path of a snapshot, unmount it.
'''
borgmatic.execute.execute_command(
(
umount_command,
snapshot_mount_path,
),
tuple(umount_command.split(' ')) + (snapshot_mount_path,),
output_log_level=logging.DEBUG,
)
@@ -200,8 +277,8 @@ def destroy_snapshot(zfs_command, full_snapshot_name): # pragma: no cover
it.
'''
borgmatic.execute.execute_command(
(
zfs_command,
tuple(zfs_command.split(' '))
+ (
'destroy',
full_snapshot_name,
),
@@ -215,8 +292,8 @@ def get_all_snapshots(zfs_command):
form "dataset@snapshot".
'''
list_output = borgmatic.execute.execute_command_and_capture_output(
(
zfs_command,
tuple(zfs_command.split(' '))
+ (
'list',
'-H',
'-t',
@@ -233,16 +310,19 @@ def remove_data_source_dumps(hook_config, config, log_prefix, borgmatic_runtime_
'''
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, then don't actually
remove anything.
borgmatic. Use the log prefix in any log entries. If this is a dry run or ZFS isn't configured
in borgmatic's configuration, then don't actually remove anything.
'''
if hook_config is None:
return
dry_run_label = ' (dry run; not actually removing anything)' if dry_run else ''
# Unmount snapshots.
zfs_command = hook_config.get('zfs_command', 'zfs')
try:
datasets = get_all_datasets(zfs_command)
dataset_mount_points = get_all_dataset_mount_points(zfs_command)
except FileNotFoundError:
logger.debug(f'{log_prefix}: Could not find "{zfs_command}" command')
return
@@ -265,18 +345,24 @@ def remove_data_source_dumps(hook_config, config, log_prefix, borgmatic_runtime_
if not os.path.isdir(snapshots_directory):
continue
# This might fail if the directory is already mounted, but we swallow errors here since
# we'll try again below. The point of doing it here is that we don't want to try to unmount
# a non-mounted directory (which *will* fail), and probing for whether a directory is
# mounted is tough to do in a cross-platform way.
if not dry_run:
shutil.rmtree(snapshots_directory, ignore_errors=True)
for _, mount_point in datasets:
# Reversing the sorted datasets ensures that we unmount the longer mount point paths of
# child datasets before the shorter mount point paths of parent datasets.
for mount_point in reversed(dataset_mount_points):
snapshot_mount_path = os.path.join(snapshots_directory, mount_point.lstrip(os.path.sep))
if not os.path.isdir(snapshot_mount_path):
continue
# This might fail if the path is already mounted, but we swallow errors here since we'll
# do another recursive delete below. The point of doing it here is that we don't want to
# try to unmount a non-mounted directory (which *will* fail), and probing for whether a
# directory is mounted is tough to do in a cross-platform way.
if not dry_run:
shutil.rmtree(snapshot_mount_path, ignore_errors=True)
# If the delete was successful, that means there's nothing to unmount.
if not os.path.isdir(snapshot_mount_path):
continue
logger.debug(
f'{log_prefix}: Unmounting ZFS snapshot at {snapshot_mount_path}{dry_run_label}'
)
+57 -54
View File
@@ -1,75 +1,74 @@
import enum
import importlib
import logging
import pkgutil
from borgmatic.hooks import (
apprise,
bootstrap,
cronhub,
cronitor,
healthchecks,
loki,
mariadb,
mongodb,
mysql,
ntfy,
pagerduty,
postgresql,
pushover,
sqlite,
uptimekuma,
zabbix,
zfs,
)
import borgmatic.hooks.data_source
import borgmatic.hooks.monitoring
logger = logging.getLogger(__name__)
HOOK_NAME_TO_MODULE = {
'apprise': apprise,
'bootstrap': bootstrap,
'cronhub': cronhub,
'cronitor': cronitor,
'healthchecks': healthchecks,
'loki': loki,
'mariadb_databases': mariadb,
'mongodb_databases': mongodb,
'mysql_databases': mysql,
'ntfy': ntfy,
'pagerduty': pagerduty,
'postgresql_databases': postgresql,
'pushover': pushover,
'sqlite_databases': sqlite,
'uptime_kuma': uptimekuma,
'zabbix': zabbix,
'zfs': zfs,
}
class Hook_type(enum.Enum):
DATA_SOURCE = 'data_source'
MONITORING = 'monitoring'
def get_submodule_names(parent_module): # pragma: no cover
'''
Given a parent module, return the names of its direct submodules as a tuple of strings.
'''
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):
'''
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 any return value.
for this hook (if any), the log prefix, and any given args and kwargs. Return the return value
of that call or None if the module in question is not a hook.
Raise ValueError if the hook name is unknown.
Raise AttributeError if the function name is not found in the module.
Raise anything else that the called function raises.
'''
hook_config = config.get(hook_name) or {}
if hook_name in config or f'{hook_name}_databases' in config:
hook_config = config.get(hook_name) or config.get(f'{hook_name}_databases') or {}
else:
hook_config = None
try:
module = HOOK_NAME_TO_MODULE[hook_name]
except KeyError:
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):
if module_name not in get_submodule_names(parent_module):
continue
module = importlib.import_module(f'{parent_module.__name__}.{module_name}')
# If this module is explicitly flagged as not a hook, bail.
if not getattr(module, 'IS_A_HOOK', True):
return None
break
else:
raise ValueError(f'Unknown hook name: {hook_name}')
logger.debug(f'{log_prefix}: Calling {hook_name} hook function {function_name}')
return getattr(module, function_name)(hook_config, config, log_prefix, *args, **kwargs)
def call_hooks(function_name, config, log_prefix, hook_names, *args, **kwargs):
def call_hooks(function_name, config, log_prefix, 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 given hook name. 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.
the Python module corresponding to each hook of the given hook type (either "data_source" or
"monitoring"). Supply each call with the configuration for that hook, the log prefix, and any
given args and kwargs.
Collect any return values into a dict from module name to return value. Note that the module
name is the name of the hook module itself, which might be different from the hook configuration
option (e.g. "postgresql" for the former vs. "postgresql_databases" for the latter).
If the hook name is not present in the hooks configuration, then don't call the function for it
and omit it from the return values.
@@ -80,22 +79,26 @@ def call_hooks(function_name, config, log_prefix, hook_names, *args, **kwargs):
'''
return {
hook_name: call_hook(function_name, config, log_prefix, hook_name, *args, **kwargs)
for hook_name in hook_names
if hook_name in config
for hook_name in get_submodule_names(
importlib.import_module(f'borgmatic.hooks.{hook_type.value}')
)
if hook_name in config or f'{hook_name}_databases' in config
}
def call_hooks_even_if_unconfigured(function_name, config, log_prefix, hook_names, *args, **kwargs):
def call_hooks_even_if_unconfigured(function_name, config, log_prefix, 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 given hook name. 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.
the Python module corresponding to each hook of the given hook type (either "data_source" or
"monitoring"). Supply each call with the configuration for that hook, the log prefix, and any
given args and kwargs. Collect any return values into a dict from hook name to return value.
Raise AttributeError if the function name is not found in the module.
Raise anything else that a called function raises. An error stops calls to subsequent functions.
'''
return {
hook_name: call_hook(function_name, config, log_prefix, hook_name, *args, **kwargs)
for hook_name in hook_names
for hook_name in get_submodule_names(
importlib.import_module(f'borgmatic.hooks.{hook_type.value}')
)
}
-21
View File
@@ -1,21 +0,0 @@
from enum import Enum
MONITOR_HOOK_NAMES = (
'apprise',
'cronhub',
'cronitor',
'healthchecks',
'loki',
'ntfy',
'pagerduty',
'pushover',
'uptime_kuma',
'zabbix',
)
class State(Enum):
START = 1
FINISH = 2
FAIL = 3
LOG = 4
@@ -1,8 +1,8 @@
import logging
import operator
import borgmatic.hooks.logs
import borgmatic.hooks.monitor
import borgmatic.hooks.monitoring.logs
import borgmatic.hooks.monitoring.monitor
logger = logging.getLogger(__name__)
@@ -22,12 +22,12 @@ def initialize_monitor(hook_config, config, config_filename, monitoring_log_leve
logs_size_limit = max(
hook_config.get('logs_size_limit', DEFAULT_LOGS_SIZE_LIMIT_BYTES)
- len(borgmatic.hooks.logs.PAYLOAD_TRUNCATION_INDICATOR),
- len(borgmatic.hooks.monitoring.logs.PAYLOAD_TRUNCATION_INDICATOR),
0,
)
borgmatic.hooks.logs.add_handler(
borgmatic.hooks.logs.Forgetful_buffering_handler(
borgmatic.hooks.monitoring.logs.add_handler(
borgmatic.hooks.monitoring.logs.Forgetful_buffering_handler(
HANDLER_IDENTIFIER, logs_size_limit, monitoring_log_level
)
)
@@ -82,11 +82,13 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
body = state_config.get('body')
if state in (
borgmatic.hooks.monitor.State.FINISH,
borgmatic.hooks.monitor.State.FAIL,
borgmatic.hooks.monitor.State.LOG,
borgmatic.hooks.monitoring.monitor.State.FINISH,
borgmatic.hooks.monitoring.monitor.State.FAIL,
borgmatic.hooks.monitoring.monitor.State.LOG,
):
formatted_logs = borgmatic.hooks.logs.format_buffered_logs_for_payload(HANDLER_IDENTIFIER)
formatted_logs = borgmatic.hooks.monitoring.logs.format_buffered_logs_for_payload(
HANDLER_IDENTIFIER
)
if formatted_logs:
body += f'\n\n{formatted_logs}'
@@ -106,4 +108,4 @@ def destroy_monitor(hook_config, config, config_filename, monitoring_log_level,
Remove the monitor handler that was added to the root logger. This prevents the handler from
getting reused by other instances of this monitor.
'''
borgmatic.hooks.logs.remove_handler(HANDLER_IDENTIFIER)
borgmatic.hooks.monitoring.logs.remove_handler(HANDLER_IDENTIFIER)
@@ -2,7 +2,7 @@ import logging
import requests
from borgmatic.hooks import monitor
from borgmatic.hooks.monitoring import monitor
logger = logging.getLogger(__name__)
@@ -2,7 +2,7 @@ import logging
import requests
from borgmatic.hooks import monitor
from borgmatic.hooks.monitoring import monitor
logger = logging.getLogger(__name__)
@@ -3,8 +3,8 @@ import re
import requests
import borgmatic.hooks.logs
from borgmatic.hooks import monitor
import borgmatic.hooks.monitoring.logs
from borgmatic.hooks.monitoring import monitor
logger = logging.getLogger(__name__)
@@ -30,12 +30,12 @@ def initialize_monitor(hook_config, config, config_filename, monitoring_log_leve
ping_body_limit = max(
hook_config.get('ping_body_limit', DEFAULT_PING_BODY_LIMIT_BYTES)
- len(borgmatic.hooks.logs.PAYLOAD_TRUNCATION_INDICATOR),
- len(borgmatic.hooks.monitoring.logs.PAYLOAD_TRUNCATION_INDICATOR),
0,
)
borgmatic.hooks.logs.add_handler(
borgmatic.hooks.logs.Forgetful_buffering_handler(
borgmatic.hooks.monitoring.logs.add_handler(
borgmatic.hooks.monitoring.logs.Forgetful_buffering_handler(
HANDLER_IDENTIFIER, ping_body_limit, monitoring_log_level
)
)
@@ -78,7 +78,9 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
logger.debug(f'{config_filename}: Using Healthchecks ping URL {ping_url}')
if state in (monitor.State.FINISH, monitor.State.FAIL, monitor.State.LOG):
payload = borgmatic.hooks.logs.format_buffered_logs_for_payload(HANDLER_IDENTIFIER)
payload = borgmatic.hooks.monitoring.logs.format_buffered_logs_for_payload(
HANDLER_IDENTIFIER
)
else:
payload = ''
@@ -99,4 +101,4 @@ def destroy_monitor(hook_config, config, config_filename, monitoring_log_level,
Remove the monitor handler that was added to the root logger. This prevents the handler from
getting reused by other instances of this monitor.
'''
borgmatic.hooks.logs.remove_handler(HANDLER_IDENTIFIER)
borgmatic.hooks.monitoring.logs.remove_handler(HANDLER_IDENTIFIER)
@@ -1,5 +1,6 @@
import logging
IS_A_HOOK = False
PAYLOAD_TRUNCATION_INDICATOR = '...\n'
@@ -6,7 +6,7 @@ import time
import requests
from borgmatic.hooks import monitor
from borgmatic.hooks.monitoring import monitor
logger = logging.getLogger(__name__)
+10
View File
@@ -0,0 +1,10 @@
import enum
IS_A_HOOK = False
class State(enum.Enum):
START = 1
FINISH = 2
FAIL = 3
LOG = 4
@@ -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:
@@ -5,7 +5,7 @@ import platform
import requests
from borgmatic.hooks import monitor
from borgmatic.hooks.monitoring import monitor
logger = logging.getLogger(__name__)
+79
View File
@@ -0,0 +1,79 @@
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{config_filename}: Invalid Sentry data source name URL: {data_source_name_url}'
)
return
cron_url = f'{match.group("protocol")}://{match.group("hostname")}/api/{match.group("project_id")}/cron/{monitor_slug}/{match.group("username")}/'
logger.info(f'{config_filename}: Pinging Sentry {state.name.lower()}{dry_run_label}')
logger.debug(f'{config_filename}: Using Sentry cron URL {cron_url}')
status = {
'start': 'in_progress',
'finish': 'ok',
'fail': 'error',
}.get(state.name.lower())
if not status:
logger.warning('f{config_filename}: 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'{config_filename}: Sentry error: {error}')
def destroy_monitor(
ping_url_or_uuid, config, config_filename, monitoring_log_level, dry_run
): # pragma: no cover
'''
No destruction is necessary for this monitor.
'''
pass
+32 -13
View File
@@ -1,10 +1,9 @@
import enum
import logging
import logging.handlers
import os
import sys
import colorama
def to_bool(arg):
'''
@@ -33,7 +32,7 @@ def interactive_console():
def should_do_markup(no_color, configs):
'''
Given the value of the command-line no-color argument, and a dict of configuration filename to
corresponding parsed configuration, determine if we should enable colorama marking up.
corresponding parsed configuration, determine if we should enable color marking up.
'''
if no_color:
return False
@@ -93,30 +92,50 @@ class Console_no_color_formatter(logging.Formatter):
return record.msg
class Color(enum.Enum):
RESET = 0
RED = 31
GREEN = 32
YELLOW = 33
MAGENTA = 35
CYAN = 36
class Console_color_formatter(logging.Formatter):
def format(self, record):
add_custom_log_levels()
color = {
logging.CRITICAL: colorama.Fore.RED,
logging.ERROR: colorama.Fore.RED,
logging.WARN: colorama.Fore.YELLOW,
logging.ANSWER: colorama.Fore.MAGENTA,
logging.INFO: colorama.Fore.GREEN,
logging.DEBUG: colorama.Fore.CYAN,
}.get(record.levelno)
color = (
{
logging.CRITICAL: Color.RED,
logging.ERROR: Color.RED,
logging.WARN: Color.YELLOW,
logging.ANSWER: Color.MAGENTA,
logging.INFO: Color.GREEN,
logging.DEBUG: Color.CYAN,
}
.get(record.levelno)
.value
)
return color_text(color, record.msg)
def ansi_escape_code(color): # pragma: no cover
'''
Given a color value, produce the corresponding ANSI escape code.
'''
return f'\x1b[{color}m'
def color_text(color, message):
'''
Give colored text.
Given a color value and a message, return the message colored with ANSI escape codes.
'''
if not color:
return message
return f'{color}{message}{colorama.Style.RESET_ALL}'
return f'{ansi_escape_code(color)}{message}{ansi_escape_code(Color.RESET.value)}'
def add_logging_level(level_name, level_number):
+117 -39
View File
@@ -391,7 +391,7 @@ with the repository's path or its label as configured in your borgmatic
configuration file.
```bash
borgmatic restore --repository repo.borg --archive host-2023-...
borgmatic restore --repository repo.borg --archive latest
```
### Restore particular databases
@@ -401,7 +401,7 @@ restore one of them, use the `--database` flag to select one or more
databases. For instance:
```bash
borgmatic restore --archive host-2023-... --database users --database orders
borgmatic restore --archive latest --database users --database orders
```
<span class="minilink minilink-addedin">New in version 1.7.6</span> You can
@@ -409,29 +409,99 @@ also restore individual databases even if you dumped them as "all"—as long as
you dumped them into separate files via use of the "format" option. See above
for more information.
### Restore databases sharing a name
<span class="minilink minilink-addedin">New in version 1.9.5</span> If you've
backed up multiple databases that happen to share the same name but different
hostnames, ports, or hooks, you can include additional flags to disambiguate
which database you'd like to restore. For instance, let's say you've backed up
the following configured databases:
```yaml
postgresql_databases:
- name: users
hostname: host1.example.org
- name: users
hostname: host2.example.org
```
... then you can run the following command to restore only one of them:
```bash
borgmatic restore --archive latest --database users --original-hostname host1.example.org
```
This selects a `users` database to restore, but only if it originally came
from the host `host1.example.org`. This command won't restore `users`
databases from any other hosts.
Here's another example configuration:
```yaml
postgresql_databases:
- name: users
hostname: example.org
port: 5433
- name: users
hostname: example.org
port: 5434
```
And a command to restore just one of the databases:
```bash
borgmatic restore --archive latest --database users --original-port 5433
```
That restores a `users` database only if it originally came from port `5433`
*and* if that port is in borgmatic's configuration, e.g. `port: 5433`.
Finally, check out this configuration:
```yaml
postgresql_databases:
- name: users
hostname: example.org
mariadb_databases:
- name: users
hostname: example.org
```
And to select just one of the databases to restore:
```bash
borgmatic restore --archive latest --database users --hook postgresql
```
That restores a `users` database only if it was dumped using the
`postgresql_databases:` data source hook. This command won't restore `users`
databases that were dumped using other hooks.
Note that these flags don't change the hostname or port to which the database
is actually restored. For that, see below about restoring to an alternate
host.
### Restore all databases
To restore all databases:
```bash
borgmatic restore --archive host-2023-... --database all
borgmatic restore --archive latest --database all
```
Or omit the `--database` flag entirely:
```bash
borgmatic restore --archive host-2023-...
borgmatic restore --archive latest
```
Prior to borgmatic version 1.7.6, this restores a combined "all" database
dump from the archive.
<span class="minilink minilink-addedin">New in version 1.7.6</span> Restoring
"all" databases restores each database found in the selected archive. That
includes any combined dump file named "all" and any other individual database
dumps found in the archive.
dumps found in the archive. Prior to borgmatic version 1.7.6, restoring "all"
only restored a combined "all" database dump from the archive.
### Restore particular schemas
@@ -466,21 +536,35 @@ postgresql_databases:
restore_password: trustsome1
```
### Manual restoration
### Limitations
If you prefer to restore a database without the help of borgmatic, first
[extract](https://torsion.org/borgmatic/docs/how-to/extract-a-backup/) an
archive containing a database dump.
borgmatic extracts the dump file into the `borgmatic/` directory within the
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
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`.
After extraction, you can manually restore the dump file using native database
commands like `pg_restore`, `mysql`, `mongorestore`, `sqlite`, or similar.
Also see the documentation on [listing database
dumps](https://torsion.org/borgmatic/docs/how-to/inspect-your-backups/#listing-database-dumps).
## Limitations
There are a few important limitations with borgmatic's current database
restoration feature that you should know about:
hooks that you should know about:
1. You must restore as the same Unix user that created the archive containing
the database dump. That's because the user's home directory path is encoded
into the path of the database dump within the archive.
2. As mentioned above, borgmatic can only restore a database that's defined in
borgmatic's own configuration file. So include your configuration file in
backups to avoid getting caught without a way to restore a database.
3. borgmatic does not currently support backing up or restoring multiple
databases that share the exact same name on different hosts.
4. When database hooks are enabled, borgmatic instructs Borg to consume
1. When database hooks are enabled, borgmatic instructs Borg to consume
special files (via `--read-special`) to support database dump
streaming—regardless of the value of your `read_special` configuration option.
And because this can cause Borg to hang, borgmatic also automatically excludes
@@ -495,7 +579,10 @@ exclude them. <span class="minilink minilink-addedin">Prior to version
1.7.3</span>Special files were not auto-excluded, and you were responsible for
excluding them yourself. Common directories to exclude are `/dev` and `/run`,
but that may not be exhaustive.
5. <span class="minilink minilink-addedin">Prior to version 1.9.0</span>
2. <span class="minilink minilink-addedin">Prior to version 1.9.5</span>
borgmatic did not support backing up or restoring multiple databases that
shared the exact same name on different hosts or with different ports.
3. <span class="minilink minilink-addedin">Prior to version 1.9.0</span>
Database hooks also implicitly enabled the `one_file_system` option, which
meant Borg wouldn't cross filesystem boundaries when looking for files to
backup. When borgmatic was running in a container, this often required a
@@ -503,25 +590,16 @@ work-around to explicitly add each mounted backup volume to
`source_directories` instead of relying on Borg to include them implicitly via
a parent directory. But as of borgmatic 1.9.0, `one_file_system` is no longer
auto-enabled and such work-arounds aren't necessary.
### Manual restoration
If you prefer to restore a database without the help of borgmatic, first
[extract](https://torsion.org/borgmatic/docs/how-to/extract-a-backup/) an
archive containing a database dump.
borgmatic extracts 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`.
After extraction, you can manually restore the dump file using native database
commands like `pg_restore`, `mysql`, `mongorestore`, `sqlite`, or similar.
Also see the documentation on [listing database
dumps](https://torsion.org/borgmatic/docs/how-to/inspect-your-backups/#listing-database-dumps).
4. <span class="minilink minilink-addedin">Prior to version 1.9.0</span> You
must restore as the same Unix user that created the archive containing the
database dump. That's because the user's home directory path is encoded into
the path of the database dump within the archive.
5. <span class="minilink minilink-addedin">Prior to version 1.7.15</span> As
mentioned above, borgmatic can only restore a database that's defined in
borgmatic's own configuration file. So include your configuration files in
backups to avoid getting caught without a way to restore a database. But
starting from version 1.7.15, borgmatic includes your configuration files
automatically.
## Preparation and cleanup hooks
+41 -1
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
+214 -7
View File
@@ -52,7 +52,10 @@ feedback](https://torsion.org/borgmatic/#issues) you have on this feature.
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`.
`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
@@ -62,17 +65,35 @@ If you have multiple borgmatic configuration files with ZFS enabled, and you'd
like particular datasets to be backed up only for particular configuration
files, use the `source_directories` option instead of the user property.
During a backup, borgmatic automatically snapshots these discovered datasets,
temporary mounts the snapshots within its [runtime
During a backup, borgmatic automatically snapshots these discovered datasets
(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 files sent to Borg. borgmatic is
also responsible for cleaning up (destroying) these snapshots after a backup
and includes the snapshotted files in the paths sent to Borg. borgmatic is also
responsible for cleaning up (destroying) these snapshots after a backup
completes.
Additionally, borgmatic rewrites the snapshot file paths so that they appear
at their original dataset locations in a Borg archive. For instance, if your
dataset is mounted at `/mnt/dataset`, then the snapshotted files will appear
in an archive at `/mnt/dataset` as well.
dataset is mounted at `/var/dataset`, then the snapshotted files will appear
in an archive at `/var/dataset` as well—even if borgmatic has to mount the
snapshot somewhere in `/run/user/1000/borgmatic/zfs_snapshots/` to perform the
backup.
<span class="minilink minilink-addedin">New in version 1.9.4</span> borgmatic
is smart enough to look at the parent (and grandparent, etc.) directories of
each of your `source_directories` to discover any datasets. For instance,
let's say you add `/var/log` and `/var/lib` to your source directories, but
`/var` is a dataset. borgmatic will discover that and snapshot `/var`
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
@@ -88,3 +109,189 @@ Filesystem snapshots are stored in a Borg archive as normal files, so
you can use the standard
[extract action](https://torsion.org/borgmatic/docs/how-to/extract-a-backup/) to
extract them.
### Btrfs
<span class="minilink minilink-addedin">New in version 1.9.4</span> <span
class="minilink minilink-addedin">Beta feature</span> borgmatic supports taking
snapshots with the [Btrfs filesystem](https://btrfs.readthedocs.io/) and sending
those snapshots to Borg for backup.
To use this feature, first you need one or more Btrfs subvolumes on mounted
filesystems. Then, enable Btrfs within borgmatic by adding the following line to
your configuration file:
```yaml
btrfs:
```
No other options are necessary to enable Btrfs support, but if desired you can
override some of the commands used by the Btrfs hook. For instance:
```yaml
btrfs:
btrfs_command: /usr/local/bin/btrfs
findmnt_command: /usr/local/bin/findmnt
```
As long as the Btrfs hook is in beta, it may be subject to breaking changes
and/or may not work well for your use cases. But feel free to use it in
production if you're okay with these caveats, and please [provide any
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.
<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.
For instance, let's say you add `/var/log` and `/var/lib` to your source
directories, but `/var` is a subvolume. borgmatic will discover that and
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
in an archive at `/var/subvolume` as well—even if borgmatic has to mount the
snapshot somewhere in `/var/subvolume/.borgmatic-snapshot-1234/` to perform
the backup.
<span class="minilink minilink-addedin">With Borg version 1.2 and
earlier</span>Snapshotted files are instead stored at a path dependent on the
temporary snapshot directory in use at the time the archive was created, as Borg
1.2 and earlier do not support path rewriting.
#### Extract a subvolume
Subvolume snapshots are stored in a Borg archive as normal files, so you can use
the standard [extract
action](https://torsion.org/borgmatic/docs/how-to/extract-a-backup/) to extract
them.
### LVM
<span class="minilink minilink-addedin">New in version 1.9.4</span> <span
class="minilink minilink-addedin">Beta feature</span> borgmatic supports
taking snapshots with [LVM](https://sourceware.org/lvm2/) (Linux Logical
Volume Manager) and sending those snapshots to Borg for backup. LVM isn't
itself a filesystem, but it can take snapshots at the layer right below your
filesystem.
To use this feature, first you need one or more mounted LVM logical volumes.
Then, enable LVM within borgmatic by adding the following line to your
configuration file:
```yaml
lvm:
```
No other options are necessary to enable LVM support, but if desired you can
override some of the options used by the LVM hook. For instance:
```yaml
lvm:
snapshot_size: 5GB # See below for details.
lvcreate_command: /usr/local/bin/lvcreate
lvremove_command: /usr/local/bin/lvremove
lvs_command: /usr/local/bin/lvs
lsbrk_command: /usr/local/bin/lsbrk
mount_command: /usr/local/bin/mount
umount_command: /usr/local/bin/umount
```
As long as the LVM hook is in beta, it may be subject to breaking changes
and/or may not work well for your use cases. But feel free to use it in
production if you're okay with these caveats, and please [provide any
feedback](https://torsion.org/borgmatic/#issues) you have on this feature.
#### Snapshot size
The `snapshot_size` option is the size to allocate for each snapshot taken,
including the units to use for that size. While borgmatic's snapshots
themselves are read-only and don't change during backups, the logical volume
being snapshotted *can* change—therefore requiring additional snapshot storage
since LVM snapshots are copy-on-write. And if the configured snapshot size is
too small (and LVM isn't configured to grow snapshots automatically), then the
snapshots will fail to allocate enough space, resulting in a broken backup.
If not specified, the `snapshot_size` option defaults to `10%ORIGIN`, which
means 10% of the size of the logical volume being snapshotted. See the
[`lvcreate --size` and `--extents`
documentation](https://www.man7.org/linux/man-pages/man8/lvcreate.8.html) for
more information about possible values here. (Under the hood, borgmatic uses
`lvcreate --extents` if the `snapshot_size` is a percentage value, and `lvcreate
--size` otherwise.)
#### Logical volume discovery
For any logical volume 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.
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.
borgmatic is smart enough to look at the parent (and grandparent, etc.)
directories of each of your `source_directories` to discover any logical
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
will appear in an archive at `/var/lvolume` as well—even if borgmatic has to
mount the snapshot somewhere in `/run/user/1000/borgmatic/lvm_snapshots/` to
perform the backup.
<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
directory](https://torsion.org/borgmatic/docs/how-to/backup-your-databases/#runtime-directory)
in use at the time the archive was created, as Borg 1.2 and earlier do not
support path rewriting.
#### Extract a logical volume
Logical volume snapshots are stored in a Borg archive as normal files, so
you can use the standard
[extract action](https://torsion.org/borgmatic/docs/how-to/extract-a-backup/) to
extract them.
+4 -2
View File
@@ -16,8 +16,10 @@ you get started. Starting at the top level, we have:
* [actions](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/actions): borgmatic-specific logic for running each action (create, list, check, etc.).
* [borg](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/borg): Lower-level code that's responsible for interacting with Borg to run each action.
* [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.
* [hooks](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/hooks): Looking to add a new database or monitoring integration? Start here.
* [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.
* [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.
* [sample](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/sample): Example configurations for cron and systemd.
* [scripts](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/scripts): Dev-facing scripts for things like building documentation and running end-to-end tests.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+6 -7
View File
@@ -1,12 +1,12 @@
[project]
name = "borgmatic"
version = "1.9.3"
version = "1.9.7"
authors = [
{ name="Dan Helfman", email="witten@torsion.org" },
]
description = "Simple, configuration-driven backup software for servers and workstations"
readme = "README.md"
requires-python = ">=3.8"
requires-python = ">=3.9"
classifiers=[
"Development Status :: 5 - Production/Stable",
"Environment :: Console",
@@ -17,11 +17,10 @@ classifiers=[
"Topic :: System :: Archiving :: Backup",
]
dependencies = [
"colorama>=0.4.1,<0.5",
"jsonschema",
"packaging",
"requests",
"ruamel.yaml>0.15.0",
"jsonschema",
"packaging",
"requests",
"ruamel.yaml>0.15.0",
]
[project.scripts]
+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
-1
View File
@@ -6,7 +6,6 @@ certifi==2024.7.4
chardet==5.2.0
click==8.1.7
codespell==2.2.6
colorama==0.4.6
coverage==7.5.1
flake8==7.0.0
flake8-quotes==3.4.0
+90
View File
@@ -0,0 +1,90 @@
import argparse
import json
import os
import shutil
import sys
def parse_arguments(*unparsed_arguments):
global_parser = argparse.ArgumentParser(add_help=False)
action_parsers = global_parser.add_subparsers(dest='action')
subvolume_parser = action_parsers.add_parser('subvolume')
subvolume_subparser = subvolume_parser.add_subparsers(dest='subaction')
list_parser = subvolume_subparser.add_parser('list')
list_parser.add_argument('-s', dest='snapshots_only', action='store_true')
list_parser.add_argument('subvolume_path')
snapshot_parser = subvolume_subparser.add_parser('snapshot')
snapshot_parser.add_argument('-r', dest='read_only', action='store_true')
snapshot_parser.add_argument('subvolume_path')
snapshot_parser.add_argument('snapshot_path')
delete_parser = subvolume_subparser.add_parser('delete')
delete_parser.add_argument('snapshot_path')
return global_parser.parse_args(unparsed_arguments)
BUILTIN_SUBVOLUME_LIST_LINES = (
'261 gen 29 top level 5 path sub',
'262 gen 29 top level 5 path other',
)
SUBVOLUME_LIST_LINE_PREFIX = '263 gen 29 top level 5 path '
def load_snapshots():
try:
return json.load(open('/tmp/fake_btrfs.json'))
except FileNotFoundError:
return []
def save_snapshots(snapshot_paths):
json.dump(snapshot_paths, open('/tmp/fake_btrfs.json', 'w'))
def print_subvolume_list(arguments, snapshot_paths):
assert arguments.subvolume_path == '/mnt/subvolume'
if not arguments.snapshots_only:
for line in BUILTIN_SUBVOLUME_LIST_LINES:
print(line)
for snapshot_path in snapshot_paths:
print(
SUBVOLUME_LIST_LINE_PREFIX
+ snapshot_path[snapshot_path.index('.borgmatic-snapshot-') :]
)
def main():
arguments = parse_arguments(*sys.argv[1:])
snapshot_paths = load_snapshots()
if arguments.subaction == 'list':
print_subvolume_list(arguments, snapshot_paths)
elif arguments.subaction == 'snapshot':
snapshot_paths.append(arguments.snapshot_path)
save_snapshots(snapshot_paths)
subdirectory = os.path.join(arguments.snapshot_path, 'subdir')
os.makedirs(subdirectory, mode=0o700, exist_ok=True)
test_file = open(os.path.join(subdirectory, 'file.txt'), 'w')
test_file.write('contents')
test_file.close()
elif arguments.subaction == 'delete':
subdirectory = os.path.join(arguments.snapshot_path, 'subdir')
shutil.rmtree(subdirectory)
snapshot_paths = [
snapshot_path
for snapshot_path in snapshot_paths
if snapshot_path.endswith('/' + arguments.snapshot_path)
]
save_snapshots(snapshot_paths)
if __name__ == '__main__':
main()
+42
View File
@@ -0,0 +1,42 @@
import argparse
import sys
def parse_arguments(*unparsed_arguments):
parser = argparse.ArgumentParser(add_help=False)
parser.add_argument('-t', dest='type')
parser.add_argument('--json', action='store_true')
parser.add_argument('--list', action='store_true')
return parser.parse_args(unparsed_arguments)
BUILTIN_FILESYSTEM_MOUNT_OUTPUT = '''{
"filesystems": [
{
"target": "/mnt/subvolume",
"source": "/dev/loop0",
"fstype": "btrfs",
"options": "rw,relatime,ssd,space_cache=v2,subvolid=5,subvol=/"
}
]
}
'''
def print_filesystem_mounts():
print(BUILTIN_FILESYSTEM_MOUNT_OUTPUT)
def main():
arguments = parse_arguments(*sys.argv[1:])
assert arguments.type == 'btrfs'
assert arguments.json
assert arguments.list
print_filesystem_mounts()
if __name__ == '__main__':
main()
+70
View File
@@ -0,0 +1,70 @@
import argparse
import json
import sys
def parse_arguments(*unparsed_arguments):
parser = argparse.ArgumentParser(add_help=False)
parser.add_argument('--output', required=True)
parser.add_argument('--json', action='store_true', required=True)
parser.add_argument('--list', action='store_true', required=True)
return parser.parse_args(unparsed_arguments)
BUILTIN_BLOCK_DEVICES = {
'blockdevices': [
{'name': 'loop0', 'path': '/dev/loop0', 'mountpoint': None, 'type': 'loop'},
{'name': 'cryptroot', 'path': '/dev/mapper/cryptroot', 'mountpoint': '/', 'type': 'crypt'},
{
'name': 'vgroup-lvolume',
'path': '/dev/mapper/vgroup-lvolume',
'mountpoint': '/mnt/lvolume',
'type': 'lvm',
},
{
'name': 'vgroup-lvolume-real',
'path': '/dev/mapper/vgroup-lvolume-real',
'mountpoint': None,
'type': 'lvm',
},
]
}
def load_snapshots():
try:
return json.load(open('/tmp/fake_lvm.json'))
except FileNotFoundError:
return []
def print_logical_volumes_json(arguments, snapshots):
data = dict(BUILTIN_BLOCK_DEVICES)
for snapshot in snapshots:
data['blockdevices'].extend(
{
'name': snapshot['lv_name'],
'path': snapshot['lv_path'],
'mountpoint': None,
'type': 'lvm',
}
for snapshot in snapshots
)
print(json.dumps(data))
def main():
arguments = parse_arguments(*sys.argv[1:])
snapshots = load_snapshots()
assert arguments.output == 'name,path,mountpoint,type'
print_logical_volumes_json(arguments, snapshots)
if __name__ == '__main__':
main()
@@ -0,0 +1,44 @@
import argparse
import json
import sys
def parse_arguments(*unparsed_arguments):
parser = argparse.ArgumentParser(add_help=False)
parser.add_argument('--snapshot', action='store_true', required=True)
parser.add_argument('--extents')
parser.add_argument('--size')
parser.add_argument('--permission', required=True)
parser.add_argument('--name', dest='snapshot_name', required=True)
parser.add_argument('logical_volume_device')
return parser.parse_args(unparsed_arguments)
def load_snapshots():
try:
return json.load(open('/tmp/fake_lvm.json'))
except FileNotFoundError:
return []
def save_snapshots(snapshots):
json.dump(snapshots, open('/tmp/fake_lvm.json', 'w'))
def main():
arguments = parse_arguments(*sys.argv[1:])
snapshots = load_snapshots()
assert arguments.extents or arguments.size
snapshots.append(
{'lv_name': arguments.snapshot_name, 'lv_path': f'/dev/vgroup/{arguments.snapshot_name}'},
)
save_snapshots(snapshots)
if __name__ == '__main__':
main()
@@ -0,0 +1,39 @@
import argparse
import json
import sys
def parse_arguments(*unparsed_arguments):
parser = argparse.ArgumentParser(add_help=False)
parser.add_argument('--force', action='store_true', required=True)
parser.add_argument('snapshot_device')
return parser.parse_args(unparsed_arguments)
def load_snapshots():
try:
return json.load(open('/tmp/fake_lvm.json'))
except FileNotFoundError:
return []
def save_snapshots(snapshots):
json.dump(snapshots, open('/tmp/fake_lvm.json', 'w'))
def main():
arguments = parse_arguments(*sys.argv[1:])
snapshots = [
snapshot
for snapshot in load_snapshots()
if snapshot['lv_path'] != arguments.snapshot_device
]
save_snapshots(snapshots)
if __name__ == '__main__':
main()
+50
View File
@@ -0,0 +1,50 @@
import argparse
import json
import sys
def parse_arguments(*unparsed_arguments):
parser = argparse.ArgumentParser(add_help=False)
parser.add_argument('--report-format', required=True)
parser.add_argument('--options', required=True)
parser.add_argument('--select', required=True)
return parser.parse_args(unparsed_arguments)
def load_snapshots():
try:
return json.load(open('/tmp/fake_lvm.json'))
except FileNotFoundError:
return []
def print_snapshots_json(arguments, snapshots):
assert arguments.report_format == 'json'
assert arguments.options == 'lv_name,lv_path'
assert arguments.select == 'lv_attr =~ ^s'
print(
json.dumps(
{
'report': [
{
'lv': snapshots,
}
],
'log': [],
}
)
)
def main():
arguments = parse_arguments(*sys.argv[1:])
snapshots = load_snapshots()
print_snapshots_json(arguments, snapshots)
if __name__ == '__main__':
main()
+29
View File
@@ -0,0 +1,29 @@
import argparse
import os
import sys
def parse_arguments(*unparsed_arguments):
parser = argparse.ArgumentParser(add_help=False)
parser.add_argument('-t', dest='type')
parser.add_argument('-o', dest='options')
parser.add_argument('snapshot_name')
parser.add_argument('mount_point')
return parser.parse_args(unparsed_arguments)
def main():
arguments = parse_arguments(*sys.argv[1:])
assert arguments.options == 'ro'
subdirectory = os.path.join(arguments.mount_point, 'subdir')
os.mkdir(subdirectory)
test_file = open(os.path.join(subdirectory, 'file.txt'), 'w')
test_file.write('contents')
test_file.close()
if __name__ == '__main__':
main()
+22
View File
@@ -0,0 +1,22 @@
import argparse
import os
import shutil
import sys
def parse_arguments(*unparsed_arguments):
parser = argparse.ArgumentParser(add_help=False)
parser.add_argument('mount_point')
return parser.parse_args(unparsed_arguments)
def main():
arguments = parse_arguments(*sys.argv[1:])
subdirectory = os.path.join(arguments.mount_point, 'subdir')
shutil.rmtree(subdirectory)
if __name__ == '__main__':
main()
+92
View File
@@ -0,0 +1,92 @@
import argparse
import json
import sys
def parse_arguments(*unparsed_arguments):
global_parser = argparse.ArgumentParser(add_help=False)
action_parsers = global_parser.add_subparsers(dest='action')
list_parser = action_parsers.add_parser('list')
list_parser.add_argument('-H', dest='header', action='store_false', default=True)
list_parser.add_argument('-t', dest='type', default='filesystem')
list_parser.add_argument('-o', dest='properties', default='name,used,avail,refer,mountpoint')
snapshot_parser = action_parsers.add_parser('snapshot')
snapshot_parser.add_argument('name')
destroy_parser = action_parsers.add_parser('destroy')
destroy_parser.add_argument('name')
return global_parser.parse_args(unparsed_arguments)
BUILTIN_DATASETS = (
{
'name': 'pool',
'used': '256K',
'avail': '23.7M',
'refer': '25K',
'mountpoint': '/pool',
},
{
'name': 'pool/dataset',
'used': '256K',
'avail': '23.7M',
'refer': '25K',
'mountpoint': '/pool/dataset',
},
)
def load_snapshots():
try:
return json.load(open('/tmp/fake_zfs.json'))
except FileNotFoundError:
return []
def save_snapshots(snapshots):
json.dump(snapshots, open('/tmp/fake_zfs.json', 'w'))
def print_dataset_list(arguments, datasets, snapshots):
properties = arguments.properties.split(',')
data = (
(tuple(property_name.upper() for property_name in properties),) if arguments.header else ()
) + tuple(
tuple(dataset.get(property_name, '-') for property_name in properties)
for dataset in (snapshots if arguments.type == 'snapshot' else datasets)
)
if not data:
return
for data_row in data:
print('\t'.join(data_row))
def main():
arguments = parse_arguments(*sys.argv[1:])
snapshots = load_snapshots()
if arguments.action == 'list':
print_dataset_list(arguments, BUILTIN_DATASETS, snapshots)
elif arguments.action == 'snapshot':
snapshots.append(
{
'name': arguments.name,
'used': '0B',
'avail': '-',
'refer': '25K',
'mountpoint': '-',
},
)
save_snapshots(snapshots)
elif arguments.action == 'destroy':
snapshots = [snapshot for snapshot in snapshots if snapshot['name'] != arguments.name]
save_snapshots(snapshots)
if __name__ == '__main__':
main()
+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:
+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')
+63
View File
@@ -0,0 +1,63 @@
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 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', f'- {config_path}')
.replace('- /etc', '- /mnt/subvolume/subdir')
.replace('- /var/log/syslog*', '')
+ 'encryption_passphrase: "test"\n'
+ 'btrfs:\n'
+ ' btrfs_command: python3 /app/tests/end-to-end/commands/fake_btrfs.py\n'
+ ' findmnt_command: python3 /app/tests/end-to-end/commands/fake_findmnt.py\n'
)
config_file = open(config_path, 'w')
config_file.write(config)
config_file.close()
def test_btrfs_create_and_list():
temporary_directory = tempfile.mkdtemp()
repository_path = os.path.join(temporary_directory, 'test.borg')
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 a create action to exercise Btrfs snapshotting and backup.
subprocess.check_call(f'borgmatic --config {config_path} create'.split(' '))
# List the resulting archive and assert that the snapshotted files are there.
output = subprocess.check_output(
f'borgmatic --config {config_path} list --archive latest'.split(' ')
).decode(sys.stdout.encoding)
assert 'mnt/subvolume/subdir/file.txt' in output
# Assert that the snapshot has been deleted.
assert not subprocess.check_output(
'python3 /app/tests/end-to-end/commands/fake_btrfs.py subvolume list -s /mnt/subvolume'.split(
' '
)
)
finally:
shutil.rmtree(temporary_directory)
+21 -15
View File
@@ -16,6 +16,8 @@ def write_configuration(
repository_path,
user_runtime_directory,
postgresql_dump_format='custom',
postgresql_all_dump_format=None,
mariadb_mysql_all_dump_format=None,
mongodb_dump_format='archive',
):
'''
@@ -23,6 +25,13 @@ def write_configuration(
for testing. This includes injecting the given repository path, borgmatic source directory for
storing database dumps, dump format (for PostgreSQL), and encryption passphrase.
'''
postgresql_all_format_option = (
f'format: {postgresql_all_dump_format}' if postgresql_all_dump_format else ''
)
mariadb_mysql_dump_format_option = (
f'format: {mariadb_mysql_all_dump_format}' if mariadb_mysql_all_dump_format else ''
)
config_yaml = f'''
source_directories:
- {source_directory}
@@ -39,11 +48,7 @@ postgresql_databases:
password: test
format: {postgresql_dump_format}
- name: all
hostname: postgresql
username: postgres
password: test
- name: all
format: custom
{postgresql_all_format_option}
hostname: postgresql
username: postgres
password: test
@@ -53,11 +58,7 @@ mariadb_databases:
username: root
password: test
- name: all
hostname: mariadb
username: root
password: test
- name: all
format: sql
{mariadb_mysql_dump_format_option}
hostname: mariadb
username: root
password: test
@@ -67,11 +68,7 @@ mysql_databases:
username: root
password: test
- name: all
hostname: not-actually-mysql
username: root
password: test
- name: all
format: sql
{mariadb_mysql_dump_format_option}
hostname: not-actually-mysql
username: root
password: test
@@ -97,12 +94,21 @@ sqlite_databases:
return ruamel.yaml.YAML(typ='safe').load(config_yaml)
@pytest.mark.parametrize(
'postgresql_all_dump_format,mariadb_mysql_all_dump_format',
(
(None, None),
('custom', 'sql'),
),
)
def write_custom_restore_configuration(
source_directory,
config_path,
repository_path,
user_runtime_directory,
postgresql_dump_format='custom',
postgresql_all_dump_format=None,
mariadb_mysql_all_dump_format=None,
mongodb_dump_format='archive',
):
'''
+71
View File
@@ -0,0 +1,71 @@
import json
import os
import shutil
import subprocess
import sys
import tempfile
def generate_configuration(config_path, repository_path):
'''
Generate borgmatic configuration into a file at the config path, and update the defaults so as
to work for testing (including 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', f'- {config_path}')
.replace('- /etc', '- /mnt/lvolume/subdir')
.replace('- /var/log/syslog*', '')
+ 'encryption_passphrase: "test"\n'
+ 'lvm:\n'
+ ' lsblk_command: python3 /app/tests/end-to-end/commands/fake_lsblk.py\n'
+ ' lvcreate_command: python3 /app/tests/end-to-end/commands/fake_lvcreate.py\n'
+ ' lvremove_command: python3 /app/tests/end-to-end/commands/fake_lvremove.py\n'
+ ' lvs_command: python3 /app/tests/end-to-end/commands/fake_lvs.py\n'
+ ' mount_command: python3 /app/tests/end-to-end/commands/fake_mount.py\n'
+ ' umount_command: python3 /app/tests/end-to-end/commands/fake_umount.py\n'
)
config_file = open(config_path, 'w')
config_file.write(config)
config_file.close()
def test_lvm_create_and_list():
temporary_directory = tempfile.mkdtemp()
repository_path = os.path.join(temporary_directory, 'test.borg')
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 a create action to exercise LVM snapshotting and backup.
subprocess.check_call(f'borgmatic --config {config_path} create'.split(' '))
# List the resulting archive and assert that the snapshotted files are there.
output = subprocess.check_output(
f'borgmatic --config {config_path} list --archive latest'.split(' ')
).decode(sys.stdout.encoding)
assert 'mnt/lvolume/subdir/file.txt' in output
# Assert that the snapshot has been deleted.
assert not json.loads(
subprocess.check_output(
'python3 /app/tests/end-to-end/commands/fake_lvs.py --report-format json --options lv_name,lv_path --select'.split(
' '
)
+ ['lv_attr =~ ^s']
)
)['report'][0]['lv']
finally:
shutil.rmtree(temporary_directory)
+62
View File
@@ -0,0 +1,62 @@
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 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', f'- {config_path}')
.replace('- /etc', '- /pool/dataset/subdir')
.replace('- /var/log/syslog*', '')
+ 'encryption_passphrase: "test"\n'
+ 'zfs:\n'
+ ' zfs_command: python3 /app/tests/end-to-end/commands/fake_zfs.py\n'
+ ' mount_command: python3 /app/tests/end-to-end/commands/fake_mount.py\n'
+ ' umount_command: python3 /app/tests/end-to-end/commands/fake_umount.py'
)
config_file = open(config_path, 'w')
config_file.write(config)
config_file.close()
def test_zfs_create_and_list():
temporary_directory = tempfile.mkdtemp()
repository_path = os.path.join(temporary_directory, 'test.borg')
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 a create action to exercise ZFS snapshotting and backup.
subprocess.check_call(f'borgmatic --config {config_path} create'.split(' '))
# List the resulting archive and assert that the snapshotted files are there.
output = subprocess.check_output(
f'borgmatic --config {config_path} list --archive latest'.split(' ')
).decode(sys.stdout.encoding)
assert 'pool/dataset/subdir/file.txt' in output
# Assert that the snapshot has been deleted.
assert not subprocess.check_output(
'python3 /app/tests/end-to-end/commands/fake_zfs.py list -H -t snapshot'.split(' ')
)
finally:
shutil.rmtree(temporary_directory)
View File
@@ -2,14 +2,14 @@ import logging
from flexmock import flexmock
from borgmatic.hooks import apprise as module
from borgmatic.hooks.monitoring import apprise as module
def test_destroy_monitor_removes_apprise_handler():
logger = logging.getLogger()
original_handlers = list(logger.handlers)
module.borgmatic.hooks.logs.add_handler(
module.borgmatic.hooks.logs.Forgetful_buffering_handler(
module.borgmatic.hooks.monitoring.logs.add_handler(
module.borgmatic.hooks.monitoring.logs.Forgetful_buffering_handler(
identifier=module.HANDLER_IDENTIFIER, byte_capacity=100, log_level=1
)
)
@@ -2,14 +2,14 @@ import logging
from flexmock import flexmock
from borgmatic.hooks import healthchecks as module
from borgmatic.hooks.monitoring import healthchecks as module
def test_destroy_monitor_removes_healthchecks_handler():
logger = logging.getLogger()
original_handlers = list(logger.handlers)
module.borgmatic.hooks.logs.add_handler(
module.borgmatic.hooks.logs.Forgetful_buffering_handler(
module.borgmatic.hooks.monitoring.logs.add_handler(
module.borgmatic.hooks.monitoring.logs.Forgetful_buffering_handler(
identifier=module.HANDLER_IDENTIFIER, byte_capacity=100, log_level=1
)
)
@@ -3,7 +3,7 @@ import platform
from flexmock import flexmock
from borgmatic.hooks import loki as module
from borgmatic.hooks.monitoring import loki as module
def test_initialize_monitor_replaces_labels():
+149 -47
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(
@@ -904,6 +936,47 @@ def test_compare_spot_check_hashes_returns_paths_having_failing_hashes():
) == ('/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(),
log_prefix='repo',
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 +991,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(
@@ -959,7 +1032,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(
@@ -997,7 +1070,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(
@@ -1033,7 +1106,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(
@@ -1076,8 +1149,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'},
@@ -1178,7 +1251,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 +1369,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()
+326 -101
View File
@@ -1,7 +1,131 @@
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', Pattern_style.SHELL
).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', Pattern_style.SHELL
).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.EXCLUDE, Pattern_style.FNMATCH),
Pattern('/bar', Pattern_type.EXCLUDE, Pattern_style.FNMATCH),
Pattern('**/baz', Pattern_type.EXCLUDE, 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', Pattern_style.SHELL
).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', Pattern_style.SHELL
).and_return(Pattern('/bar'))
flexmock(module).should_receive('parse_pattern').with_args(
'R /baz', Pattern_style.SHELL
).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.EXCLUDE, Pattern_style.FNMATCH))
flexmock(module).should_receive('parse_pattern').with_args(
'- /bar', default_style=Pattern_style.FNMATCH
).and_return(Pattern('/bar', Pattern_type.EXCLUDE, Pattern_style.FNMATCH))
flexmock(module).should_receive('parse_pattern').with_args('# comment').never()
flexmock(module).should_receive('parse_pattern').with_args('').never()
flexmock(module).should_receive('parse_pattern').with_args(' ').never()
flexmock(module).should_receive('parse_pattern').with_args(
'- /baz', default_style=Pattern_style.FNMATCH
).and_return(Pattern('/baz', Pattern_type.EXCLUDE, Pattern_style.FNMATCH))
assert module.collect_patterns({'exclude_from': ['file1.txt', 'file2.txt']}) == (
Pattern('/foo', Pattern_type.EXCLUDE, Pattern_style.FNMATCH),
Pattern('/bar', Pattern_type.EXCLUDE, Pattern_style.FNMATCH),
Pattern('/baz', Pattern_type.EXCLUDE, Pattern_style.FNMATCH),
)
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 +146,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 +163,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 +196,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 +428,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 +471,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 +551,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(),
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+16
View File
@@ -45,6 +45,22 @@ def test_replace_temporary_subdirectory_with_glob_transforms_path():
)
def test_replace_temporary_subdirectory_with_glob_passes_through_non_matching_path():
assert (
module.replace_temporary_subdirectory_with_glob('/tmp/foo-aet8kn93/borgmatic')
== '/tmp/foo-aet8kn93/borgmatic'
)
def test_replace_temporary_subdirectory_with_glob_uses_custom_temporary_directory_prefix():
assert (
module.replace_temporary_subdirectory_with_glob(
'/tmp/.borgmatic-aet8kn93/borgmatic', temporary_directory_prefix='.borgmatic-'
)
== '/tmp/.borgmatic-*/borgmatic'
)
def test_runtime_directory_uses_config_option():
flexmock(module).should_receive('expand_user_in_path').replace_with(lambda path: path)
flexmock(module.os).should_receive('makedirs')
@@ -2,7 +2,7 @@ import sys
from flexmock import flexmock
from borgmatic.hooks import bootstrap as module
from borgmatic.hooks.data_source import bootstrap as module
def test_dump_data_sources_creates_manifest_file():
@@ -22,12 +22,12 @@ def test_dump_data_sources_creates_manifest_file():
).once()
module.dump_data_sources(
hook_config={},
hook_config=None,
config={},
log_prefix='test',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
patterns=[],
dry_run=False,
)
@@ -43,7 +43,7 @@ def test_dump_data_sources_with_store_config_files_false_does_not_create_manifes
log_prefix='test',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
patterns=[],
dry_run=True,
)
@@ -53,12 +53,12 @@ def test_dump_data_sources_with_dry_run_does_not_create_manifest_file():
flexmock(module.json).should_receive('dump').never()
module.dump_data_sources(
hook_config={},
hook_config=None,
config={},
log_prefix='test',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
patterns=[],
dry_run=True,
)
@@ -74,7 +74,7 @@ def test_remove_data_source_dumps_deletes_manifest_and_parent_directory():
flexmock(module.os).should_receive('rmdir').with_args('/run/borgmatic/bootstrap').once()
module.remove_data_source_dumps(
hook_config={},
hook_config=None,
config={},
log_prefix='test',
borgmatic_runtime_directory='/run/borgmatic',
@@ -91,7 +91,7 @@ def test_remove_data_source_dumps_with_dry_run_bails():
flexmock(module.os).should_receive('rmdir').never()
module.remove_data_source_dumps(
hook_config={},
hook_config=None,
config={},
log_prefix='test',
borgmatic_runtime_directory='/run/borgmatic',
@@ -110,7 +110,7 @@ def test_remove_data_source_dumps_swallows_manifest_file_not_found_error():
flexmock(module.os).should_receive('rmdir').with_args('/run/borgmatic/bootstrap').once()
module.remove_data_source_dumps(
hook_config={},
hook_config=None,
config={},
log_prefix='test',
borgmatic_runtime_directory='/run/borgmatic',
@@ -131,7 +131,7 @@ def test_remove_data_source_dumps_swallows_manifest_parent_directory_not_found_e
).once()
module.remove_data_source_dumps(
hook_config={},
hook_config=None,
config={},
log_prefix='test',
borgmatic_runtime_directory='/run/borgmatic',
+916
View File
@@ -0,0 +1,916 @@
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():
flexmock(module.borgmatic.execute).should_receive(
'execute_command_and_capture_output'
).and_return(
'''{
"filesystems": [
{
"target": "/mnt0",
"source": "/dev/loop0",
"fstype": "btrfs",
"options": "rw,relatime,ssd,space_cache=v2,subvolid=5,subvol=/"
},
{
"target": "/mnt1",
"source": "/dev/loop0",
"fstype": "btrfs",
"options": "rw,relatime,ssd,space_cache=v2,subvolid=5,subvol=/"
}
]
}
'''
)
assert module.get_filesystem_mount_points('findmnt') == ('/mnt0', '/mnt1')
def test_get_filesystem_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')
def test_get_filesystem_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')
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'
)
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_patterns_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_patterns'
).with_args(path, object).and_return((Pattern(path),))
for path in ('/two', '/three'):
flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive(
'get_contained_patterns'
).with_args(path, object).and_return(())
assert module.get_subvolumes(
'btrfs',
'findmnt',
patterns=[
Pattern('/one'),
Pattern('/four'),
Pattern('/five'),
Pattern('/six'),
Pattern('/mnt2'),
Pattern('/mnt3'),
],
) == (
module.Subvolume('/four', contained_patterns=(Pattern('/four'),)),
module.Subvolume('/one', contained_patterns=(Pattern('/one'),)),
)
def test_get_subvolumes_without_patterns_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'))
for path in ('/one', '/two', '/three', '/four'):
flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive(
'get_contained_patterns'
).with_args(path, object).and_return((Pattern(path),))
assert module.get_subvolumes('btrfs', 'findmnt') == (
module.Subvolume('/four', contained_patterns=(Pattern('/four'),)),
module.Subvolume('/one', contained_patterns=(Pattern('/one'),)),
module.Subvolume('/three', contained_patterns=(Pattern('/three'),)),
module.Subvolume('/two', contained_patterns=(Pattern('/two'),)),
)
@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_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(
'/mnt/subvol1/.borgmatic-1234/mnt/subvol1'
)
flexmock(module).should_receive('make_snapshot_path').with_args('/mnt/subvol2').and_return(
'/mnt/subvol2/.borgmatic-1234/mnt/subvol2'
)
flexmock(module).should_receive('snapshot_subvolume').with_args(
'btrfs', '/mnt/subvol1', '/mnt/subvol1/.borgmatic-1234/mnt/subvol1'
).once()
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_pattern').with_args(
'/mnt/subvol1'
).and_return(
Pattern(
'/mnt/subvol1/.borgmatic-1234/mnt/subvol1/.borgmatic-1234',
Pattern_type.EXCLUDE,
Pattern_style.FNMATCH,
)
)
flexmock(module).should_receive('make_snapshot_exclude_pattern').with_args(
'/mnt/subvol2'
).and_return(
Pattern(
'/mnt/subvol2/.borgmatic-1234/mnt/subvol2/.borgmatic-1234',
Pattern_type.EXCLUDE,
Pattern_style.FNMATCH,
)
)
flexmock(module).should_receive('make_borg_snapshot_pattern').with_args(
'/mnt/subvol1', object
).and_return(Pattern('/mnt/subvol1/.borgmatic-1234/mnt/subvol1'))
flexmock(module).should_receive('make_borg_snapshot_pattern').with_args(
'/mnt/subvol2', object
).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',
patterns=patterns,
dry_run=False,
)
== []
)
assert patterns == [
Pattern('/foo'),
Pattern('/mnt/subvol1/.borgmatic-1234/mnt/subvol1'),
Pattern(
'/mnt/subvol1/.borgmatic-1234/mnt/subvol1/.borgmatic-1234',
Pattern_type.EXCLUDE,
Pattern_style.FNMATCH,
),
Pattern('/mnt/subvol2/.borgmatic-1234/mnt/subvol2'),
Pattern(
'/mnt/subvol2/.borgmatic-1234/mnt/subvol2/.borgmatic-1234',
Pattern_type.EXCLUDE,
Pattern_style.FNMATCH,
),
]
assert config == {
'btrfs': {},
}
def test_dump_data_sources_uses_custom_btrfs_command_in_commands():
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_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').with_args(
'/usr/local/bin/btrfs', '/mnt/subvol1', '/mnt/subvol1/.borgmatic-1234/mnt/subvol1'
).once()
flexmock(module).should_receive('make_snapshot_exclude_pattern').with_args(
'/mnt/subvol1'
).and_return(
Pattern(
'/mnt/subvol1/.borgmatic-1234/mnt/subvol1/.borgmatic-1234',
Pattern_type.EXCLUDE,
Pattern_style.FNMATCH,
)
)
flexmock(module).should_receive('make_borg_snapshot_pattern').with_args(
'/mnt/subvol1', object
).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',
patterns=patterns,
dry_run=False,
)
== []
)
assert patterns == [
Pattern('/foo'),
Pattern('/mnt/subvol1/.borgmatic-1234/mnt/subvol1'),
Pattern(
'/mnt/subvol1/.borgmatic-1234/mnt/subvol1/.borgmatic-1234',
Pattern_type.EXCLUDE,
Pattern_style.FNMATCH,
),
]
assert config == {
'btrfs': {
'btrfs_command': '/usr/local/bin/btrfs',
},
}
def test_dump_data_sources_uses_custom_findmnt_command_in_commands():
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', patterns
).and_return(
(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'
)
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_pattern').with_args(
'/mnt/subvol1'
).and_return(
Pattern(
'/mnt/subvol1/.borgmatic-1234/mnt/subvol1/.borgmatic-1234',
Pattern_type.EXCLUDE,
Pattern_style.FNMATCH,
)
)
flexmock(module).should_receive('make_borg_snapshot_pattern').with_args(
'/mnt/subvol1', object
).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',
patterns=patterns,
dry_run=False,
)
== []
)
assert patterns == [
Pattern('/foo'),
Pattern('/mnt/subvol1/.borgmatic-1234/mnt/subvol1'),
Pattern(
'/mnt/subvol1/.borgmatic-1234/mnt/subvol1/.borgmatic-1234',
Pattern_type.EXCLUDE,
Pattern_style.FNMATCH,
),
]
assert config == {
'btrfs': {
'findmnt_command': '/usr/local/bin/findmnt',
},
}
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_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_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',
patterns=patterns,
dry_run=True,
)
== []
)
assert patterns == [Pattern('/foo'), Pattern('/mnt/subvol1')]
assert config == {'btrfs': {}}
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_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',
patterns=patterns,
dry_run=False,
)
== []
)
assert patterns == [Pattern('/foo'), Pattern('/mnt/subvol1')]
assert config == {'btrfs': {}}
def test_dump_data_sources_snapshots_adds_to_existing_exclude_patterns():
patterns = [Pattern('/foo'), Pattern('/mnt/subvol1')]
config = {'btrfs': {}, 'exclude_patterns': ['/bar']}
flexmock(module).should_receive('get_subvolumes').and_return(
(
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(
'/mnt/subvol1/.borgmatic-1234/mnt/subvol1'
)
flexmock(module).should_receive('make_snapshot_path').with_args('/mnt/subvol2').and_return(
'/mnt/subvol2/.borgmatic-1234/mnt/subvol2'
)
flexmock(module).should_receive('snapshot_subvolume').with_args(
'btrfs', '/mnt/subvol1', '/mnt/subvol1/.borgmatic-1234/mnt/subvol1'
).once()
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_pattern').with_args(
'/mnt/subvol1'
).and_return(
Pattern(
'/mnt/subvol1/.borgmatic-1234/mnt/subvol1/.borgmatic-1234',
Pattern_type.EXCLUDE,
Pattern_style.FNMATCH,
)
)
flexmock(module).should_receive('make_snapshot_exclude_pattern').with_args(
'/mnt/subvol2'
).and_return(
Pattern(
'/mnt/subvol2/.borgmatic-1234/mnt/subvol2/.borgmatic-1234',
Pattern_type.EXCLUDE,
Pattern_style.FNMATCH,
)
)
flexmock(module).should_receive('make_borg_snapshot_pattern').with_args(
'/mnt/subvol1', object
).and_return(Pattern('/mnt/subvol1/.borgmatic-1234/mnt/subvol1'))
flexmock(module).should_receive('make_borg_snapshot_pattern').with_args(
'/mnt/subvol2', object
).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',
patterns=patterns,
dry_run=False,
)
== []
)
assert patterns == [
Pattern('/foo'),
Pattern('/mnt/subvol1/.borgmatic-1234/mnt/subvol1'),
Pattern(
'/mnt/subvol1/.borgmatic-1234/mnt/subvol1/.borgmatic-1234',
Pattern_type.EXCLUDE,
Pattern_style.FNMATCH,
),
Pattern('/mnt/subvol2/.borgmatic-1234/mnt/subvol2'),
Pattern(
'/mnt/subvol2/.borgmatic-1234/mnt/subvol2/.borgmatic-1234',
Pattern_type.EXCLUDE,
Pattern_style.FNMATCH,
),
]
assert config == {
'btrfs': {},
'exclude_patterns': ['/bar'],
}
def test_remove_data_source_dumps_deletes_snapshots():
config = {'btrfs': {}}
flexmock(module).should_receive('get_subvolumes').and_return(
(
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(
'/mnt/subvol1/.borgmatic-1234/./mnt/subvol1'
)
flexmock(module).should_receive('make_snapshot_path').with_args('/mnt/subvol2').and_return(
'/mnt/subvol2/.borgmatic-1234/./mnt/subvol2'
)
flexmock(module.borgmatic.config.paths).should_receive(
'replace_temporary_subdirectory_with_glob'
).with_args(
'/mnt/subvol1/.borgmatic-1234/mnt/subvol1',
temporary_directory_prefix=module.BORGMATIC_SNAPSHOT_PREFIX,
).and_return(
'/mnt/subvol1/.borgmatic-*/mnt/subvol1'
)
flexmock(module.borgmatic.config.paths).should_receive(
'replace_temporary_subdirectory_with_glob'
).with_args(
'/mnt/subvol2/.borgmatic-1234/mnt/subvol2',
temporary_directory_prefix=module.BORGMATIC_SNAPSHOT_PREFIX,
).and_return(
'/mnt/subvol2/.borgmatic-*/mnt/subvol2'
)
flexmock(module.glob).should_receive('glob').with_args(
'/mnt/subvol1/.borgmatic-*/mnt/subvol1'
).and_return(
('/mnt/subvol1/.borgmatic-1234/mnt/subvol1', '/mnt/subvol1/.borgmatic-5678/mnt/subvol1')
)
flexmock(module.glob).should_receive('glob').with_args(
'/mnt/subvol2/.borgmatic-*/mnt/subvol2'
).and_return(
('/mnt/subvol2/.borgmatic-1234/mnt/subvol2', '/mnt/subvol2/.borgmatic-5678/mnt/subvol2')
)
flexmock(module.os.path).should_receive('isdir').with_args(
'/mnt/subvol1/.borgmatic-1234/mnt/subvol1'
).and_return(True)
flexmock(module.os.path).should_receive('isdir').with_args(
'/mnt/subvol1/.borgmatic-5678/mnt/subvol1'
).and_return(True)
flexmock(module.os.path).should_receive('isdir').with_args(
'/mnt/subvol2/.borgmatic-1234/mnt/subvol2'
).and_return(True)
flexmock(module.os.path).should_receive('isdir').with_args(
'/mnt/subvol2/.borgmatic-5678/mnt/subvol2'
).and_return(False)
flexmock(module).should_receive('delete_snapshot').with_args(
'btrfs', '/mnt/subvol1/.borgmatic-1234/mnt/subvol1'
).once()
flexmock(module).should_receive('delete_snapshot').with_args(
'btrfs', '/mnt/subvol1/.borgmatic-5678/mnt/subvol1'
).once()
flexmock(module).should_receive('delete_snapshot').with_args(
'btrfs', '/mnt/subvol2/.borgmatic-1234/mnt/subvol2'
).once()
flexmock(module).should_receive('delete_snapshot').with_args(
'btrfs', '/mnt/subvol2/.borgmatic-5678/mnt/subvol2'
).never()
flexmock(module.shutil).should_receive('rmtree').with_args(
'/mnt/subvol1/.borgmatic-1234'
).once()
flexmock(module.shutil).should_receive('rmtree').with_args(
'/mnt/subvol1/.borgmatic-5678'
).once()
flexmock(module.shutil).should_receive('rmtree').with_args(
'/mnt/subvol2/.borgmatic-1234'
).once()
flexmock(module.shutil).should_receive('rmtree').with_args(
'/mnt/subvol2/.borgmatic-5678'
).never()
module.remove_data_source_dumps(
hook_config=config['btrfs'],
config=config,
log_prefix='test',
borgmatic_runtime_directory='/run/borgmatic',
dry_run=False,
)
def test_remove_data_source_dumps_without_hook_configuration_bails():
flexmock(module).should_receive('get_subvolumes').never()
flexmock(module).should_receive('make_snapshot_path').never()
flexmock(module.borgmatic.config.paths).should_receive(
'replace_temporary_subdirectory_with_glob'
).never()
flexmock(module).should_receive('delete_snapshot').never()
flexmock(module.shutil).should_receive('rmtree').never()
module.remove_data_source_dumps(
hook_config=None,
config={'source_directories': '/mnt/subvolume'},
log_prefix='test',
borgmatic_runtime_directory='/run/borgmatic',
dry_run=False,
)
def test_remove_data_source_dumps_with_get_subvolumes_file_not_found_error_bails():
config = {'btrfs': {}}
flexmock(module).should_receive('get_subvolumes').and_raise(FileNotFoundError)
flexmock(module).should_receive('make_snapshot_path').never()
flexmock(module.borgmatic.config.paths).should_receive(
'replace_temporary_subdirectory_with_glob'
).never()
flexmock(module).should_receive('delete_snapshot').never()
flexmock(module.shutil).should_receive('rmtree').never()
module.remove_data_source_dumps(
hook_config=config['btrfs'],
config=config,
log_prefix='test',
borgmatic_runtime_directory='/run/borgmatic',
dry_run=False,
)
def test_remove_data_source_dumps_with_get_subvolumes_called_process_error_bails():
config = {'btrfs': {}}
flexmock(module).should_receive('get_subvolumes').and_raise(
module.subprocess.CalledProcessError(1, 'command', 'error')
)
flexmock(module).should_receive('make_snapshot_path').never()
flexmock(module.borgmatic.config.paths).should_receive(
'replace_temporary_subdirectory_with_glob'
).never()
flexmock(module).should_receive('delete_snapshot').never()
flexmock(module.shutil).should_receive('rmtree').never()
module.remove_data_source_dumps(
hook_config=config['btrfs'],
config=config,
log_prefix='test',
borgmatic_runtime_directory='/run/borgmatic',
dry_run=False,
)
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_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(
'/mnt/subvol1/.borgmatic-1234/./mnt/subvol1'
)
flexmock(module).should_receive('make_snapshot_path').with_args('/mnt/subvol2').and_return(
'/mnt/subvol2/.borgmatic-1234/./mnt/subvol2'
)
flexmock(module.borgmatic.config.paths).should_receive(
'replace_temporary_subdirectory_with_glob'
).with_args(
'/mnt/subvol1/.borgmatic-1234/mnt/subvol1',
temporary_directory_prefix=module.BORGMATIC_SNAPSHOT_PREFIX,
).and_return(
'/mnt/subvol1/.borgmatic-*/mnt/subvol1'
)
flexmock(module.borgmatic.config.paths).should_receive(
'replace_temporary_subdirectory_with_glob'
).with_args(
'/mnt/subvol2/.borgmatic-1234/mnt/subvol2',
temporary_directory_prefix=module.BORGMATIC_SNAPSHOT_PREFIX,
).and_return(
'/mnt/subvol2/.borgmatic-*/mnt/subvol2'
)
flexmock(module.glob).should_receive('glob').with_args(
'/mnt/subvol1/.borgmatic-*/mnt/subvol1'
).and_return(
('/mnt/subvol1/.borgmatic-1234/mnt/subvol1', '/mnt/subvol1/.borgmatic-5678/mnt/subvol1')
)
flexmock(module.glob).should_receive('glob').with_args(
'/mnt/subvol2/.borgmatic-*/mnt/subvol2'
).and_return(
('/mnt/subvol2/.borgmatic-1234/mnt/subvol2', '/mnt/subvol2/.borgmatic-5678/mnt/subvol2')
)
flexmock(module.os.path).should_receive('isdir').with_args(
'/mnt/subvol1/.borgmatic-1234/mnt/subvol1'
).and_return(True)
flexmock(module.os.path).should_receive('isdir').with_args(
'/mnt/subvol1/.borgmatic-5678/mnt/subvol1'
).and_return(True)
flexmock(module.os.path).should_receive('isdir').with_args(
'/mnt/subvol2/.borgmatic-1234/mnt/subvol2'
).and_return(True)
flexmock(module.os.path).should_receive('isdir').with_args(
'/mnt/subvol2/.borgmatic-5678/mnt/subvol2'
).and_return(False)
flexmock(module).should_receive('delete_snapshot').never()
flexmock(module.shutil).should_receive('rmtree').never()
module.remove_data_source_dumps(
hook_config=config['btrfs'],
config=config,
log_prefix='test',
borgmatic_runtime_directory='/run/borgmatic',
dry_run=True,
)
def test_remove_data_source_dumps_without_subvolumes_skips_deletes():
config = {'btrfs': {}}
flexmock(module).should_receive('get_subvolumes').and_return(())
flexmock(module).should_receive('make_snapshot_path').never()
flexmock(module.borgmatic.config.paths).should_receive(
'replace_temporary_subdirectory_with_glob'
).never()
flexmock(module).should_receive('delete_snapshot').never()
flexmock(module.shutil).should_receive('rmtree').never()
module.remove_data_source_dumps(
hook_config=config['btrfs'],
config=config,
log_prefix='test',
borgmatic_runtime_directory='/run/borgmatic',
dry_run=False,
)
def test_remove_data_source_without_snapshots_skips_deletes():
config = {'btrfs': {}}
flexmock(module).should_receive('get_subvolumes').and_return(
(
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(
'/mnt/subvol1/.borgmatic-1234/./mnt/subvol1'
)
flexmock(module).should_receive('make_snapshot_path').with_args('/mnt/subvol2').and_return(
'/mnt/subvol2/.borgmatic-1234/./mnt/subvol2'
)
flexmock(module.borgmatic.config.paths).should_receive(
'replace_temporary_subdirectory_with_glob'
).with_args(
'/mnt/subvol1/.borgmatic-1234/mnt/subvol1',
temporary_directory_prefix=module.BORGMATIC_SNAPSHOT_PREFIX,
).and_return(
'/mnt/subvol1/.borgmatic-*/mnt/subvol1'
)
flexmock(module.borgmatic.config.paths).should_receive(
'replace_temporary_subdirectory_with_glob'
).with_args(
'/mnt/subvol2/.borgmatic-1234/mnt/subvol2',
temporary_directory_prefix=module.BORGMATIC_SNAPSHOT_PREFIX,
).and_return(
'/mnt/subvol2/.borgmatic-*/mnt/subvol2'
)
flexmock(module.glob).should_receive('glob').and_return(())
flexmock(module.os.path).should_receive('isdir').never()
flexmock(module).should_receive('delete_snapshot').never()
flexmock(module.shutil).should_receive('rmtree').never()
module.remove_data_source_dumps(
hook_config=config['btrfs'],
config=config,
log_prefix='test',
borgmatic_runtime_directory='/run/borgmatic',
dry_run=False,
)
def test_remove_data_source_dumps_with_delete_snapshot_file_not_found_error_bails():
config = {'btrfs': {}}
flexmock(module).should_receive('get_subvolumes').and_return(
(
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(
'/mnt/subvol1/.borgmatic-1234/./mnt/subvol1'
)
flexmock(module).should_receive('make_snapshot_path').with_args('/mnt/subvol2').and_return(
'/mnt/subvol2/.borgmatic-1234/./mnt/subvol2'
)
flexmock(module.borgmatic.config.paths).should_receive(
'replace_temporary_subdirectory_with_glob'
).with_args(
'/mnt/subvol1/.borgmatic-1234/mnt/subvol1',
temporary_directory_prefix=module.BORGMATIC_SNAPSHOT_PREFIX,
).and_return(
'/mnt/subvol1/.borgmatic-*/mnt/subvol1'
)
flexmock(module.borgmatic.config.paths).should_receive(
'replace_temporary_subdirectory_with_glob'
).with_args(
'/mnt/subvol2/.borgmatic-1234/mnt/subvol2',
temporary_directory_prefix=module.BORGMATIC_SNAPSHOT_PREFIX,
).and_return(
'/mnt/subvol2/.borgmatic-*/mnt/subvol2'
)
flexmock(module.glob).should_receive('glob').with_args(
'/mnt/subvol1/.borgmatic-*/mnt/subvol1'
).and_return(
('/mnt/subvol1/.borgmatic-1234/mnt/subvol1', '/mnt/subvol1/.borgmatic-5678/mnt/subvol1')
)
flexmock(module.glob).should_receive('glob').with_args(
'/mnt/subvol2/.borgmatic-*/mnt/subvol2'
).and_return(
('/mnt/subvol2/.borgmatic-1234/mnt/subvol2', '/mnt/subvol2/.borgmatic-5678/mnt/subvol2')
)
flexmock(module.os.path).should_receive('isdir').with_args(
'/mnt/subvol1/.borgmatic-1234/mnt/subvol1'
).and_return(True)
flexmock(module.os.path).should_receive('isdir').with_args(
'/mnt/subvol1/.borgmatic-5678/mnt/subvol1'
).and_return(True)
flexmock(module.os.path).should_receive('isdir').with_args(
'/mnt/subvol2/.borgmatic-1234/mnt/subvol2'
).and_return(True)
flexmock(module.os.path).should_receive('isdir').with_args(
'/mnt/subvol2/.borgmatic-5678/mnt/subvol2'
).and_return(False)
flexmock(module).should_receive('delete_snapshot').and_raise(FileNotFoundError)
flexmock(module.shutil).should_receive('rmtree').never()
module.remove_data_source_dumps(
hook_config=config['btrfs'],
config=config,
log_prefix='test',
borgmatic_runtime_directory='/run/borgmatic',
dry_run=False,
)
def test_remove_data_source_dumps_with_delete_snapshot_called_process_error_bails():
config = {'btrfs': {}}
flexmock(module).should_receive('get_subvolumes').and_return(
(
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(
'/mnt/subvol1/.borgmatic-1234/./mnt/subvol1'
)
flexmock(module).should_receive('make_snapshot_path').with_args('/mnt/subvol2').and_return(
'/mnt/subvol2/.borgmatic-1234/./mnt/subvol2'
)
flexmock(module.borgmatic.config.paths).should_receive(
'replace_temporary_subdirectory_with_glob'
).with_args(
'/mnt/subvol1/.borgmatic-1234/mnt/subvol1',
temporary_directory_prefix=module.BORGMATIC_SNAPSHOT_PREFIX,
).and_return(
'/mnt/subvol1/.borgmatic-*/mnt/subvol1'
)
flexmock(module.borgmatic.config.paths).should_receive(
'replace_temporary_subdirectory_with_glob'
).with_args(
'/mnt/subvol2/.borgmatic-1234/mnt/subvol2',
temporary_directory_prefix=module.BORGMATIC_SNAPSHOT_PREFIX,
).and_return(
'/mnt/subvol2/.borgmatic-*/mnt/subvol2'
)
flexmock(module.glob).should_receive('glob').with_args(
'/mnt/subvol1/.borgmatic-*/mnt/subvol1'
).and_return(
('/mnt/subvol1/.borgmatic-1234/mnt/subvol1', '/mnt/subvol1/.borgmatic-5678/mnt/subvol1')
)
flexmock(module.glob).should_receive('glob').with_args(
'/mnt/subvol2/.borgmatic-*/mnt/subvol2'
).and_return(
('/mnt/subvol2/.borgmatic-1234/mnt/subvol2', '/mnt/subvol2/.borgmatic-5678/mnt/subvol2')
)
flexmock(module.os.path).should_receive('isdir').with_args(
'/mnt/subvol1/.borgmatic-1234/mnt/subvol1'
).and_return(True)
flexmock(module.os.path).should_receive('isdir').with_args(
'/mnt/subvol1/.borgmatic-5678/mnt/subvol1'
).and_return(True)
flexmock(module.os.path).should_receive('isdir').with_args(
'/mnt/subvol2/.borgmatic-1234/mnt/subvol2'
).and_return(True)
flexmock(module.os.path).should_receive('isdir').with_args(
'/mnt/subvol2/.borgmatic-5678/mnt/subvol2'
).and_return(False)
flexmock(module).should_receive('delete_snapshot').and_raise(
module.subprocess.CalledProcessError(1, 'command', 'error')
)
flexmock(module.shutil).should_receive('rmtree').never()
module.remove_data_source_dumps(
hook_config=config['btrfs'],
config=config,
log_prefix='test',
borgmatic_runtime_directory='/run/borgmatic',
dry_run=False,
)
@@ -1,7 +1,7 @@
import pytest
from flexmock import flexmock
from borgmatic.hooks import dump as module
from borgmatic.hooks.data_source import dump as module
def test_make_data_source_dump_path_joins_arguments():
@@ -15,6 +15,13 @@ def test_make_data_source_dump_filename_uses_name_and_hostname():
)
def test_make_data_source_dump_filename_uses_name_and_hostname_and_port():
assert (
module.make_data_source_dump_filename('databases', 'test', 'hostname', 1234)
== 'databases/hostname:1234/test'
)
def test_make_data_source_dump_filename_without_hostname_defaults_to_localhost():
assert module.make_data_source_dump_filename('databases', 'test') == 'databases/localhost/test'
File diff suppressed because it is too large Load Diff
@@ -3,7 +3,7 @@ import logging
import pytest
from flexmock import flexmock
from borgmatic.hooks import mariadb as module
from borgmatic.hooks.data_source import mariadb as module
def test_database_names_to_dump_passes_through_name():
@@ -80,7 +80,7 @@ def test_dump_data_sources_dumps_each_database():
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
patterns=[],
dry_run=False,
)
== processes
@@ -111,7 +111,7 @@ def test_dump_data_sources_dumps_with_password():
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
patterns=[],
dry_run=False,
) == [process]
@@ -137,7 +137,7 @@ def test_dump_data_sources_dumps_all_databases_at_once():
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
patterns=[],
dry_run=False,
) == [process]
@@ -166,7 +166,7 @@ def test_dump_data_sources_dumps_all_databases_separately_when_format_configured
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
patterns=[],
dry_run=False,
)
== processes
@@ -478,7 +478,7 @@ def test_dump_data_sources_errors_for_missing_all_databases():
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
patterns=[],
dry_run=False,
)
@@ -498,7 +498,7 @@ def test_dump_data_sources_does_not_error_for_missing_all_databases_with_dry_run
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
patterns=[],
dry_run=True,
)
== []
@@ -2,7 +2,7 @@ import logging
from flexmock import flexmock
from borgmatic.hooks import mongodb as module
from borgmatic.hooks.data_source import mongodb as module
def test_use_streaming_true_for_any_non_directory_format_databases():
@@ -48,7 +48,7 @@ def test_dump_data_sources_runs_mongodump_for_each_database():
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
patterns=[],
dry_run=False,
)
== processes
@@ -71,7 +71,7 @@ def test_dump_data_sources_with_dry_run_skips_mongodump():
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
patterns=[],
dry_run=True,
)
== []
@@ -110,7 +110,7 @@ def test_dump_data_sources_runs_mongodump_with_hostname_and_port():
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
patterns=[],
dry_run=False,
) == [process]
@@ -156,7 +156,7 @@ def test_dump_data_sources_runs_mongodump_with_username_and_password():
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
patterns=[],
dry_run=False,
) == [process]
@@ -182,7 +182,7 @@ def test_dump_data_sources_runs_mongodump_with_directory_format():
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
patterns=[],
dry_run=False,
)
== []
@@ -218,7 +218,7 @@ def test_dump_data_sources_runs_mongodump_with_options():
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
patterns=[],
dry_run=False,
) == [process]
@@ -244,7 +244,7 @@ def test_dump_data_sources_runs_mongodumpall_for_all_databases():
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
patterns=[],
dry_run=False,
) == [process]
@@ -3,7 +3,7 @@ import logging
import pytest
from flexmock import flexmock
from borgmatic.hooks import mysql as module
from borgmatic.hooks.data_source import mysql as module
def test_database_names_to_dump_passes_through_name():
@@ -80,7 +80,7 @@ def test_dump_data_sources_dumps_each_database():
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
patterns=[],
dry_run=False,
)
== processes
@@ -111,7 +111,7 @@ def test_dump_data_sources_dumps_with_password():
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
patterns=[],
dry_run=False,
) == [process]
@@ -137,7 +137,7 @@ def test_dump_data_sources_dumps_all_databases_at_once():
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
patterns=[],
dry_run=False,
) == [process]
@@ -166,7 +166,7 @@ def test_dump_data_sources_dumps_all_databases_separately_when_format_configured
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
patterns=[],
dry_run=False,
)
== processes
@@ -476,7 +476,7 @@ def test_dump_data_sources_errors_for_missing_all_databases():
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
patterns=[],
dry_run=False,
)
@@ -496,7 +496,7 @@ def test_dump_data_sources_does_not_error_for_missing_all_databases_with_dry_run
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
patterns=[],
dry_run=True,
)
== []
@@ -3,7 +3,7 @@ import logging
import pytest
from flexmock import flexmock
from borgmatic.hooks import postgresql as module
from borgmatic.hooks.data_source import postgresql as module
def test_make_extra_environment_maps_options_to_environment():
@@ -258,7 +258,7 @@ def test_dump_data_sources_runs_pg_dump_for_each_database():
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
patterns=[],
dry_run=False,
)
== processes
@@ -278,7 +278,7 @@ def test_dump_data_sources_raises_when_no_database_names_to_dump():
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
patterns=[],
dry_run=False,
)
@@ -295,7 +295,7 @@ def test_dump_data_sources_does_not_raise_when_no_database_names_to_dump():
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
patterns=[],
dry_run=True,
) == []
@@ -321,7 +321,7 @@ def test_dump_data_sources_with_duplicate_dump_skips_pg_dump():
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
patterns=[],
dry_run=False,
)
== []
@@ -349,7 +349,7 @@ def test_dump_data_sources_with_dry_run_skips_pg_dump():
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
patterns=[],
dry_run=True,
)
== []
@@ -395,7 +395,7 @@ def test_dump_data_sources_runs_pg_dump_with_hostname_and_port():
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
patterns=[],
dry_run=False,
) == [process]
@@ -439,7 +439,7 @@ def test_dump_data_sources_runs_pg_dump_with_username_and_password():
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
patterns=[],
dry_run=False,
) == [process]
@@ -483,7 +483,7 @@ def test_dump_data_sources_with_username_injection_attack_gets_escaped():
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
patterns=[],
dry_run=False,
) == [process]
@@ -523,7 +523,7 @@ def test_dump_data_sources_runs_pg_dump_with_directory_format():
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
patterns=[],
dry_run=False,
)
== []
@@ -566,7 +566,7 @@ def test_dump_data_sources_runs_pg_dump_with_options():
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
patterns=[],
dry_run=False,
) == [process]
@@ -596,7 +596,7 @@ def test_dump_data_sources_runs_pg_dumpall_for_all_databases():
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
patterns=[],
dry_run=False,
) == [process]
@@ -638,7 +638,7 @@ def test_dump_data_sources_runs_non_default_pg_dump():
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
patterns=[],
dry_run=False,
) == [process]
@@ -0,0 +1,34 @@
from borgmatic.borg.pattern import Pattern
from borgmatic.hooks.data_source import snapshot as module
def test_get_contained_patterns_without_candidates_returns_empty():
assert module.get_contained_patterns('/mnt', {}) == ()
def test_get_contained_patterns_with_self_candidate_returns_self():
candidates = {Pattern('/foo'), Pattern('/mnt'), Pattern('/bar')}
assert module.get_contained_patterns('/mnt', candidates) == (Pattern('/mnt'),)
assert candidates == {Pattern('/foo'), Pattern('/bar')}
def test_get_contained_patterns_with_self_candidate_and_caret_prefix_returns_self():
candidates = {Pattern('^/foo'), Pattern('^/mnt'), Pattern('^/bar')}
assert module.get_contained_patterns('/mnt', candidates) == (Pattern('^/mnt'),)
assert candidates == {Pattern('^/foo'), Pattern('^/bar')}
def test_get_contained_patterns_with_child_candidate_returns_child():
candidates = {Pattern('/foo'), Pattern('/mnt/subdir'), Pattern('/bar')}
assert module.get_contained_patterns('/mnt', candidates) == (Pattern('/mnt/subdir'),)
assert candidates == {Pattern('/foo'), Pattern('/bar')}
def test_get_contained_patterns_with_grandchild_candidate_returns_child():
candidates = {Pattern('/foo'), Pattern('/mnt/sub/dir'), Pattern('/bar')}
assert module.get_contained_patterns('/mnt', candidates) == (Pattern('/mnt/sub/dir'),)
assert candidates == {Pattern('/foo'), Pattern('/bar')}
@@ -2,7 +2,7 @@ import logging
from flexmock import flexmock
from borgmatic.hooks import sqlite as module
from borgmatic.hooks.data_source import sqlite as module
def test_use_streaming_true_for_any_databases():
@@ -33,7 +33,7 @@ def test_dump_data_sources_logs_and_skips_if_dump_already_exists():
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
patterns=[],
dry_run=False,
)
== []
@@ -64,7 +64,7 @@ def test_dump_data_sources_dumps_each_database():
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
patterns=[],
dry_run=False,
)
== processes
@@ -102,7 +102,7 @@ def test_dump_data_sources_with_path_injection_attack_gets_escaped():
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
patterns=[],
dry_run=False,
)
== processes
@@ -131,7 +131,7 @@ def test_dump_data_sources_with_non_existent_path_warns_and_dumps_database():
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
patterns=[],
dry_run=False,
)
== processes
@@ -162,7 +162,7 @@ def test_dump_data_sources_with_name_all_warns_and_dumps_all_databases():
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
patterns=[],
dry_run=False,
)
== processes
@@ -187,7 +187,7 @@ def test_dump_data_sources_does_not_dump_if_dry_run():
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
patterns=[],
dry_run=True,
)
== []
@@ -1,62 +1,137 @@
import os
import pytest
from flexmock import flexmock
import borgmatic.execute
from borgmatic.hooks import zfs as module
from borgmatic.borg.pattern import Pattern, Pattern_style, Pattern_type
from borgmatic.hooks.data_source import zfs as module
def test_get_datasets_to_backup_filters_datasets_by_source_directories():
flexmock(borgmatic.execute).should_receive('execute_command_and_capture_output').and_return(
def test_get_datasets_to_backup_filters_datasets_by_patterns():
flexmock(module.borgmatic.execute).should_receive(
'execute_command_and_capture_output'
).and_return(
'dataset\t/dataset\t-\nother\t/other\t-',
)
flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive(
'get_contained_patterns'
).with_args('/dataset', object).and_return((Pattern('/dataset'),))
flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive(
'get_contained_patterns'
).with_args('/other', object).and_return(())
assert module.get_datasets_to_backup(
'zfs', source_directories=('/foo', '/dataset', '/bar')
) == (('dataset', '/dataset'),)
'zfs',
patterns=(
Pattern('/foo'),
Pattern('/dataset'),
Pattern('/bar'),
),
) == (
module.Dataset(
name='dataset',
mount_point='/dataset',
contained_patterns=(Pattern('/dataset'),),
),
)
def test_get_datasets_to_backup_filters_datasets_by_user_property():
flexmock(borgmatic.execute).should_receive('execute_command_and_capture_output').and_return(
flexmock(module.borgmatic.execute).should_receive(
'execute_command_and_capture_output'
).and_return(
'dataset\t/dataset\tauto\nother\t/other\t-',
)
flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive(
'get_contained_patterns'
).with_args('/dataset', object).and_return(())
flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive(
'get_contained_patterns'
).with_args('/other', object).and_return(())
assert module.get_datasets_to_backup('zfs', source_directories=('/foo', '/bar')) == (
('dataset', '/dataset'),
assert module.get_datasets_to_backup('zfs', patterns=(Pattern('/foo'), Pattern('/bar'))) == (
module.Dataset(
name='dataset',
mount_point='/dataset',
auto_backup=True,
contained_patterns=(Pattern('/dataset'),),
),
)
def test_get_datasets_to_backup_with_invalid_list_output_raises():
flexmock(borgmatic.execute).should_receive('execute_command_and_capture_output').and_return(
flexmock(module.borgmatic.execute).should_receive(
'execute_command_and_capture_output'
).and_return(
'dataset',
)
flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive(
'get_contained_patterns'
).never()
with pytest.raises(ValueError, match='zfs'):
module.get_datasets_to_backup('zfs', source_directories=('/foo', '/bar'))
module.get_datasets_to_backup('zfs', patterns=(Pattern('/foo'), Pattern('/bar')))
def test_get_get_all_datasets_does_not_filter_datasets():
flexmock(borgmatic.execute).should_receive('execute_command_and_capture_output').and_return(
'dataset\t/dataset\nother\t/other',
def test_get_all_dataset_mount_points_does_not_filter_datasets():
flexmock(module.borgmatic.execute).should_receive(
'execute_command_and_capture_output'
).and_return(
'/dataset\n/other',
)
flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive(
'get_contained_patterns'
).and_return((Pattern('/dataset'),))
assert module.get_all_datasets('zfs') == (
('dataset', '/dataset'),
('other', '/other'),
assert module.get_all_dataset_mount_points('zfs') == (
('/dataset'),
('/other'),
)
def test_get_all_datasets_with_invalid_list_output_raises():
flexmock(borgmatic.execute).should_receive('execute_command_and_capture_output').and_return(
'dataset',
)
with pytest.raises(ValueError, match='zfs'):
module.get_all_datasets('zfs')
@pytest.mark.parametrize(
'pattern,expected_pattern',
(
(
Pattern('/foo/bar/baz'),
Pattern('/run/borgmatic/zfs_snapshots/./foo/bar/baz'),
),
(Pattern('/foo/bar'), Pattern('/run/borgmatic/zfs_snapshots/./foo/bar')),
(
Pattern('^/foo/bar', Pattern_type.INCLUDE, Pattern_style.REGULAR_EXPRESSION),
Pattern(
'^/run/borgmatic/zfs_snapshots/./foo/bar',
Pattern_type.INCLUDE,
Pattern_style.REGULAR_EXPRESSION,
),
),
(
Pattern('/foo/bar', Pattern_type.INCLUDE, Pattern_style.REGULAR_EXPRESSION),
Pattern(
'/run/borgmatic/zfs_snapshots/./foo/bar',
Pattern_type.INCLUDE,
Pattern_style.REGULAR_EXPRESSION,
),
),
(Pattern('/foo'), Pattern('/run/borgmatic/zfs_snapshots/./foo')),
(Pattern('/'), Pattern('/run/borgmatic/zfs_snapshots/./')),
),
)
def test_make_borg_snapshot_pattern_includes_slashdot_hack_and_stripped_pattern_path(
pattern, expected_pattern
):
assert module.make_borg_snapshot_pattern(pattern, '/run/borgmatic') == expected_pattern
def test_dump_data_sources_snapshots_and_mounts_and_updates_source_directories():
def test_dump_data_sources_snapshots_and_mounts_and_updates_patterns():
flexmock(module).should_receive('get_datasets_to_backup').and_return(
(('dataset', '/mnt/dataset'),)
(
flexmock(
name='dataset',
mount_point='/mnt/dataset',
contained_patterns=(Pattern('/mnt/dataset/subdir'),),
)
)
)
flexmock(module.os).should_receive('getpid').and_return(1234)
full_snapshot_name = 'dataset@borgmatic-1234'
@@ -70,7 +145,10 @@ def test_dump_data_sources_snapshots_and_mounts_and_updates_source_directories()
full_snapshot_name,
module.os.path.normpath(snapshot_mount_path),
).once()
source_directories = ['/mnt/dataset']
flexmock(module).should_receive('make_borg_snapshot_pattern').with_args(
Pattern('/mnt/dataset/subdir'), '/run/borgmatic'
).and_return(Pattern('/run/borgmatic/zfs_snapshots/./mnt/dataset/subdir'))
patterns = [Pattern('/mnt/dataset/subdir')]
assert (
module.dump_data_sources(
@@ -79,18 +157,47 @@ def test_dump_data_sources_snapshots_and_mounts_and_updates_source_directories()
log_prefix='test',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=source_directories,
patterns=patterns,
dry_run=False,
)
== []
)
assert source_directories == [snapshot_mount_path]
assert patterns == [Pattern(os.path.join(snapshot_mount_path, 'subdir'))]
def test_dump_data_sources_with_no_datasets_skips_snapshots():
flexmock(module).should_receive('get_datasets_to_backup').and_return(())
flexmock(module.os).should_receive('getpid').and_return(1234)
flexmock(module).should_receive('snapshot_dataset').never()
flexmock(module).should_receive('mount_snapshot').never()
patterns = [Pattern('/mnt/dataset')]
assert (
module.dump_data_sources(
hook_config={},
config={'patterns': flexmock(), 'zfs': {}},
log_prefix='test',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
patterns=patterns,
dry_run=False,
)
== []
)
assert patterns == [Pattern('/mnt/dataset')]
def test_dump_data_sources_uses_custom_commands():
flexmock(module).should_receive('get_datasets_to_backup').and_return(
(('dataset', '/mnt/dataset'),)
(
flexmock(
name='dataset',
mount_point='/mnt/dataset',
contained_patterns=(Pattern('/mnt/dataset/subdir'),),
)
)
)
flexmock(module.os).should_receive('getpid').and_return(1234)
full_snapshot_name = 'dataset@borgmatic-1234'
@@ -104,7 +211,10 @@ def test_dump_data_sources_uses_custom_commands():
full_snapshot_name,
module.os.path.normpath(snapshot_mount_path),
).once()
source_directories = ['/mnt/dataset']
flexmock(module).should_receive('make_borg_snapshot_pattern').with_args(
Pattern('/mnt/dataset/subdir'), '/run/borgmatic'
).and_return(Pattern('/run/borgmatic/zfs_snapshots/./mnt/dataset/subdir'))
patterns = [Pattern('/mnt/dataset/subdir')]
hook_config = {
'zfs_command': '/usr/local/bin/zfs',
'mount_command': '/usr/local/bin/mount',
@@ -114,48 +224,93 @@ def test_dump_data_sources_uses_custom_commands():
module.dump_data_sources(
hook_config=hook_config,
config={
'source_directories': source_directories,
'patterns': flexmock(),
'zfs': hook_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 == [snapshot_mount_path]
assert patterns == [Pattern(os.path.join(snapshot_mount_path, 'subdir'))]
def test_dump_data_sources_with_dry_run_skips_commands_and_does_not_touch_source_directories():
def test_dump_data_sources_with_dry_run_skips_commands_and_does_not_touch_patterns():
flexmock(module).should_receive('get_datasets_to_backup').and_return(
(('dataset', '/mnt/dataset'),)
(flexmock(name='dataset', mount_point='/mnt/dataset'),)
)
flexmock(module.os).should_receive('getpid').and_return(1234)
flexmock(module).should_receive('snapshot_dataset').never()
flexmock(module).should_receive('mount_snapshot').never()
source_directories = ['/mnt/dataset']
patterns = [Pattern('/mnt/dataset')]
assert (
module.dump_data_sources(
hook_config={},
config={'source_directories': '/mnt/dataset', 'zfs': {}},
config={'patterns': ('R /mnt/dataset',), 'zfs': {}},
log_prefix='test',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=source_directories,
patterns=patterns,
dry_run=True,
)
== []
)
assert source_directories == ['/mnt/dataset']
assert patterns == [Pattern('/mnt/dataset')]
def test_dump_data_sources_ignores_mismatch_between_given_patterns_and_contained_patterns():
flexmock(module).should_receive('get_datasets_to_backup').and_return(
(
flexmock(
name='dataset',
mount_point='/mnt/dataset',
contained_patterns=(Pattern('/mnt/dataset/subdir'),),
)
)
)
flexmock(module.os).should_receive('getpid').and_return(1234)
full_snapshot_name = 'dataset@borgmatic-1234'
flexmock(module).should_receive('snapshot_dataset').with_args(
'zfs',
full_snapshot_name,
).once()
snapshot_mount_path = '/run/borgmatic/zfs_snapshots/./mnt/dataset'
flexmock(module).should_receive('mount_snapshot').with_args(
'mount',
full_snapshot_name,
module.os.path.normpath(snapshot_mount_path),
).once()
flexmock(module).should_receive('make_borg_snapshot_pattern').with_args(
Pattern('/mnt/dataset/subdir'), '/run/borgmatic'
).and_return(Pattern('/run/borgmatic/zfs_snapshots/./mnt/dataset/subdir'))
patterns = [Pattern('/hmm')]
assert (
module.dump_data_sources(
hook_config={},
config={'patterns': ('R /mnt/dataset',), 'zfs': {}},
log_prefix='test',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
patterns=patterns,
dry_run=False,
)
== []
)
assert patterns == [Pattern('/hmm'), Pattern(os.path.join(snapshot_mount_path, 'subdir'))]
def test_get_all_snapshots_parses_list_output():
flexmock(borgmatic.execute).should_receive('execute_command_and_capture_output').and_return(
flexmock(module.borgmatic.execute).should_receive(
'execute_command_and_capture_output'
).and_return(
'dataset1@borgmatic-1234\ndataset2@borgmatic-4567',
)
@@ -163,7 +318,7 @@ def test_get_all_snapshots_parses_list_output():
def test_remove_data_source_dumps_unmounts_and_destroys_snapshots():
flexmock(module).should_receive('get_all_datasets').and_return((('dataset', '/mnt/dataset'),))
flexmock(module).should_receive('get_all_dataset_mount_points').and_return(('/mnt/dataset',))
flexmock(module.borgmatic.config.paths).should_receive(
'replace_temporary_subdirectory_with_glob'
).and_return('/run/borgmatic')
@@ -190,7 +345,7 @@ def test_remove_data_source_dumps_unmounts_and_destroys_snapshots():
def test_remove_data_source_dumps_use_custom_commands():
flexmock(module).should_receive('get_all_datasets').and_return((('dataset', '/mnt/dataset'),))
flexmock(module).should_receive('get_all_dataset_mount_points').and_return(('/mnt/dataset',))
flexmock(module.borgmatic.config.paths).should_receive(
'replace_temporary_subdirectory_with_glob'
).and_return('/run/borgmatic')
@@ -217,8 +372,23 @@ def test_remove_data_source_dumps_use_custom_commands():
)
def test_remove_data_source_dumps_bails_for_missing_hook_configuration():
flexmock(module).should_receive('get_all_dataset_mount_points').never()
flexmock(module.borgmatic.config.paths).should_receive(
'replace_temporary_subdirectory_with_glob'
).never()
module.remove_data_source_dumps(
hook_config=None,
config={'source_directories': '/mnt/dataset'},
log_prefix='test',
borgmatic_runtime_directory='/run/borgmatic',
dry_run=False,
)
def test_remove_data_source_dumps_bails_for_missing_zfs_command():
flexmock(module).should_receive('get_all_datasets').and_raise(FileNotFoundError)
flexmock(module).should_receive('get_all_dataset_mount_points').and_raise(FileNotFoundError)
flexmock(module.borgmatic.config.paths).should_receive(
'replace_temporary_subdirectory_with_glob'
).never()
@@ -234,7 +404,7 @@ def test_remove_data_source_dumps_bails_for_missing_zfs_command():
def test_remove_data_source_dumps_bails_for_zfs_command_error():
flexmock(module).should_receive('get_all_datasets').and_raise(
flexmock(module).should_receive('get_all_dataset_mount_points').and_raise(
module.subprocess.CalledProcessError(1, 'wtf')
)
flexmock(module.borgmatic.config.paths).should_receive(
@@ -252,7 +422,7 @@ def test_remove_data_source_dumps_bails_for_zfs_command_error():
def test_remove_data_source_dumps_bails_for_missing_umount_command():
flexmock(module).should_receive('get_all_datasets').and_return((('dataset', '/mnt/dataset'),))
flexmock(module).should_receive('get_all_dataset_mount_points').and_return(('/mnt/dataset',))
flexmock(module.borgmatic.config.paths).should_receive(
'replace_temporary_subdirectory_with_glob'
).and_return('/run/borgmatic')
@@ -276,7 +446,7 @@ def test_remove_data_source_dumps_bails_for_missing_umount_command():
def test_remove_data_source_dumps_bails_for_umount_command_error():
flexmock(module).should_receive('get_all_datasets').and_return((('dataset', '/mnt/dataset'),))
flexmock(module).should_receive('get_all_dataset_mount_points').and_return(('/mnt/dataset',))
flexmock(module.borgmatic.config.paths).should_receive(
'replace_temporary_subdirectory_with_glob'
).and_return('/run/borgmatic')
@@ -300,7 +470,7 @@ def test_remove_data_source_dumps_bails_for_umount_command_error():
def test_remove_data_source_dumps_skips_unmount_snapshot_directories_that_are_not_actually_directories():
flexmock(module).should_receive('get_all_datasets').and_return((('dataset', '/mnt/dataset'),))
flexmock(module).should_receive('get_all_dataset_mount_points').and_return(('/mnt/dataset',))
flexmock(module.borgmatic.config.paths).should_receive(
'replace_temporary_subdirectory_with_glob'
).and_return('/run/borgmatic')
@@ -325,7 +495,7 @@ def test_remove_data_source_dumps_skips_unmount_snapshot_directories_that_are_no
def test_remove_data_source_dumps_skips_unmount_snapshot_mount_paths_that_are_not_actually_directories():
flexmock(module).should_receive('get_all_datasets').and_return((('dataset', '/mnt/dataset'),))
flexmock(module).should_receive('get_all_dataset_mount_points').and_return(('/mnt/dataset',))
flexmock(module.borgmatic.config.paths).should_receive(
'replace_temporary_subdirectory_with_glob'
).and_return('/run/borgmatic')
@@ -354,8 +524,38 @@ def test_remove_data_source_dumps_skips_unmount_snapshot_mount_paths_that_are_no
)
def test_remove_data_source_dumps_skips_unmount_snapshot_mount_paths_after_rmtree_succeeds():
flexmock(module).should_receive('get_all_dataset_mount_points').and_return(('/mnt/dataset',))
flexmock(module.borgmatic.config.paths).should_receive(
'replace_temporary_subdirectory_with_glob'
).and_return('/run/borgmatic')
flexmock(module.glob).should_receive('glob').replace_with(lambda path: [path])
flexmock(module.os.path).should_receive('isdir').with_args(
'/run/borgmatic/zfs_snapshots'
).and_return(True)
flexmock(module.os.path).should_receive('isdir').with_args(
'/run/borgmatic/zfs_snapshots/mnt/dataset'
).and_return(True).and_return(False)
flexmock(module.shutil).should_receive('rmtree')
flexmock(module).should_receive('unmount_snapshot').never()
flexmock(module).should_receive('get_all_snapshots').and_return(
('dataset@borgmatic-1234', 'dataset@other', 'other@other', 'invalid')
)
flexmock(module).should_receive('destroy_snapshot').with_args(
'zfs', 'dataset@borgmatic-1234'
).once()
module.remove_data_source_dumps(
hook_config={},
config={'source_directories': '/mnt/dataset', 'zfs': {}},
log_prefix='test',
borgmatic_runtime_directory='/run/borgmatic',
dry_run=False,
)
def test_remove_data_source_dumps_with_dry_run_skips_unmount_and_destroy():
flexmock(module).should_receive('get_all_datasets').and_return((('dataset', '/mnt/dataset'),))
flexmock(module).should_receive('get_all_dataset_mount_points').and_return(('/mnt/dataset',))
flexmock(module.borgmatic.config.paths).should_receive(
'replace_temporary_subdirectory_with_glob'
).and_return('/run/borgmatic')
@@ -2,8 +2,8 @@ import apprise
from apprise import NotifyFormat, NotifyType
from flexmock import flexmock
import borgmatic.hooks.monitor
from borgmatic.hooks import apprise as module
import borgmatic.hooks.monitoring.monitor
from borgmatic.hooks.monitoring import apprise as module
TOPIC = 'borgmatic-unit-testing'
@@ -18,7 +18,7 @@ def mock_apprise():
def test_initialize_monitor_with_send_logs_false_does_not_add_handler():
flexmock(module.borgmatic.hooks.logs).should_receive('add_handler').never()
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('add_handler').never()
module.initialize_monitor(
hook_config={'send_logs': False},
@@ -31,12 +31,14 @@ def test_initialize_monitor_with_send_logs_false_does_not_add_handler():
def test_initialize_monitor_with_send_logs_true_adds_handler_with_default_log_size_limit():
truncation_indicator_length = 4
flexmock(module.borgmatic.hooks.logs).should_receive('Forgetful_buffering_handler').with_args(
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive(
'Forgetful_buffering_handler'
).with_args(
module.HANDLER_IDENTIFIER,
module.DEFAULT_LOGS_SIZE_LIMIT_BYTES - truncation_indicator_length,
1,
).once()
flexmock(module.borgmatic.hooks.logs).should_receive('add_handler').once()
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('add_handler').once()
module.initialize_monitor(
hook_config={'send_logs': True},
@@ -49,12 +51,14 @@ def test_initialize_monitor_with_send_logs_true_adds_handler_with_default_log_si
def test_initialize_monitor_without_send_logs_adds_handler_with_default_log_size_limit():
truncation_indicator_length = 4
flexmock(module.borgmatic.hooks.logs).should_receive('Forgetful_buffering_handler').with_args(
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive(
'Forgetful_buffering_handler'
).with_args(
module.HANDLER_IDENTIFIER,
module.DEFAULT_LOGS_SIZE_LIMIT_BYTES - truncation_indicator_length,
1,
).once()
flexmock(module.borgmatic.hooks.logs).should_receive('add_handler').once()
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('add_handler').once()
module.initialize_monitor(
hook_config={},
@@ -66,8 +70,8 @@ def test_initialize_monitor_without_send_logs_adds_handler_with_default_log_size
def test_ping_monitor_respects_dry_run():
flexmock(module.borgmatic.hooks.logs).should_receive('get_handler')
flexmock(module.borgmatic.hooks.logs).should_receive(
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('get_handler')
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive(
'format_buffered_logs_for_payload'
).and_return('loggy log')
mock_apprise().should_receive('notify').never()
@@ -76,30 +80,32 @@ def test_ping_monitor_respects_dry_run():
{'services': [{'url': f'ntfys://{TOPIC}', 'label': 'ntfys'}]},
{},
'config.yaml',
borgmatic.hooks.monitor.State.FAIL,
borgmatic.hooks.monitoring.monitor.State.FAIL,
monitoring_log_level=1,
dry_run=True,
)
def test_ping_monitor_with_no_states_does_not_notify():
flexmock(module.borgmatic.hooks.logs).should_receive('get_handler').never()
flexmock(module.borgmatic.hooks.logs).should_receive('format_buffered_logs_for_payload').never()
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('get_handler').never()
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive(
'format_buffered_logs_for_payload'
).never()
mock_apprise().should_receive('notify').never()
module.ping_monitor(
{'services': [{'url': f'ntfys://{TOPIC}', 'label': 'ntfys'}], 'states': []},
{},
'config.yaml',
borgmatic.hooks.monitor.State.FAIL,
borgmatic.hooks.monitoring.monitor.State.FAIL,
monitoring_log_level=1,
dry_run=True,
)
def test_ping_monitor_notifies_fail_by_default():
flexmock(module.borgmatic.hooks.logs).should_receive('get_handler')
flexmock(module.borgmatic.hooks.logs).should_receive(
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('get_handler')
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive(
'format_buffered_logs_for_payload'
).and_return('')
mock_apprise().should_receive('notify').with_args(
@@ -109,7 +115,7 @@ def test_ping_monitor_notifies_fail_by_default():
notify_type=NotifyType.FAILURE,
).once()
for state in borgmatic.hooks.monitor.State:
for state in borgmatic.hooks.monitoring.monitor.State:
module.ping_monitor(
{'services': [{'url': f'ntfys://{TOPIC}', 'label': 'ntfys'}]},
{},
@@ -121,8 +127,8 @@ def test_ping_monitor_notifies_fail_by_default():
def test_ping_monitor_with_logs_appends_logs_to_body():
flexmock(module.borgmatic.hooks.logs).should_receive('get_handler')
flexmock(module.borgmatic.hooks.logs).should_receive(
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('get_handler')
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive(
'format_buffered_logs_for_payload'
).and_return('loggy log')
mock_apprise().should_receive('notify').with_args(
@@ -132,7 +138,7 @@ def test_ping_monitor_with_logs_appends_logs_to_body():
notify_type=NotifyType.FAILURE,
).once()
for state in borgmatic.hooks.monitor.State:
for state in borgmatic.hooks.monitoring.monitor.State:
module.ping_monitor(
{'services': [{'url': f'ntfys://{TOPIC}', 'label': 'ntfys'}]},
{},
@@ -144,8 +150,8 @@ def test_ping_monitor_with_logs_appends_logs_to_body():
def test_ping_monitor_with_finish_default_config_notifies():
flexmock(module.borgmatic.hooks.logs).should_receive('get_handler')
flexmock(module.borgmatic.hooks.logs).should_receive(
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('get_handler')
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive(
'format_buffered_logs_for_payload'
).and_return('')
mock_apprise().should_receive('notify').with_args(
@@ -159,15 +165,17 @@ def test_ping_monitor_with_finish_default_config_notifies():
{'services': [{'url': f'ntfys://{TOPIC}', 'label': 'ntfys'}], 'states': ['finish']},
{},
'config.yaml',
borgmatic.hooks.monitor.State.FINISH,
borgmatic.hooks.monitoring.monitor.State.FINISH,
monitoring_log_level=1,
dry_run=False,
)
def test_ping_monitor_with_start_default_config_notifies():
flexmock(module.borgmatic.hooks.logs).should_receive('get_handler').never()
flexmock(module.borgmatic.hooks.logs).should_receive('format_buffered_logs_for_payload').never()
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('get_handler').never()
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive(
'format_buffered_logs_for_payload'
).never()
mock_apprise().should_receive('notify').with_args(
title='A borgmatic START event happened',
body='A borgmatic START event happened',
@@ -179,15 +187,15 @@ def test_ping_monitor_with_start_default_config_notifies():
{'services': [{'url': f'ntfys://{TOPIC}', 'label': 'ntfys'}], 'states': ['start']},
{},
'config.yaml',
borgmatic.hooks.monitor.State.START,
borgmatic.hooks.monitoring.monitor.State.START,
monitoring_log_level=1,
dry_run=False,
)
def test_ping_monitor_with_fail_default_config_notifies():
flexmock(module.borgmatic.hooks.logs).should_receive('get_handler')
flexmock(module.borgmatic.hooks.logs).should_receive(
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('get_handler')
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive(
'format_buffered_logs_for_payload'
).and_return('')
mock_apprise().should_receive('notify').with_args(
@@ -201,15 +209,15 @@ def test_ping_monitor_with_fail_default_config_notifies():
{'services': [{'url': f'ntfys://{TOPIC}', 'label': 'ntfys'}], 'states': ['fail']},
{},
'config.yaml',
borgmatic.hooks.monitor.State.FAIL,
borgmatic.hooks.monitoring.monitor.State.FAIL,
monitoring_log_level=1,
dry_run=False,
)
def test_ping_monitor_with_log_default_config_notifies():
flexmock(module.borgmatic.hooks.logs).should_receive('get_handler')
flexmock(module.borgmatic.hooks.logs).should_receive(
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('get_handler')
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive(
'format_buffered_logs_for_payload'
).and_return('')
mock_apprise().should_receive('notify').with_args(
@@ -223,15 +231,15 @@ def test_ping_monitor_with_log_default_config_notifies():
{'services': [{'url': f'ntfys://{TOPIC}', 'label': 'ntfys'}], 'states': ['log']},
{},
'config.yaml',
borgmatic.hooks.monitor.State.LOG,
borgmatic.hooks.monitoring.monitor.State.LOG,
monitoring_log_level=1,
dry_run=False,
)
def test_ping_monitor_passes_through_custom_message_title():
flexmock(module.borgmatic.hooks.logs).should_receive('get_handler')
flexmock(module.borgmatic.hooks.logs).should_receive(
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('get_handler')
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive(
'format_buffered_logs_for_payload'
).and_return('')
mock_apprise().should_receive('notify').with_args(
@@ -249,15 +257,15 @@ def test_ping_monitor_passes_through_custom_message_title():
},
{},
'config.yaml',
borgmatic.hooks.monitor.State.FAIL,
borgmatic.hooks.monitoring.monitor.State.FAIL,
monitoring_log_level=1,
dry_run=False,
)
def test_ping_monitor_passes_through_custom_message_body():
flexmock(module.borgmatic.hooks.logs).should_receive('get_handler')
flexmock(module.borgmatic.hooks.logs).should_receive(
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('get_handler')
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive(
'format_buffered_logs_for_payload'
).and_return('')
mock_apprise().should_receive('notify').with_args(
@@ -275,15 +283,15 @@ def test_ping_monitor_passes_through_custom_message_body():
},
{},
'config.yaml',
borgmatic.hooks.monitor.State.FAIL,
borgmatic.hooks.monitoring.monitor.State.FAIL,
monitoring_log_level=1,
dry_run=False,
)
def test_ping_monitor_passes_through_custom_message_body_and_appends_logs():
flexmock(module.borgmatic.hooks.logs).should_receive('get_handler')
flexmock(module.borgmatic.hooks.logs).should_receive(
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('get_handler')
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive(
'format_buffered_logs_for_payload'
).and_return('loggy log')
mock_apprise().should_receive('notify').with_args(
@@ -301,15 +309,15 @@ def test_ping_monitor_passes_through_custom_message_body_and_appends_logs():
},
{},
'config.yaml',
borgmatic.hooks.monitor.State.FAIL,
borgmatic.hooks.monitoring.monitor.State.FAIL,
monitoring_log_level=1,
dry_run=False,
)
def test_ping_monitor_pings_multiple_services():
flexmock(module.borgmatic.hooks.logs).should_receive('get_handler')
flexmock(module.borgmatic.hooks.logs).should_receive(
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('get_handler')
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive(
'format_buffered_logs_for_payload'
).and_return('')
mock_apprise().should_receive('add').with_args([f'ntfys://{TOPIC}', f'ntfy://{TOPIC}']).once()
@@ -323,36 +331,38 @@ def test_ping_monitor_pings_multiple_services():
},
{},
'config.yaml',
borgmatic.hooks.monitor.State.FAIL,
borgmatic.hooks.monitoring.monitor.State.FAIL,
monitoring_log_level=1,
dry_run=False,
)
def test_ping_monitor_logs_info_for_no_services():
flexmock(module.borgmatic.hooks.logs).should_receive('get_handler').never()
flexmock(module.borgmatic.hooks.logs).should_receive('format_buffered_logs_for_payload').never()
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('get_handler').never()
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive(
'format_buffered_logs_for_payload'
).never()
flexmock(module.logger).should_receive('info').once()
module.ping_monitor(
{'services': []},
{},
'config.yaml',
borgmatic.hooks.monitor.State.FAIL,
borgmatic.hooks.monitoring.monitor.State.FAIL,
monitoring_log_level=1,
dry_run=False,
)
def test_ping_monitor_logs_warning_when_notify_fails():
flexmock(module.borgmatic.hooks.logs).should_receive('get_handler')
flexmock(module.borgmatic.hooks.logs).should_receive(
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('get_handler')
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive(
'format_buffered_logs_for_payload'
).and_return('')
mock_apprise().should_receive('notify').and_return(False)
flexmock(module.logger).should_receive('warning').once()
for state in borgmatic.hooks.monitor.State:
for state in borgmatic.hooks.monitoring.monitor.State:
module.ping_monitor(
{'services': [{'url': f'ntfys://{TOPIC}', 'label': 'ntfys'}]},
{},
@@ -364,7 +374,7 @@ def test_ping_monitor_logs_warning_when_notify_fails():
def test_destroy_monitor_does_not_raise():
flexmock(module.borgmatic.hooks.logs).should_receive('remove_handler')
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('remove_handler')
module.destroy_monitor(
hook_config={},
@@ -1,6 +1,6 @@
from flexmock import flexmock
from borgmatic.hooks import cronhub as module
from borgmatic.hooks.monitoring import cronhub as module
def test_ping_monitor_rewrites_ping_url_for_start_state():
@@ -1,6 +1,6 @@
from flexmock import flexmock
from borgmatic.hooks import cronitor as module
from borgmatic.hooks.monitoring import cronitor as module
def test_ping_monitor_hits_ping_url_for_start_state():
@@ -1,6 +1,6 @@
from flexmock import flexmock
from borgmatic.hooks import healthchecks as module
from borgmatic.hooks.monitoring import healthchecks as module
def mock_logger():
@@ -15,9 +15,11 @@ def test_initialize_monitor_creates_log_handler_with_ping_body_limit():
monitoring_log_level = 1
mock_logger()
flexmock(module.borgmatic.hooks.logs).should_receive('Forgetful_buffering_handler').with_args(
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive(
'Forgetful_buffering_handler'
).with_args(
module.HANDLER_IDENTIFIER,
ping_body_limit - len(module.borgmatic.hooks.logs.PAYLOAD_TRUNCATION_INDICATOR),
ping_body_limit - len(module.borgmatic.hooks.monitoring.logs.PAYLOAD_TRUNCATION_INDICATOR),
monitoring_log_level,
).once()
@@ -30,10 +32,12 @@ def test_initialize_monitor_creates_log_handler_with_default_ping_body_limit():
monitoring_log_level = 1
mock_logger()
flexmock(module.borgmatic.hooks.logs).should_receive('Forgetful_buffering_handler').with_args(
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive(
'Forgetful_buffering_handler'
).with_args(
module.HANDLER_IDENTIFIER,
module.DEFAULT_PING_BODY_LIMIT_BYTES
- len(module.borgmatic.hooks.logs.PAYLOAD_TRUNCATION_INDICATOR),
- len(module.borgmatic.hooks.monitoring.logs.PAYLOAD_TRUNCATION_INDICATOR),
monitoring_log_level,
).once()
@@ -45,9 +49,9 @@ def test_initialize_monitor_creates_log_handler_with_zero_ping_body_limit():
monitoring_log_level = 1
mock_logger()
flexmock(module.borgmatic.hooks.logs).should_receive('Forgetful_buffering_handler').with_args(
module.HANDLER_IDENTIFIER, ping_body_limit, monitoring_log_level
).once()
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive(
'Forgetful_buffering_handler'
).with_args(module.HANDLER_IDENTIFIER, ping_body_limit, monitoring_log_level).once()
module.initialize_monitor(
{'ping_body_limit': ping_body_limit}, {}, 'test.yaml', monitoring_log_level, dry_run=False
@@ -56,7 +60,9 @@ def test_initialize_monitor_creates_log_handler_with_zero_ping_body_limit():
def test_initialize_monitor_creates_log_handler_when_send_logs_true():
mock_logger()
flexmock(module.borgmatic.hooks.logs).should_receive('Forgetful_buffering_handler').once()
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive(
'Forgetful_buffering_handler'
).once()
module.initialize_monitor(
{'send_logs': True}, {}, 'test.yaml', monitoring_log_level=1, dry_run=False
@@ -65,7 +71,9 @@ def test_initialize_monitor_creates_log_handler_when_send_logs_true():
def test_initialize_monitor_bails_when_send_logs_false():
mock_logger()
flexmock(module.borgmatic.hooks.logs).should_receive('Forgetful_buffering_handler').never()
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive(
'Forgetful_buffering_handler'
).never()
module.initialize_monitor(
{'send_logs': False}, {}, 'test.yaml', monitoring_log_level=1, dry_run=False
@@ -73,7 +81,9 @@ def test_initialize_monitor_bails_when_send_logs_false():
def test_ping_monitor_hits_ping_url_for_start_state():
flexmock(module.borgmatic.hooks.logs).should_receive('Forgetful_buffering_handler').never()
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive(
'Forgetful_buffering_handler'
).never()
hook_config = {'ping_url': 'https://example.com'}
flexmock(module.requests).should_receive('post').with_args(
'https://example.com/start', data=''.encode('utf-8'), verify=True
@@ -92,8 +102,8 @@ def test_ping_monitor_hits_ping_url_for_start_state():
def test_ping_monitor_hits_ping_url_for_finish_state():
hook_config = {'ping_url': 'https://example.com'}
payload = 'data'
flexmock(module.borgmatic.hooks.logs).should_receive('get_handler')
flexmock(module.borgmatic.hooks.logs).should_receive(
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('get_handler')
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive(
'format_buffered_logs_for_payload'
).and_return(payload)
flexmock(module.requests).should_receive('post').with_args(
@@ -113,8 +123,8 @@ def test_ping_monitor_hits_ping_url_for_finish_state():
def test_ping_monitor_hits_ping_url_for_fail_state():
hook_config = {'ping_url': 'https://example.com'}
payload = 'data'
flexmock(module.borgmatic.hooks.logs).should_receive('get_handler')
flexmock(module.borgmatic.hooks.logs).should_receive(
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('get_handler')
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive(
'format_buffered_logs_for_payload'
).and_return(payload)
flexmock(module.requests).should_receive('post').with_args(
@@ -134,8 +144,8 @@ def test_ping_monitor_hits_ping_url_for_fail_state():
def test_ping_monitor_hits_ping_url_for_log_state():
hook_config = {'ping_url': 'https://example.com'}
payload = 'data'
flexmock(module.borgmatic.hooks.logs).should_receive('get_handler')
flexmock(module.borgmatic.hooks.logs).should_receive(
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('get_handler')
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive(
'format_buffered_logs_for_payload'
).and_return(payload)
flexmock(module.requests).should_receive('post').with_args(
@@ -155,8 +165,8 @@ def test_ping_monitor_hits_ping_url_for_log_state():
def test_ping_monitor_with_ping_uuid_hits_corresponding_url():
hook_config = {'ping_url': 'abcd-efgh-ijkl-mnop'}
payload = 'data'
flexmock(module.borgmatic.hooks.logs).should_receive('get_handler')
flexmock(module.borgmatic.hooks.logs).should_receive(
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('get_handler')
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive(
'format_buffered_logs_for_payload'
).and_return(payload)
flexmock(module.requests).should_receive('post').with_args(
@@ -178,8 +188,8 @@ def test_ping_monitor_with_ping_uuid_hits_corresponding_url():
def test_ping_monitor_skips_ssl_verification_when_verify_tls_false():
hook_config = {'ping_url': 'https://example.com', 'verify_tls': False}
payload = 'data'
flexmock(module.borgmatic.hooks.logs).should_receive('get_handler')
flexmock(module.borgmatic.hooks.logs).should_receive(
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('get_handler')
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive(
'format_buffered_logs_for_payload'
).and_return(payload)
flexmock(module.requests).should_receive('post').with_args(
@@ -199,8 +209,8 @@ def test_ping_monitor_skips_ssl_verification_when_verify_tls_false():
def test_ping_monitor_executes_ssl_verification_when_verify_tls_true():
hook_config = {'ping_url': 'https://example.com', 'verify_tls': True}
payload = 'data'
flexmock(module.borgmatic.hooks.logs).should_receive('get_handler')
flexmock(module.borgmatic.hooks.logs).should_receive(
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('get_handler')
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive(
'format_buffered_logs_for_payload'
).and_return(payload)
flexmock(module.requests).should_receive('post').with_args(
@@ -218,7 +228,9 @@ def test_ping_monitor_executes_ssl_verification_when_verify_tls_true():
def test_ping_monitor_dry_run_does_not_hit_ping_url():
flexmock(module.borgmatic.hooks.logs).should_receive('Forgetful_buffering_handler').never()
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive(
'Forgetful_buffering_handler'
).never()
hook_config = {'ping_url': 'https://example.com'}
flexmock(module.requests).should_receive('post').never()
@@ -233,7 +245,9 @@ def test_ping_monitor_dry_run_does_not_hit_ping_url():
def test_ping_monitor_does_not_hit_ping_url_when_states_not_matching():
flexmock(module.borgmatic.hooks.logs).should_receive('Forgetful_buffering_handler').never()
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive(
'Forgetful_buffering_handler'
).never()
hook_config = {'ping_url': 'https://example.com', 'states': ['finish']}
flexmock(module.requests).should_receive('post').never()
@@ -248,7 +262,9 @@ def test_ping_monitor_does_not_hit_ping_url_when_states_not_matching():
def test_ping_monitor_hits_ping_url_when_states_matching():
flexmock(module.borgmatic.hooks.logs).should_receive('Forgetful_buffering_handler').never()
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive(
'Forgetful_buffering_handler'
).never()
hook_config = {'ping_url': 'https://example.com', 'states': ['start', 'finish']}
flexmock(module.requests).should_receive('post').with_args(
'https://example.com/start', data=''.encode('utf-8'), verify=True
@@ -265,7 +281,9 @@ def test_ping_monitor_hits_ping_url_when_states_matching():
def test_ping_monitor_adds_create_query_parameter_when_create_slug_true():
flexmock(module.borgmatic.hooks.logs).should_receive('Forgetful_buffering_handler').never()
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive(
'Forgetful_buffering_handler'
).never()
hook_config = {'ping_url': 'https://example.com', 'create_slug': True}
flexmock(module.requests).should_receive('post').with_args(
'https://example.com/start?create=1', data=''.encode('utf-8'), verify=True
@@ -282,7 +300,9 @@ def test_ping_monitor_adds_create_query_parameter_when_create_slug_true():
def test_ping_monitor_does_not_add_create_query_parameter_when_create_slug_false():
flexmock(module.borgmatic.hooks.logs).should_receive('Forgetful_buffering_handler').never()
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive(
'Forgetful_buffering_handler'
).never()
hook_config = {'ping_url': 'https://example.com', 'create_slug': False}
flexmock(module.requests).should_receive('post').with_args(
'https://example.com/start', data=''.encode('utf-8'), verify=True
@@ -334,7 +354,9 @@ def test_ping_monitor_issues_warning_when_ping_url_is_uuid_and_create_slug_true(
def test_ping_monitor_with_connection_error_logs_warning():
flexmock(module.borgmatic.hooks.logs).should_receive('Forgetful_buffering_handler').never()
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive(
'Forgetful_buffering_handler'
).never()
hook_config = {'ping_url': 'https://example.com'}
flexmock(module.requests).should_receive('post').with_args(
'https://example.com/start', data=''.encode('utf-8'), verify=True
@@ -352,7 +374,9 @@ def test_ping_monitor_with_connection_error_logs_warning():
def test_ping_monitor_with_other_error_logs_warning():
flexmock(module.borgmatic.hooks.logs).should_receive('Forgetful_buffering_handler').never()
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive(
'Forgetful_buffering_handler'
).never()
hook_config = {'ping_url': 'https://example.com'}
response = flexmock(ok=False)
response.should_receive('raise_for_status').and_raise(
@@ -1,7 +1,7 @@
import pytest
from flexmock import flexmock
from borgmatic.hooks import logs as module
from borgmatic.hooks.monitoring import logs as module
def test_forgetful_buffering_handler_emit_collects_log_records():
@@ -3,7 +3,7 @@ import json
import requests
from flexmock import flexmock
from borgmatic.hooks import loki as module
from borgmatic.hooks.monitoring import loki as module
def test_loki_log_buffer_add_value_gets_raw():
@@ -2,8 +2,8 @@ from enum import Enum
from flexmock import flexmock
import borgmatic.hooks.monitor
from borgmatic.hooks import ntfy as module
import borgmatic.hooks.monitoring.monitor
from borgmatic.hooks.monitoring import ntfy as module
default_base_url = 'https://ntfy.sh'
custom_base_url = 'https://ntfy.example.com'
@@ -38,7 +38,7 @@ def test_ping_monitor_minimal_config_hits_hosted_ntfy_on_fail():
hook_config = {'topic': topic}
flexmock(module.requests).should_receive('post').with_args(
f'{default_base_url}/{topic}',
headers=return_default_message_headers(borgmatic.hooks.monitor.State.FAIL),
headers=return_default_message_headers(borgmatic.hooks.monitoring.monitor.State.FAIL),
auth=None,
).and_return(flexmock(ok=True)).once()
@@ -46,7 +46,7 @@ def test_ping_monitor_minimal_config_hits_hosted_ntfy_on_fail():
hook_config,
{},
'config.yaml',
borgmatic.hooks.monitor.State.FAIL,
borgmatic.hooks.monitoring.monitor.State.FAIL,
monitoring_log_level=1,
dry_run=False,
)
@@ -59,7 +59,7 @@ def test_ping_monitor_with_access_token_hits_hosted_ntfy_on_fail():
}
flexmock(module.requests).should_receive('post').with_args(
f'{default_base_url}/{topic}',
headers=return_default_message_headers(borgmatic.hooks.monitor.State.FAIL),
headers=return_default_message_headers(borgmatic.hooks.monitoring.monitor.State.FAIL),
auth=module.requests.auth.HTTPBasicAuth('', 'abc123'),
).and_return(flexmock(ok=True)).once()
@@ -67,7 +67,7 @@ def test_ping_monitor_with_access_token_hits_hosted_ntfy_on_fail():
hook_config,
{},
'config.yaml',
borgmatic.hooks.monitor.State.FAIL,
borgmatic.hooks.monitoring.monitor.State.FAIL,
monitoring_log_level=1,
dry_run=False,
)
@@ -82,7 +82,7 @@ def test_ping_monitor_with_username_password_and_access_token_ignores_username_p
}
flexmock(module.requests).should_receive('post').with_args(
f'{default_base_url}/{topic}',
headers=return_default_message_headers(borgmatic.hooks.monitor.State.FAIL),
headers=return_default_message_headers(borgmatic.hooks.monitoring.monitor.State.FAIL),
auth=module.requests.auth.HTTPBasicAuth('', 'abc123'),
).and_return(flexmock(ok=True)).once()
flexmock(module.logger).should_receive('warning').once()
@@ -91,7 +91,7 @@ def test_ping_monitor_with_username_password_and_access_token_ignores_username_p
hook_config,
{},
'config.yaml',
borgmatic.hooks.monitor.State.FAIL,
borgmatic.hooks.monitoring.monitor.State.FAIL,
monitoring_log_level=1,
dry_run=False,
)
@@ -105,7 +105,7 @@ def test_ping_monitor_with_username_password_hits_hosted_ntfy_on_fail():
}
flexmock(module.requests).should_receive('post').with_args(
f'{default_base_url}/{topic}',
headers=return_default_message_headers(borgmatic.hooks.monitor.State.FAIL),
headers=return_default_message_headers(borgmatic.hooks.monitoring.monitor.State.FAIL),
auth=module.requests.auth.HTTPBasicAuth('testuser', 'fakepassword'),
).and_return(flexmock(ok=True)).once()
@@ -113,7 +113,7 @@ def test_ping_monitor_with_username_password_hits_hosted_ntfy_on_fail():
hook_config,
{},
'config.yaml',
borgmatic.hooks.monitor.State.FAIL,
borgmatic.hooks.monitoring.monitor.State.FAIL,
monitoring_log_level=1,
dry_run=False,
)
@@ -123,7 +123,7 @@ def test_ping_monitor_with_password_but_no_username_warns():
hook_config = {'topic': topic, 'password': 'fakepassword'}
flexmock(module.requests).should_receive('post').with_args(
f'{default_base_url}/{topic}',
headers=return_default_message_headers(borgmatic.hooks.monitor.State.FAIL),
headers=return_default_message_headers(borgmatic.hooks.monitoring.monitor.State.FAIL),
auth=None,
).and_return(flexmock(ok=True)).once()
flexmock(module.logger).should_receive('warning').once()
@@ -132,7 +132,7 @@ def test_ping_monitor_with_password_but_no_username_warns():
hook_config,
{},
'config.yaml',
borgmatic.hooks.monitor.State.FAIL,
borgmatic.hooks.monitoring.monitor.State.FAIL,
monitoring_log_level=1,
dry_run=False,
)
@@ -142,7 +142,7 @@ def test_ping_monitor_with_username_but_no_password_warns():
hook_config = {'topic': topic, 'username': 'testuser'}
flexmock(module.requests).should_receive('post').with_args(
f'{default_base_url}/{topic}',
headers=return_default_message_headers(borgmatic.hooks.monitor.State.FAIL),
headers=return_default_message_headers(borgmatic.hooks.monitoring.monitor.State.FAIL),
auth=None,
).and_return(flexmock(ok=True)).once()
flexmock(module.logger).should_receive('warning').once()
@@ -151,7 +151,7 @@ def test_ping_monitor_with_username_but_no_password_warns():
hook_config,
{},
'config.yaml',
borgmatic.hooks.monitor.State.FAIL,
borgmatic.hooks.monitoring.monitor.State.FAIL,
monitoring_log_level=1,
dry_run=False,
)
@@ -165,7 +165,7 @@ def test_ping_monitor_minimal_config_does_not_hit_hosted_ntfy_on_start():
hook_config,
{},
'config.yaml',
borgmatic.hooks.monitor.State.START,
borgmatic.hooks.monitoring.monitor.State.START,
monitoring_log_level=1,
dry_run=False,
)
@@ -179,7 +179,7 @@ def test_ping_monitor_minimal_config_does_not_hit_hosted_ntfy_on_finish():
hook_config,
{},
'config.yaml',
borgmatic.hooks.monitor.State.FINISH,
borgmatic.hooks.monitoring.monitor.State.FINISH,
monitoring_log_level=1,
dry_run=False,
)
@@ -189,7 +189,7 @@ def test_ping_monitor_minimal_config_hits_selfhosted_ntfy_on_fail():
hook_config = {'topic': topic, 'server': custom_base_url}
flexmock(module.requests).should_receive('post').with_args(
f'{custom_base_url}/{topic}',
headers=return_default_message_headers(borgmatic.hooks.monitor.State.FAIL),
headers=return_default_message_headers(borgmatic.hooks.monitoring.monitor.State.FAIL),
auth=None,
).and_return(flexmock(ok=True)).once()
@@ -197,7 +197,7 @@ def test_ping_monitor_minimal_config_hits_selfhosted_ntfy_on_fail():
hook_config,
{},
'config.yaml',
borgmatic.hooks.monitor.State.FAIL,
borgmatic.hooks.monitoring.monitor.State.FAIL,
monitoring_log_level=1,
dry_run=False,
)
@@ -211,7 +211,7 @@ def test_ping_monitor_minimal_config_does_not_hit_hosted_ntfy_on_fail_dry_run():
hook_config,
{},
'config.yaml',
borgmatic.hooks.monitor.State.FAIL,
borgmatic.hooks.monitoring.monitor.State.FAIL,
monitoring_log_level=1,
dry_run=True,
)
@@ -227,7 +227,7 @@ def test_ping_monitor_custom_message_hits_hosted_ntfy_on_fail():
hook_config,
{},
'config.yaml',
borgmatic.hooks.monitor.State.FAIL,
borgmatic.hooks.monitoring.monitor.State.FAIL,
monitoring_log_level=1,
dry_run=False,
)
@@ -237,7 +237,7 @@ def test_ping_monitor_custom_state_hits_hosted_ntfy_on_start():
hook_config = {'topic': topic, 'states': ['start', 'fail']}
flexmock(module.requests).should_receive('post').with_args(
f'{default_base_url}/{topic}',
headers=return_default_message_headers(borgmatic.hooks.monitor.State.START),
headers=return_default_message_headers(borgmatic.hooks.monitoring.monitor.State.START),
auth=None,
).and_return(flexmock(ok=True)).once()
@@ -245,7 +245,7 @@ def test_ping_monitor_custom_state_hits_hosted_ntfy_on_start():
hook_config,
{},
'config.yaml',
borgmatic.hooks.monitor.State.START,
borgmatic.hooks.monitoring.monitor.State.START,
monitoring_log_level=1,
dry_run=False,
)
@@ -255,7 +255,7 @@ def test_ping_monitor_with_connection_error_logs_warning():
hook_config = {'topic': topic}
flexmock(module.requests).should_receive('post').with_args(
f'{default_base_url}/{topic}',
headers=return_default_message_headers(borgmatic.hooks.monitor.State.FAIL),
headers=return_default_message_headers(borgmatic.hooks.monitoring.monitor.State.FAIL),
auth=None,
).and_raise(module.requests.exceptions.ConnectionError)
flexmock(module.logger).should_receive('warning').once()
@@ -264,7 +264,7 @@ def test_ping_monitor_with_connection_error_logs_warning():
hook_config,
{},
'config.yaml',
borgmatic.hooks.monitor.State.FAIL,
borgmatic.hooks.monitoring.monitor.State.FAIL,
monitoring_log_level=1,
dry_run=False,
)
@@ -278,7 +278,7 @@ def test_ping_monitor_with_other_error_logs_warning():
)
flexmock(module.requests).should_receive('post').with_args(
f'{default_base_url}/{topic}',
headers=return_default_message_headers(borgmatic.hooks.monitor.State.FAIL),
headers=return_default_message_headers(borgmatic.hooks.monitoring.monitor.State.FAIL),
auth=None,
).and_return(response)
flexmock(module.logger).should_receive('warning').once()
@@ -287,7 +287,7 @@ def test_ping_monitor_with_other_error_logs_warning():
hook_config,
{},
'config.yaml',
borgmatic.hooks.monitor.State.FAIL,
borgmatic.hooks.monitoring.monitor.State.FAIL,
monitoring_log_level=1,
dry_run=False,
)
@@ -1,6 +1,6 @@
from flexmock import flexmock
from borgmatic.hooks import pagerduty as module
from borgmatic.hooks.monitoring import pagerduty as module
def test_ping_monitor_ignores_start_state():

Some files were not shown because too many files have changed in this diff Show More