mirror of
https://github.com/borgmatic-collective/borgmatic.git
synced 2026-07-22 18:13:02 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a5e202daf | ||
|
|
7f2e3e0054 | ||
|
|
173ba00caa | ||
|
|
498d662b3d | ||
|
|
75405bed89 | ||
|
|
c989b73103 | ||
|
|
35feeb4615 | ||
|
|
5dca281439 | ||
|
|
13fbee858a | ||
|
|
e2cdcba4e7 | ||
|
|
595c639d25 | ||
|
|
b89f057be0 | ||
|
|
98ddb3e535 | ||
|
|
339186b579 | ||
|
|
1192451718 | ||
|
|
06e24dfbc1 | ||
|
|
4f0162d5f2 | ||
|
|
ed58bc2943 | ||
|
|
cab6c3ee9b | ||
|
|
65c17c91b3 | ||
|
|
546c0bf83b | ||
|
|
6d48ff95d2 | ||
|
|
22b77a4262 | ||
|
|
f15e8282ab | ||
|
|
a0e2a98735 | ||
|
|
4ced5150f4 | ||
|
|
a388b17271 | ||
|
|
4feec081a8 | ||
|
|
f3d42d081e | ||
|
|
da65063216 | ||
|
|
8eea005cbc | ||
|
|
a276aaa31f | ||
|
|
abad73d605 | ||
|
|
4de879d86e | ||
|
|
0e90087dc4 | ||
|
|
56a3f6d854 | ||
|
|
16c8098b06 | ||
|
|
d44fb24670 | ||
|
|
8639b73f80 | ||
|
|
eef264dcc5 | ||
|
|
b306cac5c1 | ||
|
|
0d190016d3 | ||
|
|
97815bb498 | ||
|
|
3811a9f57b | ||
|
|
a2c9bb12e5 | ||
|
|
04cc58a5c7 | ||
|
|
db1c6f548f | ||
|
|
bcacbc0d69 | ||
|
|
e058df6b7e | ||
|
|
133a11c647 | ||
|
|
c084f10fe2 | ||
|
|
97cd3f20df | ||
|
|
fd028d42d5 | ||
|
|
0d198c7072 | ||
|
|
000d633590 | ||
|
|
084fbd2f16 | ||
|
|
3310d7c036 | ||
|
|
fbfb7fc1f5 | ||
|
|
30b8aeb764 | ||
|
|
2cae42a163 | ||
|
|
c4c40af812 | ||
|
|
5028fe9ff4 | ||
|
|
f60007545b | ||
|
|
947dc77a50 | ||
|
|
21ab371e9f | ||
|
|
b340ede9ac | ||
|
|
82e962eae2 | ||
|
|
85b198feb2 | ||
|
|
e3f4b79e76 | ||
|
|
627898cc23 | ||
|
|
46381175ef | ||
|
|
d7205694db | ||
|
|
9bbce71673 | ||
|
|
6a1f2a4c86 | ||
|
|
5a45a0cbc0 | ||
|
|
03e3e0f5dd | ||
|
|
d40f728410 | ||
|
|
049132377c | ||
|
|
fca647b3ae | ||
|
|
e02c4be493 | ||
|
|
b45c9628e7 | ||
|
|
09d702ee88 | ||
|
|
eb971694bb | ||
|
|
f1a008ee18 | ||
|
|
563cb8441e | ||
|
|
030abfa13c | ||
|
|
36d6619099 | ||
|
|
5f468c4261 | ||
|
|
88ecb96b98 | ||
|
|
099d30f044 | ||
|
|
5439e616f8 | ||
|
|
2d8b455e44 | ||
|
|
9a80fec91b | ||
|
|
ea72f1c367 | ||
|
|
34f677dba1 | ||
|
|
3eed3191e8 | ||
|
|
1a33e5fa1e | ||
|
|
c2d2ce18a2 | ||
|
|
1b62be0949 | ||
|
|
392fbcd19f | ||
|
|
0d54fda27a | ||
|
|
1e6fba6e99 | ||
|
|
a1b14d58af | ||
|
|
6058efbaac | ||
|
|
05b34d4b56 | ||
|
|
5f6173ab24 | ||
|
|
6f92ae2462 | ||
|
|
df383be8a2 | ||
|
|
11da378304 | ||
|
|
65ce7309f1 | ||
|
|
0cae581014 | ||
|
|
123f8958f6 | ||
|
|
5ac086fa73 | ||
|
|
3902dd873d | ||
|
|
652a63223a | ||
|
|
01dc7e7841 | ||
|
|
249183250b | ||
|
|
080c8dfd93 | ||
|
|
9ec287cd2e | ||
|
|
df64794115 | ||
|
|
fede523dae | ||
|
|
35bfb393c7 | ||
|
|
782f534636 | ||
|
|
8e8d9e6267 | ||
|
|
b208c5352a | ||
|
|
79cf3495ee | ||
|
|
15607d2ccd | ||
|
|
ae1fbf7940 | ||
|
|
a1eb612a08 | ||
|
|
7f3e9c5347 | ||
|
|
d83c444d9e | ||
|
|
2221be4549 | ||
|
|
9973c5fb7d | ||
|
|
ec7101ccf3 | ||
|
|
5966022b67 | ||
|
|
b68211cc0c | ||
|
|
1f773124f6 | ||
|
|
3ff9a33468 | ||
|
|
02644c3bb6 | ||
|
|
0e42ba21ba | ||
|
|
d876e8636a | ||
|
|
3127da54b5 | ||
|
|
3d44e95c1a | ||
|
|
cdbfe1c1e9 | ||
|
|
4c8ba5c5ab | ||
|
|
091e9fa9ee | ||
|
|
0f4e93c9c8 | ||
|
|
43bd42fb31 | ||
|
|
bb54068152 | ||
|
|
6c5eecac20 | ||
|
|
3f92b0b6ed | ||
|
|
30d2108399 | ||
|
|
d36e5f24ce | ||
|
|
76c50555d3 | ||
|
|
1cde65b4eb | ||
|
|
4e05539825 | ||
|
|
d17aa3e7cc | ||
|
|
3eff794f44 | ||
|
|
1ac51e34bc | ||
|
|
345b4cfb09 | ||
|
|
27a2bbc231 | ||
|
|
dbf1d0946a | ||
|
|
5465b60d37 | ||
|
|
e2b5972c09 | ||
|
|
9bf316e28f | ||
|
|
3847f31939 | ||
|
|
a815d2dfdb | ||
|
|
6ebfd60e21 | ||
|
|
2078527539 | ||
|
|
620bf52e01 | ||
|
|
fdb08b6c56 | ||
|
|
c3c37dee13 | ||
|
|
13d49fda9b | ||
|
|
b01b8498aa | ||
|
|
a573e606a5 | ||
|
|
81db67c759 | ||
|
|
0b4aff9277 | ||
|
|
7de1c2121c | ||
|
|
2c8dc5858f | ||
|
|
f27a96e22d | ||
|
|
a892a308bd | ||
|
|
2db023f785 | ||
|
|
edaca2b3cd | ||
|
|
bc79eafb0b | ||
|
|
68fafffe99 | ||
|
|
6c068a297a | ||
|
|
06a6444c86 | ||
|
|
f6de79060e | ||
|
|
0d94a6587a | ||
|
|
2f535056ee | ||
|
|
074db68a1b | ||
|
|
3cd5c1290d | ||
|
|
66a636e994 | ||
|
|
360156e3b1 | ||
|
|
b25bfbe913 | ||
|
|
acb9aace1d | ||
|
|
e6ae0b73d4 | ||
|
|
7ee0d3563b | ||
|
|
edee3c3788 | ||
|
|
445f700b09 | ||
|
|
732634f7ba | ||
|
|
ad9f037ec5 | ||
|
|
c0adc4f9df | ||
|
|
49839e884a | ||
|
|
828ada085b | ||
|
|
93b1172266 | ||
|
|
c6ce9c70ab | ||
|
|
929d343214 | ||
|
|
9ea55d9aa3 | ||
|
|
3eabda45f2 | ||
|
|
09212961a4 | ||
|
|
3f25f3f0ff | ||
|
|
e8542f3613 | ||
|
|
9407f24674 | ||
|
|
1c9d25b892 | ||
|
|
248999c23e | ||
|
|
d0a5aa63be | ||
|
|
d2c3ed26a9 | ||
|
|
bbf6f27715 | ||
|
|
9301ab13cc | ||
|
|
d5d04b89dc | ||
|
|
364200c65a | ||
|
|
4e55547235 | ||
|
|
96ec66de79 | ||
|
|
7a0c56878b | ||
|
|
4065c5d0f7 | ||
|
|
affe7cdc1b | ||
|
|
017cbae4f9 | ||
|
|
e96db2e100 | ||
|
|
af97b95e2b | ||
|
|
6a61259f1a | ||
|
|
5490a83d77 | ||
|
|
8c907bb5a3 | ||
|
|
f166111b9b | ||
|
|
10fb02c40a | ||
|
|
cf477bdc1c | ||
|
|
6f07402407 | ||
|
|
ab01e97a5e | ||
|
|
92ebc77597 | ||
|
|
863c954144 | ||
|
|
f7e4d38762 | ||
|
|
de4d7af507 | ||
|
|
5cea1e1b72 | ||
|
|
fd8c11eb0a | ||
|
|
92de539bf9 | ||
|
|
5716e61f8f | ||
|
|
3e05eeb4de | ||
|
|
65d1b9235d | ||
|
|
cffb8e88da | ||
|
|
a8362f2618 | ||
|
|
36265eea7d | ||
|
|
8101e5c56f | ||
|
|
c7feb16ab5 | ||
|
|
da324ebeb7 | ||
|
|
59f9d56aae | ||
|
|
dbf2e78f62 | ||
|
|
f6929f8891 | ||
|
|
2716d9d0b0 | ||
|
|
668f767bfc | ||
|
|
0182dbd914 | ||
|
|
1c27e0dadc | ||
|
|
8b3a682edf | ||
|
|
975a6e4540 | ||
|
|
7020f0530a | ||
|
|
5bf2f546b9 | ||
|
|
b4c558d013 | ||
|
|
79bf641668 | ||
|
|
50beb334dc | ||
|
|
26fd41da92 | ||
|
|
088da19012 | ||
|
|
4c6674e0ad | ||
|
|
486bec698d | ||
|
|
7a766c717e | ||
|
|
520fb78a00 | ||
|
|
acc2814f11 | ||
|
|
996b037946 | ||
|
|
9356924418 | ||
|
|
79e4e089ee | ||
|
|
d2714cb706 | ||
|
|
5a0430b9c8 | ||
|
|
23efbb8df3 | ||
|
|
9e694e4df9 | ||
|
|
76f7c53a1c | ||
|
|
203e84b91f | ||
|
|
ea5a2d8a46 | ||
|
|
a8726c408a | ||
|
|
3542673446 | ||
|
|
532a97623c | ||
|
|
e1fdfe4c2f | ||
|
|
83a56a3fef | ||
|
|
b60cf2449a | ||
|
|
e7f14bca87 | ||
|
|
4bca7bb198 | ||
|
|
fa3b140590 | ||
|
|
a1d2f7f221 | ||
|
|
6a470be924 | ||
|
|
d651813601 | ||
|
|
65b1d8e8b2 | ||
|
|
16a1121649 | ||
|
|
423627e67b | ||
|
|
9f7c71265e | ||
|
|
ba75958a2f | ||
|
|
57721937a3 | ||
|
|
f222bf2c1a | ||
|
|
dc9da3832d | ||
|
|
f8eda92379 | ||
|
|
cc14421460 | ||
|
|
a750d58a2d | ||
|
|
2045706faa | ||
|
|
976fb8f343 | ||
|
|
5246a10b99 | ||
|
|
524ec6b3cb | ||
|
|
6f1c77bc7d | ||
|
|
7904ffb641 | ||
|
|
cd5ba81748 | ||
|
|
5c11052b8c | ||
|
|
514ade6609 | ||
|
|
201469e2c2 | ||
|
|
9ac2a2e286 | ||
|
|
a16d138afc | ||
|
|
81a3a99578 | ||
|
|
f3cc3b1b65 | ||
|
|
587d31de7c | ||
|
|
cbfc0bead1 | ||
|
|
8aaa5ba8a6 | ||
|
|
7d989f727d | ||
|
|
5525b467ef | ||
|
|
89c98de122 | ||
|
|
c2409d9968 | ||
|
|
624a7de622 | ||
|
|
3119c924b4 | ||
|
|
ed6022d4a9 | ||
|
|
3e21cdb579 | ||
|
|
d02d31f445 | ||
|
|
1097a6576f | ||
|
|
63b0c69794 | ||
|
|
4e2805918d | ||
|
|
711f5fa6cb | ||
|
|
93e7da823c | ||
|
|
903308864c | ||
|
|
d75c8609c5 | ||
|
|
c926f0bd5d | ||
|
|
7b14e8c7f2 | ||
|
|
87b9ad5aea | ||
|
|
eca78fbc2c | ||
|
|
6adb0fd44c | ||
|
|
05900c188f | ||
|
|
1d5713c4c5 | ||
|
|
f9612cc685 | ||
|
|
5742a1a2d9 | ||
|
|
c84815bfb0 | ||
|
|
e1ff51ff1e | ||
|
|
1c92d84e09 | ||
|
|
1d94fb501f | ||
|
|
92279d3c71 | ||
|
|
1b4c94ad1e | ||
|
|
901e668c76 | ||
|
|
bcb224a243 | ||
|
|
6b6e1e0336 | ||
|
|
f5c9bc4fa9 | ||
|
|
cdd0e6f052 | ||
|
|
7bdbadbac2 | ||
|
|
d3413e0907 | ||
|
|
8a20ee7304 | ||
|
|
325f53c286 | ||
|
|
b4d24798bf | ||
|
|
7965eb9de3 | ||
|
|
8817364e6d | ||
|
|
965740c778 | ||
|
|
2a0319f02f | ||
|
|
fbdb09b87d | ||
|
|
bec5a0c0ca | ||
|
|
4ee7f72696 | ||
|
|
9941d7dc57 | ||
|
|
ec88bb2e9c | ||
|
|
68b6d01071 | ||
|
|
b52339652f | ||
|
|
4fd22b2df0 | ||
|
|
86b138e73b | ||
|
|
5ab766b51c | ||
|
|
45c114973c | ||
|
|
6a96a78cf1 | ||
|
|
e06c6740f2 | ||
|
|
10bd1c7b41 | ||
|
|
d4f48a3a9e | ||
|
|
c76a108422 | ||
|
|
eb5dc128bf | ||
|
|
1d486d024b | ||
|
|
5a8f27d75c | ||
|
|
a926b413bc | ||
|
|
18ffd96d62 | ||
|
|
c0135864c2 | ||
|
|
ddfd3c6ca1 | ||
|
|
dbe82ff11e | ||
|
|
55c0ab1610 | ||
|
|
1f86100f26 | ||
|
|
2a16ffab1b | ||
|
|
4b2f7e03af | ||
|
|
024006f4c0 | ||
|
|
4c71e600ca | ||
|
|
114f5702b2 | ||
|
|
54afe87a9f | ||
|
|
25b6a49df7 | ||
|
|
b97372adf2 | ||
|
|
6bc9a592d9 | ||
|
|
839862cff0 | ||
|
|
06b065cb09 | ||
|
|
1e5c256d54 | ||
|
|
baf5fec78d | ||
|
|
48a4fbaa89 | ||
|
|
1e274d7153 | ||
|
|
c41b743819 | ||
|
|
36d0073375 | ||
|
|
0bd418836e | ||
|
|
923fa7d82f | ||
|
|
dce0528057 | ||
|
|
8a6c6c84d2 | ||
|
|
1e21c8f97b | ||
|
|
2eab74a521 | ||
|
|
3bca686707 | ||
|
|
8854b9ad20 | ||
|
|
bcc463688a | ||
|
|
596305e3de | ||
|
|
c462f0c84c | ||
|
|
4f0142c3c5 | ||
|
|
4f88018558 | ||
|
|
3642687ab5 | ||
|
|
5d9c111910 | ||
|
|
3cf19dd1b0 | ||
|
|
ad3392ca15 | ||
|
|
087b7f5c7b | ||
|
|
34bb09e9be | ||
|
|
a61eba8c79 | ||
|
|
2280bb26b6 | ||
|
|
4ee2603fef | ||
|
|
cc2ede70ac | ||
|
|
02d8ecd66e | ||
|
|
9ba78fa33b | ||
|
|
a3e34d63e9 | ||
|
|
bc25ac4eea | ||
|
|
e69c686abf | ||
|
|
0210bf76bc | ||
|
|
e69cce7e51 | ||
|
|
3655e8784a | ||
|
|
58aed0892c | ||
|
|
0e65169503 | ||
|
|
07ecc0ffd6 | ||
|
|
37ad398aff | ||
|
|
056dfc6d33 | ||
|
|
bf850b9d38 | ||
|
|
7f22612bf1 | ||
|
|
e02a0e6322 | ||
|
|
2ca23b629c | ||
|
|
b283e379d0 | ||
|
|
5dda9c8ee5 | ||
|
|
653d8c0946 | ||
|
|
92e87d839d | ||
|
|
d6cf48544a | ||
|
|
8745b9939d | ||
|
|
5661b67cde | ||
|
|
aa4a9de3b2 | ||
|
|
f9ea45493d | ||
|
|
a0ba5b673b | ||
|
|
21cef267c1 | ||
|
|
cfad4200a9 |
@@ -0,0 +1,4 @@
|
||||
---
|
||||
name: "Pull Request"
|
||||
about: "Pull Request"
|
||||
---
|
||||
@@ -3,6 +3,8 @@ run-name: ${{ gitea.actor }} is building
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
@@ -15,6 +17,7 @@ jobs:
|
||||
docs:
|
||||
needs: [test]
|
||||
runs-on: host
|
||||
if: gitea.event_name == 'push'
|
||||
env:
|
||||
IMAGE_NAME: projects.torsion.org/borgmatic-collective/borgmatic:docs
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
## Hold up, GitHub users
|
||||
|
||||
Thanks for your contribution!
|
||||
|
||||
Unfortunately, we don't use GitHub pull requests to manage code contributions to this repository (and GitHub doesn't have any way to disable pull requests entirely). Instead, please see:
|
||||
|
||||
https://torsion.org/borgmatic/#contributing
|
||||
|
||||
... which provides full instructions on how to submit pull requests. You can even use your GitHub account to login.
|
||||
|
||||
+6
-1
@@ -1,2 +1,7 @@
|
||||
# This file only applies to the source dist tarball, not the built wheel.
|
||||
include borgmatic/config/schema.yaml
|
||||
graft sample/systemd
|
||||
graft docs
|
||||
graft sample
|
||||
graft scripts
|
||||
graft tests
|
||||
global-exclude *.py[co]
|
||||
|
||||
@@ -1,3 +1,220 @@
|
||||
2.0.9
|
||||
* #1105: More accurately collect Btrfs subvolumes to snapshot. As part of this, the Btrfs hook no
|
||||
longer uses "findmnt" and the "findmnt_command" option is deprecated.
|
||||
* #1123: Add loading of systemd credentials even when running borgmatic outside of a systemd
|
||||
service.
|
||||
* #1149: Add support for Python 3.14.
|
||||
* #1149: Include automated tests in the source dist tarball uploaded to PyPI.
|
||||
* #1151: Fix snapshotting in the ZFS, Btrfs, and LVM hooks to play nicely with the Borg 1.4+
|
||||
"slashdot" hack within source directory paths.
|
||||
* #1152: Fix a regression in the Loki monitoring hook in which log messages weren't sending.
|
||||
* #1156: Fix snapshotting in the ZFS, Btrfs, and LVM hooks to snapshot both parent and child
|
||||
volumes/filesystems instead of just the parent. As part of this fix, borgmatic no longer
|
||||
deduplicates patterns except for those containing the borgmatic runtime directory.
|
||||
* Fix a traceback (TypeError) regression in the "spot" check when the "local_path" option isn't
|
||||
set.
|
||||
|
||||
2.0.8
|
||||
* #1114: Document systemd configuration changes for the ZFS filesystem hook.
|
||||
* #1116: Add dumping of database containers via their container names, handy for backing up
|
||||
database containers from the host. See the documentation for more information:
|
||||
https://torsion.org/borgmatic/docs/how-to/backup-your-databases/#database-client-on-the-host
|
||||
* #1116: Add optional database labels to make it easier to find your dumps within a Borg archive.
|
||||
* #1118: Fix a bug in which Borg hangs during database backup when different filesystems are in
|
||||
use.
|
||||
* #1122: To prevent the user from inadvertently excluding the "bootstrap" action's manifest, always
|
||||
error and exit when the borgmatic runtime directory overlaps with the configured excludes.
|
||||
* #1125: Clarify documentation about ZFS, Btrfs, and LVM snapshotting when a separate
|
||||
filesystem is mounted in the source directory. (Spoiler: The separate filesystem doesn't get
|
||||
included in the snapshot.)
|
||||
* #1126: Create LVM snapshots as read-write to avoid an error when snapshotting ext4 filesystems
|
||||
with orphaned files that need recovery.
|
||||
* #1133: Fix the "spot" check to include borgmatic configuration files that were backed up to
|
||||
support the "bootstrap" action.
|
||||
* #1136: For all database hooks, record metadata about the dumps contained within an archive.
|
||||
* #1139: Set "borgmatic" as the user agent when connecting to monitoring services.
|
||||
* #1146: Fix a broken "create" action and "--archive latest" flag when multiple archives share the
|
||||
same name with Borg 2.
|
||||
* Treat configuration file permissions issues as errors instead of warnings.
|
||||
* When running tests, use Ruff for faster and more comprehensive code linting and formatting,
|
||||
replacing Flake8, Black, isort, etc.
|
||||
* Switch from pipx to uv for installing development tools, and added tox-uv for speeding up test
|
||||
environment creation. See the developer documentation for more information:
|
||||
https://torsion.org/borgmatic/docs/how-to/develop-on-borgmatic/
|
||||
|
||||
2.0.7
|
||||
* #1032: Fix a bug in which a Borg archive gets created even when a database hook fails.
|
||||
* #1043: Support Btrfs subvolume paths in "source_directories" even when the subvolume is mounted
|
||||
elsewhere.
|
||||
* #1048: Ignore Btrfs subvolumes whose read-only status can't be determined.
|
||||
* #1083: Add "debug_passphrase"/"display_passphrase" options and a "{unixtime}" placeholder in
|
||||
support of Borg 2 features.
|
||||
* #1099: Clarify documentation on command hooks order of execution.
|
||||
* #1100: Fix a bug in which "borg --version" failing isn't considered a "fail" state in a command
|
||||
hook.
|
||||
* #1108: Fix a bug in which quoted "extra_borg_options" values containing spaces are passed to Borg
|
||||
incorrectly.
|
||||
* #1108: Add a "--comment" flag to the "create" action for creating an archive with a comment.
|
||||
* Use the Bandit security analysis tool when running tests.
|
||||
* SECURITY: Add timeouts to all monitoring hooks to prevent hangs on network requests, e.g. due to
|
||||
a compromised monitoring server holding requests open.
|
||||
* SECURITY: For the "spot" check, use a more secure source of randomness when selecting paths to
|
||||
check.
|
||||
|
||||
2.0.6
|
||||
* #1068: Fix a warning from LVM about leaked file descriptors.
|
||||
* #1086: Fix for the "spot" check breaking when the "--progress" flag is used.
|
||||
* #1089: Fix for the "spot" check erroring when a checksum command errors.
|
||||
* #1091: Fix for the "config generate" action generating invalid configuration when upgrading
|
||||
deprecated command hooks.
|
||||
* #1093: Fix for the LVM hook erroring when the "--dry-run" flag is used.
|
||||
* #1094: Fix incorrect documentation about customizing Borg exit codes:
|
||||
https://torsion.org/borgmatic/docs/how-to/customize-warnings-and-errors/
|
||||
* #1095: Fix for the "spot" check's "xxh64sum_command" option erroring on commands containing
|
||||
spaces.
|
||||
* Add support for Borg 2's "s3:" and "b2:" repository URLs, so you can backup to S3 or B2 cloud
|
||||
storage services even without using Rclone.
|
||||
* During the "spot" check, truncate log messages containing many file paths.
|
||||
|
||||
2.0.5
|
||||
* #1033: Add a "password_transport" option to the MariaDB and MySQL database hooks for customizing
|
||||
how borgmatic transmits passwords to the database client.
|
||||
* #1078: Add "keep_3monthly" and "keep_13weekly" options for customizing "prune" action archive
|
||||
retention.
|
||||
* #1078: Add a "use_chunks_archive" option for controlling whether Borg uses its chunks cache
|
||||
directory.
|
||||
* #1078: For the "compact" action, pass "--dry-run" through to Borg.
|
||||
* #1085: Fix a regression in which the default monitoring verbosity is 0 (warnings only) instead of
|
||||
1 (info about steps borgmatic is taking). This prevented logs from showing up in monitoring
|
||||
services like Healthchecks unless you had an explicit monitoring verbosity set.
|
||||
* Move Mastodon social hosting from Fosstodon to FLOSS.social: https://floss.social/@borgmatic
|
||||
* The borgmatic project no longer accepts pull requests on GitHub. But see
|
||||
https://torsion.org/borgmatic/#contributing for how you can still submit pull requests. You can
|
||||
even use your GitHub account to login.
|
||||
|
||||
2.0.4
|
||||
* #1072: Fix path rewriting for non-root patterns in the ZFS, Btrfs, and LVM hooks.
|
||||
* #1073: Clarify the documentation about when an "after: error" command hook runs and how it
|
||||
differs from other hooks:
|
||||
https://torsion.org/borgmatic/docs/how-to/add-preparation-and-cleanup-steps-to-backups/
|
||||
* #1075: Fix an incorrect warning about Borg placeholders being unsupported in a command hook.
|
||||
* #1080: If the exact same "everything" command hook is present in multiple configuration files,
|
||||
only run it once.
|
||||
|
||||
2.0.3
|
||||
* #1065: Fix a regression in monitoring hooks in which an error pinged the finish state instead of
|
||||
the fail state.
|
||||
* #1066: Add a "states" option to command hooks, so you can optionally skip an "after" hook if
|
||||
borgmatic encounters an error.
|
||||
* #1071: Fix an error in the LVM hook when removing a snapshot directory.
|
||||
|
||||
2.0.2
|
||||
* #1035: Document potential performance issues and workarounds with the ZFS, Btrfs, and LVM hooks:
|
||||
https://torsion.org/borgmatic/docs/how-to/snapshot-your-filesystems/
|
||||
* #1053: Display a nicer error message when the "recreate" action encounters an archive that
|
||||
already exists.
|
||||
* #1059: Fix a regression in which soft failure exit codes in command hooks were not respected.
|
||||
* #1060: Fix action command hooks getting run too many times when multiple borgmatic actions are
|
||||
executed (implicitly or explicitly).
|
||||
* #1060: Don't run action command hooks for actions listed in the "skip_actions" option.
|
||||
* #1062: Fix a regression that broke environment variable interpolation.
|
||||
* #1063: List the configured "when" action names in the log entries for command hooks.
|
||||
|
||||
2.0.1
|
||||
* #1057: Fix argument parsing to avoid using Python 3.12+ string features. Now borgmatic will
|
||||
work with Python 3.9, 3.10, and 3.11 again.
|
||||
|
||||
2.0.0
|
||||
* TL;DR: More flexible, completely revamped command hooks. All configuration options settable on
|
||||
the command-line. New configuration options for many command-line flags (including verbosity!).
|
||||
New "key import" and "recreate" actions. Almost everything is backwards compatible—but mind those
|
||||
deprecation warnings!
|
||||
* #262: Add a "default_actions" option that supports disabling default actions when borgmatic is
|
||||
run without any command-line arguments.
|
||||
* #303: Deprecate the "--override" flag in favor of direct command-line flags for every borgmatic
|
||||
configuration option. See the documentation for more information:
|
||||
https://torsion.org/borgmatic/docs/how-to/make-per-application-backups/#configuration-overrides
|
||||
* #303: Add configuration options that serve as defaults for some (but not all) command-line
|
||||
action flags. For example, each entry in "repositories:" now has an "encryption" option that
|
||||
applies to the "repo-create" action, serving as a default for the "--encryption" flag. See the
|
||||
documentation for more information: https://torsion.org/borgmatic/docs/reference/configuration/
|
||||
* #345: Add a "key import" action to import a repository key from backup.
|
||||
* #422: Add home directory expansion to file-based and KeePassXC credential hooks.
|
||||
* #610: Add a "recreate" action for recreating archives, for instance for retroactively excluding
|
||||
particular files from existing archives.
|
||||
* #790, #821: Deprecate all "before_*", "after_*" and "on_error" command hooks in favor of more
|
||||
flexible "commands:". See the documentation for more information:
|
||||
https://torsion.org/borgmatic/docs/how-to/add-preparation-and-cleanup-steps-to-backups/
|
||||
* #790: BREAKING: For both new and deprecated command hooks, run a configured "after" hook even if
|
||||
an error occurs first. This allows you to perform cleanup steps that correspond to "before"
|
||||
preparation commands—even when something goes wrong.
|
||||
* #790: BREAKING: Run all command hooks (both new and deprecated) respecting the
|
||||
"working_directory" option if configured, meaning that hook commands are run in that directory.
|
||||
* #793: Add configuration options for all verbosity and logging flags, so you don't have to set
|
||||
them on the command-line.
|
||||
* #836: Add a custom command option for the SQLite hook.
|
||||
* #837: Add custom command options for the MongoDB hook.
|
||||
* #1010: When using Borg 2, don't pass the "--stats" flag to "borg prune".
|
||||
* #1020: Document a database use case involving a temporary database client container:
|
||||
https://torsion.org/borgmatic/docs/how-to/backup-your-databases/#database-containers
|
||||
* #1037: Fix an error with the "extract" action when both a remote repository and a
|
||||
"working_directory" are used.
|
||||
* #1044: Fix an error in the systemd credential hook when the credential name contains a "."
|
||||
character.
|
||||
* #1047: Add "key-file" and "yubikey" options to the KeePassXC credential hook.
|
||||
* #1048: Fix a "no such file or directory" error in ZFS, Btrfs, and LVM hooks with nested
|
||||
directories that reside on separate devices/filesystems.
|
||||
* #1050: Fix a failure in the "spot" check when the archive contains a symlink.
|
||||
* #1051: Add configuration filename to the "Successfully ran configuration file" log message.
|
||||
|
||||
1.9.14
|
||||
* #409: With the PagerDuty monitoring hook, send borgmatic logs to PagerDuty so they show up in the
|
||||
incident UI. See the documentation for more information:
|
||||
https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#pagerduty-hook
|
||||
* #936: Clarify Zabbix monitoring hook documentation about creating items:
|
||||
https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#zabbix-hook
|
||||
* #1017: Fix a regression in which some MariaDB/MySQL passwords were not escaped correctly.
|
||||
* #1021: Fix a regression in which the "exclude_patterns" option didn't expand "~" (the user's
|
||||
home directory). This fix means that all "patterns" and "patterns_from" also now expand "~".
|
||||
* #1023: Fix an error in the Btrfs hook when attempting to snapshot a read-only subvolume. Now,
|
||||
read-only subvolumes are ignored since Btrfs can't actually snapshot them.
|
||||
|
||||
1.9.13
|
||||
* #975: Add a "compression" option to the PostgreSQL database hook.
|
||||
* #1001: Fix a ZFS error during snapshot cleanup.
|
||||
* #1003: In the Zabbix monitoring hook, support Zabbix 7.2's authentication changes.
|
||||
* #1009: Send database passwords to MariaDB and MySQL via anonymous pipe, which is more secure than
|
||||
using an environment variable.
|
||||
* #1013: Send database passwords to MongoDB via anonymous pipe, which is more secure than using
|
||||
"--password" on the command-line!
|
||||
* #1015: When ctrl-C is pressed, more strongly encourage Borg to actually exit.
|
||||
* Add a "verify_tls" option to the Uptime Kuma monitoring hook for disabling TLS verification.
|
||||
* Add "tls" options to the MariaDB and MySQL database hooks to enable or disable TLS encryption
|
||||
between client and server.
|
||||
|
||||
1.9.12
|
||||
* #1005: Fix the credential hooks to avoid using Python 3.12+ string features. Now borgmatic will
|
||||
work with Python 3.9, 3.10, and 3.11 again.
|
||||
|
||||
1.9.11
|
||||
* #795: Add credential loading from file, KeePassXC, and Docker/Podman secrets. See the
|
||||
documentation for more information:
|
||||
https://torsion.org/borgmatic/docs/how-to/provide-your-passwords/
|
||||
* #996: Fix the "create" action to omit the repository label prefix from Borg's output when
|
||||
databases are enabled.
|
||||
* #998: Send the "encryption_passphrase" option to Borg via an anonymous pipe, which is more secure
|
||||
than using an environment variable.
|
||||
* #999: Fix a runtime directory error from a conflict between "extra_borg_options" and special file
|
||||
detection.
|
||||
* #1001: For the ZFS, Btrfs, and LVM hooks, only make snapshots for root patterns that come from
|
||||
a borgmatic configuration option (e.g. "source_directories")—not from other hooks within
|
||||
borgmatic.
|
||||
* #1001: Fix a ZFS/LVM error due to colliding snapshot mount points for nested datasets or logical
|
||||
volumes.
|
||||
* #1001: Don't try to snapshot ZFS datasets that have the "canmount=off" property.
|
||||
* Fix another error in the Btrfs hook when a subvolume mounted at "/" is configured in borgmatic's
|
||||
source directories.
|
||||
|
||||
1.9.10
|
||||
* #966: Add a "{credential ...}" syntax for loading systemd credentials into borgmatic
|
||||
configuration files. See the documentation for more information:
|
||||
@@ -189,7 +406,7 @@
|
||||
paths when a "working_directory" is set.
|
||||
* #906: Add documentation details for how to run custom database dump commands using binaries from
|
||||
running containers:
|
||||
https://torsion.org/borgmatic/docs/how-to/backup-your-databases/#containers
|
||||
https://torsion.org/borgmatic/docs/how-to/backup-your-databases/#database-containers
|
||||
* Fix a regression in which the "color" option had no effect.
|
||||
* Add a recent contributors section to the documentation, because credit where credit's due! See:
|
||||
https://torsion.org/borgmatic/#recent-contributors
|
||||
@@ -245,7 +462,7 @@
|
||||
* Fix handling of the NO_COLOR environment variable to ignore an empty value.
|
||||
* Add documentation about backing up containerized databases by configuring borgmatic to exec into
|
||||
a container to run a dump command:
|
||||
https://torsion.org/borgmatic/docs/how-to/backup-your-databases/#containers
|
||||
https://torsion.org/borgmatic/docs/how-to/backup-your-databases/#database-containers
|
||||
|
||||
1.8.9
|
||||
* #311: Add custom dump/restore command options for MySQL and MariaDB.
|
||||
@@ -515,7 +732,7 @@
|
||||
at the command-line. See the configuration reference for more information:
|
||||
https://torsion.org/borgmatic/docs/reference/configuration/
|
||||
* #649: Add documentation on backing up a database running in a container:
|
||||
https://torsion.org/borgmatic/docs/how-to/backup-your-databases/#containers
|
||||
https://torsion.org/borgmatic/docs/how-to/backup-your-databases/#database-containers
|
||||
* #655: Fix error when databases are configured and a source directory doesn't exist.
|
||||
* Add code style plugins to enforce use of Python f-strings and prevent single-letter variables.
|
||||
To join in the pedantry, refresh your test environment with "tox --recreate".
|
||||
|
||||
@@ -40,8 +40,10 @@ checks:
|
||||
frequency: 2 weeks
|
||||
|
||||
# Custom preparation scripts to run.
|
||||
before_backup:
|
||||
- prepare-for-backup.sh
|
||||
commands:
|
||||
- before: action
|
||||
when: [create]
|
||||
run: [prepare-for-backup.sh]
|
||||
|
||||
# Databases to dump and include in backups.
|
||||
postgresql_databases:
|
||||
@@ -88,6 +90,9 @@ borgmatic is powered by [Borg Backup](https://www.borgbackup.org/).
|
||||
### Credentials
|
||||
|
||||
<a href="https://systemd.io/"><img src="docs/static/systemd.png" alt="Sentry" height="40px" style="margin-bottom:20px; margin-right:20px;"></a>
|
||||
<a href="https://www.docker.com/"><img src="docs/static/docker.png" alt="Docker" height="40px" style="margin-bottom:20px; margin-right:20px;"></a>
|
||||
<a href="https://podman.io/"><img src="docs/static/podman.png" alt="Podman" height="40px" style="margin-bottom:20px; margin-right:20px;"></a>
|
||||
<a href="https://keepassxc.org/"><img src="docs/static/keepassxc.png" alt="Podman" height="40px" style="margin-bottom:20px; margin-right:20px;"></a>
|
||||
|
||||
|
||||
## Getting started
|
||||
@@ -133,7 +138,8 @@ issues.
|
||||
|
||||
### Social
|
||||
|
||||
Follow [borgmatic on Mastodon](https://fosstodon.org/@borgmatic).
|
||||
Follow <a rel="me" href="https://floss.social/@borgmatic">borgmatic on
|
||||
Mastodon</a>.
|
||||
|
||||
|
||||
### Chat
|
||||
@@ -154,21 +160,21 @@ Other questions or comments? Contact
|
||||
### Contributing
|
||||
|
||||
borgmatic [source code is
|
||||
available](https://projects.torsion.org/borgmatic-collective/borgmatic) and is also mirrored
|
||||
on [GitHub](https://github.com/borgmatic-collective/borgmatic) for convenience.
|
||||
available](https://projects.torsion.org/borgmatic-collective/borgmatic) and also
|
||||
has a read-only mirror on
|
||||
[GitHub](https://github.com/borgmatic-collective/borgmatic) for convenience.
|
||||
|
||||
borgmatic is licensed under the GNU General Public License version 3 or any
|
||||
later version.
|
||||
|
||||
If you'd like to contribute to borgmatic development, please feel free to
|
||||
submit a [Pull
|
||||
Request](https://projects.torsion.org/borgmatic-collective/borgmatic/pulls) or
|
||||
If you'd like to contribute to borgmatic development, please feel free to submit
|
||||
a [pull
|
||||
request](https://projects.torsion.org/borgmatic-collective/borgmatic/pulls) or
|
||||
open an
|
||||
[issue](https://projects.torsion.org/borgmatic-collective/borgmatic/issues) to
|
||||
discuss your idea. Note that you'll need to
|
||||
[register](https://projects.torsion.org/user/sign_up?invite_code=borgmatic)
|
||||
first. We also accept Pull Requests on GitHub, if that's more your thing. In
|
||||
general, contributions are very welcome. We don't bite!
|
||||
first. In general, contributions are very welcome. We don't bite!
|
||||
|
||||
Also, please check out the [borgmatic development
|
||||
how-to](https://torsion.org/borgmatic/docs/how-to/develop-on-borgmatic/) for
|
||||
|
||||
@@ -20,7 +20,8 @@ def run_borg(
|
||||
Run the "borg" action for the given repository.
|
||||
'''
|
||||
if borg_arguments.repository is None or borgmatic.config.validate.repositories_match(
|
||||
repository, borg_arguments.repository
|
||||
repository,
|
||||
borg_arguments.repository,
|
||||
):
|
||||
logger.info('Running arbitrary Borg command')
|
||||
archive_name = borgmatic.borg.repo_list.resolve_archive_name(
|
||||
|
||||
@@ -19,7 +19,8 @@ def run_break_lock(
|
||||
Run the "break-lock" action for the given repository.
|
||||
'''
|
||||
if break_lock_arguments.repository is None or borgmatic.config.validate.repositories_match(
|
||||
repository, break_lock_arguments.repository
|
||||
repository,
|
||||
break_lock_arguments.repository,
|
||||
):
|
||||
logger.info('Breaking repository and cache locks')
|
||||
borgmatic.borg.break_lock.break_lock(
|
||||
|
||||
@@ -16,12 +16,13 @@ def run_change_passphrase(
|
||||
remote_path,
|
||||
):
|
||||
'''
|
||||
Run the "key change-passprhase" action for the given repository.
|
||||
Run the "key change-passphrase" action for the given repository.
|
||||
'''
|
||||
if (
|
||||
change_passphrase_arguments.repository is None
|
||||
or borgmatic.config.validate.repositories_match(
|
||||
repository, change_passphrase_arguments.repository
|
||||
repository,
|
||||
change_passphrase_arguments.repository,
|
||||
)
|
||||
):
|
||||
logger.info('Changing repository passphrase')
|
||||
|
||||
+138
-95
@@ -1,4 +1,5 @@
|
||||
import calendar
|
||||
import contextlib
|
||||
import datetime
|
||||
import hashlib
|
||||
import itertools
|
||||
@@ -6,14 +7,18 @@ import logging
|
||||
import os
|
||||
import pathlib
|
||||
import random
|
||||
import shlex
|
||||
import shutil
|
||||
import textwrap
|
||||
|
||||
import borgmatic.actions.create
|
||||
import borgmatic.actions.config.bootstrap
|
||||
import borgmatic.actions.pattern
|
||||
import borgmatic.borg.check
|
||||
import borgmatic.borg.create
|
||||
import borgmatic.borg.environment
|
||||
import borgmatic.borg.extract
|
||||
import borgmatic.borg.list
|
||||
import borgmatic.borg.pattern
|
||||
import borgmatic.borg.repo_list
|
||||
import borgmatic.borg.state
|
||||
import borgmatic.config.paths
|
||||
@@ -53,12 +58,14 @@ def parse_checks(config, only_checks=None):
|
||||
|
||||
if 'disabled' in checks:
|
||||
logger.warning(
|
||||
'The "disabled" value for the "checks" option is deprecated and will be removed from a future release; use "skip_actions" instead'
|
||||
'The "disabled" value for the "checks" option is deprecated and will be removed from a future release; use "skip_actions" instead',
|
||||
)
|
||||
|
||||
if len(checks) > 1:
|
||||
logger.warning(
|
||||
'Multiple checks are configured, but one of them is "disabled"; not running any checks'
|
||||
'Multiple checks are configured, but one of them is "disabled"; not running any checks',
|
||||
)
|
||||
|
||||
return ()
|
||||
|
||||
return checks
|
||||
@@ -162,6 +169,7 @@ def filter_checks_on_frequency(
|
||||
**dict.fromkeys(day for day in days if day != 'weekday'),
|
||||
**dict.fromkeys(WEEKDAY_DAYS),
|
||||
}
|
||||
|
||||
if 'weekend' in days:
|
||||
days = {
|
||||
**dict.fromkeys(day for day in days if day != 'weekend'),
|
||||
@@ -170,7 +178,7 @@ def filter_checks_on_frequency(
|
||||
|
||||
if calendar.day_name[datetime_now().weekday()] not in days:
|
||||
logger.info(
|
||||
f"Skipping {check} check due to day of the week; check only runs on {'/'.join(days)} (use --force to check anyway)"
|
||||
f"Skipping {check} check due to day of the week; check only runs on {'/'.join(day.title() for day in days)} (use --force to check anyway)",
|
||||
)
|
||||
filtered_checks.remove(check)
|
||||
continue
|
||||
@@ -188,7 +196,7 @@ def filter_checks_on_frequency(
|
||||
if datetime_now() < check_time + frequency_delta:
|
||||
remaining = check_time + frequency_delta - datetime_now()
|
||||
logger.info(
|
||||
f'Skipping {check} check due to configured frequency; {remaining} until next check (use --force to check anyway)'
|
||||
f'Skipping {check} check due to configured frequency; {remaining} until next check (use --force to check anyway)',
|
||||
)
|
||||
filtered_checks.remove(check)
|
||||
|
||||
@@ -214,7 +222,7 @@ def make_check_time_path(config, borg_repository_id, check_type, archives_check_
|
||||
'''
|
||||
borgmatic_state_directory = borgmatic.config.paths.get_borgmatic_state_directory(config)
|
||||
|
||||
if check_type in ('archives', 'data'):
|
||||
if check_type in {'archives', 'data'}:
|
||||
return os.path.join(
|
||||
borgmatic_state_directory,
|
||||
'checks',
|
||||
@@ -249,7 +257,7 @@ def read_check_time(path):
|
||||
logger.debug(f'Reading check time from {path}')
|
||||
|
||||
try:
|
||||
return datetime.datetime.fromtimestamp(os.stat(path).st_mtime)
|
||||
return datetime.datetime.fromtimestamp(os.stat(path).st_mtime) # noqa: DTZ006
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
|
||||
@@ -280,7 +288,7 @@ def probe_for_check_time(config, borg_repository_id, check, archives_check_id):
|
||||
(
|
||||
make_check_time_path(config, borg_repository_id, check, archives_check_id),
|
||||
make_check_time_path(config, borg_repository_id, check),
|
||||
)
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -312,16 +320,17 @@ def upgrade_check_times(config, borg_repository_id):
|
||||
{borgmatic_state_directory}/checks/1234567890/archives/all
|
||||
'''
|
||||
borgmatic_source_checks_path = os.path.join(
|
||||
borgmatic.config.paths.get_borgmatic_source_directory(config), 'checks'
|
||||
borgmatic.config.paths.get_borgmatic_source_directory(config),
|
||||
'checks',
|
||||
)
|
||||
borgmatic_state_path = borgmatic.config.paths.get_borgmatic_state_directory(config)
|
||||
borgmatic_state_checks_path = os.path.join(borgmatic_state_path, 'checks')
|
||||
|
||||
if os.path.exists(borgmatic_source_checks_path) and not os.path.exists(
|
||||
borgmatic_state_checks_path
|
||||
borgmatic_state_checks_path,
|
||||
):
|
||||
logger.debug(
|
||||
f'Upgrading archives check times directory from {borgmatic_source_checks_path} to {borgmatic_state_checks_path}'
|
||||
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)
|
||||
shutil.move(borgmatic_source_checks_path, borgmatic_state_checks_path)
|
||||
@@ -336,10 +345,8 @@ def upgrade_check_times(config, borg_repository_id):
|
||||
|
||||
logger.debug(f'Upgrading archives check time file from {old_path} to {new_path}')
|
||||
|
||||
try:
|
||||
with contextlib.suppress(FileNotFoundError):
|
||||
shutil.move(old_path, temporary_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
os.mkdir(old_path)
|
||||
shutil.move(temporary_path, new_path)
|
||||
@@ -353,45 +360,58 @@ def collect_spot_check_source_paths(
|
||||
local_path,
|
||||
remote_path,
|
||||
borgmatic_runtime_directory,
|
||||
bootstrap_config_paths,
|
||||
):
|
||||
'''
|
||||
Given a repository configuration dict, 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 source paths that Borg would use in an actual create (but only include files).
|
||||
arguments as an argparse.Namespace instance, the local Borg path, the remote Borg path, and the
|
||||
bootstrap configuration paths as read from an archive's manifest, collect the source paths that
|
||||
Borg would use in an actual create (but only include files). As part of this, include the
|
||||
bootstrap configuration paths, so that any configuration files included in the archive to
|
||||
support bootstrapping are also spot checked.
|
||||
'''
|
||||
stream_processes = any(
|
||||
borgmatic.hooks.dispatch.call_hooks(
|
||||
'use_streaming',
|
||||
config,
|
||||
borgmatic.hooks.dispatch.Hook_type.DATA_SOURCE,
|
||||
).values()
|
||||
).values(),
|
||||
)
|
||||
working_directory = borgmatic.config.paths.get_working_directory(config)
|
||||
|
||||
(create_flags, create_positional_arguments, pattern_file) = (
|
||||
borgmatic.borg.create.make_base_create_command(
|
||||
dry_run=True,
|
||||
repository_path=repository['path'],
|
||||
config=config,
|
||||
patterns=borgmatic.actions.create.process_patterns(
|
||||
borgmatic.actions.create.collect_patterns(config),
|
||||
working_directory,
|
||||
(create_flags, create_positional_arguments, _) = borgmatic.borg.create.make_base_create_command(
|
||||
dry_run=True,
|
||||
repository_path=repository['path'],
|
||||
# Omit "progress" because it interferes with "list_details".
|
||||
config=dict(
|
||||
{option: value for option, value in config.items() if option != 'progress'},
|
||||
list_details=True,
|
||||
),
|
||||
patterns=borgmatic.actions.pattern.process_patterns(
|
||||
borgmatic.actions.pattern.collect_patterns(config)
|
||||
+ tuple(
|
||||
borgmatic.borg.pattern.Pattern(
|
||||
config_path,
|
||||
source=borgmatic.borg.pattern.Pattern_source.INTERNAL,
|
||||
)
|
||||
for config_path in bootstrap_config_paths
|
||||
),
|
||||
local_borg_version=local_borg_version,
|
||||
global_arguments=global_arguments,
|
||||
borgmatic_runtime_directory=borgmatic_runtime_directory,
|
||||
local_path=local_path,
|
||||
remote_path=remote_path,
|
||||
list_files=True,
|
||||
stream_processes=stream_processes,
|
||||
)
|
||||
config,
|
||||
working_directory,
|
||||
),
|
||||
local_borg_version=local_borg_version,
|
||||
global_arguments=global_arguments,
|
||||
borgmatic_runtime_directory=borgmatic_runtime_directory,
|
||||
local_path=local_path,
|
||||
remote_path=remote_path,
|
||||
stream_processes=stream_processes,
|
||||
)
|
||||
working_directory = borgmatic.config.paths.get_working_directory(config)
|
||||
|
||||
paths_output = borgmatic.execute.execute_command_and_capture_output(
|
||||
create_flags + create_positional_arguments,
|
||||
capture_stderr=True,
|
||||
extra_environment=borgmatic.borg.environment.make_environment(config),
|
||||
environment=borgmatic.borg.environment.make_environment(config),
|
||||
working_directory=working_directory,
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=config.get('borg_exit_codes'),
|
||||
@@ -400,7 +420,7 @@ def collect_spot_check_source_paths(
|
||||
paths = tuple(
|
||||
path_line.split(' ', 1)[1]
|
||||
for path_line in paths_output.splitlines()
|
||||
if path_line and path_line.startswith('- ') or path_line.startswith('+ ')
|
||||
if path_line and path_line.startswith(('- ', '+ '))
|
||||
)
|
||||
|
||||
return tuple(
|
||||
@@ -441,12 +461,12 @@ def collect_spot_check_archive_paths(
|
||||
config,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
path_format='{type} {path}{NUL}', # noqa: FS003
|
||||
path_format='{type} {path}{NUL}',
|
||||
local_path=local_path,
|
||||
remote_path=remote_path,
|
||||
)
|
||||
for (file_type, path) in (line.split(' ', 1),)
|
||||
if file_type not in (BORG_DIRECTORY_FILE_TYPE, BORG_PIPE_FILE_TYPE)
|
||||
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
|
||||
@@ -455,7 +475,7 @@ def collect_spot_check_archive_paths(
|
||||
)
|
||||
|
||||
|
||||
SAMPLE_PATHS_SUBSET_COUNT = 10000
|
||||
SAMPLE_PATHS_SUBSET_COUNT = 5000
|
||||
|
||||
|
||||
def compare_spot_check_hashes(
|
||||
@@ -479,17 +499,20 @@ def compare_spot_check_hashes(
|
||||
# source directories.
|
||||
spot_check_config = next(check for check in config['checks'] if check['name'] == 'spot')
|
||||
sample_count = max(
|
||||
int(len(source_paths) * (min(spot_check_config['data_sample_percentage'], 100) / 100)), 1
|
||||
int(len(source_paths) * (min(spot_check_config['data_sample_percentage'], 100) / 100)),
|
||||
1,
|
||||
)
|
||||
source_sample_paths = tuple(random.sample(source_paths, sample_count))
|
||||
source_sample_paths = tuple(random.SystemRandom().sample(source_paths, sample_count))
|
||||
working_directory = borgmatic.config.paths.get_working_directory(config)
|
||||
existing_source_sample_paths = {
|
||||
hashable_source_sample_path = {
|
||||
source_path
|
||||
for source_path in source_sample_paths
|
||||
if os.path.exists(os.path.join(working_directory or '', source_path))
|
||||
for full_source_path in (os.path.join(working_directory or '', source_path),)
|
||||
if os.path.exists(full_source_path)
|
||||
if not os.path.islink(full_source_path)
|
||||
}
|
||||
logger.debug(
|
||||
f'Sampling {sample_count} source paths (~{spot_check_config["data_sample_percentage"]}%) for spot check'
|
||||
f'Sampling {sample_count} source paths (~{spot_check_config["data_sample_percentage"]}%) for spot check',
|
||||
)
|
||||
|
||||
source_sample_paths_iterator = iter(source_sample_paths)
|
||||
@@ -501,15 +524,18 @@ def compare_spot_check_hashes(
|
||||
while True:
|
||||
# Hash each file in the sample paths (if it exists).
|
||||
source_sample_paths_subset = tuple(
|
||||
itertools.islice(source_sample_paths_iterator, SAMPLE_PATHS_SUBSET_COUNT)
|
||||
itertools.islice(source_sample_paths_iterator, SAMPLE_PATHS_SUBSET_COUNT),
|
||||
)
|
||||
if not source_sample_paths_subset:
|
||||
break
|
||||
|
||||
hash_output = borgmatic.execute.execute_command_and_capture_output(
|
||||
(spot_check_config.get('xxh64sum_command', 'xxh64sum'),)
|
||||
tuple(
|
||||
shlex.quote(part)
|
||||
for part in shlex.split(spot_check_config.get('xxh64sum_command', 'xxh64sum'))
|
||||
)
|
||||
+ tuple(
|
||||
path for path in source_sample_paths_subset if path in existing_source_sample_paths
|
||||
path for path in source_sample_paths_subset if path in hashable_source_sample_path
|
||||
),
|
||||
working_directory=working_directory,
|
||||
)
|
||||
@@ -517,13 +543,15 @@ def compare_spot_check_hashes(
|
||||
source_hashes.update(
|
||||
**dict(
|
||||
(reversed(line.split(' ', 1)) for line in hash_output.splitlines()),
|
||||
# Represent non-existent files as having empty hashes so the comparison below still works.
|
||||
# Represent non-existent files as having empty hashes so the comparison below still
|
||||
# works. Same thing for filesystem links, since Borg produces empty archive hashes
|
||||
# for them.
|
||||
**{
|
||||
path: ''
|
||||
for path in source_sample_paths_subset
|
||||
if path not in existing_source_sample_paths
|
||||
if path not in hashable_source_sample_path
|
||||
},
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
# Get the hash for each file in the archive.
|
||||
@@ -537,12 +565,12 @@ def compare_spot_check_hashes(
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
list_paths=source_sample_paths_subset,
|
||||
path_format='{xxh64} {path}{NUL}', # noqa: FS003
|
||||
path_format='{xxh64} {path}{NUL}',
|
||||
local_path=local_path,
|
||||
remote_path=remote_path,
|
||||
)
|
||||
if line
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
# Compare the source hashes with the archive hashes to see how many match.
|
||||
@@ -559,6 +587,9 @@ def compare_spot_check_hashes(
|
||||
return tuple(failing_paths)
|
||||
|
||||
|
||||
MAX_SPOT_CHECK_PATHS_LENGTH = 1000
|
||||
|
||||
|
||||
def spot_check(
|
||||
repository,
|
||||
config,
|
||||
@@ -588,20 +619,9 @@ def spot_check(
|
||||
|
||||
if spot_check_config['data_tolerance_percentage'] > spot_check_config['data_sample_percentage']:
|
||||
raise ValueError(
|
||||
'The data_tolerance_percentage must be less than or equal to the data_sample_percentage'
|
||||
'The data_tolerance_percentage must be less than or equal to the data_sample_percentage',
|
||||
)
|
||||
|
||||
source_paths = collect_spot_check_source_paths(
|
||||
repository,
|
||||
config,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
borgmatic_runtime_directory,
|
||||
)
|
||||
logger.debug(f'{len(source_paths)} total source paths for spot check')
|
||||
|
||||
archive = borgmatic.borg.repo_list.resolve_archive_name(
|
||||
repository['path'],
|
||||
'latest',
|
||||
@@ -613,6 +633,25 @@ def spot_check(
|
||||
)
|
||||
logger.debug(f'Using archive {archive} for spot check')
|
||||
|
||||
source_paths = collect_spot_check_source_paths(
|
||||
repository,
|
||||
config,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
borgmatic_runtime_directory,
|
||||
bootstrap_config_paths=borgmatic.actions.config.bootstrap.load_config_paths_from_archive(
|
||||
repository['path'],
|
||||
archive,
|
||||
config,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
borgmatic_runtime_directory,
|
||||
),
|
||||
)
|
||||
logger.debug(f'{len(source_paths)} total source paths for spot check')
|
||||
|
||||
archive_paths = collect_spot_check_archive_paths(
|
||||
repository,
|
||||
archive,
|
||||
@@ -626,11 +665,14 @@ def spot_check(
|
||||
logger.debug(f'{len(archive_paths)} total archive paths for spot check')
|
||||
|
||||
if len(source_paths) == 0:
|
||||
logger.debug(
|
||||
f'Paths in latest archive but not source paths: {", ".join(set(archive_paths)) or "none"}'
|
||||
truncated_archive_paths = textwrap.shorten(
|
||||
', '.join(set(archive_paths)) or 'none',
|
||||
width=MAX_SPOT_CHECK_PATHS_LENGTH,
|
||||
placeholder=' ...',
|
||||
)
|
||||
logger.debug(f'Paths in latest archive but not source paths: {truncated_archive_paths}')
|
||||
raise ValueError(
|
||||
'Spot check failed: There are no source paths to compare against the archive'
|
||||
'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
|
||||
@@ -638,15 +680,25 @@ def spot_check(
|
||||
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'Paths in source paths but not latest archive: {", ".join(rootless_source_paths - set(archive_paths)) or "none"}'
|
||||
rootless_source_paths = {path.lstrip(os.path.sep) for path in source_paths}
|
||||
truncated_exclusive_source_paths = textwrap.shorten(
|
||||
', '.join(rootless_source_paths - set(archive_paths)) or 'none',
|
||||
width=MAX_SPOT_CHECK_PATHS_LENGTH,
|
||||
placeholder=' ...',
|
||||
)
|
||||
logger.debug(
|
||||
f'Paths in latest archive but not source paths: {", ".join(set(archive_paths) - rootless_source_paths) or "none"}'
|
||||
f'Paths in source paths but not latest archive: {truncated_exclusive_source_paths}',
|
||||
)
|
||||
truncated_exclusive_archive_paths = textwrap.shorten(
|
||||
', '.join(set(archive_paths) - rootless_source_paths) or 'none',
|
||||
width=MAX_SPOT_CHECK_PATHS_LENGTH,
|
||||
placeholder=' ...',
|
||||
)
|
||||
logger.debug(
|
||||
f'Paths in latest archive but not source paths: {truncated_exclusive_archive_paths}',
|
||||
)
|
||||
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"]}%)'
|
||||
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"]}%)',
|
||||
)
|
||||
|
||||
failing_paths = compare_spot_check_hashes(
|
||||
@@ -666,15 +718,20 @@ def spot_check(
|
||||
failing_percentage = (len(failing_paths) / len(source_paths)) * 100
|
||||
|
||||
if failing_percentage > data_tolerance_percentage:
|
||||
truncated_failing_paths = textwrap.shorten(
|
||||
', '.join(failing_paths),
|
||||
width=MAX_SPOT_CHECK_PATHS_LENGTH,
|
||||
placeholder=' ...',
|
||||
)
|
||||
logger.debug(
|
||||
f'Source paths with data not matching the latest archive: {", ".join(failing_paths)}'
|
||||
f'Source paths with data not matching the latest archive: {truncated_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}%)'
|
||||
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'Spot check passed with a {count_delta_percentage:.2f}% file count delta and a {failing_percentage:.2f}% file data delta'
|
||||
f'Spot check passed with a {count_delta_percentage:.2f}% file count delta and a {failing_percentage:.2f}% file data delta',
|
||||
)
|
||||
|
||||
|
||||
@@ -682,7 +739,6 @@ def run_check(
|
||||
config_filename,
|
||||
repository,
|
||||
config,
|
||||
hook_context,
|
||||
local_borg_version,
|
||||
check_arguments,
|
||||
global_arguments,
|
||||
@@ -695,19 +751,11 @@ def run_check(
|
||||
Raise ValueError if the Borg repository ID cannot be determined.
|
||||
'''
|
||||
if check_arguments.repository and not borgmatic.config.validate.repositories_match(
|
||||
repository, check_arguments.repository
|
||||
repository,
|
||||
check_arguments.repository,
|
||||
):
|
||||
return
|
||||
|
||||
borgmatic.hooks.command.execute_hook(
|
||||
config.get('before_check'),
|
||||
config.get('umask'),
|
||||
config_filename,
|
||||
'pre-check',
|
||||
global_arguments.dry_run,
|
||||
**hook_context,
|
||||
)
|
||||
|
||||
logger.info('Running consistency checks')
|
||||
|
||||
repository_id = borgmatic.borg.check.get_repository_id(
|
||||
@@ -721,7 +769,10 @@ def run_check(
|
||||
upgrade_check_times(config, repository_id)
|
||||
configured_checks = parse_checks(config, check_arguments.only_checks)
|
||||
archive_filter_flags = borgmatic.borg.check.make_archive_filter_flags(
|
||||
local_borg_version, config, configured_checks, check_arguments
|
||||
local_borg_version,
|
||||
config,
|
||||
configured_checks,
|
||||
check_arguments,
|
||||
)
|
||||
archives_check_id = make_archives_check_id(archive_filter_flags)
|
||||
checks = filter_checks_on_frequency(
|
||||
@@ -771,13 +822,5 @@ def run_check(
|
||||
remote_path,
|
||||
borgmatic_runtime_directory,
|
||||
)
|
||||
write_check_time(make_check_time_path(config, repository_id, 'spot'))
|
||||
|
||||
borgmatic.hooks.command.execute_hook(
|
||||
config.get('after_check'),
|
||||
config.get('umask'),
|
||||
config_filename,
|
||||
'post-check',
|
||||
global_arguments.dry_run,
|
||||
**hook_context,
|
||||
)
|
||||
write_check_time(make_check_time_path(config, repository_id, 'spot'))
|
||||
|
||||
@@ -12,7 +12,6 @@ def run_compact(
|
||||
config_filename,
|
||||
repository,
|
||||
config,
|
||||
hook_context,
|
||||
local_borg_version,
|
||||
compact_arguments,
|
||||
global_arguments,
|
||||
@@ -24,18 +23,11 @@ def run_compact(
|
||||
Run the "compact" action for the given repository.
|
||||
'''
|
||||
if compact_arguments.repository and not borgmatic.config.validate.repositories_match(
|
||||
repository, compact_arguments.repository
|
||||
repository,
|
||||
compact_arguments.repository,
|
||||
):
|
||||
return
|
||||
|
||||
borgmatic.hooks.command.execute_hook(
|
||||
config.get('before_compact'),
|
||||
config.get('umask'),
|
||||
config_filename,
|
||||
'pre-compact',
|
||||
global_arguments.dry_run,
|
||||
**hook_context,
|
||||
)
|
||||
if borgmatic.borg.feature.available(borgmatic.borg.feature.Feature.COMPACT, local_borg_version):
|
||||
logger.info(f'Compacting segments{dry_run_label}')
|
||||
borgmatic.borg.compact.compact_segments(
|
||||
@@ -46,18 +38,7 @@ def run_compact(
|
||||
global_arguments,
|
||||
local_path=local_path,
|
||||
remote_path=remote_path,
|
||||
progress=compact_arguments.progress,
|
||||
cleanup_commits=compact_arguments.cleanup_commits,
|
||||
threshold=compact_arguments.threshold,
|
||||
)
|
||||
else: # pragma: nocover
|
||||
logger.info('Skipping compact (only available/needed in Borg 1.2+)')
|
||||
|
||||
borgmatic.hooks.command.execute_hook(
|
||||
config.get('after_compact'),
|
||||
config.get('umask'),
|
||||
config_filename,
|
||||
'post-compact',
|
||||
global_arguments.dry_run,
|
||||
**hook_context,
|
||||
)
|
||||
|
||||
@@ -16,78 +16,81 @@ def make_bootstrap_config(bootstrap_arguments):
|
||||
Given the bootstrap arguments as an argparse.Namespace, return a corresponding config dict.
|
||||
'''
|
||||
return {
|
||||
'ssh_command': bootstrap_arguments.ssh_command,
|
||||
'borgmatic_source_directory': bootstrap_arguments.borgmatic_source_directory,
|
||||
'local_path': bootstrap_arguments.local_path,
|
||||
'remote_path': bootstrap_arguments.remote_path,
|
||||
# In case the repo has been moved or is accessed from a different path at the point of
|
||||
# bootstrapping.
|
||||
'relocated_repo_access_is_ok': True,
|
||||
'ssh_command': bootstrap_arguments.ssh_command,
|
||||
'user_runtime_directory': bootstrap_arguments.user_runtime_directory,
|
||||
}
|
||||
|
||||
|
||||
def get_config_paths(archive_name, bootstrap_arguments, global_arguments, local_borg_version):
|
||||
def load_config_paths_from_archive(
|
||||
repository_path,
|
||||
archive_name,
|
||||
config,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
borgmatic_runtime_directory,
|
||||
):
|
||||
'''
|
||||
Given an archive name, the bootstrap arguments as an argparse.Namespace (containing the
|
||||
repository and archive name, Borg local path, Borg remote path, borgmatic runtime directory,
|
||||
borgmatic source directory, destination directory, and whether to strip components), the global
|
||||
arguments as an argparse.Namespace (containing the dry run flag and the local borg version),
|
||||
return the config paths from the manifest.json file in the borgmatic source directory or runtime
|
||||
directory after extracting it from the repository archive.
|
||||
Given a repository path, an archive name, a configuration dict, the local Borg version, the
|
||||
global arguments as an argparse.Namespace, and the borgmatic runtime directory, return the
|
||||
config paths from the manifest.json file in the borgmatic source directory or runtime directory
|
||||
within the repository archive.
|
||||
|
||||
Raise ValueError if the manifest JSON is missing, can't be decoded, or doesn't contain the
|
||||
expected configuration path data.
|
||||
'''
|
||||
borgmatic_source_directory = borgmatic.config.paths.get_borgmatic_source_directory(
|
||||
{'borgmatic_source_directory': bootstrap_arguments.borgmatic_source_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 gets stored as just "/borgmatic" with Borg 1.4+). But we
|
||||
# still want to support reading the manifest from previously created archives as well.
|
||||
with borgmatic.config.paths.Runtime_directory(
|
||||
{'user_runtime_directory': bootstrap_arguments.user_runtime_directory},
|
||||
) 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'
|
||||
)
|
||||
for base_directory in (
|
||||
'borgmatic',
|
||||
borgmatic.config.paths.make_runtime_directory_glob(borgmatic_runtime_directory),
|
||||
borgmatic.config.paths.get_borgmatic_source_directory(config),
|
||||
):
|
||||
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,
|
||||
repository_path,
|
||||
archive_name,
|
||||
[borgmatic_manifest_path],
|
||||
config,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
local_path=config.get('local_path', 'borg'),
|
||||
remote_path=config.get('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 archive or bootstrap manifest',
|
||||
)
|
||||
|
||||
try:
|
||||
manifest_data = json.loads(manifest_json)
|
||||
except json.JSONDecodeError as error:
|
||||
raise ValueError(
|
||||
f'Cannot read configuration paths from archive due to invalid bootstrap manifest JSON: {error}'
|
||||
f'Cannot read configuration paths from archive due to invalid bootstrap manifest JSON: {error}',
|
||||
)
|
||||
|
||||
try:
|
||||
return manifest_data['config_paths']
|
||||
except KeyError:
|
||||
raise ValueError(
|
||||
'Cannot read configuration paths from archive due to invalid bootstrap manifest'
|
||||
'Cannot read configuration paths from archive due to invalid bootstrap manifest',
|
||||
)
|
||||
|
||||
|
||||
@@ -108,9 +111,16 @@ def run_bootstrap(bootstrap_arguments, global_arguments, local_borg_version):
|
||||
local_path=bootstrap_arguments.local_path,
|
||||
remote_path=bootstrap_arguments.remote_path,
|
||||
)
|
||||
manifest_config_paths = get_config_paths(
|
||||
archive_name, bootstrap_arguments, global_arguments, local_borg_version
|
||||
)
|
||||
|
||||
with borgmatic.config.paths.Runtime_directory(config) as borgmatic_runtime_directory:
|
||||
manifest_config_paths = load_config_paths_from_archive(
|
||||
bootstrap_arguments.repository,
|
||||
archive_name,
|
||||
config,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
borgmatic_runtime_directory,
|
||||
)
|
||||
|
||||
logger.info(f"Bootstrapping config paths: {', '.join(manifest_config_paths)}")
|
||||
|
||||
@@ -119,7 +129,9 @@ def run_bootstrap(bootstrap_arguments, global_arguments, local_borg_version):
|
||||
bootstrap_arguments.repository,
|
||||
archive_name,
|
||||
[config_path.lstrip(os.path.sep) for config_path in manifest_config_paths],
|
||||
config,
|
||||
# Only add progress here and not the extract_archive() call above, because progress
|
||||
# conflicts with extract_to_stdout.
|
||||
dict(config, progress=bootstrap_arguments.progress or False),
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
local_path=bootstrap_arguments.local_path,
|
||||
@@ -127,5 +139,4 @@ def run_bootstrap(bootstrap_arguments, global_arguments, local_borg_version):
|
||||
extract_to_stdout=False,
|
||||
destination_path=bootstrap_arguments.destination,
|
||||
strip_components=bootstrap_arguments.strip_components,
|
||||
progress=bootstrap_arguments.progress,
|
||||
)
|
||||
|
||||
@@ -19,7 +19,7 @@ def run_generate(generate_arguments, global_arguments):
|
||||
dry_run_label = ' (dry run; not actually writing anything)' if global_arguments.dry_run else ''
|
||||
|
||||
logger.answer(
|
||||
f'Generating a configuration file at: {generate_arguments.destination_filename}{dry_run_label}'
|
||||
f'Generating a configuration file at: {generate_arguments.destination_filename}{dry_run_label}',
|
||||
)
|
||||
|
||||
borgmatic.config.generate.generate_sample_configuration(
|
||||
@@ -36,7 +36,7 @@ def run_generate(generate_arguments, global_arguments):
|
||||
Merged in the contents of configuration file at: {generate_arguments.source_filename}
|
||||
To review the changes made, run:
|
||||
|
||||
diff --unified {generate_arguments.source_filename} {generate_arguments.destination_filename}'''
|
||||
diff --unified {generate_arguments.source_filename} {generate_arguments.destination_filename}''',
|
||||
)
|
||||
|
||||
logger.answer(
|
||||
@@ -44,5 +44,5 @@ To review the changes made, run:
|
||||
This includes all available configuration options with example values, the few
|
||||
required options as indicated. Please edit the file to suit your needs.
|
||||
|
||||
If you ever need help: https://torsion.org/borgmatic/#issues'''
|
||||
If you ever need help: https://torsion.org/borgmatic/#issues''',
|
||||
)
|
||||
|
||||
@@ -18,7 +18,7 @@ def run_validate(validate_arguments, configs):
|
||||
borgmatic.logger.add_custom_log_levels()
|
||||
|
||||
if validate_arguments.show:
|
||||
for config_path, config in configs.items():
|
||||
for config in configs.values():
|
||||
if len(configs) > 1:
|
||||
logger.answer('---')
|
||||
|
||||
|
||||
+118
-264
@@ -1,262 +1,23 @@
|
||||
import glob
|
||||
import itertools
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
|
||||
import borgmatic.actions.json
|
||||
import borgmatic.borg.create
|
||||
import borgmatic.borg.pattern
|
||||
import borgmatic.borg.feature
|
||||
import borgmatic.borg.rename
|
||||
import borgmatic.borg.repo_list
|
||||
import borgmatic.config.paths
|
||||
import borgmatic.config.validate
|
||||
import borgmatic.hooks.command
|
||||
import borgmatic.hooks.dispatch
|
||||
from borgmatic.actions import pattern
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def parse_pattern(pattern_line, default_style=borgmatic.borg.pattern.Pattern_style.NONE):
|
||||
'''
|
||||
Given a Borg pattern as a string, parse it into a borgmatic.borg.pattern.Pattern instance and
|
||||
return it.
|
||||
'''
|
||||
try:
|
||||
(pattern_type, remainder) = pattern_line.split(' ', maxsplit=1)
|
||||
except ValueError:
|
||||
raise ValueError(f'Invalid pattern: {pattern_line}')
|
||||
|
||||
try:
|
||||
(parsed_pattern_style, path) = remainder.split(':', maxsplit=1)
|
||||
pattern_style = borgmatic.borg.pattern.Pattern_style(parsed_pattern_style)
|
||||
except ValueError:
|
||||
pattern_style = default_style
|
||||
path = remainder
|
||||
|
||||
return borgmatic.borg.pattern.Pattern(
|
||||
path,
|
||||
borgmatic.borg.pattern.Pattern_type(pattern_type),
|
||||
borgmatic.borg.pattern.Pattern_style(pattern_style),
|
||||
)
|
||||
|
||||
|
||||
def collect_patterns(config):
|
||||
'''
|
||||
Given a configuration dict, produce a single sequence of patterns comprised of the configured
|
||||
source directories, patterns, excludes, pattern files, and exclude files.
|
||||
|
||||
The idea is that Borg has all these different ways of specifying includes, excludes, source
|
||||
directories, etc., but we'd like to collapse them all down to one common format (patterns) for
|
||||
ease of manipulation within borgmatic.
|
||||
'''
|
||||
try:
|
||||
return (
|
||||
tuple(
|
||||
borgmatic.borg.pattern.Pattern(source_directory)
|
||||
for source_directory in config.get('source_directories', ())
|
||||
)
|
||||
+ tuple(
|
||||
parse_pattern(pattern_line.strip())
|
||||
for pattern_line in config.get('patterns', ())
|
||||
if not pattern_line.lstrip().startswith('#')
|
||||
if pattern_line.strip()
|
||||
)
|
||||
+ tuple(
|
||||
parse_pattern(
|
||||
f'{borgmatic.borg.pattern.Pattern_type.NO_RECURSE.value} {exclude_line.strip()}',
|
||||
borgmatic.borg.pattern.Pattern_style.FNMATCH,
|
||||
)
|
||||
for exclude_line in config.get('exclude_patterns', ())
|
||||
)
|
||||
+ tuple(
|
||||
parse_pattern(pattern_line.strip())
|
||||
for filename in config.get('patterns_from', ())
|
||||
for pattern_line in open(filename).readlines()
|
||||
if not pattern_line.lstrip().startswith('#')
|
||||
if pattern_line.strip()
|
||||
)
|
||||
+ tuple(
|
||||
parse_pattern(
|
||||
f'{borgmatic.borg.pattern.Pattern_type.NO_RECURSE.value} {exclude_line.strip()}',
|
||||
borgmatic.borg.pattern.Pattern_style.FNMATCH,
|
||||
)
|
||||
for filename in config.get('exclude_from', ())
|
||||
for exclude_line in open(filename).readlines()
|
||||
if not exclude_line.lstrip().startswith('#')
|
||||
if exclude_line.strip()
|
||||
)
|
||||
)
|
||||
except (FileNotFoundError, OSError) as error:
|
||||
logger.debug(error)
|
||||
|
||||
raise ValueError(f'Cannot read patterns_from/exclude_from file: {error.filename}')
|
||||
|
||||
|
||||
def expand_directory(directory, working_directory):
|
||||
'''
|
||||
Given a directory path, expand any tilde (representing a user's home directory) and any globs
|
||||
therein. Return a list of one or more resulting paths.
|
||||
|
||||
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(
|
||||
config_filename,
|
||||
repository,
|
||||
config,
|
||||
config_paths,
|
||||
hook_context,
|
||||
local_borg_version,
|
||||
create_arguments,
|
||||
global_arguments,
|
||||
@@ -270,31 +31,39 @@ def run_create(
|
||||
If create_arguments.json is True, yield the JSON output from creating the archive.
|
||||
'''
|
||||
if create_arguments.repository and not borgmatic.config.validate.repositories_match(
|
||||
repository, create_arguments.repository
|
||||
repository,
|
||||
create_arguments.repository,
|
||||
):
|
||||
return
|
||||
|
||||
borgmatic.hooks.command.execute_hook(
|
||||
config.get('before_backup'),
|
||||
config.get('umask'),
|
||||
config_filename,
|
||||
'pre-backup',
|
||||
global_arguments.dry_run,
|
||||
**hook_context,
|
||||
)
|
||||
if config.get('list_details') and config.get('progress'):
|
||||
raise ValueError(
|
||||
'With the create action, only one of --list/--files/list_details and --progress/progress can be used.',
|
||||
)
|
||||
|
||||
if config.get('list_details') and create_arguments.json:
|
||||
raise ValueError(
|
||||
'With the create action, only one of --list/--files/list_details and --json can be used.',
|
||||
)
|
||||
|
||||
logger.info(f'Creating archive{dry_run_label}')
|
||||
working_directory = borgmatic.config.paths.get_working_directory(config)
|
||||
|
||||
with borgmatic.config.paths.Runtime_directory(config) as borgmatic_runtime_directory:
|
||||
patterns = pattern.process_patterns(
|
||||
pattern.collect_patterns(config),
|
||||
config,
|
||||
working_directory,
|
||||
borgmatic_runtime_directory,
|
||||
)
|
||||
borgmatic.hooks.dispatch.call_hooks_even_if_unconfigured(
|
||||
'remove_data_source_dumps',
|
||||
config,
|
||||
borgmatic.hooks.dispatch.Hook_type.DATA_SOURCE,
|
||||
borgmatic_runtime_directory,
|
||||
patterns,
|
||||
global_arguments.dry_run,
|
||||
)
|
||||
patterns = process_patterns(collect_patterns(config), working_directory)
|
||||
active_dumps = borgmatic.hooks.dispatch.call_hooks(
|
||||
'dump_data_sources',
|
||||
config,
|
||||
@@ -308,9 +77,21 @@ def run_create(
|
||||
# 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)
|
||||
patterns = pattern.process_patterns(
|
||||
patterns,
|
||||
config,
|
||||
working_directory,
|
||||
borgmatic_runtime_directory,
|
||||
skip_expand_paths=config_paths,
|
||||
)
|
||||
stream_processes = [process for processes in active_dumps.values() for process in processes]
|
||||
|
||||
# If we have stream processes, we first create an archive with .checkpoint suffix. This is
|
||||
# to make sure we only create a real archive if all the streaming processes completed
|
||||
# successfully (create_archive will fail if a streaming process fails, but the archive might
|
||||
# have already been created at this point).
|
||||
use_checkpoint = bool(stream_processes)
|
||||
|
||||
json_output = borgmatic.borg.create.create_archive(
|
||||
global_arguments.dry_run,
|
||||
repository['path'],
|
||||
@@ -319,31 +100,104 @@ def run_create(
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
borgmatic_runtime_directory,
|
||||
archive_suffix='.checkpoint' if use_checkpoint else '',
|
||||
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,
|
||||
comment=create_arguments.comment,
|
||||
stream_processes=stream_processes,
|
||||
)
|
||||
|
||||
if use_checkpoint:
|
||||
rename_checkpoint_archive(
|
||||
repository['path'],
|
||||
global_arguments,
|
||||
config,
|
||||
local_borg_version,
|
||||
local_path,
|
||||
remote_path,
|
||||
)
|
||||
|
||||
if json_output:
|
||||
yield borgmatic.actions.json.parse_json(json_output, repository.get('label'))
|
||||
output = borgmatic.actions.json.parse_json(json_output, repository.get('label'))
|
||||
if use_checkpoint:
|
||||
# Patch archive name and ID
|
||||
renamed_archive = borgmatic.borg.repo_list.get_latest_archive(
|
||||
repository['path'],
|
||||
config,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
)
|
||||
|
||||
output['archive']['name'] = renamed_archive['name']
|
||||
output['archive']['id'] = renamed_archive['id']
|
||||
|
||||
yield output
|
||||
|
||||
borgmatic.hooks.dispatch.call_hooks_even_if_unconfigured(
|
||||
'remove_data_source_dumps',
|
||||
config,
|
||||
borgmatic.hooks.dispatch.Hook_type.DATA_SOURCE,
|
||||
borgmatic_runtime_directory,
|
||||
patterns,
|
||||
global_arguments.dry_run,
|
||||
)
|
||||
|
||||
borgmatic.hooks.command.execute_hook(
|
||||
config.get('after_backup'),
|
||||
config.get('umask'),
|
||||
config_filename,
|
||||
'post-backup',
|
||||
|
||||
def rename_checkpoint_archive(
|
||||
repository_path,
|
||||
global_arguments,
|
||||
config,
|
||||
local_borg_version,
|
||||
local_path,
|
||||
remote_path,
|
||||
):
|
||||
'''
|
||||
Renames the latest archive to not have a '.checkpoint' suffix.
|
||||
|
||||
Raises ValueError if
|
||||
- there is not latest archive
|
||||
- the latest archive does not have a '.checkpoint' suffix
|
||||
|
||||
Implementation note: We cannot reliably get the just created archive name.
|
||||
So we resort to listing the archives and picking the last one.
|
||||
|
||||
A similar comment applies to retrieving the ID of the renamed archive.
|
||||
'''
|
||||
archive = borgmatic.borg.repo_list.get_latest_archive(
|
||||
repository_path,
|
||||
config,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
consider_checkpoints=True,
|
||||
)
|
||||
|
||||
archive_name = archive['name']
|
||||
|
||||
if not archive_name.endswith('.checkpoint'):
|
||||
raise ValueError(f'Latest archive did not have a .checkpoint suffix. Got: {archive_name}')
|
||||
|
||||
new_archive_name = archive_name.removesuffix('.checkpoint')
|
||||
|
||||
logger.info(f'Renaming archive {archive_name} -> {new_archive_name}')
|
||||
|
||||
borgmatic.borg.rename.rename_archive(
|
||||
repository_path,
|
||||
(
|
||||
archive['id']
|
||||
if borgmatic.borg.feature.available(
|
||||
borgmatic.borg.feature.Feature.ARCHIVE_SERIES, local_borg_version
|
||||
)
|
||||
else archive['name']
|
||||
),
|
||||
new_archive_name,
|
||||
global_arguments.dry_run,
|
||||
**hook_context,
|
||||
config,
|
||||
local_borg_version,
|
||||
local_path,
|
||||
remote_path,
|
||||
)
|
||||
|
||||
@@ -21,7 +21,8 @@ def run_delete(
|
||||
Run the "delete" action for the given repository and archive(s).
|
||||
'''
|
||||
if delete_arguments.repository is None or borgmatic.config.validate.repositories_match(
|
||||
repository, delete_arguments.repository
|
||||
repository,
|
||||
delete_arguments.repository,
|
||||
):
|
||||
logger.answer('Deleting archives')
|
||||
|
||||
|
||||
@@ -19,7 +19,8 @@ def run_export_key(
|
||||
Run the "key export" action for the given repository.
|
||||
'''
|
||||
if export_arguments.repository is None or borgmatic.config.validate.repositories_match(
|
||||
repository, export_arguments.repository
|
||||
repository,
|
||||
export_arguments.repository,
|
||||
):
|
||||
logger.info('Exporting repository key')
|
||||
borgmatic.borg.export_key.export_key(
|
||||
|
||||
@@ -20,7 +20,8 @@ def run_export_tar(
|
||||
Run the "export-tar" action for the given repository.
|
||||
'''
|
||||
if export_tar_arguments.repository is None or borgmatic.config.validate.repositories_match(
|
||||
repository, export_tar_arguments.repository
|
||||
repository,
|
||||
export_tar_arguments.repository,
|
||||
):
|
||||
logger.info(f'Exporting archive {export_tar_arguments.archive} as tar file')
|
||||
borgmatic.borg.export_tar.export_tar_archive(
|
||||
@@ -43,6 +44,5 @@ def run_export_tar(
|
||||
local_path=local_path,
|
||||
remote_path=remote_path,
|
||||
tar_filter=export_tar_arguments.tar_filter,
|
||||
list_files=export_tar_arguments.list_files,
|
||||
strip_components=export_tar_arguments.strip_components,
|
||||
)
|
||||
|
||||
@@ -12,7 +12,6 @@ def run_extract(
|
||||
config_filename,
|
||||
repository,
|
||||
config,
|
||||
hook_context,
|
||||
local_borg_version,
|
||||
extract_arguments,
|
||||
global_arguments,
|
||||
@@ -22,16 +21,9 @@ def run_extract(
|
||||
'''
|
||||
Run the "extract" action for the given repository.
|
||||
'''
|
||||
borgmatic.hooks.command.execute_hook(
|
||||
config.get('before_extract'),
|
||||
config.get('umask'),
|
||||
config_filename,
|
||||
'pre-extract',
|
||||
global_arguments.dry_run,
|
||||
**hook_context,
|
||||
)
|
||||
if extract_arguments.repository is None or borgmatic.config.validate.repositories_match(
|
||||
repository, extract_arguments.repository
|
||||
repository,
|
||||
extract_arguments.repository,
|
||||
):
|
||||
logger.info(f'Extracting archive {extract_arguments.archive}')
|
||||
borgmatic.borg.extract.extract_archive(
|
||||
@@ -54,13 +46,4 @@ def run_extract(
|
||||
remote_path=remote_path,
|
||||
destination_path=extract_arguments.destination,
|
||||
strip_components=extract_arguments.strip_components,
|
||||
progress=extract_arguments.progress,
|
||||
)
|
||||
borgmatic.hooks.command.execute_hook(
|
||||
config.get('after_extract'),
|
||||
config.get('umask'),
|
||||
config_filename,
|
||||
'post-extract',
|
||||
global_arguments.dry_run,
|
||||
**hook_context,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import logging
|
||||
|
||||
import borgmatic.borg.import_key
|
||||
import borgmatic.config.validate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def run_import_key(
|
||||
repository,
|
||||
config,
|
||||
local_borg_version,
|
||||
import_arguments,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
):
|
||||
'''
|
||||
Run the "key import" action for the given repository.
|
||||
'''
|
||||
if import_arguments.repository is None or borgmatic.config.validate.repositories_match(
|
||||
repository,
|
||||
import_arguments.repository,
|
||||
):
|
||||
logger.info('Importing repository key')
|
||||
borgmatic.borg.import_key.import_key(
|
||||
repository['path'],
|
||||
config,
|
||||
local_borg_version,
|
||||
import_arguments,
|
||||
global_arguments,
|
||||
local_path=local_path,
|
||||
remote_path=remote_path,
|
||||
)
|
||||
@@ -24,10 +24,12 @@ def run_info(
|
||||
If info_arguments.json is True, yield the JSON output from the info for the archive.
|
||||
'''
|
||||
if info_arguments.repository is None or borgmatic.config.validate.repositories_match(
|
||||
repository, info_arguments.repository
|
||||
repository,
|
||||
info_arguments.repository,
|
||||
):
|
||||
if not info_arguments.json:
|
||||
logger.answer('Displaying archive summary information')
|
||||
|
||||
archive_name = borgmatic.borg.repo_list.resolve_archive_name(
|
||||
repository['path'],
|
||||
info_arguments.archive,
|
||||
|
||||
@@ -23,7 +23,8 @@ def run_list(
|
||||
If list_arguments.json is True, yield the JSON output from listing the archive.
|
||||
'''
|
||||
if list_arguments.repository is None or borgmatic.config.validate.repositories_match(
|
||||
repository, list_arguments.repository
|
||||
repository,
|
||||
list_arguments.repository,
|
||||
):
|
||||
if not list_arguments.json:
|
||||
if list_arguments.find_paths: # pragma: no cover
|
||||
|
||||
@@ -20,7 +20,8 @@ def run_mount(
|
||||
Run the "mount" action for the given repository.
|
||||
'''
|
||||
if mount_arguments.repository is None or borgmatic.config.validate.repositories_match(
|
||||
repository, mount_arguments.repository
|
||||
repository,
|
||||
mount_arguments.repository,
|
||||
):
|
||||
if mount_arguments.archive:
|
||||
logger.info(f'Mounting archive {mount_arguments.archive}')
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
import glob
|
||||
import itertools
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
|
||||
import borgmatic.borg.pattern
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def parse_pattern(pattern_line, default_style=borgmatic.borg.pattern.Pattern_style.NONE):
|
||||
'''
|
||||
Given a Borg pattern as a string, parse it into a borgmatic.borg.pattern.Pattern instance and
|
||||
return it.
|
||||
'''
|
||||
try:
|
||||
(pattern_type, remainder) = pattern_line.split(' ', maxsplit=1)
|
||||
except ValueError:
|
||||
raise ValueError(f'Invalid pattern: {pattern_line}')
|
||||
|
||||
try:
|
||||
(parsed_pattern_style, path) = remainder.split(':', maxsplit=1)
|
||||
pattern_style = borgmatic.borg.pattern.Pattern_style(parsed_pattern_style)
|
||||
except ValueError:
|
||||
pattern_style = default_style
|
||||
path = remainder
|
||||
|
||||
return borgmatic.borg.pattern.Pattern(
|
||||
path,
|
||||
borgmatic.borg.pattern.Pattern_type(pattern_type),
|
||||
borgmatic.borg.pattern.Pattern_style(pattern_style),
|
||||
source=borgmatic.borg.pattern.Pattern_source.CONFIG,
|
||||
)
|
||||
|
||||
|
||||
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,
|
||||
source=borgmatic.borg.pattern.Pattern_source.CONFIG,
|
||||
)
|
||||
for source_directory in config.get('source_directories', ())
|
||||
)
|
||||
+ tuple(
|
||||
parse_pattern(pattern_line.strip())
|
||||
for pattern_line in config.get('patterns', ())
|
||||
if not pattern_line.lstrip().startswith('#')
|
||||
if pattern_line.strip()
|
||||
)
|
||||
+ tuple(
|
||||
parse_pattern(
|
||||
f'{borgmatic.borg.pattern.Pattern_type.NO_RECURSE.value} {exclude_line.strip()}',
|
||||
borgmatic.borg.pattern.Pattern_style.FNMATCH,
|
||||
)
|
||||
for exclude_line in config.get('exclude_patterns', ())
|
||||
)
|
||||
+ tuple(
|
||||
parse_pattern(pattern_line.strip())
|
||||
for filename in config.get('patterns_from', ())
|
||||
for pattern_line in open(filename, encoding='utf-8').readlines()
|
||||
if not pattern_line.lstrip().startswith('#')
|
||||
if pattern_line.strip()
|
||||
)
|
||||
+ tuple(
|
||||
parse_pattern(
|
||||
f'{borgmatic.borg.pattern.Pattern_type.NO_RECURSE.value} {exclude_line.strip()}',
|
||||
borgmatic.borg.pattern.Pattern_style.FNMATCH,
|
||||
)
|
||||
for filename in config.get('exclude_from', ())
|
||||
for exclude_line in open(filename, encoding='utf-8').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 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 and expand just tildes in each non-root pattern.
|
||||
The idea is that non-root patterns may be regular expressions or other pattern styles containing
|
||||
"*" that borgmatic should not expand as a shell glob.
|
||||
|
||||
Return all the resulting 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,
|
||||
pattern.source,
|
||||
)
|
||||
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 (
|
||||
borgmatic.borg.pattern.Pattern(
|
||||
os.path.expanduser(pattern.path),
|
||||
pattern.type,
|
||||
pattern.style,
|
||||
pattern.device,
|
||||
pattern.source,
|
||||
),
|
||||
)
|
||||
)
|
||||
for pattern in patterns
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def get_existent_path_or_parent(path):
|
||||
'''
|
||||
Given a path, return it if it exists. Otherwise, return the longest parent directory of the path
|
||||
that exists. Return None if none of these paths exist.
|
||||
|
||||
This is used below for finding an existent path prefix of pattern's path, which is necessary if
|
||||
the path contain globs or other special characters that we don't want to try to interpret
|
||||
(because we want to leave that responsibility to Borg).
|
||||
'''
|
||||
if path.startswith('/e2e/'):
|
||||
return None
|
||||
|
||||
try:
|
||||
return next(
|
||||
candidate_path
|
||||
for candidate_path in (
|
||||
path,
|
||||
*tuple(str(parent) for parent in pathlib.PurePath(path).parents),
|
||||
)
|
||||
if os.path.exists(candidate_path)
|
||||
)
|
||||
except StopIteration:
|
||||
return None
|
||||
|
||||
|
||||
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).
|
||||
|
||||
This function only considers the start of a pattern's path—from the start of the path up until
|
||||
there's a path component with a glob or other non-literal character. If there are no such
|
||||
characters, the whole path is considered. The rationale is that it's not feasible for borgmatic
|
||||
to interpret Borg's patterns to see which actual files (and therefore devices) they map to. So
|
||||
for instance, a pattern with a path of "/var/log/*/data" would end up with its device set to the
|
||||
device of "/var/log"—ignoring the "/*/data" part due to that glob.
|
||||
|
||||
The one exception is that if a regular expression pattern path starts with "^", that will get
|
||||
stripped off for purposes of determining its device.
|
||||
'''
|
||||
return tuple(
|
||||
borgmatic.borg.pattern.Pattern(
|
||||
pattern.path,
|
||||
pattern.type,
|
||||
pattern.style,
|
||||
device=pattern.device or (os.stat(existent_path).st_dev if existent_path else None),
|
||||
source=pattern.source,
|
||||
)
|
||||
for pattern in patterns
|
||||
for existent_path in (
|
||||
get_existent_path_or_parent(
|
||||
os.path.join(working_directory or '', pattern.path.lstrip('^')),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def deduplicate_runtime_directory_patterns(patterns, config, borgmatic_runtime_directory=None):
|
||||
'''
|
||||
Given a sequence of borgmatic.borg.pattern.Pattern instances, the borgmatic runtime directory,
|
||||
and a configuration dict, return them without any duplicate root child patterns that contain the
|
||||
runtime directory. For instance, if two root patterns are given with paths "/foo" and
|
||||
"/foo/bar", and the runtime directory is "/foo/bar", return just the "/foo" pattern. Non-root
|
||||
patterns and patterns not containing the runtime directory are passed through without
|
||||
modification.
|
||||
|
||||
One exception to deduplication is if two paths are on different filesystems (devices) and
|
||||
"one_file_system" is True in the given configuration. In that case, the paths won't get
|
||||
deduplicated, because Borg won't cross filesystem boundaries when "one_file_system" is True.
|
||||
|
||||
The idea is that if Borg is given a root parent pattern containing the borgmatic runtime
|
||||
directory, then Borg doesn't also need to be given child patterns, because it will naturally
|
||||
spider the contents of the parent pattern's path. Additionally, 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 in the borgmatic runtime directory is using a named pipe for streaming database
|
||||
dumps to Borg.
|
||||
|
||||
This deduplication is limited to the borgmatic runtime directory (where borgmatic's named pipes
|
||||
exist), because there are other legitimate use cases for parent and child patterns to both exist
|
||||
in patterns. For instance, with some snapshotted filesystems, snapshots don't traverse from a
|
||||
parent filesystem to a child and therefore both need to remain in patterns.
|
||||
|
||||
And for the case of named pipes outside of the borgmatic runtime directory, there is code
|
||||
elsewhere (in the "create" action) that auto-excludes special files to prevent Borg hangs.
|
||||
'''
|
||||
if borgmatic_runtime_directory is None:
|
||||
return patterns
|
||||
|
||||
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 (or one_file_system is not set), 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 pathlib.PurePosixPath(other_pattern.path)
|
||||
in pathlib.PurePath(borgmatic_runtime_directory).parents
|
||||
and pattern.device is not None
|
||||
and (
|
||||
other_pattern.device == pattern.device
|
||||
or config.get('one_file_system') is not True
|
||||
)
|
||||
for parent in parents
|
||||
):
|
||||
break
|
||||
else:
|
||||
deduplicated[pattern] = True
|
||||
|
||||
return tuple(deduplicated.keys())
|
||||
|
||||
|
||||
def process_patterns(
|
||||
patterns, config, working_directory, borgmatic_runtime_directory=None, skip_expand_paths=None
|
||||
):
|
||||
'''
|
||||
Given a sequence of Borg patterns, a configuration dict, a configured working directory, the
|
||||
borgmatic runtime directory, and a sequence of paths to skip path expansion for, expand and
|
||||
deduplicate any "root" patterns, returning the resulting root and non-root patterns as a list.
|
||||
|
||||
If the borgmatic runtime directory is None, then don't deduplicate patterns. Deduplication is
|
||||
really only necessary for the "create" action when the runtime directory might contain named
|
||||
pipes for database dumps.
|
||||
'''
|
||||
skip_paths = set(skip_expand_paths or ())
|
||||
|
||||
return list(
|
||||
deduplicate_runtime_directory_patterns(
|
||||
device_map_patterns(
|
||||
expand_patterns(
|
||||
patterns,
|
||||
working_directory=working_directory,
|
||||
skip_paths=skip_paths,
|
||||
),
|
||||
),
|
||||
config,
|
||||
borgmatic_runtime_directory,
|
||||
),
|
||||
)
|
||||
@@ -11,7 +11,6 @@ def run_prune(
|
||||
config_filename,
|
||||
repository,
|
||||
config,
|
||||
hook_context,
|
||||
local_borg_version,
|
||||
prune_arguments,
|
||||
global_arguments,
|
||||
@@ -23,18 +22,11 @@ def run_prune(
|
||||
Run the "prune" action for the given repository.
|
||||
'''
|
||||
if prune_arguments.repository and not borgmatic.config.validate.repositories_match(
|
||||
repository, prune_arguments.repository
|
||||
repository,
|
||||
prune_arguments.repository,
|
||||
):
|
||||
return
|
||||
|
||||
borgmatic.hooks.command.execute_hook(
|
||||
config.get('before_prune'),
|
||||
config.get('umask'),
|
||||
config_filename,
|
||||
'pre-prune',
|
||||
global_arguments.dry_run,
|
||||
**hook_context,
|
||||
)
|
||||
logger.info(f'Pruning archives{dry_run_label}')
|
||||
borgmatic.borg.prune.prune_archives(
|
||||
global_arguments.dry_run,
|
||||
@@ -46,11 +38,3 @@ def run_prune(
|
||||
local_path=local_path,
|
||||
remote_path=remote_path,
|
||||
)
|
||||
borgmatic.hooks.command.execute_hook(
|
||||
config.get('after_prune'),
|
||||
config.get('umask'),
|
||||
config_filename,
|
||||
'post-prune',
|
||||
global_arguments.dry_run,
|
||||
**hook_context,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import logging
|
||||
import subprocess
|
||||
|
||||
import borgmatic.borg.info
|
||||
import borgmatic.borg.recreate
|
||||
import borgmatic.borg.repo_list
|
||||
import borgmatic.config.validate
|
||||
from borgmatic.actions.pattern import collect_patterns, process_patterns
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
BORG_EXIT_CODE_ARCHIVE_ALREADY_EXISTS = 30
|
||||
|
||||
|
||||
def run_recreate(
|
||||
repository,
|
||||
config,
|
||||
local_borg_version,
|
||||
recreate_arguments,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
):
|
||||
'''
|
||||
Run the "recreate" action for the given repository.
|
||||
'''
|
||||
if recreate_arguments.repository is None or borgmatic.config.validate.repositories_match(
|
||||
repository,
|
||||
recreate_arguments.repository,
|
||||
):
|
||||
if recreate_arguments.archive:
|
||||
logger.answer(f'Recreating archive {recreate_arguments.archive}')
|
||||
else:
|
||||
logger.answer('Recreating repository')
|
||||
|
||||
# Collect and process patterns.
|
||||
processed_patterns = process_patterns(
|
||||
collect_patterns(config),
|
||||
config,
|
||||
borgmatic.config.paths.get_working_directory(config),
|
||||
)
|
||||
|
||||
archive = borgmatic.borg.repo_list.resolve_archive_name(
|
||||
repository['path'],
|
||||
recreate_arguments.archive,
|
||||
config,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
)
|
||||
|
||||
if archive and archive.endswith('.recreate'):
|
||||
if recreate_arguments.archive == 'latest':
|
||||
raise ValueError(
|
||||
f'The latest archive "{archive}" is leftover from a prior recreate. Delete it first or select a different archive.',
|
||||
)
|
||||
|
||||
raise ValueError(
|
||||
f'The archive "{recreate_arguments.archive}" is leftover from a prior recreate. Select a different archive.',
|
||||
)
|
||||
|
||||
try:
|
||||
borgmatic.borg.recreate.recreate_archive(
|
||||
repository['path'],
|
||||
archive,
|
||||
config,
|
||||
local_borg_version,
|
||||
recreate_arguments,
|
||||
global_arguments,
|
||||
local_path=local_path,
|
||||
remote_path=remote_path,
|
||||
patterns=processed_patterns,
|
||||
)
|
||||
except subprocess.CalledProcessError as error:
|
||||
if error.returncode == BORG_EXIT_CODE_ARCHIVE_ALREADY_EXISTS:
|
||||
if recreate_arguments.target:
|
||||
raise ValueError(
|
||||
f'The archive "{recreate_arguments.target}" already exists. Delete it first or set a different target archive name.',
|
||||
)
|
||||
|
||||
if archive:
|
||||
raise ValueError(
|
||||
f'The archive "{archive}.recreate" is leftover from a prior recreate. Delete it first or select a different archive.',
|
||||
)
|
||||
|
||||
raise
|
||||
@@ -19,23 +19,44 @@ def run_repo_create(
|
||||
Run the "repo-create" action for the given repository.
|
||||
'''
|
||||
if repo_create_arguments.repository and not borgmatic.config.validate.repositories_match(
|
||||
repository, repo_create_arguments.repository
|
||||
repository,
|
||||
repo_create_arguments.repository,
|
||||
):
|
||||
return
|
||||
|
||||
logger.info('Creating repository')
|
||||
|
||||
encryption_mode = repo_create_arguments.encryption_mode or repository.get('encryption')
|
||||
|
||||
if not encryption_mode:
|
||||
raise ValueError(
|
||||
'With the repo-create action, either the --encryption flag or the repository encryption option is required.',
|
||||
)
|
||||
|
||||
borgmatic.borg.repo_create.create_repository(
|
||||
global_arguments.dry_run,
|
||||
repository['path'],
|
||||
config,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
repo_create_arguments.encryption_mode,
|
||||
encryption_mode,
|
||||
repo_create_arguments.source_repository,
|
||||
repo_create_arguments.copy_crypt_key,
|
||||
repo_create_arguments.append_only,
|
||||
repo_create_arguments.storage_quota,
|
||||
repo_create_arguments.make_parent_dirs,
|
||||
(
|
||||
repository.get('append_only')
|
||||
if repo_create_arguments.append_only is None
|
||||
else repo_create_arguments.append_only
|
||||
),
|
||||
(
|
||||
repository.get('storage_quota')
|
||||
if repo_create_arguments.storage_quota is None
|
||||
else repo_create_arguments.storage_quota
|
||||
),
|
||||
(
|
||||
repository.get('make_parent_directories')
|
||||
if repo_create_arguments.make_parent_directories is None
|
||||
else repo_create_arguments.make_parent_directories
|
||||
),
|
||||
local_path=local_path,
|
||||
remote_path=remote_path,
|
||||
)
|
||||
|
||||
@@ -18,10 +18,11 @@ def run_repo_delete(
|
||||
Run the "repo-delete" action for the given repository.
|
||||
'''
|
||||
if repo_delete_arguments.repository is None or borgmatic.config.validate.repositories_match(
|
||||
repository, repo_delete_arguments.repository
|
||||
repository,
|
||||
repo_delete_arguments.repository,
|
||||
):
|
||||
logger.answer(
|
||||
'Deleting repository' + (' cache' if repo_delete_arguments.cache_only else '')
|
||||
'Deleting repository' + (' cache' if repo_delete_arguments.cache_only else ''),
|
||||
)
|
||||
|
||||
borgmatic.borg.repo_delete.delete_repository(
|
||||
|
||||
@@ -22,7 +22,8 @@ def run_repo_info(
|
||||
If repo_info_arguments.json is True, yield the JSON output from the info for the repository.
|
||||
'''
|
||||
if repo_info_arguments.repository is None or borgmatic.config.validate.repositories_match(
|
||||
repository, repo_info_arguments.repository
|
||||
repository,
|
||||
repo_info_arguments.repository,
|
||||
):
|
||||
if not repo_info_arguments.json:
|
||||
logger.answer('Displaying repository summary information')
|
||||
|
||||
@@ -22,7 +22,8 @@ def run_repo_list(
|
||||
If repo_list_arguments.json is True, yield the JSON output from listing the repository.
|
||||
'''
|
||||
if repo_list_arguments.repository is None or borgmatic.config.validate.repositories_match(
|
||||
repository, repo_list_arguments.repository
|
||||
repository,
|
||||
repo_list_arguments.repository,
|
||||
):
|
||||
if not repo_list_arguments.json:
|
||||
logger.answer('Listing repository')
|
||||
|
||||
+143
-34
@@ -5,6 +5,7 @@ import pathlib
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
import borgmatic.actions.pattern
|
||||
import borgmatic.borg.extract
|
||||
import borgmatic.borg.list
|
||||
import borgmatic.borg.mount
|
||||
@@ -22,8 +23,8 @@ UNSPECIFIED = object()
|
||||
|
||||
Dump = collections.namedtuple(
|
||||
'Dump',
|
||||
('hook_name', 'data_source_name', 'hostname', 'port'),
|
||||
defaults=('localhost', None),
|
||||
('hook_name', 'data_source_name', 'hostname', 'port', 'label', 'container'),
|
||||
defaults=(None, None, None, None),
|
||||
)
|
||||
|
||||
|
||||
@@ -33,17 +34,24 @@ def dumps_match(first, second, default_port=None):
|
||||
indicates that the field should match any value. If a default port is given, then consider any
|
||||
dump having that port to match with a dump having a None port.
|
||||
'''
|
||||
for field_name in first._fields:
|
||||
# label kinda counts as an unique id, if they match ignore host/container/port
|
||||
if first.label not in {None, UNSPECIFIED} and first.label == second.label:
|
||||
field_list = ('hook_name', 'data_source_name')
|
||||
else:
|
||||
field_list = Dump._fields
|
||||
|
||||
for field_name in field_list:
|
||||
first_value = getattr(first, field_name)
|
||||
second_value = getattr(second, field_name)
|
||||
|
||||
if default_port is not None and field_name == 'port':
|
||||
if first_value == default_port and second_value is None:
|
||||
continue
|
||||
|
||||
if second_value == default_port and first_value is None:
|
||||
continue
|
||||
|
||||
if first_value == UNSPECIFIED or second_value == UNSPECIFIED:
|
||||
if first_value == UNSPECIFIED or second_value == UNSPECIFIED: # noqa: PLR1714
|
||||
continue
|
||||
|
||||
if first_value != second_value:
|
||||
@@ -56,16 +64,19 @@ def render_dump_metadata(dump):
|
||||
'''
|
||||
Given a Dump instance, make a display string describing it for use in log messages.
|
||||
'''
|
||||
label = dump.label or UNSPECIFIED
|
||||
name = 'unspecified' if dump.data_source_name is UNSPECIFIED else dump.data_source_name
|
||||
hostname = dump.hostname or UNSPECIFIED
|
||||
host = dump.container or dump.hostname or UNSPECIFIED
|
||||
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}'
|
||||
if label is not UNSPECIFIED:
|
||||
metadata = f'{name}@{label}'
|
||||
elif port:
|
||||
metadata = f'{name}@:{port}' if host is UNSPECIFIED else f'{name}@{host}:{port}'
|
||||
else:
|
||||
metadata = f'{name}' if hostname is UNSPECIFIED else f'{name}@{hostname}'
|
||||
metadata = f'{name}' if host is UNSPECIFIED else f'{name}@{host}'
|
||||
|
||||
if dump.hook_name not in (None, UNSPECIFIED):
|
||||
if dump.hook_name not in {None, UNSPECIFIED}:
|
||||
return f'{metadata} ({dump.hook_name})'
|
||||
|
||||
return metadata
|
||||
@@ -98,8 +109,10 @@ def get_configured_data_source(config, restore_dump):
|
||||
Dump(
|
||||
hook_name,
|
||||
hook_data_source.get('name'),
|
||||
hook_data_source.get('hostname', 'localhost'),
|
||||
hook_data_source.get('hostname'),
|
||||
hook_data_source.get('port'),
|
||||
hook_data_source.get('label') or UNSPECIFIED,
|
||||
hook_data_source.get('container'),
|
||||
),
|
||||
restore_dump,
|
||||
default_port,
|
||||
@@ -111,14 +124,15 @@ def get_configured_data_source(config, restore_dump):
|
||||
|
||||
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'
|
||||
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
|
||||
destination_path,
|
||||
borgmatic_runtime_directory,
|
||||
):
|
||||
'''
|
||||
Directory-format dump files get extracted into a temporary directory containing a path prefix
|
||||
@@ -145,7 +159,8 @@ def strip_path_prefix_from_extracted_dump_destination(
|
||||
continue
|
||||
|
||||
shutil.move(
|
||||
subdirectory_path, os.path.join(borgmatic_runtime_directory, databases_directory)
|
||||
subdirectory_path,
|
||||
os.path.join(borgmatic_runtime_directory, databases_directory),
|
||||
)
|
||||
break
|
||||
|
||||
@@ -169,7 +184,14 @@ def restore_single_dump(
|
||||
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'))
|
||||
Dump(
|
||||
hook_name,
|
||||
data_source['name'],
|
||||
data_source.get('hostname'),
|
||||
data_source.get('port'),
|
||||
data_source.get('label') or UNSPECIFIED,
|
||||
data_source.get('container'),
|
||||
),
|
||||
)
|
||||
|
||||
logger.info(f'Restoring data source {dump_metadata}')
|
||||
@@ -197,8 +219,8 @@ def restore_single_dump(
|
||||
archive=archive_name,
|
||||
paths=[
|
||||
borgmatic.hooks.data_source.dump.convert_glob_patterns_to_borg_pattern(
|
||||
dump_patterns
|
||||
)
|
||||
dump_patterns,
|
||||
),
|
||||
],
|
||||
config=config,
|
||||
local_borg_version=local_borg_version,
|
||||
@@ -213,7 +235,8 @@ def restore_single_dump(
|
||||
|
||||
if destination_path and not global_arguments.dry_run:
|
||||
strip_path_prefix_from_extracted_dump_destination(
|
||||
destination_path, borgmatic_runtime_directory
|
||||
destination_path,
|
||||
borgmatic_runtime_directory,
|
||||
)
|
||||
finally:
|
||||
if destination_path and not global_arguments.dry_run:
|
||||
@@ -248,8 +271,68 @@ def collect_dumps_from_archive(
|
||||
borgmatic runtime directory, query the archive for the names of data sources dumps it contains
|
||||
and return them as a set of Dump instances.
|
||||
'''
|
||||
dumps_from_archive = set()
|
||||
|
||||
# There is (at most) one dump metadata file per data source hook. Load each.
|
||||
for dumps_metadata_path in borgmatic.borg.list.capture_archive_listing(
|
||||
repository,
|
||||
archive,
|
||||
config,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
list_paths=[
|
||||
'sh:'
|
||||
+ borgmatic.hooks.data_source.dump.make_data_source_dump_path(
|
||||
base_directory,
|
||||
'*_databases/dumps.json',
|
||||
)
|
||||
# Probe for dump metadata files in multiple locations, as the default location is
|
||||
# "/borgmatic/*_databases/dumps.json" with Borg 1.4+, but instead begins with the
|
||||
# borgmatic runtime directory for older versions of Borg.
|
||||
for base_directory in (
|
||||
'borgmatic',
|
||||
borgmatic.config.paths.make_runtime_directory_glob(borgmatic_runtime_directory),
|
||||
)
|
||||
],
|
||||
local_path=local_path,
|
||||
remote_path=remote_path,
|
||||
):
|
||||
if not dumps_metadata_path:
|
||||
continue
|
||||
|
||||
dumps_from_archive.update(
|
||||
set(
|
||||
borgmatic.hooks.data_source.dump.parse_data_source_dumps_metadata(
|
||||
borgmatic.borg.extract.extract_archive(
|
||||
global_arguments.dry_run,
|
||||
repository,
|
||||
archive,
|
||||
[dumps_metadata_path],
|
||||
config,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
local_path=local_path,
|
||||
remote_path=remote_path,
|
||||
extract_to_stdout=True,
|
||||
)
|
||||
.stdout.read()
|
||||
.decode(),
|
||||
dumps_metadata_path,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# If we've successfully loaded any dumps metadata, we're done.
|
||||
if dumps_from_archive:
|
||||
logger.debug('Collecting database dumps from archive data source dumps metadata files')
|
||||
|
||||
return dumps_from_archive
|
||||
|
||||
# No dumps metadata files were found, so for backwards compatibility, fall back to parsing the
|
||||
# paths of dumps found in the archive to get their respective dump metadata.
|
||||
logger.debug('Collecting database dumps from archive data source dump paths (fallback)')
|
||||
borgmatic_source_directory = str(
|
||||
pathlib.Path(borgmatic.config.paths.get_borgmatic_source_directory(config))
|
||||
pathlib.Path(borgmatic.config.paths.get_borgmatic_source_directory(config)),
|
||||
)
|
||||
|
||||
# Probe for the data source dumps in multiple locations, as the default location has moved to
|
||||
@@ -264,7 +347,8 @@ def collect_dumps_from_archive(
|
||||
list_paths=[
|
||||
'sh:'
|
||||
+ borgmatic.hooks.data_source.dump.make_data_source_dump_path(
|
||||
base_directory, '*_databases/*/*'
|
||||
base_directory,
|
||||
'*_databases/*/*',
|
||||
)
|
||||
for base_directory in (
|
||||
'borgmatic',
|
||||
@@ -276,9 +360,6 @@ def collect_dumps_from_archive(
|
||||
remote_path=remote_path,
|
||||
)
|
||||
|
||||
# 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
|
||||
@@ -291,7 +372,8 @@ def collect_dumps_from_archive(
|
||||
):
|
||||
try:
|
||||
(hook_name, host_and_port, data_source_name) = dump_path.split(
|
||||
base_directory + os.path.sep, 1
|
||||
base_directory + os.path.sep,
|
||||
1,
|
||||
)[1].split(os.path.sep)[0:3]
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
@@ -308,13 +390,17 @@ def collect_dumps_from_archive(
|
||||
except (ValueError, TypeError):
|
||||
port = None
|
||||
|
||||
dumps_from_archive.add(Dump(hook_name, data_source_name, hostname, port))
|
||||
dumps_from_archive.add(
|
||||
Dump(
|
||||
hook_name, data_source_name, None if hostname == 'localhost' else hostname, port
|
||||
)
|
||||
)
|
||||
|
||||
# We've successfully parsed the dump path, so need to probe any further.
|
||||
break
|
||||
else:
|
||||
logger.warning(
|
||||
f'Ignoring invalid data source dump path "{dump_path}" in archive {archive}'
|
||||
f'Ignoring invalid data source dump path "{dump_path}" in archive {archive}',
|
||||
)
|
||||
|
||||
return dumps_from_archive
|
||||
@@ -345,6 +431,8 @@ def get_dumps_to_restore(restore_arguments, dumps_from_archive):
|
||||
data_source_name=name,
|
||||
hostname=restore_arguments.original_hostname or UNSPECIFIED,
|
||||
port=restore_arguments.original_port,
|
||||
label=restore_arguments.original_label or UNSPECIFIED,
|
||||
container=restore_arguments.original_container or UNSPECIFIED,
|
||||
)
|
||||
for name in restore_arguments.data_sources or (UNSPECIFIED,)
|
||||
}
|
||||
@@ -352,13 +440,17 @@ def get_dumps_to_restore(restore_arguments, dumps_from_archive):
|
||||
or restore_arguments.data_sources
|
||||
or restore_arguments.original_hostname
|
||||
or restore_arguments.original_port
|
||||
or restore_arguments.original_label
|
||||
or restore_arguments.original_container
|
||||
else {
|
||||
Dump(
|
||||
hook_name=UNSPECIFIED,
|
||||
data_source_name='all',
|
||||
hostname=UNSPECIFIED,
|
||||
port=UNSPECIFIED,
|
||||
)
|
||||
label=UNSPECIFIED,
|
||||
container=UNSPECIFIED,
|
||||
),
|
||||
}
|
||||
)
|
||||
missing_dumps = set()
|
||||
@@ -385,7 +477,7 @@ def get_dumps_to_restore(restore_arguments, dumps_from_archive):
|
||||
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.'
|
||||
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:
|
||||
@@ -394,7 +486,7 @@ def get_dumps_to_restore(restore_arguments, dumps_from_archive):
|
||||
)
|
||||
|
||||
raise ValueError(
|
||||
f"Cannot restore data source dump{'s' if len(missing_dumps) > 1 else ''} {rendered_dumps} missing from archive"
|
||||
f"Cannot restore data source dump{'s' if len(missing_dumps) > 1 else ''} {rendered_dumps} missing from archive",
|
||||
)
|
||||
|
||||
return dumps_to_restore
|
||||
@@ -410,14 +502,15 @@ def ensure_requested_dumps_restored(dumps_to_restore, dumps_actually_restored):
|
||||
raise ValueError('No data source dumps were found to restore')
|
||||
|
||||
missing_dumps = sorted(
|
||||
dumps_to_restore - dumps_actually_restored, key=lambda dump: dump.data_source_name
|
||||
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_dumps) > 1 else ''} {rendered_dumps} missing from borgmatic's configuration"
|
||||
f"Cannot restore data source{'s' if len(missing_dumps) > 1 else ''} {rendered_dumps} missing from borgmatic's configuration",
|
||||
)
|
||||
|
||||
|
||||
@@ -438,18 +531,26 @@ def run_restore(
|
||||
matching dump in the archive.
|
||||
'''
|
||||
if restore_arguments.repository and not borgmatic.config.validate.repositories_match(
|
||||
repository, restore_arguments.repository
|
||||
repository,
|
||||
restore_arguments.repository,
|
||||
):
|
||||
return
|
||||
|
||||
logger.info(f'Restoring data sources from archive {restore_arguments.archive}')
|
||||
working_directory = borgmatic.config.paths.get_working_directory(config)
|
||||
|
||||
with borgmatic.config.paths.Runtime_directory(config) as borgmatic_runtime_directory:
|
||||
patterns = borgmatic.actions.pattern.process_patterns(
|
||||
borgmatic.actions.pattern.collect_patterns(config),
|
||||
config,
|
||||
working_directory,
|
||||
)
|
||||
borgmatic.hooks.dispatch.call_hooks_even_if_unconfigured(
|
||||
'remove_data_source_dumps',
|
||||
config,
|
||||
borgmatic.hooks.dispatch.Hook_type.DATA_SOURCE,
|
||||
borgmatic_runtime_directory,
|
||||
patterns,
|
||||
global_arguments.dry_run,
|
||||
)
|
||||
|
||||
@@ -476,6 +577,7 @@ def run_restore(
|
||||
|
||||
dumps_actually_restored = set()
|
||||
connection_params = {
|
||||
'container': restore_arguments.container,
|
||||
'hostname': restore_arguments.hostname,
|
||||
'port': restore_arguments.port,
|
||||
'username': restore_arguments.username,
|
||||
@@ -489,13 +591,19 @@ def run_restore(
|
||||
config,
|
||||
restore_dump,
|
||||
)
|
||||
|
||||
# For a dump that wasn't found via an exact match in the configuration, try to fallback
|
||||
# to an "all" data source.
|
||||
if not found_data_source:
|
||||
found_data_source = get_configured_data_source(
|
||||
config,
|
||||
Dump(restore_dump.hook_name, 'all', restore_dump.hostname, restore_dump.port),
|
||||
Dump(
|
||||
restore_dump.hook_name,
|
||||
'all',
|
||||
restore_dump.hostname,
|
||||
restore_dump.port,
|
||||
restore_dump.label,
|
||||
restore_dump.container,
|
||||
),
|
||||
)
|
||||
|
||||
if not found_data_source:
|
||||
@@ -515,7 +623,7 @@ def run_restore(
|
||||
remote_path,
|
||||
archive_name,
|
||||
restore_dump.hook_name,
|
||||
dict(found_data_source, **{'schemas': restore_arguments.schemas}),
|
||||
dict(found_data_source, schemas=restore_arguments.schemas),
|
||||
connection_params,
|
||||
borgmatic_runtime_directory,
|
||||
)
|
||||
@@ -525,6 +633,7 @@ def run_restore(
|
||||
config,
|
||||
borgmatic.hooks.dispatch.Hook_type.DATA_SOURCE,
|
||||
borgmatic_runtime_directory,
|
||||
patterns,
|
||||
global_arguments.dry_run,
|
||||
)
|
||||
|
||||
|
||||
@@ -17,7 +17,13 @@ def run_transfer(
|
||||
'''
|
||||
Run the "transfer" action for the given repository.
|
||||
'''
|
||||
if transfer_arguments.archive and config.get('match_archives'):
|
||||
raise ValueError(
|
||||
'With the transfer action, only one of --archive and --match-archives/match_archives can be used.',
|
||||
)
|
||||
|
||||
logger.info('Transferring archives to repository')
|
||||
|
||||
borgmatic.borg.transfer.transfer_archives(
|
||||
global_arguments.dry_run,
|
||||
repository['path'],
|
||||
|
||||
@@ -39,9 +39,9 @@ def run_arbitrary_borg(
|
||||
borg_command = tuple(options[:command_options_start_index])
|
||||
command_options = tuple(options[command_options_start_index:])
|
||||
|
||||
if borg_command and borg_command[0] in borgmatic.commands.arguments.ACTION_ALIASES.keys():
|
||||
if borg_command and borg_command[0] in borgmatic.commands.arguments.ACTION_ALIASES:
|
||||
logger.warning(
|
||||
f"Borg's {borg_command[0]} subcommand is supported natively by borgmatic. Try this instead: borgmatic {borg_command[0]}"
|
||||
f"Borg's {borg_command[0]} subcommand is supported natively by borgmatic. Try this instead: borgmatic {borg_command[0]}",
|
||||
)
|
||||
except IndexError:
|
||||
borg_command = ()
|
||||
@@ -57,16 +57,14 @@ def run_arbitrary_borg(
|
||||
+ command_options
|
||||
)
|
||||
|
||||
return execute_command(
|
||||
return execute_command( # noqa: S604
|
||||
tuple(shlex.quote(part) for part in full_command),
|
||||
output_file=DO_NOT_CAPTURE,
|
||||
shell=True,
|
||||
extra_environment=dict(
|
||||
environment=dict(
|
||||
(environment.make_environment(config) or {}),
|
||||
**{
|
||||
'BORG_REPO': repository_path,
|
||||
'ARCHIVE': archive if archive else '',
|
||||
},
|
||||
BORG_REPO=repository_path,
|
||||
ARCHIVE=archive if archive else '',
|
||||
),
|
||||
working_directory=borgmatic.config.paths.get_working_directory(config),
|
||||
borg_local_path=local_path,
|
||||
|
||||
@@ -27,7 +27,7 @@ def break_lock(
|
||||
(local_path, 'break-lock')
|
||||
+ (('--remote-path', remote_path) if remote_path else ())
|
||||
+ (('--umask', str(umask)) if umask else ())
|
||||
+ (('--log-json',) if global_arguments.log_json else ())
|
||||
+ (('--log-json',) if config.get('log_json') else ())
|
||||
+ (('--lock-wait', str(lock_wait)) if lock_wait else ())
|
||||
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
|
||||
+ (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ())
|
||||
@@ -36,7 +36,7 @@ def break_lock(
|
||||
|
||||
execute_command(
|
||||
full_command,
|
||||
extra_environment=environment.make_environment(config),
|
||||
environment=environment.make_environment(config),
|
||||
working_directory=borgmatic.config.paths.get_working_directory(config),
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=config.get('borg_exit_codes'),
|
||||
|
||||
@@ -30,7 +30,7 @@ def change_passphrase(
|
||||
(local_path, 'key', 'change-passphrase')
|
||||
+ (('--remote-path', remote_path) if remote_path else ())
|
||||
+ (('--umask', str(umask)) if umask else ())
|
||||
+ (('--log-json',) if global_arguments.log_json else ())
|
||||
+ (('--log-json',) if config.get('log_json') else ())
|
||||
+ (('--lock-wait', str(lock_wait)) if lock_wait else ())
|
||||
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
|
||||
+ (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ())
|
||||
@@ -49,19 +49,19 @@ def change_passphrase(
|
||||
config_without_passphrase = {
|
||||
option_name: value
|
||||
for (option_name, value) in config.items()
|
||||
if option_name not in ('encryption_passphrase', 'encryption_passcommand')
|
||||
if option_name not in {'encryption_passphrase', 'encryption_passcommand'}
|
||||
}
|
||||
|
||||
borgmatic.execute.execute_command(
|
||||
full_command,
|
||||
output_file=borgmatic.execute.DO_NOT_CAPTURE,
|
||||
output_log_level=logging.ANSWER,
|
||||
extra_environment=environment.make_environment(config_without_passphrase),
|
||||
environment=environment.make_environment(config_without_passphrase),
|
||||
working_directory=borgmatic.config.paths.get_working_directory(config),
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=config.get('borg_exit_codes'),
|
||||
)
|
||||
|
||||
logger.answer(
|
||||
f"{repository_path}: Don't forget to update your encryption_passphrase option (if needed)"
|
||||
f"{repository_path}: Don't forget to update your encryption_passphrase option (if needed)",
|
||||
)
|
||||
|
||||
+20
-11
@@ -1,6 +1,7 @@
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import shlex
|
||||
|
||||
import borgmatic.config.paths
|
||||
from borgmatic.borg import environment, feature, flags, repo_info
|
||||
@@ -32,7 +33,7 @@ def make_archive_filter_flags(local_borg_version, config, checks, check_argument
|
||||
if prefix
|
||||
else (
|
||||
flags.make_match_archives_flags(
|
||||
check_arguments.match_archives or config.get('match_archives'),
|
||||
config.get('match_archives'),
|
||||
config.get('archive_name_format'),
|
||||
local_borg_version,
|
||||
)
|
||||
@@ -41,11 +42,12 @@ def make_archive_filter_flags(local_borg_version, config, checks, check_argument
|
||||
|
||||
if check_last:
|
||||
logger.warning(
|
||||
'Ignoring check_last option, as "archives" or "data" are not in consistency checks'
|
||||
'Ignoring check_last option, as "archives" or "data" are not in consistency checks',
|
||||
)
|
||||
|
||||
if prefix:
|
||||
logger.warning(
|
||||
'Ignoring consistency prefix option, as "archives" or "data" are not in consistency checks'
|
||||
'Ignoring consistency prefix option, as "archives" or "data" are not in consistency checks',
|
||||
)
|
||||
|
||||
return ()
|
||||
@@ -75,13 +77,18 @@ def make_check_name_flags(checks, archive_filter_flags):
|
||||
return common_flags
|
||||
|
||||
return (
|
||||
tuple(f'--{check}-only' for check in checks if check in ('repository', 'archives'))
|
||||
tuple(f'--{check}-only' for check in checks if check in {'repository', 'archives'})
|
||||
+ common_flags
|
||||
)
|
||||
|
||||
|
||||
def get_repository_id(
|
||||
repository_path, config, local_borg_version, global_arguments, local_path, remote_path
|
||||
repository_path,
|
||||
config,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
):
|
||||
'''
|
||||
Given a local or remote repository path, a configuration dict, the local Borg version, global
|
||||
@@ -99,7 +106,7 @@ def get_repository_id(
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
)
|
||||
),
|
||||
)['repository']['id']
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
raise ValueError(f'Cannot determine Borg repository ID for {repository_path}')
|
||||
@@ -126,8 +133,10 @@ def check_archives(
|
||||
extra_borg_options = config.get('extra_borg_options', {}).get('check', '')
|
||||
|
||||
verbosity_flags = ()
|
||||
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
verbosity_flags = ('--info',)
|
||||
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
verbosity_flags = ('--debug', '--show-rc')
|
||||
|
||||
@@ -167,11 +176,11 @@ def check_archives(
|
||||
+ make_check_name_flags(checks_subset, archive_filter_flags)
|
||||
+ (('--remote-path', remote_path) if remote_path else ())
|
||||
+ (('--umask', str(umask)) if umask else ())
|
||||
+ (('--log-json',) if global_arguments.log_json else ())
|
||||
+ (('--log-json',) if config.get('log_json') else ())
|
||||
+ (('--lock-wait', str(lock_wait)) if lock_wait else ())
|
||||
+ verbosity_flags
|
||||
+ (('--progress',) if check_arguments.progress else ())
|
||||
+ (tuple(extra_borg_options.split(' ')) if extra_borg_options else ())
|
||||
+ (('--progress',) if config.get('progress') else ())
|
||||
+ (tuple(shlex.split(extra_borg_options)) if extra_borg_options else ())
|
||||
+ flags.make_repository_flags(repository_path, local_borg_version)
|
||||
)
|
||||
|
||||
@@ -180,9 +189,9 @@ def check_archives(
|
||||
# The Borg repair option triggers an interactive prompt, which won't work when output is
|
||||
# captured. And progress messes with the terminal directly.
|
||||
output_file=(
|
||||
DO_NOT_CAPTURE if check_arguments.repair or check_arguments.progress else None
|
||||
DO_NOT_CAPTURE if check_arguments.repair or config.get('progress') else None
|
||||
),
|
||||
extra_environment=environment.make_environment(config),
|
||||
environment=environment.make_environment(config),
|
||||
working_directory=working_directory,
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=borg_exit_codes,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import logging
|
||||
import shlex
|
||||
|
||||
import borgmatic.config.paths
|
||||
from borgmatic.borg import environment, flags
|
||||
from borgmatic.borg import environment, feature, flags
|
||||
from borgmatic.execute import execute_command
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -15,9 +16,7 @@ def compact_segments(
|
||||
global_arguments,
|
||||
local_path='borg',
|
||||
remote_path=None,
|
||||
progress=False,
|
||||
cleanup_commits=False,
|
||||
threshold=None,
|
||||
):
|
||||
'''
|
||||
Given dry-run flag, a local or remote repository path, a configuration dict, and the local Borg
|
||||
@@ -26,30 +25,36 @@ def compact_segments(
|
||||
umask = config.get('umask', None)
|
||||
lock_wait = config.get('lock_wait', None)
|
||||
extra_borg_options = config.get('extra_borg_options', {}).get('compact', '')
|
||||
threshold = config.get('compact_threshold')
|
||||
|
||||
full_command = (
|
||||
(local_path, 'compact')
|
||||
+ (('--remote-path', remote_path) if remote_path else ())
|
||||
+ (('--umask', str(umask)) if umask else ())
|
||||
+ (('--log-json',) if global_arguments.log_json else ())
|
||||
+ (('--log-json',) if config.get('log_json') else ())
|
||||
+ (('--lock-wait', str(lock_wait)) if lock_wait else ())
|
||||
+ (('--progress',) if progress else ())
|
||||
+ (('--progress',) if config.get('progress') else ())
|
||||
+ (('--cleanup-commits',) if cleanup_commits else ())
|
||||
+ (('--threshold', str(threshold)) if threshold else ())
|
||||
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
|
||||
+ (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ())
|
||||
+ (tuple(extra_borg_options.split(' ')) if extra_borg_options else ())
|
||||
+ (
|
||||
('--dry-run',)
|
||||
if dry_run and feature.available(feature.Feature.DRY_RUN_COMPACT, local_borg_version)
|
||||
else ()
|
||||
)
|
||||
+ (tuple(shlex.split(extra_borg_options)) if extra_borg_options else ())
|
||||
+ flags.make_repository_flags(repository_path, local_borg_version)
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
logging.info('Skipping compact (dry run)')
|
||||
if dry_run and not feature.available(feature.Feature.DRY_RUN_COMPACT, local_borg_version):
|
||||
logger.info('Skipping compact (dry run)')
|
||||
return
|
||||
|
||||
execute_command(
|
||||
full_command,
|
||||
output_log_level=logging.INFO,
|
||||
extra_environment=environment.make_environment(config),
|
||||
environment=environment.make_environment(config),
|
||||
working_directory=borgmatic.config.paths.get_working_directory(config),
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=config.get('borg_exit_codes'),
|
||||
|
||||
+117
-165
@@ -1,9 +1,8 @@
|
||||
import itertools
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
import shlex
|
||||
import stat
|
||||
import tempfile
|
||||
import textwrap
|
||||
|
||||
import borgmatic.borg.pattern
|
||||
@@ -20,76 +19,6 @@ from borgmatic.execute import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def write_patterns_file(patterns, borgmatic_runtime_directory, patterns_file=None):
|
||||
'''
|
||||
Given a sequence of patterns as borgmatic.borg.pattern.Pattern instances, write them to a named
|
||||
temporary file in the given borgmatic runtime directory and return the file object so it can
|
||||
continue to exist on disk as long as the caller needs it.
|
||||
|
||||
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:
|
||||
return None
|
||||
|
||||
if patterns_file is None:
|
||||
patterns_file = tempfile.NamedTemporaryFile('w', dir=borgmatic_runtime_directory)
|
||||
operation_name = 'Writing'
|
||||
else:
|
||||
patterns_file.write('\n')
|
||||
operation_name = 'Appending'
|
||||
|
||||
patterns_output = '\n'.join(
|
||||
f'{pattern.type.value} {pattern.style.value}{":" if pattern.style.value else ""}{pattern.path}'
|
||||
for pattern in patterns
|
||||
)
|
||||
logger.debug(f'{operation_name} patterns to {patterns_file.name}:\n{patterns_output}')
|
||||
|
||||
patterns_file.write(patterns_output)
|
||||
patterns_file.flush()
|
||||
|
||||
return patterns_file
|
||||
|
||||
|
||||
def make_exclude_flags(config):
|
||||
'''
|
||||
Given a configuration dict with various exclude options, return the corresponding Borg flags as
|
||||
a tuple.
|
||||
'''
|
||||
caches_flag = ('--exclude-caches',) if config.get('exclude_caches') else ()
|
||||
if_present_flags = tuple(
|
||||
itertools.chain.from_iterable(
|
||||
('--exclude-if-present', if_present)
|
||||
for if_present in config.get('exclude_if_present', ())
|
||||
)
|
||||
)
|
||||
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 caches_flag + if_present_flags + keep_exclude_tags_flags + exclude_nodump_flags
|
||||
|
||||
|
||||
def make_list_filter_flags(local_borg_version, dry_run):
|
||||
'''
|
||||
Given the local Borg version and whether this is a dry run, return the corresponding flags for
|
||||
passing to "--list --filter". The general idea is that excludes are shown for a dry run or when
|
||||
the verbosity is debug.
|
||||
'''
|
||||
base_flags = 'AME'
|
||||
show_excludes = logger.isEnabledFor(logging.DEBUG)
|
||||
|
||||
if feature.available(feature.Feature.EXCLUDED_FILES_MINUS, local_borg_version):
|
||||
if show_excludes or dry_run:
|
||||
return f'{base_flags}+-'
|
||||
else:
|
||||
return base_flags
|
||||
|
||||
if show_excludes:
|
||||
return f'{base_flags}x-'
|
||||
else:
|
||||
return f'{base_flags}-'
|
||||
|
||||
|
||||
def special_file(path, working_directory=None):
|
||||
'''
|
||||
Return whether the given path is a special file (character device, block device, or named pipe
|
||||
@@ -116,77 +45,80 @@ def any_parent_directories(path, candidate_parents):
|
||||
return False
|
||||
|
||||
|
||||
def collect_special_file_paths(
|
||||
def validate_planned_backup_paths(
|
||||
dry_run,
|
||||
create_command,
|
||||
config,
|
||||
patterns,
|
||||
local_path,
|
||||
working_directory,
|
||||
borgmatic_runtime_directory,
|
||||
):
|
||||
'''
|
||||
Given a dry-run flag, a Borg create command as a tuple, a configuration dict, a local Borg path,
|
||||
a working directory, and the borgmatic runtime directory, collect the paths for any special
|
||||
files (character devices, block devices, and named pipes / FIFOs) that Borg would encounter
|
||||
during a create. These are all paths that could cause Borg to hang if its --read-special flag is
|
||||
used.
|
||||
a working directory, and the borgmatic runtime directory, perform a "borg create --dry-run" to
|
||||
determine whether Borg's planned paths to include in a backup look good. Specifically, if the
|
||||
given runtime directory exists, validate that it will be included in a backup and hasn't been
|
||||
excluded.
|
||||
|
||||
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.
|
||||
Raise ValueError if the runtime directory has been excluded via "exclude_patterns" or similar,
|
||||
because any features that rely on the runtime directory getting backed up will break. For
|
||||
instance, without the runtime directory, Borg can't consume any database dumps and borgmatic may
|
||||
hang waiting for them to be consumed.
|
||||
'''
|
||||
# Omit "--exclude-nodump" from the Borg dry run command, because that flag causes Borg to open
|
||||
# files including any named pipe we've created.
|
||||
# files including any named pipe we've created. And omit "--filter" because that can break the
|
||||
# paths output parsing below such that path lines no longer start with the expected "- ".
|
||||
paths_output = execute_command_and_capture_output(
|
||||
tuple(argument for argument in create_command if argument != '--exclude-nodump')
|
||||
+ ('--dry-run', '--list'),
|
||||
(
|
||||
*flags.omit_flag_and_value(
|
||||
flags.omit_flag(create_command, '--exclude-nodump'), '--filter'
|
||||
),
|
||||
'--dry-run',
|
||||
'--list',
|
||||
),
|
||||
capture_stderr=True,
|
||||
working_directory=working_directory,
|
||||
extra_environment=environment.make_environment(config),
|
||||
environment=environment.make_environment(config),
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=config.get('borg_exit_codes'),
|
||||
)
|
||||
|
||||
# These are all the individual files that Borg is planning to backup as determined by the Borg
|
||||
# create dry run above.
|
||||
paths = tuple(
|
||||
path_line.split(' ', 1)[1]
|
||||
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, working_directory) if path not in skip_paths
|
||||
if path_line and path_line.startswith(('- ', '+ '))
|
||||
)
|
||||
|
||||
# These are the subset of output paths contained within the borgmatic runtime directory.
|
||||
paths_inside_runtime_directory = {
|
||||
path for path in paths if any_parent_directories(path, (borgmatic_runtime_directory,))
|
||||
}
|
||||
|
||||
def check_all_root_patterns_exist(patterns):
|
||||
'''
|
||||
Given a sequence of borgmatic.borg.pattern.Pattern instances, check that all root pattern
|
||||
paths exist. If any don't, raise an exception.
|
||||
'''
|
||||
missing_paths = [
|
||||
pattern.path
|
||||
# If the runtime directory isn't present in the source patterns, then we shouldn't expect it to
|
||||
# be in the paths output from the Borg dry run.
|
||||
runtime_directory_present_in_patterns = any(
|
||||
pattern
|
||||
for pattern in patterns
|
||||
if any_parent_directories(pattern.path, (borgmatic_runtime_directory,))
|
||||
if pattern.type == borgmatic.borg.pattern.Pattern_type.ROOT
|
||||
if not os.path.exists(pattern.path)
|
||||
]
|
||||
)
|
||||
|
||||
if missing_paths:
|
||||
# If no paths to backup are inside the runtime directory, it must've been excluded.
|
||||
if (
|
||||
not paths_inside_runtime_directory
|
||||
and runtime_directory_present_in_patterns
|
||||
and not dry_run
|
||||
and os.path.exists(borgmatic_runtime_directory)
|
||||
):
|
||||
raise ValueError(
|
||||
f"Source directories / root pattern paths do not exist: {', '.join(missing_paths)}"
|
||||
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 path not in paths_inside_runtime_directory)
|
||||
|
||||
|
||||
MAX_SPECIAL_FILE_PATHS_LENGTH = 1000
|
||||
|
||||
@@ -199,24 +131,29 @@ def make_base_create_command(
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
borgmatic_runtime_directory,
|
||||
archive_suffix='',
|
||||
local_path='borg',
|
||||
remote_path=None,
|
||||
progress=False,
|
||||
json=False,
|
||||
list_files=False,
|
||||
comment=None,
|
||||
stream_processes=None,
|
||||
):
|
||||
'''
|
||||
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).
|
||||
sequence of patterns as borgmatic.borg.pattern.Pattern instances, the local Borg version, global
|
||||
arguments as an argparse.Namespace instance, the borgmatic runtime directory, a string suffix to
|
||||
add to the archive name, the local Borg path, the remote Borg path, whether to output JSON,
|
||||
comment text to add to the created archive, and a sequence of processes streaming data to Borg,
|
||||
return a tuple of (base Borg create command flags, Borg create command positional arguments,
|
||||
open pattern file handle).
|
||||
'''
|
||||
if config.get('source_directories_must_exist', False):
|
||||
check_all_root_patterns_exist(patterns)
|
||||
borgmatic.borg.pattern.check_all_root_patterns_exist(patterns)
|
||||
|
||||
patterns_file = write_patterns_file(patterns, borgmatic_runtime_directory)
|
||||
patterns_file = borgmatic.borg.pattern.write_patterns_file(
|
||||
patterns,
|
||||
borgmatic_runtime_directory,
|
||||
)
|
||||
checkpoint_interval = config.get('checkpoint_interval', None)
|
||||
checkpoint_volume = config.get('checkpoint_volume', None)
|
||||
chunker_params = config.get('chunker_params', None)
|
||||
@@ -225,10 +162,11 @@ def make_base_create_command(
|
||||
upload_buffer_size = config.get('upload_buffer_size', None)
|
||||
umask = config.get('umask', None)
|
||||
lock_wait = config.get('lock_wait', None)
|
||||
list_filter_flags = make_list_filter_flags(local_borg_version, dry_run)
|
||||
list_filter_flags = flags.make_list_filter_flags(local_borg_version, dry_run)
|
||||
files_cache = config.get('files_cache')
|
||||
archive_name_format = config.get(
|
||||
'archive_name_format', flags.get_default_archive_name_format(local_borg_version)
|
||||
archive_name_format = (
|
||||
config.get('archive_name_format', flags.get_default_archive_name_format(local_borg_version))
|
||||
+ archive_suffix
|
||||
)
|
||||
extra_borg_options = config.get('extra_borg_options', {}).get('create', '')
|
||||
|
||||
@@ -260,7 +198,8 @@ def make_base_create_command(
|
||||
tuple(local_path.split(' '))
|
||||
+ ('create',)
|
||||
+ (('--patterns-from', patterns_file.name) if patterns_file else ())
|
||||
+ make_exclude_flags(config)
|
||||
+ flags.make_exclude_flags(config)
|
||||
+ (('--comment', comment) if comment else ())
|
||||
+ (('--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 ())
|
||||
@@ -277,37 +216,44 @@ def make_base_create_command(
|
||||
+ (('--files-cache', files_cache) if files_cache else ())
|
||||
+ (('--remote-path', remote_path) if remote_path else ())
|
||||
+ (('--umask', str(umask)) if umask else ())
|
||||
+ (('--log-json',) if global_arguments.log_json else ())
|
||||
+ (('--log-json',) if config.get('log_json') else ())
|
||||
+ (('--lock-wait', str(lock_wait)) if lock_wait else ())
|
||||
+ (
|
||||
('--list', '--filter', list_filter_flags)
|
||||
if list_files and not json and not progress
|
||||
if config.get('list_details') and not json and not config.get('progress')
|
||||
else ()
|
||||
)
|
||||
+ (('--dry-run',) if dry_run else ())
|
||||
+ (tuple(extra_borg_options.split(' ')) if extra_borg_options else ())
|
||||
+ (tuple(shlex.split(extra_borg_options)) if extra_borg_options else ())
|
||||
)
|
||||
|
||||
create_positional_arguments = flags.make_repository_archive_flags(
|
||||
repository_path, archive_name_format, local_borg_version
|
||||
repository_path,
|
||||
archive_name_format,
|
||||
local_borg_version,
|
||||
)
|
||||
working_directory = borgmatic.config.paths.get_working_directory(config)
|
||||
|
||||
logger.debug('Checking file paths Borg plans to include')
|
||||
planned_backup_paths = validate_planned_backup_paths(
|
||||
dry_run,
|
||||
create_flags + create_positional_arguments,
|
||||
config,
|
||||
patterns,
|
||||
local_path,
|
||||
working_directory,
|
||||
borgmatic_runtime_directory=borgmatic_runtime_directory,
|
||||
)
|
||||
|
||||
# If database hooks are enabled (as indicated by streaming processes), exclude files that might
|
||||
# cause Borg to hang. But skip this if the user has explicitly set the "read_special" to True.
|
||||
if stream_processes and not config.get('read_special'):
|
||||
logger.warning(
|
||||
'Ignoring configured "read_special" value of false, as true is needed for database hooks.'
|
||||
'Ignoring configured "read_special" value of false, as true is needed for database hooks.',
|
||||
)
|
||||
working_directory = borgmatic.config.paths.get_working_directory(config)
|
||||
|
||||
logger.debug('Collecting special file paths')
|
||||
special_file_paths = collect_special_file_paths(
|
||||
dry_run,
|
||||
create_flags + create_positional_arguments,
|
||||
config,
|
||||
local_path,
|
||||
working_directory,
|
||||
borgmatic_runtime_directory=borgmatic_runtime_directory,
|
||||
special_file_paths = tuple(
|
||||
path for path in planned_backup_paths if special_file(path, working_directory)
|
||||
)
|
||||
|
||||
if special_file_paths:
|
||||
@@ -317,14 +263,15 @@ def make_base_create_command(
|
||||
placeholder=' ...',
|
||||
)
|
||||
logger.warning(
|
||||
f'Excluding special files to prevent Borg from hanging: {truncated_special_file_paths}'
|
||||
f'Excluding special files to prevent Borg from hanging: {truncated_special_file_paths}',
|
||||
)
|
||||
patterns_file = write_patterns_file(
|
||||
patterns_file = borgmatic.borg.pattern.write_patterns_file(
|
||||
tuple(
|
||||
borgmatic.borg.pattern.Pattern(
|
||||
special_file_path,
|
||||
borgmatic.borg.pattern.Pattern_type.NO_RECURSE,
|
||||
borgmatic.borg.pattern.Pattern_style.FNMATCH,
|
||||
source=borgmatic.borg.pattern.Pattern_source.INTERNAL,
|
||||
)
|
||||
for special_file_path in special_file_paths
|
||||
),
|
||||
@@ -346,18 +293,20 @@ def create_archive(
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
borgmatic_runtime_directory,
|
||||
archive_suffix='',
|
||||
local_path='borg',
|
||||
remote_path=None,
|
||||
progress=False,
|
||||
stats=False,
|
||||
json=False,
|
||||
list_files=False,
|
||||
comment=None,
|
||||
stream_processes=None,
|
||||
):
|
||||
'''
|
||||
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).
|
||||
sequence of loaded configuration paths, the local Borg version, global arguments as an
|
||||
argparse.Namespace instance, the borgmatic runtime directory, a string suffix to add to the
|
||||
archive name, the local Borg path, the remote Borg path, whether to output JSON, and comment
|
||||
text to add to the created archive, and a sequence of processes streaming data to Borg, create a
|
||||
Borg archive and return Borg's JSON output (if any).
|
||||
|
||||
If a sequence of stream processes is given (instances of subprocess.Popen), then execute the
|
||||
create command while also triggering the given processes to produce output.
|
||||
@@ -366,7 +315,7 @@ def create_archive(
|
||||
|
||||
working_directory = borgmatic.config.paths.get_working_directory(config)
|
||||
|
||||
(create_flags, create_positional_arguments, patterns_file) = make_base_create_command(
|
||||
(create_flags, create_positional_arguments, _) = make_base_create_command(
|
||||
dry_run,
|
||||
repository_path,
|
||||
config,
|
||||
@@ -374,30 +323,30 @@ def create_archive(
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
borgmatic_runtime_directory,
|
||||
archive_suffix,
|
||||
local_path,
|
||||
remote_path,
|
||||
progress,
|
||||
json,
|
||||
list_files,
|
||||
comment,
|
||||
stream_processes,
|
||||
)
|
||||
|
||||
if json:
|
||||
output_log_level = None
|
||||
elif list_files or (stats and not dry_run):
|
||||
elif config.get('list_details') or (config.get('statistics') and not dry_run):
|
||||
output_log_level = logging.ANSWER
|
||||
else:
|
||||
output_log_level = logging.INFO
|
||||
|
||||
# The progress output isn't compatible with captured and logged output, as progress messes with
|
||||
# the terminal directly.
|
||||
output_file = DO_NOT_CAPTURE if progress else None
|
||||
output_file = DO_NOT_CAPTURE if config.get('progress') else None
|
||||
|
||||
create_flags += (
|
||||
(('--info',) if logger.getEffectiveLevel() == logging.INFO and not json else ())
|
||||
+ (('--stats',) if stats and not json and not dry_run else ())
|
||||
+ (('--stats',) if config.get('statistics') and not json and not dry_run else ())
|
||||
+ (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) and not json else ())
|
||||
+ (('--progress',) if progress else ())
|
||||
+ (('--progress',) if config.get('progress') else ())
|
||||
+ (('--json',) if json else ())
|
||||
)
|
||||
borg_exit_codes = config.get('borg_exit_codes')
|
||||
@@ -409,25 +358,28 @@ def create_archive(
|
||||
output_log_level,
|
||||
output_file,
|
||||
working_directory=working_directory,
|
||||
extra_environment=environment.make_environment(config),
|
||||
environment=environment.make_environment(config),
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=borg_exit_codes,
|
||||
)
|
||||
elif output_log_level is None:
|
||||
|
||||
if output_log_level is None:
|
||||
return execute_command_and_capture_output(
|
||||
create_flags + create_positional_arguments,
|
||||
working_directory=working_directory,
|
||||
extra_environment=environment.make_environment(config),
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=borg_exit_codes,
|
||||
)
|
||||
else:
|
||||
execute_command(
|
||||
create_flags + create_positional_arguments,
|
||||
output_log_level,
|
||||
output_file,
|
||||
working_directory=working_directory,
|
||||
extra_environment=environment.make_environment(config),
|
||||
environment=environment.make_environment(config),
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=borg_exit_codes,
|
||||
)
|
||||
|
||||
execute_command(
|
||||
create_flags + create_positional_arguments,
|
||||
output_log_level,
|
||||
output_file,
|
||||
working_directory=working_directory,
|
||||
environment=environment.make_environment(config),
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=borg_exit_codes,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
@@ -11,6 +11,9 @@ import borgmatic.execute
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
FORCE_HARDER_FLAG_COUNT = 2
|
||||
|
||||
|
||||
def make_delete_command(
|
||||
repository,
|
||||
config,
|
||||
@@ -32,11 +35,14 @@ def make_delete_command(
|
||||
+ 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('log-json', config.get('log_json'))
|
||||
+ borgmatic.borg.flags.make_flags('lock-wait', config.get('lock_wait'))
|
||||
+ borgmatic.borg.flags.make_flags('list', delete_arguments.list_archives)
|
||||
+ borgmatic.borg.flags.make_flags('list', config.get('list_details'))
|
||||
+ (
|
||||
(('--force',) + (('--force',) if delete_arguments.force >= 2 else ()))
|
||||
(
|
||||
('--force',)
|
||||
+ (('--force',) if delete_arguments.force >= FORCE_HARDER_FLAG_COUNT else ())
|
||||
)
|
||||
if delete_arguments.force
|
||||
else ()
|
||||
)
|
||||
@@ -48,9 +54,17 @@ def make_delete_command(
|
||||
local_borg_version=local_borg_version,
|
||||
default_archive_name_format='*',
|
||||
)
|
||||
+ (('--stats',) if config.get('statistics') else ())
|
||||
+ borgmatic.borg.flags.make_flags_from_arguments(
|
||||
delete_arguments,
|
||||
excludes=('list_archives', 'force', 'match_archives', 'archive', 'repository'),
|
||||
excludes=(
|
||||
'list_details',
|
||||
'statistics',
|
||||
'force',
|
||||
'match_archives',
|
||||
'archive',
|
||||
'repository',
|
||||
),
|
||||
)
|
||||
+ borgmatic.borg.flags.make_repository_flags(repository['path'], local_borg_version)
|
||||
)
|
||||
@@ -90,15 +104,16 @@ def delete_archives(
|
||||
for argument_name in ARCHIVE_RELATED_ARGUMENT_NAMES
|
||||
):
|
||||
if borgmatic.borg.feature.available(
|
||||
borgmatic.borg.feature.Feature.REPO_DELETE, local_borg_version
|
||||
borgmatic.borg.feature.Feature.REPO_DELETE,
|
||||
local_borg_version,
|
||||
):
|
||||
logger.warning(
|
||||
'Deleting an entire repository with the delete action is deprecated when using Borg 2.x+. Use the repo-delete action instead.'
|
||||
'Deleting an entire repository with the delete action is deprecated when using Borg 2.x+. Use the repo-delete action instead.',
|
||||
)
|
||||
|
||||
repo_delete_arguments = argparse.Namespace(
|
||||
repository=repository['path'],
|
||||
list_archives=delete_arguments.list_archives,
|
||||
list_details=delete_arguments.list_details,
|
||||
force=delete_arguments.force,
|
||||
cache_only=delete_arguments.cache_only,
|
||||
keep_security_info=delete_arguments.keep_security_info,
|
||||
@@ -128,7 +143,7 @@ def delete_archives(
|
||||
borgmatic.execute.execute_command(
|
||||
command,
|
||||
output_log_level=logging.ANSWER,
|
||||
extra_environment=borgmatic.borg.environment.make_environment(config),
|
||||
environment=borgmatic.borg.environment.make_environment(config),
|
||||
working_directory=borgmatic.config.paths.get_working_directory(config),
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=config.get('borg_exit_codes'),
|
||||
|
||||
@@ -10,51 +10,75 @@ OPTION_TO_ENVIRONMENT_VARIABLE = {
|
||||
'borg_files_cache_ttl': 'BORG_FILES_CACHE_TTL',
|
||||
'borg_security_directory': 'BORG_SECURITY_DIR',
|
||||
'borg_keys_directory': 'BORG_KEYS_DIR',
|
||||
'encryption_passphrase': 'BORG_PASSPHRASE',
|
||||
'ssh_command': 'BORG_RSH',
|
||||
'temporary_directory': 'TMPDIR',
|
||||
}
|
||||
|
||||
CREDENTIAL_OPTIONS = {'encryption_passphrase'}
|
||||
|
||||
DEFAULT_BOOL_OPTION_TO_DOWNCASE_ENVIRONMENT_VARIABLE = {
|
||||
'relocated_repo_access_is_ok': 'BORG_RELOCATED_REPO_ACCESS_IS_OK',
|
||||
'unknown_unencrypted_repo_access_is_ok': 'BORG_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK',
|
||||
DEFAULT_BOOL_OPTION_TO_UNCONDITIONAL_ENVIRONMENT_VARIABLE = {
|
||||
'check_i_know_what_i_am_doing': 'BORG_CHECK_I_KNOW_WHAT_I_AM_DOING',
|
||||
}
|
||||
|
||||
DEFAULT_BOOL_OPTION_TO_UPPERCASE_ENVIRONMENT_VARIABLE = {
|
||||
'check_i_know_what_i_am_doing': 'BORG_CHECK_I_KNOW_WHAT_I_AM_DOING',
|
||||
DEFAULT_BOOL_OPTION_TO_ENVIRONMENT_VARIABLE = {
|
||||
'debug_passphrase': 'BORG_DEBUG_PASSPHRASE',
|
||||
'display_passphrase': 'BORG_DISPLAY_PASSPHRASE',
|
||||
'relocated_repo_access_is_ok': 'BORG_RELOCATED_REPO_ACCESS_IS_OK',
|
||||
'unknown_unencrypted_repo_access_is_ok': 'BORG_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK',
|
||||
'use_chunks_archive': 'BORG_USE_CHUNKS_ARCHIVE',
|
||||
}
|
||||
|
||||
|
||||
def make_environment(config):
|
||||
'''
|
||||
Given a borgmatic configuration dict, return its options converted to a Borg environment
|
||||
variable dict.
|
||||
Given a borgmatic configuration dict, convert it to a Borg environment variable dict, merge it
|
||||
with a copy of the current environment variables, and return the result.
|
||||
|
||||
Do not reuse this environment across multiple Borg invocations, because it can include
|
||||
references to resources like anonymous pipes for passphrases—which can only be consumed once.
|
||||
|
||||
Here's how native Borg precedence works for a few of the environment variables:
|
||||
|
||||
1. BORG_PASSPHRASE, if set, is used first.
|
||||
2. BORG_PASSCOMMAND is used only if BORG_PASSPHRASE isn't set.
|
||||
3. BORG_PASSPHRASE_FD is used only if neither of the above are set.
|
||||
|
||||
In borgmatic, we want to simulate this precedence order, but there are some additional
|
||||
complications. First, values can come from either configuration or from environment variables
|
||||
set outside borgmatic; configured options should take precedence. Second, when borgmatic gets a
|
||||
passphrase—directly from configuration or indirectly via a credential hook or a passcommand—we
|
||||
want to pass that passphrase to Borg via an anonymous pipe (+ BORG_PASSPHRASE_FD), since that's
|
||||
more secure than using an environment variable (BORG_PASSPHRASE).
|
||||
'''
|
||||
environment = {}
|
||||
environment = dict(os.environ)
|
||||
|
||||
for option_name, environment_variable_name in OPTION_TO_ENVIRONMENT_VARIABLE.items():
|
||||
value = config.get(option_name)
|
||||
|
||||
if option_name in CREDENTIAL_OPTIONS and value is not None:
|
||||
value = borgmatic.hooks.credential.parse.resolve_credential(value)
|
||||
|
||||
if value is not None:
|
||||
environment[environment_variable_name] = str(value)
|
||||
|
||||
passphrase = borgmatic.borg.passcommand.get_passphrase_from_passcommand(config)
|
||||
if 'encryption_passphrase' in config:
|
||||
environment.pop('BORG_PASSPHRASE', None)
|
||||
environment.pop('BORG_PASSCOMMAND', None)
|
||||
|
||||
# If the passcommand produced a passphrase, send it to Borg via an anonymous pipe.
|
||||
if passphrase:
|
||||
if 'encryption_passcommand' in config:
|
||||
environment.pop('BORG_PASSCOMMAND', None)
|
||||
|
||||
passphrase = borgmatic.hooks.credential.parse.resolve_credential(
|
||||
config.get('encryption_passphrase'),
|
||||
config,
|
||||
)
|
||||
|
||||
if passphrase is None:
|
||||
passphrase = borgmatic.borg.passcommand.get_passphrase_from_passcommand(config)
|
||||
|
||||
# If there's a passphrase (from configuration, from a configured credential, or from a
|
||||
# configured passcommand), send it to Borg via an anonymous pipe.
|
||||
if passphrase is not None:
|
||||
read_file_descriptor, write_file_descriptor = os.pipe()
|
||||
os.write(write_file_descriptor, passphrase.encode('utf-8'))
|
||||
os.close(write_file_descriptor)
|
||||
|
||||
# This, plus subprocess.Popen(..., close_fds=False) in execute.py, is necessary for the Borg
|
||||
# This plus subprocess.Popen(..., close_fds=False) in execute.py is necessary for the Borg
|
||||
# child process to inherit the file descriptor.
|
||||
os.set_inheritable(read_file_descriptor, True)
|
||||
environment['BORG_PASSPHRASE_FD'] = str(read_file_descriptor)
|
||||
@@ -62,15 +86,15 @@ def make_environment(config):
|
||||
for (
|
||||
option_name,
|
||||
environment_variable_name,
|
||||
) in DEFAULT_BOOL_OPTION_TO_DOWNCASE_ENVIRONMENT_VARIABLE.items():
|
||||
) in DEFAULT_BOOL_OPTION_TO_ENVIRONMENT_VARIABLE.items():
|
||||
if os.environ.get(environment_variable_name) is None:
|
||||
value = config.get(option_name)
|
||||
environment[environment_variable_name] = 'yes' if value else 'no'
|
||||
environment[environment_variable_name] = 'YES' if value else 'NO'
|
||||
|
||||
for (
|
||||
option_name,
|
||||
environment_variable_name,
|
||||
) in DEFAULT_BOOL_OPTION_TO_UPPERCASE_ENVIRONMENT_VARIABLE.items():
|
||||
) in DEFAULT_BOOL_OPTION_TO_UNCONDITIONAL_ENVIRONMENT_VARIABLE.items():
|
||||
value = config.get(option_name)
|
||||
if value is not None:
|
||||
environment[environment_variable_name] = 'YES' if value else 'NO'
|
||||
|
||||
@@ -35,7 +35,7 @@ def export_key(
|
||||
if export_arguments.path and export_arguments.path != '-':
|
||||
if os.path.exists(os.path.join(working_directory or '', export_arguments.path)):
|
||||
raise FileExistsError(
|
||||
f'Destination path {export_arguments.path} already exists. Aborting.'
|
||||
f'Destination path {export_arguments.path} already exists. Aborting.',
|
||||
)
|
||||
|
||||
output_file = None
|
||||
@@ -46,7 +46,7 @@ def export_key(
|
||||
(local_path, 'key', 'export')
|
||||
+ (('--remote-path', remote_path) if remote_path else ())
|
||||
+ (('--umask', str(umask)) if umask else ())
|
||||
+ (('--log-json',) if global_arguments.log_json else ())
|
||||
+ (('--log-json',) if config.get('log_json') else ())
|
||||
+ (('--lock-wait', str(lock_wait)) if lock_wait else ())
|
||||
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
|
||||
+ (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ())
|
||||
@@ -67,7 +67,7 @@ def export_key(
|
||||
full_command,
|
||||
output_file=output_file,
|
||||
output_log_level=logging.ANSWER,
|
||||
extra_environment=environment.make_environment(config),
|
||||
environment=environment.make_environment(config),
|
||||
working_directory=working_directory,
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=config.get('borg_exit_codes'),
|
||||
|
||||
@@ -20,7 +20,6 @@ def export_tar_archive(
|
||||
local_path='borg',
|
||||
remote_path=None,
|
||||
tar_filter=None,
|
||||
list_files=False,
|
||||
strip_components=None,
|
||||
):
|
||||
'''
|
||||
@@ -40,10 +39,10 @@ def export_tar_archive(
|
||||
(local_path, 'export-tar')
|
||||
+ (('--remote-path', remote_path) if remote_path else ())
|
||||
+ (('--umask', str(umask)) if umask else ())
|
||||
+ (('--log-json',) if global_arguments.log_json else ())
|
||||
+ (('--log-json',) if config.get('log_json') else ())
|
||||
+ (('--lock-wait', str(lock_wait)) if lock_wait else ())
|
||||
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
|
||||
+ (('--list',) if list_files else ())
|
||||
+ (('--list',) if config.get('list_details') else ())
|
||||
+ (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ())
|
||||
+ (('--dry-run',) if dry_run else ())
|
||||
+ (('--tar-filter', tar_filter) if tar_filter else ())
|
||||
@@ -57,20 +56,17 @@ def export_tar_archive(
|
||||
+ (tuple(paths) if paths else ())
|
||||
)
|
||||
|
||||
if list_files:
|
||||
output_log_level = logging.ANSWER
|
||||
else:
|
||||
output_log_level = logging.INFO
|
||||
output_log_level = logging.ANSWER if config.get('list_details') else logging.INFO
|
||||
|
||||
if dry_run:
|
||||
logging.info('Skipping export to tar file (dry run)')
|
||||
logger.info('Skipping export to tar file (dry run)')
|
||||
return
|
||||
|
||||
execute_command(
|
||||
full_command,
|
||||
output_file=DO_NOT_CAPTURE if destination_path == '-' else None,
|
||||
output_log_level=output_log_level,
|
||||
extra_environment=environment.make_environment(config),
|
||||
environment=environment.make_environment(config),
|
||||
working_directory=borgmatic.config.paths.get_working_directory(config),
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=config.get('borg_exit_codes'),
|
||||
|
||||
+16
-15
@@ -47,18 +47,20 @@ def extract_last_archive_dry_run(
|
||||
full_extract_command = (
|
||||
(local_path, 'extract', '--dry-run')
|
||||
+ (('--remote-path', remote_path) if remote_path else ())
|
||||
+ (('--log-json',) if global_arguments.log_json else ())
|
||||
+ (('--log-json',) if config.get('log_json') else ())
|
||||
+ (('--lock-wait', str(lock_wait)) if lock_wait else ())
|
||||
+ verbosity_flags
|
||||
+ list_flag
|
||||
+ flags.make_repository_archive_flags(
|
||||
repository_path, last_archive_name, local_borg_version
|
||||
repository_path,
|
||||
last_archive_name,
|
||||
local_borg_version,
|
||||
)
|
||||
)
|
||||
|
||||
execute_command(
|
||||
full_extract_command,
|
||||
extra_environment=environment.make_environment(config),
|
||||
environment=environment.make_environment(config),
|
||||
working_directory=borgmatic.config.paths.get_working_directory(config),
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=config.get('borg_exit_codes'),
|
||||
@@ -77,7 +79,6 @@ def extract_archive(
|
||||
remote_path=None,
|
||||
destination_path=None,
|
||||
strip_components=None,
|
||||
progress=False,
|
||||
extract_to_stdout=False,
|
||||
):
|
||||
'''
|
||||
@@ -92,8 +93,8 @@ def extract_archive(
|
||||
umask = config.get('umask', None)
|
||||
lock_wait = config.get('lock_wait', None)
|
||||
|
||||
if progress and extract_to_stdout:
|
||||
raise ValueError('progress and extract_to_stdout cannot both be set')
|
||||
if config.get('progress') and extract_to_stdout:
|
||||
raise ValueError('progress and extract to stdout cannot both be set')
|
||||
|
||||
if feature.available(feature.Feature.NUMERIC_IDS, local_borg_version):
|
||||
numeric_ids_flags = ('--numeric-ids',) if config.get('numeric_ids') else ()
|
||||
@@ -122,21 +123,19 @@ def extract_archive(
|
||||
+ (('--remote-path', remote_path) if remote_path else ())
|
||||
+ numeric_ids_flags
|
||||
+ (('--umask', str(umask)) if umask else ())
|
||||
+ (('--log-json',) if global_arguments.log_json else ())
|
||||
+ (('--log-json',) if config.get('log_json') else ())
|
||||
+ (('--lock-wait', str(lock_wait)) if lock_wait else ())
|
||||
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
|
||||
+ (('--debug', '--list', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ())
|
||||
+ (('--dry-run',) if dry_run else ())
|
||||
+ (('--strip-components', str(strip_components)) if strip_components else ())
|
||||
+ (('--progress',) if progress else ())
|
||||
+ (('--progress',) if config.get('progress') else ())
|
||||
+ (('--stdout',) if extract_to_stdout else ())
|
||||
+ flags.make_repository_archive_flags(
|
||||
# Make the repository path absolute so the destination directory used below via changing
|
||||
# the working directory doesn't prevent Borg from finding the repo. But also apply the
|
||||
# user's configured working directory (if any) to the repo path.
|
||||
borgmatic.config.validate.normalize_repository_path(
|
||||
os.path.join(working_directory or '', repository)
|
||||
),
|
||||
borgmatic.config.validate.normalize_repository_path(repository, working_directory),
|
||||
archive,
|
||||
local_borg_version,
|
||||
)
|
||||
@@ -150,11 +149,11 @@ def extract_archive(
|
||||
|
||||
# The progress output isn't compatible with captured and logged output, as progress messes with
|
||||
# the terminal directly.
|
||||
if progress:
|
||||
if config.get('progress'):
|
||||
return execute_command(
|
||||
full_command,
|
||||
output_file=DO_NOT_CAPTURE,
|
||||
extra_environment=environment.make_environment(config),
|
||||
environment=environment.make_environment(config),
|
||||
working_directory=full_destination_path,
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=borg_exit_codes,
|
||||
@@ -166,7 +165,7 @@ def extract_archive(
|
||||
full_command,
|
||||
output_file=subprocess.PIPE,
|
||||
run_to_completion=False,
|
||||
extra_environment=environment.make_environment(config),
|
||||
environment=environment.make_environment(config),
|
||||
working_directory=full_destination_path,
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=borg_exit_codes,
|
||||
@@ -176,8 +175,10 @@ def extract_archive(
|
||||
# if the restore paths don't exist in the archive.
|
||||
execute_command(
|
||||
full_command,
|
||||
extra_environment=environment.make_environment(config),
|
||||
environment=environment.make_environment(config),
|
||||
working_directory=full_destination_path,
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=borg_exit_codes,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
@@ -17,6 +17,8 @@ class Feature(Enum):
|
||||
MATCH_ARCHIVES = 11
|
||||
EXCLUDED_FILES_MINUS = 12
|
||||
ARCHIVE_SERIES = 13
|
||||
NO_PRUNE_STATS = 14
|
||||
DRY_RUN_COMPACT = 15
|
||||
|
||||
|
||||
FEATURE_TO_MINIMUM_BORG_VERSION = {
|
||||
@@ -33,6 +35,8 @@ FEATURE_TO_MINIMUM_BORG_VERSION = {
|
||||
Feature.MATCH_ARCHIVES: parse('2.0.0b3'), # borg --match-archives
|
||||
Feature.EXCLUDED_FILES_MINUS: parse('2.0.0b5'), # --list --filter uses "-" for excludes
|
||||
Feature.ARCHIVE_SERIES: parse('2.0.0b11'), # identically named archives form a series
|
||||
Feature.NO_PRUNE_STATS: parse('2.0.0b10'), # prune --stats is not available
|
||||
Feature.DRY_RUN_COMPACT: parse('1.2.9'), # borg compact --dry-run support
|
||||
}
|
||||
|
||||
|
||||
|
||||
+90
-10
@@ -34,7 +34,7 @@ def make_flags_from_arguments(arguments, excludes=()):
|
||||
make_flags(name, value=getattr(arguments, name))
|
||||
for name in sorted(vars(arguments))
|
||||
if name not in excludes and not name.startswith('_')
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ def make_repository_flags(repository_path, local_borg_version):
|
||||
) + (repository_path,)
|
||||
|
||||
|
||||
ARCHIVE_HASH_PATTERN = re.compile('[0-9a-fA-F]{8,}$')
|
||||
ARCHIVE_HASH_PATTERN = re.compile(r'[0-9a-fA-F]{8,}$')
|
||||
|
||||
|
||||
def make_repository_archive_flags(repository_path, archive, local_borg_version):
|
||||
@@ -76,8 +76,8 @@ def make_repository_archive_flags(repository_path, archive, local_borg_version):
|
||||
)
|
||||
|
||||
|
||||
DEFAULT_ARCHIVE_NAME_FORMAT_WITHOUT_SERIES = '{hostname}-{now:%Y-%m-%dT%H:%M:%S.%f}' # noqa: FS003
|
||||
DEFAULT_ARCHIVE_NAME_FORMAT_WITH_SERIES = '{hostname}' # noqa: FS003
|
||||
DEFAULT_ARCHIVE_NAME_FORMAT_WITHOUT_SERIES = '{hostname}-{now:%Y-%m-%dT%H:%M:%S.%f}'
|
||||
DEFAULT_ARCHIVE_NAME_FORMAT_WITH_SERIES = '{hostname}'
|
||||
|
||||
|
||||
def get_default_archive_name_format(local_borg_version):
|
||||
@@ -90,7 +90,7 @@ def get_default_archive_name_format(local_borg_version):
|
||||
return DEFAULT_ARCHIVE_NAME_FORMAT_WITHOUT_SERIES
|
||||
|
||||
|
||||
def make_match_archives_flags(
|
||||
def make_match_archives_flags( # noqa: PLR0911
|
||||
match_archives,
|
||||
archive_name_format,
|
||||
local_borg_version,
|
||||
@@ -115,8 +115,8 @@ def make_match_archives_flags(
|
||||
return ('--match-archives', f'aid:{match_archives}')
|
||||
|
||||
return ('--match-archives', match_archives)
|
||||
else:
|
||||
return ('--glob-archives', re.sub(r'^sh:', '', match_archives))
|
||||
|
||||
return ('--glob-archives', re.sub(r'^sh:', '', match_archives))
|
||||
|
||||
derived_match_archives = re.sub(
|
||||
r'\{(now|utcnow|pid)([:%\w\.-]*)\}',
|
||||
@@ -131,8 +131,8 @@ def make_match_archives_flags(
|
||||
|
||||
if feature.available(feature.Feature.MATCH_ARCHIVES, local_borg_version):
|
||||
return ('--match-archives', f'sh:{derived_match_archives}')
|
||||
else:
|
||||
return ('--glob-archives', f'{derived_match_archives}')
|
||||
|
||||
return ('--glob-archives', f'{derived_match_archives}')
|
||||
|
||||
|
||||
def warn_for_aggressive_archive_flags(json_command, json_output):
|
||||
@@ -150,9 +150,89 @@ def warn_for_aggressive_archive_flags(json_command, json_output):
|
||||
if len(json.loads(json_output)['archives']) == 0:
|
||||
logger.warning('An archive filter was applied, but no matching archives were found.')
|
||||
logger.warning(
|
||||
'Try adding --match-archives "*" or adjusting archive_name_format/match_archives in configuration.'
|
||||
'Try adding --match-archives "*" or adjusting archive_name_format/match_archives in configuration.',
|
||||
)
|
||||
except json.JSONDecodeError as error:
|
||||
logger.debug(f'Cannot parse JSON output from archive command: {error}')
|
||||
except (TypeError, KeyError):
|
||||
logger.debug('Cannot parse JSON output from archive command: No "archives" key found')
|
||||
|
||||
|
||||
def omit_flag(arguments, flag):
|
||||
'''
|
||||
Given a sequence of Borg command-line arguments, return them with the given (valueless) flag
|
||||
omitted. For instance, if the flag is "--flag" and arguments is:
|
||||
|
||||
('borg', 'create', '--flag', '--other-flag')
|
||||
|
||||
... then return:
|
||||
|
||||
('borg', 'create', '--other-flag')
|
||||
'''
|
||||
return tuple(argument for argument in arguments if argument != flag)
|
||||
|
||||
|
||||
def omit_flag_and_value(arguments, flag):
|
||||
'''
|
||||
Given a sequence of Borg command-line arguments, return them with the given flag and its
|
||||
corresponding value omitted. For instance, if the flag is "--flag" and arguments is:
|
||||
|
||||
('borg', 'create', '--flag', 'value', '--other-flag')
|
||||
|
||||
... or:
|
||||
|
||||
('borg', 'create', '--flag=value', '--other-flag')
|
||||
|
||||
... then return:
|
||||
|
||||
('borg', 'create', '--other-flag')
|
||||
'''
|
||||
# This works by zipping together a list of overlapping pairwise arguments. E.g., ('one', 'two',
|
||||
# 'three', 'four') becomes ((None, 'one'), ('one, 'two'), ('two', 'three'), ('three', 'four')).
|
||||
# This makes it easy to "look back" at the previous arguments so we can exclude both a flag and
|
||||
# its value.
|
||||
return tuple(
|
||||
argument
|
||||
for (previous_argument, argument) in zip((None, *arguments), arguments)
|
||||
if flag not in {previous_argument, argument}
|
||||
if not argument.startswith(f'{flag}=')
|
||||
)
|
||||
|
||||
|
||||
def make_exclude_flags(config):
|
||||
'''
|
||||
Given a configuration dict with various exclude options, return the corresponding Borg flags as
|
||||
a tuple.
|
||||
'''
|
||||
caches_flag = ('--exclude-caches',) if config.get('exclude_caches') else ()
|
||||
if_present_flags = tuple(
|
||||
itertools.chain.from_iterable(
|
||||
('--exclude-if-present', if_present)
|
||||
for if_present in config.get('exclude_if_present', ())
|
||||
),
|
||||
)
|
||||
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 caches_flag + if_present_flags + keep_exclude_tags_flags + exclude_nodump_flags
|
||||
|
||||
|
||||
def make_list_filter_flags(local_borg_version, dry_run):
|
||||
'''
|
||||
Given the local Borg version and whether this is a dry run, return the corresponding flags for
|
||||
passing to "--list --filter". The general idea is that excludes are shown for a dry run or when
|
||||
the verbosity is debug.
|
||||
'''
|
||||
base_flags = 'AME'
|
||||
show_excludes = logger.isEnabledFor(logging.DEBUG)
|
||||
|
||||
if feature.available(feature.Feature.EXCLUDED_FILES_MINUS, local_borg_version):
|
||||
if show_excludes or dry_run:
|
||||
return f'{base_flags}+-'
|
||||
|
||||
return base_flags
|
||||
|
||||
if show_excludes:
|
||||
return f'{base_flags}x-'
|
||||
|
||||
return f'{base_flags}-'
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import logging
|
||||
import os
|
||||
|
||||
import borgmatic.config.paths
|
||||
import borgmatic.logger
|
||||
from borgmatic.borg import environment, flags
|
||||
from borgmatic.execute import DO_NOT_CAPTURE, execute_command
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def import_key(
|
||||
repository_path,
|
||||
config,
|
||||
local_borg_version,
|
||||
import_arguments,
|
||||
global_arguments,
|
||||
local_path='borg',
|
||||
remote_path=None,
|
||||
):
|
||||
'''
|
||||
Given a local or remote repository path, a configuration dict, the local Borg version, import
|
||||
arguments, and optional local and remote Borg paths, import the repository key from the
|
||||
path indicated in the import arguments.
|
||||
|
||||
If the path is empty or "-", then read the key from stdin.
|
||||
|
||||
Raise ValueError if the path is given and it does not exist.
|
||||
'''
|
||||
umask = config.get('umask', None)
|
||||
lock_wait = config.get('lock_wait', None)
|
||||
working_directory = borgmatic.config.paths.get_working_directory(config)
|
||||
|
||||
if import_arguments.path and import_arguments.path != '-':
|
||||
if not os.path.exists(os.path.join(working_directory or '', import_arguments.path)):
|
||||
raise ValueError(f'Path {import_arguments.path} does not exist. Aborting.')
|
||||
|
||||
input_file = None
|
||||
else:
|
||||
input_file = DO_NOT_CAPTURE
|
||||
|
||||
full_command = (
|
||||
(local_path, 'key', 'import')
|
||||
+ (('--remote-path', remote_path) if remote_path else ())
|
||||
+ (('--umask', str(umask)) if umask else ())
|
||||
+ (('--log-json',) if config.get('log_json') else ())
|
||||
+ (('--lock-wait', str(lock_wait)) if lock_wait else ())
|
||||
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
|
||||
+ (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ())
|
||||
+ flags.make_flags('paper', import_arguments.paper)
|
||||
+ flags.make_repository_flags(
|
||||
repository_path,
|
||||
local_borg_version,
|
||||
)
|
||||
+ ((import_arguments.path,) if input_file is None else ())
|
||||
)
|
||||
|
||||
if global_arguments.dry_run:
|
||||
logger.info('Skipping key import (dry run)')
|
||||
return
|
||||
|
||||
execute_command(
|
||||
full_command,
|
||||
input_file=input_file,
|
||||
output_log_level=logging.INFO,
|
||||
environment=environment.make_environment(config),
|
||||
working_directory=working_directory,
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=config.get('borg_exit_codes'),
|
||||
)
|
||||
@@ -37,7 +37,7 @@ def make_info_command(
|
||||
)
|
||||
+ 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('log-json', config.get('log_json'))
|
||||
+ flags.make_flags('lock-wait', config.get('lock_wait'))
|
||||
+ (
|
||||
(
|
||||
@@ -48,16 +48,15 @@ def make_info_command(
|
||||
if info_arguments.prefix
|
||||
else (
|
||||
flags.make_match_archives_flags(
|
||||
info_arguments.match_archives
|
||||
or info_arguments.archive
|
||||
or config.get('match_archives'),
|
||||
info_arguments.archive or config.get('match_archives'),
|
||||
config.get('archive_name_format'),
|
||||
local_borg_version,
|
||||
)
|
||||
)
|
||||
)
|
||||
+ flags.make_flags_from_arguments(
|
||||
info_arguments, excludes=('repository', 'archive', 'prefix', 'match_archives')
|
||||
info_arguments,
|
||||
excludes=('repository', 'archive', 'prefix', 'match_archives'),
|
||||
)
|
||||
+ flags.make_repository_flags(repository_path, local_borg_version)
|
||||
)
|
||||
@@ -102,7 +101,7 @@ def display_archives_info(
|
||||
|
||||
json_info = execute_command_and_capture_output(
|
||||
json_command,
|
||||
extra_environment=environment.make_environment(config),
|
||||
environment=environment.make_environment(config),
|
||||
working_directory=working_directory,
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=borg_exit_codes,
|
||||
@@ -116,8 +115,10 @@ def display_archives_info(
|
||||
execute_command(
|
||||
main_command,
|
||||
output_log_level=logging.ANSWER,
|
||||
extra_environment=environment.make_environment(config),
|
||||
environment=environment.make_environment(config),
|
||||
working_directory=working_directory,
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=borg_exit_codes,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
+18
-13
@@ -17,7 +17,8 @@ MAKE_FLAGS_EXCLUDES = (
|
||||
'archive',
|
||||
'paths',
|
||||
'find_paths',
|
||||
) + ARCHIVE_FILTER_FLAGS_MOVED_TO_REPO_LIST
|
||||
*ARCHIVE_FILTER_FLAGS_MOVED_TO_REPO_LIST,
|
||||
)
|
||||
|
||||
|
||||
def make_list_command(
|
||||
@@ -48,12 +49,14 @@ def make_list_command(
|
||||
)
|
||||
+ 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('log-json', config.get('log_json'))
|
||||
+ 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(
|
||||
repository_path, list_arguments.archive, local_borg_version
|
||||
repository_path,
|
||||
list_arguments.archive,
|
||||
local_borg_version,
|
||||
)
|
||||
if list_arguments.archive
|
||||
else flags.make_repository_flags(repository_path, local_borg_version)
|
||||
@@ -115,22 +118,22 @@ def capture_archive_listing(
|
||||
argparse.Namespace(
|
||||
repository=repository_path,
|
||||
archive=archive,
|
||||
paths=[path for path in list_paths] if list_paths else None,
|
||||
paths=list(list_paths) if list_paths else None,
|
||||
find_paths=None,
|
||||
json=None,
|
||||
format=path_format or '{path}{NUL}', # noqa: FS003
|
||||
format=path_format or '{path}{NUL}',
|
||||
),
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
),
|
||||
extra_environment=environment.make_environment(config),
|
||||
environment=environment.make_environment(config),
|
||||
working_directory=borgmatic.config.paths.get_working_directory(config),
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=config.get('borg_exit_codes'),
|
||||
)
|
||||
.strip('\0')
|
||||
.split('\0')
|
||||
.split('\0'),
|
||||
)
|
||||
|
||||
|
||||
@@ -156,7 +159,7 @@ def list_archive(
|
||||
if not list_arguments.archive and not list_arguments.find_paths:
|
||||
if feature.available(feature.Feature.REPO_LIST, local_borg_version):
|
||||
logger.warning(
|
||||
'Omitting the --archive flag on the list action is deprecated when using Borg 2.x+. Use the repo-list action instead.'
|
||||
'Omitting the --archive flag on the list action is deprecated when using Borg 2.x+. Use the repo-list action instead.',
|
||||
)
|
||||
|
||||
repo_list_arguments = argparse.Namespace(
|
||||
@@ -184,12 +187,12 @@ def list_archive(
|
||||
for name in ARCHIVE_FILTER_FLAGS_MOVED_TO_REPO_LIST:
|
||||
if getattr(list_arguments, name, None):
|
||||
logger.warning(
|
||||
f"The --{name.replace('_', '-')} flag on the list action is ignored when using the --archive flag."
|
||||
f"The --{name.replace('_', '-')} flag on the list action is ignored when using the --archive flag.",
|
||||
)
|
||||
|
||||
if list_arguments.json:
|
||||
raise ValueError(
|
||||
'The --json flag on the list action is not supported when using the --archive/--find flags.'
|
||||
'The --json flag on the list action is not supported when using the --archive/--find flags.',
|
||||
)
|
||||
|
||||
borg_exit_codes = config.get('borg_exit_codes')
|
||||
@@ -221,13 +224,13 @@ def list_archive(
|
||||
local_path,
|
||||
remote_path,
|
||||
),
|
||||
extra_environment=environment.make_environment(config),
|
||||
environment=environment.make_environment(config),
|
||||
working_directory=borgmatic.config.paths.get_working_directory(config),
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=borg_exit_codes,
|
||||
)
|
||||
.strip('\n')
|
||||
.splitlines()
|
||||
.splitlines(),
|
||||
)
|
||||
else:
|
||||
archive_lines = (list_arguments.archive,)
|
||||
@@ -257,8 +260,10 @@ def list_archive(
|
||||
execute_command(
|
||||
main_command,
|
||||
output_log_level=logging.ANSWER,
|
||||
extra_environment=environment.make_environment(config),
|
||||
environment=environment.make_environment(config),
|
||||
working_directory=borgmatic.config.paths.get_working_directory(config),
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=borg_exit_codes,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
@@ -30,7 +30,7 @@ def mount_archive(
|
||||
(local_path, 'mount')
|
||||
+ (('--remote-path', remote_path) if remote_path else ())
|
||||
+ (('--umask', str(umask)) if umask else ())
|
||||
+ (('--log-json',) if global_arguments.log_json else ())
|
||||
+ (('--log-json',) if config.get('log_json') else ())
|
||||
+ (('--lock-wait', str(lock_wait)) if lock_wait else ())
|
||||
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
|
||||
+ (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ())
|
||||
@@ -66,7 +66,7 @@ def mount_archive(
|
||||
execute_command(
|
||||
full_command,
|
||||
output_file=DO_NOT_CAPTURE,
|
||||
extra_environment=environment.make_environment(config),
|
||||
environment=environment.make_environment(config),
|
||||
working_directory=working_directory,
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=config.get('borg_exit_codes'),
|
||||
@@ -75,7 +75,7 @@ def mount_archive(
|
||||
|
||||
execute_command(
|
||||
full_command,
|
||||
extra_environment=environment.make_environment(config),
|
||||
environment=environment.make_environment(config),
|
||||
working_directory=working_directory,
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=config.get('borg_exit_codes'),
|
||||
|
||||
@@ -9,21 +9,14 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@functools.cache
|
||||
def run_passcommand(passcommand, passphrase_configured, working_directory):
|
||||
def run_passcommand(passcommand, working_directory):
|
||||
'''
|
||||
Run the given passcommand using the given working directory and return the passphrase produced
|
||||
by the command. But bail first if a passphrase is already configured; this mimics Borg's
|
||||
behavior.
|
||||
by the command.
|
||||
|
||||
Cache the results so that the passcommand only needs to run—and potentially prompt the user—once
|
||||
per borgmatic invocation.
|
||||
'''
|
||||
if passcommand and passphrase_configured:
|
||||
logger.warning(
|
||||
'Ignoring the "encryption_passcommand" option because "encryption_passphrase" is set'
|
||||
)
|
||||
return None
|
||||
|
||||
return borgmatic.execute.execute_command_and_capture_output(
|
||||
shlex.split(passcommand),
|
||||
working_directory=working_directory,
|
||||
@@ -44,7 +37,4 @@ def get_passphrase_from_passcommand(config):
|
||||
if not passcommand:
|
||||
return None
|
||||
|
||||
passphrase = config.get('encryption_passphrase')
|
||||
working_directory = borgmatic.config.paths.get_working_directory(config)
|
||||
|
||||
return run_passcommand(passcommand, bool(passphrase is not None), working_directory)
|
||||
return run_passcommand(passcommand, borgmatic.config.paths.get_working_directory(config))
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import collections
|
||||
import enum
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# See https://borgbackup.readthedocs.io/en/stable/usage/help.html#borg-help-patterns
|
||||
@@ -20,12 +25,82 @@ class Pattern_style(enum.Enum):
|
||||
PATH_FULL_MATCH = 'pf'
|
||||
|
||||
|
||||
class Pattern_source(enum.Enum):
|
||||
'''
|
||||
Where the pattern came from within borgmatic. This is important because certain use cases (like
|
||||
filesystem snapshotting) only want to consider patterns that the user actually put in a
|
||||
configuration file and not patterns from other sources.
|
||||
'''
|
||||
|
||||
# The pattern is from a borgmatic configuration option, e.g. listed in "source_directories".
|
||||
CONFIG = 'config'
|
||||
|
||||
# The pattern is generated internally within borgmatic, e.g. for special file excludes.
|
||||
INTERNAL = 'internal'
|
||||
|
||||
# The pattern originates from within a borgmatic hook, e.g. a database hook that adds its dump
|
||||
# directory.
|
||||
HOOK = 'hook'
|
||||
|
||||
|
||||
Pattern = collections.namedtuple(
|
||||
'Pattern',
|
||||
('path', 'type', 'style', 'device'),
|
||||
('path', 'type', 'style', 'device', 'source'),
|
||||
defaults=(
|
||||
Pattern_type.ROOT,
|
||||
Pattern_style.NONE,
|
||||
None,
|
||||
Pattern_source.HOOK,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def write_patterns_file(patterns, borgmatic_runtime_directory, patterns_file=None):
|
||||
'''
|
||||
Given a sequence of patterns as 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.
|
||||
|
||||
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:
|
||||
return None
|
||||
|
||||
if patterns_file is None:
|
||||
patterns_file = tempfile.NamedTemporaryFile(
|
||||
'w', dir=borgmatic_runtime_directory, encoding='utf-8'
|
||||
)
|
||||
operation_name = 'Writing'
|
||||
else:
|
||||
patterns_file.write('\n')
|
||||
operation_name = 'Appending'
|
||||
|
||||
patterns_output = '\n'.join(
|
||||
f'{pattern.type.value} {pattern.style.value}{":" if pattern.style.value else ""}{pattern.path}'
|
||||
for pattern in patterns
|
||||
)
|
||||
logger.debug(f'{operation_name} patterns to {patterns_file.name}:\n{patterns_output}')
|
||||
|
||||
patterns_file.write(patterns_output)
|
||||
patterns_file.flush()
|
||||
|
||||
return patterns_file
|
||||
|
||||
|
||||
def check_all_root_patterns_exist(patterns):
|
||||
'''
|
||||
Given a sequence of Pattern instances, check that all root pattern paths exist. If any don't,
|
||||
raise an exception.
|
||||
'''
|
||||
missing_paths = [
|
||||
pattern.path
|
||||
for pattern in patterns
|
||||
if pattern.type == Pattern_type.ROOT
|
||||
if not os.path.exists(pattern.path)
|
||||
]
|
||||
|
||||
if missing_paths:
|
||||
raise ValueError(
|
||||
f"Source directories or root pattern paths do not exist: {', '.join(missing_paths)}",
|
||||
)
|
||||
|
||||
+15
-8
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
import shlex
|
||||
|
||||
import borgmatic.config.paths
|
||||
import borgmatic.logger
|
||||
@@ -41,7 +42,7 @@ def make_prune_flags(config, prune_arguments, local_borg_version):
|
||||
if prefix
|
||||
else (
|
||||
flags.make_match_archives_flags(
|
||||
prune_arguments.match_archives or config.get('match_archives'),
|
||||
config.get('match_archives'),
|
||||
config.get('archive_name_format'),
|
||||
local_borg_version,
|
||||
)
|
||||
@@ -73,22 +74,28 @@ def prune_archives(
|
||||
+ make_prune_flags(config, prune_arguments, local_borg_version)
|
||||
+ (('--remote-path', remote_path) if remote_path else ())
|
||||
+ (('--umask', str(umask)) if umask else ())
|
||||
+ (('--log-json',) if global_arguments.log_json else ())
|
||||
+ (('--log-json',) if config.get('log_json') else ())
|
||||
+ (('--lock-wait', str(lock_wait)) if lock_wait else ())
|
||||
+ (('--stats',) if prune_arguments.stats and not dry_run else ())
|
||||
+ (
|
||||
('--stats',)
|
||||
if config.get('statistics')
|
||||
and not dry_run
|
||||
and not feature.available(feature.Feature.NO_PRUNE_STATS, local_borg_version)
|
||||
else ()
|
||||
)
|
||||
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
|
||||
+ flags.make_flags_from_arguments(
|
||||
prune_arguments,
|
||||
excludes=('repository', 'match_archives', 'stats', 'list_archives'),
|
||||
excludes=('repository', 'match_archives', 'statistics', 'list_details'),
|
||||
)
|
||||
+ (('--list',) if prune_arguments.list_archives else ())
|
||||
+ (('--list',) if config.get('list_details') else ())
|
||||
+ (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ())
|
||||
+ (('--dry-run',) if dry_run else ())
|
||||
+ (tuple(extra_borg_options.split(' ')) if extra_borg_options else ())
|
||||
+ (tuple(shlex.split(extra_borg_options)) if extra_borg_options else ())
|
||||
+ flags.make_repository_flags(repository_path, local_borg_version)
|
||||
)
|
||||
|
||||
if prune_arguments.stats or prune_arguments.list_archives:
|
||||
if config.get('statistics') or config.get('list_details'):
|
||||
output_log_level = logging.ANSWER
|
||||
else:
|
||||
output_log_level = logging.INFO
|
||||
@@ -96,7 +103,7 @@ def prune_archives(
|
||||
execute_command(
|
||||
full_command,
|
||||
output_log_level=output_log_level,
|
||||
extra_environment=environment.make_environment(config),
|
||||
environment=environment.make_environment(config),
|
||||
working_directory=borgmatic.config.paths.get_working_directory(config),
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=config.get('borg_exit_codes'),
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import logging
|
||||
import shlex
|
||||
|
||||
import borgmatic.borg.environment
|
||||
import borgmatic.borg.feature
|
||||
import borgmatic.config.paths
|
||||
import borgmatic.execute
|
||||
from borgmatic.borg import flags
|
||||
from borgmatic.borg.pattern import write_patterns_file
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def recreate_archive(
|
||||
repository,
|
||||
archive,
|
||||
config,
|
||||
local_borg_version,
|
||||
recreate_arguments,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path=None,
|
||||
patterns=None,
|
||||
):
|
||||
'''
|
||||
Given a local or remote repository path, an archive name, a configuration dict, the local Borg
|
||||
version string, an argparse.Namespace of recreate arguments, an argparse.Namespace of global
|
||||
arguments, optional local and remote Borg paths, executes the recreate command with the given
|
||||
arguments.
|
||||
'''
|
||||
lock_wait = config.get('lock_wait', None)
|
||||
exclude_flags = flags.make_exclude_flags(config)
|
||||
compression = config.get('compression', None)
|
||||
chunker_params = config.get('chunker_params', None)
|
||||
|
||||
# Available recompress MODES: "if-different", "always", "never" (default)
|
||||
recompress = config.get('recompress', None)
|
||||
|
||||
# Write patterns to a temporary file and use that file with --patterns-from.
|
||||
patterns_file = write_patterns_file(
|
||||
patterns,
|
||||
borgmatic.config.paths.get_working_directory(config),
|
||||
)
|
||||
|
||||
recreate_command = (
|
||||
(local_path, 'recreate')
|
||||
+ (('--remote-path', remote_path) if remote_path else ())
|
||||
+ (('--log-json',) if config.get('log_json') else ())
|
||||
+ (('--lock-wait', str(lock_wait)) if lock_wait is not None else ())
|
||||
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
|
||||
+ (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ())
|
||||
+ (('--patterns-from', patterns_file.name) if patterns_file else ())
|
||||
+ (
|
||||
(
|
||||
'--list',
|
||||
'--filter',
|
||||
flags.make_list_filter_flags(local_borg_version, global_arguments.dry_run),
|
||||
)
|
||||
if config.get('list_details')
|
||||
else ()
|
||||
)
|
||||
# Flag --target works only for a single archive.
|
||||
+ (('--target', recreate_arguments.target) if recreate_arguments.target and archive else ())
|
||||
+ (
|
||||
('--comment', shlex.quote(recreate_arguments.comment))
|
||||
if recreate_arguments.comment
|
||||
else ()
|
||||
)
|
||||
+ (('--timestamp', recreate_arguments.timestamp) if recreate_arguments.timestamp else ())
|
||||
+ (('--compression', compression) if compression else ())
|
||||
+ (('--chunker-params', chunker_params) if chunker_params else ())
|
||||
+ (('--recompress', recompress) if recompress else ())
|
||||
+ exclude_flags
|
||||
+ (
|
||||
(
|
||||
flags.make_repository_flags(repository, local_borg_version)
|
||||
+ flags.make_match_archives_flags(
|
||||
archive or config.get('match_archives'),
|
||||
config.get('archive_name_format'),
|
||||
local_borg_version,
|
||||
)
|
||||
)
|
||||
if borgmatic.borg.feature.available(
|
||||
borgmatic.borg.feature.Feature.SEPARATE_REPOSITORY_ARCHIVE,
|
||||
local_borg_version,
|
||||
)
|
||||
else (
|
||||
flags.make_repository_archive_flags(repository, archive, local_borg_version)
|
||||
if archive
|
||||
else flags.make_repository_flags(repository, local_borg_version)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if global_arguments.dry_run:
|
||||
logger.info('Skipping the archive recreation (dry run)')
|
||||
return
|
||||
|
||||
borgmatic.execute.execute_command(
|
||||
full_command=recreate_command,
|
||||
output_log_level=logging.INFO,
|
||||
environment=borgmatic.borg.environment.make_environment(config),
|
||||
working_directory=borgmatic.config.paths.get_working_directory(config),
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=config.get('borg_exit_codes'),
|
||||
)
|
||||
@@ -0,0 +1,64 @@
|
||||
import logging
|
||||
|
||||
import borgmatic.borg.flags
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def make_rename_command(
|
||||
dry_run,
|
||||
repository_name,
|
||||
old_archive_name,
|
||||
new_archive_name,
|
||||
config,
|
||||
local_borg_version,
|
||||
local_path,
|
||||
remote_path,
|
||||
):
|
||||
return (
|
||||
(local_path, 'rename')
|
||||
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
|
||||
+ (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ())
|
||||
+ borgmatic.borg.flags.make_flags('dry-run', 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', config.get('log_json'))
|
||||
+ borgmatic.borg.flags.make_flags('lock-wait', config.get('lock_wait'))
|
||||
+ borgmatic.borg.flags.make_repository_archive_flags(
|
||||
repository_name,
|
||||
old_archive_name,
|
||||
local_borg_version,
|
||||
)
|
||||
+ (new_archive_name,)
|
||||
)
|
||||
|
||||
|
||||
def rename_archive(
|
||||
repository_name,
|
||||
old_archive_name,
|
||||
new_archive_name,
|
||||
dry_run,
|
||||
config,
|
||||
local_borg_version,
|
||||
local_path,
|
||||
remote_path,
|
||||
):
|
||||
command = make_rename_command(
|
||||
dry_run,
|
||||
repository_name,
|
||||
old_archive_name,
|
||||
new_archive_name,
|
||||
config,
|
||||
local_borg_version,
|
||||
local_path,
|
||||
remote_path,
|
||||
)
|
||||
|
||||
borgmatic.execute.execute_command(
|
||||
command,
|
||||
output_log_level=logging.INFO,
|
||||
environment=borgmatic.borg.environment.make_environment(config),
|
||||
working_directory=borgmatic.config.paths.get_working_directory(config),
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=config.get('borg_exit_codes'),
|
||||
)
|
||||
@@ -1,6 +1,7 @@
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import shlex
|
||||
import subprocess
|
||||
|
||||
import borgmatic.config.paths
|
||||
@@ -24,7 +25,7 @@ def create_repository(
|
||||
copy_crypt_key=False,
|
||||
append_only=None,
|
||||
storage_quota=None,
|
||||
make_parent_dirs=False,
|
||||
make_parent_directories=False,
|
||||
local_path='borg',
|
||||
remote_path=None,
|
||||
):
|
||||
@@ -48,13 +49,13 @@ def create_repository(
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
)
|
||||
),
|
||||
)
|
||||
repository_encryption_mode = info_data.get('encryption', {}).get('mode')
|
||||
|
||||
if repository_encryption_mode != encryption_mode:
|
||||
raise ValueError(
|
||||
f'Requested encryption mode "{encryption_mode}" does not match existing repository encryption mode "{repository_encryption_mode}"'
|
||||
f'Requested encryption mode "{encryption_mode}" does not match existing repository encryption mode "{repository_encryption_mode}"',
|
||||
)
|
||||
|
||||
logger.info('Repository already exists. Skipping creation.')
|
||||
@@ -79,26 +80,26 @@ def create_repository(
|
||||
+ (('--copy-crypt-key',) if copy_crypt_key else ())
|
||||
+ (('--append-only',) if append_only else ())
|
||||
+ (('--storage-quota', storage_quota) if storage_quota else ())
|
||||
+ (('--make-parent-dirs',) if make_parent_dirs else ())
|
||||
+ (('--make-parent-dirs',) if make_parent_directories else ())
|
||||
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
|
||||
+ (('--debug',) if logger.isEnabledFor(logging.DEBUG) else ())
|
||||
+ (('--log-json',) if global_arguments.log_json else ())
|
||||
+ (('--log-json',) if config.get('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 ())
|
||||
+ (tuple(shlex.split(extra_borg_options)) if extra_borg_options else ())
|
||||
+ flags.make_repository_flags(repository_path, local_borg_version)
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
logging.info('Skipping repository creation (dry run)')
|
||||
logger.info('Skipping repository creation (dry run)')
|
||||
return
|
||||
|
||||
# Do not capture output here, so as to support interactive prompts.
|
||||
execute_command(
|
||||
repo_create_command,
|
||||
output_file=DO_NOT_CAPTURE,
|
||||
extra_environment=environment.make_environment(config),
|
||||
environment=environment.make_environment(config),
|
||||
working_directory=borgmatic.config.paths.get_working_directory(config),
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=config.get('borg_exit_codes'),
|
||||
|
||||
@@ -9,6 +9,9 @@ import borgmatic.execute
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
FORCE_HARDER_FLAG_COUNT = 2
|
||||
|
||||
|
||||
def make_repo_delete_command(
|
||||
repository,
|
||||
config,
|
||||
@@ -28,7 +31,8 @@ def make_repo_delete_command(
|
||||
+ (
|
||||
('repo-delete',)
|
||||
if borgmatic.borg.feature.available(
|
||||
borgmatic.borg.feature.Feature.REPO_DELETE, local_borg_version
|
||||
borgmatic.borg.feature.Feature.REPO_DELETE,
|
||||
local_borg_version,
|
||||
)
|
||||
else ('delete',)
|
||||
)
|
||||
@@ -37,16 +41,20 @@ def make_repo_delete_command(
|
||||
+ 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('log-json', config.get('log_json'))
|
||||
+ borgmatic.borg.flags.make_flags('lock-wait', config.get('lock_wait'))
|
||||
+ borgmatic.borg.flags.make_flags('list', repo_delete_arguments.list_archives)
|
||||
+ borgmatic.borg.flags.make_flags('list', config.get('list_details'))
|
||||
+ (
|
||||
(('--force',) + (('--force',) if repo_delete_arguments.force >= 2 else ()))
|
||||
(
|
||||
('--force',)
|
||||
+ (('--force',) if repo_delete_arguments.force >= FORCE_HARDER_FLAG_COUNT else ())
|
||||
)
|
||||
if repo_delete_arguments.force
|
||||
else ()
|
||||
)
|
||||
+ borgmatic.borg.flags.make_flags_from_arguments(
|
||||
repo_delete_arguments, excludes=('list_archives', 'force', 'repository')
|
||||
repo_delete_arguments,
|
||||
excludes=('list_details', 'force', 'repository'),
|
||||
)
|
||||
+ borgmatic.borg.flags.make_repository_flags(repository['path'], local_borg_version)
|
||||
)
|
||||
@@ -88,7 +96,7 @@ def delete_repository(
|
||||
if repo_delete_arguments.force or repo_delete_arguments.cache_only
|
||||
else borgmatic.execute.DO_NOT_CAPTURE
|
||||
),
|
||||
extra_environment=borgmatic.borg.environment.make_environment(config),
|
||||
environment=borgmatic.borg.environment.make_environment(config),
|
||||
working_directory=borgmatic.config.paths.get_working_directory(config),
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=config.get('borg_exit_codes'),
|
||||
|
||||
+13
-11
@@ -44,7 +44,7 @@ def display_repository_info(
|
||||
)
|
||||
+ 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('log-json', config.get('log_json'))
|
||||
+ flags.make_flags('lock-wait', lock_wait)
|
||||
+ (('--json',) if repo_info_arguments.json else ())
|
||||
+ flags.make_repository_flags(repository_path, local_borg_version)
|
||||
@@ -56,17 +56,19 @@ def display_repository_info(
|
||||
if repo_info_arguments.json:
|
||||
return execute_command_and_capture_output(
|
||||
full_command,
|
||||
extra_environment=environment.make_environment(config),
|
||||
working_directory=working_directory,
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=borg_exit_codes,
|
||||
)
|
||||
else:
|
||||
execute_command(
|
||||
full_command,
|
||||
output_log_level=logging.ANSWER,
|
||||
extra_environment=environment.make_environment(config),
|
||||
environment=environment.make_environment(config),
|
||||
working_directory=working_directory,
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=borg_exit_codes,
|
||||
)
|
||||
|
||||
execute_command(
|
||||
full_command,
|
||||
output_log_level=logging.ANSWER,
|
||||
environment=environment.make_environment(config),
|
||||
working_directory=working_directory,
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=borg_exit_codes,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
+67
-25
@@ -1,4 +1,5 @@
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
|
||||
import borgmatic.config.paths
|
||||
@@ -22,44 +23,83 @@ def resolve_archive_name(
|
||||
Given a local or remote repository path, an archive name, a configuration dict, the local Borg
|
||||
version, global arguments as an argparse.Namespace, a local Borg path, and a remote Borg path,
|
||||
return the archive name. But if the archive name is "latest", then instead introspect the
|
||||
repository for the latest archive and return its name.
|
||||
repository for the latest archive and return its name or ID, depending on whether the version of
|
||||
Borg in use supports archive series—different archives that share the same name but have unique
|
||||
IDs.
|
||||
|
||||
Raise ValueError if "latest" is given but there are no archives in the repository.
|
||||
'''
|
||||
if archive != 'latest':
|
||||
return archive
|
||||
|
||||
full_command = (
|
||||
(
|
||||
local_path,
|
||||
(
|
||||
'repo-list'
|
||||
if feature.available(feature.Feature.REPO_LIST, local_borg_version)
|
||||
else 'list'
|
||||
),
|
||||
)
|
||||
+ 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)
|
||||
+ ('--short',)
|
||||
+ flags.make_repository_flags(repository_path, local_borg_version)
|
||||
latest_archive = get_latest_archive(
|
||||
repository_path,
|
||||
config,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
local_path=local_path,
|
||||
remote_path=remote_path,
|
||||
)
|
||||
|
||||
output = execute_command_and_capture_output(
|
||||
return (
|
||||
latest_archive['id']
|
||||
if feature.available(feature.Feature.ARCHIVE_SERIES, local_borg_version)
|
||||
else latest_archive['name']
|
||||
)
|
||||
|
||||
|
||||
def get_latest_archive(
|
||||
repository_path,
|
||||
config,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
local_path='borg',
|
||||
remote_path=None,
|
||||
consider_checkpoints=False,
|
||||
):
|
||||
'''
|
||||
Returns a dict with information about the latest archive of a repository.
|
||||
|
||||
Raises ValueError if there are no archives in the repository.
|
||||
'''
|
||||
|
||||
full_command = (
|
||||
local_path,
|
||||
(
|
||||
'repo-list'
|
||||
if feature.available(feature.Feature.REPO_LIST, local_borg_version)
|
||||
else 'list'
|
||||
),
|
||||
*flags.make_flags('remote-path', remote_path),
|
||||
*flags.make_flags('umask', config.get('umask')),
|
||||
*flags.make_flags('log-json', config.get('log_json')),
|
||||
*flags.make_flags('lock-wait', config.get('lock_wait')),
|
||||
*(
|
||||
flags.make_flags('consider-checkpoints', consider_checkpoints)
|
||||
if not feature.available(feature.Feature.REPO_LIST, local_borg_version)
|
||||
else ()
|
||||
),
|
||||
*flags.make_flags('last', 1),
|
||||
'--json',
|
||||
*flags.make_repository_flags(repository_path, local_borg_version),
|
||||
)
|
||||
|
||||
json_output = execute_command_and_capture_output(
|
||||
full_command,
|
||||
extra_environment=environment.make_environment(config),
|
||||
environment=environment.make_environment(config),
|
||||
working_directory=borgmatic.config.paths.get_working_directory(config),
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=config.get('borg_exit_codes'),
|
||||
)
|
||||
|
||||
archives = json.loads(json_output)['archives']
|
||||
|
||||
try:
|
||||
latest_archive = output.strip().splitlines()[-1]
|
||||
latest_archive = archives[-1]
|
||||
except IndexError:
|
||||
raise ValueError('No archives found in the repository')
|
||||
|
||||
logger.debug(f'Latest archive is {latest_archive}')
|
||||
logger.debug(f'Latest archive is {latest_archive["name"]} ({latest_archive["id"]})')
|
||||
|
||||
return latest_archive
|
||||
|
||||
@@ -102,7 +142,7 @@ def make_repo_list_command(
|
||||
)
|
||||
+ 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('log-json', config.get('log_json'))
|
||||
+ flags.make_flags('lock-wait', config.get('lock_wait'))
|
||||
+ (
|
||||
(
|
||||
@@ -113,7 +153,7 @@ def make_repo_list_command(
|
||||
if repo_list_arguments.prefix
|
||||
else (
|
||||
flags.make_match_archives_flags(
|
||||
repo_list_arguments.match_archives or config.get('match_archives'),
|
||||
config.get('match_archives'),
|
||||
config.get('archive_name_format'),
|
||||
local_borg_version,
|
||||
)
|
||||
@@ -164,7 +204,7 @@ def list_repository(
|
||||
|
||||
json_listing = execute_command_and_capture_output(
|
||||
json_command,
|
||||
extra_environment=environment.make_environment(config),
|
||||
environment=environment.make_environment(config),
|
||||
working_directory=working_directory,
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=borg_exit_codes,
|
||||
@@ -178,8 +218,10 @@ def list_repository(
|
||||
execute_command(
|
||||
main_command,
|
||||
output_log_level=logging.ANSWER,
|
||||
extra_environment=environment.make_environment(config),
|
||||
environment=environment.make_environment(config),
|
||||
working_directory=working_directory,
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=borg_exit_codes,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
@@ -31,18 +31,23 @@ def transfer_archives(
|
||||
+ (('--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))
|
||||
+ flags.make_flags('log-json', config.get('log_json'))
|
||||
+ flags.make_flags('lock-wait', config.get('lock_wait'))
|
||||
+ flags.make_flags('progress', config.get('progress'))
|
||||
+ (
|
||||
flags.make_flags_from_arguments(
|
||||
transfer_arguments,
|
||||
excludes=('repository', 'source_repository', 'archive', 'match_archives'),
|
||||
excludes=(
|
||||
'repository',
|
||||
'source_repository',
|
||||
'archive',
|
||||
'match_archives',
|
||||
'progress',
|
||||
),
|
||||
)
|
||||
or (
|
||||
flags.make_match_archives_flags(
|
||||
transfer_arguments.match_archives
|
||||
or transfer_arguments.archive
|
||||
or config.get('match_archives'),
|
||||
transfer_arguments.archive or config.get('match_archives'),
|
||||
config.get('archive_name_format'),
|
||||
local_borg_version,
|
||||
)
|
||||
@@ -56,8 +61,8 @@ def transfer_archives(
|
||||
return execute_command(
|
||||
full_command,
|
||||
output_log_level=logging.ANSWER,
|
||||
output_file=DO_NOT_CAPTURE if transfer_arguments.progress else None,
|
||||
extra_environment=environment.make_environment(config),
|
||||
output_file=DO_NOT_CAPTURE if config.get('progress') else None,
|
||||
environment=environment.make_environment(config),
|
||||
working_directory=borgmatic.config.paths.get_working_directory(config),
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=config.get('borg_exit_codes'),
|
||||
|
||||
@@ -21,7 +21,7 @@ def local_borg_version(config, local_path='borg'):
|
||||
)
|
||||
output = execute_command_and_capture_output(
|
||||
full_command,
|
||||
extra_environment=environment.make_environment(config),
|
||||
environment=environment.make_environment(config),
|
||||
working_directory=borgmatic.config.paths.get_working_directory(config),
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=config.get('borg_exit_codes'),
|
||||
|
||||
+632
-179
File diff suppressed because it is too large
Load Diff
+668
-477
File diff suppressed because it is too large
Load Diff
@@ -22,15 +22,15 @@ def available_actions(subparsers, current_action=None):
|
||||
action of "config" but not "list".
|
||||
'''
|
||||
action_to_subactions = borgmatic.commands.arguments.get_subactions_for_actions(
|
||||
subparsers.choices
|
||||
subparsers.choices,
|
||||
)
|
||||
current_subactions = action_to_subactions.get(current_action)
|
||||
|
||||
if current_subactions:
|
||||
return current_subactions
|
||||
|
||||
all_subactions = set(
|
||||
all_subactions = {
|
||||
subaction for subactions in action_to_subactions.values() for subaction in subactions
|
||||
)
|
||||
}
|
||||
|
||||
return tuple(action for action in subparsers.choices.keys() if action not in all_subactions)
|
||||
return tuple(action for action in subparsers.choices if action not in all_subactions)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import borgmatic.commands.arguments
|
||||
import borgmatic.commands.completion.actions
|
||||
import borgmatic.commands.completion.flag
|
||||
import borgmatic.config.validate
|
||||
|
||||
|
||||
def parser_flags(parser):
|
||||
@@ -7,7 +9,12 @@ def parser_flags(parser):
|
||||
Given an argparse.ArgumentParser instance, return its argument flags in a space-separated
|
||||
string.
|
||||
'''
|
||||
return ' '.join(option for action in parser._actions for option in action.option_strings)
|
||||
return ' '.join(
|
||||
flag_variant
|
||||
for action in parser._actions
|
||||
for flag_name in action.option_strings
|
||||
for flag_variant in borgmatic.commands.completion.flag.variants(flag_name)
|
||||
)
|
||||
|
||||
|
||||
def bash_completion():
|
||||
@@ -16,13 +23,17 @@ def bash_completion():
|
||||
borgmatic's command-line argument parsers.
|
||||
'''
|
||||
(
|
||||
unused_global_parser,
|
||||
_,
|
||||
action_parsers,
|
||||
global_plus_action_parser,
|
||||
) = borgmatic.commands.arguments.make_parsers()
|
||||
) = borgmatic.commands.arguments.make_parsers(
|
||||
schema=borgmatic.config.validate.load_schema(borgmatic.config.validate.schema_filename()),
|
||||
unparsed_arguments=(),
|
||||
)
|
||||
global_flags = parser_flags(global_plus_action_parser)
|
||||
|
||||
# Avert your eyes.
|
||||
# fmt: off
|
||||
return '\n'.join(
|
||||
(
|
||||
'check_version() {',
|
||||
@@ -37,24 +48,22 @@ def bash_completion():
|
||||
' fi',
|
||||
'}',
|
||||
'complete_borgmatic() {',
|
||||
)
|
||||
+ tuple(
|
||||
*tuple(
|
||||
''' if [[ " ${COMP_WORDS[*]} " =~ " %s " ]]; then
|
||||
COMPREPLY=($(compgen -W "%s %s %s" -- "${COMP_WORDS[COMP_CWORD]}"))
|
||||
return 0
|
||||
fi'''
|
||||
fi''' # noqa: UP031
|
||||
% (
|
||||
action,
|
||||
parser_flags(action_parser),
|
||||
' '.join(
|
||||
borgmatic.commands.completion.actions.available_actions(action_parsers, action)
|
||||
borgmatic.commands.completion.actions.available_actions(action_parsers, action),
|
||||
),
|
||||
global_flags,
|
||||
)
|
||||
for action, action_parser in reversed(action_parsers.choices.items())
|
||||
)
|
||||
+ (
|
||||
' COMPREPLY=($(compgen -W "%s %s" -- "${COMP_WORDS[COMP_CWORD]}"))' # noqa: FS003
|
||||
),
|
||||
' COMPREPLY=($(compgen -W "%s %s" -- "${COMP_WORDS[COMP_CWORD]}"))' # noqa: UP031
|
||||
% (
|
||||
' '.join(borgmatic.commands.completion.actions.available_actions(action_parsers)),
|
||||
global_flags,
|
||||
@@ -62,5 +71,5 @@ def bash_completion():
|
||||
' (check_version &)',
|
||||
'}',
|
||||
'\ncomplete -o bashdefault -o default -F complete_borgmatic borgmatic',
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
@@ -4,16 +4,17 @@ from textwrap import dedent
|
||||
|
||||
import borgmatic.commands.arguments
|
||||
import borgmatic.commands.completion.actions
|
||||
import borgmatic.config.validate
|
||||
|
||||
|
||||
def has_file_options(action: Action):
|
||||
'''
|
||||
Given an argparse.Action instance, return True if it takes a file argument.
|
||||
'''
|
||||
return action.metavar in (
|
||||
return action.metavar in {
|
||||
'FILENAME',
|
||||
'PATH',
|
||||
) or action.dest in ('config_paths',)
|
||||
} or action.dest in {'config_paths'}
|
||||
|
||||
|
||||
def has_choice_options(action: Action):
|
||||
@@ -26,18 +27,20 @@ def has_choice_options(action: Action):
|
||||
def has_unknown_required_param_options(action: Action):
|
||||
'''
|
||||
A catch-all for options that take a required parameter, but we don't know what the parameter is.
|
||||
This should be used last. These are actions that take something like a glob, a list of numbers, or a string.
|
||||
This should be used last. These are actions that take something like a glob, a list of numbers,
|
||||
or a string.
|
||||
|
||||
Actions that match this pattern should not show the normal arguments, because those are unlikely to be valid.
|
||||
Actions that match this pattern should not show the normal arguments, because those are unlikely
|
||||
to be valid.
|
||||
'''
|
||||
return (
|
||||
action.required is True
|
||||
or action.nargs
|
||||
in (
|
||||
in {
|
||||
'+',
|
||||
'*',
|
||||
)
|
||||
or action.metavar in ('PATTERN', 'KEYS', 'N')
|
||||
}
|
||||
or action.metavar in {'PATTERN', 'KEYS', 'N'}
|
||||
or (action.type is not None and action.default is None)
|
||||
)
|
||||
|
||||
@@ -52,9 +55,9 @@ def has_exact_options(action: Action):
|
||||
|
||||
def exact_options_completion(action: Action):
|
||||
'''
|
||||
Given an argparse.Action instance, return a completion invocation that forces file completions, options completion,
|
||||
or just that some value follow the action, if the action takes such an argument and was the last action on the
|
||||
command line prior to the cursor.
|
||||
Given an argparse.Action instance, return a completion invocation that forces file completions,
|
||||
options completion, or just that some value follow the action, if the action takes such an
|
||||
argument and was the last action on the command line prior to the cursor.
|
||||
|
||||
Otherwise, return an empty string.
|
||||
'''
|
||||
@@ -74,14 +77,15 @@ def exact_options_completion(action: Action):
|
||||
return f'''\ncomplete -c borgmatic -x -n "__borgmatic_current_arg {args}"'''
|
||||
|
||||
raise ValueError(
|
||||
f'Unexpected action: {action} passes has_exact_options but has no choices produced'
|
||||
f'Unexpected action: {action} passes has_exact_options but has no choices produced',
|
||||
)
|
||||
|
||||
|
||||
def dedent_strip_as_tuple(string: str):
|
||||
'''
|
||||
Dedent a string, then strip it to avoid requiring your first line to have content, then return a tuple of the string.
|
||||
Makes it easier to write multiline strings for completions when you join them with a tuple.
|
||||
Dedent a string, then strip it to avoid requiring your first line to have content, then return a
|
||||
tuple of the string. Makes it easier to write multiline strings for completions when you join
|
||||
them with a tuple.
|
||||
'''
|
||||
return (dedent(string).strip('\n'),)
|
||||
|
||||
@@ -92,12 +96,15 @@ def fish_completion():
|
||||
borgmatic's command-line argument parsers.
|
||||
'''
|
||||
(
|
||||
unused_global_parser,
|
||||
_,
|
||||
action_parsers,
|
||||
global_plus_action_parser,
|
||||
) = borgmatic.commands.arguments.make_parsers()
|
||||
) = borgmatic.commands.arguments.make_parsers(
|
||||
schema=borgmatic.config.validate.load_schema(borgmatic.config.validate.schema_filename()),
|
||||
unparsed_arguments=(),
|
||||
)
|
||||
|
||||
all_action_parsers = ' '.join(action for action in action_parsers.choices.keys())
|
||||
all_action_parsers = ' '.join(action for action in action_parsers.choices)
|
||||
|
||||
exact_option_args = tuple(
|
||||
' '.join(action.option_strings)
|
||||
@@ -112,8 +119,9 @@ def fish_completion():
|
||||
)
|
||||
|
||||
# Avert your eyes.
|
||||
return '\n'.join(
|
||||
dedent_strip_as_tuple(
|
||||
# fmt: off
|
||||
return '\n'.join((
|
||||
*dedent_strip_as_tuple(
|
||||
f'''
|
||||
function __borgmatic_check_version
|
||||
set -fx this_filename (status current-filename)
|
||||
@@ -150,27 +158,27 @@ def fish_completion():
|
||||
|
||||
set --local action_parser_condition "not __fish_seen_subcommand_from {all_action_parsers}"
|
||||
set --local exact_option_condition "not __borgmatic_current_arg {' '.join(exact_option_args)}"
|
||||
'''
|
||||
)
|
||||
+ ('\n# action_parser completions',)
|
||||
+ tuple(
|
||||
''',
|
||||
),
|
||||
'\n# action_parser completions',
|
||||
*tuple(
|
||||
f'''complete -c borgmatic -f -n "$action_parser_condition" -n "$exact_option_condition" -a '{action_name}' -d {shlex.quote(action_parser.description)}'''
|
||||
for action_name, action_parser in action_parsers.choices.items()
|
||||
)
|
||||
+ ('\n# global flags',)
|
||||
+ tuple(
|
||||
),
|
||||
'\n# global flags',
|
||||
*tuple(
|
||||
# -n is checked in order, so put faster / more likely to be true checks first
|
||||
f'''complete -c borgmatic -f -n "$exact_option_condition" -a '{' '.join(action.option_strings)}' -d {shlex.quote(action.help)}{exact_options_completion(action)}'''
|
||||
for action in global_plus_action_parser._actions
|
||||
# ignore the noargs action, as this is an impossible completion for fish
|
||||
if len(action.option_strings) > 0
|
||||
if 'Deprecated' not in action.help
|
||||
)
|
||||
+ ('\n# action_parser flags',)
|
||||
+ tuple(
|
||||
),
|
||||
'\n# action_parser flags',
|
||||
*tuple(
|
||||
f'''complete -c borgmatic -f -n "$exact_option_condition" -a '{' '.join(action.option_strings)}' -d {shlex.quote(action.help)} -n "__fish_seen_subcommand_from {action_name}"{exact_options_completion(action)}'''
|
||||
for action_name, action_parser in action_parsers.choices.items()
|
||||
for action in action_parser._actions
|
||||
if 'Deprecated' not in (action.help or ())
|
||||
)
|
||||
)
|
||||
),
|
||||
))
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
def variants(flag_name):
|
||||
'''
|
||||
Given a flag name as a string, yield it and any variations that should be complete-able as well.
|
||||
For instance, for a string like "--foo[0].bar", yield "--foo[0].bar", "--foo[1].bar", ...,
|
||||
"--foo[9].bar".
|
||||
'''
|
||||
if '[0]' in flag_name:
|
||||
for index in range(10):
|
||||
yield flag_name.replace('[0]', f'[{index}]')
|
||||
|
||||
return
|
||||
|
||||
yield flag_name
|
||||
@@ -10,8 +10,8 @@ def main():
|
||||
levelno=logging.WARNING,
|
||||
levelname='WARNING',
|
||||
msg='generate-borgmatic-config is deprecated and will be removed from a future release. Please use "borgmatic config generate" instead.',
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
sys.argv = ['borgmatic', 'config', 'generate'] + sys.argv[1:]
|
||||
sys.argv = ['borgmatic', 'config', 'generate', *sys.argv[1:]]
|
||||
borgmatic.commands.borgmatic.main([warning_log])
|
||||
|
||||
@@ -10,8 +10,8 @@ def main():
|
||||
levelno=logging.WARNING,
|
||||
levelname='WARNING',
|
||||
msg='validate-borgmatic-config is deprecated and will be removed from a future release. Please use "borgmatic config validate" instead.',
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
sys.argv = ['borgmatic', 'config', 'validate'] + sys.argv[1:]
|
||||
sys.argv = ['borgmatic', 'config', 'validate', *sys.argv[1:]]
|
||||
borgmatic.commands.borgmatic.main([warning_log])
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import io
|
||||
import re
|
||||
|
||||
import ruamel.yaml
|
||||
|
||||
import borgmatic.config.schema
|
||||
|
||||
LIST_INDEX_KEY_PATTERN = re.compile(r'^(?P<list_name>[a-zA-z-]+)\[(?P<index>\d+)\]$')
|
||||
|
||||
|
||||
def set_values(config, keys, value):
|
||||
'''
|
||||
Given a configuration dict, a sequence of parsed key strings, and a string value, descend into
|
||||
the configuration hierarchy based on the given keys and set the value into the right place.
|
||||
For example, consider these keys:
|
||||
|
||||
('foo', 'bar', 'baz')
|
||||
|
||||
This looks up "foo" in the given configuration dict. And within that, it looks up "bar". And
|
||||
then within that, it looks up "baz" and sets it to the given value. Another example:
|
||||
|
||||
('mylist[0]', 'foo')
|
||||
|
||||
This looks for the zeroth element of "mylist" in the given configuration. And within that, it
|
||||
looks up "foo" and sets it to the given value.
|
||||
'''
|
||||
if not keys:
|
||||
return
|
||||
|
||||
first_key = keys[0]
|
||||
|
||||
# Support "mylist[0]" list index syntax.
|
||||
match = LIST_INDEX_KEY_PATTERN.match(first_key)
|
||||
|
||||
if match:
|
||||
list_key = match.group('list_name')
|
||||
list_index = int(match.group('index'))
|
||||
|
||||
try:
|
||||
if len(keys) == 1:
|
||||
config[list_key][list_index] = value
|
||||
|
||||
return
|
||||
|
||||
if list_key not in config:
|
||||
config[list_key] = []
|
||||
|
||||
set_values(config[list_key][list_index], keys[1:], value)
|
||||
except (IndexError, KeyError):
|
||||
raise ValueError(f'Argument list index {first_key} is out of range')
|
||||
|
||||
return
|
||||
|
||||
if len(keys) == 1:
|
||||
config[first_key] = value
|
||||
|
||||
return
|
||||
|
||||
if first_key not in config:
|
||||
config[first_key] = {}
|
||||
|
||||
set_values(config[first_key], keys[1:], value)
|
||||
|
||||
|
||||
def type_for_option(schema, option_keys):
|
||||
'''
|
||||
Given a configuration schema dict and a sequence of keys identifying a potentially nested
|
||||
option, e.g. ('extra_borg_options', 'create'), return the schema type of that option as a
|
||||
string.
|
||||
|
||||
Return None if the option or its type cannot be found in the schema.
|
||||
'''
|
||||
option_schema = schema
|
||||
|
||||
for key in option_keys:
|
||||
# Support "name[0]"-style list index syntax.
|
||||
match = LIST_INDEX_KEY_PATTERN.match(key)
|
||||
properties = borgmatic.config.schema.get_properties(option_schema)
|
||||
|
||||
try:
|
||||
if match:
|
||||
option_schema = properties[match.group('list_name')]['items']
|
||||
else:
|
||||
option_schema = properties[key]
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
try:
|
||||
return option_schema['type']
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
|
||||
def convert_value_type(value, option_type):
|
||||
'''
|
||||
Given a string value and its schema type as a string, determine its logical type (string,
|
||||
boolean, integer, etc.), and return it converted to that type.
|
||||
|
||||
If the destination option type is a string, then leave the value as-is so that special
|
||||
characters in it don't get interpreted as YAML during conversion.
|
||||
|
||||
And if the source value isn't a string, return it as-is.
|
||||
|
||||
Raise ruamel.yaml.error.YAMLError if there's a parse issue with the YAML.
|
||||
Raise ValueError if the parsed value doesn't match the option type.
|
||||
'''
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
|
||||
if option_type == 'string':
|
||||
return value
|
||||
|
||||
try:
|
||||
parsed_value = ruamel.yaml.YAML(typ='safe').load(io.StringIO(value))
|
||||
except ruamel.yaml.error.YAMLError as error:
|
||||
raise ValueError(f'Argument value "{value}" is invalid: {error.problem}')
|
||||
|
||||
if not isinstance(parsed_value, borgmatic.config.schema.parse_type(option_type)):
|
||||
raise ValueError(f'Argument value "{value}" is not of the expected type: {option_type}')
|
||||
|
||||
return parsed_value
|
||||
|
||||
|
||||
def prepare_arguments_for_config(global_arguments, schema):
|
||||
'''
|
||||
Given global arguments as an argparse.Namespace and a configuration schema dict, parse each
|
||||
argument that corresponds to an option in the schema and return a sequence of tuples (keys,
|
||||
values) for that option, where keys is a sequence of strings. For instance, given the following
|
||||
arguments:
|
||||
|
||||
argparse.Namespace(**{'my_option.sub_option': 'value1', 'other_option': 'value2'})
|
||||
|
||||
... return this:
|
||||
|
||||
(
|
||||
(('my_option', 'sub_option'), 'value1'),
|
||||
(('other_option',), 'value2'),
|
||||
)
|
||||
'''
|
||||
prepared_values = []
|
||||
|
||||
for argument_name, value in global_arguments.__dict__.items():
|
||||
if value is None:
|
||||
continue
|
||||
|
||||
keys = tuple(argument_name.split('.'))
|
||||
option_type = type_for_option(schema, keys)
|
||||
|
||||
# The argument doesn't correspond to any option in the schema, or it is a complex argument, so ignore it.
|
||||
# It's probably a flag that borgmatic has on the command-line but not in configuration.
|
||||
if option_type in {'object', None}:
|
||||
continue
|
||||
|
||||
prepared_values.append(
|
||||
(
|
||||
keys,
|
||||
convert_value_type(value, option_type),
|
||||
),
|
||||
)
|
||||
|
||||
return tuple(prepared_values)
|
||||
|
||||
|
||||
def apply_arguments_to_config(config, schema, arguments):
|
||||
'''
|
||||
Given a configuration dict, a corresponding configuration schema dict, and arguments as a dict
|
||||
from action name to argparse.Namespace, set those given argument values into their corresponding
|
||||
configuration options in the configuration dict.
|
||||
|
||||
This supports argument flags of the from "--foo.bar.baz" where each dotted component is a nested
|
||||
configuration object. Additionally, flags like "--foo.bar[0].baz" are supported to update a list
|
||||
element in the configuration.
|
||||
'''
|
||||
for action_arguments in arguments.values():
|
||||
for keys, value in prepare_arguments_for_config(action_arguments, schema):
|
||||
set_values(config, keys, value)
|
||||
@@ -49,6 +49,6 @@ def collect_config_filenames(config_paths):
|
||||
|
||||
for filename in sorted(os.listdir(path)):
|
||||
full_filename = os.path.join(path, filename)
|
||||
matching_filetype = full_filename.endswith('.yaml') or full_filename.endswith('.yml')
|
||||
matching_filetype = full_filename.endswith(('.yaml', '.yml'))
|
||||
if matching_filetype and not os.path.isdir(full_filename):
|
||||
yield os.path.abspath(full_filename)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import contextlib
|
||||
import shlex
|
||||
|
||||
|
||||
@@ -6,17 +7,18 @@ def coerce_scalar(value):
|
||||
Given a configuration value, coerce it to an integer or a boolean as appropriate and return the
|
||||
result.
|
||||
'''
|
||||
try:
|
||||
with contextlib.suppress(TypeError, ValueError):
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
if value == 'true' or value == 'True':
|
||||
return True
|
||||
if value == 'false' or value == 'False':
|
||||
return False
|
||||
|
||||
return value
|
||||
try:
|
||||
return {
|
||||
'true': True,
|
||||
'True': True,
|
||||
'false': False,
|
||||
'False': False,
|
||||
}.get(value, value)
|
||||
except TypeError: # e.g. for an unhashable type
|
||||
return value
|
||||
|
||||
|
||||
def apply_constants(value, constants, shell_escape=False):
|
||||
@@ -55,8 +57,7 @@ def apply_constants(value, constants, shell_escape=False):
|
||||
constants,
|
||||
shell_escape=(
|
||||
shell_escape
|
||||
or option_name.startswith('before_')
|
||||
or option_name.startswith('after_')
|
||||
or option_name.startswith(('before_', 'after_'))
|
||||
or option_name == 'on_error'
|
||||
),
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@ import os
|
||||
import re
|
||||
|
||||
VARIABLE_PATTERN = re.compile(
|
||||
r'(?P<escape>\\)?(?P<variable>\$\{(?P<name>[A-Za-z0-9_]+)((:?-)(?P<default>[^}]+))?\})'
|
||||
r'(?P<escape>\\)?(?P<variable>\$\{(?P<name>[A-Za-z0-9_]+)((:?-)(?P<default>[^}]+))?\})',
|
||||
)
|
||||
|
||||
|
||||
|
||||
+112
-70
@@ -1,10 +1,12 @@
|
||||
import collections
|
||||
import contextlib
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
|
||||
import ruamel.yaml
|
||||
|
||||
import borgmatic.config.schema
|
||||
from borgmatic.config import load, normalize
|
||||
|
||||
INDENT = 4
|
||||
@@ -17,49 +19,74 @@ def insert_newline_before_comment(config, field_name):
|
||||
field and its comments.
|
||||
'''
|
||||
config.ca.items[field_name][1].insert(
|
||||
0, ruamel.yaml.tokens.CommentToken('\n', ruamel.yaml.error.CommentMark(0), None)
|
||||
0,
|
||||
ruamel.yaml.tokens.CommentToken('\n', ruamel.yaml.error.CommentMark(0), None),
|
||||
)
|
||||
|
||||
|
||||
def get_properties(schema):
|
||||
'''
|
||||
Given a schema dict, return its properties. But if it's got sub-schemas with multiple different
|
||||
potential properties, returned their merged properties instead.
|
||||
'''
|
||||
if 'oneOf' in schema:
|
||||
return dict(
|
||||
collections.ChainMap(*[sub_schema['properties'] for sub_schema in schema['oneOf']])
|
||||
)
|
||||
|
||||
return schema['properties']
|
||||
SCALAR_SCHEMA_TYPES = {'string', 'boolean', 'integer', 'number'}
|
||||
|
||||
|
||||
def schema_to_sample_configuration(schema, level=0, parent_is_sequence=False):
|
||||
def schema_to_sample_configuration(schema, source_config=None, level=0, parent_is_sequence=False):
|
||||
'''
|
||||
Given a loaded configuration schema, generate and return sample config for it. Include comments
|
||||
for each option based on the schema "description".
|
||||
Given a loaded configuration schema and a source configuration, generate and return sample
|
||||
config for the schema. Include comments for each option based on the schema "description".
|
||||
|
||||
If a source config is given, walk it alongside the given schema so that both can be taken into
|
||||
account when commenting out particular options in add_comments_to_configuration_object().
|
||||
'''
|
||||
schema_type = schema.get('type')
|
||||
example = schema.get('example')
|
||||
if example is not None:
|
||||
return example
|
||||
|
||||
if schema_type == 'array' or (isinstance(schema_type, list) and 'array' in schema_type):
|
||||
if borgmatic.config.schema.compare_types(schema_type, {'array'}):
|
||||
config = ruamel.yaml.comments.CommentedSeq(
|
||||
[schema_to_sample_configuration(schema['items'], level, parent_is_sequence=True)]
|
||||
example
|
||||
if borgmatic.config.schema.compare_types(
|
||||
schema['items'].get('type'),
|
||||
SCALAR_SCHEMA_TYPES,
|
||||
)
|
||||
else [
|
||||
schema_to_sample_configuration(
|
||||
schema['items'],
|
||||
source_config,
|
||||
level,
|
||||
parent_is_sequence=True,
|
||||
),
|
||||
],
|
||||
)
|
||||
add_comments_to_configuration_sequence(config, schema, indent=(level * INDENT))
|
||||
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))
|
||||
for field_name, sub_schema in get_properties(schema).items()
|
||||
]
|
||||
elif borgmatic.config.schema.compare_types(schema_type, {'object'}):
|
||||
if source_config and isinstance(source_config, list) and isinstance(source_config[0], dict):
|
||||
source_config = source_config[0]
|
||||
|
||||
config = (
|
||||
ruamel.yaml.comments.CommentedMap(
|
||||
[
|
||||
(
|
||||
field_name,
|
||||
schema_to_sample_configuration(
|
||||
sub_schema,
|
||||
(source_config or {}).get(field_name, {}),
|
||||
level + 1,
|
||||
),
|
||||
)
|
||||
for field_name, sub_schema in borgmatic.config.schema.get_properties(
|
||||
schema,
|
||||
).items()
|
||||
],
|
||||
)
|
||||
or example
|
||||
)
|
||||
indent = (level * INDENT) + (SEQUENCE_INDENT if parent_is_sequence else 0)
|
||||
add_comments_to_configuration_object(
|
||||
config, schema, indent=indent, skip_first=parent_is_sequence
|
||||
config,
|
||||
schema,
|
||||
source_config,
|
||||
indent=indent,
|
||||
skip_first_field=parent_is_sequence,
|
||||
)
|
||||
elif borgmatic.config.schema.compare_types(schema_type, SCALAR_SCHEMA_TYPES, match=all):
|
||||
return example
|
||||
else:
|
||||
raise ValueError(f'Schema at level {level} is unsupported: {schema}')
|
||||
|
||||
@@ -93,17 +120,26 @@ def comment_out_optional_configuration(rendered_config):
|
||||
'''
|
||||
lines = []
|
||||
optional = False
|
||||
indent_characters = None
|
||||
indent_characters_at_sentinel = None
|
||||
|
||||
for line in rendered_config.split('\n'):
|
||||
indent_characters = len(line) - len(line.lstrip())
|
||||
|
||||
# Upon encountering an optional configuration option, comment out lines until the next blank
|
||||
# line.
|
||||
if line.strip().startswith(f'# {COMMENTED_OUT_SENTINEL}'):
|
||||
optional = True
|
||||
indent_characters_at_sentinel = indent_characters
|
||||
continue
|
||||
|
||||
# Hit a blank line, so reset commenting.
|
||||
if not line.strip():
|
||||
# Hit a blank line or dedented, so reset commenting.
|
||||
if not line.strip() or (
|
||||
indent_characters_at_sentinel is not None
|
||||
and indent_characters < indent_characters_at_sentinel
|
||||
):
|
||||
optional = False
|
||||
indent_characters_at_sentinel = None
|
||||
|
||||
lines.append(comment_out_line(line) if optional else line)
|
||||
|
||||
@@ -130,15 +166,13 @@ def write_configuration(config_filename, rendered_config, mode=0o600, overwrite=
|
||||
'''
|
||||
if not overwrite and os.path.exists(config_filename):
|
||||
raise FileExistsError(
|
||||
f'{config_filename} already exists. Aborting. Use --overwrite to replace the file.'
|
||||
f'{config_filename} already exists. Aborting. Use --overwrite to replace the file.',
|
||||
)
|
||||
|
||||
try:
|
||||
with contextlib.suppress(FileExistsError, FileNotFoundError):
|
||||
os.makedirs(os.path.dirname(config_filename), mode=0o700)
|
||||
except (FileExistsError, FileNotFoundError):
|
||||
pass
|
||||
|
||||
with open(config_filename, 'w') as config_file:
|
||||
with open(config_filename, 'w', encoding='utf-8') as config_file:
|
||||
config_file.write(rendered_config)
|
||||
|
||||
os.chmod(config_filename, mode)
|
||||
@@ -163,8 +197,8 @@ def add_comments_to_configuration_sequence(config, schema, indent=0):
|
||||
if schema['items'].get('type') != 'object':
|
||||
return
|
||||
|
||||
for field_name in config[0].keys():
|
||||
field_schema = get_properties(schema['items']).get(field_name, {})
|
||||
for field_name in config[0]:
|
||||
field_schema = borgmatic.config.schema.get_properties(schema['items']).get(field_name, {})
|
||||
description = field_schema.get('description')
|
||||
|
||||
# No description to use? Skip it.
|
||||
@@ -178,28 +212,44 @@ def add_comments_to_configuration_sequence(config, schema, indent=0):
|
||||
return
|
||||
|
||||
|
||||
REQUIRED_KEYS = {'source_directories', 'repositories', 'keep_daily'}
|
||||
DEFAULT_KEYS = {'source_directories', 'repositories', 'keep_daily'}
|
||||
COMMENTED_OUT_SENTINEL = 'COMMENT_OUT'
|
||||
|
||||
|
||||
def add_comments_to_configuration_object(config, schema, indent=0, skip_first=False):
|
||||
def add_comments_to_configuration_object(
|
||||
config,
|
||||
schema,
|
||||
source_config=None,
|
||||
indent=0,
|
||||
skip_first_field=False,
|
||||
):
|
||||
'''
|
||||
Using descriptions from a schema as a source, add those descriptions as comments to the given
|
||||
config mapping, before each field. Indent the comment the given number of characters.
|
||||
configuration dict, putting them before each field. Indent the comment the given number of
|
||||
characters.
|
||||
|
||||
If skip_first_field is True, omit the comment for the initial field. This is useful for
|
||||
sequences, where the comment for the first field goes before the sequence itself.
|
||||
|
||||
And a sentinel for commenting out options that are neither in DEFAULT_KEYS nor the the given
|
||||
source configuration dict. The idea is that any options used in the source configuration should
|
||||
stay active in the generated configuration.
|
||||
'''
|
||||
for index, field_name in enumerate(config.keys()):
|
||||
if skip_first and index == 0:
|
||||
if skip_first_field and index == 0:
|
||||
continue
|
||||
|
||||
field_schema = get_properties(schema).get(field_name, {})
|
||||
field_schema = borgmatic.config.schema.get_properties(schema).get(field_name, {})
|
||||
description = field_schema.get('description', '').strip()
|
||||
|
||||
# If this is an optional key, add an indicator to the comment flagging it to be commented
|
||||
# If this isn't a default key, add an indicator to the comment, flagging it to be commented
|
||||
# out from the sample configuration. This sentinel is consumed by downstream processing that
|
||||
# does the actual commenting out.
|
||||
if field_name not in REQUIRED_KEYS:
|
||||
if field_name not in DEFAULT_KEYS and (
|
||||
source_config is None or field_name not in source_config
|
||||
):
|
||||
description = (
|
||||
'\n'.join((description, COMMENTED_OUT_SENTINEL))
|
||||
f'{description}\n{COMMENTED_OUT_SENTINEL}'
|
||||
if description
|
||||
else COMMENTED_OUT_SENTINEL
|
||||
)
|
||||
@@ -217,21 +267,6 @@ def add_comments_to_configuration_object(config, schema, indent=0, skip_first=Fa
|
||||
RUAMEL_YAML_COMMENTS_INDEX = 1
|
||||
|
||||
|
||||
def remove_commented_out_sentinel(config, field_name):
|
||||
'''
|
||||
Given a configuration CommentedMap and a top-level field name in it, remove any "commented out"
|
||||
sentinel found at the end of its YAML comments. This prevents the given field name from getting
|
||||
commented out by downstream processing that consumes the sentinel.
|
||||
'''
|
||||
try:
|
||||
last_comment_value = config.ca.items[field_name][RUAMEL_YAML_COMMENTS_INDEX][-1].value
|
||||
except KeyError:
|
||||
return
|
||||
|
||||
if last_comment_value == f'# {COMMENTED_OUT_SENTINEL}\n':
|
||||
config.ca.items[field_name][RUAMEL_YAML_COMMENTS_INDEX].pop()
|
||||
|
||||
|
||||
def merge_source_configuration_into_destination(destination_config, source_config):
|
||||
'''
|
||||
Deep merge the given source configuration dict into the destination configuration CommentedMap,
|
||||
@@ -242,20 +277,16 @@ def merge_source_configuration_into_destination(destination_config, source_confi
|
||||
'''
|
||||
if not source_config:
|
||||
return destination_config
|
||||
|
||||
if not destination_config or not isinstance(source_config, collections.abc.Mapping):
|
||||
return source_config
|
||||
|
||||
for field_name, source_value in source_config.items():
|
||||
# Since this key/value is from the source configuration, leave it uncommented and remove any
|
||||
# sentinel that would cause it to get commented out.
|
||||
remove_commented_out_sentinel(
|
||||
ruamel.yaml.comments.CommentedMap(destination_config), field_name
|
||||
)
|
||||
|
||||
# This is a mapping. Recurse for this key/value.
|
||||
if isinstance(source_value, collections.abc.Mapping):
|
||||
destination_config[field_name] = merge_source_configuration_into_destination(
|
||||
destination_config[field_name], source_value
|
||||
destination_config[field_name],
|
||||
source_value,
|
||||
)
|
||||
continue
|
||||
|
||||
@@ -269,18 +300,22 @@ def merge_source_configuration_into_destination(destination_config, source_confi
|
||||
source_item,
|
||||
)
|
||||
for index, source_item in enumerate(source_value)
|
||||
]
|
||||
],
|
||||
)
|
||||
continue
|
||||
|
||||
# This is some sort of scalar. Set it into the destination.
|
||||
destination_config[field_name] = source_config[field_name]
|
||||
destination_config[field_name] = source_value
|
||||
|
||||
return destination_config
|
||||
|
||||
|
||||
def generate_sample_configuration(
|
||||
dry_run, source_filename, destination_filename, schema_filename, overwrite=False
|
||||
dry_run,
|
||||
source_filename,
|
||||
destination_filename,
|
||||
schema_filename,
|
||||
overwrite=False,
|
||||
):
|
||||
'''
|
||||
Given an optional source configuration filename, and a required destination configuration
|
||||
@@ -289,15 +324,22 @@ def generate_sample_configuration(
|
||||
schema. If a source filename is provided, merge the parsed contents of that configuration into
|
||||
the generated configuration.
|
||||
'''
|
||||
schema = ruamel.yaml.YAML(typ='safe').load(open(schema_filename))
|
||||
schema = ruamel.yaml.YAML(typ='safe').load(open(schema_filename, encoding='utf-8'))
|
||||
source_config = None
|
||||
|
||||
if source_filename:
|
||||
source_config = load.load_configuration(source_filename)
|
||||
normalize.normalize(source_filename, source_config)
|
||||
|
||||
# The borgmatic.config.normalize.normalize() function tacks on an empty "bootstrap" if
|
||||
# needed, so the hook gets used by default. But we don't want it to end up in the generated
|
||||
# config unless the user has set it explicitly, as an empty "bootstrap:" won't validate.
|
||||
if source_config and source_config.get('bootstrap') == {}:
|
||||
del source_config['bootstrap']
|
||||
|
||||
destination_config = merge_source_configuration_into_destination(
|
||||
schema_to_sample_configuration(schema), source_config
|
||||
schema_to_sample_configuration(schema, source_config),
|
||||
source_config,
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
|
||||
+21
-16
@@ -31,7 +31,7 @@ def probe_and_include_file(filename, include_directories, config_paths):
|
||||
return load_configuration(candidate_filename, config_paths)
|
||||
|
||||
raise FileNotFoundError(
|
||||
f'Could not find include {filename} at {" or ".join(candidate_filenames)}'
|
||||
f'Could not find include {filename} at {" or ".join(candidate_filenames)}',
|
||||
)
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ def include_configuration(loader, filename_node, include_directory, config_paths
|
||||
]
|
||||
|
||||
raise ValueError(
|
||||
'The value given for the !include tag is invalid; use a single filename or a list of filenames instead'
|
||||
'The value given for the !include tag is invalid; use a single filename or a list of filenames instead',
|
||||
)
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ def raise_retain_node_error(loader, node):
|
||||
'''
|
||||
if isinstance(node, (ruamel.yaml.nodes.MappingNode, ruamel.yaml.nodes.SequenceNode)):
|
||||
raise ValueError(
|
||||
'The !retain tag may only be used within a configuration file containing a merged !include tag.'
|
||||
'The !retain tag may only be used within a configuration file containing a merged !include tag.',
|
||||
)
|
||||
|
||||
raise ValueError('The !retain tag may only be used on a mapping or list.')
|
||||
@@ -100,7 +100,7 @@ def raise_omit_node_error(loader, node):
|
||||
tags are handled by deep_merge_nodes() below.
|
||||
'''
|
||||
raise ValueError(
|
||||
'The !omit tag may only be used on a scalar (e.g., string) or list element within a configuration file containing a merged !include tag.'
|
||||
'The !omit tag may only be used on a scalar (e.g., string) or list element within a configuration file containing a merged !include tag.',
|
||||
)
|
||||
|
||||
|
||||
@@ -111,9 +111,13 @@ class Include_constructor(ruamel.yaml.SafeConstructor):
|
||||
'''
|
||||
|
||||
def __init__(
|
||||
self, preserve_quotes=None, loader=None, include_directory=None, config_paths=None
|
||||
self,
|
||||
preserve_quotes=None,
|
||||
loader=None,
|
||||
include_directory=None,
|
||||
config_paths=None,
|
||||
):
|
||||
super(Include_constructor, self).__init__(preserve_quotes, loader)
|
||||
super().__init__(preserve_quotes, loader)
|
||||
self.add_constructor(
|
||||
'!include',
|
||||
functools.partial(
|
||||
@@ -147,7 +151,7 @@ class Include_constructor(ruamel.yaml.SafeConstructor):
|
||||
representer = ruamel.yaml.representer.SafeRepresenter()
|
||||
|
||||
for index, (key_node, value_node) in enumerate(node.value):
|
||||
if key_node.tag == u'tag:yaml.org,2002:merge' and value_node.tag == '!include':
|
||||
if key_node.tag == 'tag:yaml.org,2002:merge' and value_node.tag == '!include':
|
||||
# Replace the merge include with a sequence of included configuration nodes ready
|
||||
# for merging. The construct_object() call here triggers include_configuration()
|
||||
# among other constructors.
|
||||
@@ -157,7 +161,7 @@ class Include_constructor(ruamel.yaml.SafeConstructor):
|
||||
)
|
||||
|
||||
# This super().flatten_mapping() call actually performs "<<" merges.
|
||||
super(Include_constructor, self).flatten_mapping(node)
|
||||
super().flatten_mapping(node)
|
||||
|
||||
node.value = deep_merge_nodes(node.value)
|
||||
|
||||
@@ -179,7 +183,7 @@ def load_configuration(filename, config_paths=None):
|
||||
# because yaml.Constructor has to be an actual class.)
|
||||
class Include_constructor_with_extras(Include_constructor):
|
||||
def __init__(self, preserve_quotes=None, loader=None):
|
||||
super(Include_constructor_with_extras, self).__init__(
|
||||
super().__init__(
|
||||
preserve_quotes,
|
||||
loader,
|
||||
include_directory=os.path.dirname(filename),
|
||||
@@ -190,7 +194,7 @@ def load_configuration(filename, config_paths=None):
|
||||
yaml.Constructor = Include_constructor_with_extras
|
||||
config_paths.add(filename)
|
||||
|
||||
with open(filename) as file:
|
||||
with open(filename, encoding='utf-8') as file:
|
||||
return yaml.load(file.read())
|
||||
|
||||
|
||||
@@ -318,17 +322,18 @@ def deep_merge_nodes(nodes):
|
||||
|
||||
# Bucket the nodes by their keys. Then merge all of the values sharing the same key.
|
||||
for key_name, grouped_nodes in itertools.groupby(
|
||||
sorted(nodes, key=get_node_key_name), get_node_key_name
|
||||
sorted(nodes, key=get_node_key_name),
|
||||
get_node_key_name,
|
||||
):
|
||||
grouped_nodes = list(grouped_nodes)
|
||||
grouped_nodes = list(grouped_nodes) # noqa: PLW2901
|
||||
|
||||
# The merged node inherits its attributes from the final node in the group.
|
||||
(last_node_key, last_node_value) = grouped_nodes[-1]
|
||||
value_types = set(type(value) for (_, value) in grouped_nodes)
|
||||
value_types = {type(value) for (_, value) in grouped_nodes}
|
||||
|
||||
if len(value_types) > 1:
|
||||
raise ValueError(
|
||||
f'Incompatible types found when trying to merge "{key_name}:" values across configuration files: {", ".join(value_type.id for value_type in value_types)}'
|
||||
f'Incompatible types found when trying to merge "{key_name}:" values across configuration files: {", ".join(value_type.id for value_type in value_types)}',
|
||||
)
|
||||
|
||||
# If we're dealing with MappingNodes, recurse and merge its values as well.
|
||||
@@ -351,7 +356,7 @@ def deep_merge_nodes(nodes):
|
||||
comment=last_node_value.comment,
|
||||
anchor=last_node_value.anchor,
|
||||
),
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
continue
|
||||
@@ -374,7 +379,7 @@ def deep_merge_nodes(nodes):
|
||||
comment=last_node_value.comment,
|
||||
anchor=last_node_value.anchor,
|
||||
),
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
continue
|
||||
|
||||
+132
-46
@@ -25,12 +25,12 @@ def normalize_sections(config_filename, config):
|
||||
and location.get('prefix') != consistency.get('prefix')
|
||||
):
|
||||
raise ValueError(
|
||||
'The retention prefix and the consistency prefix cannot have different values (unless one is not set).'
|
||||
'The retention prefix and the consistency prefix cannot have different values (unless one is not set).',
|
||||
)
|
||||
|
||||
if storage.get('umask') and hooks.get('umask') and storage.get('umask') != hooks.get('umask'):
|
||||
raise ValueError(
|
||||
'The storage umask and the hooks umask cannot have different values (unless one is not set).'
|
||||
'The storage umask and the hooks umask cannot have different values (unless one is not set).',
|
||||
)
|
||||
|
||||
any_section_upgraded = False
|
||||
@@ -51,14 +51,98 @@ def normalize_sections(config_filename, config):
|
||||
levelno=logging.WARNING,
|
||||
levelname='WARNING',
|
||||
msg=f'{config_filename}: Configuration sections (like location:, storage:, retention:, consistency:, and hooks:) are deprecated and support will be removed from a future release. To prepare for this, move your options out of sections to the global scope.',
|
||||
)
|
||||
)
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def normalize(config_filename, config):
|
||||
def make_command_hook_deprecation_log(config_filename, option_name): # pragma: no cover
|
||||
'''
|
||||
Given a configuration filename and the name of a configuration option, return a deprecation
|
||||
warning log for it.
|
||||
'''
|
||||
return logging.makeLogRecord(
|
||||
dict(
|
||||
levelno=logging.WARNING,
|
||||
levelname='WARNING',
|
||||
msg=f'{config_filename}: {option_name} is deprecated and support will be removed from a future release. Use commands: instead.',
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def normalize_commands(config_filename, config):
|
||||
'''
|
||||
Given a configuration filename and a configuration dict, transform any "before_*"- and
|
||||
"after_*"-style command hooks into "commands:".
|
||||
'''
|
||||
logs = []
|
||||
|
||||
# Normalize "before_actions" and "after_actions".
|
||||
for preposition in ('before', 'after'):
|
||||
option_name = f'{preposition}_actions'
|
||||
commands = config.pop(option_name, None)
|
||||
|
||||
if commands:
|
||||
logs.append(make_command_hook_deprecation_log(config_filename, option_name))
|
||||
config.setdefault('commands', []).append(
|
||||
{
|
||||
preposition: 'repository',
|
||||
'run': commands,
|
||||
},
|
||||
)
|
||||
|
||||
# Normalize "before_backup", "before_prune", "after_backup", "after_prune", etc.
|
||||
for action_name in ('create', 'prune', 'compact', 'check', 'extract'):
|
||||
for preposition in ('before', 'after'):
|
||||
option_name = f'{preposition}_{"backup" if action_name == "create" else action_name}'
|
||||
commands = config.pop(option_name, None)
|
||||
|
||||
if not commands:
|
||||
continue
|
||||
|
||||
logs.append(make_command_hook_deprecation_log(config_filename, option_name))
|
||||
config.setdefault('commands', []).append(
|
||||
{
|
||||
preposition: 'action',
|
||||
'when': [action_name],
|
||||
'run': commands,
|
||||
},
|
||||
)
|
||||
|
||||
# Normalize "on_error".
|
||||
commands = config.pop('on_error', None)
|
||||
|
||||
if commands:
|
||||
logs.append(make_command_hook_deprecation_log(config_filename, 'on_error'))
|
||||
config.setdefault('commands', []).append(
|
||||
{
|
||||
'after': 'error',
|
||||
'when': ['create', 'prune', 'compact', 'check'],
|
||||
'run': commands,
|
||||
},
|
||||
)
|
||||
|
||||
# Normalize "before_everything" and "after_everything".
|
||||
for preposition in ('before', 'after'):
|
||||
option_name = f'{preposition}_everything'
|
||||
commands = config.pop(option_name, None)
|
||||
|
||||
if commands:
|
||||
logs.append(make_command_hook_deprecation_log(config_filename, option_name))
|
||||
config.setdefault('commands', []).append(
|
||||
{
|
||||
preposition: 'everything',
|
||||
'when': ['create'],
|
||||
'run': commands,
|
||||
},
|
||||
)
|
||||
|
||||
return logs
|
||||
|
||||
|
||||
def normalize(config_filename, config): # noqa: PLR0912, PLR0915
|
||||
'''
|
||||
Given a configuration filename and a configuration dict of its loaded contents, apply particular
|
||||
hard-coded rules to normalize the configuration to adhere to the current schema. Return any log
|
||||
@@ -67,6 +151,7 @@ def normalize(config_filename, config):
|
||||
Raise ValueError the configuration cannot be normalized.
|
||||
'''
|
||||
logs = normalize_sections(config_filename, config)
|
||||
logs += normalize_commands(config_filename, config)
|
||||
|
||||
if config.get('borgmatic_source_directory'):
|
||||
logs.append(
|
||||
@@ -74,9 +159,9 @@ def normalize(config_filename, config):
|
||||
dict(
|
||||
levelno=logging.WARNING,
|
||||
levelname='WARNING',
|
||||
msg=f'{config_filename}: The borgmatic_source_directory option is deprecated and will be removed from a future release. Use borgmatic_runtime_directory and borgmatic_state_directory instead.',
|
||||
)
|
||||
)
|
||||
msg=f'{config_filename}: The borgmatic_source_directory option is deprecated and will be removed from a future release. Use user_runtime_directory and user_state_directory instead.',
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# Upgrade exclude_if_present from a string to a list.
|
||||
@@ -88,8 +173,8 @@ def normalize(config_filename, config):
|
||||
levelno=logging.WARNING,
|
||||
levelname='WARNING',
|
||||
msg=f'{config_filename}: The exclude_if_present option now expects a list value. String values for this option are deprecated and support will be removed from a future release.',
|
||||
)
|
||||
)
|
||||
),
|
||||
),
|
||||
)
|
||||
config['exclude_if_present'] = [exclude_if_present]
|
||||
|
||||
@@ -106,8 +191,8 @@ def normalize(config_filename, config):
|
||||
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
|
||||
@@ -121,8 +206,8 @@ def normalize(config_filename, config):
|
||||
levelno=logging.WARNING,
|
||||
levelname='WARNING',
|
||||
msg=f'{config_filename}: The healthchecks hook now expects a key/value pair with "ping_url" as a key. String values for this option are deprecated and support will be removed from a future release.',
|
||||
)
|
||||
)
|
||||
),
|
||||
),
|
||||
)
|
||||
config['healthchecks'] = {'ping_url': healthchecks}
|
||||
|
||||
@@ -134,8 +219,8 @@ def normalize(config_filename, config):
|
||||
levelno=logging.WARNING,
|
||||
levelname='WARNING',
|
||||
msg=f'{config_filename}: The healthchecks hook now expects key/value pairs. String values for this option are deprecated and support will be removed from a future release.',
|
||||
)
|
||||
)
|
||||
),
|
||||
),
|
||||
)
|
||||
config['cronitor'] = {'ping_url': cronitor}
|
||||
|
||||
@@ -147,8 +232,8 @@ def normalize(config_filename, config):
|
||||
levelno=logging.WARNING,
|
||||
levelname='WARNING',
|
||||
msg=f'{config_filename}: The healthchecks hook now expects key/value pairs. String values for this option are deprecated and support will be removed from a future release.',
|
||||
)
|
||||
)
|
||||
),
|
||||
),
|
||||
)
|
||||
config['pagerduty'] = {'integration_key': pagerduty}
|
||||
|
||||
@@ -160,8 +245,8 @@ def normalize(config_filename, config):
|
||||
levelno=logging.WARNING,
|
||||
levelname='WARNING',
|
||||
msg=f'{config_filename}: The healthchecks hook now expects key/value pairs. String values for this option are deprecated and support will be removed from a future release.',
|
||||
)
|
||||
)
|
||||
),
|
||||
),
|
||||
)
|
||||
config['cronhub'] = {'ping_url': cronhub}
|
||||
|
||||
@@ -174,8 +259,8 @@ def normalize(config_filename, config):
|
||||
levelno=logging.WARNING,
|
||||
levelname='WARNING',
|
||||
msg=f'{config_filename}: The checks option now expects a list of key/value pairs. Lists of strings for this option are deprecated and support will be removed from a future release.',
|
||||
)
|
||||
)
|
||||
),
|
||||
),
|
||||
)
|
||||
config['checks'] = [{'name': check_type} for check_type in checks]
|
||||
|
||||
@@ -188,8 +273,8 @@ def normalize(config_filename, config):
|
||||
levelno=logging.WARNING,
|
||||
levelname='WARNING',
|
||||
msg=f'{config_filename}: The numeric_owner option has been renamed to numeric_ids. numeric_owner is deprecated and support will be removed from a future release.',
|
||||
)
|
||||
)
|
||||
),
|
||||
),
|
||||
)
|
||||
config['numeric_ids'] = numeric_owner
|
||||
|
||||
@@ -201,8 +286,8 @@ def normalize(config_filename, config):
|
||||
levelno=logging.WARNING,
|
||||
levelname='WARNING',
|
||||
msg=f'{config_filename}: The bsd_flags option has been renamed to flags. bsd_flags is deprecated and support will be removed from a future release.',
|
||||
)
|
||||
)
|
||||
),
|
||||
),
|
||||
)
|
||||
config['flags'] = bsd_flags
|
||||
|
||||
@@ -214,8 +299,8 @@ def normalize(config_filename, config):
|
||||
levelno=logging.WARNING,
|
||||
levelname='WARNING',
|
||||
msg=f'{config_filename}: The remote_rate_limit option has been renamed to upload_rate_limit. remote_rate_limit is deprecated and support will be removed from a future release.',
|
||||
)
|
||||
)
|
||||
),
|
||||
),
|
||||
)
|
||||
config['upload_rate_limit'] = remote_rate_limit
|
||||
|
||||
@@ -229,8 +314,8 @@ def normalize(config_filename, config):
|
||||
levelno=logging.WARNING,
|
||||
levelname='WARNING',
|
||||
msg=f'{config_filename}: The repositories option now expects a list of key/value pairs. Lists of strings for this option are deprecated and support will be removed from a future release.',
|
||||
)
|
||||
)
|
||||
),
|
||||
),
|
||||
)
|
||||
config['repositories'] = [
|
||||
{'path': repository} if isinstance(repository, str) else repository
|
||||
@@ -241,7 +326,11 @@ def normalize(config_filename, config):
|
||||
config['repositories'] = []
|
||||
|
||||
for repository_dict in repositories:
|
||||
repository_path = repository_dict['path']
|
||||
repository_path = repository_dict.get('path')
|
||||
|
||||
if repository_path is None:
|
||||
continue
|
||||
|
||||
if '~' in repository_path:
|
||||
logs.append(
|
||||
logging.makeLogRecord(
|
||||
@@ -249,25 +338,22 @@ def normalize(config_filename, config):
|
||||
levelno=logging.WARNING,
|
||||
levelname='WARNING',
|
||||
msg=f'{config_filename}: Repository paths containing "~" are deprecated in borgmatic and support will be removed from a future release.',
|
||||
)
|
||||
)
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
if ':' in repository_path:
|
||||
if repository_path.startswith('file://'):
|
||||
updated_repository_path = os.path.abspath(
|
||||
repository_path.partition('file://')[-1]
|
||||
repository_path.partition('file://')[-1],
|
||||
)
|
||||
config['repositories'].append(
|
||||
dict(
|
||||
repository_dict,
|
||||
path=updated_repository_path,
|
||||
)
|
||||
),
|
||||
)
|
||||
elif (
|
||||
repository_path.startswith('ssh://')
|
||||
or repository_path.startswith('sftp://')
|
||||
or repository_path.startswith('rclone:')
|
||||
):
|
||||
elif repository_path.startswith(('ssh://', 'sftp://', 'rclone:', 's3:', 'b2:')):
|
||||
config['repositories'].append(repository_dict)
|
||||
else:
|
||||
rewritten_repository_path = f"ssh://{repository_path.replace(':~', '/~').replace(':/', '/').replace(':', '/./')}"
|
||||
@@ -276,15 +362,15 @@ def normalize(config_filename, config):
|
||||
dict(
|
||||
levelno=logging.WARNING,
|
||||
levelname='WARNING',
|
||||
msg=f'{config_filename}: Remote repository paths without ssh:// or rclone: syntax are deprecated and support will be removed from a future release. Interpreting "{repository_path}" as "{rewritten_repository_path}"',
|
||||
)
|
||||
)
|
||||
msg=f'{config_filename}: Remote repository paths without ssh://, sftp://, rclone:, s3:, or b2:, syntax are deprecated and support will be removed from a future release. Interpreting "{repository_path}" as "{rewritten_repository_path}"',
|
||||
),
|
||||
),
|
||||
)
|
||||
config['repositories'].append(
|
||||
dict(
|
||||
repository_dict,
|
||||
path=rewritten_repository_path,
|
||||
)
|
||||
),
|
||||
)
|
||||
else:
|
||||
config['repositories'].append(repository_dict)
|
||||
@@ -296,8 +382,8 @@ def normalize(config_filename, config):
|
||||
levelno=logging.WARNING,
|
||||
levelname='WARNING',
|
||||
msg=f'{config_filename}: The prefix option is deprecated and support will be removed from a future release. Use archive_name_format or match_archives instead.',
|
||||
)
|
||||
)
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
return logs
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import io
|
||||
import logging
|
||||
|
||||
import ruamel.yaml
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def set_values(config, keys, value):
|
||||
'''
|
||||
@@ -15,7 +18,7 @@ def set_values(config, keys, value):
|
||||
if len(keys) == 1:
|
||||
if isinstance(config, list):
|
||||
raise ValueError(
|
||||
'When overriding a list option, the value must use list syntax (e.g., "[foo, bar]" or "[{key: value}]" as appropriate)'
|
||||
'When overriding a list option, the value must use list syntax (e.g., "[foo, bar]" or "[{key: value}]" as appropriate)',
|
||||
)
|
||||
|
||||
config[first_key] = value
|
||||
@@ -66,11 +69,11 @@ def type_for_option(schema, option_keys):
|
||||
'''
|
||||
option_schema = schema
|
||||
|
||||
for key in option_keys:
|
||||
try:
|
||||
try:
|
||||
for key in option_keys:
|
||||
option_schema = option_schema['properties'][key]
|
||||
except KeyError:
|
||||
return None
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
try:
|
||||
return option_schema['type']
|
||||
@@ -100,8 +103,8 @@ def parse_overrides(raw_overrides, schema):
|
||||
|
||||
parsed_overrides = []
|
||||
|
||||
for raw_override in raw_overrides:
|
||||
try:
|
||||
try:
|
||||
for raw_override in raw_overrides:
|
||||
raw_keys, value = raw_override.split('=', 1)
|
||||
keys = tuple(raw_keys.split('.'))
|
||||
option_type = type_for_option(schema, keys)
|
||||
@@ -110,14 +113,14 @@ def parse_overrides(raw_overrides, schema):
|
||||
(
|
||||
keys,
|
||||
convert_value_type(value, option_type),
|
||||
)
|
||||
),
|
||||
)
|
||||
except ValueError:
|
||||
raise ValueError(
|
||||
f"Invalid override '{raw_override}'. Make sure you use the form: OPTION=VALUE or OPTION.SUBOPTION=VALUE"
|
||||
)
|
||||
except ruamel.yaml.error.YAMLError as error:
|
||||
raise ValueError(f"Invalid override '{raw_override}': {error.problem}")
|
||||
except ValueError:
|
||||
raise ValueError(
|
||||
f"Invalid override '{raw_override}'. Make sure you use the form: OPTION=VALUE or OPTION.SUBOPTION=VALUE",
|
||||
)
|
||||
except ruamel.yaml.error.YAMLError as error:
|
||||
raise ValueError(f"Invalid override '{raw_override}': {error.problem}")
|
||||
|
||||
return tuple(parsed_overrides)
|
||||
|
||||
@@ -134,6 +137,11 @@ def apply_overrides(config, schema, raw_overrides):
|
||||
'''
|
||||
overrides = parse_overrides(raw_overrides, schema)
|
||||
|
||||
if overrides:
|
||||
logger.warning(
|
||||
"The --override flag is deprecated and will be removed from a future release. Instead, use a command-line flag corresponding to the configuration option you'd like to set.",
|
||||
)
|
||||
|
||||
for keys, value in overrides:
|
||||
set_values(config, keys, value)
|
||||
set_values(config, strip_section_names(keys), value)
|
||||
|
||||
+12
-10
@@ -1,3 +1,4 @@
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
@@ -34,7 +35,8 @@ TEMPORARY_DIRECTORY_PREFIX = 'borgmatic-'
|
||||
|
||||
|
||||
def replace_temporary_subdirectory_with_glob(
|
||||
path, temporary_directory_prefix=TEMPORARY_DIRECTORY_PREFIX
|
||||
path,
|
||||
temporary_directory_prefix=TEMPORARY_DIRECTORY_PREFIX,
|
||||
):
|
||||
'''
|
||||
Given an absolute temporary directory path and an optional temporary directory prefix, look for
|
||||
@@ -103,7 +105,9 @@ class Runtime_directory:
|
||||
|
||||
self.temporary_directory = None
|
||||
else:
|
||||
base_directory = os.environ.get('TMPDIR') or os.environ.get('TEMP') or '/tmp'
|
||||
base_directory = (
|
||||
os.environ.get('TMPDIR') or os.environ.get('TEMP') or '/tmp' # noqa: S108
|
||||
)
|
||||
|
||||
if not base_directory.startswith(os.path.sep):
|
||||
raise ValueError('The temporary directory must be an absolute path')
|
||||
@@ -122,7 +126,7 @@ class Runtime_directory:
|
||||
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)
|
||||
|
||||
@@ -134,18 +138,16 @@ class Runtime_directory:
|
||||
'''
|
||||
return self.runtime_path
|
||||
|
||||
def __exit__(self, exception, value, traceback):
|
||||
def __exit__(self, exception_type, exception, 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
|
||||
with contextlib.suppress(OSError):
|
||||
self.temporary_directory.cleanup()
|
||||
|
||||
|
||||
def make_runtime_directory_glob(borgmatic_runtime_directory):
|
||||
@@ -158,7 +160,7 @@ def make_runtime_directory_glob(borgmatic_runtime_directory):
|
||||
*(
|
||||
'*' if subdirectory.startswith(TEMPORARY_DIRECTORY_PREFIX) else subdirectory
|
||||
for subdirectory in os.path.normpath(borgmatic_runtime_directory).split(os.path.sep)
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -175,5 +177,5 @@ def get_borgmatic_state_directory(config):
|
||||
or os.environ.get('STATE_DIRECTORY') # Set by systemd if configured.
|
||||
or '~/.local/state',
|
||||
'borgmatic',
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import decimal
|
||||
import itertools
|
||||
|
||||
|
||||
def get_properties(schema):
|
||||
'''
|
||||
Given a schema dict, return its properties. But if it's got sub-schemas with multiple different
|
||||
potential properties, return their merged properties instead (interleaved so the first
|
||||
properties of each sub-schema come first). The idea is that the user should see all possible
|
||||
options even if they're not all possible together.
|
||||
'''
|
||||
if 'oneOf' in schema:
|
||||
return dict(
|
||||
item
|
||||
for item in itertools.chain(
|
||||
*itertools.zip_longest(
|
||||
*[sub_schema['properties'].items() for sub_schema in schema['oneOf']],
|
||||
),
|
||||
)
|
||||
if item is not None
|
||||
)
|
||||
|
||||
return schema.get('properties', {})
|
||||
|
||||
|
||||
SCHEMA_TYPE_TO_PYTHON_TYPE = {
|
||||
'array': list,
|
||||
'boolean': bool,
|
||||
'integer': int,
|
||||
'number': decimal.Decimal,
|
||||
'object': dict,
|
||||
'string': str,
|
||||
}
|
||||
|
||||
|
||||
def parse_type(schema_type, **overrides):
|
||||
'''
|
||||
Given a schema type as a string, return the corresponding Python type.
|
||||
|
||||
If any overrides are given in the from of a schema type string to a Python type, then override
|
||||
the default type mapping with them.
|
||||
|
||||
Raise ValueError if the schema type is unknown.
|
||||
'''
|
||||
try:
|
||||
return dict(
|
||||
SCHEMA_TYPE_TO_PYTHON_TYPE,
|
||||
**overrides,
|
||||
)[schema_type]
|
||||
except KeyError:
|
||||
raise ValueError(f'Unknown type in configuration schema: {schema_type}')
|
||||
|
||||
|
||||
def compare_types(schema_type, target_types, match=any):
|
||||
'''
|
||||
Given a schema type as a string or a list of strings (representing multiple types) and a set of
|
||||
target type strings, return whether every schema type is in the set of target types.
|
||||
|
||||
If the schema type is a list of strings, use the given match function (such as any or all) to
|
||||
compare elements. For instance, if match is given as all, then every element of the schema_type
|
||||
list must be in the target types.
|
||||
'''
|
||||
if isinstance(schema_type, list):
|
||||
return match(element_schema_type in target_types for element_schema_type in schema_type)
|
||||
|
||||
return schema_type in target_types
|
||||
+726
-70
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@ import os
|
||||
import jsonschema
|
||||
import ruamel.yaml
|
||||
|
||||
import borgmatic.config
|
||||
import borgmatic.config.arguments
|
||||
from borgmatic.config import constants, environment, load, normalize, override
|
||||
|
||||
|
||||
@@ -17,10 +17,22 @@ def schema_filename():
|
||||
'''
|
||||
schema_path = os.path.join(os.path.dirname(borgmatic.config.__file__), 'schema.yaml')
|
||||
|
||||
with open(schema_path):
|
||||
with open(schema_path, encoding='utf-8'):
|
||||
return schema_path
|
||||
|
||||
|
||||
def load_schema(schema_path): # pragma: no cover
|
||||
'''
|
||||
Given a schema filename path, load the schema and return it as a dict.
|
||||
|
||||
Raise Validation_error if the schema could not be parsed.
|
||||
'''
|
||||
try:
|
||||
return load.load_configuration(schema_path)
|
||||
except (ruamel.yaml.error.YAMLError, RecursionError) as error:
|
||||
raise Validation_error(schema_path, (str(error),))
|
||||
|
||||
|
||||
def format_json_error_path_element(path_element):
|
||||
'''
|
||||
Given a path element into a JSON data structure, format it for display as a string.
|
||||
@@ -84,13 +96,21 @@ def apply_logical_validation(config_filename, parsed_configuration):
|
||||
)
|
||||
|
||||
|
||||
def parse_configuration(config_filename, schema_filename, overrides=None, resolve_env=True):
|
||||
def parse_configuration(
|
||||
config_filename,
|
||||
schema_filename,
|
||||
arguments,
|
||||
overrides=None,
|
||||
resolve_env=True,
|
||||
):
|
||||
'''
|
||||
Given the path to a config filename in YAML format, the path to a schema filename in a YAML
|
||||
rendition of JSON Schema format, a sequence of configuration file override strings in the form
|
||||
of "option.suboption=value", and whether to resolve environment variables, return the parsed
|
||||
configuration as a data structure of nested dicts and lists corresponding to the schema. Example
|
||||
return value:
|
||||
rendition of JSON Schema format, arguments as dict from action name to argparse.Namespace, a
|
||||
sequence of configuration file override strings in the form of "option.suboption=value", and
|
||||
whether to resolve environment variables, return the parsed configuration as a data structure of
|
||||
nested dicts and lists corresponding to the schema. Example return value.
|
||||
|
||||
Example return value:
|
||||
|
||||
{
|
||||
'source_directories': ['/home', '/etc'],
|
||||
@@ -113,6 +133,7 @@ def parse_configuration(config_filename, schema_filename, overrides=None, resolv
|
||||
except (ruamel.yaml.error.YAMLError, RecursionError) as error:
|
||||
raise Validation_error(config_filename, (str(error),))
|
||||
|
||||
borgmatic.config.arguments.apply_arguments_to_config(config, schema, arguments)
|
||||
override.apply_overrides(config, schema, overrides)
|
||||
constants.apply_constants(config, config.get('constants') if config else {})
|
||||
|
||||
@@ -130,7 +151,8 @@ def parse_configuration(config_filename, schema_filename, overrides=None, resolv
|
||||
|
||||
if validation_errors:
|
||||
raise Validation_error(
|
||||
config_filename, tuple(format_json_error(error) for error in validation_errors)
|
||||
config_filename,
|
||||
tuple(format_json_error(error) for error in validation_errors),
|
||||
)
|
||||
|
||||
apply_logical_validation(config_filename, config)
|
||||
@@ -138,18 +160,25 @@ def parse_configuration(config_filename, schema_filename, overrides=None, resolv
|
||||
return config, config_paths, logs
|
||||
|
||||
|
||||
def normalize_repository_path(repository):
|
||||
def normalize_repository_path(repository, base=None):
|
||||
'''
|
||||
Given a repository path, return the absolute path of it (for local repositories).
|
||||
Optionally, use a base path for resolving relative paths, e.g. to the configured working directory.
|
||||
'''
|
||||
# A colon in the repository could mean that it's either a file:// URL or a remote repository.
|
||||
# If it's a remote repository, we don't want to normalize it. If it's a file:// URL, we do.
|
||||
if ':' not in repository:
|
||||
return os.path.abspath(repository)
|
||||
elif repository.startswith('file://'):
|
||||
return os.path.abspath(repository.partition('file://')[-1])
|
||||
else:
|
||||
return repository
|
||||
return (
|
||||
os.path.abspath(os.path.join(base, repository)) if base else os.path.abspath(repository)
|
||||
)
|
||||
|
||||
if repository.startswith('file://'):
|
||||
local_path = repository.partition('file://')[-1]
|
||||
return (
|
||||
os.path.abspath(os.path.join(base, local_path)) if base else os.path.abspath(local_path)
|
||||
)
|
||||
|
||||
return repository
|
||||
|
||||
|
||||
def glob_match(first, second):
|
||||
@@ -171,11 +200,13 @@ def repositories_match(first, second):
|
||||
'''
|
||||
if isinstance(first, str):
|
||||
first = {'path': first, 'label': first}
|
||||
|
||||
if isinstance(second, str):
|
||||
second = {'path': second, 'label': second}
|
||||
|
||||
return glob_match(first.get('label'), second.get('label')) or glob_match(
|
||||
normalize_repository_path(first.get('path')), normalize_repository_path(second.get('path'))
|
||||
normalize_repository_path(first.get('path')),
|
||||
normalize_repository_path(second.get('path')),
|
||||
)
|
||||
|
||||
|
||||
@@ -196,7 +227,7 @@ def guard_configuration_contains_repository(repository, configurations):
|
||||
for config in configurations.values()
|
||||
for config_repository in config['repositories']
|
||||
if repositories_match(config_repository, repository)
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
if count == 0:
|
||||
|
||||
+83
-60
@@ -1,7 +1,6 @@
|
||||
import collections
|
||||
import enum
|
||||
import logging
|
||||
import os
|
||||
import select
|
||||
import subprocess
|
||||
import textwrap
|
||||
@@ -32,6 +31,7 @@ def interpret_exit_code(command, exit_code, borg_local_path=None, borg_exit_code
|
||||
'''
|
||||
if exit_code is None:
|
||||
return Exit_status.STILL_RUNNING
|
||||
|
||||
if exit_code == 0:
|
||||
return Exit_status.SUCCESS
|
||||
|
||||
@@ -43,12 +43,13 @@ def interpret_exit_code(command, exit_code, borg_local_path=None, borg_exit_code
|
||||
|
||||
if treat_as == 'error':
|
||||
logger.error(
|
||||
f'Treating exit code {exit_code} as an error, as per configuration'
|
||||
f'Treating exit code {exit_code} as an error, as per configuration',
|
||||
)
|
||||
return Exit_status.ERROR
|
||||
elif treat_as == 'warning':
|
||||
|
||||
if treat_as == 'warning':
|
||||
logger.warning(
|
||||
f'Treating exit code {exit_code} as a warning, as per configuration'
|
||||
f'Treating exit code {exit_code} as a warning, as per configuration',
|
||||
)
|
||||
return Exit_status.WARNING
|
||||
|
||||
@@ -103,7 +104,7 @@ def append_last_lines(last_lines, captured_output, line, output_log_level):
|
||||
logger.log(output_log_level, line)
|
||||
|
||||
|
||||
def log_outputs(processes, exclude_stdouts, output_log_level, borg_local_path, borg_exit_codes):
|
||||
def log_outputs(processes, exclude_stdouts, output_log_level, borg_local_path, borg_exit_codes): # noqa: PLR0912
|
||||
'''
|
||||
Given a sequence of subprocess.Popen() instances for multiple processes, log the output for each
|
||||
process with the requested log level. Additionally, raise a CalledProcessError if a process
|
||||
@@ -132,7 +133,7 @@ def log_outputs(processes, exclude_stdouts, output_log_level, borg_local_path, b
|
||||
still_running = True
|
||||
|
||||
# Log output for each process until they all exit.
|
||||
while True:
|
||||
while True: # noqa: PLR1702
|
||||
if output_buffers:
|
||||
(ready_buffers, _, _) = select.select(output_buffers, [], [])
|
||||
|
||||
@@ -182,7 +183,7 @@ def log_outputs(processes, exclude_stdouts, output_log_level, borg_local_path, b
|
||||
command = process.args.split(' ') if isinstance(process.args, str) else process.args
|
||||
exit_status = interpret_exit_code(command, exit_code, borg_local_path, borg_exit_codes)
|
||||
|
||||
if exit_status in (Exit_status.ERROR, Exit_status.WARNING):
|
||||
if exit_status in {Exit_status.ERROR, Exit_status.WARNING}:
|
||||
# If an error occurs, include its output in the raised exception so that we don't
|
||||
# inadvertently hide error output.
|
||||
output_buffer = output_buffer_for_process(process, exclude_stdouts)
|
||||
@@ -195,7 +196,10 @@ def log_outputs(processes, exclude_stdouts, output_log_level, borg_local_path, b
|
||||
break
|
||||
|
||||
append_last_lines(
|
||||
last_lines, captured_outputs[process], line, output_log_level=logging.ERROR
|
||||
last_lines,
|
||||
captured_outputs[process],
|
||||
line,
|
||||
output_log_level=logging.ERROR,
|
||||
)
|
||||
|
||||
if len(last_lines) == ERROR_OUTPUT_MAX_LINE_COUNT:
|
||||
@@ -210,7 +214,9 @@ def log_outputs(processes, exclude_stdouts, output_log_level, borg_local_path, b
|
||||
|
||||
if exit_status == Exit_status.ERROR:
|
||||
raise subprocess.CalledProcessError(
|
||||
exit_code, command_for_process(process), '\n'.join(last_lines)
|
||||
exit_code,
|
||||
command_for_process(process),
|
||||
'\n'.join(last_lines),
|
||||
)
|
||||
|
||||
still_running = False
|
||||
@@ -221,6 +227,8 @@ def log_outputs(processes, exclude_stdouts, output_log_level, borg_local_path, b
|
||||
process: '\n'.join(output_lines) for process, output_lines in captured_outputs.items()
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
|
||||
SECRET_COMMAND_FLAG_NAMES = {'--password'}
|
||||
|
||||
@@ -243,6 +251,9 @@ def mask_command_secrets(full_command):
|
||||
MAX_LOGGED_COMMAND_LENGTH = 1000
|
||||
|
||||
|
||||
PREFIXES_OF_ENVIRONMENT_VARIABLES_TO_LOG = ('BORG_', 'PG', 'MARIADB_', 'MYSQL_')
|
||||
|
||||
|
||||
def log_command(full_command, input_file=None, output_file=None, environment=None):
|
||||
'''
|
||||
Log the given command (a sequence of command/argument strings), along with its input/output file
|
||||
@@ -251,14 +262,21 @@ def log_command(full_command, input_file=None, output_file=None, environment=Non
|
||||
logger.debug(
|
||||
textwrap.shorten(
|
||||
' '.join(
|
||||
tuple(f'{key}=***' for key in (environment or {}).keys())
|
||||
+ mask_command_secrets(full_command)
|
||||
tuple(
|
||||
f'{key}=***'
|
||||
for key in (environment or {})
|
||||
if any(
|
||||
key.startswith(prefix)
|
||||
for prefix in PREFIXES_OF_ENVIRONMENT_VARIABLES_TO_LOG
|
||||
)
|
||||
)
|
||||
+ mask_command_secrets(full_command),
|
||||
),
|
||||
width=MAX_LOGGED_COMMAND_LENGTH,
|
||||
placeholder=' ...',
|
||||
)
|
||||
+ (f" < {getattr(input_file, 'name', '')}" if input_file else '')
|
||||
+ (f" > {getattr(output_file, 'name', '')}" if output_file else '')
|
||||
+ (f" < {getattr(input_file, 'name', input_file)}" if input_file else '')
|
||||
+ (f" > {getattr(output_file, 'name', output_file)}" if output_file else ''),
|
||||
)
|
||||
|
||||
|
||||
@@ -274,32 +292,32 @@ def execute_command(
|
||||
output_file=None,
|
||||
input_file=None,
|
||||
shell=False,
|
||||
extra_environment=None,
|
||||
environment=None,
|
||||
working_directory=None,
|
||||
borg_local_path=None,
|
||||
borg_exit_codes=None,
|
||||
run_to_completion=True,
|
||||
close_fds=False, # Necessary for passing credentials via anonymous pipe.
|
||||
):
|
||||
'''
|
||||
Execute the given command (a sequence of command/argument strings) and log its output at the
|
||||
given log level. If an open output file object is given, then write stdout to the file and only
|
||||
log stderr. If an open input file object is given, then read stdin from the file. If shell is
|
||||
True, execute the command within a shell. If an extra environment dict is given, then use it to
|
||||
augment the current environment, and pass the result into the command. If a working directory is
|
||||
given, use that as the present working directory when running the command. If a Borg local path
|
||||
is given, and the command matches it (regardless of arguments), treat exit code 1 as a warning
|
||||
instead of an error. But if Borg exit codes are given as a sequence of exit code configuration
|
||||
dicts, then use that configuration to decide what's an error and what's a warning. If run to
|
||||
completion is False, then return the process for the command without executing it to completion.
|
||||
True, execute the command within a shell. If an environment variables dict is given, then pass
|
||||
it into the command. If a working directory is given, use that as the present working directory
|
||||
when running the command. If a Borg local path is given, and the command matches it (regardless
|
||||
of arguments), treat exit code 1 as a warning instead of an error. But if Borg exit codes are
|
||||
given as a sequence of exit code configuration dicts, then use that configuration to decide
|
||||
what's an error and what's a warning. If run to completion is False, then return the process for
|
||||
the command without executing it to completion.
|
||||
|
||||
Raise subprocesses.CalledProcessError if an error occurs while running the command.
|
||||
'''
|
||||
log_command(full_command, input_file, output_file, extra_environment)
|
||||
environment = {**os.environ, **extra_environment} if extra_environment else None
|
||||
log_command(full_command, input_file, output_file, environment)
|
||||
do_not_capture = bool(output_file is DO_NOT_CAPTURE)
|
||||
command = ' '.join(full_command) if shell else full_command
|
||||
|
||||
process = subprocess.Popen(
|
||||
process = subprocess.Popen( # noqa: S603
|
||||
command,
|
||||
stdin=input_file,
|
||||
stdout=None if do_not_capture else (output_file or subprocess.PIPE),
|
||||
@@ -307,8 +325,7 @@ def execute_command(
|
||||
shell=shell,
|
||||
env=environment,
|
||||
cwd=working_directory,
|
||||
# Necessary for the passcommand credential hook to work.
|
||||
close_fds=not bool((extra_environment or {}).get('BORG_PASSPHRASE_FD')),
|
||||
close_fds=close_fds,
|
||||
)
|
||||
if not run_to_completion:
|
||||
return process
|
||||
@@ -322,42 +339,45 @@ def execute_command(
|
||||
borg_exit_codes,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def execute_command_and_capture_output(
|
||||
full_command,
|
||||
input_file=None,
|
||||
capture_stderr=False,
|
||||
shell=False,
|
||||
extra_environment=None,
|
||||
environment=None,
|
||||
working_directory=None,
|
||||
borg_local_path=None,
|
||||
borg_exit_codes=None,
|
||||
close_fds=False, # Necessary for passing credentials via anonymous pipe.
|
||||
):
|
||||
'''
|
||||
Execute the given command (a sequence of command/argument strings), capturing and returning its
|
||||
output (stdout). If capture stderr is True, then capture and return stderr in addition to
|
||||
stdout. If shell is True, execute the command within a shell. If an extra environment dict is
|
||||
given, then use it to augment the current environment, and pass the result into the command. If
|
||||
a working directory is given, use that as the present working directory when running the
|
||||
command. If a Borg local path is given, and the command matches it (regardless of arguments),
|
||||
treat exit code 1 as a warning instead of an error. But if Borg exit codes are given as a
|
||||
sequence of exit code configuration dicts, then use that configuration to decide what's an error
|
||||
and what's a warning.
|
||||
output (stdout). If an input file descriptor is given, then pipe it to the command's stdin. If
|
||||
capture stderr is True, then capture and return stderr in addition to stdout. If shell is True,
|
||||
execute the command within a shell. If an environment variables dict is given, then pass it into
|
||||
the command. If a working directory is given, use that as the present working directory when
|
||||
running the command. If a Borg local path is given, and the command matches it (regardless of
|
||||
arguments), treat exit code 1 as a warning instead of an error. But if Borg exit codes are given
|
||||
as a sequence of exit code configuration dicts, then use that configuration to decide what's an
|
||||
error and what's a warning.
|
||||
|
||||
Raise subprocesses.CalledProcessError if an error occurs while running the command.
|
||||
'''
|
||||
log_command(full_command, environment=extra_environment)
|
||||
environment = {**os.environ, **extra_environment} if extra_environment else None
|
||||
log_command(full_command, input_file, environment=environment)
|
||||
command = ' '.join(full_command) if shell else full_command
|
||||
|
||||
try:
|
||||
output = subprocess.check_output(
|
||||
output = subprocess.check_output( # noqa: S603
|
||||
command,
|
||||
stdin=input_file,
|
||||
stderr=subprocess.STDOUT if capture_stderr else None,
|
||||
shell=shell,
|
||||
env=environment,
|
||||
cwd=working_directory,
|
||||
# Necessary for the passcommand credential hook to work.
|
||||
close_fds=not bool((extra_environment or {}).get('BORG_PASSPHRASE_FD')),
|
||||
close_fds=close_fds,
|
||||
)
|
||||
except subprocess.CalledProcessError as error:
|
||||
if (
|
||||
@@ -365,6 +385,7 @@ def execute_command_and_capture_output(
|
||||
== Exit_status.ERROR
|
||||
):
|
||||
raise
|
||||
|
||||
output = error.output
|
||||
|
||||
return output.decode() if output is not None else None
|
||||
@@ -377,10 +398,11 @@ def execute_command_with_processes(
|
||||
output_file=None,
|
||||
input_file=None,
|
||||
shell=False,
|
||||
extra_environment=None,
|
||||
environment=None,
|
||||
working_directory=None,
|
||||
borg_local_path=None,
|
||||
borg_exit_codes=None,
|
||||
close_fds=False, # Necessary for passing credentials via anonymous pipe.
|
||||
):
|
||||
'''
|
||||
Execute the given command (a sequence of command/argument strings) and log its output at the
|
||||
@@ -391,24 +413,22 @@ def execute_command_with_processes(
|
||||
If an open output file object is given, then write stdout to the file and only log stderr. But
|
||||
if output log level is None, instead suppress logging and return the captured output for (only)
|
||||
the given command. If an open input file object is given, then read stdin from the file. If
|
||||
shell is True, execute the command within a shell. If an extra environment dict is given, then
|
||||
use it to augment the current environment, and pass the result into the command. If a working
|
||||
directory is given, use that as the present working directory when running the command. If a
|
||||
Borg local path is given, then for any matching command or process (regardless of arguments),
|
||||
treat exit code 1 as a warning instead of an error. But if Borg exit codes are given as a
|
||||
sequence of exit code configuration dicts, then use that configuration to decide what's an error
|
||||
and what's a warning.
|
||||
shell is True, execute the command within a shell. If an environment variables dict is given,
|
||||
then pass it into the command. If a working directory is given, use that as the present working
|
||||
directory when running the command. If a Borg local path is given, then for any matching command
|
||||
or process (regardless of arguments), treat exit code 1 as a warning instead of an error. But if
|
||||
Borg exit codes are given as a sequence of exit code configuration dicts, then use that
|
||||
configuration to decide what's an error and what's a warning.
|
||||
|
||||
Raise subprocesses.CalledProcessError if an error occurs while running the command or in the
|
||||
upstream process.
|
||||
'''
|
||||
log_command(full_command, input_file, output_file, extra_environment)
|
||||
environment = {**os.environ, **extra_environment} if extra_environment else None
|
||||
log_command(full_command, input_file, output_file, environment)
|
||||
do_not_capture = bool(output_file is DO_NOT_CAPTURE)
|
||||
command = ' '.join(full_command) if shell else full_command
|
||||
|
||||
try:
|
||||
command_process = subprocess.Popen(
|
||||
command_process = subprocess.Popen( # noqa: S603
|
||||
command,
|
||||
stdin=input_file,
|
||||
stdout=None if do_not_capture else (output_file or subprocess.PIPE),
|
||||
@@ -418,8 +438,7 @@ def execute_command_with_processes(
|
||||
shell=shell,
|
||||
env=environment,
|
||||
cwd=working_directory,
|
||||
# Necessary for the passcommand credential hook to work.
|
||||
close_fds=not bool((extra_environment or {}).get('BORG_PASSPHRASE_FD')),
|
||||
close_fds=close_fds,
|
||||
)
|
||||
except (subprocess.CalledProcessError, OSError):
|
||||
# Something has gone wrong. So vent each process' output buffer to prevent it from hanging.
|
||||
@@ -428,15 +447,19 @@ def execute_command_with_processes(
|
||||
if process.poll() is None:
|
||||
process.stdout.read(0)
|
||||
process.kill()
|
||||
|
||||
raise
|
||||
|
||||
captured_outputs = log_outputs(
|
||||
tuple(processes) + (command_process,),
|
||||
(input_file, output_file),
|
||||
output_log_level,
|
||||
borg_local_path,
|
||||
borg_exit_codes,
|
||||
)
|
||||
with borgmatic.logger.Log_prefix(None): # Log command output without any prefix.
|
||||
captured_outputs = log_outputs(
|
||||
(*processes, command_process),
|
||||
(input_file, output_file),
|
||||
output_log_level,
|
||||
borg_local_path,
|
||||
borg_exit_codes,
|
||||
)
|
||||
|
||||
if output_log_level is None:
|
||||
return captured_outputs.get(command_process)
|
||||
|
||||
return None
|
||||
|
||||
+203
-43
@@ -1,15 +1,32 @@
|
||||
import functools
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import borgmatic.execute
|
||||
import borgmatic.logger
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
SOFT_FAIL_EXIT_CODE = 75
|
||||
BORG_PLACEHOLDERS = {
|
||||
'{hostname}',
|
||||
'{fqdn}',
|
||||
'{reverse-fqdn}',
|
||||
'{now}',
|
||||
'{utcnow}',
|
||||
'{unixtime}',
|
||||
'{user}',
|
||||
'{pid}',
|
||||
'{borgversion}',
|
||||
'{borgmajor}',
|
||||
'{borgminor}',
|
||||
'{borgpatch}',
|
||||
}
|
||||
|
||||
|
||||
def interpolate_context(hook_description, command, context):
|
||||
@@ -20,83 +37,226 @@ def interpolate_context(hook_description, command, context):
|
||||
for name, value in context.items():
|
||||
command = command.replace(f'{{{name}}}', shlex.quote(str(value)))
|
||||
|
||||
for unsupported_variable in re.findall(r'{\w+}', command):
|
||||
logger.warning(
|
||||
f"Variable '{unsupported_variable}' is not supported in {hook_description} hook"
|
||||
)
|
||||
for unsupported_variable in re.findall(r'\{\w+\}', command):
|
||||
# Warn about variables unknown to borgmatic, but don't warn if the variable name happens to
|
||||
# be a Borg placeholder, as Borg should hopefully consume it.
|
||||
if unsupported_variable not in BORG_PLACEHOLDERS:
|
||||
logger.warning(
|
||||
f'Variable "{unsupported_variable}" is not supported in the {hook_description} hook',
|
||||
)
|
||||
|
||||
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.
|
||||
Given the existing system environment as a map from environment variable name to value, return a
|
||||
copy of it, augmented with any extra environment variables that should be used when running
|
||||
command hooks.
|
||||
'''
|
||||
environment = dict(current_environment)
|
||||
|
||||
# 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', '')}
|
||||
environment['LD_LIBRARY_PATH'] = environment.get('LD_LIBRARY_PATH_ORIG', '')
|
||||
|
||||
return {}
|
||||
return environment
|
||||
|
||||
|
||||
def execute_hook(commands, umask, config_filename, description, dry_run, **context):
|
||||
def filter_hooks(command_hooks, before=None, after=None, action_names=None, state_names=None):
|
||||
'''
|
||||
Given a list of hook commands to execute, a umask to execute with (or None), a config filename,
|
||||
a hook description, and whether this is a dry run, run the given commands. Or, don't run them
|
||||
if this is a dry run.
|
||||
Given a sequence of command hook dicts from configuration and one or more filters (before name,
|
||||
after name, a sequence of action names, and/or a sequence of execution result state names),
|
||||
filter down the command hooks to just the ones that match the given filters.
|
||||
'''
|
||||
return tuple(
|
||||
hook_config
|
||||
for hook_config in command_hooks or ()
|
||||
for config_action_names in (hook_config.get('when'),)
|
||||
for config_state_names in (hook_config.get('states'),)
|
||||
if before is None or hook_config.get('before') == before
|
||||
if after is None or hook_config.get('after') == after
|
||||
if action_names is None
|
||||
or config_action_names is None
|
||||
or set(config_action_names or ()).intersection(set(action_names))
|
||||
if state_names is None
|
||||
or config_state_names is None
|
||||
or set(config_state_names or ()).intersection(set(state_names))
|
||||
)
|
||||
|
||||
|
||||
def execute_hooks(command_hooks, umask, working_directory, dry_run, **context): # noqa: PLR0912
|
||||
'''
|
||||
Given a sequence of command hook dicts from configuration, a umask to execute with (or None), a
|
||||
working directory to execute with, and whether this is a dry run, run the commands for each
|
||||
hook. Or don't run them if this is a dry run.
|
||||
|
||||
The context contains optional values interpolated by name into the hook commands.
|
||||
|
||||
Raise ValueError if the umask cannot be parsed.
|
||||
Raise ValueError if the umask cannot be parsed or a hook is invalid.
|
||||
Raise subprocesses.CalledProcessError if an error occurs in a hook.
|
||||
'''
|
||||
if not commands:
|
||||
logger.debug(f'No commands to run for {description} hook')
|
||||
return
|
||||
borgmatic.logger.add_custom_log_levels()
|
||||
|
||||
dry_run_label = ' (dry run; not actually running hooks)' if dry_run else ''
|
||||
|
||||
context['configuration_filename'] = config_filename
|
||||
commands = [interpolate_context(description, command, context) for command in commands]
|
||||
|
||||
if len(commands) == 1:
|
||||
logger.info(f'Running command for {description} hook{dry_run_label}')
|
||||
else:
|
||||
logger.info(
|
||||
f'Running {len(commands)} commands for {description} hook{dry_run_label}',
|
||||
for hook_config in command_hooks:
|
||||
commands = hook_config.get('run')
|
||||
when_description = (
|
||||
f"{'/'.join(hook_config.get('when'))} " if hook_config.get('when') else ''
|
||||
)
|
||||
|
||||
if umask:
|
||||
parsed_umask = int(str(umask), 8)
|
||||
logger.debug(f'Set hook umask to {oct(parsed_umask)}')
|
||||
original_umask = os.umask(parsed_umask)
|
||||
else:
|
||||
original_umask = None
|
||||
if 'before' in hook_config:
|
||||
description = f'before {when_description}{hook_config.get("before")}'
|
||||
elif 'after' in hook_config:
|
||||
description = f'after {when_description}{hook_config.get("after")}'
|
||||
else:
|
||||
raise ValueError(f'Invalid hook configuration: {hook_config}')
|
||||
|
||||
try:
|
||||
for command in commands:
|
||||
if dry_run:
|
||||
continue
|
||||
if not commands:
|
||||
logger.debug(f'No commands to run for {description} hook')
|
||||
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),
|
||||
commands = [interpolate_context(description, command, context) for command in commands]
|
||||
|
||||
if len(commands) == 1:
|
||||
logger.info(f'Running {description} command hook{dry_run_label}')
|
||||
else:
|
||||
logger.info(
|
||||
f'Running {len(commands)} commands for {description} hook{dry_run_label}',
|
||||
)
|
||||
finally:
|
||||
if original_umask:
|
||||
os.umask(original_umask)
|
||||
|
||||
if umask:
|
||||
parsed_umask = int(str(umask), 8)
|
||||
logger.debug(f'Setting hook umask to {oct(parsed_umask)}')
|
||||
original_umask = os.umask(parsed_umask)
|
||||
else:
|
||||
original_umask = None
|
||||
|
||||
try:
|
||||
for command in commands:
|
||||
if dry_run:
|
||||
continue
|
||||
|
||||
borgmatic.execute.execute_command( # noqa: S604
|
||||
[command],
|
||||
output_log_level=(
|
||||
logging.ERROR if hook_config.get('after') == 'error' else logging.ANSWER
|
||||
),
|
||||
shell=True,
|
||||
environment=make_environment(os.environ),
|
||||
working_directory=working_directory,
|
||||
)
|
||||
finally:
|
||||
if original_umask:
|
||||
os.umask(original_umask)
|
||||
|
||||
|
||||
class Before_after_hooks:
|
||||
'''
|
||||
A Python context manager for executing command hooks both before and after the wrapped code.
|
||||
|
||||
Example use as a context manager:
|
||||
|
||||
with borgmatic.hooks.command.Before_after_hooks(
|
||||
command_hooks=config.get('commands'),
|
||||
before_after='do_stuff',
|
||||
umask=config.get('umask'),
|
||||
dry_run=dry_run,
|
||||
action_names=['create'],
|
||||
):
|
||||
do()
|
||||
some()
|
||||
stuff()
|
||||
|
||||
With that context manager in place, "before" command hooks execute before the wrapped code runs,
|
||||
and "after" command hooks execute after the wrapped code completes.
|
||||
'''
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
command_hooks,
|
||||
before_after,
|
||||
umask,
|
||||
working_directory,
|
||||
dry_run,
|
||||
action_names=None,
|
||||
**context,
|
||||
):
|
||||
'''
|
||||
Given a sequence of command hook configuration dicts, the before/after name, a umask to run
|
||||
commands with, a working directory to run commands with, a dry run flag, a sequence of
|
||||
action names, and any context for the executed commands, save those data points for use
|
||||
below.
|
||||
'''
|
||||
self.command_hooks = command_hooks
|
||||
self.before_after = before_after
|
||||
self.umask = umask
|
||||
self.working_directory = working_directory
|
||||
self.dry_run = dry_run
|
||||
self.action_names = action_names
|
||||
self.context = context
|
||||
|
||||
def __enter__(self):
|
||||
'''
|
||||
Run the configured "before" command hooks that match the initialized data points.
|
||||
'''
|
||||
try:
|
||||
execute_hooks(
|
||||
borgmatic.hooks.command.filter_hooks(
|
||||
self.command_hooks,
|
||||
before=self.before_after,
|
||||
action_names=self.action_names,
|
||||
),
|
||||
self.umask,
|
||||
self.working_directory,
|
||||
self.dry_run,
|
||||
**self.context,
|
||||
)
|
||||
except (OSError, subprocess.CalledProcessError) as error:
|
||||
if considered_soft_failure(error):
|
||||
raise
|
||||
|
||||
# Trigger the after hook manually, since raising here will prevent it from being run
|
||||
# otherwise.
|
||||
self.__exit__(exception_type=type(error), exception=error, traceback=None)
|
||||
|
||||
raise ValueError(f'Error running before {self.before_after} hook: {error}')
|
||||
|
||||
def __exit__(self, exception_type, exception, traceback):
|
||||
'''
|
||||
Run the configured "after" command hooks that match the initialized data points.
|
||||
'''
|
||||
try:
|
||||
execute_hooks(
|
||||
borgmatic.hooks.command.filter_hooks(
|
||||
self.command_hooks,
|
||||
after=self.before_after,
|
||||
action_names=self.action_names,
|
||||
state_names=['fail' if exception_type else 'finish'],
|
||||
),
|
||||
self.umask,
|
||||
self.working_directory,
|
||||
self.dry_run,
|
||||
**self.context,
|
||||
)
|
||||
except (OSError, subprocess.CalledProcessError) as error:
|
||||
if considered_soft_failure(error):
|
||||
raise
|
||||
|
||||
raise ValueError(f'Error running after {self.before_after} hook: {error}')
|
||||
|
||||
|
||||
@functools.cache
|
||||
def considered_soft_failure(error):
|
||||
'''
|
||||
Given a configuration filename and an exception object, return whether the exception object
|
||||
represents a subprocess.CalledProcessError with a return code of SOFT_FAIL_EXIT_CODE. If so,
|
||||
that indicates that the error is a "soft failure", and should not result in an error.
|
||||
|
||||
The results of this function are cached so that it can be called multiple times without logging
|
||||
multiple times.
|
||||
'''
|
||||
exit_code = getattr(error, 'returncode', None)
|
||||
if exit_code is None:
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
SECRET_NAME_PATTERN = re.compile(r'^\w+$')
|
||||
DEFAULT_SECRETS_DIRECTORY = '/run/secrets'
|
||||
|
||||
|
||||
def load_credential(hook_config, config, credential_parameters):
|
||||
'''
|
||||
Given the hook configuration dict, the configuration dict, and a credential parameters tuple
|
||||
containing a secret name to load, read the secret from the corresponding container secrets file
|
||||
and return it.
|
||||
|
||||
Raise ValueError if the credential parameters is not one element, the secret name is invalid, or
|
||||
the secret file cannot be read.
|
||||
'''
|
||||
try:
|
||||
(secret_name,) = credential_parameters
|
||||
except ValueError:
|
||||
name = ' '.join(credential_parameters)
|
||||
|
||||
raise ValueError(f'Cannot load invalid secret name: "{name}"')
|
||||
|
||||
if not SECRET_NAME_PATTERN.match(secret_name):
|
||||
raise ValueError(f'Cannot load invalid secret name: "{secret_name}"')
|
||||
|
||||
try:
|
||||
with open(
|
||||
os.path.join(
|
||||
config.get('working_directory', ''),
|
||||
(hook_config or {}).get('secrets_directory', DEFAULT_SECRETS_DIRECTORY),
|
||||
secret_name,
|
||||
),
|
||||
encoding='utf-8',
|
||||
) as secret_file:
|
||||
return secret_file.read().rstrip(os.linesep)
|
||||
except (FileNotFoundError, OSError) as error:
|
||||
logger.warning(error)
|
||||
|
||||
raise ValueError(f'Cannot load secret "{secret_name}" from file: {error.filename}')
|
||||
@@ -0,0 +1,33 @@
|
||||
import logging
|
||||
import os
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def load_credential(hook_config, config, credential_parameters):
|
||||
'''
|
||||
Given the hook configuration dict, the configuration dict, and a credential parameters tuple
|
||||
containing a credential path to load, load the credential from file and return it.
|
||||
|
||||
Raise ValueError if the credential parameters is not one element or the secret file cannot be
|
||||
read.
|
||||
'''
|
||||
try:
|
||||
(credential_path,) = credential_parameters
|
||||
except ValueError:
|
||||
name = ' '.join(credential_parameters)
|
||||
|
||||
raise ValueError(f'Cannot load invalid credential: "{name}"')
|
||||
|
||||
expanded_credential_path = os.path.expanduser(credential_path)
|
||||
|
||||
try:
|
||||
with open(
|
||||
os.path.join(config.get('working_directory', ''), expanded_credential_path),
|
||||
encoding='utf-8',
|
||||
) as credential_file:
|
||||
return credential_file.read().rstrip(os.linesep)
|
||||
except (FileNotFoundError, OSError) as error:
|
||||
logger.warning(error)
|
||||
|
||||
raise ValueError(f'Cannot load credential file: {error.filename}')
|
||||
@@ -0,0 +1,45 @@
|
||||
import logging
|
||||
import os
|
||||
import shlex
|
||||
|
||||
import borgmatic.execute
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def load_credential(hook_config, config, credential_parameters):
|
||||
'''
|
||||
Given the hook configuration dict, the configuration dict, and a credential parameters tuple
|
||||
containing a KeePassXC database path and an attribute name to load, run keepassxc-cli to fetch
|
||||
the corresponding KeePassXC credential and return it.
|
||||
|
||||
Raise ValueError if keepassxc-cli can't retrieve the credential.
|
||||
'''
|
||||
try:
|
||||
(database_path, attribute_name) = credential_parameters
|
||||
except ValueError:
|
||||
raise ValueError(f'Invalid KeePassXC credential parameters: {credential_parameters}')
|
||||
|
||||
expanded_database_path = os.path.expanduser(database_path)
|
||||
|
||||
if not os.path.exists(expanded_database_path):
|
||||
raise ValueError(f'KeePassXC database path does not exist: {database_path}')
|
||||
|
||||
# Build the keepassxc-cli command.
|
||||
command = (
|
||||
tuple(shlex.split((hook_config or {}).get('keepassxc_cli_command', 'keepassxc-cli')))
|
||||
+ ('show', '--show-protected', '--attributes', 'Password')
|
||||
+ (
|
||||
('--key-file', hook_config['key_file'])
|
||||
if hook_config and hook_config.get('key_file')
|
||||
else ()
|
||||
)
|
||||
+ (
|
||||
('--yubikey', hook_config['yubikey'])
|
||||
if hook_config and hook_config.get('yubikey')
|
||||
else ()
|
||||
)
|
||||
+ (expanded_database_path, attribute_name) # Ensure database and entry are last.
|
||||
)
|
||||
|
||||
return borgmatic.execute.execute_command_and_capture_output(command).rstrip(os.linesep)
|
||||
@@ -1,42 +1,127 @@
|
||||
import functools
|
||||
import re
|
||||
import shlex
|
||||
|
||||
import borgmatic.hooks.dispatch
|
||||
|
||||
IS_A_HOOK = False
|
||||
|
||||
|
||||
CREDENTIAL_PATTERN = re.compile(
|
||||
r'\{credential +(?P<hook_name>[A-Za-z0-9_]+) +(?P<credential_name>[A-Za-z0-9_]+)\}'
|
||||
)
|
||||
|
||||
GENERAL_CREDENTIAL_PATTERN = re.compile(r'\{credential( +[^}]*)?\}')
|
||||
|
||||
|
||||
@functools.cache
|
||||
def resolve_credential(value):
|
||||
class Hash_adapter:
|
||||
'''
|
||||
Given a configuration value containing a string like "{credential hookname credentialname}", resolve it by
|
||||
calling the relevant hook to get the actual credential value. If the given value does not
|
||||
actually contain a credential tag, then return it unchanged.
|
||||
A Hash_adapter instance wraps an unhashable object and pretends it's hashable. This is intended
|
||||
for passing to a @functools.cache-decorated function to prevent it from complaining that an
|
||||
argument is unhashable. It should only be used for arguments that you don't want to actually
|
||||
impact the cache hashing, because Hash_adapter doesn't actually hash the object's contents.
|
||||
|
||||
Cache the value so repeated calls to this function don't need to load the credential repeatedly.
|
||||
Example usage:
|
||||
|
||||
@functools.cache
|
||||
def func(a, b):
|
||||
print(a, b.actual_value)
|
||||
return a
|
||||
|
||||
func(5, Hash_adapter({1: 2, 3: 4})) # Calls func(), prints, and returns.
|
||||
func(5, Hash_adapter({1: 2, 3: 4})) # Hits the cache and just returns the value.
|
||||
func(5, Hash_adapter({5: 6, 7: 8})) # Also uses cache, since the Hash_adapter is ignored.
|
||||
|
||||
In the above function, the "b" value is one that has been wrapped with Hash_adappter, and
|
||||
therefore "b.actual_value" is necessary to access the original value.
|
||||
'''
|
||||
|
||||
def __init__(self, actual_value):
|
||||
self.actual_value = actual_value
|
||||
|
||||
def __eq__(self, other):
|
||||
return True
|
||||
|
||||
def __hash__(self):
|
||||
return 0
|
||||
|
||||
|
||||
UNHASHABLE_TYPES = (dict, list, set)
|
||||
|
||||
|
||||
def cache_ignoring_unhashable_arguments(function):
|
||||
'''
|
||||
A function decorator that caches calls to the decorated function but ignores any unhashable
|
||||
arguments when performing cache lookups. This is intended to be a drop-in replacement for
|
||||
functools.cache.
|
||||
|
||||
Example usage:
|
||||
|
||||
@cache_ignoring_unhashable_arguments
|
||||
def func(a, b):
|
||||
print(a, b)
|
||||
return a
|
||||
|
||||
func(5, {1: 2, 3: 4}) # Calls func(), prints, and returns.
|
||||
func(5, {1: 2, 3: 4}) # Hits the cache and just returns the value.
|
||||
func(5, {5: 6, 7: 8}) # Also uses cache, since the unhashable value (the dict) is ignored.
|
||||
'''
|
||||
|
||||
@functools.cache
|
||||
def cached_function(*args, **kwargs):
|
||||
return function(
|
||||
*(arg.actual_value if isinstance(arg, Hash_adapter) else arg for arg in args),
|
||||
**{
|
||||
key: value.actual_value if isinstance(value, Hash_adapter) else value
|
||||
for (key, value) in kwargs.items()
|
||||
},
|
||||
)
|
||||
|
||||
@functools.wraps(function)
|
||||
def wrapper_function(*args, **kwargs):
|
||||
return cached_function(
|
||||
*(Hash_adapter(arg) if isinstance(arg, UNHASHABLE_TYPES) else arg for arg in args),
|
||||
**{
|
||||
key: Hash_adapter(value) if isinstance(value, UNHASHABLE_TYPES) else value
|
||||
for (key, value) in kwargs.items()
|
||||
},
|
||||
)
|
||||
|
||||
wrapper_function.cache_clear = cached_function.cache_clear
|
||||
|
||||
return wrapper_function
|
||||
|
||||
|
||||
CREDENTIAL_PATTERN = re.compile(r'\{credential( +(?P<hook_and_parameters>.*))?\}')
|
||||
|
||||
|
||||
@cache_ignoring_unhashable_arguments
|
||||
def resolve_credential(value, config):
|
||||
'''
|
||||
Given a configuration value containing a string like "{credential hookname credentialname}" and
|
||||
a configuration dict, resolve the credential by calling the relevant hook to get the actual
|
||||
credential value. If the given value does not actually contain a credential tag, then return it
|
||||
unchanged.
|
||||
|
||||
Cache the value (ignoring the config for purposes of caching), so repeated calls to this
|
||||
function don't need to load the credential repeatedly.
|
||||
|
||||
Raise ValueError if the config could not be parsed or the credential could not be loaded.
|
||||
'''
|
||||
if value is None:
|
||||
return value
|
||||
|
||||
result = CREDENTIAL_PATTERN.sub(
|
||||
lambda matcher: borgmatic.hooks.dispatch.call_hook(
|
||||
'load_credential', {}, matcher.group('hook_name'), matcher.group('credential_name')
|
||||
),
|
||||
value,
|
||||
)
|
||||
matcher = CREDENTIAL_PATTERN.match(value)
|
||||
|
||||
# If we've tried to parse the credential, but the parsed result still looks kind of like a
|
||||
# credential, it means it's invalid syntax.
|
||||
if GENERAL_CREDENTIAL_PATTERN.match(result):
|
||||
if not matcher:
|
||||
return value
|
||||
|
||||
hook_and_parameters = matcher.group('hook_and_parameters')
|
||||
|
||||
if not hook_and_parameters:
|
||||
raise ValueError(f'Cannot load credential with invalid syntax "{value}"')
|
||||
|
||||
return result
|
||||
(hook_name, *credential_parameters) = shlex.split(hook_and_parameters)
|
||||
|
||||
if not credential_parameters:
|
||||
raise ValueError(f'Cannot load credential with invalid syntax "{value}"')
|
||||
|
||||
return borgmatic.hooks.dispatch.call_hook(
|
||||
'load_credential',
|
||||
config,
|
||||
hook_name,
|
||||
tuple(credential_parameters),
|
||||
)
|
||||
|
||||
@@ -1,33 +1,59 @@
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
|
||||
import borgmatic.execute
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
CREDENTIAL_NAME_PATTERN = re.compile(r'^\w+$')
|
||||
CREDENTIAL_NAME_PATTERN = re.compile(r'^[\w.-]+$')
|
||||
|
||||
|
||||
def load_credential(hook_config, config, credential_name):
|
||||
def load_credential(hook_config, config, credential_parameters):
|
||||
'''
|
||||
Given the hook configuration dict, the configuration dict, and a credential name to load, read
|
||||
the credential from the corresponding systemd credential file and return it.
|
||||
Given the hook configuration dict, the configuration dict, and a credential parameters tuple
|
||||
containing a credential name to load, read the credential from the corresponding systemd
|
||||
credential file and return it.
|
||||
|
||||
Raise ValueError if the systemd CREDENTIALS_DIRECTORY environment variable is not set, the
|
||||
credential name is invalid, or the credential file cannot be read.
|
||||
'''
|
||||
credentials_directory = os.environ.get('CREDENTIALS_DIRECTORY')
|
||||
try:
|
||||
(credential_name,) = credential_parameters
|
||||
except ValueError:
|
||||
name = ' '.join(credential_parameters)
|
||||
|
||||
if not credentials_directory:
|
||||
raise ValueError(
|
||||
f'Cannot load credential "{credential_name}" because the systemd CREDENTIALS_DIRECTORY environment variable is not set'
|
||||
)
|
||||
raise ValueError(f'Cannot load invalid credential name: "{name}"')
|
||||
|
||||
if not CREDENTIAL_NAME_PATTERN.match(credential_name):
|
||||
raise ValueError(f'Cannot load invalid credential name "{credential_name}"')
|
||||
|
||||
credentials_directory = os.environ.get('CREDENTIALS_DIRECTORY')
|
||||
|
||||
if not credentials_directory:
|
||||
logger.debug(
|
||||
f'Falling back to loading credential "{credential_name}" via systemd-creds because the systemd CREDENTIALS_DIRECTORY environment variable is not set'
|
||||
)
|
||||
|
||||
command = (
|
||||
*shlex.split((hook_config or {}).get('systemd_creds_command', 'systemd-creds')),
|
||||
'decrypt',
|
||||
os.path.join(
|
||||
(hook_config or {}).get(
|
||||
'encrypted_credentials_directory', '/etc/credstore.encrypted'
|
||||
),
|
||||
credential_name,
|
||||
),
|
||||
)
|
||||
|
||||
return borgmatic.execute.execute_command_and_capture_output(command).rstrip(os.linesep)
|
||||
|
||||
try:
|
||||
with open(os.path.join(credentials_directory, credential_name)) as credential_file:
|
||||
with open(
|
||||
os.path.join(credentials_directory, credential_name), encoding='utf-8'
|
||||
) as credential_file:
|
||||
return credential_file.read().rstrip(os.linesep)
|
||||
except (FileNotFoundError, OSError) as error:
|
||||
logger.warning(error)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import contextlib
|
||||
import glob
|
||||
import importlib
|
||||
import json
|
||||
@@ -38,7 +39,9 @@ def dump_data_sources(
|
||||
return []
|
||||
|
||||
borgmatic_manifest_path = os.path.join(
|
||||
borgmatic_runtime_directory, 'bootstrap', 'manifest.json'
|
||||
borgmatic_runtime_directory,
|
||||
'bootstrap',
|
||||
'manifest.json',
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
@@ -46,7 +49,7 @@ def dump_data_sources(
|
||||
|
||||
os.makedirs(os.path.dirname(borgmatic_manifest_path), exist_ok=True)
|
||||
|
||||
with open(borgmatic_manifest_path, 'w') as manifest_file:
|
||||
with open(borgmatic_manifest_path, 'w', encoding='utf-8') as manifest_file:
|
||||
json.dump(
|
||||
{
|
||||
'borgmatic_version': importlib.metadata.version('borgmatic'),
|
||||
@@ -55,19 +58,28 @@ def dump_data_sources(
|
||||
manifest_file,
|
||||
)
|
||||
|
||||
patterns.extend(borgmatic.borg.pattern.Pattern(config_path) for config_path in config_paths)
|
||||
patterns.extend(
|
||||
borgmatic.borg.pattern.Pattern(
|
||||
config_path,
|
||||
source=borgmatic.borg.pattern.Pattern_source.HOOK,
|
||||
)
|
||||
for config_path in config_paths
|
||||
)
|
||||
patterns.append(
|
||||
borgmatic.borg.pattern.Pattern(os.path.join(borgmatic_runtime_directory, 'bootstrap'))
|
||||
borgmatic.borg.pattern.Pattern(
|
||||
os.path.join(borgmatic_runtime_directory, 'bootstrap'),
|
||||
source=borgmatic.borg.pattern.Pattern_source.HOOK,
|
||||
),
|
||||
)
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def remove_data_source_dumps(hook_config, config, borgmatic_runtime_directory, dry_run):
|
||||
def remove_data_source_dumps(hook_config, config, borgmatic_runtime_directory, patterns, dry_run):
|
||||
'''
|
||||
Given a bootstrap configuration dict, a configuration dict, the borgmatic runtime directory, and
|
||||
whether this is a dry run, then remove the manifest file created above. If this is a dry run,
|
||||
then don't actually remove anything.
|
||||
Given a bootstrap configuration dict, a configuration dict, the borgmatic runtime directory, the
|
||||
configured patterns, 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 ''
|
||||
|
||||
@@ -78,7 +90,7 @@ def remove_data_source_dumps(hook_config, config, borgmatic_runtime_directory, d
|
||||
'bootstrap',
|
||||
)
|
||||
logger.debug(
|
||||
f'Looking for bootstrap manifest files to remove in {manifest_glob}{dry_run_label}'
|
||||
f'Looking for bootstrap manifest files to remove in {manifest_glob}{dry_run_label}',
|
||||
)
|
||||
|
||||
for manifest_directory in glob.glob(manifest_glob):
|
||||
@@ -88,19 +100,18 @@ def remove_data_source_dumps(hook_config, config, borgmatic_runtime_directory, d
|
||||
if dry_run:
|
||||
continue
|
||||
|
||||
try:
|
||||
with contextlib.suppress(FileNotFoundError):
|
||||
os.remove(manifest_file_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
try:
|
||||
with contextlib.suppress(FileNotFoundError):
|
||||
os.rmdir(manifest_directory)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def make_data_source_dump_patterns(
|
||||
hook_config, config, borgmatic_runtime_directory, name=None
|
||||
hook_config,
|
||||
config,
|
||||
borgmatic_runtime_directory,
|
||||
name=None,
|
||||
): # pragma: no cover
|
||||
'''
|
||||
Restores are implemented via the separate, purpose-specific "bootstrap" action rather than the
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import collections
|
||||
import functools
|
||||
import glob
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
@@ -21,59 +22,158 @@ def use_streaming(hook_config, config): # pragma: no cover
|
||||
return False
|
||||
|
||||
|
||||
def get_subvolume_mount_points(findmnt_command):
|
||||
@functools.cache
|
||||
def path_is_a_subvolume(btrfs_command, path):
|
||||
'''
|
||||
Given a findmnt command to run, get all sorted Btrfs subvolume mount points.
|
||||
Given a btrfs command and a path, return whether the path is a Btrfs subvolume. Return False if
|
||||
the btrfs command errors, which probably indicates there isn't a containing Btrfs subvolume for
|
||||
the given path.
|
||||
|
||||
As a performance optimization, multiple calls to this function with the same arguments are
|
||||
cached.
|
||||
'''
|
||||
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:
|
||||
borgmatic.execute.execute_command(
|
||||
(
|
||||
*btrfs_command.split(' '),
|
||||
'subvolume',
|
||||
'show',
|
||||
path,
|
||||
),
|
||||
output_log_level=None,
|
||||
close_fds=True,
|
||||
)
|
||||
# An error from the command (probably) indicates that the path is not actually a subvolume.
|
||||
except subprocess.CalledProcessError:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@functools.cache
|
||||
def get_subvolume_property(btrfs_command, subvolume_path, property_name):
|
||||
'''
|
||||
Given a btrfs command, a subvolume path, and a property name to lookup, return the value of the
|
||||
corresponding property.
|
||||
|
||||
Raise subprocess.CalledProcessError if the btrfs command errors.
|
||||
|
||||
As a performance optimization, multiple calls to this function with the same arguments are
|
||||
cached.
|
||||
'''
|
||||
output = borgmatic.execute.execute_command_and_capture_output(
|
||||
(
|
||||
*btrfs_command.split(' '),
|
||||
'property',
|
||||
'get',
|
||||
'-t', # Type.
|
||||
'subvol',
|
||||
subvolume_path,
|
||||
property_name,
|
||||
),
|
||||
close_fds=True,
|
||||
)
|
||||
|
||||
try:
|
||||
return tuple(
|
||||
sorted(filesystem['target'] for filesystem in json.loads(findmnt_output)['filesystems'])
|
||||
)
|
||||
except json.JSONDecodeError as error:
|
||||
raise ValueError(f'Invalid {findmnt_command} JSON output: {error}')
|
||||
except KeyError as error:
|
||||
raise ValueError(f'Invalid {findmnt_command} output: Missing key "{error}"')
|
||||
value = output.strip().split('=')[1]
|
||||
except IndexError:
|
||||
raise ValueError(f'Invalid {btrfs_command} property output')
|
||||
|
||||
return {
|
||||
'true': True,
|
||||
'false': False,
|
||||
}.get(value, value)
|
||||
|
||||
|
||||
def get_containing_subvolume_path(btrfs_command, path):
|
||||
'''
|
||||
Given a btrfs command and a path, return the subvolume path that contains the given path (or is
|
||||
the same as the path).
|
||||
|
||||
If there is no such subvolume path or the containing subvolume is read-only, return None.
|
||||
'''
|
||||
# Probe the given pattern's path and all of its parents, grandparents, etc. to try to find a
|
||||
# Btrfs subvolume.
|
||||
for candidate_path in (
|
||||
path,
|
||||
*tuple(str(ancestor) for ancestor in pathlib.PurePath(path).parents),
|
||||
):
|
||||
if not path_is_a_subvolume(btrfs_command, candidate_path):
|
||||
continue
|
||||
|
||||
try:
|
||||
if get_subvolume_property(btrfs_command, candidate_path, 'ro'):
|
||||
logger.debug(f'Ignoring Btrfs subvolume {candidate_path} because it is read-only')
|
||||
|
||||
return None
|
||||
|
||||
logger.debug(f'Path {candidate_path} is a Btrfs subvolume')
|
||||
|
||||
return candidate_path
|
||||
except subprocess.CalledProcessError as error:
|
||||
logger.debug(
|
||||
f'Error determining read-only status of Btrfs subvolume {candidate_path}: {error}',
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_all_subvolume_paths(btrfs_command, patterns):
|
||||
'''
|
||||
Given a btrfs command and a sequence of patterns, get the sorted paths for all Btrfs subvolumes
|
||||
containing those patterns.
|
||||
'''
|
||||
return tuple(
|
||||
sorted(
|
||||
{
|
||||
subvolume_path
|
||||
for pattern in patterns
|
||||
if pattern.type == borgmatic.borg.pattern.Pattern_type.ROOT
|
||||
if pattern.source == borgmatic.borg.pattern.Pattern_source.CONFIG
|
||||
for subvolume_path in (get_containing_subvolume_path(btrfs_command, pattern.path),)
|
||||
if subvolume_path
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
Subvolume = collections.namedtuple('Subvolume', ('path', 'contained_patterns'), defaults=((),))
|
||||
|
||||
|
||||
def get_subvolumes(btrfs_command, findmnt_command, patterns=None):
|
||||
def get_subvolumes(btrfs_command, patterns):
|
||||
'''
|
||||
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.
|
||||
between the current Btrfs filesystem and subvolume paths 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.
|
||||
Only include subvolumes that contain at least one root pattern sourced from borgmatic
|
||||
configuration (as opposed to generated elsewhere in borgmatic).
|
||||
|
||||
Return the result as a sequence of matching subvolume mount points.
|
||||
Return the result as a sequence of matching Subvolume instances.
|
||||
'''
|
||||
candidate_patterns = set(patterns or ())
|
||||
subvolumes = []
|
||||
|
||||
# For each subvolume mount point, match it against the given patterns to find the subvolumes to
|
||||
# backup. Sort the subvolumes from longest to shortest mount points, so longer mount points get
|
||||
# For each subvolume path, match it against the given patterns to find the subvolumes to
|
||||
# backup. Sort the subvolumes from longest to shortest mount points, so longer subvolumes get
|
||||
# a whack at the candidate pattern piñata before their parents do. (Patterns are consumed during
|
||||
# this process, so no two subvolumes end up with the same contained patterns.)
|
||||
for mount_point in reversed(get_subvolume_mount_points(findmnt_command)):
|
||||
for subvolume_path in reversed(get_all_subvolume_paths(btrfs_command, patterns)):
|
||||
subvolumes.extend(
|
||||
Subvolume(mount_point, contained_patterns)
|
||||
Subvolume(subvolume_path, contained_patterns)
|
||||
for contained_patterns in (
|
||||
borgmatic.hooks.data_source.snapshot.get_contained_patterns(
|
||||
mount_point, candidate_patterns
|
||||
subvolume_path,
|
||||
candidate_patterns,
|
||||
),
|
||||
)
|
||||
if patterns is None or contained_patterns
|
||||
if any(
|
||||
pattern.type == borgmatic.borg.pattern.Pattern_type.ROOT
|
||||
and pattern.source == borgmatic.borg.pattern.Pattern_source.CONFIG
|
||||
for pattern in contained_patterns
|
||||
)
|
||||
)
|
||||
|
||||
return tuple(sorted(subvolumes, key=lambda subvolume: subvolume.path))
|
||||
@@ -121,6 +221,7 @@ def make_snapshot_exclude_pattern(subvolume_path): # pragma: no cover
|
||||
),
|
||||
borgmatic.borg.pattern.Pattern_type.NO_RECURSE,
|
||||
borgmatic.borg.pattern.Pattern_style.FNMATCH,
|
||||
source=borgmatic.borg.pattern.Pattern_source.HOOK,
|
||||
)
|
||||
|
||||
|
||||
@@ -143,7 +244,10 @@ def make_borg_snapshot_pattern(subvolume_path, pattern):
|
||||
rewritten_path = initial_caret + os.path.join(
|
||||
subvolume_path,
|
||||
f'{BORGMATIC_SNAPSHOT_PREFIX}{os.getpid()}',
|
||||
'.', # Borg 1.4+ "slashdot" hack.
|
||||
# Use the Borg 1.4+ "slashdot" hack to prevent the snapshot path prefix from getting
|
||||
# included in the archive—but only if there's not already a slashdot hack present in the
|
||||
# pattern.
|
||||
('' if f'{os.path.sep}.{os.path.sep}' in pattern.path else '.'),
|
||||
# Included so that the source directory ends up in the Borg archive at its "original" path.
|
||||
pattern.path.lstrip('^').lstrip(os.path.sep),
|
||||
)
|
||||
@@ -153,6 +257,7 @@ def make_borg_snapshot_pattern(subvolume_path, pattern):
|
||||
pattern.type,
|
||||
pattern.style,
|
||||
pattern.device,
|
||||
source=borgmatic.borg.pattern.Pattern_source.HOOK,
|
||||
)
|
||||
|
||||
|
||||
@@ -164,8 +269,8 @@ def snapshot_subvolume(btrfs_command, subvolume_path, snapshot_path): # pragma:
|
||||
os.makedirs(os.path.dirname(snapshot_path), mode=0o700, exist_ok=True)
|
||||
|
||||
borgmatic.execute.execute_command(
|
||||
tuple(btrfs_command.split(' '))
|
||||
+ (
|
||||
(
|
||||
*btrfs_command.split(' '),
|
||||
'subvolume',
|
||||
'snapshot',
|
||||
'-r', # Read-only.
|
||||
@@ -173,6 +278,7 @@ def snapshot_subvolume(btrfs_command, subvolume_path, snapshot_path): # pragma:
|
||||
snapshot_path,
|
||||
),
|
||||
output_log_level=logging.DEBUG,
|
||||
close_fds=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -187,9 +293,9 @@ def dump_data_sources(
|
||||
'''
|
||||
Given a Btrfs configuration dict, a configuration dict, the borgmatic configuration file paths,
|
||||
the borgmatic runtime directory, the configured patterns, and whether this is a dry run,
|
||||
auto-detect and snapshot any Btrfs subvolume mount points listed in the given patterns. Also
|
||||
update those patterns, replacing subvolume mount points with corresponding snapshot directories
|
||||
so they get stored in the Borg archive instead.
|
||||
auto-detect and snapshot any Btrfs subvolume paths listed in the given patterns. Also update
|
||||
those patterns, replacing subvolume paths with corresponding snapshot directories so they get
|
||||
stored in the Borg archive instead.
|
||||
|
||||
Return an empty sequence, since there are no ongoing dump processes from this hook.
|
||||
|
||||
@@ -198,10 +304,15 @@ def dump_data_sources(
|
||||
dry_run_label = ' (dry run; not actually snapshotting anything)' if dry_run else ''
|
||||
logger.info(f'Snapshotting Btrfs subvolumes{dry_run_label}')
|
||||
|
||||
# Based on the configured patterns, determine Btrfs subvolumes to backup.
|
||||
if 'findmnt_command' in hook_config:
|
||||
logger.warning(
|
||||
'The Btrfs "findmnt_command" option is deprecated and will be removed from a future release; findmnt is no longer used',
|
||||
)
|
||||
|
||||
# Based on the configured patterns, determine Btrfs subvolumes to backup. Only consider those
|
||||
# patterns that came from actual user configuration (as opposed to, say, other hooks).
|
||||
btrfs_command = hook_config.get('btrfs_command', 'btrfs')
|
||||
findmnt_command = hook_config.get('findmnt_command', 'findmnt')
|
||||
subvolumes = get_subvolumes(btrfs_command, findmnt_command, patterns)
|
||||
subvolumes = get_subvolumes(btrfs_command, patterns)
|
||||
|
||||
if not subvolumes:
|
||||
logger.warning(f'No Btrfs subvolumes found to snapshot{dry_run_label}')
|
||||
@@ -236,21 +347,23 @@ 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(' '))
|
||||
+ (
|
||||
(
|
||||
*btrfs_command.split(' '),
|
||||
'subvolume',
|
||||
'delete',
|
||||
snapshot_path,
|
||||
),
|
||||
output_log_level=logging.DEBUG,
|
||||
close_fds=True,
|
||||
)
|
||||
|
||||
|
||||
def remove_data_source_dumps(hook_config, config, borgmatic_runtime_directory, dry_run):
|
||||
def remove_data_source_dumps(hook_config, config, borgmatic_runtime_directory, patterns, dry_run):
|
||||
'''
|
||||
Given a Btrfs configuration dict, a configuration dict, the borgmatic runtime directory, and
|
||||
whether this is a dry run, delete any Btrfs snapshots created by borgmatic. If this is a dry run
|
||||
or Btrfs isn't configured in borgmatic's configuration, then don't actually remove anything.
|
||||
Given a Btrfs configuration dict, a configuration dict, the borgmatic runtime directory, the
|
||||
configured patterns, and whether this is a dry run, delete any Btrfs snapshots created by
|
||||
borgmatic. If this is a dry run or Btrfs isn't configured in borgmatic's configuration, then
|
||||
don't actually remove anything.
|
||||
'''
|
||||
if hook_config is None:
|
||||
return
|
||||
@@ -258,10 +371,9 @@ def remove_data_source_dumps(hook_config, config, borgmatic_runtime_directory, d
|
||||
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)
|
||||
all_subvolumes = get_subvolumes(btrfs_command, patterns)
|
||||
except FileNotFoundError as error:
|
||||
logger.debug(f'Could not find "{error.filename}" command')
|
||||
return
|
||||
@@ -269,8 +381,8 @@ def remove_data_source_dumps(hook_config, config, borgmatic_runtime_directory, d
|
||||
logger.debug(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.
|
||||
# Reversing the sorted subvolumes ensures that we remove longer paths of child subvolumes before
|
||||
# the shorter 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)),
|
||||
@@ -278,7 +390,7 @@ def remove_data_source_dumps(hook_config, config, borgmatic_runtime_directory, d
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f'Looking for snapshots to remove in {subvolume_snapshots_glob}{dry_run_label}'
|
||||
f'Looking for snapshots to remove in {subvolume_snapshots_glob}{dry_run_label}',
|
||||
)
|
||||
|
||||
for snapshot_path in glob.glob(subvolume_snapshots_glob):
|
||||
@@ -299,13 +411,19 @@ def remove_data_source_dumps(hook_config, config, borgmatic_runtime_directory, d
|
||||
logger.debug(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])
|
||||
# Remove the snapshot parent directory if it still exists. (It might not exist if the
|
||||
# snapshot was for "/".)
|
||||
snapshot_parent_dir = snapshot_path.rsplit(subvolume.path, 1)[0]
|
||||
|
||||
if os.path.isdir(snapshot_parent_dir):
|
||||
shutil.rmtree(snapshot_parent_dir)
|
||||
|
||||
|
||||
def make_data_source_dump_patterns(
|
||||
hook_config, config, borgmatic_runtime_directory, name=None
|
||||
hook_config,
|
||||
config,
|
||||
borgmatic_runtime_directory,
|
||||
name=None,
|
||||
): # pragma: no cover
|
||||
'''
|
||||
Restores aren't implemented, because stored files can be extracted directly with "extract".
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
from borgmatic.execute import execute_command_and_capture_output
|
||||
|
||||
IS_A_HOOK = False
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def resolve_database_option(option, data_source, connection_params=None, restore=False):
|
||||
'''
|
||||
Resolves a database option from the given data source configuration dict and
|
||||
connection parameters dict. If restore is set to True it will consider the
|
||||
`restore_<option>` instead.
|
||||
|
||||
Returns the resolved option or None. Can raise a ValueError if the hostname lookup
|
||||
results in a container IP check.
|
||||
'''
|
||||
# Special case `hostname` since it overlaps with `container`
|
||||
if option == 'hostname':
|
||||
return get_hostname_from_config(data_source, connection_params, restore)
|
||||
if connection_params and (value := connection_params.get(option)):
|
||||
return value
|
||||
if restore and f'restore_{option}' in data_source:
|
||||
return data_source[f'restore_{option}']
|
||||
|
||||
return data_source.get(option)
|
||||
|
||||
|
||||
def get_hostname_from_config(data_source, connection_params=None, restore=False):
|
||||
'''
|
||||
Specialisation of `resolve_database_option` to handle the extra complexity of
|
||||
the hostname option to also handle containers.
|
||||
|
||||
Returns a hostname/IP or raises an ValueError if a container IP lookup fails.
|
||||
'''
|
||||
# connection params win, full stop
|
||||
if connection_params:
|
||||
if container := connection_params.get('container'):
|
||||
return get_ip_from_container(container)
|
||||
if hostname := connection_params.get('hostname'):
|
||||
return hostname
|
||||
# ... then try the restore config
|
||||
if restore:
|
||||
if 'restore_container' in data_source:
|
||||
return get_ip_from_container(data_source['restore_container'])
|
||||
if 'restore_hostname' in data_source:
|
||||
return data_source['restore_hostname']
|
||||
# ... and finally fall back to the normal options
|
||||
if 'container' in data_source:
|
||||
return get_ip_from_container(data_source['container'])
|
||||
|
||||
return data_source.get('hostname')
|
||||
|
||||
|
||||
def get_ip_from_container(container):
|
||||
'''
|
||||
Determine the IP for a given container name via podman and docker.
|
||||
|
||||
Returns an IP or raises a ValueError if the lookup fails.
|
||||
'''
|
||||
engines = (shutil.which(engine) for engine in ('docker', 'podman'))
|
||||
engines = [engine for engine in engines if engine]
|
||||
|
||||
if not engines:
|
||||
raise ValueError("Neither 'docker' nor 'podman' could be found on the system")
|
||||
|
||||
last_error = None
|
||||
for engine in engines:
|
||||
try:
|
||||
output = execute_command_and_capture_output(
|
||||
(
|
||||
engine,
|
||||
'container',
|
||||
'inspect',
|
||||
'--format={{json .NetworkSettings}}',
|
||||
container,
|
||||
)
|
||||
)
|
||||
except subprocess.CalledProcessError as error:
|
||||
last_error = error
|
||||
logger.debug(f"Could not find container '{container}' with engine '{engine}'")
|
||||
continue # Container does not exist
|
||||
|
||||
try:
|
||||
network_data = json.loads(output.strip())
|
||||
except json.JSONDecodeError as e:
|
||||
raise ValueError(f'Could not decode JSON output from {engine}') from e
|
||||
if main_ip := network_data.get('IPAddress'):
|
||||
return main_ip
|
||||
# No main IP found, try the networks
|
||||
for network in network_data.get('Networks', {}).values():
|
||||
if ip := network.get('IPAddress'):
|
||||
return ip
|
||||
|
||||
if last_error:
|
||||
raise last_error
|
||||
|
||||
raise ValueError(
|
||||
f"Could not determine ip address for container '{container}'; running in host mode or userspace networking?"
|
||||
)
|
||||
@@ -1,8 +1,11 @@
|
||||
import fnmatch
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
|
||||
import borgmatic.actions.restore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
IS_A_HOOK = False
|
||||
@@ -16,7 +19,9 @@ 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, port=None):
|
||||
def make_data_source_dump_filename(
|
||||
dump_path, name, hostname=None, port=None, container=None, label=None
|
||||
):
|
||||
'''
|
||||
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.
|
||||
@@ -26,9 +31,56 @@ def make_data_source_dump_filename(dump_path, name, hostname=None, port=None):
|
||||
if os.path.sep in name:
|
||||
raise ValueError(f'Invalid data source name {name}')
|
||||
|
||||
return os.path.join(
|
||||
dump_path, (hostname or 'localhost') + ('' if port is None else f':{port}'), name
|
||||
)
|
||||
if container:
|
||||
hostname = container
|
||||
elif not hostname:
|
||||
hostname = 'localhost'
|
||||
|
||||
identifier = label if label else hostname + ('' if port is None else f':{port}')
|
||||
|
||||
return os.path.join(dump_path, identifier, name)
|
||||
|
||||
|
||||
def write_data_source_dumps_metadata(borgmatic_runtime_directory, hook_name, dumps_metadata):
|
||||
'''
|
||||
Given the borgmatic runtime directory, a data source hook name, and a sequence of
|
||||
borgmatic.actions.restore.Dump instances of dump metadata, write a metadata file describing all
|
||||
of those dumps. This metadata is being dumped so that it's available upon restore, e.g. to
|
||||
support the user selecting which data source(s) should be restored.
|
||||
|
||||
Raise ValueError if writing to the file results in an operating system error.
|
||||
'''
|
||||
dumps_metadata_path = os.path.join(borgmatic_runtime_directory, hook_name, 'dumps.json')
|
||||
|
||||
try:
|
||||
with open(dumps_metadata_path, 'w', encoding='utf-8') as metadata_file:
|
||||
json.dump(
|
||||
{
|
||||
'dumps': [dump._asdict() for dump in dumps_metadata],
|
||||
},
|
||||
metadata_file,
|
||||
sort_keys=True,
|
||||
)
|
||||
except OSError as error:
|
||||
raise ValueError(f'Error writing to dumps metadata at {dumps_metadata_path}: {error}')
|
||||
|
||||
|
||||
def parse_data_source_dumps_metadata(dumps_json, dumps_metadata_path):
|
||||
'''
|
||||
Given a dumps metadata JSON string as extracted from an archive and its path within the archive,
|
||||
parse it into a tuple of borgmatic.actions.restore.Dump instances and return them.
|
||||
|
||||
Raise ValueError if parsing the JSON results in a JSON decode error or the data does not have
|
||||
the expected keys.
|
||||
'''
|
||||
try:
|
||||
return tuple(
|
||||
borgmatic.actions.restore.Dump(**dump) for dump in json.loads(dumps_json)['dumps']
|
||||
)
|
||||
except (json.JSONDecodeError, TypeError, KeyError) as error:
|
||||
raise ValueError(
|
||||
f'Cannot read archive data source dumps metadata at {dumps_metadata_path} due to invalid JSON: {error}',
|
||||
)
|
||||
|
||||
|
||||
def create_parent_directory_for_dump(dump_path):
|
||||
@@ -67,10 +119,11 @@ def convert_glob_patterns_to_borg_pattern(patterns):
|
||||
Convert a sequence of shell glob patterns like "/etc/*", "/tmp/*" to the corresponding Borg
|
||||
regular expression archive pattern as a single string like "re:etc/.*|tmp/.*".
|
||||
'''
|
||||
# Remove the "\Z" generated by fnmatch.translate() because we don't want the pattern to match
|
||||
# only at the end of a path, as directory format dumps require extracting files with paths
|
||||
# Remove the "\z" or "\Z" generated by fnmatch.translate() because we don't want the pattern to
|
||||
# match only at the end of a path, as directory format dumps require extracting files with paths
|
||||
# longer than the pattern. E.g., a pattern of "borgmatic/*/foo_databases/test" should also match
|
||||
# paths like "borgmatic/*/foo_databases/test/toc.dat"
|
||||
return 're:' + '|'.join(
|
||||
fnmatch.translate(pattern.lstrip('/')).replace('\\Z', '') for pattern in patterns
|
||||
fnmatch.translate(pattern.lstrip('/')).replace('\\z', '').replace('\\Z', '')
|
||||
for pattern in patterns
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import collections
|
||||
import glob
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -23,7 +24,8 @@ def use_streaming(hook_config, config): # pragma: no cover
|
||||
|
||||
BORGMATIC_SNAPSHOT_PREFIX = 'borgmatic-'
|
||||
Logical_volume = collections.namedtuple(
|
||||
'Logical_volume', ('name', 'device_path', 'mount_point', 'contained_patterns')
|
||||
'Logical_volume',
|
||||
('name', 'device_path', 'mount_point', 'contained_patterns'),
|
||||
)
|
||||
|
||||
|
||||
@@ -33,7 +35,9 @@ def get_logical_volumes(lsblk_command, patterns=None):
|
||||
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.
|
||||
Only include logical volumes that contain at least one root pattern sourced from borgmatic
|
||||
configuration (as opposed to generated elsewhere in borgmatic). But if patterns is None, include
|
||||
all logical volume mounts points instead, not just those in patterns.
|
||||
|
||||
Return the result as a sequence of Logical_volume instances.
|
||||
'''
|
||||
@@ -41,14 +45,15 @@ def get_logical_volumes(lsblk_command, patterns=None):
|
||||
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(' '))
|
||||
+ (
|
||||
(
|
||||
*lsblk_command.split(' '),
|
||||
'--output',
|
||||
'name,path,mountpoint,type',
|
||||
'--json',
|
||||
'--list',
|
||||
)
|
||||
)
|
||||
),
|
||||
close_fds=True,
|
||||
),
|
||||
)
|
||||
except json.JSONDecodeError as error:
|
||||
raise ValueError(f'Invalid {lsblk_command} JSON output: {error}')
|
||||
@@ -69,10 +74,16 @@ def get_logical_volumes(lsblk_command, patterns=None):
|
||||
if device['mountpoint'] and device['type'] == 'lvm'
|
||||
for contained_patterns in (
|
||||
borgmatic.hooks.data_source.snapshot.get_contained_patterns(
|
||||
device['mountpoint'], candidate_patterns
|
||||
device['mountpoint'],
|
||||
candidate_patterns,
|
||||
),
|
||||
)
|
||||
if not patterns or contained_patterns
|
||||
if not patterns
|
||||
or any(
|
||||
pattern.type == borgmatic.borg.pattern.Pattern_type.ROOT
|
||||
and pattern.source == borgmatic.borg.pattern.Pattern_source.CONFIG
|
||||
for pattern in contained_patterns
|
||||
)
|
||||
)
|
||||
except KeyError as error:
|
||||
raise ValueError(f'Invalid {lsblk_command} output: Missing key "{error}"')
|
||||
@@ -89,18 +100,19 @@ def snapshot_logical_volume(
|
||||
snapshot, and a snapshot size string, create a new LVM snapshot.
|
||||
'''
|
||||
borgmatic.execute.execute_command(
|
||||
tuple(lvcreate_command.split(' '))
|
||||
+ (
|
||||
(
|
||||
*lvcreate_command.split(' '),
|
||||
'--snapshot',
|
||||
('--extents' if '%' in snapshot_size else '--size'),
|
||||
snapshot_size,
|
||||
'--permission',
|
||||
'r', # Read-only.
|
||||
'rw', # Read-write in case an ext4 filesystem has orphaned files that need recovery.
|
||||
'--name',
|
||||
snapshot_name,
|
||||
logical_volume_device,
|
||||
),
|
||||
output_log_level=logging.DEBUG,
|
||||
close_fds=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -113,21 +125,26 @@ def mount_snapshot(mount_command, snapshot_device, snapshot_mount_path): # prag
|
||||
os.makedirs(snapshot_mount_path, mode=0o700, exist_ok=True)
|
||||
|
||||
borgmatic.execute.execute_command(
|
||||
tuple(mount_command.split(' '))
|
||||
+ (
|
||||
(
|
||||
*mount_command.split(' '),
|
||||
'-o',
|
||||
'ro',
|
||||
snapshot_device,
|
||||
snapshot_mount_path,
|
||||
),
|
||||
output_log_level=logging.DEBUG,
|
||||
close_fds=True,
|
||||
)
|
||||
|
||||
|
||||
def make_borg_snapshot_pattern(pattern, normalized_runtime_directory):
|
||||
MOUNT_POINT_HASH_LENGTH = 10
|
||||
|
||||
|
||||
def make_borg_snapshot_pattern(pattern, logical_volume, 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.
|
||||
Given a Borg pattern as a borgmatic.borg.pattern.Pattern instance and a Logical_volume
|
||||
containing it, return a new Pattern with its path rewritten to be in a snapshot directory based
|
||||
on both the given runtime directory and the given Logical_volume's mount point.
|
||||
|
||||
Move any initial caret in a regular expression pattern path to the beginning, so as not to break
|
||||
the regular expression.
|
||||
@@ -142,7 +159,17 @@ def make_borg_snapshot_pattern(pattern, normalized_runtime_directory):
|
||||
rewritten_path = initial_caret + os.path.join(
|
||||
normalized_runtime_directory,
|
||||
'lvm_snapshots',
|
||||
'.', # Borg 1.4+ "slashdot" hack.
|
||||
# Including this hash prevents conflicts between snapshot patterns for different logical
|
||||
# volumes. For instance, without this, snapshotting a logical volume at /var and another at
|
||||
# /var/spool would result in overlapping snapshot patterns and therefore colliding mount
|
||||
# attempts.
|
||||
hashlib.shake_256(logical_volume.mount_point.encode('utf-8')).hexdigest(
|
||||
MOUNT_POINT_HASH_LENGTH,
|
||||
),
|
||||
# Use the Borg 1.4+ "slashdot" hack to prevent the snapshot path prefix from getting
|
||||
# included in the archive—but only if there's not already a slashdot hack present in the
|
||||
# pattern.
|
||||
('' if f'{os.path.sep}.{os.path.sep}' in pattern.path else '.'),
|
||||
# Included so that the source directory ends up in the Borg archive at its "original" path.
|
||||
pattern.path.lstrip('^').lstrip(os.path.sep),
|
||||
)
|
||||
@@ -152,6 +179,7 @@ def make_borg_snapshot_pattern(pattern, normalized_runtime_directory):
|
||||
pattern.type,
|
||||
pattern.style,
|
||||
pattern.device,
|
||||
source=borgmatic.borg.pattern.Pattern_source.HOOK,
|
||||
)
|
||||
|
||||
|
||||
@@ -180,7 +208,8 @@ def dump_data_sources(
|
||||
dry_run_label = ' (dry run; not actually snapshotting anything)' if dry_run else ''
|
||||
logger.info(f'Snapshotting LVM logical volumes{dry_run_label}')
|
||||
|
||||
# List logical volumes to get their mount points.
|
||||
# List logical volumes to get their mount points, but only consider those patterns that came
|
||||
# from actual user configuration (as opposed to, say, other hooks).
|
||||
lsblk_command = hook_config.get('lsblk_command', 'lsblk')
|
||||
requested_logical_volumes = get_logical_volumes(lsblk_command, patterns)
|
||||
|
||||
@@ -194,7 +223,7 @@ def dump_data_sources(
|
||||
for logical_volume in requested_logical_volumes:
|
||||
snapshot_name = f'{logical_volume.name}_{snapshot_suffix}'
|
||||
logger.debug(
|
||||
f'Creating LVM snapshot {snapshot_name} of {logical_volume.mount_point}{dry_run_label}'
|
||||
f'Creating LVM snapshot {snapshot_name} of {logical_volume.mount_point}{dry_run_label}',
|
||||
)
|
||||
|
||||
if not dry_run:
|
||||
@@ -206,34 +235,45 @@ def dump_data_sources(
|
||||
)
|
||||
|
||||
# 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}')
|
||||
if not dry_run:
|
||||
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',
|
||||
hashlib.shake_256(logical_volume.mount_point.encode('utf-8')).hexdigest(
|
||||
MOUNT_POINT_HASH_LENGTH,
|
||||
),
|
||||
logical_volume.mount_point.lstrip(os.path.sep),
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f'Mounting LVM snapshot {snapshot_name} at {snapshot_mount_path}{dry_run_label}'
|
||||
f'Mounting LVM snapshot {snapshot_name} at {snapshot_mount_path}{dry_run_label}',
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
continue
|
||||
|
||||
mount_snapshot(
|
||||
hook_config.get('mount_command', 'mount'), snapshot.device_path, snapshot_mount_path
|
||||
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)
|
||||
snapshot_pattern = make_borg_snapshot_pattern(
|
||||
pattern,
|
||||
logical_volume,
|
||||
normalized_runtime_directory,
|
||||
)
|
||||
|
||||
# Attempt to update the pattern in place, since pattern order matters to Borg.
|
||||
try:
|
||||
@@ -249,8 +289,9 @@ 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,),
|
||||
(*umount_command.split(' '), snapshot_mount_path),
|
||||
output_log_level=logging.DEBUG,
|
||||
close_fds=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -259,12 +300,13 @@ 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(' '))
|
||||
+ (
|
||||
(
|
||||
*lvremove_command.split(' '),
|
||||
'--force', # Suppress an interactive "are you sure?" type prompt.
|
||||
snapshot_device_path,
|
||||
),
|
||||
output_log_level=logging.DEBUG,
|
||||
close_fds=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -284,16 +326,17 @@ def get_snapshots(lvs_command, snapshot_name=None):
|
||||
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(' '))
|
||||
+ (
|
||||
(
|
||||
*lvs_command.split(' '),
|
||||
'--report-format',
|
||||
'json',
|
||||
'--options',
|
||||
'lv_name,lv_path',
|
||||
'--select',
|
||||
'lv_attr =~ ^s', # Filter to just snapshots.
|
||||
)
|
||||
)
|
||||
),
|
||||
close_fds=True,
|
||||
),
|
||||
)
|
||||
except json.JSONDecodeError as error:
|
||||
raise ValueError(f'Invalid {lvs_command} JSON output: {error}')
|
||||
@@ -310,12 +353,12 @@ def get_snapshots(lvs_command, snapshot_name=None):
|
||||
raise ValueError(f'Invalid {lvs_command} output: Missing key "{error}"')
|
||||
|
||||
|
||||
def remove_data_source_dumps(hook_config, config, borgmatic_runtime_directory, dry_run):
|
||||
def remove_data_source_dumps(hook_config, config, borgmatic_runtime_directory, patterns, dry_run): # noqa: PLR0912
|
||||
'''
|
||||
Given an LVM configuration dict, a configuration dict, the borgmatic runtime directory, and
|
||||
whether this is a dry run, unmount and delete any LVM snapshots created by borgmatic. If this is
|
||||
a dry run or LVM isn't configured in borgmatic's configuration, then don't actually remove
|
||||
anything.
|
||||
Given an LVM configuration dict, a configuration dict, the borgmatic runtime directory, the
|
||||
configured patterns, and whether this is a dry run, unmount and delete any LVM snapshots created
|
||||
by borgmatic. If this is a dry run or LVM isn't configured in borgmatic's configuration, then
|
||||
don't actually remove anything.
|
||||
'''
|
||||
if hook_config is None:
|
||||
return
|
||||
@@ -337,6 +380,7 @@ def remove_data_source_dumps(hook_config, config, borgmatic_runtime_directory, d
|
||||
os.path.normpath(borgmatic_runtime_directory),
|
||||
),
|
||||
'lvm_snapshots',
|
||||
'*',
|
||||
)
|
||||
logger.debug(f'Looking for snapshots to remove in {snapshots_glob}{dry_run_label}')
|
||||
umount_command = hook_config.get('umount_command', 'umount')
|
||||
@@ -347,9 +391,13 @@ def remove_data_source_dumps(hook_config, config, borgmatic_runtime_directory, d
|
||||
|
||||
for logical_volume in logical_volumes:
|
||||
snapshot_mount_path = os.path.join(
|
||||
snapshots_directory, logical_volume.mount_point.lstrip(os.path.sep)
|
||||
snapshots_directory,
|
||||
logical_volume.mount_point.lstrip(os.path.sep),
|
||||
)
|
||||
if not os.path.isdir(snapshot_mount_path):
|
||||
|
||||
# If the snapshot mount path is empty, this is probably just a "shadow" of a nested
|
||||
# logical volume and therefore there's nothing to unmount.
|
||||
if not os.path.isdir(snapshot_mount_path) or not os.listdir(snapshot_mount_path):
|
||||
continue
|
||||
|
||||
# This might fail if the directory is already mounted, but we swallow errors here since
|
||||
@@ -374,10 +422,10 @@ def remove_data_source_dumps(hook_config, config, borgmatic_runtime_directory, d
|
||||
return
|
||||
except subprocess.CalledProcessError as error:
|
||||
logger.debug(error)
|
||||
return
|
||||
continue
|
||||
|
||||
if not dry_run:
|
||||
shutil.rmtree(snapshots_directory)
|
||||
shutil.rmtree(snapshots_directory, ignore_errors=True)
|
||||
|
||||
# Delete snapshots.
|
||||
lvremove_command = hook_config.get('lvremove_command', 'lvremove')
|
||||
@@ -403,7 +451,10 @@ def remove_data_source_dumps(hook_config, config, borgmatic_runtime_directory, d
|
||||
|
||||
|
||||
def make_data_source_dump_patterns(
|
||||
hook_config, config, borgmatic_runtime_directory, name=None
|
||||
hook_config,
|
||||
config,
|
||||
borgmatic_runtime_directory,
|
||||
name=None,
|
||||
): # pragma: no cover
|
||||
'''
|
||||
Restores aren't implemented, because stored files can be extracted directly with "extract".
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import copy
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
|
||||
import borgmatic.borg.pattern
|
||||
@@ -11,6 +12,7 @@ from borgmatic.execute import (
|
||||
execute_command_and_capture_output,
|
||||
execute_command_with_processes,
|
||||
)
|
||||
from borgmatic.hooks.data_source import config as database_config
|
||||
from borgmatic.hooks.data_source import dump
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -23,41 +25,126 @@ def make_dump_path(base_directory): # pragma: no cover
|
||||
return dump.make_data_source_dump_path(base_directory, 'mariadb_databases')
|
||||
|
||||
|
||||
SYSTEM_DATABASE_NAMES = ('information_schema', 'mysql', 'performance_schema', 'sys')
|
||||
DEFAULTS_EXTRA_FILE_FLAG_PATTERN = re.compile(r'^--defaults-extra-file=(?P<filename>.*)$')
|
||||
|
||||
|
||||
def database_names_to_dump(database, extra_environment, dry_run):
|
||||
def parse_extra_options(extra_options):
|
||||
'''
|
||||
Given a requested database config, return the corresponding sequence of database names to dump.
|
||||
In the case of "all", query for the names of databases on the configured host and return them,
|
||||
excluding any system databases that will cause problems during restore.
|
||||
Given an extra options string, split the options into a tuple and return it. Additionally, if
|
||||
the first option is "--defaults-extra-file=...", then remove it from the options and return the
|
||||
filename.
|
||||
|
||||
So the return value is a tuple of: (parsed options, defaults extra filename).
|
||||
|
||||
The intent is to support downstream merging of multiple "--defaults-extra-file"s, as
|
||||
MariaDB/MySQL only allows one at a time.
|
||||
'''
|
||||
split_extra_options = tuple(shlex.split(extra_options)) if extra_options else ()
|
||||
|
||||
if not split_extra_options:
|
||||
return ((), None)
|
||||
|
||||
match = DEFAULTS_EXTRA_FILE_FLAG_PATTERN.match(split_extra_options[0])
|
||||
|
||||
if not match:
|
||||
return (split_extra_options, None)
|
||||
|
||||
return (split_extra_options[1:], match.group('filename'))
|
||||
|
||||
|
||||
def make_defaults_file_options(username=None, password=None, defaults_extra_filename=None):
|
||||
'''
|
||||
Given a database username and/or password, write it to an anonymous pipe and return the flags
|
||||
for passing that file descriptor to an executed command. The idea is that this is a more secure
|
||||
way to transmit credentials to a database client than using an environment variable.
|
||||
|
||||
If no username or password are given, then return the options for the given defaults extra
|
||||
filename (if any). But if there is a username and/or password and a defaults extra filename is
|
||||
given, then "!include" it from the generated file, effectively allowing multiple defaults extra
|
||||
files.
|
||||
|
||||
Do not use the returned value for multiple different command invocations. That will not work
|
||||
because each pipe is "used up" once read.
|
||||
'''
|
||||
escaped_password = None if password is None else password.replace('\\', '\\\\')
|
||||
|
||||
values = '\n'.join(
|
||||
(
|
||||
(f'user={username}' if username is not None else ''),
|
||||
(f'password="{escaped_password}"' if escaped_password is not None else ''),
|
||||
),
|
||||
).strip()
|
||||
|
||||
if not values:
|
||||
if defaults_extra_filename:
|
||||
return (f'--defaults-extra-file={defaults_extra_filename}',)
|
||||
|
||||
return ()
|
||||
|
||||
fields_message = ' and '.join(
|
||||
field_name
|
||||
for field_name in (
|
||||
(f'username ({username})' if username is not None else None),
|
||||
('password' if password is not None else None),
|
||||
)
|
||||
if field_name is not None
|
||||
)
|
||||
include_message = f' (including {defaults_extra_filename})' if defaults_extra_filename else ''
|
||||
logger.debug(f'Writing database {fields_message} to defaults extra file pipe{include_message}')
|
||||
|
||||
include = f'!include {defaults_extra_filename}\n' if defaults_extra_filename else ''
|
||||
|
||||
read_file_descriptor, write_file_descriptor = os.pipe()
|
||||
os.write(write_file_descriptor, f'{include}[client]\n{values}'.encode())
|
||||
os.close(write_file_descriptor)
|
||||
|
||||
# This plus subprocess.Popen(..., close_fds=False) in execute.py is necessary for the database
|
||||
# client child process to inherit the file descriptor.
|
||||
os.set_inheritable(read_file_descriptor, True)
|
||||
|
||||
return (f'--defaults-extra-file=/dev/fd/{read_file_descriptor}',)
|
||||
|
||||
|
||||
def database_names_to_dump(database, config, username, password, environment, dry_run):
|
||||
'''
|
||||
Given a requested database config, a configuration dict, a database username and password, an
|
||||
environment dict, and whether this is a dry run, return the corresponding sequence of database
|
||||
names to dump. In the case of "all", query for the names of databases on the configured host and
|
||||
return them, excluding any system databases that will cause problems during restore.
|
||||
'''
|
||||
if database['name'] != 'all':
|
||||
return (database['name'],)
|
||||
|
||||
if dry_run:
|
||||
return ()
|
||||
|
||||
mariadb_show_command = tuple(
|
||||
shlex.quote(part) for part in shlex.split(database.get('mariadb_command') or 'mariadb')
|
||||
)
|
||||
extra_options, defaults_extra_filename = parse_extra_options(database.get('list_options'))
|
||||
password_transport = database.get('password_transport', 'pipe')
|
||||
hostname = database_config.resolve_database_option('hostname', database)
|
||||
show_command = (
|
||||
mariadb_show_command
|
||||
+ (tuple(database['list_options'].split(' ')) if 'list_options' in database else ())
|
||||
+ (('--host', database['hostname']) if 'hostname' in database else ())
|
||||
+ (('--port', str(database['port'])) if 'port' in database else ())
|
||||
+ (('--protocol', 'tcp') if 'hostname' in database or 'port' in database else ())
|
||||
+ (
|
||||
('--user', borgmatic.hooks.credential.parse.resolve_credential(database['username']))
|
||||
if 'username' in database
|
||||
make_defaults_file_options(username, password, defaults_extra_filename)
|
||||
if password_transport == 'pipe'
|
||||
else ()
|
||||
)
|
||||
+ extra_options
|
||||
+ (('--host', hostname) if hostname else ())
|
||||
+ (('--port', str(database['port'])) if 'port' in database else ())
|
||||
+ (('--protocol', 'tcp') if hostname or 'port' in database else ())
|
||||
+ (('--user', username) if username and password_transport == 'environment' else ())
|
||||
+ (('--ssl',) if database.get('tls') is True else ())
|
||||
+ (('--skip-ssl',) if database.get('tls') is False else ())
|
||||
+ ('--skip-column-names', '--batch')
|
||||
+ ('--execute', 'show schemas')
|
||||
)
|
||||
|
||||
logger.debug('Querying for "all" MariaDB databases to dump')
|
||||
show_output = execute_command_and_capture_output(
|
||||
show_command, extra_environment=extra_environment
|
||||
)
|
||||
|
||||
show_output = execute_command_and_capture_output(show_command, environment=environment)
|
||||
|
||||
return tuple(
|
||||
show_name
|
||||
@@ -66,8 +153,19 @@ def database_names_to_dump(database, extra_environment, dry_run):
|
||||
)
|
||||
|
||||
|
||||
SYSTEM_DATABASE_NAMES = ('information_schema', 'mysql', 'performance_schema', 'sys')
|
||||
|
||||
|
||||
def execute_dump_command(
|
||||
database, dump_path, database_names, extra_environment, dry_run, dry_run_label
|
||||
database,
|
||||
config,
|
||||
username,
|
||||
password,
|
||||
dump_path,
|
||||
database_names,
|
||||
environment,
|
||||
dry_run,
|
||||
dry_run_label,
|
||||
):
|
||||
'''
|
||||
Kick off a dump for the given MariaDB database (provided as a configuration dict) to a named
|
||||
@@ -80,13 +178,15 @@ def execute_dump_command(
|
||||
dump_filename = dump.make_data_source_dump_filename(
|
||||
dump_path,
|
||||
database['name'],
|
||||
database.get('hostname'),
|
||||
database.get('port'),
|
||||
hostname=database.get('hostname'),
|
||||
port=database.get('port'),
|
||||
container=database.get('container'),
|
||||
label=database.get('label'),
|
||||
)
|
||||
|
||||
if os.path.exists(dump_filename):
|
||||
logger.warning(
|
||||
f'Skipping duplicate dump of MariaDB database "{database_name}" to {dump_filename}'
|
||||
f'Skipping duplicate dump of MariaDB database "{database_name}" to {dump_filename}',
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -94,18 +194,24 @@ def execute_dump_command(
|
||||
shlex.quote(part)
|
||||
for part in shlex.split(database.get('mariadb_dump_command') or 'mariadb-dump')
|
||||
)
|
||||
extra_options, defaults_extra_filename = parse_extra_options(database.get('options'))
|
||||
password_transport = database.get('password_transport', 'pipe')
|
||||
hostname = database_config.resolve_database_option('hostname', database)
|
||||
dump_command = (
|
||||
mariadb_dump_command
|
||||
+ (tuple(database['options'].split(' ')) if 'options' in database else ())
|
||||
+ (('--add-drop-database',) if database.get('add_drop_database', True) else ())
|
||||
+ (('--host', database['hostname']) if 'hostname' in database else ())
|
||||
+ (('--port', str(database['port'])) if 'port' in database else ())
|
||||
+ (('--protocol', 'tcp') if 'hostname' in database or 'port' in database else ())
|
||||
+ (
|
||||
('--user', borgmatic.hooks.credential.parse.resolve_credential(database['username']))
|
||||
if 'username' in database
|
||||
make_defaults_file_options(username, password, defaults_extra_filename)
|
||||
if password_transport == 'pipe'
|
||||
else ()
|
||||
)
|
||||
+ extra_options
|
||||
+ (('--add-drop-database',) if database.get('add_drop_database', True) else ())
|
||||
+ (('--host', hostname) if hostname else ())
|
||||
+ (('--port', str(database['port'])) if 'port' in database else ())
|
||||
+ (('--protocol', 'tcp') if hostname or 'port' in database else ())
|
||||
+ (('--user', username) if username and password_transport == 'environment' else ())
|
||||
+ (('--ssl',) if database.get('tls') is True else ())
|
||||
+ (('--skip-ssl',) if database.get('tls') is False else ())
|
||||
+ ('--databases',)
|
||||
+ database_names
|
||||
+ ('--result-file', dump_filename)
|
||||
@@ -119,7 +225,7 @@ def execute_dump_command(
|
||||
|
||||
return execute_command(
|
||||
dump_command,
|
||||
extra_environment=extra_environment,
|
||||
environment=environment,
|
||||
run_to_completion=False,
|
||||
)
|
||||
|
||||
@@ -156,17 +262,36 @@ def dump_data_sources(
|
||||
'''
|
||||
dry_run_label = ' (dry run; not actually dumping anything)' if dry_run else ''
|
||||
processes = []
|
||||
dumps_metadata = []
|
||||
|
||||
logger.info(f'Dumping MariaDB databases{dry_run_label}')
|
||||
dump_path = make_dump_path(borgmatic_runtime_directory)
|
||||
|
||||
for database in databases:
|
||||
dump_path = make_dump_path(borgmatic_runtime_directory)
|
||||
extra_environment = (
|
||||
{'MYSQL_PWD': borgmatic.hooks.credential.parse.resolve_credential(database['password'])}
|
||||
if 'password' in database
|
||||
else None
|
||||
username = borgmatic.hooks.credential.parse.resolve_credential(
|
||||
database.get('username'),
|
||||
config,
|
||||
)
|
||||
password = borgmatic.hooks.credential.parse.resolve_credential(
|
||||
database.get('password'),
|
||||
config,
|
||||
)
|
||||
environment = dict(
|
||||
os.environ,
|
||||
**(
|
||||
{'MYSQL_PWD': password}
|
||||
if password and database.get('password_transport') == 'environment'
|
||||
else {}
|
||||
),
|
||||
)
|
||||
dump_database_names = database_names_to_dump(
|
||||
database,
|
||||
config,
|
||||
username,
|
||||
password,
|
||||
environment,
|
||||
dry_run,
|
||||
)
|
||||
dump_database_names = database_names_to_dump(database, extra_environment, dry_run)
|
||||
|
||||
if not dump_database_names:
|
||||
if dry_run:
|
||||
@@ -175,43 +300,77 @@ def dump_data_sources(
|
||||
raise ValueError('Cannot find any MariaDB databases to dump.')
|
||||
|
||||
if database['name'] == 'all' and database.get('format'):
|
||||
for dump_name in dump_database_names:
|
||||
for database_name in dump_database_names:
|
||||
dumps_metadata.append(
|
||||
borgmatic.actions.restore.Dump(
|
||||
'mariadb_databases',
|
||||
database_name,
|
||||
database.get('hostname'),
|
||||
database.get('port'),
|
||||
database.get('label'),
|
||||
database.get('container'),
|
||||
)
|
||||
)
|
||||
renamed_database = copy.copy(database)
|
||||
renamed_database['name'] = dump_name
|
||||
renamed_database['name'] = database_name
|
||||
processes.append(
|
||||
execute_dump_command(
|
||||
renamed_database,
|
||||
config,
|
||||
username,
|
||||
password,
|
||||
dump_path,
|
||||
(dump_name,),
|
||||
extra_environment,
|
||||
(database_name,),
|
||||
environment,
|
||||
dry_run,
|
||||
dry_run_label,
|
||||
)
|
||||
),
|
||||
)
|
||||
else:
|
||||
dumps_metadata.append(
|
||||
borgmatic.actions.restore.Dump(
|
||||
'mariadb_databases',
|
||||
database['name'],
|
||||
database.get('hostname'),
|
||||
database.get('port'),
|
||||
database.get('label'),
|
||||
database.get('container'),
|
||||
)
|
||||
)
|
||||
processes.append(
|
||||
execute_dump_command(
|
||||
database,
|
||||
config,
|
||||
username,
|
||||
password,
|
||||
dump_path,
|
||||
dump_database_names,
|
||||
extra_environment,
|
||||
environment,
|
||||
dry_run,
|
||||
dry_run_label,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
if not dry_run:
|
||||
dump.write_data_source_dumps_metadata(
|
||||
borgmatic_runtime_directory, 'mariadb_databases', dumps_metadata
|
||||
)
|
||||
patterns.append(
|
||||
borgmatic.borg.pattern.Pattern(
|
||||
os.path.join(borgmatic_runtime_directory, 'mariadb_databases')
|
||||
)
|
||||
os.path.join(borgmatic_runtime_directory, 'mariadb_databases'),
|
||||
source=borgmatic.borg.pattern.Pattern_source.HOOK,
|
||||
),
|
||||
)
|
||||
|
||||
return [process for process in processes if process]
|
||||
|
||||
|
||||
def remove_data_source_dumps(
|
||||
databases, config, borgmatic_runtime_directory, dry_run
|
||||
databases,
|
||||
config,
|
||||
borgmatic_runtime_directory,
|
||||
patterns,
|
||||
dry_run,
|
||||
): # pragma: no cover
|
||||
'''
|
||||
Remove all database dump files for this hook regardless of the given databases. Use the
|
||||
@@ -222,7 +381,10 @@ def remove_data_source_dumps(
|
||||
|
||||
|
||||
def make_data_source_dump_patterns(
|
||||
databases, config, borgmatic_runtime_directory, name=None
|
||||
databases,
|
||||
config,
|
||||
borgmatic_runtime_directory,
|
||||
name=None,
|
||||
): # pragma: no cover
|
||||
'''
|
||||
Given a sequence of configurations dicts, a configuration dict, the borgmatic runtime directory,
|
||||
@@ -232,12 +394,16 @@ def make_data_source_dump_patterns(
|
||||
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('borgmatic'), name, label='*'),
|
||||
dump.make_data_source_dump_filename(
|
||||
make_dump_path(borgmatic_runtime_directory), name, hostname='*'
|
||||
make_dump_path(borgmatic_runtime_directory),
|
||||
name,
|
||||
label='*',
|
||||
),
|
||||
dump.make_data_source_dump_filename(
|
||||
make_dump_path(borgmatic_source_directory), name, hostname='*'
|
||||
make_dump_path(borgmatic_source_directory),
|
||||
name,
|
||||
label='*',
|
||||
),
|
||||
)
|
||||
|
||||
@@ -258,38 +424,51 @@ def restore_data_source_dump(
|
||||
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(
|
||||
'restore_hostname', data_source.get('hostname')
|
||||
hostname = database_config.resolve_database_option(
|
||||
'hostname', data_source, connection_params, restore=True
|
||||
)
|
||||
port = str(
|
||||
connection_params['port'] or data_source.get('restore_port', data_source.get('port', ''))
|
||||
port = database_config.resolve_database_option(
|
||||
'port', data_source, connection_params, restore=True
|
||||
)
|
||||
tls = database_config.resolve_database_option('tls', data_source, restore=True)
|
||||
username = borgmatic.hooks.credential.parse.resolve_credential(
|
||||
connection_params['username']
|
||||
or data_source.get('restore_username', data_source.get('username'))
|
||||
database_config.resolve_database_option(
|
||||
'username', data_source, connection_params, restore=True
|
||||
),
|
||||
config,
|
||||
)
|
||||
password = borgmatic.hooks.credential.parse.resolve_credential(
|
||||
connection_params['password']
|
||||
or data_source.get('restore_password', data_source.get('password'))
|
||||
database_config.resolve_database_option(
|
||||
'password', data_source, connection_params, restore=True
|
||||
),
|
||||
config,
|
||||
)
|
||||
|
||||
mariadb_restore_command = tuple(
|
||||
shlex.quote(part) for part in shlex.split(data_source.get('mariadb_command') or 'mariadb')
|
||||
)
|
||||
extra_options, defaults_extra_filename = parse_extra_options(data_source.get('restore_options'))
|
||||
password_transport = data_source.get('password_transport', 'pipe')
|
||||
restore_command = (
|
||||
mariadb_restore_command
|
||||
+ ('--batch',)
|
||||
+ (
|
||||
tuple(data_source['restore_options'].split(' '))
|
||||
if 'restore_options' in data_source
|
||||
make_defaults_file_options(username, password, defaults_extra_filename)
|
||||
if password_transport == 'pipe'
|
||||
else ()
|
||||
)
|
||||
+ extra_options
|
||||
+ ('--batch',)
|
||||
+ (('--host', hostname) if hostname else ())
|
||||
+ (('--port', str(port)) if port else ())
|
||||
+ (('--protocol', 'tcp') if hostname or port else ())
|
||||
+ (('--user', username) if username else ())
|
||||
+ (('--user', username) if username and password_transport == 'environment' else ())
|
||||
+ (('--ssl',) if tls is True else ())
|
||||
+ (('--skip-ssl',) if tls is False else ())
|
||||
)
|
||||
environment = dict(
|
||||
os.environ,
|
||||
**({'MYSQL_PWD': password} if password and password_transport == 'environment' else {}),
|
||||
)
|
||||
extra_environment = {'MYSQL_PWD': password} if password else None
|
||||
|
||||
logger.debug(f"Restoring MariaDB database {data_source['name']}{dry_run_label}")
|
||||
if dry_run:
|
||||
@@ -302,5 +481,5 @@ def restore_data_source_dump(
|
||||
[extract_process],
|
||||
output_log_level=logging.DEBUG,
|
||||
input_file=extract_process.stdout,
|
||||
extra_environment=extra_environment,
|
||||
environment=environment,
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ import borgmatic.borg.pattern
|
||||
import borgmatic.config.paths
|
||||
import borgmatic.hooks.credential.parse
|
||||
from borgmatic.execute import execute_command, execute_command_with_processes
|
||||
from borgmatic.hooks.data_source import config as database_config
|
||||
from borgmatic.hooks.data_source import dump
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -53,13 +54,28 @@ def dump_data_sources(
|
||||
logger.info(f'Dumping MongoDB databases{dry_run_label}')
|
||||
|
||||
processes = []
|
||||
dumps_metadata = []
|
||||
|
||||
for database in databases:
|
||||
name = database['name']
|
||||
dumps_metadata.append(
|
||||
borgmatic.actions.restore.Dump(
|
||||
'mongodb_databases',
|
||||
name,
|
||||
database.get('hostname'),
|
||||
database.get('port'),
|
||||
database.get('label'),
|
||||
database.get('container'),
|
||||
)
|
||||
)
|
||||
|
||||
dump_filename = dump.make_data_source_dump_filename(
|
||||
make_dump_path(borgmatic_runtime_directory),
|
||||
name,
|
||||
database.get('hostname'),
|
||||
database.get('port'),
|
||||
hostname=database.get('hostname'),
|
||||
port=database.get('port'),
|
||||
container=database.get('container'),
|
||||
label=database.get('label'),
|
||||
)
|
||||
dump_format = database.get('format', 'archive')
|
||||
|
||||
@@ -69,58 +85,89 @@ def dump_data_sources(
|
||||
if dry_run:
|
||||
continue
|
||||
|
||||
command = build_dump_command(database, dump_filename, dump_format)
|
||||
command = build_dump_command(database, config, dump_filename, dump_format)
|
||||
|
||||
if dump_format == 'directory':
|
||||
dump.create_parent_directory_for_dump(dump_filename)
|
||||
execute_command(command, shell=True)
|
||||
execute_command(command, shell=True) # noqa: S604
|
||||
else:
|
||||
dump.create_named_pipe_for_dump(dump_filename)
|
||||
processes.append(execute_command(command, shell=True, run_to_completion=False))
|
||||
processes.append(
|
||||
execute_command(command, shell=True, run_to_completion=False), # noqa: S604
|
||||
)
|
||||
|
||||
if not dry_run:
|
||||
dump.write_data_source_dumps_metadata(
|
||||
borgmatic_runtime_directory, 'mongodb_databases', dumps_metadata
|
||||
)
|
||||
patterns.append(
|
||||
borgmatic.borg.pattern.Pattern(
|
||||
os.path.join(borgmatic_runtime_directory, 'mongodb_databases')
|
||||
)
|
||||
os.path.join(borgmatic_runtime_directory, 'mongodb_databases'),
|
||||
source=borgmatic.borg.pattern.Pattern_source.HOOK,
|
||||
),
|
||||
)
|
||||
|
||||
return processes
|
||||
|
||||
|
||||
def build_dump_command(database, dump_filename, dump_format):
|
||||
def make_password_config_file(password):
|
||||
'''
|
||||
Return the mongodump command from a single database configuration.
|
||||
Given a database password, write it as a MongoDB configuration file to an anonymous pipe and
|
||||
return its filename. The idea is that this is a more secure way to transmit a password to
|
||||
MongoDB than providing it directly on the command-line.
|
||||
|
||||
Do not use the returned value for multiple different command invocations. That will not work
|
||||
because each pipe is "used up" once read.
|
||||
'''
|
||||
logger.debug('Writing MongoDB password to configuration file pipe')
|
||||
|
||||
read_file_descriptor, write_file_descriptor = os.pipe()
|
||||
os.write(write_file_descriptor, f'password: {password}'.encode())
|
||||
os.close(write_file_descriptor)
|
||||
|
||||
# This plus subprocess.Popen(..., close_fds=False) in execute.py is necessary for the database
|
||||
# client child process to inherit the file descriptor.
|
||||
os.set_inheritable(read_file_descriptor, True)
|
||||
|
||||
return f'/dev/fd/{read_file_descriptor}'
|
||||
|
||||
|
||||
def build_dump_command(database, config, dump_filename, dump_format):
|
||||
'''
|
||||
Return the custom mongodump_command from a single database configuration.
|
||||
'''
|
||||
all_databases = database['name'] == 'all'
|
||||
|
||||
password = borgmatic.hooks.credential.parse.resolve_credential(database.get('password'), config)
|
||||
|
||||
dump_command = tuple(
|
||||
shlex.quote(part) for part in shlex.split(database.get('mongodump_command') or 'mongodump')
|
||||
)
|
||||
hostname = database_config.resolve_database_option('hostname', database)
|
||||
return (
|
||||
('mongodump',)
|
||||
dump_command
|
||||
+ (('--out', shlex.quote(dump_filename)) if dump_format == 'directory' else ())
|
||||
+ (('--host', shlex.quote(database['hostname'])) if 'hostname' in database else ())
|
||||
+ (('--host', shlex.quote(hostname)) if hostname else ())
|
||||
+ (('--port', shlex.quote(str(database['port']))) if 'port' in database else ())
|
||||
+ (
|
||||
(
|
||||
'--username',
|
||||
shlex.quote(
|
||||
borgmatic.hooks.credential.parse.resolve_credential(database['username'])
|
||||
borgmatic.hooks.credential.parse.resolve_credential(
|
||||
database['username'],
|
||||
config,
|
||||
),
|
||||
),
|
||||
)
|
||||
if 'username' in database
|
||||
else ()
|
||||
)
|
||||
+ (('--config', make_password_config_file(password)) if password else ())
|
||||
+ (
|
||||
(
|
||||
'--password',
|
||||
shlex.quote(
|
||||
borgmatic.hooks.credential.parse.resolve_credential(database['password'])
|
||||
),
|
||||
'--authenticationDatabase',
|
||||
shlex.quote(database['authentication_database']),
|
||||
)
|
||||
if 'password' in database
|
||||
else ()
|
||||
)
|
||||
+ (
|
||||
('--authenticationDatabase', shlex.quote(database['authentication_database']))
|
||||
if 'authentication_database' in database
|
||||
else ()
|
||||
)
|
||||
@@ -135,7 +182,11 @@ def build_dump_command(database, dump_filename, dump_format):
|
||||
|
||||
|
||||
def remove_data_source_dumps(
|
||||
databases, config, borgmatic_runtime_directory, dry_run
|
||||
databases,
|
||||
config,
|
||||
borgmatic_runtime_directory,
|
||||
patterns,
|
||||
dry_run,
|
||||
): # pragma: no cover
|
||||
'''
|
||||
Remove all database dump files for this hook regardless of the given databases. Use the
|
||||
@@ -146,7 +197,10 @@ def remove_data_source_dumps(
|
||||
|
||||
|
||||
def make_data_source_dump_patterns(
|
||||
databases, config, borgmatic_runtime_directory, name=None
|
||||
databases,
|
||||
config,
|
||||
borgmatic_runtime_directory,
|
||||
name=None,
|
||||
): # pragma: no cover
|
||||
'''
|
||||
Given a sequence of configurations dicts, a configuration dict, the borgmatic runtime directory,
|
||||
@@ -156,12 +210,16 @@ def make_data_source_dump_patterns(
|
||||
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('borgmatic'), name, label='*'),
|
||||
dump.make_data_source_dump_filename(
|
||||
make_dump_path(borgmatic_runtime_directory), name, hostname='*'
|
||||
make_dump_path(borgmatic_runtime_directory),
|
||||
name,
|
||||
label='*',
|
||||
),
|
||||
dump.make_data_source_dump_filename(
|
||||
make_dump_path(borgmatic_source_directory), name, hostname='*'
|
||||
make_dump_path(borgmatic_source_directory),
|
||||
name,
|
||||
label='*',
|
||||
),
|
||||
)
|
||||
|
||||
@@ -189,13 +247,20 @@ def restore_data_source_dump(
|
||||
dump_filename = dump.make_data_source_dump_filename(
|
||||
make_dump_path(borgmatic_runtime_directory),
|
||||
data_source['name'],
|
||||
data_source.get('hostname'),
|
||||
hostname=data_source.get('hostname'),
|
||||
port=data_source.get('port'),
|
||||
container=data_source.get('container'),
|
||||
label=data_source.get('label'),
|
||||
)
|
||||
restore_command = build_restore_command(
|
||||
extract_process, data_source, dump_filename, connection_params
|
||||
extract_process,
|
||||
data_source,
|
||||
config,
|
||||
dump_filename,
|
||||
connection_params,
|
||||
)
|
||||
|
||||
logger.debug(f"Restoring MongoDB database {data_source['name']}{dry_run_label}")
|
||||
logger.debug(f'Restoring MongoDB database {data_source["name"]}{dry_run_label}')
|
||||
if dry_run:
|
||||
return
|
||||
|
||||
@@ -209,40 +274,60 @@ def restore_data_source_dump(
|
||||
)
|
||||
|
||||
|
||||
def build_restore_command(extract_process, database, dump_filename, connection_params):
|
||||
def build_restore_command(extract_process, database, config, dump_filename, connection_params):
|
||||
'''
|
||||
Return the mongorestore command from a single database configuration.
|
||||
Return the custom mongorestore_command from a single database configuration.
|
||||
'''
|
||||
hostname = connection_params['hostname'] or database.get(
|
||||
'restore_hostname', database.get('hostname')
|
||||
hostname = database_config.resolve_database_option(
|
||||
'hostname', database, connection_params, restore=True
|
||||
)
|
||||
port = database_config.resolve_database_option(
|
||||
'port', database, connection_params, restore=True
|
||||
)
|
||||
port = str(connection_params['port'] or database.get('restore_port', database.get('port', '')))
|
||||
username = borgmatic.hooks.credential.parse.resolve_credential(
|
||||
connection_params['username'] or database.get('restore_username', database.get('username'))
|
||||
database_config.resolve_database_option(
|
||||
'username', database, connection_params, restore=True
|
||||
),
|
||||
config,
|
||||
)
|
||||
password = borgmatic.hooks.credential.parse.resolve_credential(
|
||||
connection_params['password'] or database.get('restore_password', database.get('password'))
|
||||
database_config.resolve_database_option(
|
||||
'password', database, connection_params, restore=True
|
||||
),
|
||||
config,
|
||||
)
|
||||
|
||||
command = ['mongorestore']
|
||||
command = [
|
||||
shlex.quote(part)
|
||||
for part in shlex.split(database.get('mongorestore_command') or 'mongorestore')
|
||||
]
|
||||
|
||||
if extract_process:
|
||||
command.append('--archive')
|
||||
else:
|
||||
command.extend(('--dir', dump_filename))
|
||||
|
||||
if database['name'] != 'all':
|
||||
command.extend(('--drop',))
|
||||
|
||||
if hostname:
|
||||
command.extend(('--host', hostname))
|
||||
|
||||
if port:
|
||||
command.extend(('--port', str(port)))
|
||||
|
||||
if username:
|
||||
command.extend(('--username', username))
|
||||
|
||||
if password:
|
||||
command.extend(('--password', password))
|
||||
command.extend(('--config', make_password_config_file(password)))
|
||||
|
||||
if 'authentication_database' in database:
|
||||
command.extend(('--authenticationDatabase', database['authentication_database']))
|
||||
|
||||
if 'restore_options' in database:
|
||||
command.extend(database['restore_options'].split(' '))
|
||||
|
||||
if database.get('schemas'):
|
||||
for schema in database['schemas']:
|
||||
command.extend(('--nsInclude', schema))
|
||||
|
||||
@@ -6,11 +6,13 @@ import shlex
|
||||
import borgmatic.borg.pattern
|
||||
import borgmatic.config.paths
|
||||
import borgmatic.hooks.credential.parse
|
||||
import borgmatic.hooks.data_source.mariadb
|
||||
from borgmatic.execute import (
|
||||
execute_command,
|
||||
execute_command_and_capture_output,
|
||||
execute_command_with_processes,
|
||||
)
|
||||
from borgmatic.hooks.data_source import config as database_config
|
||||
from borgmatic.hooks.data_source import dump
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -26,38 +28,52 @@ def make_dump_path(base_directory): # pragma: no cover
|
||||
SYSTEM_DATABASE_NAMES = ('information_schema', 'mysql', 'performance_schema', 'sys')
|
||||
|
||||
|
||||
def database_names_to_dump(database, extra_environment, dry_run):
|
||||
def database_names_to_dump(database, config, username, password, environment, dry_run):
|
||||
'''
|
||||
Given a requested database config, return the corresponding sequence of database names to dump.
|
||||
In the case of "all", query for the names of databases on the configured host and return them,
|
||||
excluding any system databases that will cause problems during restore.
|
||||
Given a requested database config, a configuration dict, a database username and password, an
|
||||
environment dict, and whether this is a dry run, return the corresponding sequence of database
|
||||
names to dump. In the case of "all", query for the names of databases on the configured host and
|
||||
return them, excluding any system databases that will cause problems during restore.
|
||||
'''
|
||||
if database['name'] != 'all':
|
||||
return (database['name'],)
|
||||
|
||||
if dry_run:
|
||||
return ()
|
||||
|
||||
mysql_show_command = tuple(
|
||||
shlex.quote(part) for part in shlex.split(database.get('mysql_command') or 'mysql')
|
||||
)
|
||||
extra_options, defaults_extra_filename = (
|
||||
borgmatic.hooks.data_source.mariadb.parse_extra_options(database.get('list_options'))
|
||||
)
|
||||
password_transport = database.get('password_transport', 'pipe')
|
||||
hostname = database_config.resolve_database_option('hostname', database)
|
||||
show_command = (
|
||||
mysql_show_command
|
||||
+ (tuple(database['list_options'].split(' ')) if 'list_options' in database else ())
|
||||
+ (('--host', database['hostname']) if 'hostname' in database else ())
|
||||
+ (('--port', str(database['port'])) if 'port' in database else ())
|
||||
+ (('--protocol', 'tcp') if 'hostname' in database or 'port' in database else ())
|
||||
+ (
|
||||
('--user', borgmatic.hooks.credential.parse.resolve_credential(database['username']))
|
||||
if 'username' in database
|
||||
borgmatic.hooks.data_source.mariadb.make_defaults_file_options(
|
||||
username,
|
||||
password,
|
||||
defaults_extra_filename,
|
||||
)
|
||||
if password_transport == 'pipe'
|
||||
else ()
|
||||
)
|
||||
+ extra_options
|
||||
+ (('--host', hostname) if hostname else ())
|
||||
+ (('--port', str(database['port'])) if 'port' in database else ())
|
||||
+ (('--protocol', 'tcp') if hostname or 'port' in database else ())
|
||||
+ (('--user', username) if username and password_transport == 'environment' else ())
|
||||
+ (('--ssl',) if database.get('tls') is True else ())
|
||||
+ (('--skip-ssl',) if database.get('tls') is False else ())
|
||||
+ ('--skip-column-names', '--batch')
|
||||
+ ('--execute', 'show schemas')
|
||||
)
|
||||
|
||||
logger.debug('Querying for "all" MySQL databases to dump')
|
||||
show_output = execute_command_and_capture_output(
|
||||
show_command, extra_environment=extra_environment
|
||||
)
|
||||
|
||||
show_output = execute_command_and_capture_output(show_command, environment=environment)
|
||||
|
||||
return tuple(
|
||||
show_name
|
||||
@@ -67,7 +83,15 @@ def database_names_to_dump(database, extra_environment, dry_run):
|
||||
|
||||
|
||||
def execute_dump_command(
|
||||
database, dump_path, database_names, extra_environment, dry_run, dry_run_label
|
||||
database,
|
||||
config,
|
||||
username,
|
||||
password,
|
||||
dump_path,
|
||||
database_names,
|
||||
environment,
|
||||
dry_run,
|
||||
dry_run_label,
|
||||
):
|
||||
'''
|
||||
Kick off a dump for the given MySQL/MariaDB database (provided as a configuration dict) to a
|
||||
@@ -80,31 +104,45 @@ def execute_dump_command(
|
||||
dump_filename = dump.make_data_source_dump_filename(
|
||||
dump_path,
|
||||
database['name'],
|
||||
database.get('hostname'),
|
||||
database.get('port'),
|
||||
hostname=database.get('hostname'),
|
||||
port=database.get('port'),
|
||||
container=database.get('container'),
|
||||
label=database.get('label'),
|
||||
)
|
||||
|
||||
if os.path.exists(dump_filename):
|
||||
logger.warning(
|
||||
f'Skipping duplicate dump of MySQL database "{database_name}" to {dump_filename}'
|
||||
f'Skipping duplicate dump of MySQL database "{database_name}" to {dump_filename}',
|
||||
)
|
||||
return None
|
||||
|
||||
mysql_dump_command = tuple(
|
||||
shlex.quote(part) for part in shlex.split(database.get('mysql_dump_command') or 'mysqldump')
|
||||
)
|
||||
extra_options, defaults_extra_filename = (
|
||||
borgmatic.hooks.data_source.mariadb.parse_extra_options(database.get('options'))
|
||||
)
|
||||
password_transport = database.get('password_transport', 'pipe')
|
||||
hostname = database_config.resolve_database_option('hostname', database)
|
||||
dump_command = (
|
||||
mysql_dump_command
|
||||
+ (tuple(database['options'].split(' ')) if 'options' in database else ())
|
||||
+ (('--add-drop-database',) if database.get('add_drop_database', True) else ())
|
||||
+ (('--host', database['hostname']) if 'hostname' in database else ())
|
||||
+ (('--port', str(database['port'])) if 'port' in database else ())
|
||||
+ (('--protocol', 'tcp') if 'hostname' in database or 'port' in database else ())
|
||||
+ (
|
||||
('--user', borgmatic.hooks.credential.parse.resolve_credential(database['username']))
|
||||
if 'username' in database
|
||||
borgmatic.hooks.data_source.mariadb.make_defaults_file_options(
|
||||
username,
|
||||
password,
|
||||
defaults_extra_filename,
|
||||
)
|
||||
if password_transport == 'pipe'
|
||||
else ()
|
||||
)
|
||||
+ extra_options
|
||||
+ (('--add-drop-database',) if database.get('add_drop_database', True) else ())
|
||||
+ (('--host', hostname) if hostname else ())
|
||||
+ (('--port', str(database['port'])) if 'port' in database else ())
|
||||
+ (('--protocol', 'tcp') if hostname or 'port' in database else ())
|
||||
+ (('--user', username) if username and password_transport == 'environment' else ())
|
||||
+ (('--ssl',) if database.get('tls') is True else ())
|
||||
+ (('--skip-ssl',) if database.get('tls') is False else ())
|
||||
+ ('--databases',)
|
||||
+ database_names
|
||||
+ ('--result-file', dump_filename)
|
||||
@@ -118,7 +156,7 @@ def execute_dump_command(
|
||||
|
||||
return execute_command(
|
||||
dump_command,
|
||||
extra_environment=extra_environment,
|
||||
environment=environment,
|
||||
run_to_completion=False,
|
||||
)
|
||||
|
||||
@@ -155,17 +193,36 @@ def dump_data_sources(
|
||||
'''
|
||||
dry_run_label = ' (dry run; not actually dumping anything)' if dry_run else ''
|
||||
processes = []
|
||||
dumps_metadata = []
|
||||
|
||||
logger.info(f'Dumping MySQL databases{dry_run_label}')
|
||||
dump_path = make_dump_path(borgmatic_runtime_directory)
|
||||
|
||||
for database in databases:
|
||||
dump_path = make_dump_path(borgmatic_runtime_directory)
|
||||
extra_environment = (
|
||||
{'MYSQL_PWD': borgmatic.hooks.credential.parse.resolve_credential(database['password'])}
|
||||
if 'password' in database
|
||||
else None
|
||||
username = borgmatic.hooks.credential.parse.resolve_credential(
|
||||
database.get('username'),
|
||||
config,
|
||||
)
|
||||
password = borgmatic.hooks.credential.parse.resolve_credential(
|
||||
database.get('password'),
|
||||
config,
|
||||
)
|
||||
environment = dict(
|
||||
os.environ,
|
||||
**(
|
||||
{'MYSQL_PWD': password}
|
||||
if password and database.get('password_transport') == 'environment'
|
||||
else {}
|
||||
),
|
||||
)
|
||||
dump_database_names = database_names_to_dump(
|
||||
database,
|
||||
config,
|
||||
username,
|
||||
password,
|
||||
environment,
|
||||
dry_run,
|
||||
)
|
||||
dump_database_names = database_names_to_dump(database, extra_environment, dry_run)
|
||||
|
||||
if not dump_database_names:
|
||||
if dry_run:
|
||||
@@ -174,43 +231,77 @@ def dump_data_sources(
|
||||
raise ValueError('Cannot find any MySQL databases to dump.')
|
||||
|
||||
if database['name'] == 'all' and database.get('format'):
|
||||
for dump_name in dump_database_names:
|
||||
for database_name in dump_database_names:
|
||||
dumps_metadata.append(
|
||||
borgmatic.actions.restore.Dump(
|
||||
'mysql_databases',
|
||||
database_name,
|
||||
database.get('hostname'),
|
||||
database.get('port'),
|
||||
database.get('label'),
|
||||
database.get('container'),
|
||||
)
|
||||
)
|
||||
renamed_database = copy.copy(database)
|
||||
renamed_database['name'] = dump_name
|
||||
renamed_database['name'] = database_name
|
||||
processes.append(
|
||||
execute_dump_command(
|
||||
renamed_database,
|
||||
config,
|
||||
username,
|
||||
password,
|
||||
dump_path,
|
||||
(dump_name,),
|
||||
extra_environment,
|
||||
(database_name,),
|
||||
environment,
|
||||
dry_run,
|
||||
dry_run_label,
|
||||
)
|
||||
),
|
||||
)
|
||||
else:
|
||||
dumps_metadata.append(
|
||||
borgmatic.actions.restore.Dump(
|
||||
'mysql_databases',
|
||||
database['name'],
|
||||
database.get('hostname'),
|
||||
database.get('port'),
|
||||
database.get('label'),
|
||||
database.get('container'),
|
||||
)
|
||||
)
|
||||
processes.append(
|
||||
execute_dump_command(
|
||||
database,
|
||||
config,
|
||||
username,
|
||||
password,
|
||||
dump_path,
|
||||
dump_database_names,
|
||||
extra_environment,
|
||||
environment,
|
||||
dry_run,
|
||||
dry_run_label,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
if not dry_run:
|
||||
dump.write_data_source_dumps_metadata(
|
||||
borgmatic_runtime_directory, 'mysql_databases', dumps_metadata
|
||||
)
|
||||
patterns.append(
|
||||
borgmatic.borg.pattern.Pattern(
|
||||
os.path.join(borgmatic_runtime_directory, 'mysql_databases')
|
||||
)
|
||||
os.path.join(borgmatic_runtime_directory, 'mysql_databases'),
|
||||
source=borgmatic.borg.pattern.Pattern_source.HOOK,
|
||||
),
|
||||
)
|
||||
|
||||
return [process for process in processes if process]
|
||||
|
||||
|
||||
def remove_data_source_dumps(
|
||||
databases, config, borgmatic_runtime_directory, dry_run
|
||||
databases,
|
||||
config,
|
||||
borgmatic_runtime_directory,
|
||||
patterns,
|
||||
dry_run,
|
||||
): # pragma: no cover
|
||||
'''
|
||||
Remove all database dump files for this hook regardless of the given databases. Use the
|
||||
@@ -221,7 +312,10 @@ def remove_data_source_dumps(
|
||||
|
||||
|
||||
def make_data_source_dump_patterns(
|
||||
databases, config, borgmatic_runtime_directory, name=None
|
||||
databases,
|
||||
config,
|
||||
borgmatic_runtime_directory,
|
||||
name=None,
|
||||
): # pragma: no cover
|
||||
'''
|
||||
Given a sequence of configurations dicts, a configuration dict, the borgmatic runtime directory,
|
||||
@@ -231,12 +325,16 @@ def make_data_source_dump_patterns(
|
||||
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('borgmatic'), name, label='*'),
|
||||
dump.make_data_source_dump_filename(
|
||||
make_dump_path(borgmatic_runtime_directory), name, hostname='*'
|
||||
make_dump_path(borgmatic_runtime_directory),
|
||||
name,
|
||||
label='*',
|
||||
),
|
||||
dump.make_data_source_dump_filename(
|
||||
make_dump_path(borgmatic_source_directory), name, hostname='*'
|
||||
make_dump_path(borgmatic_source_directory),
|
||||
name,
|
||||
label='*',
|
||||
),
|
||||
)
|
||||
|
||||
@@ -257,38 +355,57 @@ def restore_data_source_dump(
|
||||
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(
|
||||
'restore_hostname', data_source.get('hostname')
|
||||
hostname = database_config.resolve_database_option(
|
||||
'hostname', data_source, connection_params, restore=True
|
||||
)
|
||||
port = str(
|
||||
connection_params['port'] or data_source.get('restore_port', data_source.get('port', ''))
|
||||
port = database_config.resolve_database_option(
|
||||
'port', data_source, connection_params, restore=True
|
||||
)
|
||||
tls = database_config.resolve_database_option('tls', data_source, restore=True)
|
||||
username = borgmatic.hooks.credential.parse.resolve_credential(
|
||||
connection_params['username']
|
||||
or data_source.get('restore_username', data_source.get('username'))
|
||||
database_config.resolve_database_option(
|
||||
'username', data_source, connection_params, restore=True
|
||||
),
|
||||
config,
|
||||
)
|
||||
password = borgmatic.hooks.credential.parse.resolve_credential(
|
||||
connection_params['password']
|
||||
or data_source.get('restore_password', data_source.get('password'))
|
||||
database_config.resolve_database_option(
|
||||
'password', data_source, connection_params, restore=True
|
||||
),
|
||||
config,
|
||||
)
|
||||
|
||||
mysql_restore_command = tuple(
|
||||
shlex.quote(part) for part in shlex.split(data_source.get('mysql_command') or 'mysql')
|
||||
)
|
||||
extra_options, defaults_extra_filename = (
|
||||
borgmatic.hooks.data_source.mariadb.parse_extra_options(data_source.get('restore_options'))
|
||||
)
|
||||
password_transport = data_source.get('password_transport', 'pipe')
|
||||
restore_command = (
|
||||
mysql_restore_command
|
||||
+ ('--batch',)
|
||||
+ (
|
||||
tuple(data_source['restore_options'].split(' '))
|
||||
if 'restore_options' in data_source
|
||||
borgmatic.hooks.data_source.mariadb.make_defaults_file_options(
|
||||
username,
|
||||
password,
|
||||
defaults_extra_filename,
|
||||
)
|
||||
if password_transport == 'pipe'
|
||||
else ()
|
||||
)
|
||||
+ extra_options
|
||||
+ ('--batch',)
|
||||
+ (('--host', hostname) if hostname else ())
|
||||
+ (('--port', str(port)) if port else ())
|
||||
+ (('--protocol', 'tcp') if hostname or port else ())
|
||||
+ (('--user', username) if username else ())
|
||||
+ (('--user', username) if username and password_transport == 'environment' else ())
|
||||
+ (('--ssl',) if tls is True else ())
|
||||
+ (('--skip-ssl',) if tls is False else ())
|
||||
)
|
||||
environment = dict(
|
||||
os.environ,
|
||||
**({'MYSQL_PWD': password} if password and password_transport == 'environment' else {}),
|
||||
)
|
||||
extra_environment = {'MYSQL_PWD': password} if password else None
|
||||
|
||||
logger.debug(f"Restoring MySQL database {data_source['name']}{dry_run_label}")
|
||||
if dry_run:
|
||||
@@ -301,5 +418,5 @@ def restore_data_source_dump(
|
||||
[extract_process],
|
||||
output_log_level=logging.DEBUG,
|
||||
input_file=extract_process.stdout,
|
||||
extra_environment=extra_environment,
|
||||
environment=environment,
|
||||
)
|
||||
|
||||
@@ -13,6 +13,7 @@ from borgmatic.execute import (
|
||||
execute_command_and_capture_output,
|
||||
execute_command_with_processes,
|
||||
)
|
||||
from borgmatic.hooks.data_source import config as database_config
|
||||
from borgmatic.hooks.data_source import dump
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -25,71 +26,75 @@ def make_dump_path(base_directory): # pragma: no cover
|
||||
return dump.make_data_source_dump_path(base_directory, 'postgresql_databases')
|
||||
|
||||
|
||||
def make_extra_environment(database, restore_connection_params=None):
|
||||
def make_environment(database, config, restore_connection_params=None):
|
||||
'''
|
||||
Make the extra_environment dict from the given database configuration. If restore connection
|
||||
params are given, this is for a restore operation.
|
||||
Make an environment dict from the current environment variables and the given database
|
||||
configuration. If restore connection params are given, this is for a restore operation.
|
||||
'''
|
||||
extra = dict()
|
||||
environment = dict(os.environ)
|
||||
|
||||
try:
|
||||
if restore_connection_params:
|
||||
extra['PGPASSWORD'] = borgmatic.hooks.credential.parse.resolve_credential(
|
||||
restore_connection_params.get('password')
|
||||
or database.get('restore_password', database['password'])
|
||||
)
|
||||
else:
|
||||
extra['PGPASSWORD'] = borgmatic.hooks.credential.parse.resolve_credential(
|
||||
database['password']
|
||||
)
|
||||
except (AttributeError, KeyError):
|
||||
pass
|
||||
password = database_config.resolve_database_option(
|
||||
'password', database, restore_connection_params, restore=restore_connection_params
|
||||
)
|
||||
|
||||
if password:
|
||||
environment['PGPASSWORD'] = borgmatic.hooks.credential.parse.resolve_credential(
|
||||
password,
|
||||
config,
|
||||
)
|
||||
|
||||
if 'ssl_mode' in database:
|
||||
extra['PGSSLMODE'] = database['ssl_mode']
|
||||
if 'ssl_cert' in database:
|
||||
extra['PGSSLCERT'] = database['ssl_cert']
|
||||
if 'ssl_key' in database:
|
||||
extra['PGSSLKEY'] = database['ssl_key']
|
||||
if 'ssl_root_cert' in database:
|
||||
extra['PGSSLROOTCERT'] = database['ssl_root_cert']
|
||||
if 'ssl_crl' in database:
|
||||
extra['PGSSLCRL'] = database['ssl_crl']
|
||||
environment['PGSSLMODE'] = database['ssl_mode']
|
||||
|
||||
return extra
|
||||
if 'ssl_cert' in database:
|
||||
environment['PGSSLCERT'] = database['ssl_cert']
|
||||
|
||||
if 'ssl_key' in database:
|
||||
environment['PGSSLKEY'] = database['ssl_key']
|
||||
|
||||
if 'ssl_root_cert' in database:
|
||||
environment['PGSSLROOTCERT'] = database['ssl_root_cert']
|
||||
|
||||
if 'ssl_crl' in database:
|
||||
environment['PGSSLCRL'] = database['ssl_crl']
|
||||
|
||||
return environment
|
||||
|
||||
|
||||
EXCLUDED_DATABASE_NAMES = ('template0', 'template1')
|
||||
|
||||
|
||||
def database_names_to_dump(database, extra_environment, dry_run):
|
||||
def database_names_to_dump(database, config, environment, dry_run):
|
||||
'''
|
||||
Given a requested database config, return the corresponding sequence of database names to dump.
|
||||
In the case of "all" when a database format is given, query for the names of databases on the
|
||||
configured host and return them. For "all" without a database format, just return a sequence
|
||||
containing "all".
|
||||
Given a requested database config and a configuration dict, return the corresponding sequence of
|
||||
database names to dump. In the case of "all" when a database format is given, query for the
|
||||
names of databases on the configured host and return them. For "all" without a database format,
|
||||
just return a sequence containing "all".
|
||||
'''
|
||||
requested_name = database['name']
|
||||
|
||||
if requested_name != 'all':
|
||||
return (requested_name,)
|
||||
|
||||
if not database.get('format'):
|
||||
return ('all',)
|
||||
|
||||
if dry_run:
|
||||
return ()
|
||||
|
||||
psql_command = tuple(
|
||||
shlex.quote(part) for part in shlex.split(database.get('psql_command') or 'psql')
|
||||
)
|
||||
hostname = database_config.resolve_database_option('hostname', database)
|
||||
list_command = (
|
||||
psql_command
|
||||
+ ('--list', '--no-password', '--no-psqlrc', '--csv', '--tuples-only')
|
||||
+ (('--host', database['hostname']) if 'hostname' in database else ())
|
||||
+ (('--host', hostname) if hostname else ())
|
||||
+ (('--port', str(database['port'])) if 'port' in database else ())
|
||||
+ (
|
||||
(
|
||||
'--username',
|
||||
borgmatic.hooks.credential.parse.resolve_credential(database['username']),
|
||||
borgmatic.hooks.credential.parse.resolve_credential(database['username'], config),
|
||||
)
|
||||
if 'username' in database
|
||||
else ()
|
||||
@@ -97,9 +102,7 @@ def database_names_to_dump(database, extra_environment, dry_run):
|
||||
+ (tuple(database['list_options'].split(' ')) if 'list_options' in database else ())
|
||||
)
|
||||
logger.debug('Querying for "all" PostgreSQL databases to dump')
|
||||
list_output = execute_command_and_capture_output(
|
||||
list_command, extra_environment=extra_environment
|
||||
)
|
||||
list_output = execute_command_and_capture_output(list_command, environment=environment)
|
||||
|
||||
return tuple(
|
||||
row[0]
|
||||
@@ -142,13 +145,14 @@ def dump_data_sources(
|
||||
'''
|
||||
dry_run_label = ' (dry run; not actually dumping anything)' if dry_run else ''
|
||||
processes = []
|
||||
dumps_metadata = []
|
||||
|
||||
logger.info(f'Dumping PostgreSQL databases{dry_run_label}')
|
||||
dump_path = make_dump_path(borgmatic_runtime_directory)
|
||||
|
||||
for database in databases:
|
||||
extra_environment = make_extra_environment(database)
|
||||
dump_path = make_dump_path(borgmatic_runtime_directory)
|
||||
dump_database_names = database_names_to_dump(database, extra_environment, dry_run)
|
||||
environment = make_environment(database, config)
|
||||
dump_database_names = database_names_to_dump(database, config, environment, dry_run)
|
||||
|
||||
if not dump_database_names:
|
||||
if dry_run:
|
||||
@@ -157,7 +161,18 @@ def dump_data_sources(
|
||||
raise ValueError('Cannot find any PostgreSQL databases to dump.')
|
||||
|
||||
for database_name in dump_database_names:
|
||||
dumps_metadata.append(
|
||||
borgmatic.actions.restore.Dump(
|
||||
'postgresql_databases',
|
||||
database_name,
|
||||
database.get('hostname'),
|
||||
database.get('port'),
|
||||
database.get('label'),
|
||||
database.get('container'),
|
||||
)
|
||||
)
|
||||
dump_format = database.get('format', None if database_name == 'all' else 'custom')
|
||||
compression = database.get('compression')
|
||||
default_dump_command = 'pg_dumpall' if database_name == 'all' else 'pg_dump'
|
||||
dump_command = tuple(
|
||||
shlex.quote(part)
|
||||
@@ -166,15 +181,19 @@ def dump_data_sources(
|
||||
dump_filename = dump.make_data_source_dump_filename(
|
||||
dump_path,
|
||||
database_name,
|
||||
database.get('hostname'),
|
||||
database.get('port'),
|
||||
hostname=database.get('hostname'),
|
||||
port=database.get('port'),
|
||||
container=database.get('container'),
|
||||
label=database.get('label'),
|
||||
)
|
||||
|
||||
if os.path.exists(dump_filename):
|
||||
logger.warning(
|
||||
f'Skipping duplicate dump of PostgreSQL database "{database_name}" to {dump_filename}'
|
||||
f'Skipping duplicate dump of PostgreSQL database "{database_name}" to {dump_filename}',
|
||||
)
|
||||
continue
|
||||
|
||||
hostname = database_config.resolve_database_option('hostname', database)
|
||||
command = (
|
||||
dump_command
|
||||
+ (
|
||||
@@ -182,15 +201,16 @@ def dump_data_sources(
|
||||
'--clean',
|
||||
'--if-exists',
|
||||
)
|
||||
+ (('--host', shlex.quote(database['hostname'])) if 'hostname' in database else ())
|
||||
+ (('--host', shlex.quote(hostname)) if hostname else ())
|
||||
+ (('--port', shlex.quote(str(database['port']))) if 'port' in database else ())
|
||||
+ (
|
||||
(
|
||||
'--username',
|
||||
shlex.quote(
|
||||
borgmatic.hooks.credential.parse.resolve_credential(
|
||||
database['username']
|
||||
)
|
||||
database['username'],
|
||||
config,
|
||||
),
|
||||
),
|
||||
)
|
||||
if 'username' in database
|
||||
@@ -198,6 +218,7 @@ def dump_data_sources(
|
||||
)
|
||||
+ (('--no-owner',) if database.get('no_owner', False) else ())
|
||||
+ (('--format', shlex.quote(dump_format)) if dump_format else ())
|
||||
+ (('--compress', shlex.quote(str(compression))) if compression is not None else ())
|
||||
+ (('--file', shlex.quote(dump_filename)) if dump_format == 'directory' else ())
|
||||
+ (
|
||||
tuple(shlex.quote(option) for option in database['options'].split(' '))
|
||||
@@ -212,41 +233,49 @@ def dump_data_sources(
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f'Dumping PostgreSQL database "{database_name}" to {dump_filename}{dry_run_label}'
|
||||
f'Dumping PostgreSQL database "{database_name}" to {dump_filename}{dry_run_label}',
|
||||
)
|
||||
if dry_run:
|
||||
continue
|
||||
|
||||
if dump_format == 'directory':
|
||||
dump.create_parent_directory_for_dump(dump_filename)
|
||||
execute_command(
|
||||
execute_command( # noqa: S604
|
||||
command,
|
||||
shell=True,
|
||||
extra_environment=extra_environment,
|
||||
environment=environment,
|
||||
)
|
||||
else:
|
||||
dump.create_named_pipe_for_dump(dump_filename)
|
||||
processes.append(
|
||||
execute_command(
|
||||
execute_command( # noqa: S604
|
||||
command,
|
||||
shell=True,
|
||||
extra_environment=extra_environment,
|
||||
environment=environment,
|
||||
run_to_completion=False,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
if not dry_run:
|
||||
dump.write_data_source_dumps_metadata(
|
||||
borgmatic_runtime_directory, 'postgresql_databases', dumps_metadata
|
||||
)
|
||||
patterns.append(
|
||||
borgmatic.borg.pattern.Pattern(
|
||||
os.path.join(borgmatic_runtime_directory, 'postgresql_databases')
|
||||
)
|
||||
os.path.join(borgmatic_runtime_directory, 'postgresql_databases'),
|
||||
source=borgmatic.borg.pattern.Pattern_source.HOOK,
|
||||
),
|
||||
)
|
||||
|
||||
return processes
|
||||
|
||||
|
||||
def remove_data_source_dumps(
|
||||
databases, config, borgmatic_runtime_directory, dry_run
|
||||
databases,
|
||||
config,
|
||||
borgmatic_runtime_directory,
|
||||
patterns,
|
||||
dry_run,
|
||||
): # pragma: no cover
|
||||
'''
|
||||
Remove all database dump files for this hook regardless of the given databases. Use the
|
||||
@@ -254,12 +283,17 @@ def remove_data_source_dumps(
|
||||
actually remove anything.
|
||||
'''
|
||||
dump.remove_data_source_dumps(
|
||||
make_dump_path(borgmatic_runtime_directory), 'PostgreSQL', dry_run
|
||||
make_dump_path(borgmatic_runtime_directory),
|
||||
'PostgreSQL',
|
||||
dry_run,
|
||||
)
|
||||
|
||||
|
||||
def make_data_source_dump_patterns(
|
||||
databases, config, borgmatic_runtime_directory, name=None
|
||||
databases,
|
||||
config,
|
||||
borgmatic_runtime_directory,
|
||||
name=None,
|
||||
): # pragma: no cover
|
||||
'''
|
||||
Given a sequence of configurations dicts, a configuration dict, the borgmatic runtime directory,
|
||||
@@ -269,12 +303,16 @@ def make_data_source_dump_patterns(
|
||||
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('borgmatic'), name, label='*'),
|
||||
dump.make_data_source_dump_filename(
|
||||
make_dump_path(borgmatic_runtime_directory), name, hostname='*'
|
||||
make_dump_path(borgmatic_runtime_directory),
|
||||
name,
|
||||
label='*',
|
||||
),
|
||||
dump.make_data_source_dump_filename(
|
||||
make_dump_path(borgmatic_source_directory), name, hostname='*'
|
||||
make_dump_path(borgmatic_source_directory),
|
||||
name,
|
||||
label='*',
|
||||
),
|
||||
)
|
||||
|
||||
@@ -302,22 +340,27 @@ def restore_data_source_dump(
|
||||
hostname, port, username, and password.
|
||||
'''
|
||||
dry_run_label = ' (dry run; not actually restoring anything)' if dry_run else ''
|
||||
hostname = connection_params['hostname'] or data_source.get(
|
||||
'restore_hostname', data_source.get('hostname')
|
||||
hostname = database_config.resolve_database_option(
|
||||
'hostname', data_source, connection_params, restore=True
|
||||
)
|
||||
port = str(
|
||||
connection_params['port'] or data_source.get('restore_port', data_source.get('port', ''))
|
||||
port = database_config.resolve_database_option(
|
||||
'port', data_source, connection_params, restore=True
|
||||
)
|
||||
username = borgmatic.hooks.credential.parse.resolve_credential(
|
||||
connection_params['username']
|
||||
or data_source.get('restore_username', data_source.get('username'))
|
||||
database_config.resolve_database_option(
|
||||
'username', data_source, connection_params, restore=True
|
||||
),
|
||||
config,
|
||||
)
|
||||
|
||||
all_databases = bool(data_source['name'] == 'all')
|
||||
dump_filename = dump.make_data_source_dump_filename(
|
||||
make_dump_path(borgmatic_runtime_directory),
|
||||
data_source['name'],
|
||||
data_source.get('hostname'),
|
||||
hostname=hostname,
|
||||
port=port,
|
||||
container=data_source.get('container'),
|
||||
label=data_source.get('label'),
|
||||
)
|
||||
psql_command = tuple(
|
||||
shlex.quote(part) for part in shlex.split(data_source.get('psql_command') or 'psql')
|
||||
@@ -326,7 +369,7 @@ def restore_data_source_dump(
|
||||
psql_command
|
||||
+ ('--no-password', '--no-psqlrc', '--quiet')
|
||||
+ (('--host', hostname) if hostname else ())
|
||||
+ (('--port', port) if port else ())
|
||||
+ (('--port', str(port)) if port else ())
|
||||
+ (('--username', username) if username else ())
|
||||
+ (('--dbname', data_source['name']) if not all_databases else ())
|
||||
+ (
|
||||
@@ -347,7 +390,7 @@ def restore_data_source_dump(
|
||||
+ (('--no-psqlrc',) if use_psql_command else ('--if-exists', '--exit-on-error', '--clean'))
|
||||
+ (('--dbname', data_source['name']) if not all_databases else ())
|
||||
+ (('--host', hostname) if hostname else ())
|
||||
+ (('--port', port) if port else ())
|
||||
+ (('--port', str(port)) if port else ())
|
||||
+ (('--username', username) if username else ())
|
||||
+ (('--no-owner',) if data_source.get('no_owner', False) else ())
|
||||
+ (
|
||||
@@ -359,13 +402,11 @@ def restore_data_source_dump(
|
||||
+ tuple(
|
||||
itertools.chain.from_iterable(('--schema', schema) for schema in data_source['schemas'])
|
||||
if data_source.get('schemas')
|
||||
else ()
|
||||
else (),
|
||||
)
|
||||
)
|
||||
|
||||
extra_environment = make_extra_environment(
|
||||
data_source, restore_connection_params=connection_params
|
||||
)
|
||||
environment = make_environment(data_source, config, restore_connection_params=connection_params)
|
||||
|
||||
logger.debug(f"Restoring PostgreSQL database {data_source['name']}{dry_run_label}")
|
||||
if dry_run:
|
||||
@@ -378,6 +419,6 @@ def restore_data_source_dump(
|
||||
[extract_process] if extract_process else [],
|
||||
output_log_level=logging.DEBUG,
|
||||
input_file=extract_process.stdout if extract_process else None,
|
||||
extra_environment=extra_environment,
|
||||
environment=environment,
|
||||
)
|
||||
execute_command(analyze_command, extra_environment=extra_environment)
|
||||
execute_command(analyze_command, environment=environment)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import os
|
||||
import pathlib
|
||||
|
||||
IS_A_HOOK = False
|
||||
@@ -7,14 +8,18 @@ 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.
|
||||
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.
|
||||
If a parent directory and a candidate pattern are on different devices, skip the pattern. That's
|
||||
because any snapshot of a parent directory won't actually include "contained" directories if
|
||||
they reside on separate devices.
|
||||
|
||||
For this function 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.
|
||||
@@ -27,12 +32,17 @@ def get_contained_patterns(parent_directory, candidate_patterns):
|
||||
if not candidate_patterns:
|
||||
return ()
|
||||
|
||||
parent_device = os.stat(parent_directory).st_dev if os.path.exists(parent_directory) else None
|
||||
|
||||
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
|
||||
if (
|
||||
pathlib.PurePath(parent_directory) == candidate_path
|
||||
or pathlib.PurePath(parent_directory) in candidate_path.parents
|
||||
)
|
||||
if candidate.device == parent_device
|
||||
)
|
||||
candidate_patterns -= set(contained_patterns)
|
||||
|
||||
|
||||
@@ -49,56 +49,79 @@ def dump_data_sources(
|
||||
'''
|
||||
dry_run_label = ' (dry run; not actually dumping anything)' if dry_run else ''
|
||||
processes = []
|
||||
dumps_metadata = []
|
||||
|
||||
logger.info(f'Dumping SQLite databases{dry_run_label}')
|
||||
|
||||
for database in databases:
|
||||
database_path = database['path']
|
||||
dumps_metadata.append(
|
||||
borgmatic.actions.restore.Dump(
|
||||
'sqlite_databases', database['name'], label=database.get('label')
|
||||
)
|
||||
)
|
||||
|
||||
if database['name'] == 'all':
|
||||
logger.warning('The "all" database name has no meaning for SQLite databases')
|
||||
|
||||
if not os.path.exists(database_path):
|
||||
logger.warning(
|
||||
f'No SQLite database at {database_path}; an empty database will be created and dumped'
|
||||
f'No SQLite database at {database_path}; an empty database will be created and dumped',
|
||||
)
|
||||
|
||||
dump_path = make_dump_path(borgmatic_runtime_directory)
|
||||
dump_filename = dump.make_data_source_dump_filename(dump_path, database['name'])
|
||||
dump_filename = dump.make_data_source_dump_filename(
|
||||
dump_path, database['name'], label=database.get('label')
|
||||
)
|
||||
|
||||
if os.path.exists(dump_filename):
|
||||
logger.warning(
|
||||
f'Skipping duplicate dump of SQLite database at {database_path} to {dump_filename}'
|
||||
f'Skipping duplicate dump of SQLite database at {database_path} to {dump_filename}',
|
||||
)
|
||||
continue
|
||||
|
||||
sqlite_command = tuple(
|
||||
shlex.quote(part) for part in shlex.split(database.get('sqlite_command') or 'sqlite3')
|
||||
)
|
||||
command = (
|
||||
'sqlite3',
|
||||
*sqlite_command,
|
||||
shlex.quote(database_path),
|
||||
'.dump',
|
||||
'>',
|
||||
shlex.quote(dump_filename),
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f'Dumping SQLite database at {database_path} to {dump_filename}{dry_run_label}'
|
||||
f'Dumping SQLite database at {database_path} to {dump_filename}{dry_run_label}',
|
||||
)
|
||||
if dry_run:
|
||||
continue
|
||||
|
||||
dump.create_named_pipe_for_dump(dump_filename)
|
||||
processes.append(execute_command(command, shell=True, run_to_completion=False))
|
||||
processes.append(
|
||||
execute_command(command, shell=True, run_to_completion=False), # noqa: S604
|
||||
)
|
||||
|
||||
if not dry_run:
|
||||
dump.write_data_source_dumps_metadata(
|
||||
borgmatic_runtime_directory, 'sqlite_databases', dumps_metadata
|
||||
)
|
||||
patterns.append(
|
||||
borgmatic.borg.pattern.Pattern(
|
||||
os.path.join(borgmatic_runtime_directory, 'sqlite_databases')
|
||||
)
|
||||
os.path.join(borgmatic_runtime_directory, 'sqlite_databases'),
|
||||
source=borgmatic.borg.pattern.Pattern_source.HOOK,
|
||||
),
|
||||
)
|
||||
|
||||
return processes
|
||||
|
||||
|
||||
def remove_data_source_dumps(
|
||||
databases, config, borgmatic_runtime_directory, dry_run
|
||||
databases,
|
||||
config,
|
||||
borgmatic_runtime_directory,
|
||||
patterns,
|
||||
dry_run,
|
||||
): # pragma: no cover
|
||||
'''
|
||||
Remove all database dump files for this hook regardless of the given databases. Use the
|
||||
@@ -109,7 +132,10 @@ def remove_data_source_dumps(
|
||||
|
||||
|
||||
def make_data_source_dump_patterns(
|
||||
databases, config, borgmatic_runtime_directory, name=None
|
||||
databases,
|
||||
config,
|
||||
borgmatic_runtime_directory,
|
||||
name=None,
|
||||
): # pragma: no cover
|
||||
'''
|
||||
Given a sequence of configurations dicts, a configuration dict, the borgmatic runtime directory,
|
||||
@@ -119,12 +145,16 @@ def make_data_source_dump_patterns(
|
||||
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('borgmatic'), name, label='*'),
|
||||
dump.make_data_source_dump_filename(
|
||||
make_dump_path(borgmatic_runtime_directory), name, hostname='*'
|
||||
make_dump_path(borgmatic_runtime_directory),
|
||||
name,
|
||||
label='*',
|
||||
),
|
||||
dump.make_data_source_dump_filename(
|
||||
make_dump_path(borgmatic_source_directory), name, hostname='*'
|
||||
make_dump_path(borgmatic_source_directory),
|
||||
name,
|
||||
label='*',
|
||||
),
|
||||
)
|
||||
|
||||
@@ -146,7 +176,8 @@ def restore_data_source_dump(
|
||||
'''
|
||||
dry_run_label = ' (dry run; not actually restoring anything)' if dry_run else ''
|
||||
database_path = connection_params['restore_path'] or data_source.get(
|
||||
'restore_path', data_source.get('path')
|
||||
'restore_path',
|
||||
data_source.get('path'),
|
||||
)
|
||||
|
||||
logger.debug(f'Restoring SQLite database at {database_path}{dry_run_label}')
|
||||
@@ -159,11 +190,11 @@ def restore_data_source_dump(
|
||||
except FileNotFoundError: # pragma: no cover
|
||||
pass
|
||||
|
||||
restore_command = (
|
||||
'sqlite3',
|
||||
database_path,
|
||||
sqlite_restore_command = tuple(
|
||||
shlex.quote(part)
|
||||
for part in shlex.split(data_source.get('sqlite_restore_command') or 'sqlite3')
|
||||
)
|
||||
|
||||
restore_command = (*sqlite_restore_command, shlex.quote(database_path))
|
||||
# Don't give Borg local path so as to error on warnings, as "borg extract" only gives a warning
|
||||
# if the restore paths don't exist in the archive.
|
||||
execute_command_with_processes(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import collections
|
||||
import glob
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
@@ -38,18 +39,22 @@ def get_datasets_to_backup(zfs_command, patterns):
|
||||
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.
|
||||
|
||||
Only include datasets that contain at least one root pattern sourced from borgmatic
|
||||
configuration (as opposed to generated elsewhere in borgmatic).
|
||||
|
||||
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(' '))
|
||||
+ (
|
||||
(
|
||||
*zfs_command.split(' '),
|
||||
'list',
|
||||
'-H',
|
||||
'-t',
|
||||
'filesystem',
|
||||
'-o',
|
||||
f'name,mountpoint,{BORGMATIC_USER_PROPERTY}',
|
||||
)
|
||||
f'name,mountpoint,canmount,{BORGMATIC_USER_PROPERTY}',
|
||||
),
|
||||
close_fds=True,
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -60,7 +65,12 @@ def get_datasets_to_backup(zfs_command, patterns):
|
||||
(
|
||||
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'),)
|
||||
for (dataset_name, mount_point, can_mount, user_property_value) in (
|
||||
line.rstrip().split('\t'),
|
||||
)
|
||||
# Skip datasets that are marked "canmount=off", because mounting their snapshots will
|
||||
# result in completely empty mount points—thereby preventing us from backing them up.
|
||||
if can_mount == 'on'
|
||||
),
|
||||
key=lambda dataset: dataset.mount_point,
|
||||
reverse=True,
|
||||
@@ -83,19 +93,30 @@ def get_datasets_to_backup(zfs_command, patterns):
|
||||
for contained_patterns in (
|
||||
(
|
||||
(
|
||||
(borgmatic.borg.pattern.Pattern(dataset.mount_point),)
|
||||
(
|
||||
borgmatic.borg.pattern.Pattern(
|
||||
dataset.mount_point,
|
||||
source=borgmatic.borg.pattern.Pattern_source.HOOK,
|
||||
),
|
||||
)
|
||||
if dataset.auto_backup
|
||||
else ()
|
||||
)
|
||||
+ borgmatic.hooks.data_source.snapshot.get_contained_patterns(
|
||||
dataset.mount_point, candidate_patterns
|
||||
dataset.mount_point,
|
||||
candidate_patterns,
|
||||
)
|
||||
),
|
||||
)
|
||||
if contained_patterns
|
||||
if dataset.auto_backup
|
||||
or any(
|
||||
pattern.type == borgmatic.borg.pattern.Pattern_type.ROOT
|
||||
and pattern.source == borgmatic.borg.pattern.Pattern_source.CONFIG
|
||||
for pattern in contained_patterns
|
||||
)
|
||||
),
|
||||
key=lambda dataset: dataset.mount_point,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -104,18 +125,28 @@ 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(' '))
|
||||
+ (
|
||||
(
|
||||
*zfs_command.split(' '),
|
||||
'list',
|
||||
'-H',
|
||||
'-t',
|
||||
'filesystem',
|
||||
'-o',
|
||||
'mountpoint',
|
||||
)
|
||||
),
|
||||
close_fds=True,
|
||||
)
|
||||
|
||||
return tuple(sorted(line.rstrip() for line in list_output.splitlines()))
|
||||
return tuple(
|
||||
sorted(
|
||||
{
|
||||
mount_point
|
||||
for line in list_output.splitlines()
|
||||
for mount_point in (line.rstrip(),)
|
||||
if mount_point != 'none'
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def snapshot_dataset(zfs_command, full_snapshot_name): # pragma: no cover
|
||||
@@ -124,12 +155,13 @@ def snapshot_dataset(zfs_command, full_snapshot_name): # pragma: no cover
|
||||
snapshot.
|
||||
'''
|
||||
borgmatic.execute.execute_command(
|
||||
tuple(zfs_command.split(' '))
|
||||
+ (
|
||||
(
|
||||
*zfs_command.split(' '),
|
||||
'snapshot',
|
||||
full_snapshot_name,
|
||||
),
|
||||
output_log_level=logging.DEBUG,
|
||||
close_fds=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -142,8 +174,8 @@ def mount_snapshot(mount_command, full_snapshot_name, snapshot_mount_path): # p
|
||||
os.makedirs(snapshot_mount_path, mode=0o700, exist_ok=True)
|
||||
|
||||
borgmatic.execute.execute_command(
|
||||
tuple(mount_command.split(' '))
|
||||
+ (
|
||||
(
|
||||
*mount_command.split(' '),
|
||||
'-t',
|
||||
'zfs',
|
||||
'-o',
|
||||
@@ -152,13 +184,18 @@ def mount_snapshot(mount_command, full_snapshot_name, snapshot_mount_path): # p
|
||||
snapshot_mount_path,
|
||||
),
|
||||
output_log_level=logging.DEBUG,
|
||||
close_fds=True,
|
||||
)
|
||||
|
||||
|
||||
def make_borg_snapshot_pattern(pattern, normalized_runtime_directory):
|
||||
MOUNT_POINT_HASH_LENGTH = 10
|
||||
|
||||
|
||||
def make_borg_snapshot_pattern(pattern, dataset, 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.
|
||||
Given a Borg pattern as a borgmatic.borg.pattern.Pattern instance and the Dataset containing it,
|
||||
return a new Pattern with its path rewritten to be in a snapshot directory based on both the
|
||||
given runtime directory and the given Dataset's mount point.
|
||||
|
||||
Move any initial caret in a regular expression pattern path to the beginning, so as not to break
|
||||
the regular expression.
|
||||
@@ -173,7 +210,14 @@ def make_borg_snapshot_pattern(pattern, normalized_runtime_directory):
|
||||
rewritten_path = initial_caret + os.path.join(
|
||||
normalized_runtime_directory,
|
||||
'zfs_snapshots',
|
||||
'.', # Borg 1.4+ "slashdot" hack.
|
||||
# Including this hash prevents conflicts between snapshot patterns for different datasets.
|
||||
# For instance, without this, snapshotting a dataset at /var and another at /var/spool would
|
||||
# result in overlapping snapshot patterns and therefore colliding mount attempts.
|
||||
hashlib.shake_256(dataset.mount_point.encode('utf-8')).hexdigest(MOUNT_POINT_HASH_LENGTH),
|
||||
# Use the Borg 1.4+ "slashdot" hack to prevent the snapshot path prefix from getting
|
||||
# included in the archive—but only if there's not already a slashdot hack present in the
|
||||
# pattern.
|
||||
('' if f'{os.path.sep}.{os.path.sep}' in pattern.path else '.'),
|
||||
# Included so that the source directory ends up in the Borg archive at its "original" path.
|
||||
pattern.path.lstrip('^').lstrip(os.path.sep),
|
||||
)
|
||||
@@ -183,6 +227,7 @@ def make_borg_snapshot_pattern(pattern, normalized_runtime_directory):
|
||||
pattern.type,
|
||||
pattern.style,
|
||||
pattern.device,
|
||||
source=borgmatic.borg.pattern.Pattern_source.HOOK,
|
||||
)
|
||||
|
||||
|
||||
@@ -209,7 +254,8 @@ def dump_data_sources(
|
||||
dry_run_label = ' (dry run; not actually snapshotting anything)' if dry_run else ''
|
||||
logger.info(f'Snapshotting ZFS datasets{dry_run_label}')
|
||||
|
||||
# List ZFS datasets to get their mount points.
|
||||
# List ZFS datasets to get their mount points, but only consider those patterns that came from
|
||||
# actual user configuration (as opposed to, say, other hooks).
|
||||
zfs_command = hook_config.get('zfs_command', 'zfs')
|
||||
requested_datasets = get_datasets_to_backup(zfs_command, patterns)
|
||||
|
||||
@@ -223,7 +269,7 @@ def dump_data_sources(
|
||||
for dataset in requested_datasets:
|
||||
full_snapshot_name = f'{dataset.name}@{snapshot_name}'
|
||||
logger.debug(
|
||||
f'Creating ZFS snapshot {full_snapshot_name} of {dataset.mount_point}{dry_run_label}'
|
||||
f'Creating ZFS snapshot {full_snapshot_name} of {dataset.mount_point}{dry_run_label}',
|
||||
)
|
||||
|
||||
if not dry_run:
|
||||
@@ -234,22 +280,31 @@ def dump_data_sources(
|
||||
snapshot_mount_path = os.path.join(
|
||||
normalized_runtime_directory,
|
||||
'zfs_snapshots',
|
||||
hashlib.shake_256(dataset.mount_point.encode('utf-8')).hexdigest(
|
||||
MOUNT_POINT_HASH_LENGTH,
|
||||
),
|
||||
dataset.mount_point.lstrip(os.path.sep),
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f'Mounting ZFS snapshot {full_snapshot_name} at {snapshot_mount_path}{dry_run_label}'
|
||||
f'Mounting ZFS snapshot {full_snapshot_name} at {snapshot_mount_path}{dry_run_label}',
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
continue
|
||||
|
||||
mount_snapshot(
|
||||
hook_config.get('mount_command', 'mount'), full_snapshot_name, snapshot_mount_path
|
||||
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)
|
||||
snapshot_pattern = make_borg_snapshot_pattern(
|
||||
pattern,
|
||||
dataset,
|
||||
normalized_runtime_directory,
|
||||
)
|
||||
|
||||
# Attempt to update the pattern in place, since pattern order matters to Borg.
|
||||
try:
|
||||
@@ -265,8 +320,9 @@ 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,),
|
||||
(*umount_command.split(' '), snapshot_mount_path),
|
||||
output_log_level=logging.DEBUG,
|
||||
close_fds=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -276,12 +332,13 @@ def destroy_snapshot(zfs_command, full_snapshot_name): # pragma: no cover
|
||||
it.
|
||||
'''
|
||||
borgmatic.execute.execute_command(
|
||||
tuple(zfs_command.split(' '))
|
||||
+ (
|
||||
(
|
||||
*tuple(zfs_command.split(' ')),
|
||||
'destroy',
|
||||
full_snapshot_name,
|
||||
),
|
||||
output_log_level=logging.DEBUG,
|
||||
close_fds=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -291,26 +348,27 @@ def get_all_snapshots(zfs_command):
|
||||
form "dataset@snapshot".
|
||||
'''
|
||||
list_output = borgmatic.execute.execute_command_and_capture_output(
|
||||
tuple(zfs_command.split(' '))
|
||||
+ (
|
||||
(
|
||||
*tuple(zfs_command.split(' ')),
|
||||
'list',
|
||||
'-H',
|
||||
'-t',
|
||||
'snapshot',
|
||||
'-o',
|
||||
'name',
|
||||
)
|
||||
),
|
||||
close_fds=True,
|
||||
)
|
||||
|
||||
return tuple(line.rstrip() for line in list_output.splitlines())
|
||||
|
||||
|
||||
def remove_data_source_dumps(hook_config, config, borgmatic_runtime_directory, dry_run):
|
||||
def remove_data_source_dumps(hook_config, config, borgmatic_runtime_directory, patterns, dry_run): # noqa: PLR0912
|
||||
'''
|
||||
Given a ZFS configuration dict, a configuration dict, the borgmatic runtime directory, and
|
||||
whether this is a dry run, unmount and destroy any ZFS snapshots created by borgmatic. If this
|
||||
is a dry run or ZFS isn't configured in borgmatic's configuration, then don't actually remove
|
||||
anything.
|
||||
Given a ZFS configuration dict, a configuration dict, the borgmatic runtime directory, the
|
||||
configured patterns, and whether this is a dry run, unmount and destroy any ZFS snapshots
|
||||
created by borgmatic. If this is a dry run or ZFS isn't configured in borgmatic's configuration,
|
||||
then don't actually remove anything.
|
||||
'''
|
||||
if hook_config is None:
|
||||
return
|
||||
@@ -334,6 +392,7 @@ def remove_data_source_dumps(hook_config, config, borgmatic_runtime_directory, d
|
||||
os.path.normpath(borgmatic_runtime_directory),
|
||||
),
|
||||
'zfs_snapshots',
|
||||
'*',
|
||||
)
|
||||
logger.debug(f'Looking for snapshots to remove in {snapshots_glob}{dry_run_label}')
|
||||
umount_command = hook_config.get('umount_command', 'umount')
|
||||
@@ -346,7 +405,10 @@ def remove_data_source_dumps(hook_config, config, borgmatic_runtime_directory, d
|
||||
# child datasets before the shorter mount point paths of parent datasets.
|
||||
for mount_point in reversed(dataset_mount_points):
|
||||
snapshot_mount_path = os.path.join(snapshots_directory, mount_point.lstrip(os.path.sep))
|
||||
if not os.path.isdir(snapshot_mount_path):
|
||||
|
||||
# If the snapshot mount path is empty, this is probably just a "shadow" of a nested
|
||||
# dataset and therefore there's nothing to unmount.
|
||||
if not os.path.isdir(snapshot_mount_path) or not os.listdir(snapshot_mount_path):
|
||||
continue
|
||||
|
||||
# This might fail if the path is already mounted, but we swallow errors here since we'll
|
||||
@@ -370,10 +432,10 @@ def remove_data_source_dumps(hook_config, config, borgmatic_runtime_directory, d
|
||||
return
|
||||
except subprocess.CalledProcessError as error:
|
||||
logger.debug(error)
|
||||
return
|
||||
continue
|
||||
|
||||
if not dry_run:
|
||||
shutil.rmtree(snapshots_directory)
|
||||
shutil.rmtree(snapshot_mount_path, ignore_errors=True)
|
||||
|
||||
# Destroy snapshots.
|
||||
full_snapshot_names = get_all_snapshots(zfs_command)
|
||||
@@ -390,7 +452,10 @@ def remove_data_source_dumps(hook_config, config, borgmatic_runtime_directory, d
|
||||
|
||||
|
||||
def make_data_source_dump_patterns(
|
||||
hook_config, config, borgmatic_runtime_directory, name=None
|
||||
hook_config,
|
||||
config,
|
||||
borgmatic_runtime_directory,
|
||||
name=None,
|
||||
): # pragma: no cover
|
||||
'''
|
||||
Restores aren't implemented, because stored files can be extracted directly with "extract".
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user