Compare commits

...

246 commits

Author SHA1 Message Date
730350b31a Fix incorrect option name within schema description.
All checks were successful
build / test (push) Successful in 3m56s
build / docs (push) Successful in 1m6s
2025-01-25 08:04:13 -08:00
203e1f4e99 Bump version for release. 2025-01-25 08:01:34 -08:00
4c35a564ef Fix root patterns so they don't have an invalid "sh:" prefix before getting passed to Borg (#979).
All checks were successful
build / test (push) Successful in 5m44s
build / docs (push) Successful in 1m38s
2025-01-25 07:59:53 -08:00
7551810ea6 Clarify/correct documentation about dumping databases when using containers (#978).
All checks were successful
build / test (push) Successful in 6m26s
build / docs (push) Successful in 1m39s
2025-01-24 14:31:38 -08:00
ce523eeed6 Add a blurb about recent contributors.
All checks were successful
build / test (push) Successful in 11m15s
build / docs (push) Successful in 1m15s
2025-01-23 15:11:54 -08:00
3c0def6d6d Expand the recent contributors documentation section to ticket submitters.
All checks were successful
build / test (push) Successful in 4m12s
build / docs (push) Successful in 1m1s
2025-01-23 14:41:26 -08:00
f08014e3be Code formatting.
All checks were successful
build / test (push) Successful in 4m23s
build / docs (push) Successful in 1m36s
2025-01-23 12:11:27 -08:00
86ad93676d Bump version for release. 2025-01-23 12:09:20 -08:00
e1825d2bcb Add #977 to NEWS. 2025-01-23 12:08:34 -08:00
92b8c0230e Fix exclude patterns parsing to support pattern styles (#977).
Some checks failed
build / test (push) Failing after 3m37s
build / docs (push) Has been skipped
Reviewed-on: #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
ffb342780b Link to Sentry's DSN documentation (#855).
All checks were successful
build / test (push) Successful in 4m24s
build / docs (push) Successful in 1m42s
2025-01-21 17:28:32 -08:00
9871267f97 Add a Sentry monitoring hook (#855).
Some checks failed
build / docs (push) Blocked by required conditions
build / test (push) Has been cancelled
2025-01-21 17:23:56 -08:00
914c2b17e9 Add a Sentry monitoring hook (#855). 2025-01-21 17:23:18 -08:00
804455ac9f Fix for "exclude_from" files being completely ignored (#971).
All checks were successful
build / test (push) Successful in 5m44s
build / docs (push) Successful in 1m34s
2025-01-19 10:27:13 -08:00
4fe0fd1576 Fix version number in NEWS.
All checks were successful
build / test (push) Successful in 4m3s
build / docs (push) Successful in 1m34s
2025-01-18 09:55:03 -08:00
e3d40125cb Fix for a "spot" check error when a filename in the most recent archive contains a newline (#968).
Some checks failed
build / docs (push) Blocked by required conditions
build / test (push) Has been cancelled
2025-01-18 09:54:30 -08:00
e66df22a6e Fix for an error when a blank line occurs in the configured patterns or excludes (#970).
Some checks failed
build / test (push) Failing after 3m15s
build / docs (push) Has been skipped
2025-01-18 09:25:29 -08:00
e789de0851 Bump version for release.
All checks were successful
build / test (push) Successful in 3m52s
build / docs (push) Successful in 54s
2025-01-17 13:50:22 -08:00
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
f183800009 For the LVM hook, add support for nested logical volumes (#962).
All checks were successful
build / test (push) Successful in 5m46s
build / docs (push) Successful in 57s
2025-01-17 09:38:49 -08:00
b7362bfbac Apply snapshot path rewriting to excludes and patterns, not just source directories (#962).
All checks were successful
build / test (push) Successful in 5m48s
build / docs (push) Successful in 1m35s
Reviewed-on: #964
2025-01-17 03:23:41 +00:00
2467518d4e Add even more missing test coverage (#962). 2025-01-16 15:11:59 -08:00
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
44efca2be9 Update patterns schema comment (#962). 2025-01-15 12:37:44 -08:00
cfeeb87bbe Fix pattern expansion/normalization bug with working directory (#962). 2025-01-15 11:26:26 -08:00
bb2e986c9d Fix end-to-end tests (#962). 2025-01-15 10:52:09 -08:00
67ac70354b Merge branch 'main' into snapshot-excludes-and-patterns 2025-01-15 10:37:36 -08:00
8c1d5dbfe1 Revert end-to-end script change.
All checks were successful
build / test (push) Successful in 4m4s
build / docs (push) Successful in 1m36s
2025-01-15 10:37:04 -08:00
a3aeb36159 Merge branch 'main' into snapshot-excludes-and-patterns 2025-01-15 10:35:45 -08:00
c702a988bd Add a basic end-to-end test for patterns (#962).
Some checks failed
build / docs (push) Blocked by required conditions
build / test (push) Has been cancelled
2025-01-15 10:33:38 -08:00
bbf1c3d55e Add test coverage for new code. 2025-01-14 23:01:39 -08:00
0b17fb2d3f Get all existing tests passing (#962). 2025-01-14 13:48:20 -08:00
ca54da1067 Getting additional tests passing (#962). 2025-01-13 20:51:27 -08:00
661041da04 Fix check tests (#962). 2025-01-13 10:22:32 -08:00
ad14ff3ee5 Fix tests for remaining hooks (#962). 2025-01-13 10:07:25 -08:00
b72b9aaf13 Fix several tests (#962). 2025-01-12 22:42:58 -08:00
a70fd30cb1 Merge branch 'main' into snapshot-excludes-and-patterns 2025-01-12 11:38:50 -08:00
5560f30aa6 Fix a borgmatic runtime directory error when running the "spot" check with a database hook enabled (#965).
All checks were successful
build / test (push) Successful in 5m56s
build / docs (push) Successful in 1m55s
2025-01-12 09:36:24 -08:00
256ed4170b Minor comment clarifications (#962). 2025-01-10 22:20:49 -08:00
071d8d945a Strip comments from patterns (#962). 2025-01-10 12:33:25 -08:00
926c26315a Add documentation for patterns and snapshot hooks (#962). 2025-01-10 12:22:37 -08:00
120a29ab4d Initial work on applying snapshot path rewriting to excludes and patterns (#962). 2025-01-10 10:38:27 -08:00
8573660ff0 Clarify error message to mention patterns, not just excludes (#947).
All checks were successful
build / test (push) Successful in 5m59s
build / docs (push) Successful in 1m54s
2025-01-10 08:41:56 -08:00
0b9f3ae8a1 Fix comment typo. 2025-01-04 20:18:18 -08:00
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.
All checks were successful
build / test (push) Successful in 6m4s
build / docs (push) Successful in 1m58s
2025-01-01 15:00:36 -08:00
d6c3ec05aa Reduce duplication (#960).
All checks were successful
build / test (push) Successful in 4m5s
build / docs (push) Successful in 1m6s
2024-12-29 22:00:25 -08:00
a4954cc7a3 Fix for archives storing relative source directory paths such that they contain the working directory (#960).
All checks were successful
build / test (push) Successful in 5m53s
build / docs (push) Successful in 2m1s
2024-12-29 20:09:13 -08:00
a6b6dd32c1 Upgrade dependencies and containers for end-to-end tests.
All checks were successful
build / test (push) Successful in 5m48s
build / docs (push) Successful in 1m57s
2024-12-29 09:33:25 -08:00
d3409df84c Fix an error in the Btrfs hook when a "/" subvolume is configured in borgmatic's source directories (#959).
All checks were successful
build / test (push) Successful in 6m59s
build / docs (push) Successful in 2m7s
2024-12-28 09:57:33 -08:00
87e77ff2b7 Bump version for release.
All checks were successful
build / test (push) Successful in 7m31s
build / docs (push) Successful in 2m1s
2024-12-27 08:54:53 -08:00
3517d9d4f3 Indentation tweak. 2024-12-26 19:11:45 -08:00
d3c7279dad Backup and restore databases that have the same name but with different ports, hostnames, or hooks (#418).
All checks were successful
build / test (push) Successful in 5m30s
build / docs (push) Successful in 1m12s
Reviewed-on: #952
2024-12-26 23:17:58 +00:00
a99c48c115 Documentation / CLI help clarifications around "--original-port" (#418). 2024-12-26 15:16:29 -08:00
94cedd4cf8 Merge branch 'main' into same-named-databases 2024-12-25 23:04:45 -08:00
a4baf4623b Drop colorama as a library dependency (#958).
All checks were successful
build / test (push) Successful in 7m15s
build / docs (push) Successful in 1m58s
2024-12-25 23:02:38 -08:00
77df425bd1 Minor edits and clarifying comments (#418). 2024-12-25 21:59:10 -08:00
69476a4fab Documentation clarifications (#418). 2024-12-24 23:25:26 -08:00
be6b865a81 Add even more missing test coverage (#418). 2024-12-24 23:09:44 -08:00
b58a52e03f Merge branch 'main' into same-named-databases 2024-12-24 15:25:57 -08:00
9b85c5bc61 Add missing restore test coverage (#418). 2024-12-24 15:24:09 -08:00
b8041f5c39 Fix end-to-end tests broken by new database config checks during restore (#418). 2024-12-24 09:13:42 -08:00
d9d6d3f7f2 Simplify logic to get configured data sources during restoration (#418). 2024-12-23 22:12:47 -08:00
0844cd0d4f Fix the printing of a color reset code at exit even when color is disabled (#956).
All checks were successful
build / test (push) Successful in 8m17s
build / docs (push) Successful in 2m42s
2024-12-23 19:53:57 -08:00
d4705602fa Handle more edge cases by erroring (#418). 2024-12-22 22:02:53 -08:00
5174a78109 Get existing tests passing (#418). 2024-12-21 13:35:00 -08:00
3db79b4352 Simplified dump metadata comparison logic and got a few tests passing (#418). 2024-12-20 22:40:20 -08:00
d6732d9abb Merge branch 'main' into same-named-databases 2024-12-19 21:07:44 -08:00
267af5b372 To avoid a hang in the database hooks, error and exit when the borgmatic runtime directory overlaps with the configured excludes (#947).
All checks were successful
build / test (push) Successful in 7m11s
build / docs (push) Successful in 2m6s
2024-12-19 20:59:57 -08:00
d53ea09adb In tests, account for some function renames (#418). 2024-12-17 16:28:59 -08:00
8696cbfa22 Clarify some comments (#418). 2024-12-17 12:02:31 -08:00
48dca28c74 When the ZFS, Btrfs, or LVM hooks aren't configured, don't try to cleanup snapshots for them.
All checks were successful
build / test (push) Successful in 6m47s
build / docs (push) Successful in 1m49s
2024-12-17 11:00:19 -08:00
36bcbd0592 Documentation about restoring datebases with the same name (#418). 2024-12-17 08:51:04 -08:00
ebb3bca4b3 Fix findmnt command error in the Btrfs hook by switching to parsing JSON output (#954).
All checks were successful
build / test (push) Successful in 9m15s
build / docs (push) Successful in 2m17s
2024-12-12 11:58:18 -08:00
b1e343f15c Initial work on supporting same-named database with different ports, hosts, or hooks (#418). 2024-12-09 08:48:34 -08:00
cb7f98192c Updates to out-of-date documentation on database dumps.
All checks were successful
build / test (push) Successful in 4m37s
build / docs (push) Successful in 1m1s
2024-12-07 12:25:39 -08:00
3ceb4f554f Fix out-of-date schema comments about databases and one_file_system.
All checks were successful
build / test (push) Successful in 4m34s
build / docs (push) Successful in 1m9s
2024-12-07 11:42:41 -08:00
4b18c0bc81 Make LVM snapshots read-only.
All checks were successful
build / test (push) Successful in 4m33s
build / docs (push) Successful in 55s
2024-12-07 09:41:50 -08:00
2ce09dbf82 Snapshot documentation clarifications.
All checks were successful
build / test (push) Successful in 4m35s
build / docs (push) Successful in 57s
2024-12-07 09:10:52 -08:00
8a4f3b8f1a Add word missing from docs (#80).
All checks were successful
build / test (push) Successful in 4m34s
build / docs (push) Successful in 1m5s
2024-12-06 20:39:50 -08:00
81cd03cbbf Bump version for release. 2024-12-06 20:29:16 -08:00
f2455527fc Fix spelling in comment (#80).
All checks were successful
build / test (push) Successful in 4m37s
build / docs (push) Successful in 1m41s
2024-12-06 20:22:45 -08:00
62d67cde0a LVM snapshots + ZFS and Btrfs improvements (#80).
Some checks failed
build / docs (push) Blocked by required conditions
build / test (push) Has been cancelled
Reviewed-on: #949
2024-12-07 04:21:22 +00:00
ae8a9db27d Fix flake issues (#80). 2024-12-06 16:12:01 -08:00
8979f8918d Organize imports (#80). 2024-12-06 16:05:46 -08:00
eb97708092 Completed tests for LVM (#80). 2024-12-06 16:02:33 -08:00
f2d93b85b4 Lots of LVM unit tests + code formatting (#80). 2024-12-06 13:59:38 -08:00
b999d2dc4d Add some missing test coverage (#80). 2024-12-06 10:27:47 -08:00
7f2e38d061 Fix file permissions (#80). 2024-12-06 09:40:32 -08:00
140fc248b6 Fix LVM end-to-end tests (#80). 2024-12-06 09:39:24 -08:00
ec9e1a8223 LVM hook end-to-end tests, not quite working yet (#80). 2024-12-05 22:46:50 -08:00
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
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
f8df06fb92 Remove divison by zero (#80). 2024-12-04 20:33:59 -08:00
d95707ff9b Get existing tests passing (#80). 2024-12-04 20:22:59 -08:00
51a7f50e3a Add ZFS snapshot unmount error fix to NEWS (#950). 2024-12-04 15:43:05 -08:00
49b8b693af Don't try to unmount a ZFS snapshot if it's already deleted (#80). 2024-12-04 15:39:04 -08:00
d0e92493f6 Fix broken ZFS tests (#80). 2024-12-04 14:48:13 -08:00
9afdaca985 Before unmounting, remove the snapshot mount path instead of the parent snapshot directory (#80). 2024-12-03 19:19:22 -08:00
cc11ed78e0 Put LVM snapshots into a data structure for convenience (#80). 2024-12-03 19:12:41 -08:00
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
347a4c3dd5 Fix breakage of ZFS user property auto-backup (#80). 2024-12-03 15:43:50 -08:00
399bb6ef68 Add recent LVM and ZFS work to NEWS (#80). 2024-12-03 12:22:43 -08:00
9b9ecad299 Port the parent directory discovery logic from LVM to Btrfs (#80). 2024-12-03 12:15:34 -08:00
8c4b899a13 Use a namedtuple for logical volume metadata (#80). 2024-12-03 11:12:27 -08:00
9b77de3d66 Port the parent directory discovery logic from LVM to ZFS (#80). 2024-12-03 11:05:45 -08:00
bfeea5d394 Code formatting (#80). 2024-12-03 08:52:05 -08:00
8a6225b7c2 Factor out logic for finding contained source directories in a parent directory (#80). 2024-12-03 08:51:10 -08:00
9aaa3c925f Code formatting (#80). 2024-12-02 21:01:34 -08:00
88fd1ae454 Discover parent/grandparent/etc. logical volumes of source directories (#80). 2024-12-02 20:58:50 -08:00
27305ec2bf Clarify the path rewriting for LVM (but also ZFS + Btrfs) (#80). 2024-12-02 12:01:04 -08:00
4453c2d49c Add LVM logo to integrations docs. 2024-12-02 11:28:57 -08:00
6367a00013 Add snapshot_size option (#80). 2024-12-02 11:09:07 -08:00
cd654cbb57 Fix a few docstring typos (#80). 2024-12-01 21:00:11 -08:00
1e8f73779f Fix typo in schema comment (#80). 2024-12-01 20:25:16 -08:00
27d167b071 LVM snapshots WIP (#80). 2024-12-01 20:13:02 -08:00
cfff6c6855 Btrfs snapshotting (#251).
All checks were successful
build / test (push) Successful in 5m46s
build / docs (push) Successful in 1m38s
Reviewed-on: #946
2024-11-30 19:19:09 +00:00
37efaeae88 Warn if Btrfs is configured but there are no Btrfs subvolumes detected (#251). 2024-11-30 10:55:30 -08:00
0978c669ad A little more Btrfs error handling (#251). 2024-11-30 10:25:01 -08:00
1366269586 Add a couple of missing tests (#251). 2024-11-30 09:44:55 -08:00
a9a0910817 Add Btrfs logo to integrations docs (#251). 2024-11-30 09:36:52 -08:00
5bcc7b60c8 Tests for Btrfs (#251). 2024-11-30 09:32:50 -08:00
84a0552277 Improve Btrfs hook factoring/organization (#251). 2024-11-29 09:36:46 -08:00
d4a02f73b5 Create Btrfs snapshots as read-only (#251). 2024-11-28 22:18:44 -08:00
3f901c0a52 Btrfs hook documentation (#251). 2024-11-28 20:32:12 -08:00
b5b5c1fafa Initial work on a Btrfs hook (#251). 2024-11-28 18:47:15 -08:00
86e5085acc Fix incorrect documentation links to source.
All checks were successful
build / test (push) Successful in 4m5s
build / docs (push) Successful in 1m38s
2024-11-27 08:54:19 -08:00
08a5e8717b Merge branch 'main' of ssh://projects.torsion.org:3022/borgmatic-collective/borgmatic
Some checks failed
build / docs (push) Blocked by required conditions
build / test (push) Has been cancelled
2024-11-27 08:51:00 -08:00
6b2f2b2ac4 Reorganize data source and monitoring hooks to make developing new hooks easier. 2024-11-27 08:50:34 -08:00
a07cf9e699 Revert temporary reversion of 1.9.4.dev0.
All checks were successful
build / test (push) Successful in 4m9s
build / docs (push) Successful in 6s
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
bf40b01077 Temporary revert of 1.9.4.dev0 changeset so we can re-build 1.9.3 (which never actually got built).
All checks were successful
build / test (push) Successful in 4m8s
build / docs (push) Successful in 55s
revert Fix library error when running within a PyInstaller bundle (#926).
2024-11-26 16:13:39 +00:00
a5c6a2fe1c Fix library error when running within a PyInstaller bundle (#926).
All checks were successful
build / test (push) Successful in 5m47s
build / docs (push) Successful in 1m39s
2024-11-25 20:14:18 -08:00
82141fe981 Bump version for release. 2024-11-25 07:49:11 -08:00
228a83978d Check docs clarifications.
All checks were successful
build / test (push) Successful in 4m19s
build / docs (push) Successful in 1m38s
2024-11-24 19:40:00 -08:00
638db3770b Clarify how frequent default checks run.
Some checks failed
build / docs (push) Blocked by required conditions
build / test (push) Has been cancelled
2024-11-24 19:38:20 -08:00
98df5c3af2 Fix docs about the relative speeds of different checks (#945).
All checks were successful
build / test (push) Successful in 4m9s
build / docs (push) Successful in 1m2s
Reviewed-on: #945
2024-11-25 03:23:39 +00:00
b0e906c0e7 NEWS clarifications.
All checks were successful
build / test (push) Successful in 4m9s
build / docs (push) Successful in 59s
2024-11-24 19:05:11 -08:00
e8dccbf1c1 Promote the "spot" check from a beta feature to stable.
Some checks failed
build / docs (push) Blocked by required conditions
build / test (push) Has been cancelled
2024-11-24 19:03:05 -08:00
4a997bc234 Add rclone to the integrations.
Some checks failed
build / docs (push) Blocked by required conditions
build / test (push) Has been cancelled
2024-11-24 18:59:21 -08:00
3197178b3d Fix flake errors.
All checks were successful
build / test (push) Successful in 4m10s
build / docs (push) Successful in 1m9s
2024-11-24 16:32:48 -08:00
5e618154d0 Clarify error message.
Some checks failed
build / test (push) Failing after 1m50s
build / docs (push) Has been skipped
2024-11-24 16:18:29 -08:00
84f611ae4f Require the runtime directory to be an absolute path.
Some checks failed
build / test (push) Failing after 1m51s
build / docs (push) Has been skipped
2024-11-24 16:15:19 -08:00
5dc8450c8e Adding missing bootstrap files. 2024-11-24 16:15:12 -08:00
689643e5fa Move bootstrap manifest file creation into a hook so it can actually clean up after itself.
Some checks failed
build / test (push) Failing after 1m9s
build / docs (push) Has been skipped
2024-11-24 16:00:33 -08:00
0a3d87eaea
docs: repository check means full CRC check -> slow
Ref: https://borgbackup.readthedocs.io/en/stable/usage/check.html#description
2024-11-24 18:04:58 +01:00
b45b62cd38 Don't recursively snapshot ZFS datasets, since we're not mounting them recursively (#261).
All checks were successful
build / test (push) Successful in 4m9s
build / docs (push) Successful in 56s
2024-11-23 22:37:46 -08:00
8de7094691 ZFS snapshots (#261).
All checks were successful
build / test (push) Successful in 6m1s
build / docs (push) Successful in 1m40s
Reviewed-on: #944
2024-11-24 04:42:19 +00:00
8c7e68305e A few clarifications to the ZFS docs (#261). 2024-11-23 20:41:43 -08:00
65a323433c Add comment (#261). 2024-11-23 20:12:40 -08:00
b5a3589471 A little more error handling (#261). 2024-11-23 18:09:59 -08:00
f4a736bdfe Deduplicate directories again after hooks have their way with them (#261). 2024-11-23 14:33:41 -08:00
eab0ec15ef Expand ZFS NEWS entry (#261). 2024-11-23 11:46:39 -08:00
c65aa24001 Add test coverage for ZFS hook (#261). 2024-11-23 10:50:58 -08:00
5a24bf2037 Get tests passing (#261). 2024-11-22 20:16:18 -08:00
324dbc3a79 Swallow temporary directory removal errors (#261). 2024-11-22 10:56:07 -08:00
9fe7db320a Consider ZFS hook as a beta feature (#261). 2024-11-22 09:53:46 -08:00
4d19596616 Add ZFS documentation (#261). 2024-11-22 08:33:24 -08:00
5cec2bf3d9 Don't unmount directories that don't exist. 2024-11-21 22:16:05 -08:00
06e0f98fd8 More refactoring for better organization of ZFS hook (#261). 2024-11-21 22:09:18 -08:00
87f36caf8d Factoring out some utility functions (#261). 2024-11-21 20:17:57 -08:00
ab7acceff6 Unmount and remove mounted snapshot directories, not just for the current process but for previous borgmatic runs as well (#261). 2024-11-21 19:09:30 -08:00
1b2b0c3020 Update out-of-date ZFS hook comments (#261). 2024-11-21 16:49:25 -08:00
289d178581 Also support discovery of ZFS datasets tagged with a borgmatic-specific user property (#261). 2024-11-21 16:45:44 -08:00
1e7f6d9f41 ZFS hook support for borgmatic's --dry-run (#261). 2024-11-21 11:55:45 -08:00
d0c90389fb Remove ZFS "enabled" option and fix override command options. 2024-11-21 10:52:00 -08:00
f9e920dce9 Prevent ZFS snapshots from ending up in the Borg archive twice (#261). 2024-11-21 10:23:27 -08:00
0ed52bbc4a Proceed gracefully in ZFS data source removal if ZFS isn't installed (#261). 2024-11-21 08:59:59 -08:00
da8278b566 Use os.path.normpath() instead of custom list comprehension (#261). 2024-11-21 08:36:15 -08:00
2af3522902 Fix broken check action (#261). 2024-11-21 08:32:02 -08:00
5e4784991a Comment tweaks and additional TODOs (#261). 2024-11-20 22:33:23 -08:00
ab43ef00ce ZFS snapshots WIP (#261). 2024-11-20 22:21:27 -08:00
47a8a95b29 Test path fix for finding schema file.
All checks were successful
build / test (push) Successful in 4m7s
build / docs (push) Successful in 58s
2024-11-20 08:18:06 -08:00
7c90c04ce0 Add a "--deleted" flag to the "repo-list" action (Borg 2 only).
All checks were successful
build / test (push) Successful in 5m49s
build / docs (push) Successful in 1m39s
2024-11-19 22:33:15 -08:00
97305cc3ce Fix broken tests when NO_COLOR=1 is set (#943).
All checks were successful
build / test (push) Successful in 4m7s
build / docs (push) Successful in 56s
2024-11-19 08:48:21 -08:00
4985b805b4 Bump version for release. 2024-11-18 20:40:51 -08:00
d09b4c72a9 Fix a few remaining Pushover issues from the PR.
All checks were successful
build / test (push) Successful in 5m45s
build / docs (push) Successful in 1m39s
2024-11-18 20:32:17 -08:00
9807549f88
Add a Pushover monitoring hook.
Merge pull request #86 from tony1661/pushover-branch.
2024-11-18 20:17:28 -08:00
30c821120e Fix borgmatic ignoring the "BORG_RELOCATED_REPO_ACCESS_IS_OK" and "BORG_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK" environment variables (#939).
All checks were successful
build / test (push) Successful in 4m5s
build / docs (push) Successful in 55s
2024-11-18 09:46:52 -08:00
13884bd448 Apply the "umask" option to all relevant actions, not just some of them (#441).
All checks were successful
build / test (push) Successful in 4m5s
build / docs (push) Successful in 56s
2024-11-18 09:07:29 -08:00
6bce4c4a0d changed version release to 1.9.2 2024-11-18 07:56:00 -05:00
25572c98d7 better fuction name 2024-11-18 07:52:19 -05:00
dab0dfcb32 added test for value error 2024-11-18 07:49:53 -05:00
851c454ef0 Remove the restriction that the "extract" and "mount" actions must match a single repository (#722).
All checks were successful
build / test (push) Successful in 5m47s
build / docs (push) Successful in 1m40s
2024-11-17 21:39:59 -08:00
c7a0cebaf7 Add a documentation link to NEWS (#934).
All checks were successful
build / test (push) Successful in 4m5s
build / docs (push) Successful in 56s
2024-11-17 12:46:58 -08:00
76cfeda290 Update the logic that probes for the borgmatic runtime directory to support more platforms and use cases (#934).
All checks were successful
build / test (push) Successful in 5m45s
build / docs (push) Successful in 1m40s
Reviewed-on: #937
2024-11-17 19:57:09 +00:00
afdf831c59 Fix broken restore/bootstrap when using Borg 1.2 and a randomly named temporary directory (#934). 2024-11-17 11:55:10 -08:00
9ac3087304 Before creating a temp file in a directory, make sure the directory exists (#934). 2024-11-17 10:30:17 -08:00
7cca83b698 Log the path of the borgmatic runtime directory used (#934). 2024-11-17 10:15:58 -08:00
4b5df7117a Fix documentation type (#934). 2024-11-17 09:01:58 -08:00
57decfa4db Document the fact that the config bootstrap feature writes to the runtime directory (#934). 2024-11-16 16:45:49 -08:00
b80f60a731 Create the borgmatic runtime directory if it doesn't exist (#934). 2024-11-16 16:03:18 -08:00
8f5ea95348 Fix use of borgmatic runtime directory in the restore action (#934). 2024-11-16 12:19:20 -08:00
b0cad58d6c Add a dash to the prefix of the randomly named temporary directory to improve readability (#937). 2024-11-16 11:26:24 -08:00
073d6bddf6 Fix outdated comment (#934). 2024-11-16 07:26:23 -08:00
810b65589f Documentation for runtime/state directory changes (#934). 2024-11-15 22:23:49 -08:00
295bfb0c57 Update the logic that probes for the borgmatic streaming database dump, bootstrap metadata, and check state directories to support more platforms and use cases (#934). 2024-11-15 18:15:32 -08:00
5f3d4f9b03 final fix for true/false and 1/0 2024-11-13 15:27:25 -05:00
5321301708 fix for true/false to 1/0 2024-11-13 15:23:47 -05:00
a939a66fb4 raise ValueError on priprity 2 with retry or expire 2024-11-13 15:20:52 -05:00
c0721a8cad Fix misleading example for user_runtime_directory.
All checks were successful
build / test (push) Successful in 5m45s
build / docs (push) Successful in 1m40s
2024-11-13 11:17:40 -08:00
ea47704d86 added missing tests to get 100% test coverage 2024-11-13 12:09:25 -05:00
61e4eeff6c converted html to a boolean and updated documentation and schema 2024-11-13 11:52:08 -05:00
3ab4b45041 added test for early exit when state is not in config 2024-11-13 09:07:47 -05:00
4e1f256b48 added dryrun test case with minimum config 2024-11-12 10:15:19 -05:00
96bb402837 fix test 2 2024-11-12 10:10:13 -05:00
97949266b3 fix test 2024-11-12 10:09:23 -05:00
e69d2385fc better name for test 2024-11-12 10:08:13 -05:00
6d9340ebb2 better comment blocks 2024-11-12 10:05:57 -05:00
0441e79b41 "fail" -> "fails" 2024-11-12 10:00:34 -05:00
b1af304125 better data dict creation 2024-11-12 09:59:02 -05:00
eb8f7e0329 better description for expire and retry 2024-11-12 09:55:07 -05:00
bf978f2db4 Fix missing build backend setting in pyproject.toml to allow Fedora builds (#932).
All checks were successful
build / test (push) Successful in 4m3s
build / docs (push) Successful in 56s
2024-11-10 17:09:10 -08:00
22776b123d Bump version for release.
All checks were successful
build / test (push) Successful in 10m24s
build / docs (push) Successful in 1m37s
2024-11-10 08:13:18 -08:00
ef66349674 small fixed for some failing tests 2024-11-08 20:02:29 -05:00
51b885e7db added global constant for priority 2024-11-08 19:49:23 -05:00
1781787305 better schema description for retry and expire 2024-11-08 19:43:40 -05:00
46ebb0cebb removed redundant code 2024-11-08 18:45:46 -05:00
3e0fa57860 removed tests that are not needed 2024-11-08 17:44:27 -05:00
59f8722e05 better spacing for comments 2024-11-08 17:42:46 -05:00
4ba42e8905 better wording. Added 'by default' 2024-11-08 17:41:18 -05:00
3b79482b24 better wording 2024-11-08 17:38:58 -05:00
7eb19cb0a7 added period 2024-11-08 17:38:20 -05:00
a4fabb8521 fix version 2024-11-08 17:37:26 -05:00
85ea8f4f45 fix 10min in seconds 2024-11-08 15:03:38 -05:00
290559116d better logic for priority 2024-11-08 15:01:28 -05:00
72b27b0858 better message description in schema 2024-11-08 14:57:44 -05:00
0fdee067c7 double space fix 2024-11-08 14:45:36 -05:00
0dca5eeafc fix title wordwrap 2024-11-08 14:09:03 -05:00
02ce3ba190 fix url_title word wrap 2024-11-08 14:07:49 -05:00
dc78bf4d6b fix TTL wordwrap 2024-11-08 14:06:39 -05:00
4b7fbce291 fix sound word wrap 2024-11-08 14:05:27 -05:00
1817b9a9ea fix wordwrap for html 2024-11-08 13:50:22 -05:00
009055c61a device description rewrap 2024-11-08 13:48:21 -05:00
54884da8fa priority word wrap 2024-11-08 13:46:19 -05:00
1177385e08 fix expire description 2024-11-08 13:44:12 -05:00
a45ba8553c removed duplicate type:object 2024-11-08 13:42:19 -05:00
d7d6e30178 moved checks from hook to schema 2024-11-08 13:40:23 -05:00
56304fdcad Add NEWS entry for multiple system credentials fix (#930).
All checks were successful
build / test (push) Successful in 4m10s
build / docs (push) Successful in 1m37s
2024-11-07 20:20:41 -08:00
3f75e9931f Only support a single systemd credential by default (#930).
Some checks failed
build / docs (push) Blocked by required conditions
build / test (push) Has been cancelled
Reviewed-on: #930
Reviewed-by: Dan Helfman <witten@torsion.org>
2024-11-08 04:18:56 +00:00
227f475e17 Fix an error when implicitly upgrading the check state directory across filesystems (#931).
All checks were successful
build / test (push) Successful in 5m48s
build / docs (push) Successful in 1m38s
2024-11-07 19:19:56 -08:00
467ddd0e93 creds: Only support single credential by default 2024-11-08 00:36:24 +01:00
be08e889f0 Fix the user runtime directory location on macOS (and possibly Cygwin) (#928).
All checks were successful
build / test (push) Successful in 6m31s
build / docs (push) Successful in 2m5s
2024-11-03 21:44:11 -08:00
94c8a56373 Reorder NEWS items. 2024-11-03 13:58:04 -08:00
94db527500 finalized support for Pushover 2024-10-30 15:43:06 -04:00
2849f54932 initial pushover commit 2024-10-30 11:25:26 -04:00
153 changed files with 11909 additions and 2919 deletions

96
NEWS
View file

@ -1,3 +1,97 @@
1.9.8
* #979: Fix root patterns so they don't have an invalid "sh:" prefix before getting passed to Borg.
* Expand the recent contributors documentation section to include ticket submitters—not just code
contributors—because there are multiple ways to contribute to the project! See:
https://torsion.org/borgmatic/#recent-contributors
1.9.7
* #855: Add a Sentry monitoring hook. See the documentation for more information:
https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#sentry-hook
* #968: Fix for a "spot" check error when a filename in the most recent archive contains a newline.
* #970: Fix for an error when there's a blank line in the configured patterns or excludes.
* #971: Fix for "exclude_from" files being completely ignored.
* #977: Fix for "exclude_patterns" and "exclude_from" not supporting explicit pattern styles (e.g.,
"sh:" or "re:").
1.9.6
* #959: Fix an error in the Btrfs hook when a subvolume mounted at "/" is configured in borgmatic's
source directories.
* #960: Fix for archives storing relative source directory paths such that they contain the working
directory.
* #960: Fix the "spot" check to support relative source directory paths.
* #962: For the ZFS, Btrfs, and LVM hooks, perform path rewriting for excludes and patterns in
addition to the existing source directories rewriting.
* #962: Under the hood, merge all configured source directories, excludes, and patterns into a
unified temporary patterns file for passing to Borg. The borgmatic configuration options remain
unchanged.
* #962: For the LVM hook, add support for nested logical volumes.
* #965: Fix a borgmatic runtime directory error when running the "spot" check with a database hook
enabled.
* #969: Fix the "restore" action to work on database dumps without a port when a default port is
present in configuration.
* Fix the "spot" check to no longer consider pipe files within an archive for file comparisons.
* Fix the "spot" check to have a nicer error when there are no source paths to compare.
* Fix auto-excluding of special files (when databases are configured) to support relative source
directory paths.
* Drop support for Python 3.8, which has been end-of-lifed.
1.9.5
* #418: Backup and restore databases that have the same name but with different ports, hostnames,
or hooks.
* #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/
* Remove any temporary copies of the manifest file created in support of the "bootstrap" action.
* Deprecate the "store_config_files" option at the global scope and move it under the "bootstrap"
hook. See the documentation for more information:
https://torsion.org/borgmatic/docs/how-to/extract-a-backup/#extract-the-configuration-files-used-to-create-an-archive
* Require the runtime directory to be an absolute path.
* Add a "--deleted" flag to the "repo-list" action for listing deleted archives that haven't
yet been compacted (Borg 2 only).
* Promote the "spot" check from a beta feature to stable.
1.9.2
* #441: Apply the "umask" option to all relevant actions, not just some of them.
* #722: Remove the restriction that the "extract" and "mount" actions must match a single
repository. Now they work more like other actions, where each repository is applied in turn.
* #932: Fix the missing build backend setting in pyproject.toml to allow Fedora builds.
* #934: Update the logic that probes for the borgmatic streaming database dump, bootstrap
metadata, and check state directories to support more platforms and use cases. See the
documentation for more information:
https://torsion.org/borgmatic/docs/how-to/backup-your-databases/#runtime-directory
* #934: Add the "RuntimeDirectory" and "StateDirectory" options to the sample systemd service
file to support the new runtime and state directory logic.
* #939: Fix borgmatic ignoring the "BORG_RELOCATED_REPO_ACCESS_IS_OK" and
"BORG_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK" environment variables.
* Add a Pushover monitoring hook. See the documentation for more information:
https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#pushover-hook
1.9.1
* #928: Fix the user runtime directory location on macOS (and possibly Cygwin).
* #930: Fix an error with the sample systemd service when no credentials are configured.
* #931: Fix an error when implicitly upgrading the check state directory from ~/.borgmatic to
~/.local/state/borgmatic across filesystems.
1.9.0
* #609: Fix the glob expansion of "source_directories" values to respect the "working_directory"
option.
@ -20,6 +114,7 @@
cases.
* #902: Add loading of encrypted systemd credentials. See the documentation for more information:
https://torsion.org/borgmatic/docs/how-to/provide-your-passwords/#using-systemd-service-credentials
* #911: Add a "key change-passphrase" action to change the passphrase protecting a repository key.
* #914: Fix a confusing apparent hang when when the repository location changes, and instead
show a helpful error message.
* #915: BREAKING: Rename repository actions like "rcreate" to more explicit names like
@ -31,7 +126,6 @@
* #919: Clarify the command-line help for the "--config" flag.
* #919: Document a policy for versioning and breaking changes:
https://torsion.org/borgmatic/docs/how-to/upgrade/#versioning-and-breaking-changes
* #911: Add a "key change-passphrase" action to change the passphrase protecting a repository key.
* #921: BREAKING: Change soft failure command hooks to skip only the current repository rather than
all repositories in the configuration file.
* #922: Replace setup.py (Python packaging metadata) with the more modern pyproject.toml.

View file

@ -61,15 +61,21 @@ borgmatic is powered by [Borg Backup](https://www.borgbackup.org/).
<a href="https://mariadb.com/"><img src="docs/static/mariadb.png" alt="MariaDB" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<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>
<a href="https://cronitor.io/"><img src="docs/static/cronitor.png" alt="Cronitor" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://cronhub.io/"><img src="docs/static/cronhub.png" alt="Cronhub" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://www.pagerduty.com/"><img src="docs/static/pagerduty.png" alt="PagerDuty" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://www.pushover.net/"><img src="docs/static/pushover.png" alt="Pushover" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://ntfy.sh/"><img src="docs/static/ntfy.png" alt="ntfy" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<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>
@ -159,4 +165,8 @@ info on cloning source code, running tests, etc.
### Recent contributors
Thanks to all borgmatic contributors! There are multiple ways to contribute to
this project, so the following includes those who have fixed bugs, contributed
features, *or* filed tickets.
{% include borgmatic/contributors.html %}

View file

@ -6,7 +6,9 @@ import logging
import os
import pathlib
import random
import shutil
import borgmatic.actions.create
import borgmatic.borg.check
import borgmatic.borg.create
import borgmatic.borg.environment
@ -322,7 +324,7 @@ def upgrade_check_times(config, borg_repository_id):
f'Upgrading archives check times directory from {borgmatic_source_checks_path} to {borgmatic_state_checks_path}'
)
os.makedirs(borgmatic_state_path, mode=0o700, exist_ok=True)
os.rename(borgmatic_source_checks_path, borgmatic_state_checks_path)
shutil.move(borgmatic_source_checks_path, borgmatic_state_checks_path)
for check_type in ('archives', 'data'):
new_path = make_check_time_path(config, borg_repository_id, check_type, 'all')
@ -335,16 +337,22 @@ def upgrade_check_times(config, borg_repository_id):
logger.debug(f'Upgrading archives check time file from {old_path} to {new_path}')
try:
os.rename(old_path, temporary_path)
shutil.move(old_path, temporary_path)
except FileNotFoundError:
pass
os.mkdir(old_path)
os.rename(temporary_path, new_path)
shutil.move(temporary_path, new_path)
def collect_spot_check_source_paths(
repository, config, local_borg_version, global_arguments, local_path, remote_path
repository,
config,
local_borg_version,
global_arguments,
local_path,
remote_path,
borgmatic_runtime_directory,
):
'''
Given a repository configuration dict, a configuration dict, the local Borg version, global
@ -356,19 +364,23 @@ 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,
config_paths=(),
patterns=borgmatic.actions.create.process_patterns(
borgmatic.actions.create.collect_patterns(config),
working_directory,
),
local_borg_version=local_borg_version,
global_arguments=global_arguments,
borgmatic_runtime_directories=(),
borgmatic_runtime_directory=borgmatic_runtime_directory,
local_path=local_path,
remote_path=remote_path,
list_files=True,
@ -389,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('+ ')
)
@ -399,19 +411,29 @@ def collect_spot_check_source_paths(
BORG_DIRECTORY_FILE_TYPE = 'd'
BORG_PIPE_FILE_TYPE = 'p'
def collect_spot_check_archive_paths(
repository, archive, config, local_borg_version, global_arguments, local_path, remote_path
repository,
archive,
config,
local_borg_version,
global_arguments,
local_path,
remote_path,
borgmatic_runtime_directory,
):
'''
Given a repository configuration dict, the name of the latest archive, a configuration dict, the
local Borg version, global arguments as an argparse.Namespace instance, the local Borg path, and
the remote Borg path, collect the paths from the given archive (but only include files and
symlinks and exclude borgmatic runtime directories).
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)
borgmatic_runtime_directory = borgmatic.config.paths.get_borgmatic_runtime_directory(config)
return tuple(
path
@ -421,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
)
@ -444,7 +468,7 @@ def compare_spot_check_hashes(
global_arguments,
local_path,
remote_path,
log_label,
log_prefix,
source_paths,
):
'''
@ -468,7 +492,7 @@ def compare_spot_check_hashes(
if os.path.exists(os.path.join(working_directory or '', source_path))
}
logger.debug(
f'{log_label}: Sampling {sample_count} source paths (~{spot_check_config["data_sample_percentage"]}%) for spot check'
f'{log_prefix}: Sampling {sample_count} source paths (~{spot_check_config["data_sample_percentage"]}%) for spot check'
)
source_sample_paths_iterator = iter(source_sample_paths)
@ -516,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,
)
@ -528,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
@ -545,18 +569,19 @@ def spot_check(
global_arguments,
local_path,
remote_path,
borgmatic_runtime_directory,
):
'''
Given a repository dict, a loaded configuration dict, the local Borg version, global arguments
as an argparse.Namespace instance, the local Borg path, and the remote Borg path, perform a spot
check for the latest archive in the given repository.
as an argparse.Namespace instance, the local Borg path, the remote Borg path, and the borgmatic
runtime directory, perform a spot check for the latest archive in the given repository.
A spot check compares file counts and also the hashes for a random sampling of source files on
disk to those stored in the latest archive. If any differences are beyond configured tolerances,
then the check fails.
'''
log_label = f'{repository.get("label", repository["path"])}'
logger.debug(f'{log_label}: Running spot check')
log_prefix = f'{repository.get("label", repository["path"])}'
logger.debug(f'{log_prefix}: Running spot check')
try:
spot_check_config = next(
@ -577,8 +602,9 @@ def spot_check(
global_arguments,
local_path,
remote_path,
borgmatic_runtime_directory,
)
logger.debug(f'{log_label}: {len(source_paths)} total source paths for spot check')
logger.debug(f'{log_prefix}: {len(source_paths)} total source paths for spot check')
archive = borgmatic.borg.repo_list.resolve_archive_name(
repository['path'],
@ -589,7 +615,7 @@ def spot_check(
local_path,
remote_path,
)
logger.debug(f'{log_label}: Using archive {archive} for spot check')
logger.debug(f'{log_prefix}: Using archive {archive} for spot check')
archive_paths = collect_spot_check_archive_paths(
repository,
@ -599,19 +625,29 @@ def spot_check(
global_arguments,
local_path,
remote_path,
borgmatic_runtime_directory,
)
logger.debug(f'{log_label}: {len(archive_paths)} total archive paths for spot check')
logger.debug(f'{log_prefix}: {len(archive_paths)} total archive paths for spot check')
if len(source_paths) == 0:
logger.debug(
f'{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_label}: 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_label}: 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"]}%)'
@ -625,25 +661,25 @@ def spot_check(
global_arguments,
local_path,
remote_path,
log_label,
log_prefix,
source_paths,
)
# Error if the percentage of failing hashes exceeds the configured tolerance percentage.
logger.debug(f'{log_label}: {len(failing_paths)} non-matching spot check hashes')
logger.debug(f'{log_prefix}: {len(failing_paths)} non-matching spot check hashes')
data_tolerance_percentage = spot_check_config['data_tolerance_percentage']
failing_percentage = (len(failing_paths) / len(source_paths)) * 100
if failing_percentage > data_tolerance_percentage:
logger.debug(
f'{log_label}: Source paths with data not matching the latest archive: {", ".join(failing_paths)}'
f'{log_prefix}: Source paths with data not matching the latest archive: {", ".join(failing_paths)}'
)
raise ValueError(
f'Spot check failed: {failing_percentage:.2f}% of source paths with data not matching the latest archive (tolerance is {data_tolerance_percentage}%)'
)
logger.info(
f'{log_label}: Spot check passed with a {count_delta_percentage:.2f}% file count delta and a {failing_percentage:.2f}% file data delta'
f'{log_prefix}: Spot check passed with a {count_delta_percentage:.2f}% file count delta and a {failing_percentage:.2f}% file data delta'
)
@ -677,7 +713,9 @@ def run_check(
**hook_context,
)
logger.info(f'{repository.get("label", repository["path"])}: Running consistency checks')
log_prefix = repository.get('label', repository['path'])
logger.info(f'{log_prefix}: Running consistency checks')
repository_id = borgmatic.borg.check.get_repository_id(
repository['path'],
config,
@ -729,14 +767,18 @@ def run_check(
write_check_time(make_check_time_path(config, repository_id, 'extract'))
if 'spot' in checks:
spot_check(
repository,
config,
local_borg_version,
global_arguments,
local_path,
remote_path,
)
with borgmatic.config.paths.Runtime_directory(
config, log_prefix
) as borgmatic_runtime_directory:
spot_check(
repository,
config,
local_borg_version,
global_arguments,
local_path,
remote_path,
borgmatic_runtime_directory,
)
write_check_time(make_check_time_path(config, repository_id, 'spot'))
borgmatic.hooks.command.execute_hook(

View file

@ -38,37 +38,44 @@ def get_config_paths(archive_name, bootstrap_arguments, global_arguments, local_
borgmatic_source_directory = borgmatic.config.paths.get_borgmatic_source_directory(
{'borgmatic_source_directory': bootstrap_arguments.borgmatic_source_directory}
)
borgmatic_runtime_directory = borgmatic.config.paths.get_borgmatic_runtime_directory(
{'user_runtime_directory': bootstrap_arguments.user_runtime_directory}
)
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.
for base_directory in ('borgmatic', borgmatic_runtime_directory, borgmatic_source_directory):
borgmatic_manifest_path = os.path.join(base_directory, 'bootstrap', 'manifest.json')
with borgmatic.config.paths.Runtime_directory(
{'user_runtime_directory': bootstrap_arguments.user_runtime_directory},
bootstrap_arguments.repository,
) as borgmatic_runtime_directory:
for base_directory in (
'borgmatic',
borgmatic.config.paths.make_runtime_directory_glob(borgmatic_runtime_directory),
borgmatic_source_directory,
):
borgmatic_manifest_path = 'sh:' + os.path.join(
base_directory, 'bootstrap', 'manifest.json'
)
extract_process = borgmatic.borg.extract.extract_archive(
global_arguments.dry_run,
bootstrap_arguments.repository,
archive_name,
[borgmatic_manifest_path],
config,
local_borg_version,
global_arguments,
local_path=bootstrap_arguments.local_path,
remote_path=bootstrap_arguments.remote_path,
extract_to_stdout=True,
)
manifest_json = extract_process.stdout.read()
extract_process = borgmatic.borg.extract.extract_archive(
global_arguments.dry_run,
bootstrap_arguments.repository,
archive_name,
[borgmatic_manifest_path],
config,
local_borg_version,
global_arguments,
local_path=bootstrap_arguments.local_path,
remote_path=bootstrap_arguments.remote_path,
extract_to_stdout=True,
)
manifest_json = extract_process.stdout.read()
if manifest_json:
break
else:
raise ValueError(
'Cannot read configuration paths from archive due to missing bootstrap manifest'
)
if manifest_json:
break
else:
raise ValueError(
'Cannot read configuration paths from archive due to missing bootstrap manifest'
)
try:
manifest_data = json.loads(manifest_json)

View file

@ -1,43 +1,254 @@
import importlib.metadata
import json
import glob
import itertools
import logging
import os
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 create_borgmatic_manifest(config, config_paths, dry_run):
def parse_pattern(pattern_line, default_style=borgmatic.borg.pattern.Pattern_style.NONE):
'''
Create a borgmatic manifest file to store the paths to the configuration files used to create
the archive.
Given a Borg pattern as a string, parse it into a borgmatic.borg.pattern.Pattern instance and
return it.
'''
if dry_run:
return
try:
(pattern_type, remainder) = pattern_line.split(' ', maxsplit=1)
except ValueError:
raise ValueError(f'Invalid pattern: {pattern_line}')
borgmatic_runtime_directory = borgmatic.config.paths.get_borgmatic_runtime_directory(config)
borgmatic_manifest_path = os.path.join(
borgmatic_runtime_directory, 'bootstrap', 'manifest.json'
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),
)
if not os.path.exists(borgmatic_manifest_path):
os.makedirs(os.path.dirname(borgmatic_manifest_path), exist_ok=True)
with open(borgmatic_manifest_path, 'w') as config_list_file:
json.dump(
{
'borgmatic_version': importlib.metadata.version('borgmatic'),
'config_paths': config_paths,
},
config_list_file,
def collect_patterns(config):
'''
Given a configuration dict, produce a single sequence of patterns comprised of the configured
source directories, patterns, excludes, pattern files, and exclude files.
The idea is that Borg has all these different ways of specifying includes, excludes, source
directories, etc., but we'd like to collapse them all down to one common format (patterns) for
ease of manipulation within borgmatic.
'''
try:
return (
tuple(
borgmatic.borg.pattern.Pattern(source_directory)
for source_directory in config.get('source_directories', ())
)
+ tuple(
parse_pattern(pattern_line.strip())
for pattern_line in config.get('patterns', ())
if not pattern_line.lstrip().startswith('#')
if pattern_line.strip()
)
+ tuple(
parse_pattern(
f'{borgmatic.borg.pattern.Pattern_type.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())
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.
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_patterns(patterns, working_directory=None, skip_paths=None):
'''
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 patterns is None:
return ()
return tuple(
itertools.chain.from_iterable(
(
(
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 device_map_patterns(patterns, working_directory=None):
'''
Given a sequence of borgmatic.borg.pattern.Pattern instances and an optional working directory,
determine the identifier for the device on which the pattern's path resides—or None if the path
doesn't exist or is from a non-root pattern. Return an updated sequence of patterns with the
device field populated. But if the device field is already set, don't bother setting it again.
This is handy for determining whether two different pattern paths are on the same filesystem
(have the same device identifier).
'''
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_patterns(patterns):
'''
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 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 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.
'''
deduplicated = {} # Use just the keys as an ordered set.
for pattern in patterns:
if pattern.type != borgmatic.borg.pattern.Pattern_type.ROOT:
deduplicated[pattern] = True
continue
parents = pathlib.PurePath(pattern.path).parents
# 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())
def process_patterns(patterns, working_directory, skip_expand_paths=None):
'''
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.
'''
skip_paths = set(skip_expand_paths or ())
return list(
deduplicate_patterns(
device_map_patterns(
expand_patterns(
patterns,
working_directory=working_directory,
skip_paths=skip_paths,
)
)
)
)
def run_create(
@ -71,54 +282,69 @@ def run_create(
global_arguments.dry_run,
**hook_context,
)
logger.info(f'{repository.get("label", repository["path"])}: Creating archive{dry_run_label}')
borgmatic.hooks.dispatch.call_hooks_even_if_unconfigured(
'remove_data_source_dumps',
config,
repository['path'],
borgmatic.hooks.dump.DATA_SOURCE_HOOK_NAMES,
global_arguments.dry_run,
)
active_dumps = borgmatic.hooks.dispatch.call_hooks(
'dump_data_sources',
config,
repository['path'],
borgmatic.hooks.dump.DATA_SOURCE_HOOK_NAMES,
global_arguments.dry_run,
)
if config.get('store_config_files', True):
create_borgmatic_manifest(
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
) as borgmatic_runtime_directory:
borgmatic.hooks.dispatch.call_hooks_even_if_unconfigured(
'remove_data_source_dumps',
config,
config_paths,
repository['path'],
borgmatic.hooks.dispatch.Hook_type.DATA_SOURCE,
borgmatic_runtime_directory,
global_arguments.dry_run,
)
patterns = process_patterns(collect_patterns(config), working_directory)
active_dumps = borgmatic.hooks.dispatch.call_hooks(
'dump_data_sources',
config,
repository['path'],
borgmatic.hooks.dispatch.Hook_type.DATA_SOURCE,
config_paths,
borgmatic_runtime_directory,
patterns,
global_arguments.dry_run,
)
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,
config_paths,
local_borg_version,
global_arguments,
local_path=local_path,
remote_path=remote_path,
progress=create_arguments.progress,
stats=create_arguments.stats,
json=create_arguments.json,
list_files=create_arguments.list_files,
stream_processes=stream_processes,
)
if json_output:
yield borgmatic.actions.json.parse_json(json_output, repository.get('label'))
# 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,
patterns,
local_borg_version,
global_arguments,
borgmatic_runtime_directory,
local_path=local_path,
remote_path=remote_path,
progress=create_arguments.progress,
stats=create_arguments.stats,
json=create_arguments.json,
list_files=create_arguments.list_files,
stream_processes=stream_processes,
)
if json_output:
yield borgmatic.actions.json.parse_json(json_output, repository.get('label'))
borgmatic.hooks.dispatch.call_hooks_even_if_unconfigured(
'remove_data_source_dumps',
config,
config_filename,
borgmatic.hooks.dispatch.Hook_type.DATA_SOURCE,
borgmatic_runtime_directory,
global_arguments.dry_run,
)
borgmatic.hooks.dispatch.call_hooks_even_if_unconfigured(
'remove_data_source_dumps',
config,
config_filename,
borgmatic.hooks.dump.DATA_SOURCE_HOOK_NAMES,
global_arguments.dry_run,
)
borgmatic.hooks.command.execute_hook(
config.get('after_backup'),
config.get('umask'),

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
@ -91,11 +145,13 @@ def strip_path_prefix_from_extracted_dump_destination(
if not databases_directory.endswith('_databases'):
continue
os.rename(subdirectory_path, os.path.join(borgmatic_runtime_directory, databases_directory))
shutil.move(
subdirectory_path, os.path.join(borgmatic_runtime_directory, databases_directory)
)
break
def restore_single_data_source(
def restore_single_dump(
repository,
config,
local_borg_version,
@ -106,24 +162,29 @@ def restore_single_data_source(
hook_name,
data_source,
connection_params,
borgmatic_runtime_directory,
):
'''
Given (among other things) an archive name, a data source hook name, the hostname, port,
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]
borgmatic_runtime_directory = borgmatic.config.paths.get_borgmatic_runtime_directory(config)
)[hook_name.split('_databases', 1)[0]]
destination_path = (
tempfile.mkdtemp(dir=borgmatic_runtime_directory)
@ -133,12 +194,16 @@ def restore_single_data_source(
try:
# Kick off a single data source extract. If using a directory format, extract to a temporary
# directory. Otheriwes extract the single dump file to stdout.
# directory. Otherwise extract the single dump file to stdout.
extract_process = borgmatic.borg.extract.extract_archive(
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,
@ -159,19 +224,20 @@ 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,
connection_params=connection_params,
borgmatic_runtime_directory=borgmatic_runtime_directory,
)
def collect_archive_data_source_names(
def collect_dumps_from_archive(
repository,
archive,
config,
@ -179,21 +245,21 @@ def collect_archive_data_source_names(
global_arguments,
local_path,
remote_path,
borgmatic_runtime_directory,
):
'''
Given a local or remote repository path, a resolved archive name, a configuration dict, the
local Borg version, global_arguments an argparse.Namespace, and local and remote Borg paths,
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))
)
borgmatic_runtime_directory = borgmatic.config.paths.get_borgmatic_runtime_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,
@ -202,10 +268,12 @@ 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_runtime_directory.lstrip('/'),
borgmatic.config.paths.make_runtime_directory_glob(borgmatic_runtime_directory),
borgmatic_source_directory.lstrip('/'),
)
],
@ -213,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"
)
@ -333,70 +436,85 @@ 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
):
return
logger.info(
f'{repository.get("label", repository["path"])}: Restoring data sources from archive {restore_arguments.archive}'
)
log_prefix = repository.get('label', repository['path'])
logger.info(f'{log_prefix}: Restoring data sources from archive {restore_arguments.archive}')
borgmatic.hooks.dispatch.call_hooks_even_if_unconfigured(
'remove_data_source_dumps',
config,
repository['path'],
borgmatic.hooks.dump.DATA_SOURCE_HOOK_NAMES,
global_arguments.dry_run,
)
with borgmatic.config.paths.Runtime_directory(
config, log_prefix
) as borgmatic_runtime_directory:
borgmatic.hooks.dispatch.call_hooks_even_if_unconfigured(
'remove_data_source_dumps',
config,
repository['path'],
borgmatic.hooks.dispatch.Hook_type.DATA_SOURCE,
borgmatic_runtime_directory,
global_arguments.dry_run,
)
archive_name = borgmatic.borg.repo_list.resolve_archive_name(
repository['path'],
restore_arguments.archive,
config,
local_borg_version,
global_arguments,
local_path,
remote_path,
)
archive_data_source_names = collect_archive_data_source_names(
repository['path'],
archive_name,
config,
local_borg_version,
global_arguments,
local_path,
remote_path,
)
restore_names = find_data_sources_to_restore(
restore_arguments.data_sources, archive_data_source_names
)
found_names = set()
remaining_restore_names = {}
connection_params = {
'hostname': restore_arguments.hostname,
'port': restore_arguments.port,
'username': restore_arguments.username,
'password': restore_arguments.password,
'restore_path': restore_arguments.restore_path,
}
archive_name = borgmatic.borg.repo_list.resolve_archive_name(
repository['path'],
restore_arguments.archive,
config,
local_borg_version,
global_arguments,
local_path,
remote_path,
)
dumps_from_archive = collect_dumps_from_archive(
repository['path'],
archive_name,
config,
local_borg_version,
global_arguments,
local_path,
remote_path,
borgmatic_runtime_directory,
)
dumps_to_restore = get_dumps_to_restore(restore_arguments, dumps_from_archive)
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
dumps_actually_restored = set()
connection_params = {
'hostname': restore_arguments.hostname,
'port': restore_arguments.port,
'username': restore_arguments.username,
'password': restore_arguments.password,
'restore_path': restore_arguments.restore_path,
}
# Restore each dump.
for restore_dump in dumps_to_restore:
found_data_source = get_configured_data_source(
config,
restore_dump,
log_prefix=repository['path'],
)
# For a dump that wasn't found via an exact match in the configuration, try to fallback
# to an "all" data source.
if not found_data_source:
remaining_restore_names.setdefault(found_hook_name or hook_name, []).append(
data_source_name
found_data_source = get_configured_data_source(
config,
Dump(restore_dump.hook_name, 'all', restore_dump.hostname, restore_dump.port),
log_prefix=repository['path'],
)
continue
found_names.add(data_source_name)
restore_single_data_source(
if not found_data_source:
continue
found_data_source = dict(found_data_source)
found_data_source['name'] = restore_dump.data_source_name
dumps_actually_restored.add(restore_dump)
restore_single_dump(
repository,
config,
local_borg_version,
@ -404,45 +522,19 @@ def run_restore(
local_path,
remote_path,
archive_name,
found_hook_name or hook_name,
restore_dump.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'
)
borgmatic.hooks.dispatch.call_hooks_even_if_unconfigured(
'remove_data_source_dumps',
config,
repository['path'],
borgmatic.hooks.dispatch.Hook_type.DATA_SOURCE,
borgmatic_runtime_directory,
global_arguments.dry_run,
)
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
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.hooks.dispatch.call_hooks_even_if_unconfigured(
'remove_data_source_dumps',
config,
repository['path'],
borgmatic.hooks.dump.DATA_SOURCE_HOOK_NAMES,
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)

View file

@ -150,6 +150,7 @@ def check_archives(
)
max_duration = check_arguments.max_duration or repository_check_config.get('max_duration')
umask = config.get('umask')
borg_environment = environment.make_environment(config)
borg_exit_codes = config.get('borg_exit_codes')
@ -160,6 +161,7 @@ def check_archives(
+ (('--max-duration', str(max_duration)) if max_duration else ())
+ make_check_name_flags(checks, archive_filter_flags)
+ (('--remote-path', remote_path) if remote_path else ())
+ (('--umask', str(umask)) if umask else ())
+ (('--log-json',) if global_arguments.log_json else ())
+ (('--lock-wait', str(lock_wait)) if lock_wait else ())
+ verbosity_flags

View file

@ -1,4 +1,3 @@
import glob
import itertools
import logging
import os
@ -7,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
@ -20,167 +20,42 @@ from borgmatic.execute import (
logger = logging.getLogger(__name__)
def expand_directory(directory, working_directory):
def write_patterns_file(patterns, borgmatic_runtime_directory, log_prefix, patterns_file=None):
'''
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))
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 glob.glob(expanded_directory) or [expanded_directory]
Use the given log prefix in any logging.
def expand_directories(directories, working_directory=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.
'''
if directories is None:
return ()
return tuple(
itertools.chain.from_iterable(
expand_directory(directory, working_directory) for directory in directories
)
)
def expand_home_directories(directories):
'''
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 ()
return tuple(os.path.expanduser(directory) for directory in directories)
def map_directories_to_devices(directories, 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.
This is handy for determining whether two different directories 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),)
}
def deduplicate_directories(directory_devices, additional_directory_devices):
'''
Given a map from directory to the identifier for the device on which that directory resides,
return the directories as a sorted tuple with all duplicate child directories removed. For
instance, if paths is ('/foo', '/foo/bar'), return just: ('/foo',)
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 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
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}
for directory in directories:
deduplicated.add(directory)
parents = pathlib.PurePath(directory).parents
# 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
return tuple(sorted(deduplicated))
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(
@ -191,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):
@ -221,39 +90,14 @@ def make_list_filter_flags(local_borg_version, dry_run):
return f'{base_flags}-'
def collect_borgmatic_runtime_directories(borgmatic_runtime_directory):
'''
Return a list of borgmatic-specific runtime directories used for temporary runtime data like
streaming database dumps and bootstrap metadata. If no such directories exist, return an empty
list.
'''
return [borgmatic_runtime_directory] if os.path.exists(borgmatic_runtime_directory) else []
ROOT_PATTERN_PREFIX = 'R '
def pattern_root_directories(patterns=None):
'''
Given a sequence of patterns, parse out and return just the root directories.
'''
if not patterns:
return []
return [
pattern.split(ROOT_PATTERN_PREFIX, maxsplit=1)[1]
for pattern in patterns
if pattern.startswith(ROOT_PATTERN_PREFIX)
]
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
@ -273,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.
@ -299,32 +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, working_directory=None):
def check_all_root_patterns_exist(patterns):
'''
Given a sequence of source directories and an optional working directory to serve as a prefix
for each (if it's a relative directory), 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 all(
[
os.path.exists(os.path.join(working_directory or '', directory))
for directory in expand_directory(source_directory, working_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
@ -334,10 +196,10 @@ def make_base_create_command(
dry_run,
repository_path,
config,
config_paths,
patterns,
local_borg_version,
global_arguments,
borgmatic_runtime_directories,
borgmatic_runtime_directory,
local_path='borg',
remote_path=None,
progress=False,
@ -346,44 +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).
'''
working_directory = borgmatic.config.paths.get_working_directory(config)
if config.get('source_directories_must_exist', False):
check_all_source_directories_exist(
config.get('source_directories'), working_directory=working_directory
)
check_all_root_patterns_exist(patterns)
sources = deduplicate_directories(
map_directories_to_devices(
expand_directories(
tuple(config.get('source_directories', ()))
+ borgmatic_runtime_directories
+ tuple(config_paths if config.get('store_config_files', True) else ()),
working_directory=working_directory,
)
),
additional_directory_devices=map_directories_to_devices(
expand_directories(
pattern_root_directories(config.get('patterns')),
working_directory=working_directory,
)
),
patterns_file = write_patterns_file(
patterns, borgmatic_runtime_directory, log_prefix=repository_path
)
ensure_files_readable(config.get('patterns_from'), config.get('exclude_from'))
pattern_file = (
write_pattern_file(config.get('patterns'), sources)
if config.get('patterns') or config.get('patterns_from')
else None
)
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)
@ -426,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 ())
@ -457,7 +293,7 @@ def make_base_create_command(
create_positional_arguments = flags.make_repository_archive_flags(
repository_path, archive_name_format, local_borg_version
) + (sources 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.
@ -466,15 +302,17 @@ def make_base_create_command(
f'{repository_path}: Ignoring configured "read_special" value of false, as true is needed for database hooks.'
)
borg_environment = environment.make_environment(config)
working_directory = borgmatic.config.paths.get_working_directory(config)
logger.debug(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_directories,
borgmatic_runtime_directory=borgmatic_runtime_directory,
)
if special_file_paths:
@ -486,24 +324,34 @@ 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,
config_paths,
patterns,
local_borg_version,
global_arguments,
borgmatic_runtime_directory,
local_path='borg',
remote_path=None,
progress=False,
@ -513,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).
@ -523,29 +371,21 @@ def create_archive(
borgmatic.logger.add_custom_log_levels()
working_directory = borgmatic.config.paths.get_working_directory(config)
borgmatic_runtime_directories = expand_directories(
collect_borgmatic_runtime_directories(
borgmatic.config.paths.get_borgmatic_runtime_directory(config)
),
working_directory=working_directory,
)
(create_flags, create_positional_arguments, pattern_file, exclude_file) = (
make_base_create_command(
dry_run,
repository_path,
config,
config_paths,
local_borg_version,
global_arguments,
borgmatic_runtime_directories,
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:

View file

@ -31,6 +31,7 @@ def make_delete_command(
+ (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ())
+ borgmatic.borg.flags.make_flags('dry-run', global_arguments.dry_run)
+ borgmatic.borg.flags.make_flags('remote-path', remote_path)
+ borgmatic.borg.flags.make_flags('umask', config.get('umask'))
+ borgmatic.borg.flags.make_flags('log-json', global_arguments.log_json)
+ borgmatic.borg.flags.make_flags('lock-wait', config.get('lock_wait'))
+ borgmatic.borg.flags.make_flags('list', delete_arguments.list_archives)

View file

@ -1,3 +1,5 @@
import os
OPTION_TO_ENVIRONMENT_VARIABLE = {
'borg_base_directory': 'BORG_BASE_DIR',
'borg_config_directory': 'BORG_CONFIG_DIR',
@ -38,8 +40,9 @@ def make_environment(config):
option_name,
environment_variable_name,
) in DEFAULT_BOOL_OPTION_TO_DOWNCASE_ENVIRONMENT_VARIABLE.items():
value = config.get(option_name)
environment[environment_variable_name] = 'yes' if value else 'no'
if os.environ.get(environment_variable_name) is None:
value = config.get(option_name)
environment[environment_variable_name] = 'yes' if value else 'no'
for (
option_name,

View file

@ -36,6 +36,7 @@ def make_info_command(
else ()
)
+ flags.make_flags('remote-path', remote_path)
+ flags.make_flags('umask', config.get('umask'))
+ flags.make_flags('log-json', global_arguments.log_json)
+ flags.make_flags('lock-wait', config.get('lock_wait'))
+ (

View file

@ -34,8 +34,6 @@ def make_list_command(
and local and remote Borg paths, return a command as a tuple to list archives or paths within an
archive.
'''
lock_wait = config.get('lock_wait', None)
return (
(local_path, 'list')
+ (
@ -49,8 +47,9 @@ def make_list_command(
else ()
)
+ flags.make_flags('remote-path', remote_path)
+ flags.make_flags('umask', config.get('umask'))
+ flags.make_flags('log-json', global_arguments.log_json)
+ flags.make_flags('lock-wait', lock_wait)
+ flags.make_flags('lock-wait', config.get('lock_wait'))
+ flags.make_flags_from_arguments(list_arguments, excludes=MAKE_FLAGS_EXCLUDES)
+ (
flags.make_repository_archive_flags(
@ -121,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,
@ -133,7 +132,7 @@ def capture_archive_listing(
borg_exit_codes=config.get('borg_exit_codes'),
)
.strip('\n')
.split('\n')
.split('\0')
)
@ -231,7 +230,7 @@ def list_archive(
borg_exit_codes=borg_exit_codes,
)
.strip('\n')
.split('\n')
.splitlines()
)
else:
archive_lines = (list_arguments.archive,)

31
borgmatic/borg/pattern.py Normal file
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,
),
)

View file

@ -64,6 +64,7 @@ def create_repository(
raise
lock_wait = config.get('lock_wait')
umask = config.get('umask')
extra_borg_options = config.get('extra_borg_options', {}).get('repo-create', '')
repo_create_command = (
@ -84,6 +85,7 @@ def create_repository(
+ (('--log-json',) if global_arguments.log_json else ())
+ (('--lock-wait', str(lock_wait)) if lock_wait else ())
+ (('--remote-path', remote_path) if remote_path else ())
+ (('--umask', str(umask)) if umask else ())
+ (tuple(extra_borg_options.split(' ')) if extra_borg_options else ())
+ flags.make_repository_flags(repository_path, local_borg_version)
)

View file

@ -36,6 +36,7 @@ def make_repo_delete_command(
+ (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ())
+ borgmatic.borg.flags.make_flags('dry-run', global_arguments.dry_run)
+ borgmatic.borg.flags.make_flags('remote-path', remote_path)
+ borgmatic.borg.flags.make_flags('umask', config.get('umask'))
+ borgmatic.borg.flags.make_flags('log-json', global_arguments.log_json)
+ borgmatic.borg.flags.make_flags('lock-wait', config.get('lock_wait'))
+ borgmatic.borg.flags.make_flags('list', repo_delete_arguments.list_archives)

View file

@ -43,6 +43,7 @@ def display_repository_info(
else ()
)
+ flags.make_flags('remote-path', remote_path)
+ flags.make_flags('umask', config.get('umask'))
+ flags.make_flags('log-json', global_arguments.log_json)
+ flags.make_flags('lock-wait', lock_wait)
+ (('--json',) if repo_info_arguments.json else ())

View file

@ -39,6 +39,7 @@ def resolve_archive_name(
),
)
+ flags.make_flags('remote-path', remote_path)
+ flags.make_flags('umask', config.get('umask'))
+ flags.make_flags('log-json', global_arguments.log_json)
+ flags.make_flags('lock-wait', config.get('lock_wait'))
+ flags.make_flags('last', 1)
@ -100,6 +101,7 @@ def make_repo_list_command(
else ()
)
+ flags.make_flags('remote-path', remote_path)
+ flags.make_flags('umask', config.get('umask'))
+ flags.make_flags('log-json', global_arguments.log_json)
+ flags.make_flags('lock-wait', config.get('lock_wait'))
+ (

View file

@ -30,6 +30,7 @@ def transfer_archives(
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
+ (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ())
+ flags.make_flags('remote-path', remote_path)
+ flags.make_flags('umask', config.get('umask'))
+ flags.make_flags('log-json', global_arguments.log_json)
+ flags.make_flags('lock-wait', config.get('lock_wait', None))
+ (

View file

@ -876,7 +876,7 @@ def make_parsers():
)
config_bootstrap_group.add_argument(
'--user-runtime-directory',
help='Path used for temporary runtime data like bootstrap metadata. Defaults to $XDG_RUNTIME_DIR or /var/run/$UID',
help='Path used for temporary runtime data like bootstrap metadata. Defaults to $XDG_RUNTIME_DIR or $TMPDIR or $TEMP or /var/run/$UID',
)
config_bootstrap_group.add_argument(
'--borgmatic-source-directory',
@ -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'
)
@ -1244,6 +1257,12 @@ def make_parsers():
metavar='TIMESPAN',
help='List archives that are newer than the specified time range (e.g. 7d or 12m) from the current time [Borg 2.x+ only]',
)
repo_list_group.add_argument(
'--deleted',
default=False,
action='store_true',
help="List only deleted archives that haven't yet been compacted [Borg 2.x+ only]",
)
repo_list_group.add_argument(
'-h', '--help', action='help', help='Show this help message and exit'
)

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,
)
@ -793,9 +792,6 @@ def collect_configuration_run_summary_logs(configs, config_paths, arguments):
break
try:
if 'extract' in arguments or 'mount' in arguments:
validate.guard_single_repository_selected(repository, configs)
validate.guard_configuration_contains_repository(repository, configs)
except ValueError as error:
yield from log_error_records(str(error))
@ -917,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),

View file

@ -44,12 +44,12 @@ def schema_to_sample_configuration(schema, level=0, parent_is_sequence=False):
if example is not None:
return example
if schema_type == 'array':
if schema_type == 'array' or (isinstance(schema_type, list) and 'array' in schema_type):
config = ruamel.yaml.comments.CommentedSeq(
[schema_to_sample_configuration(schema['items'], level, parent_is_sequence=True)]
)
add_comments_to_configuration_sequence(config, schema, indent=(level * INDENT))
elif schema_type == 'object':
elif schema_type == 'object' or (isinstance(schema_type, list) and 'object' in schema_type):
config = ruamel.yaml.comments.CommentedMap(
[
(field_name, schema_to_sample_configuration(sub_schema, level + 1))

View file

@ -93,6 +93,25 @@ def normalize(config_filename, config):
)
config['exclude_if_present'] = [exclude_if_present]
# Unconditionally set the bootstrap hook so that it's enabled by default and config files get
# stored in each Borg archive.
config.setdefault('bootstrap', {})
# Move store_config_files from the global scope to the bootstrap hook.
store_config_files = config.get('store_config_files')
if store_config_files is not None:
logs.append(
logging.makeLogRecord(
dict(
levelno=logging.WARNING,
levelname='WARNING',
msg=f'{config_filename}: The store_config_files option has moved under the bootstrap hook. Specifying store_config_files at the global scope is deprecated and support will be removed from a future release.',
)
)
)
del config['store_config_files']
config['bootstrap']['store_config_files'] = store_config_files
# Upgrade various monitoring hooks from a string to a dict.
healthchecks = config.get('healthchecks')
if isinstance(healthchecks, str):

View file

@ -1,4 +1,8 @@
import logging
import os
import tempfile
logger = logging.getLogger(__name__)
def expand_user_in_path(path):
@ -26,25 +30,135 @@ def get_borgmatic_source_directory(config):
return expand_user_in_path(config.get('borgmatic_source_directory') or '~/.borgmatic')
def get_borgmatic_runtime_directory(config):
'''
Given a configuration dict, get the borgmatic runtime directory used for storing temporary
runtime data like streaming database dumps and bootstrap metadata. Defaults to
$XDG_RUNTIME_DIR/./borgmatic or /run/user/$UID/./borgmatic.
TEMPORARY_DIRECTORY_PREFIX = 'borgmatic-'
The "/./" is taking advantage of a Borg feature such that the part of the path before the "/./"
does not get stored in the file path within an archive. That way, the path of the runtime
directory can change without leaving database dumps within an archive inaccessible.
def replace_temporary_subdirectory_with_glob(
path, temporary_directory_prefix=TEMPORARY_DIRECTORY_PREFIX
):
'''
return expand_user_in_path(
os.path.join(
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
... replace it with:
/tmp/borgmatic-*/borgmatic
This is useful for finding previous temporary directories from prior borgmatic runs.
'''
return os.path.join(
'/',
*(
(
f'{temporary_directory_prefix}*'
if subdirectory.startswith(temporary_directory_prefix)
else subdirectory
)
for subdirectory in path.split(os.path.sep)
),
)
class Runtime_directory:
'''
A Python context manager for creating and cleaning up the borgmatic runtime directory used for
storing temporary runtime data like streaming database dumps and bootstrap metadata.
Example use as a context manager:
with borgmatic.config.paths.Runtime_directory(config) as borgmatic_runtime_directory:
do_something_with(borgmatic_runtime_directory)
For the scope of that "with" statement, the runtime directory is available. Afterwards, it
automatically gets cleaned up as necessary.
'''
def __init__(self, config, log_prefix):
'''
Given a configuration dict and a log prefix, determine the borgmatic runtime directory,
creating a secure, temporary directory within it if necessary. Defaults to
$XDG_RUNTIME_DIR/./borgmatic or $RUNTIME_DIRECTORY/./borgmatic or
$TMPDIR/borgmatic-[random]/./borgmatic or $TEMP/borgmatic-[random]/./borgmatic or
/tmp/borgmatic-[random]/./borgmatic where "[random]" is a randomly generated string intended
to avoid path collisions.
If XDG_RUNTIME_DIR or RUNTIME_DIRECTORY is set and already ends in "/borgmatic", then don't
tack on a second "/borgmatic" path component.
The "/./" is taking advantage of a Borg feature such that the part of the path before the "/./"
does not get stored in the file path within an archive. That way, the path of the runtime
directory can change without leaving database dumps within an archive inaccessible.
'''
runtime_directory = (
config.get('user_runtime_directory')
or os.environ.get(
'XDG_RUNTIME_DIR',
f'/run/user/{os.getuid()}',
),
'.',
'borgmatic',
or os.environ.get('XDG_RUNTIME_DIR') # Set by PAM on Linux.
or os.environ.get('RUNTIME_DIRECTORY') # Set by systemd if configured.
)
if runtime_directory:
if not runtime_directory.startswith(os.path.sep):
raise ValueError('The runtime directory must be an absolute path')
self.temporary_directory = None
else:
base_directory = os.environ.get('TMPDIR') or os.environ.get('TEMP') or '/tmp'
if not base_directory.startswith(os.path.sep):
raise ValueError('The temporary directory must be an absolute path')
os.makedirs(base_directory, mode=0o700, exist_ok=True)
self.temporary_directory = tempfile.TemporaryDirectory(
prefix=TEMPORARY_DIRECTORY_PREFIX,
dir=base_directory,
)
runtime_directory = self.temporary_directory.name
(base_path, final_directory) = os.path.split(runtime_directory.rstrip(os.path.sep))
self.runtime_path = expand_user_in_path(
os.path.join(
base_path if final_directory == 'borgmatic' else runtime_directory,
'.', # Borg 1.4+ "slashdot" hack.
'borgmatic',
)
)
os.makedirs(self.runtime_path, mode=0o700, exist_ok=True)
logger.debug(f'{log_prefix}: Using runtime directory {os.path.normpath(self.runtime_path)}')
def __enter__(self):
'''
Return the borgmatic runtime path as a string.
'''
return self.runtime_path
def __exit__(self, exception, value, traceback):
'''
Delete any temporary directory that was created as part of initialization.
'''
if self.temporary_directory:
try:
self.temporary_directory.cleanup()
# The cleanup() call errors if, for instance, there's still a
# mounted filesystem within the temporary directory. There's
# nothing we can do about that here, so swallow the error.
except OSError:
pass
def make_runtime_directory_glob(borgmatic_runtime_directory):
'''
Given a borgmatic runtime directory path, make a glob that would match that path, specifically
replacing any randomly generated temporary subdirectory with "*" since such a directory's name
changes on every borgmatic run.
'''
return os.path.join(
*(
'*' if subdirectory.startswith(TEMPORARY_DIRECTORY_PREFIX) else subdirectory
for subdirectory in os.path.normpath(borgmatic_runtime_directory).split(os.path.sep)
)
)
@ -58,10 +172,9 @@ def get_borgmatic_state_directory(config):
return expand_user_in_path(
os.path.join(
config.get('user_state_directory')
or os.environ.get(
'XDG_STATE_HOME',
'~/.local/state',
),
or os.environ.get('XDG_STATE_HOME')
or os.environ.get('STATE_DIRECTORY') # Set by systemd if configured.
or '~/.local/state',
'borgmatic',
)
)

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:
@ -209,8 +205,8 @@ properties:
description: |
Deprecated. Only used for locating database dumps and bootstrap
metadata within backup archives created prior to deprecation.
Replaced by borgmatic_runtime_directory and
borgmatic_state_directory. Defaults to ~/.borgmatic
Replaced by user_runtime_directory and user_state_directory.
Defaults to ~/.borgmatic
example: /tmp/borgmatic
user_runtime_directory:
type: string
@ -218,8 +214,8 @@ properties:
Path for storing temporary runtime data like streaming database
dumps and bootstrap metadata. borgmatic automatically creates and
uses a "borgmatic" subdirectory here. Defaults to $XDG_RUNTIME_DIR
or /run/user/$UID.
example: /run/user/1001/borgmatic
or or $TMPDIR or $TEMP or /run/user/$UID.
example: /run/user/1001
user_state_directory:
type: string
description: |
@ -229,18 +225,11 @@ properties:
create the check records again (and therefore re-run checks).
Defaults to $XDG_STATE_HOME or ~/.local/state.
example: /var/lib/borgmatic
store_config_files:
type: boolean
description: |
Store configuration files used to create a backup in the backup
itself. Defaults to true. Changing this to false prevents "borgmatic
bootstrap" from extracting configuration files from the backup.
example: false
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
@ -942,6 +931,20 @@ properties:
of them (after any action).
example:
- "echo Completed actions."
bootstrap:
type: object
properties:
store_config_files:
type: boolean
description: |
Store configuration files used to create a backup inside the
backup itself. Defaults to true. Changing this to false
prevents "borgmatic bootstrap" from extracting configuration
files from the backup.
example: false
description: |
Support for the "borgmatic bootstrap" action, used to extract
borgmatic configuration files from a backup archive.
postgresql_databases:
type: array
items:
@ -956,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
@ -1138,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
@ -1265,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
@ -1400,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
@ -1422,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
@ -1626,6 +1626,264 @@ properties:
example:
- start
- finish
pushover:
type: object
required: ['token', 'user']
additionalProperties: false
properties:
token:
type: string
description: |
Your application's API token.
example: 7ms6TXHpTokTou2P6x4SodDeentHRa
user:
type: string
description: |
Your user/group key (or that of your target user), viewable
when logged into your dashboard: often referred to as
USER_KEY in Pushover documentation and code examples.
example: hwRwoWsXMBWwgrSecfa9EfPey55WSN
start:
type: object
properties:
message:
type: string
description: |
Message to be sent to the user or group. If omitted
the default is the name of the state.
example: A backup job has started.
priority:
type: integer
description: |
A value of -2, -1, 0 (default), 1 or 2 that
indicates the message priority.
example: 0
expire:
type: integer
description: |
How many seconds your notification will continue
to be retried (every retry seconds). Defaults to
600. This settings only applies to priority 2
notifications.
example: 600
retry:
type: integer
description: |
The retry parameter specifies how often
(in seconds) the Pushover servers will send the
same notification to the user. Defaults to 30. This
settings only applies to priority 2 notifications.
example: 30
device:
type: string
description: |
The name of one of your devices to send just to
that device instead of all devices.
example: pixel8
html:
type: boolean
description: |
Set to True to enable HTML parsing of the message.
Set to False for plain text.
example: True
sound:
type: string
description: |
The name of a supported sound to override your
default sound choice. All options can be found
here: https://pushover.net/api#sounds
example: bike
title:
type: string
description: |
Your message's title, otherwise your app's name is
used.
example: A backup job has started.
ttl:
type: integer
description: |
The number of seconds that the message will live,
before being deleted automatically. The ttl
parameter is ignored for messages with a priority.
value of 2.
example: 3600
url:
type: string
description: |
A supplementary URL to show with your message.
example: https://pushover.net/apps/xxxxx-borgbackup
url_title:
type: string
description: |
A title for the URL specified as the url parameter,
otherwise just the URL is shown.
example: Pushover Link
finish:
type: object
properties:
message:
type: string
description: |
Message to be sent to the user or group. If omitted
the default is the name of the state.
example: A backup job has finished.
priority:
type: integer
description: |
A value of -2, -1, 0 (default), 1 or 2 that
indicates the message priority.
example: 0
expire:
type: integer
description: |
How many seconds your notification will continue
to be retried (every retry seconds). Defaults to
600. This settings only applies to priority 2
notifications.
example: 600
retry:
type: integer
description: |
The retry parameter specifies how often
(in seconds) the Pushover servers will send the
same notification to the user. Defaults to 30. This
settings only applies to priority 2 notifications.
example: 30
device:
type: string
description: |
The name of one of your devices to send just to
that device instead of all devices.
example: pixel8
html:
type: boolean
description: |
Set to True to enable HTML parsing of the message.
Set to False for plain text.
example: True
sound:
type: string
description: |
The name of a supported sound to override your
default sound choice. All options can be found
here: https://pushover.net/api#sounds
example: bike
title:
type: string
description: |
Your message's title, otherwise your app's name is
used.
example: A backup job has started.
ttl:
type: integer
description: |
The number of seconds that the message will live,
before being deleted automatically. The ttl
parameter is ignored for messages with a priority.
value of 2.
example: 3600
url:
type: string
description: |
A supplementary URL to show with your message.
example: https://pushover.net/apps/xxxxx-borgbackup
url_title:
type: string
description: |
A title for the URL specified as the url parameter,
otherwise just the URL is shown.
example: Pushover Link
fail:
type: object
properties:
message:
type: string
description: |
Message to be sent to the user or group. If omitted
the default is the name of the state.
example: A backup job has failed.
priority:
type: integer
description: |
A value of -2, -1, 0 (default), 1 or 2 that
indicates the message priority.
example: 0
expire:
type: integer
description: |
How many seconds your notification will continue
to be retried (every retry seconds). Defaults to
600. This settings only applies to priority 2
notifications.
example: 600
retry:
type: integer
description: |
The retry parameter specifies how often
(in seconds) the Pushover servers will send the
same notification to the user. Defaults to 30. This
settings only applies to priority 2 notifications.
example: 30
device:
type: string
description: |
The name of one of your devices to send just to
that device instead of all devices.
example: pixel8
html:
type: boolean
description: |
Set to True to enable HTML parsing of the message.
Set to False for plain text.
example: True
sound:
type: string
description: |
The name of a supported sound to override your
default sound choice. All options can be found
here: https://pushover.net/api#sounds
example: bike
title:
type: string
description: |
Your message's title, otherwise your app's name is
used.
example: A backup job has started.
ttl:
type: integer
description: |
The number of seconds that the message will live,
before being deleted automatically. The ttl
parameter is ignored for messages with a priority.
value of 2.
example: 3600
url:
type: string
description: |
A supplementary URL to show with your message.
example: https://pushover.net/apps/xxxxx-borgbackup
url_title:
type: string
description: |
A title for the URL specified as the url parameter,
otherwise just the URL is shown.
example: Pushover Link
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 failure
only.
example:
- start
- finish
zabbix:
type: object
additionalProperties: false
@ -1997,7 +2255,130 @@ properties:
config: "__config"
hostname: "__hostname"
description: |
Configuration for a monitoring integration with Grafana loki. You
Configuration for a monitoring integration with Grafana Loki. You
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
properties:
zfs_command:
type: string
description: |
Command to use instead of "zfs".
example: /usr/local/bin/zfs
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 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).

View file

@ -199,26 +199,3 @@ def guard_configuration_contains_repository(repository, configurations):
if count == 0:
raise ValueError(f'Repository "{repository}" not found in configuration files')
def guard_single_repository_selected(repository, configurations):
'''
Given a repository path and a dict mapping from config filename to corresponding parsed config
dict, ensure either a single repository exists across all configuration files or a repository
path was given.
'''
if repository:
return
count = len(
tuple(
config_repository
for config in configurations.values()
for config_repository in config['repositories']
)
)
if count != 1:
raise ValueError(
"Can't determine which repository to use. Use --repository to disambiguate"
)

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)

View file

View file

@ -0,0 +1,129 @@
import glob
import importlib
import json
import logging
import os
import borgmatic.borg.pattern
import borgmatic.config.paths
logger = logging.getLogger(__name__)
def use_streaming(hook_config, config, log_prefix): # pragma: no cover
'''
Return whether dump streaming is used for this hook. (Spoiler: It isn't.)
'''
return False
def dump_data_sources(
hook_config,
config,
log_prefix,
config_paths,
borgmatic_runtime_directory,
patterns,
dry_run,
):
'''
Given a bootstrap configuration dict, a configuration dict, a log prefix, the borgmatic
configuration file paths, the borgmatic runtime directory, the configured patterns, and whether
this is a dry run, create a borgmatic manifest file to store the paths of the configuration
files used to create the archive. But skip this if the bootstrap store_config_files option is
False or if this is a dry run.
Return an empty sequence, since there are no ongoing dump processes from this hook.
'''
if hook_config and hook_config.get('store_config_files') is False:
return []
borgmatic_manifest_path = os.path.join(
borgmatic_runtime_directory, 'bootstrap', 'manifest.json'
)
if dry_run:
return []
os.makedirs(os.path.dirname(borgmatic_manifest_path), exist_ok=True)
with open(borgmatic_manifest_path, 'w') as manifest_file:
json.dump(
{
'borgmatic_version': importlib.metadata.version('borgmatic'),
'config_paths': config_paths,
},
manifest_file,
)
patterns.extend(borgmatic.borg.pattern.Pattern(config_path) for config_path in config_paths)
patterns.append(
borgmatic.borg.pattern.Pattern(os.path.join(borgmatic_runtime_directory, 'bootstrap'))
)
return []
def remove_data_source_dumps(hook_config, config, log_prefix, borgmatic_runtime_directory, dry_run):
'''
Given a bootstrap configuration dict, a configuration dict, a log prefix, the borgmatic runtime
directory, and whether this is a dry run, then remove the manifest file created above. If this
is a dry run, then don't actually remove anything.
'''
dry_run_label = ' (dry run; not actually removing anything)' if dry_run else ''
manifest_glob = os.path.join(
borgmatic.config.paths.replace_temporary_subdirectory_with_glob(
os.path.normpath(borgmatic_runtime_directory),
),
'bootstrap',
)
logger.debug(
f'{log_prefix}: Looking for bootstrap manifest files to remove in {manifest_glob}{dry_run_label}'
)
for manifest_directory in glob.glob(manifest_glob):
manifest_file_path = os.path.join(manifest_directory, 'manifest.json')
logger.debug(
f'{log_prefix}: Removing bootstrap manifest at {manifest_file_path}{dry_run_label}'
)
if dry_run:
continue
try:
os.remove(manifest_file_path)
except FileNotFoundError:
pass
try:
os.rmdir(manifest_directory)
except FileNotFoundError:
pass
def make_data_source_dump_patterns(
hook_config, config, log_prefix, borgmatic_runtime_directory, name=None
): # pragma: no cover
'''
Restores are implemented via the separate, purpose-specific "bootstrap" action rather than the
generic "restore".
'''
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 are implemented via the separate, purpose-specific "bootstrap" action rather than the
generic "restore".
'''
raise NotImplementedError()

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()

View file

@ -5,13 +5,7 @@ import shutil
logger = logging.getLogger(__name__)
DATA_SOURCE_HOOK_NAMES = (
'mariadb_databases',
'mysql_databases',
'mongodb_databases',
'postgresql_databases',
'sqlite_databases',
)
IS_A_HOOK = False
def make_data_source_dump_path(borgmatic_runtime_directory, data_source_hook_name):
@ -22,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):

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()

View file

@ -3,26 +3,23 @@ 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__)
def make_dump_path(config, base_directory=None): # pragma: no cover
def make_dump_path(base_directory): # pragma: no cover
'''
Given a configuration dict and an optional base directory, make the corresponding dump path. If
a base directory isn't provided, use the borgmatic runtime directory.
Given a base directory, make the corresponding dump path.
'''
return dump.make_data_source_dump_path(
base_directory or borgmatic.config.paths.get_borgmatic_runtime_directory(config),
'mariadb_databases',
)
return dump.make_data_source_dump_path(base_directory, 'mariadb_databases')
SYSTEM_DATABASE_NAMES = ('information_schema', 'mysql', 'performance_schema', 'sys')
@ -77,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):
@ -118,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
@ -126,15 +130,25 @@ def use_streaming(databases, config, log_prefix):
return any(databases)
def dump_data_sources(databases, config, log_prefix, dry_run):
def dump_data_sources(
databases,
config,
log_prefix,
config_paths,
borgmatic_runtime_directory,
patterns,
dry_run,
):
'''
Dump the given MariaDB databases to a named pipe. The databases are supplied as a sequence of
dicts, one dict describing each database as per the configuration schema. Use the given
configuration dict to construct the destination path and the given log prefix in any log
entries.
borgmatic runtime directory to construct the destination path and the given log prefix in any
log entries.
Return a sequence of subprocess.Popen instances for the dump processes ready to spew to a named
pipe. But if this is a dry run, then don't actually dump anything and return an empty sequence.
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 = []
@ -142,7 +156,7 @@ def dump_data_sources(databases, config, log_prefix, dry_run):
logger.info(f'{log_prefix}: Dumping MariaDB databases{dry_run_label}')
for database in databases:
dump_path = make_dump_path(config)
dump_path = make_dump_path(borgmatic_runtime_directory)
extra_environment = {'MYSQL_PWD': database['password']} if 'password' in database else None
dump_database_names = database_names_to_dump(
database, extra_environment, log_prefix, dry_run
@ -182,46 +196,65 @@ def dump_data_sources(databases, config, log_prefix, dry_run):
)
)
if not dry_run:
patterns.append(
borgmatic.borg.pattern.Pattern(
os.path.join(borgmatic_runtime_directory, 'mariadb_databases')
)
)
return [process for process in processes if process]
def remove_data_source_dumps(databases, config, log_prefix, dry_run): # pragma: no cover
def remove_data_source_dumps(
databases, config, log_prefix, borgmatic_runtime_directory, dry_run
): # pragma: no cover
'''
Remove all database dump files for this hook regardless of the given databases. Use the given
configuration dict to construct the destination path and the log prefix in any log entries. If
this is a dry run, then don't actually remove anything.
Remove all database dump files for this hook regardless of the given databases. Use the
borgmatic_runtime_directory to construct the destination path and the log prefix in any log
entries. If this is a dry run, then don't actually remove anything.
'''
dump.remove_data_source_dumps(make_dump_path(config), 'MariaDB', log_prefix, dry_run)
dump.remove_data_source_dumps(
make_dump_path(borgmatic_runtime_directory), 'MariaDB', log_prefix, dry_run
)
def make_data_source_dump_patterns(databases, config, log_prefix, name=None): # pragma: no cover
def make_data_source_dump_patterns(
databases, config, log_prefix, borgmatic_runtime_directory, name=None
): # pragma: no cover
'''
Given a sequence of configurations dicts, a configuration dict, a prefix to log with, and a
database name to match, return the corresponding glob patterns to match the database dump in an
archive.
Given a sequence of configurations dicts, a configuration dict, a prefix to log with, the
borgmatic runtime directory, and a database name to match, return the corresponding glob
patterns to match the database dump in an archive.
'''
borgmatic_source_directory = borgmatic.config.paths.get_borgmatic_source_directory(config)
return (
dump.make_data_source_dump_filename(make_dump_path('borgmatic'), name, hostname='*'),
dump.make_data_source_dump_filename(
make_dump_path(config, 'borgmatic'), name, hostname='*'
make_dump_path(borgmatic_runtime_directory), name, hostname='*'
),
dump.make_data_source_dump_filename(make_dump_path(config), name, hostname='*'),
dump.make_data_source_dump_filename(
make_dump_path(config, borgmatic_source_directory), name, hostname='*'
make_dump_path(borgmatic_source_directory), name, hostname='*'
),
)
def restore_data_source_dump(
hook_config, config, log_prefix, data_source, dry_run, extract_process, connection_params
hook_config,
config,
log_prefix,
data_source,
dry_run,
extract_process,
connection_params,
borgmatic_runtime_directory,
):
'''
Restore a database from the given extract stream. The database is supplied as a data source
configuration dict, but the given hook configuration is ignored. The given configuration dict is
used to construct the destination path, and the given log prefix is used for any log entries. If
this is a dry run, then don't actually restore anything. Trigger the given active extract
process (an instance of subprocess.Popen) to produce output to consume.
configuration dict, but the given hook configuration is ignored. The given log prefix is used
for any log entries. If this is a dry run, then don't actually restore anything. Trigger the
given active extract process (an instance of subprocess.Popen) to produce output to consume.
'''
dry_run_label = ' (dry run; not actually restoring anything)' if dry_run else ''
hostname = connection_params['hostname'] or data_source.get(

View file

@ -1,22 +1,24 @@
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__)
def make_dump_path(config, base_directory=None): # pragma: no cover
def make_dump_path(base_directory): # pragma: no cover
'''
Given a configuration dict and an optional base directory, make the corresponding dump path. If
a base directory isn't provided, use the borgmatic runtime directory.
Given a base directory, make the corresponding dump path.
'''
return dump.make_data_source_dump_path(
base_directory or borgmatic.config.paths.get_borgmatic_runtime_directory(config),
'mongodb_databases',
)
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):
@ -27,14 +29,25 @@ def use_streaming(databases, config, log_prefix):
return any(database.get('format') != 'directory' for database in databases)
def dump_data_sources(databases, config, log_prefix, dry_run):
def dump_data_sources(
databases,
config,
log_prefix,
config_paths,
borgmatic_runtime_directory,
patterns,
dry_run,
):
'''
Dump the given MongoDB databases to a named pipe. The databases are supplied as a sequence of
dicts, one dict describing each database as per the configuration schema. Use the configuration
dict to construct the destination path and the given log prefix in any log entries.
dicts, one dict describing each database as per the configuration schema. Use the borgmatic
runtime directory to construct the destination path (used for the directory format and the given
log prefix in any log entries.
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 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 ''
@ -44,7 +57,10 @@ def dump_data_sources(databases, config, log_prefix, dry_run):
for database in databases:
name = database['name']
dump_filename = dump.make_data_source_dump_filename(
make_dump_path(config), name, database.get('hostname')
make_dump_path(borgmatic_runtime_directory),
name,
database.get('hostname'),
database.get('port'),
)
dump_format = database.get('format', 'archive')
@ -63,6 +79,13 @@ def dump_data_sources(databases, config, log_prefix, dry_run):
dump.create_named_pipe_for_dump(dump_filename)
processes.append(execute_command(command, shell=True, run_to_completion=False))
if not dry_run:
patterns.append(
borgmatic.borg.pattern.Pattern(
os.path.join(borgmatic_runtime_directory, 'mongodb_databases')
)
)
return processes
@ -94,36 +117,49 @@ def build_dump_command(database, dump_filename, dump_format):
)
def remove_data_source_dumps(databases, config, log_prefix, dry_run): # pragma: no cover
def remove_data_source_dumps(
databases, config, log_prefix, borgmatic_runtime_directory, dry_run
): # pragma: no cover
'''
Remove all database dump files for this hook regardless of the given databases. Use the log
prefix in any log entries. Use the given configuration dict to construct the destination path.
If this is a dry run, then don't actually remove anything.
Remove all database dump files for this hook regardless of the given databases. Use the
borgmatic_runtime_directory to construct the destination path and the log prefix in any log
entries. If this is a dry run, then don't actually remove anything.
'''
dump.remove_data_source_dumps(make_dump_path(config), 'MongoDB', log_prefix, dry_run)
dump.remove_data_source_dumps(
make_dump_path(borgmatic_runtime_directory), 'MongoDB', log_prefix, dry_run
)
def make_data_source_dump_patterns(databases, config, log_prefix, name=None): # pragma: no cover
def make_data_source_dump_patterns(
databases, config, log_prefix, borgmatic_runtime_directory, name=None
): # pragma: no cover
'''
Given a sequence of database configurations dicts, a configuration dict, a prefix to log with,
and a database name to match, return the corresponding glob patterns to match the database dump
in an archive.
Given a sequence of configurations dicts, a configuration dict, a prefix to log with, the
borgmatic runtime directory, and a database name to match, return the corresponding glob
patterns to match the database dump in an archive.
'''
borgmatic_source_directory = borgmatic.config.paths.get_borgmatic_source_directory(config)
return (
dump.make_data_source_dump_filename(make_dump_path('borgmatic'), name, hostname='*'),
dump.make_data_source_dump_filename(
make_dump_path(config, 'borgmatic'), name, hostname='*'
make_dump_path(borgmatic_runtime_directory), name, hostname='*'
),
dump.make_data_source_dump_filename(make_dump_path(config), name, hostname='*'),
dump.make_data_source_dump_filename(
make_dump_path(config, borgmatic_source_directory), name, hostname='*'
make_dump_path(borgmatic_source_directory), name, hostname='*'
),
)
def restore_data_source_dump(
hook_config, config, log_prefix, data_source, dry_run, extract_process, connection_params
hook_config,
config,
log_prefix,
data_source,
dry_run,
extract_process,
connection_params,
borgmatic_runtime_directory,
):
'''
Restore a database from the given extract stream. The database is supplied as a data source
@ -137,7 +173,9 @@ def restore_data_source_dump(
'''
dry_run_label = ' (dry run; not actually restoring anything)' if dry_run else ''
dump_filename = dump.make_data_source_dump_filename(
make_dump_path(config), data_source['name'], data_source.get('hostname')
make_dump_path(borgmatic_runtime_directory),
data_source['name'],
data_source.get('hostname'),
)
restore_command = build_restore_command(
extract_process, data_source, dump_filename, connection_params

View file

@ -3,26 +3,23 @@ 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__)
def make_dump_path(config, base_directory=None): # pragma: no cover
def make_dump_path(base_directory): # pragma: no cover
'''
Given a configuration dict and an optional base directory, make the corresponding dump path. If
a base directory isn't provided, use the borgmatic runtime directory.
Given a base directory, make the corresponding dump path.
'''
return dump.make_data_source_dump_path(
base_directory or borgmatic.config.paths.get_borgmatic_runtime_directory(config),
'mysql_databases',
)
return dump.make_data_source_dump_path(base_directory, 'mysql_databases')
SYSTEM_DATABASE_NAMES = ('information_schema', 'mysql', 'performance_schema', 'sys')
@ -77,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):
@ -117,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
@ -125,14 +129,25 @@ def use_streaming(databases, config, log_prefix):
return any(databases)
def dump_data_sources(databases, config, log_prefix, dry_run):
def dump_data_sources(
databases,
config,
log_prefix,
config_paths,
borgmatic_runtime_directory,
patterns,
dry_run,
):
'''
Dump the given MySQL/MariaDB databases to a named pipe. The databases are supplied as a sequence
of dicts, one dict describing each database as per the configuration schema. Use the given
configuration dict to construct the destination path and the given log prefix in any log entries.
borgmatic runtime directory to construct the destination path and the given log prefix in any
log entries.
Return a sequence of subprocess.Popen instances for the dump processes ready to spew to a named
pipe. But if this is a dry run, then don't actually dump anything and return an empty sequence.
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 = []
@ -140,7 +155,7 @@ def dump_data_sources(databases, config, log_prefix, dry_run):
logger.info(f'{log_prefix}: Dumping MySQL databases{dry_run_label}')
for database in databases:
dump_path = make_dump_path(config)
dump_path = make_dump_path(borgmatic_runtime_directory)
extra_environment = {'MYSQL_PWD': database['password']} if 'password' in database else None
dump_database_names = database_names_to_dump(
database, extra_environment, log_prefix, dry_run
@ -180,46 +195,65 @@ def dump_data_sources(databases, config, log_prefix, dry_run):
)
)
if not dry_run:
patterns.append(
borgmatic.borg.pattern.Pattern(
os.path.join(borgmatic_runtime_directory, 'mysql_databases')
)
)
return [process for process in processes if process]
def remove_data_source_dumps(databases, config, log_prefix, dry_run): # pragma: no cover
def remove_data_source_dumps(
databases, config, log_prefix, borgmatic_runtime_directory, dry_run
): # pragma: no cover
'''
Remove all database dump files for this hook regardless of the given databases. Use the given
configuration dict to construct the destination path and the log prefix in any log entries. If
this is a dry run, then don't actually remove anything.
Remove all database dump files for this hook regardless of the given databases. Use the
borgmatic runtime directory to construct the destination path and the log prefix in any log
entries. If this is a dry run, then don't actually remove anything.
'''
dump.remove_data_source_dumps(make_dump_path(config), 'MySQL', log_prefix, dry_run)
dump.remove_data_source_dumps(
make_dump_path(borgmatic_runtime_directory), 'MySQL', log_prefix, dry_run
)
def make_data_source_dump_patterns(databases, config, log_prefix, name=None): # pragma: no cover
def make_data_source_dump_patterns(
databases, config, log_prefix, borgmatic_runtime_directory, name=None
): # pragma: no cover
'''
Given a sequence of configurations dicts, a configuration dict, a prefix to log with, and a
database name to match, return the corresponding glob patterns to match the database dump in an
archive.
Given a sequence of configurations dicts, a configuration dict, a prefix to log with, the
borgmatic runtime directory, and a database name to match, return the corresponding glob
patterns to match the database dump in an archive.
'''
borgmatic_source_directory = borgmatic.config.paths.get_borgmatic_source_directory(config)
return (
dump.make_data_source_dump_filename(make_dump_path('borgmatic'), name, hostname='*'),
dump.make_data_source_dump_filename(
make_dump_path(config, 'borgmatic'), name, hostname='*'
make_dump_path(borgmatic_runtime_directory), name, hostname='*'
),
dump.make_data_source_dump_filename(make_dump_path(config), name, hostname='*'),
dump.make_data_source_dump_filename(
make_dump_path(config, borgmatic_source_directory), name, hostname='*'
make_dump_path(borgmatic_source_directory), name, hostname='*'
),
)
def restore_data_source_dump(
hook_config, config, log_prefix, data_source, dry_run, extract_process, connection_params
hook_config,
config,
log_prefix,
data_source,
dry_run,
extract_process,
connection_params,
borgmatic_runtime_directory,
):
'''
Restore a database from the given extract stream. The database is supplied as a data source
configuration dict, but the given hook configuration is ignored. The given configuration dict is
used to construct the destination path, and the given log prefix is used for any log entries. If
this is a dry run, then don't actually restore anything. Trigger the given active extract
process (an instance of subprocess.Popen) to produce output to consume.
configuration dict, but the given hook configuration is ignored. The given log prefix is used
for any log entries. If this is a dry run, then don't actually restore anything. Trigger the
given active extract process (an instance of subprocess.Popen) to produce output to consume.
'''
dry_run_label = ' (dry run; not actually restoring anything)' if dry_run else ''
hostname = connection_params['hostname'] or data_source.get(

View file

@ -5,26 +5,23 @@ 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__)
def make_dump_path(config, base_directory=None): # pragma: no cover
def make_dump_path(base_directory): # pragma: no cover
'''
Given a configuration dict and an optional base directory, make the corresponding dump path. If
a base directory isn't provided, use the borgmatic runtime directory.
Given a base directory, make the corresponding dump path.
'''
return dump.make_data_source_dump_path(
base_directory or borgmatic.config.paths.get_borgmatic_runtime_directory(config),
'postgresql_databases',
)
return dump.make_data_source_dump_path(base_directory, 'postgresql_databases')
def make_extra_environment(database, restore_connection_params=None):
@ -100,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
@ -108,15 +109,25 @@ def use_streaming(databases, config, log_prefix):
return any(database.get('format') != 'directory' for database in databases)
def dump_data_sources(databases, config, log_prefix, dry_run):
def dump_data_sources(
databases,
config,
log_prefix,
config_paths,
borgmatic_runtime_directory,
patterns,
dry_run,
):
'''
Dump the given PostgreSQL databases to a named pipe. The databases are supplied as a sequence of
dicts, one dict describing each database as per the configuration schema. Use the given
configuration dict to construct the destination path and the given log prefix in any log
entries.
borgmatic runtime directory to construct the destination path and the given log prefix in any
log entries.
Return a sequence of subprocess.Popen instances for the dump processes ready to spew to a named
pipe. But if this is a dry run, then don't actually dump anything and return an empty sequence.
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.
'''
@ -127,7 +138,7 @@ def dump_data_sources(databases, config, log_prefix, dry_run):
for database in databases:
extra_environment = make_extra_environment(database)
dump_path = make_dump_path(config)
dump_path = make_dump_path(borgmatic_runtime_directory)
dump_database_names = database_names_to_dump(
database, extra_environment, log_prefix, dry_run
)
@ -146,7 +157,10 @@ def dump_data_sources(databases, config, log_prefix, dry_run):
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(
@ -207,46 +221,67 @@ def dump_data_sources(databases, config, log_prefix, dry_run):
)
)
if not dry_run:
patterns.append(
borgmatic.borg.pattern.Pattern(
os.path.join(borgmatic_runtime_directory, 'postgresql_databases')
)
)
return processes
def remove_data_source_dumps(databases, config, log_prefix, dry_run): # pragma: no cover
def remove_data_source_dumps(
databases, config, log_prefix, borgmatic_runtime_directory, dry_run
): # pragma: no cover
'''
Remove all database dump files for this hook regardless of the given databases. Use the given
configuration dict to construct the destination path and the log prefix in any log entries. If
this is a dry run, then don't actually remove anything.
Remove all database dump files for this hook regardless of the given databases. Use the
borgmatic runtime directory to construct the destination path and the log prefix in any log
entries. If this is a dry run, then don't actually remove anything.
'''
dump.remove_data_source_dumps(make_dump_path(config), 'PostgreSQL', log_prefix, dry_run)
dump.remove_data_source_dumps(
make_dump_path(borgmatic_runtime_directory), 'PostgreSQL', log_prefix, dry_run
)
def make_data_source_dump_patterns(databases, config, log_prefix, name=None): # pragma: no cover
def make_data_source_dump_patterns(
databases, config, log_prefix, borgmatic_runtime_directory, name=None
): # pragma: no cover
'''
Given a sequence of configurations dicts, a configuration dict, a prefix to log with, and a
database name to match, return the corresponding glob patterns to match the database dump in an
archive.
Given a sequence of configurations dicts, a configuration dict, a prefix to log with, the
borgmatic runtime directory, and a database name to match, return the corresponding glob
patterns to match the database dump in an archive.
'''
borgmatic_source_directory = borgmatic.config.paths.get_borgmatic_source_directory(config)
return (
dump.make_data_source_dump_filename(make_dump_path('borgmatic'), name, hostname='*'),
dump.make_data_source_dump_filename(
make_dump_path(config, 'borgmatic'), name, hostname='*'
make_dump_path(borgmatic_runtime_directory), name, hostname='*'
),
dump.make_data_source_dump_filename(make_dump_path(config), name, hostname='*'),
dump.make_data_source_dump_filename(
make_dump_path(config, borgmatic_source_directory), name, hostname='*'
make_dump_path(borgmatic_source_directory), name, hostname='*'
),
)
def restore_data_source_dump(
hook_config, config, log_prefix, data_source, dry_run, extract_process, connection_params
hook_config,
config,
log_prefix,
data_source,
dry_run,
extract_process,
connection_params,
borgmatic_runtime_directory,
):
'''
Restore a database from the given extract stream. The database is supplied as a data source
configuration dict, but the given hook configuration is ignored. The given configuration dict is
used to construct the destination path, and the given log prefix is used for any log entries. If
this is a dry run, then don't actually restore anything. Trigger the given active extract
process (an instance of subprocess.Popen) to produce output to consume.
configuration dict, but the given hook configuration is ignored. The given borgmatic runtime
directory is used to construct the destination path (used for the directory format), and the
given log prefix is used for any log entries. If this is a dry run, then don't actually restore
anything. Trigger the given active extract process (an instance of subprocess.Popen) to produce
output to consume.
If the extract process is None, then restore the dump from the filesystem rather than from an
extract stream.
@ -267,7 +302,9 @@ def restore_data_source_dump(
all_databases = bool(data_source['name'] == 'all')
dump_filename = dump.make_data_source_dump_filename(
make_dump_path(config), data_source['name'], data_source.get('hostname')
make_dump_path(borgmatic_runtime_directory),
data_source['name'],
data_source.get('hostname'),
)
psql_command = tuple(
shlex.quote(part) for part in shlex.split(data_source.get('psql_command') or 'psql')

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

View file

@ -2,22 +2,23 @@ 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__)
def make_dump_path(config, base_directory=None): # pragma: no cover
def make_dump_path(base_directory): # pragma: no cover
'''
Given a configuration dict and an optional base directory, make the corresponding dump path. If
a base directory isn't provided, use the borgmatic runtime directory.
Given a base directory, make the corresponding dump path.
'''
return dump.make_data_source_dump_path(
base_directory or borgmatic.config.paths.get_borgmatic_runtime_directory(config),
'sqlite_databases',
)
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):
@ -28,14 +29,24 @@ def use_streaming(databases, config, log_prefix):
return any(databases)
def dump_data_sources(databases, config, log_prefix, dry_run):
def dump_data_sources(
databases,
config,
log_prefix,
config_paths,
borgmatic_runtime_directory,
patterns,
dry_run,
):
'''
Dump the given SQLite databases to a named pipe. The databases are supplied as a sequence of
configuration dicts, as per the configuration schema. Use the given configuration dict to
construct the destination path and the given log prefix in any log entries.
configuration dicts, as per the configuration schema. Use the given borgmatic runtime directory
to construct the destination path and the given log prefix in any log entries.
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 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 = []
@ -52,7 +63,7 @@ def dump_data_sources(databases, config, log_prefix, dry_run):
f'{log_prefix}: No SQLite database at {database_path}; an empty database will be created and dumped'
)
dump_path = make_dump_path(config)
dump_path = make_dump_path(borgmatic_runtime_directory)
dump_filename = dump.make_data_source_dump_filename(dump_path, database['name'])
if os.path.exists(dump_filename):
@ -77,46 +88,65 @@ def dump_data_sources(databases, config, log_prefix, dry_run):
dump.create_named_pipe_for_dump(dump_filename)
processes.append(execute_command(command, shell=True, run_to_completion=False))
if not dry_run:
patterns.append(
borgmatic.borg.pattern.Pattern(
os.path.join(borgmatic_runtime_directory, 'sqlite_databases')
)
)
return processes
def remove_data_source_dumps(databases, config, log_prefix, dry_run): # pragma: no cover
def remove_data_source_dumps(
databases, config, log_prefix, borgmatic_runtime_directory, dry_run
): # pragma: no cover
'''
Remove the given SQLite database dumps from the filesystem. The databases are supplied as a
sequence of configuration dicts, as per the configuration schema. Use the given configuration
dict to construct the destination path and the given log prefix in any log entries. If this is a
dry run, then don't actually remove anything.
Remove all database dump files for this hook regardless of the given databases. Use the
borgmatic runtime directory to construct the destination path and the log prefix in any log
entries. If this is a dry run, then don't actually remove anything.
'''
dump.remove_data_source_dumps(make_dump_path(config), 'SQLite', log_prefix, dry_run)
dump.remove_data_source_dumps(
make_dump_path(borgmatic_runtime_directory), 'SQLite', log_prefix, dry_run
)
def make_data_source_dump_patterns(databases, config, log_prefix, name=None): # pragma: no cover
def make_data_source_dump_patterns(
databases, config, log_prefix, borgmatic_runtime_directory, name=None
): # pragma: no cover
'''
Make a pattern that matches the given SQLite databases. The databases are supplied as a sequence
of configuration dicts, as per the configuration schema.
Given a sequence of configurations dicts, a configuration dict, a prefix to log with, the
borgmatic runtime directory, and a database name to match, return the corresponding glob
patterns to match the database dump in an archive.
'''
borgmatic_source_directory = borgmatic.config.paths.get_borgmatic_source_directory(config)
return (
dump.make_data_source_dump_filename(make_dump_path('borgmatic'), name, hostname='*'),
dump.make_data_source_dump_filename(
make_dump_path(config, 'borgmatic'), name, hostname='*'
make_dump_path(borgmatic_runtime_directory), name, hostname='*'
),
dump.make_data_source_dump_filename(make_dump_path(config), name, hostname='*'),
dump.make_data_source_dump_filename(
make_dump_path(config, borgmatic_source_directory), name, hostname='*'
make_dump_path(borgmatic_source_directory), name, hostname='*'
),
)
def restore_data_source_dump(
hook_config, config, log_prefix, data_source, dry_run, extract_process, connection_params
hook_config,
config,
log_prefix,
data_source,
dry_run,
extract_process,
connection_params,
borgmatic_runtime_directory,
):
'''
Restore a database from the given extract stream. The database is supplied as a data source
configuration dict, but the given hook configuration is ignored. The given configuration dict is
used to construct the destination path, and the given log prefix is used for any log entries. If
this is a dry run, then don't actually restore anything. Trigger the given active extract
process (an instance of subprocess.Popen) to produce output to consume.
configuration dict, but the given hook configuration is ignored. The given log prefix is used
for any log entries. If this is a dry run, then don't actually restore anything. Trigger the
given active extract process (an instance of subprocess.Popen) to produce output to consume.
'''
dry_run_label = ' (dry run; not actually restoring anything)' if dry_run else ''
database_path = connection_params['restore_path'] or data_source.get(

View file

@ -0,0 +1,419 @@
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__)
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-'
BORGMATIC_USER_PROPERTY = 'org.torsion.borgmatic:backup'
Dataset = collections.namedtuple(
'Dataset',
('name', 'mount_point', 'auto_backup', 'contained_patterns'),
defaults=(False, ()),
)
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(
tuple(zfs_command.split(' '))
+ (
'list',
'-H',
'-t',
'filesystem',
'-o',
f'name,mountpoint,{BORGMATIC_USER_PROPERTY}',
)
)
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 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(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_dataset_mount_points(zfs_command):
'''
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(
tuple(zfs_command.split(' '))
+ (
'list',
'-H',
'-t',
'filesystem',
'-o',
'mountpoint',
)
)
return tuple(sorted(line.rstrip() for line in list_output.splitlines()))
def snapshot_dataset(zfs_command, full_snapshot_name): # pragma: no cover
'''
Given a ZFS command to run and a snapshot name of the form "dataset@snapshot", create a new ZFS
snapshot.
'''
borgmatic.execute.execute_command(
tuple(zfs_command.split(' '))
+ (
'snapshot',
full_snapshot_name,
),
output_log_level=logging.DEBUG,
)
def mount_snapshot(mount_command, full_snapshot_name, snapshot_mount_path): # pragma: no cover
'''
Given a mount command to run, an existing snapshot name of the form "dataset@snapshot", and the
path where the snapshot should be mounted, mount the snapshot (making any necessary directories
first).
'''
os.makedirs(snapshot_mount_path, mode=0o700, exist_ok=True)
borgmatic.execute.execute_command(
tuple(mount_command.split(' '))
+ (
'-t',
'zfs',
'-o',
'ro',
full_snapshot_name,
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,
'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,
patterns,
dry_run,
):
'''
Given a ZFS configuration dict, a configuration dict, a log prefix, the borgmatic configuration
file paths, the borgmatic runtime directory, the configured patterns, and whether this is a dry
run, auto-detect and snapshot any ZFS dataset mount points listed in the given patterns and any
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.
If this is a dry run, then don't actually snapshot anything.
'''
dry_run_label = ' (dry run; not actually snapshotting anything)' if dry_run else ''
logger.info(f'{log_prefix}: Snapshotting ZFS datasets{dry_run_label}')
# List ZFS datasets to get their mount points.
zfs_command = hook_config.get('zfs_command', 'zfs')
requested_datasets = get_datasets_to_backup(zfs_command, patterns)
# Snapshot each dataset, rewriting patterns to use the snapshot paths.
snapshot_name = f'{BORGMATIC_SNAPSHOT_PREFIX}{os.getpid()}'
normalized_runtime_directory = os.path.normpath(borgmatic_runtime_directory)
if not requested_datasets:
logger.warning(f'{log_prefix}: No ZFS datasets found to snapshot{dry_run_label}')
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 = os.path.join(
normalized_runtime_directory,
'zfs_snapshots',
dataset.mount_point.lstrip(os.path.sep),
)
logger.debug(
f'{log_prefix}: Mounting ZFS snapshot {full_snapshot_name} at {snapshot_mount_path}{dry_run_label}'
)
if dry_run:
continue
mount_snapshot(
hook_config.get('mount_command', 'mount'), full_snapshot_name, snapshot_mount_path
)
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 []
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 destroy_snapshot(zfs_command, full_snapshot_name): # pragma: no cover
'''
Given a ZFS command to run and the name of a snapshot in the form "dataset@snapshot", destroy
it.
'''
borgmatic.execute.execute_command(
tuple(zfs_command.split(' '))
+ (
'destroy',
full_snapshot_name,
),
output_log_level=logging.DEBUG,
)
def get_all_snapshots(zfs_command):
'''
Given a ZFS command to run, return all ZFS snapshots as a sequence of full snapshot names of the
form "dataset@snapshot".
'''
list_output = borgmatic.execute.