mirror of
https://github.com/borgmatic-collective/borgmatic.git
synced 2026-07-22 02:03:01 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
50096296da | ||
|
|
3bc14ba364 | ||
|
|
c9c6913547 | ||
|
|
779f51f40a | ||
|
|
24b846e9ca | ||
|
|
73fe29b055 | ||
|
|
775385e688 | ||
|
|
efdbee934a | ||
|
|
49719dc309 | ||
|
|
b7e3ee8277 | ||
|
|
97fe1a2c50 | ||
|
|
66abf38b39 | ||
|
|
5baf091853 | ||
|
|
c5abcc1fdf | ||
|
|
9a9a8fd1c6 | ||
|
|
ab9e8d06ee | ||
|
|
5a2cd1b261 | ||
|
|
ffaa99ba15 | ||
|
|
5dc0b08f22 | ||
|
|
23009e22aa | ||
|
|
6cfa10fb7e | ||
|
|
d29d0bc1c6 | ||
|
|
c3f4f94190 | ||
|
|
b2d61ade4e | ||
|
|
cca9039863 | ||
|
|
afcf253318 | ||
|
|
76533c7db5 | ||
|
|
0073366dfc | ||
|
|
13acaa47e4 | ||
|
|
cf326a98a5 | ||
|
|
355eef186e | ||
|
|
c392e4914c | ||
|
|
8fed8e0695 | ||
|
|
52189490a2 | ||
|
|
26b44699ba | ||
|
|
09933c3dc7 | ||
|
|
c702dca8da | ||
|
|
62003c58ea | ||
|
|
67c22e464a | ||
|
|
5a9066940f | ||
|
|
61f0987051 | ||
|
|
63c39be55f | ||
|
|
7e344e6e0a | ||
|
|
b02ff8b6e5 | ||
|
|
b6ff242d3a | ||
|
|
71f1819f05 | ||
|
|
31b6e21139 | ||
|
|
7d56641f56 | ||
|
|
1ad6be2077 | ||
|
|
803361b850 | ||
|
|
e0059de711 | ||
|
|
b9ec9bb873 | ||
|
|
8c5db19490 | ||
|
|
cc7e01be68 | ||
|
|
1232ba8045 | ||
|
|
90c1161a8c | ||
|
|
02451a8b30 | ||
|
|
730350b31a | ||
|
|
203e1f4e99 | ||
|
|
4c35a564ef | ||
|
|
7551810ea6 | ||
|
|
ce523eeed6 | ||
|
|
3c0def6d6d | ||
|
|
f08014e3be | ||
|
|
86ad93676d | ||
|
|
e1825d2bcb | ||
|
|
92b8c0230e | ||
|
|
73c196aa70 | ||
|
|
5d390d7953 | ||
|
|
ffb342780b | ||
|
|
9871267f97 | ||
|
|
914c2b17e9 | ||
|
|
804455ac9f | ||
|
|
4fe0fd1576 | ||
|
|
e3d40125cb | ||
|
|
e66df22a6e | ||
|
|
e789de0851 | ||
|
|
f1cac95b9c | ||
|
|
f183800009 | ||
|
|
b7362bfbac | ||
|
|
2467518d4e | ||
|
|
3bda843139 | ||
|
|
44efca2be9 | ||
|
|
cfeeb87bbe | ||
|
|
bb2e986c9d | ||
|
|
67ac70354b | ||
|
|
8c1d5dbfe1 | ||
|
|
a3aeb36159 | ||
|
|
c702a988bd | ||
|
|
bbf1c3d55e | ||
|
|
0b17fb2d3f | ||
|
|
ca54da1067 | ||
|
|
661041da04 | ||
|
|
ad14ff3ee5 | ||
|
|
b72b9aaf13 | ||
|
|
a70fd30cb1 | ||
|
|
5560f30aa6 | ||
|
|
256ed4170b | ||
|
|
071d8d945a | ||
|
|
926c26315a | ||
|
|
120a29ab4d | ||
|
|
8573660ff0 | ||
|
|
0b9f3ae8a1 | ||
|
|
2c70ad81ec | ||
|
|
d6c3ec05aa | ||
|
|
a4954cc7a3 | ||
|
|
a6b6dd32c1 | ||
|
|
d3409df84c | ||
|
|
87e77ff2b7 | ||
|
|
3517d9d4f3 | ||
|
|
d3c7279dad | ||
|
|
a99c48c115 | ||
|
|
94cedd4cf8 | ||
|
|
a4baf4623b | ||
|
|
77df425bd1 | ||
|
|
69476a4fab | ||
|
|
be6b865a81 | ||
|
|
b58a52e03f | ||
|
|
9b85c5bc61 | ||
|
|
b8041f5c39 | ||
|
|
d9d6d3f7f2 | ||
|
|
0844cd0d4f | ||
|
|
d4705602fa | ||
|
|
5174a78109 | ||
|
|
3db79b4352 | ||
|
|
d6732d9abb | ||
|
|
267af5b372 | ||
|
|
d53ea09adb | ||
|
|
8696cbfa22 | ||
|
|
48dca28c74 | ||
|
|
36bcbd0592 | ||
|
|
ebb3bca4b3 | ||
|
|
b1e343f15c | ||
|
|
cb7f98192c | ||
|
|
3ceb4f554f | ||
|
|
4b18c0bc81 | ||
|
|
2ce09dbf82 | ||
|
|
8a4f3b8f1a | ||
|
|
81cd03cbbf | ||
|
|
f2455527fc | ||
|
|
62d67cde0a | ||
|
|
ae8a9db27d | ||
|
|
8979f8918d | ||
|
|
eb97708092 | ||
|
|
f2d93b85b4 | ||
|
|
b999d2dc4d | ||
|
|
7f2e38d061 | ||
|
|
140fc248b6 | ||
|
|
ec9e1a8223 | ||
|
|
03bbe77dd9 | ||
|
|
f1c5f11422 | ||
|
|
f8df06fb92 | ||
|
|
d95707ff9b | ||
|
|
51a7f50e3a | ||
|
|
49b8b693af | ||
|
|
d0e92493f6 | ||
|
|
9afdaca985 | ||
|
|
cc11ed78e0 | ||
|
|
87f3746881 | ||
|
|
347a4c3dd5 | ||
|
|
399bb6ef68 | ||
|
|
9b9ecad299 | ||
|
|
8c4b899a13 | ||
|
|
9b77de3d66 | ||
|
|
bfeea5d394 | ||
|
|
8a6225b7c2 | ||
|
|
9aaa3c925f | ||
|
|
88fd1ae454 | ||
|
|
27305ec2bf | ||
|
|
4453c2d49c | ||
|
|
6367a00013 | ||
|
|
cd654cbb57 | ||
|
|
1e8f73779f | ||
|
|
27d167b071 | ||
|
|
cfff6c6855 | ||
|
|
37efaeae88 | ||
|
|
0978c669ad | ||
|
|
1366269586 | ||
|
|
a9a0910817 | ||
|
|
5bcc7b60c8 | ||
|
|
84a0552277 | ||
|
|
d4a02f73b5 | ||
|
|
3f901c0a52 | ||
|
|
b5b5c1fafa | ||
|
|
86e5085acc | ||
|
|
08a5e8717b | ||
|
|
6b2f2b2ac4 | ||
|
|
a07cf9e699 | ||
|
|
bf40b01077 | ||
|
|
a5c6a2fe1c | ||
|
|
82141fe981 | ||
|
|
228a83978d | ||
|
|
638db3770b | ||
|
|
98df5c3af2 | ||
|
|
b0e906c0e7 | ||
|
|
e8dccbf1c1 | ||
|
|
4a997bc234 | ||
|
|
3197178b3d | ||
|
|
5e618154d0 | ||
|
|
84f611ae4f | ||
|
|
5dc8450c8e | ||
|
|
689643e5fa | ||
|
|
0a3d87eaea | ||
|
|
b45b62cd38 | ||
|
|
8de7094691 | ||
|
|
8c7e68305e | ||
|
|
65a323433c | ||
|
|
b5a3589471 | ||
|
|
f4a736bdfe | ||
|
|
eab0ec15ef | ||
|
|
c65aa24001 | ||
|
|
5a24bf2037 | ||
|
|
324dbc3a79 | ||
|
|
9fe7db320a | ||
|
|
4d19596616 | ||
|
|
5cec2bf3d9 | ||
|
|
06e0f98fd8 | ||
|
|
87f36caf8d | ||
|
|
ab7acceff6 | ||
|
|
1b2b0c3020 | ||
|
|
289d178581 | ||
|
|
1e7f6d9f41 | ||
|
|
d0c90389fb | ||
|
|
f9e920dce9 | ||
|
|
0ed52bbc4a | ||
|
|
da8278b566 | ||
|
|
2af3522902 | ||
|
|
5e4784991a | ||
|
|
ab43ef00ce | ||
|
|
47a8a95b29 | ||
|
|
7c90c04ce0 | ||
|
|
97305cc3ce | ||
|
|
4985b805b4 | ||
|
|
d09b4c72a9 | ||
|
|
9807549f88 | ||
|
|
30c821120e | ||
|
|
13884bd448 | ||
|
|
6bce4c4a0d | ||
|
|
25572c98d7 | ||
|
|
dab0dfcb32 | ||
|
|
851c454ef0 | ||
|
|
c7a0cebaf7 | ||
|
|
76cfeda290 | ||
|
|
afdf831c59 | ||
|
|
9ac3087304 | ||
|
|
7cca83b698 | ||
|
|
4b5df7117a | ||
|
|
57decfa4db | ||
|
|
b80f60a731 | ||
|
|
8f5ea95348 | ||
|
|
b0cad58d6c | ||
|
|
073d6bddf6 | ||
|
|
810b65589f | ||
|
|
295bfb0c57 | ||
|
|
5f3d4f9b03 | ||
|
|
5321301708 | ||
|
|
a939a66fb4 | ||
|
|
c0721a8cad | ||
|
|
ea47704d86 | ||
|
|
61e4eeff6c | ||
|
|
3ab4b45041 | ||
|
|
4e1f256b48 | ||
|
|
96bb402837 | ||
|
|
97949266b3 | ||
|
|
e69d2385fc | ||
|
|
6d9340ebb2 | ||
|
|
0441e79b41 | ||
|
|
b1af304125 | ||
|
|
eb8f7e0329 | ||
|
|
bf978f2db4 | ||
|
|
22776b123d | ||
|
|
ef66349674 | ||
|
|
51b885e7db | ||
|
|
1781787305 | ||
|
|
46ebb0cebb | ||
|
|
3e0fa57860 | ||
|
|
59f8722e05 | ||
|
|
4ba42e8905 | ||
|
|
3b79482b24 | ||
|
|
7eb19cb0a7 | ||
|
|
a4fabb8521 | ||
|
|
85ea8f4f45 | ||
|
|
290559116d | ||
|
|
72b27b0858 | ||
|
|
0fdee067c7 | ||
|
|
0dca5eeafc | ||
|
|
02ce3ba190 | ||
|
|
dc78bf4d6b | ||
|
|
4b7fbce291 | ||
|
|
1817b9a9ea | ||
|
|
009055c61a | ||
|
|
54884da8fa | ||
|
|
1177385e08 | ||
|
|
a45ba8553c | ||
|
|
d7d6e30178 | ||
|
|
56304fdcad | ||
|
|
3f75e9931f | ||
|
|
227f475e17 | ||
|
|
467ddd0e93 | ||
|
|
be08e889f0 | ||
|
|
94c8a56373 | ||
|
|
cecf04aa69 | ||
|
|
814cdb4b87 | ||
|
|
13878be254 | ||
|
|
94db527500 | ||
|
|
2849f54932 | ||
|
|
129f3e753c | ||
|
|
c85bf46ad9 | ||
|
|
fa6a4734d4 | ||
|
|
e52e29444f | ||
|
|
f713f1df7e | ||
|
|
87d824553d | ||
|
|
52fbf8cb24 | ||
|
|
f758374772 | ||
|
|
60c5949c23 | ||
|
|
d11c517f67 | ||
|
|
237999cc81 | ||
|
|
d060f8d77a | ||
|
|
385ef2d012 | ||
|
|
02a2e77315 | ||
|
|
9b623a8a8e | ||
|
|
e1f60e4b09 | ||
|
|
269c00b240 | ||
|
|
48f008d720 | ||
|
|
53d9ffd1d3 | ||
|
|
d7323e08ac | ||
|
|
fc7a2852e0 | ||
|
|
f8c9d985e8 | ||
|
|
c7ca9bf844 | ||
|
|
7a117d5cc9 | ||
|
|
bd9751586c | ||
|
|
fe0fe27c36 | ||
|
|
1426859e1c | ||
|
|
ffb431e3ab | ||
|
|
11fee81486 | ||
|
|
83bc737185 | ||
|
|
cda83310c8 | ||
|
|
90ccbecf07 | ||
|
|
ccbf668bea | ||
|
|
f18219a768 | ||
|
|
9ba8ca24eb | ||
|
|
b42793a2dc | ||
|
|
2877c1ad0d | ||
|
|
1b9f95ca47 | ||
|
|
7aff22536d | ||
|
|
d6e1cc3e12 | ||
|
|
c1a08edca2 | ||
|
|
02a219fac2 | ||
|
|
78f81c7b73 | ||
|
|
8edb40a8e9 | ||
|
|
f2d7687ca3 | ||
|
|
b5fb0c8247 | ||
|
|
9b2ac961d7 | ||
|
|
38ce98771b | ||
|
|
f616284ffb | ||
|
|
5da898003f | ||
|
|
5a464b3186 | ||
|
|
42fb8c38e0 | ||
|
|
80839566d6 | ||
|
|
702e55e6f7 | ||
|
|
1a8e8835c1 | ||
|
|
9abc5c60d4 | ||
|
|
fc87b74ab0 | ||
|
|
ad21eb41ae | ||
|
|
601e393ec7 | ||
|
|
e391fd196d | ||
|
|
5f387b3991 | ||
|
|
ed957a940a | ||
|
|
bd4c672382 | ||
|
|
c71da46963 | ||
|
|
3da7471fe6 | ||
|
|
b874e7e66f | ||
|
|
b029d1cb67 | ||
|
|
33b1101ce1 | ||
|
|
d96c5f79fb | ||
|
|
9e29ce788f | ||
|
|
4658c5d1cb | ||
|
|
5280de86ff | ||
|
|
29d5b36a78 | ||
|
|
29f214a269 | ||
|
|
6fdce2a4a6 | ||
|
|
a4b65cf710 | ||
|
|
79725d2ff7 | ||
|
|
f7e8a2c1d1 | ||
|
|
f54d566edc | ||
|
|
b7efa0d3f0 | ||
|
|
88b945dcb9 | ||
|
|
34305d686c | ||
|
|
bf1e8bc44e | ||
|
|
fd4f69f6c3 | ||
|
|
1fe6ae83a8 | ||
|
|
1197d6d0f6 | ||
|
|
288a4bf243 | ||
|
|
1fb943e5f1 | ||
|
|
e0298685a1 | ||
|
|
c5633227bf | ||
|
|
6087c12e09 | ||
|
|
9d83f02e24 | ||
|
|
b6ccde6757 | ||
|
|
548aceb3d5 | ||
|
|
2bd63bbdd2 | ||
|
|
9b4fed64a6 | ||
|
|
ff1001a5f5 | ||
|
|
e7f1c3eda5 | ||
|
|
21e343a948 | ||
|
|
4c4fd92013 | ||
|
|
37735e464c | ||
|
|
424cc6b66c | ||
|
|
e128a3e0a9 | ||
|
|
27e7ece2f5 | ||
|
|
d44dc93509 | ||
|
|
31778fd3bf | ||
|
|
4313f90dd8 | ||
|
|
1f82ea2798 | ||
|
|
148536d867 | ||
|
|
197f0a521d | ||
|
|
0d8e352033 | ||
|
|
20a3995977 | ||
|
|
66aa953371 | ||
|
|
ba053de8f7 | ||
|
|
2f844d65d5 | ||
|
|
2dca5e1834 | ||
|
|
36197ce027 | ||
|
|
e9a0226ee0 | ||
|
|
fc3b4a653e | ||
|
|
3673abb01e | ||
|
|
ac4277d36c | ||
|
|
21cbc99d9e | ||
|
|
d080bf2ae9 | ||
|
|
2a1c790655 | ||
|
|
410204a70d | ||
|
|
4a0c167c1c | ||
|
|
593c956d33 | ||
|
|
d18cb89493 | ||
|
|
067c79c606 | ||
|
|
ebde88ccaa | ||
|
|
cc402487d9 | ||
|
|
d108e6102b | ||
|
|
3e60043632 | ||
|
|
a8d691169a | ||
|
|
939c2f6718 | ||
|
|
0837059e21 | ||
|
|
0ee166fdf0 | ||
|
|
b50996b864 | ||
|
|
8f423c7293 | ||
|
|
14ce88e04b | ||
|
|
f97968b72d | ||
|
|
612f867ea8 | ||
|
|
303d6609e4 | ||
|
|
bf7b163ccd | ||
|
|
4bd798f0ad | ||
|
|
52aa7c5d21 | ||
|
|
f5a1dd31c8 | ||
|
|
c41000a4b1 | ||
|
|
c3f8b05a68 | ||
|
|
f4fcf92bd6 | ||
|
|
a2c139245d | ||
|
|
a509cdedd5 | ||
|
|
dcbc30b164 | ||
|
|
5ab99b4cc0 | ||
|
|
27c90b7cf1 | ||
|
|
6eb76454bb | ||
|
|
83bcea98dc | ||
|
|
4db09a73b3 | ||
|
|
45a9e3bfc3 | ||
|
|
bd40015e1c | ||
|
|
7894600408 | ||
|
|
df4668754d | ||
|
|
08d6f83b2e | ||
|
|
c58f510054 | ||
|
|
c2879d054a | ||
|
|
f821d2c909 | ||
|
|
1ef2218919 | ||
|
|
177c958572 | ||
|
|
b5ab1ff0cd | ||
|
|
70a978b83d | ||
|
|
2037810c6b | ||
|
|
de304f83de | ||
|
|
5752373009 | ||
|
|
fecae39fcd | ||
|
|
38bc4fbfe2 | ||
|
|
92ed7573d4 | ||
|
|
80f0e92462 | ||
|
|
5f10b1b2ca | ||
|
|
4f83b1e6b3 | ||
|
|
15d5a687fb | ||
|
|
eb1fce3787 | ||
|
|
7f735cbe59 | ||
|
|
a690ea4016 | ||
|
|
7a110c7acd | ||
|
|
407bb33359 | ||
|
|
4b7f7bba04 | ||
|
|
cfdc0a1f2a | ||
|
|
f926055e67 | ||
|
|
058af95d70 | ||
|
|
54facdc391 | ||
|
|
2e4c0cc7e7 | ||
|
|
cb2fd7c5e8 | ||
|
|
94133cc8b1 | ||
|
|
dcec89be90 | ||
|
|
fefd5d1d0e | ||
|
|
163c37d77f | ||
|
|
b0e49ebce0 | ||
|
|
7e51c41ebf | ||
|
|
f9182514d8 | ||
|
|
7700b87b60 | ||
|
|
75bdbe6087 | ||
|
|
d243a8c836 | ||
|
|
4c2eb2bfe3 | ||
|
|
89ce060dbd | ||
|
|
ad7dcb4615 | ||
|
|
6680aece5a | ||
|
|
57eb93760f | ||
|
|
f21a2c06e3 | ||
|
|
2212539cb0 | ||
|
|
36d10fecb1 | ||
|
|
3ecd0e731e | ||
|
|
ecf5a7e294 | ||
|
|
893fbcf9ff | ||
|
|
f8f6560502 | ||
|
|
8c301ba688 | ||
|
|
035e96156a | ||
|
|
a08c7fc77a | ||
|
|
cf9e387811 | ||
|
|
e37224606a | ||
|
|
9647301b99 | ||
|
|
a0e5dbff96 | ||
|
|
86117edccf | ||
|
|
440f3eeb63 | ||
|
|
181051eae1 | ||
|
|
ec0ee971ed | ||
|
|
b83ffa0cf6 | ||
|
|
cf88665d37 | ||
|
|
b233adba63 | ||
|
|
018f5e3315 | ||
|
|
284f26b49d | ||
|
|
11b437794e | ||
|
|
0665b50d57 | ||
|
|
0586b80e5b | ||
|
|
272a7b4866 | ||
|
|
98d4a59459 | ||
|
|
744139cf97 | ||
|
|
1339509e9b | ||
|
|
e14f61415b | ||
|
|
98cf8f7e20 | ||
|
|
5f16b64639 | ||
|
|
fe62a81151 | ||
|
|
585b1573ae | ||
|
|
141ba2771d | ||
|
|
a527f76d08 | ||
|
|
a97c68b4c8 | ||
|
|
ef07005a75 | ||
|
|
43c7c3b6be | ||
|
|
2f6ad9d173 | ||
|
|
16bc0de3fb | ||
|
|
458d157e62 | ||
|
|
40c3a28620 | ||
|
|
60107f1ee8 | ||
|
|
a1153a21fa | ||
|
|
b6cb7da98e | ||
|
|
9e3d19a406 | ||
|
|
2b755d8ade | ||
|
|
925f99cfef | ||
|
|
c9f20eb260 | ||
|
|
f4744826fe | ||
|
|
5586aab967 |
-95
@@ -1,95 +0,0 @@
|
||||
---
|
||||
kind: pipeline
|
||||
name: python-3-8-alpine-3-13
|
||||
|
||||
services:
|
||||
- name: postgresql
|
||||
image: docker.io/postgres:13.1-alpine
|
||||
environment:
|
||||
POSTGRES_PASSWORD: test
|
||||
POSTGRES_DB: test
|
||||
- name: postgresql2
|
||||
image: docker.io/postgres:13.1-alpine
|
||||
environment:
|
||||
POSTGRES_PASSWORD: test2
|
||||
POSTGRES_DB: test
|
||||
commands:
|
||||
- docker-entrypoint.sh -p 5433
|
||||
- name: mariadb
|
||||
image: docker.io/mariadb:10.11.4
|
||||
environment:
|
||||
MARIADB_ROOT_PASSWORD: test
|
||||
MARIADB_DATABASE: test
|
||||
- name: mariadb2
|
||||
image: docker.io/mariadb:10.11.4
|
||||
environment:
|
||||
MARIADB_ROOT_PASSWORD: test2
|
||||
MARIADB_DATABASE: test
|
||||
commands:
|
||||
- docker-entrypoint.sh --port=3307
|
||||
- name: not-actually-mysql
|
||||
image: docker.io/mariadb:10.11.4
|
||||
environment:
|
||||
MARIADB_ROOT_PASSWORD: test
|
||||
MARIADB_DATABASE: test
|
||||
- name: not-actually-mysql2
|
||||
image: docker.io/mariadb:10.11.4
|
||||
environment:
|
||||
MARIADB_ROOT_PASSWORD: test2
|
||||
MARIADB_DATABASE: test
|
||||
commands:
|
||||
- docker-entrypoint.sh --port=3307
|
||||
- name: mongodb
|
||||
image: docker.io/mongo:5.0.5
|
||||
environment:
|
||||
MONGO_INITDB_ROOT_USERNAME: root
|
||||
MONGO_INITDB_ROOT_PASSWORD: test
|
||||
- name: mongodb2
|
||||
image: docker.io/mongo:5.0.5
|
||||
environment:
|
||||
MONGO_INITDB_ROOT_USERNAME: root2
|
||||
MONGO_INITDB_ROOT_PASSWORD: test2
|
||||
commands:
|
||||
- docker-entrypoint.sh --port=27018
|
||||
|
||||
clone:
|
||||
skip_verify: true
|
||||
|
||||
steps:
|
||||
- name: build
|
||||
image: docker.io/alpine:3.13
|
||||
environment:
|
||||
TEST_CONTAINER: true
|
||||
pull: always
|
||||
commands:
|
||||
- scripts/run-full-tests
|
||||
---
|
||||
kind: pipeline
|
||||
name: documentation
|
||||
type: exec
|
||||
|
||||
platform:
|
||||
os: linux
|
||||
arch: amd64
|
||||
|
||||
clone:
|
||||
skip_verify: true
|
||||
|
||||
steps:
|
||||
- name: build
|
||||
environment:
|
||||
USERNAME:
|
||||
from_secret: docker_username
|
||||
PASSWORD:
|
||||
from_secret: docker_password
|
||||
IMAGE_NAME: projects.torsion.org/borgmatic-collective/borgmatic:docs
|
||||
commands:
|
||||
- podman login --username "$USERNAME" --password "$PASSWORD" projects.torsion.org
|
||||
- podman build --tag "$IMAGE_NAME" --file docs/Dockerfile --storage-opt "overlay.mount_program=/usr/bin/fuse-overlayfs" .
|
||||
- podman push "$IMAGE_NAME"
|
||||
|
||||
trigger:
|
||||
repo:
|
||||
- borgmatic-collective/borgmatic
|
||||
branch:
|
||||
- main
|
||||
@@ -0,0 +1,30 @@
|
||||
name: build
|
||||
run-name: ${{ gitea.actor }} is building
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: host
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: scripts/run-end-to-end-tests
|
||||
|
||||
docs:
|
||||
needs: [test]
|
||||
runs-on: host
|
||||
env:
|
||||
IMAGE_NAME: projects.torsion.org/borgmatic-collective/borgmatic:docs
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: podman login --username "$USERNAME" --password "$PASSWORD" projects.torsion.org
|
||||
env:
|
||||
USERNAME: "${{ secrets.REGISTRY_USERNAME }}"
|
||||
PASSWORD: "${{ secrets.REGISTRY_PASSWORD }}"
|
||||
- run: podman build --tag "$IMAGE_NAME" --file docs/Dockerfile --storage-opt "overlay.mount_program=/usr/bin/fuse-overlayfs" .
|
||||
- run: podman push "$IMAGE_NAME"
|
||||
- run: scripts/export-docs-from-image
|
||||
- run: curl --user "${{ secrets.REGISTRY_USERNAME }}:${{ secrets.REGISTRY_PASSWORD }}" --upload-file borgmatic-docs.tar.gz https://projects.torsion.org/api/packages/borgmatic-collective/generic/borgmatic-docs/$(head --lines=1 NEWS)/borgmatic-docs.tar.gz
|
||||
@@ -1,3 +1,295 @@
|
||||
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:
|
||||
https://torsion.org/borgmatic/docs/how-to/provide-your-passwords/
|
||||
* #987: Fix a "list" action error when the "encryption_passcommand" option is set.
|
||||
* #987: When both "encryption_passcommand" and "encryption_passphrase" are configured, prefer
|
||||
"encryption_passphrase" even if it's an empty value.
|
||||
* #988: With the "max_duration" option or the "--max-duration" flag, run the archives and
|
||||
repository checks separately so they don't interfere with one another. Previously, borgmatic
|
||||
refused to run checks in this situation.
|
||||
* #989: Fix the log message code to avoid using Python 3.10+ logging features. Now borgmatic will
|
||||
work with Python 3.9 again.
|
||||
* Capture and delay any log records produced before logging is fully configured, so early log
|
||||
records don't get lost.
|
||||
* Add support for Python 3.13.
|
||||
|
||||
1.9.9
|
||||
* #635: Log the repository path or label on every relevant log message, not just some logs.
|
||||
* #961: When the "encryption_passcommand" option is set, call the command once from borgmatic to
|
||||
collect the encryption passphrase and then pass it to Borg multiple times. See the documentation
|
||||
for more information: https://torsion.org/borgmatic/docs/how-to/provide-your-passwords/
|
||||
* #981: Fix a "spot" check file count delta error.
|
||||
* #982: Fix for borgmatic "exclude_patterns" and "exclude_from" recursing into excluded
|
||||
subdirectories.
|
||||
* #983: Fix the Btrfs hook to support subvolumes with names like "@home" different from their
|
||||
mount points.
|
||||
* #985: Change the default value for the "--original-hostname" flag from "localhost" to no host
|
||||
specified. This way, the "restore" action works without a hostname if there's a single matching
|
||||
database dump.
|
||||
|
||||
1.9.8
|
||||
* #979: Fix root patterns so they don't have an invalid "sh:" prefix before getting passed to Borg.
|
||||
* Expand the recent contributors documentation section to include ticket submitters—not just code
|
||||
contributors—because there are multiple ways to contribute to the project! See:
|
||||
https://torsion.org/borgmatic/#recent-contributors
|
||||
|
||||
1.9.7
|
||||
* #855: Add a Sentry monitoring hook. See the documentation for more information:
|
||||
https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#sentry-hook
|
||||
* #968: Fix for a "spot" check error when a filename in the most recent archive contains a newline.
|
||||
* #970: Fix for an error when there's a blank line in the configured patterns or excludes.
|
||||
* #971: Fix for "exclude_from" files being completely ignored.
|
||||
* #977: Fix for "exclude_patterns" and "exclude_from" not supporting explicit pattern styles (e.g.,
|
||||
"sh:" or "re:").
|
||||
|
||||
1.9.6
|
||||
* #959: Fix an error in the Btrfs hook when a subvolume mounted at "/" is configured in borgmatic's
|
||||
source directories.
|
||||
* #960: Fix for archives storing relative source directory paths such that they contain the working
|
||||
directory.
|
||||
* #960: Fix the "spot" check to support relative source directory paths.
|
||||
* #962: For the ZFS, Btrfs, and LVM hooks, perform path rewriting for excludes and patterns in
|
||||
addition to the existing source directories rewriting.
|
||||
* #962: Under the hood, merge all configured source directories, excludes, and patterns into a
|
||||
unified temporary patterns file for passing to Borg. The borgmatic configuration options remain
|
||||
unchanged.
|
||||
* #962: For the LVM hook, add support for nested logical volumes.
|
||||
* #965: Fix a borgmatic runtime directory error when running the "spot" check with a database hook
|
||||
enabled.
|
||||
* #969: Fix the "restore" action to work on database dumps without a port when a default port is
|
||||
present in configuration.
|
||||
* Fix the "spot" check to no longer consider pipe files within an archive for file comparisons.
|
||||
* Fix the "spot" check to have a nicer error when there are no source paths to compare.
|
||||
* Fix auto-excluding of special files (when databases are configured) to support relative source
|
||||
directory paths.
|
||||
* Drop support for Python 3.8, which has been end-of-lifed.
|
||||
|
||||
1.9.5
|
||||
* #418: Backup and restore databases that have the same name but with different ports, hostnames,
|
||||
or hooks.
|
||||
* #947: To avoid a hang in the database hooks, error and exit when the borgmatic runtime
|
||||
directory overlaps with the configured excludes.
|
||||
* #954: Fix a findmnt command error in the Btrfs hook by switching to parsing JSON output.
|
||||
* #956: Fix the printing of a color reset code even when color is disabled.
|
||||
* #958: Drop colorama as a library dependency.
|
||||
* When the ZFS, Btrfs, or LVM hooks aren't configured, don't try to cleanup snapshots for them.
|
||||
|
||||
1.9.4
|
||||
* #80 (beta): Add an LVM hook for snapshotting and backing up LVM logical volumes. See the
|
||||
documentation for more information:
|
||||
https://torsion.org/borgmatic/docs/how-to/snapshot-your-filesystems/
|
||||
* #251 (beta): Add a Btrfs hook for snapshotting and backing up Btrfs subvolumes. See the
|
||||
documentation for more information:
|
||||
https://torsion.org/borgmatic/docs/how-to/snapshot-your-filesystems/
|
||||
* #926: Fix a library error when running within a PyInstaller bundle.
|
||||
* #950: Fix a snapshot unmount error in the ZFS hook when using nested datasets.
|
||||
* Update the ZFS hook to discover and snapshot ZFS datasets even if they are parent/grandparent
|
||||
directories of your source directories.
|
||||
* Reorganize data source and monitoring hooks to make developing new hooks easier.
|
||||
|
||||
1.9.3
|
||||
* #261 (beta): Add a ZFS hook for snapshotting and backing up ZFS datasets. See the documentation
|
||||
for more information: https://torsion.org/borgmatic/docs/how-to/snapshot-your-filesystems/
|
||||
* Remove any temporary copies of the manifest file created in support of the "bootstrap" action.
|
||||
* Deprecate the "store_config_files" option at the global scope and move it under the "bootstrap"
|
||||
hook. See the documentation for more information:
|
||||
https://torsion.org/borgmatic/docs/how-to/extract-a-backup/#extract-the-configuration-files-used-to-create-an-archive
|
||||
* Require the runtime directory to be an absolute path.
|
||||
* Add a "--deleted" flag to the "repo-list" action for listing deleted archives that haven't
|
||||
yet been compacted (Borg 2 only).
|
||||
* Promote the "spot" check from a beta feature to stable.
|
||||
|
||||
1.9.2
|
||||
* #441: Apply the "umask" option to all relevant actions, not just some of them.
|
||||
* #722: Remove the restriction that the "extract" and "mount" actions must match a single
|
||||
repository. Now they work more like other actions, where each repository is applied in turn.
|
||||
* #932: Fix the missing build backend setting in pyproject.toml to allow Fedora builds.
|
||||
* #934: Update the logic that probes for the borgmatic streaming database dump, bootstrap
|
||||
metadata, and check state directories to support more platforms and use cases. See the
|
||||
documentation for more information:
|
||||
https://torsion.org/borgmatic/docs/how-to/backup-your-databases/#runtime-directory
|
||||
* #934: Add the "RuntimeDirectory" and "StateDirectory" options to the sample systemd service
|
||||
file to support the new runtime and state directory logic.
|
||||
* #939: Fix borgmatic ignoring the "BORG_RELOCATED_REPO_ACCESS_IS_OK" and
|
||||
"BORG_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK" environment variables.
|
||||
* Add a Pushover monitoring hook. See the documentation for more information:
|
||||
https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#pushover-hook
|
||||
|
||||
1.9.1
|
||||
* #928: Fix the user runtime directory location on macOS (and possibly Cygwin).
|
||||
* #930: Fix an error with the sample systemd service when no credentials are configured.
|
||||
* #931: Fix an error when implicitly upgrading the check state directory from ~/.borgmatic to
|
||||
~/.local/state/borgmatic across filesystems.
|
||||
|
||||
1.9.0
|
||||
* #609: Fix the glob expansion of "source_directories" values to respect the "working_directory"
|
||||
option.
|
||||
* #609: BREAKING: Apply the "working_directory" option to all actions, not just "create". This
|
||||
includes repository paths, destination paths, mount points, etc.
|
||||
* #562: Deprecate the "borgmatic_source_directory" option in favor of "user_runtime_directory"
|
||||
and "user_state_directory".
|
||||
* #562: BREAKING: Move the default borgmatic streaming database dump and bootstrap metadata
|
||||
directory from ~/.borgmatic to /run/user/$UID/borgmatic, which is more XDG-compliant. You can
|
||||
override this location with the new "user_runtime_directory" option. Existing archives with
|
||||
database dumps at the old location are still restorable.
|
||||
* #562, #638: Move the default check state directory from ~/.borgmatic to
|
||||
~/.local/state/borgmatic. This is more XDG-compliant and also prevents these state files from
|
||||
getting backed up (unless you explicitly include them). You can override this location with the
|
||||
new "user_state_directory" option. After the first time you run the "check" action with borgmatic
|
||||
1.9.0, you can safely delete the ~/.borgmatic directory.
|
||||
* #838: BREAKING: With Borg 1.4+, store database dumps and bootstrap metadata in a "/borgmatic"
|
||||
directory within a backup archive, so the path doesn't depend on the current user. This means
|
||||
that you can now backup as one user and restore or bootstrap as another user, among other use
|
||||
cases.
|
||||
* #902: Add loading of encrypted systemd credentials. See the documentation for more information:
|
||||
https://torsion.org/borgmatic/docs/how-to/provide-your-passwords/#using-systemd-service-credentials
|
||||
* #911: Add a "key change-passphrase" action to change the passphrase protecting a repository key.
|
||||
* #914: Fix a confusing apparent hang when when the repository location changes, and instead
|
||||
show a helpful error message.
|
||||
* #915: BREAKING: Rename repository actions like "rcreate" to more explicit names like
|
||||
"repo-create" for compatibility with recent changes in Borg 2.0.0b10.
|
||||
* #918: BREAKING: When databases are configured, don't auto-enable the "one_file_system" option,
|
||||
as existing auto-excludes of special files should be sufficient to prevent Borg from hanging on
|
||||
them. But if this change causes problems for you, you can always enable "one_file_system"
|
||||
explicitly.
|
||||
* #919: Clarify the command-line help for the "--config" flag.
|
||||
* #919: Document a policy for versioning and breaking changes:
|
||||
https://torsion.org/borgmatic/docs/how-to/upgrade/#versioning-and-breaking-changes
|
||||
* #921: BREAKING: Change soft failure command hooks to skip only the current repository rather than
|
||||
all repositories in the configuration file.
|
||||
* #922: Replace setup.py (Python packaging metadata) with the more modern pyproject.toml.
|
||||
* When using Borg 2, default the "archive_name_format" option to just "{hostname}", as Borg 2 does
|
||||
not require unique archive names; identical archive names form a common "series" that can be
|
||||
targeted together. See the Borg 2 documentation for more information:
|
||||
https://borgbackup.readthedocs.io/en/2.0.0b13/changes.html#borg-1-2-x-1-4-x-to-borg-2-0
|
||||
* Add support for Borg 2's "rclone:" repository URLs, so you can backup to 70+ cloud storage
|
||||
services whether or not they support Borg explicitly.
|
||||
* Add support for Borg 2's "sftp://" repository URLs.
|
||||
* Update the "--match-archives" and "--archive" flags to support Borg 2 series names or archive
|
||||
hashes.
|
||||
* Add a "--match-archives" flag to the "prune" action.
|
||||
* Add "--local-path" and "--remote-path" flags to the "config bootstrap" action for setting the
|
||||
Borg executable paths used for bootstrapping.
|
||||
* Add a "--user-runtime-directory" flag to the "config bootstrap" action for helping borgmatic
|
||||
locate the bootstrap metadata stored in an archive.
|
||||
* Add a Zabbix monitoring hook. See the documentation for more information:
|
||||
https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#zabbix-hook
|
||||
* Add a tarball of borgmatic's HTML documentation to the packages on the project page.
|
||||
|
||||
1.8.14
|
||||
* #896: Fix an error in borgmatic rcreate/init on an empty repository directory with Borg 1.4.
|
||||
* #898: Add glob ("*") support to the "--repository" flag. Just quote any values containing
|
||||
globs so your shell doesn't interpret them.
|
||||
* #899: Fix for a "bad character" Borg error in which the "spot" check fed Borg an invalid pattern.
|
||||
* #900: Fix for a potential traceback (TypeError) during the handling of another error.
|
||||
* #904: Clarify the configuration reference about the "spot" check options:
|
||||
https://torsion.org/borgmatic/docs/reference/configuration/
|
||||
* #905: Fix the "source_directories_must_exist" option to work with relative "source_directories"
|
||||
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
|
||||
* 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
|
||||
|
||||
1.8.13
|
||||
* #298: Add "delete" and "rdelete" actions to delete archives or entire repositories.
|
||||
* #785: Add an "only_run_on" option to consistency checks so you can limit a check to running on
|
||||
particular days of the week. See the documentation for more information:
|
||||
https://torsion.org/borgmatic/docs/how-to/deal-with-very-large-backups/#check-days
|
||||
* #885: Add an Uptime Kuma monitoring hook. See the documentation for more information:
|
||||
https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#uptime-kuma-hook
|
||||
* #886: Fix a PagerDuty hook traceback with Python < 3.10.
|
||||
* #889: Fix the Healthchecks ping body size limit, restoring it to the documented 100,000 bytes.
|
||||
|
||||
1.8.12
|
||||
* #817: Add a "--max-duration" flag to the "check" action and a "max_duration" option to the
|
||||
repository check configuration. This tells Borg to interrupt a repository check after a certain
|
||||
duration.
|
||||
* #860: Fix interaction between environment variable interpolation in constants and shell escaping.
|
||||
* #863: When color output is disabled (explicitly or implicitly), don't prefix each log line with
|
||||
the log level.
|
||||
* #865: Add an "upload_buffer_size" option to set the size of the upload buffer used in "create"
|
||||
action.
|
||||
* #866: Fix "Argument list too long" error in the "spot" check when checking hundreds of thousands
|
||||
of files at once.
|
||||
* #874: Add the configured repository label as "repository_label" to the interpolated variables
|
||||
passed to before/after command hooks.
|
||||
* #881: Fix "Unrecognized argument" error when the same value is used with different command-line
|
||||
flags.
|
||||
* In the "spot" check, don't try to hash symlinked directories.
|
||||
|
||||
1.8.11
|
||||
* #815: Add optional Healthchecks auto-provisioning via "create_slug" option.
|
||||
* #851: Fix lack of file extraction when using "extract --strip-components all" on a path with a
|
||||
leading slash.
|
||||
* #854: Fix a traceback when the "data" consistency check is used.
|
||||
* #857: Fix a traceback with "check --only spot" when the "spot" check is unconfigured.
|
||||
|
||||
1.8.10
|
||||
* #656 (beta): Add a "spot" consistency check that compares file counts and contents between your
|
||||
source files and the latest archive, ensuring they fall within configured tolerances. This can
|
||||
catch problems like incorrect excludes, inadvertent deletes, files changed by malware, etc. See
|
||||
the documentation for more information:
|
||||
https://torsion.org/borgmatic/docs/how-to/deal-with-very-large-backups/#spot-check
|
||||
* #779: When "--match-archives *" is used with "check" action, don't skip Borg's orphaned objects
|
||||
check.
|
||||
* #842: When a command hook exits with a soft failure, ping the log and finish states for any
|
||||
configured monitoring hooks.
|
||||
* #843: Add documentation link to Loki dashboard for borgmatic:
|
||||
https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#loki-hook
|
||||
* #847: Fix "--json" error when Borg includes non-JSON warnings in JSON output.
|
||||
* #848: SECURITY: Mask the password when logging a MongoDB dump or restore command.
|
||||
* 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
|
||||
|
||||
1.8.9
|
||||
* #311: Add custom dump/restore command options for MySQL and MariaDB.
|
||||
* #811: Add an "access_token" option to the ntfy monitoring hook for authenticating
|
||||
without username/password.
|
||||
* #827: When the "--json" flag is given, suppress console escape codes so as not to
|
||||
interfere with JSON output.
|
||||
* #829: Fix "--override" values containing deprecated section headers not actually overriding
|
||||
configuration options under deprecated section headers.
|
||||
* #835: Add support for the NO_COLOR environment variable. See the documentation for more
|
||||
information:
|
||||
https://torsion.org/borgmatic/docs/how-to/set-up-backups/#colored-output
|
||||
* #839: Add log sending for the Apprise logging hook, enabled by default. See the documentation for
|
||||
more information:
|
||||
https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#apprise-hook
|
||||
* #839: Document a potentially breaking shell quoting edge case within error hooks:
|
||||
https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#error-hooks
|
||||
* #840: When running the "rcreate" action and the repository already exists but with a different
|
||||
encryption mode than requested, error.
|
||||
* Switch from Drone to Gitea Actions for continuous integration.
|
||||
* Rename scripts/run-end-to-end-dev-tests to scripts/run-end-to-end-tests and use it in both dev
|
||||
and CI for better dev-CI parity.
|
||||
* Clarify documentation about restoring a database: borgmatic does not create the database upon
|
||||
restore.
|
||||
|
||||
1.8.8
|
||||
* #370: For the PostgreSQL hook, pass the "PGSSLMODE" environment variable through to Borg when the
|
||||
database's configuration omits the "ssl_mode" option.
|
||||
|
||||
@@ -56,19 +56,41 @@ borgmatic is powered by [Borg Backup](https://www.borgbackup.org/).
|
||||
|
||||
## Integrations
|
||||
|
||||
### Data
|
||||
|
||||
<a href="https://www.postgresql.org/"><img src="docs/static/postgresql.png" alt="PostgreSQL" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
|
||||
<a href="https://www.mysql.com/"><img src="docs/static/mysql.png" alt="MySQL" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
|
||||
<a href="https://mariadb.com/"><img src="docs/static/mariadb.png" alt="MariaDB" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
|
||||
<a href="https://www.mongodb.com/"><img src="docs/static/mongodb.png" alt="MongoDB" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
|
||||
<a href="https://sqlite.org/"><img src="docs/static/sqlite.png" alt="SQLite" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
|
||||
<a href="https://openzfs.org/"><img src="docs/static/openzfs.png" alt="OpenZFS" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
|
||||
<a href="https://btrfs.readthedocs.io/"><img src="docs/static/btrfs.png" alt="Btrfs" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
|
||||
<a href="https://sourceware.org/lvm2/"><img src="docs/static/lvm.png" alt="LVM" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
|
||||
<a href="https://rclone.org"><img src="docs/static/rclone.png" alt="rclone" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
|
||||
<a href="https://www.borgbase.com/?utm_source=borgmatic"><img src="docs/static/borgbase.png" alt="BorgBase" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
|
||||
|
||||
|
||||
### Monitoring
|
||||
|
||||
<a href="https://healthchecks.io/"><img src="docs/static/healthchecks.png" alt="Healthchecks" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
|
||||
<a href="https://uptime.kuma.pet/"><img src="docs/static/uptimekuma.png" alt="Uptime Kuma" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
|
||||
<a href="https://cronitor.io/"><img src="docs/static/cronitor.png" alt="Cronitor" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
|
||||
<a href="https://cronhub.io/"><img src="docs/static/cronhub.png" alt="Cronhub" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
|
||||
<a href="https://www.pagerduty.com/"><img src="docs/static/pagerduty.png" alt="PagerDuty" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
|
||||
<a href="https://www.pushover.net/"><img src="docs/static/pushover.png" alt="Pushover" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
|
||||
<a href="https://ntfy.sh/"><img src="docs/static/ntfy.png" alt="ntfy" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
|
||||
<a href="https://grafana.com/oss/loki/"><img src="docs/static/loki.png" alt="Loki" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
|
||||
<a href="https://github.com/caronc/apprise/wiki"><img src="docs/static/apprise.png" alt="Apprise" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
|
||||
<a href="https://www.borgbase.com/?utm_source=borgmatic"><img src="docs/static/borgbase.png" alt="BorgBase" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
|
||||
<a href="https://www.zabbix.com/"><img src="docs/static/zabbix.png" alt="Zabbix" height="40px" style="margin-bottom:20px; margin-right:20px;"></a>
|
||||
<a href="https://sentry.io/"><img src="docs/static/sentry.png" alt="Sentry" height="40px" style="margin-bottom:20px; margin-right:20px;"></a>
|
||||
|
||||
|
||||
### 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
|
||||
@@ -155,5 +177,10 @@ Also, please check out the [borgmatic development
|
||||
how-to](https://torsion.org/borgmatic/docs/how-to/develop-on-borgmatic/) for
|
||||
info on cloning source code, running tests, etc.
|
||||
|
||||
<a href="https://build.torsion.org/borgmatic-collective/borgmatic" alt="build status"></a>
|
||||
### Recent contributors
|
||||
|
||||
Thanks to all borgmatic contributors! There are multiple ways to contribute to
|
||||
this project, so the following includes those who have fixed bugs, contributed
|
||||
features, *or* filed tickets.
|
||||
|
||||
{% include borgmatic/contributors.html %}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import logging
|
||||
|
||||
import borgmatic.borg.borg
|
||||
import borgmatic.borg.rlist
|
||||
import borgmatic.borg.repo_list
|
||||
import borgmatic.config.validate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -22,10 +22,8 @@ def run_borg(
|
||||
if borg_arguments.repository is None or borgmatic.config.validate.repositories_match(
|
||||
repository, borg_arguments.repository
|
||||
):
|
||||
logger.info(
|
||||
f'{repository.get("label", repository["path"])}: Running arbitrary Borg command'
|
||||
)
|
||||
archive_name = borgmatic.borg.rlist.resolve_archive_name(
|
||||
logger.info('Running arbitrary Borg command')
|
||||
archive_name = borgmatic.borg.repo_list.resolve_archive_name(
|
||||
repository['path'],
|
||||
borg_arguments.archive,
|
||||
config,
|
||||
|
||||
@@ -21,9 +21,7 @@ def run_break_lock(
|
||||
if break_lock_arguments.repository is None or borgmatic.config.validate.repositories_match(
|
||||
repository, break_lock_arguments.repository
|
||||
):
|
||||
logger.info(
|
||||
f'{repository.get("label", repository["path"])}: Breaking repository and cache locks'
|
||||
)
|
||||
logger.info('Breaking repository and cache locks')
|
||||
borgmatic.borg.break_lock.break_lock(
|
||||
repository['path'],
|
||||
config,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import logging
|
||||
|
||||
import borgmatic.borg.change_passphrase
|
||||
import borgmatic.config.validate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def run_change_passphrase(
|
||||
repository,
|
||||
config,
|
||||
local_borg_version,
|
||||
change_passphrase_arguments,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
):
|
||||
'''
|
||||
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
|
||||
)
|
||||
):
|
||||
logger.info('Changing repository passphrase')
|
||||
borgmatic.borg.change_passphrase.change_passphrase(
|
||||
repository['path'],
|
||||
config,
|
||||
local_borg_version,
|
||||
change_passphrase_arguments,
|
||||
global_arguments,
|
||||
local_path=local_path,
|
||||
remote_path=remote_path,
|
||||
)
|
||||
+732
-3
@@ -1,12 +1,683 @@
|
||||
import calendar
|
||||
import datetime
|
||||
import hashlib
|
||||
import itertools
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
import random
|
||||
import shutil
|
||||
|
||||
import borgmatic.actions.create
|
||||
import borgmatic.borg.check
|
||||
import borgmatic.borg.create
|
||||
import borgmatic.borg.environment
|
||||
import borgmatic.borg.extract
|
||||
import borgmatic.borg.list
|
||||
import borgmatic.borg.repo_list
|
||||
import borgmatic.borg.state
|
||||
import borgmatic.config.paths
|
||||
import borgmatic.config.validate
|
||||
import borgmatic.execute
|
||||
import borgmatic.hooks.command
|
||||
|
||||
DEFAULT_CHECKS = (
|
||||
{'name': 'repository', 'frequency': '1 month'},
|
||||
{'name': 'archives', 'frequency': '1 month'},
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def parse_checks(config, only_checks=None):
|
||||
'''
|
||||
Given a configuration dict with a "checks" sequence of dicts and an optional list of override
|
||||
checks, return a tuple of named checks to run.
|
||||
|
||||
For example, given a config of:
|
||||
|
||||
{'checks': ({'name': 'repository'}, {'name': 'archives'})}
|
||||
|
||||
This will be returned as:
|
||||
|
||||
('repository', 'archives')
|
||||
|
||||
If no "checks" option is present in the config, return the DEFAULT_CHECKS. If a checks value
|
||||
has a name of "disabled", return an empty tuple, meaning that no checks should be run.
|
||||
'''
|
||||
checks = only_checks or tuple(
|
||||
check_config['name'] for check_config in (config.get('checks', None) or DEFAULT_CHECKS)
|
||||
)
|
||||
checks = tuple(check.lower() for check in checks)
|
||||
|
||||
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'
|
||||
)
|
||||
if len(checks) > 1:
|
||||
logger.warning(
|
||||
'Multiple checks are configured, but one of them is "disabled"; not running any checks'
|
||||
)
|
||||
return ()
|
||||
|
||||
return checks
|
||||
|
||||
|
||||
def parse_frequency(frequency):
|
||||
'''
|
||||
Given a frequency string with a number and a unit of time, return a corresponding
|
||||
datetime.timedelta instance or None if the frequency is None or "always".
|
||||
|
||||
For instance, given "3 weeks", return datetime.timedelta(weeks=3)
|
||||
|
||||
Raise ValueError if the given frequency cannot be parsed.
|
||||
'''
|
||||
if not frequency:
|
||||
return None
|
||||
|
||||
frequency = frequency.strip().lower()
|
||||
|
||||
if frequency == 'always':
|
||||
return None
|
||||
|
||||
try:
|
||||
number, time_unit = frequency.split(' ')
|
||||
number = int(number)
|
||||
except ValueError:
|
||||
raise ValueError(f"Could not parse consistency check frequency '{frequency}'")
|
||||
|
||||
if not time_unit.endswith('s'):
|
||||
time_unit += 's'
|
||||
|
||||
if time_unit == 'months':
|
||||
number *= 30
|
||||
time_unit = 'days'
|
||||
elif time_unit == 'years':
|
||||
number *= 365
|
||||
time_unit = 'days'
|
||||
|
||||
try:
|
||||
return datetime.timedelta(**{time_unit: number})
|
||||
except TypeError:
|
||||
raise ValueError(f"Could not parse consistency check frequency '{frequency}'")
|
||||
|
||||
|
||||
WEEKDAY_DAYS = calendar.day_name[0:5]
|
||||
WEEKEND_DAYS = calendar.day_name[5:7]
|
||||
|
||||
|
||||
def filter_checks_on_frequency(
|
||||
config,
|
||||
borg_repository_id,
|
||||
checks,
|
||||
force,
|
||||
archives_check_id=None,
|
||||
datetime_now=datetime.datetime.now,
|
||||
):
|
||||
'''
|
||||
Given a configuration dict with a "checks" sequence of dicts, a Borg repository ID, a sequence
|
||||
of checks, whether to force checks to run, and an ID for the archives check potentially being
|
||||
run (if any), filter down those checks based on the configured "frequency" for each check as
|
||||
compared to its check time file.
|
||||
|
||||
In other words, a check whose check time file's timestamp is too new (based on the configured
|
||||
frequency) will get cut from the returned sequence of checks. Example:
|
||||
|
||||
config = {
|
||||
'checks': [
|
||||
{
|
||||
'name': 'archives',
|
||||
'frequency': '2 weeks',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
When this function is called with that config and "archives" in checks, "archives" will get
|
||||
filtered out of the returned result if its check time file is newer than 2 weeks old, indicating
|
||||
that it's not yet time to run that check again.
|
||||
|
||||
Raise ValueError if a frequency cannot be parsed.
|
||||
'''
|
||||
if not checks:
|
||||
return checks
|
||||
|
||||
filtered_checks = list(checks)
|
||||
|
||||
if force:
|
||||
return tuple(filtered_checks)
|
||||
|
||||
for check_config in config.get('checks', DEFAULT_CHECKS):
|
||||
check = check_config['name']
|
||||
if checks and check not in checks:
|
||||
continue
|
||||
|
||||
only_run_on = check_config.get('only_run_on')
|
||||
if only_run_on:
|
||||
# Use a dict instead of a set to preserve ordering.
|
||||
days = dict.fromkeys(only_run_on)
|
||||
|
||||
if 'weekday' in days:
|
||||
days = {
|
||||
**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'),
|
||||
**dict.fromkeys(WEEKEND_DAYS),
|
||||
}
|
||||
|
||||
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)"
|
||||
)
|
||||
filtered_checks.remove(check)
|
||||
continue
|
||||
|
||||
frequency_delta = parse_frequency(check_config.get('frequency'))
|
||||
if not frequency_delta:
|
||||
continue
|
||||
|
||||
check_time = probe_for_check_time(config, borg_repository_id, check, archives_check_id)
|
||||
if not check_time:
|
||||
continue
|
||||
|
||||
# If we've not yet reached the time when the frequency dictates we're ready for another
|
||||
# check, skip this check.
|
||||
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)'
|
||||
)
|
||||
filtered_checks.remove(check)
|
||||
|
||||
return tuple(filtered_checks)
|
||||
|
||||
|
||||
def make_archives_check_id(archive_filter_flags):
|
||||
'''
|
||||
Given a sequence of flags to filter archives, return a unique hash corresponding to those
|
||||
particular flags. If there are no flags, return None.
|
||||
'''
|
||||
if not archive_filter_flags:
|
||||
return None
|
||||
|
||||
return hashlib.sha256(' '.join(archive_filter_flags).encode()).hexdigest()
|
||||
|
||||
|
||||
def make_check_time_path(config, borg_repository_id, check_type, archives_check_id=None):
|
||||
'''
|
||||
Given a configuration dict, a Borg repository ID, the name of a check type ("repository",
|
||||
"archives", etc.), and a unique hash of the archives filter flags, return a path for recording
|
||||
that check's time (the time of that check last occurring).
|
||||
'''
|
||||
borgmatic_state_directory = borgmatic.config.paths.get_borgmatic_state_directory(config)
|
||||
|
||||
if check_type in ('archives', 'data'):
|
||||
return os.path.join(
|
||||
borgmatic_state_directory,
|
||||
'checks',
|
||||
borg_repository_id,
|
||||
check_type,
|
||||
archives_check_id if archives_check_id else 'all',
|
||||
)
|
||||
|
||||
return os.path.join(
|
||||
borgmatic_state_directory,
|
||||
'checks',
|
||||
borg_repository_id,
|
||||
check_type,
|
||||
)
|
||||
|
||||
|
||||
def write_check_time(path): # pragma: no cover
|
||||
'''
|
||||
Record a check time of now as the modification time of the given path.
|
||||
'''
|
||||
logger.debug(f'Writing check time at {path}')
|
||||
|
||||
os.makedirs(os.path.dirname(path), mode=0o700, exist_ok=True)
|
||||
pathlib.Path(path, mode=0o600).touch()
|
||||
|
||||
|
||||
def read_check_time(path):
|
||||
'''
|
||||
Return the check time based on the modification time of the given path. Return None if the path
|
||||
doesn't exist.
|
||||
'''
|
||||
logger.debug(f'Reading check time from {path}')
|
||||
|
||||
try:
|
||||
return datetime.datetime.fromtimestamp(os.stat(path).st_mtime)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
|
||||
|
||||
def probe_for_check_time(config, borg_repository_id, check, archives_check_id):
|
||||
'''
|
||||
Given a configuration dict, a Borg repository ID, the name of a check type ("repository",
|
||||
"archives", etc.), and a unique hash of the archives filter flags, return the corresponding
|
||||
check time or None if such a check time does not exist.
|
||||
|
||||
When the check type is "archives" or "data", this function probes two different paths to find
|
||||
the check time, e.g.:
|
||||
|
||||
~/.borgmatic/checks/1234567890/archives/9876543210
|
||||
~/.borgmatic/checks/1234567890/archives/all
|
||||
|
||||
... and returns the maximum modification time of the files found (if any). The first path
|
||||
represents a more specific archives check time (a check on a subset of archives), and the second
|
||||
is a fallback to the last "all" archives check.
|
||||
|
||||
For other check types, this function reads from a single check time path, e.g.:
|
||||
|
||||
~/.borgmatic/checks/1234567890/repository
|
||||
'''
|
||||
check_times = (
|
||||
read_check_time(group[0])
|
||||
for group in itertools.groupby(
|
||||
(
|
||||
make_check_time_path(config, borg_repository_id, check, archives_check_id),
|
||||
make_check_time_path(config, borg_repository_id, check),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
return max(check_time for check_time in check_times if check_time)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def upgrade_check_times(config, borg_repository_id):
|
||||
'''
|
||||
Given a configuration dict and a Borg repository ID, upgrade any corresponding check times on
|
||||
disk from old-style paths to new-style paths.
|
||||
|
||||
One upgrade performed is moving the checks directory from:
|
||||
|
||||
{borgmatic_source_directory}/checks (e.g., ~/.borgmatic/checks)
|
||||
|
||||
to:
|
||||
|
||||
{borgmatic_state_directory}/checks (e.g. ~/.local/state/borgmatic)
|
||||
|
||||
Another upgrade is renaming an archive or data check path that looks like:
|
||||
|
||||
{borgmatic_state_directory}/checks/1234567890/archives
|
||||
|
||||
to:
|
||||
|
||||
{borgmatic_state_directory}/checks/1234567890/archives/all
|
||||
'''
|
||||
borgmatic_source_checks_path = os.path.join(
|
||||
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
|
||||
):
|
||||
logger.debug(
|
||||
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)
|
||||
|
||||
for check_type in ('archives', 'data'):
|
||||
new_path = make_check_time_path(config, borg_repository_id, check_type, 'all')
|
||||
old_path = os.path.dirname(new_path)
|
||||
temporary_path = f'{old_path}.temp'
|
||||
|
||||
if not os.path.isfile(old_path) and not os.path.isfile(temporary_path):
|
||||
continue
|
||||
|
||||
logger.debug(f'Upgrading archives check time file from {old_path} to {new_path}')
|
||||
|
||||
try:
|
||||
shutil.move(old_path, temporary_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
os.mkdir(old_path)
|
||||
shutil.move(temporary_path, new_path)
|
||||
|
||||
|
||||
def collect_spot_check_source_paths(
|
||||
repository,
|
||||
config,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
borgmatic_runtime_directory,
|
||||
):
|
||||
'''
|
||||
Given a repository configuration dict, a configuration dict, the local Borg version, global
|
||||
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).
|
||||
'''
|
||||
stream_processes = any(
|
||||
borgmatic.hooks.dispatch.call_hooks(
|
||||
'use_streaming',
|
||||
config,
|
||||
borgmatic.hooks.dispatch.Hook_type.DATA_SOURCE,
|
||||
).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,
|
||||
),
|
||||
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,
|
||||
)
|
||||
)
|
||||
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,
|
||||
environment=borgmatic.borg.environment.make_environment(config),
|
||||
working_directory=working_directory,
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=config.get('borg_exit_codes'),
|
||||
)
|
||||
|
||||
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('+ ')
|
||||
)
|
||||
|
||||
return tuple(
|
||||
path for path in paths if os.path.isfile(os.path.join(working_directory or '', path))
|
||||
)
|
||||
|
||||
|
||||
BORG_DIRECTORY_FILE_TYPE = 'd'
|
||||
BORG_PIPE_FILE_TYPE = 'p'
|
||||
|
||||
|
||||
def collect_spot_check_archive_paths(
|
||||
repository,
|
||||
archive,
|
||||
config,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
borgmatic_runtime_directory,
|
||||
):
|
||||
'''
|
||||
Given a repository configuration dict, the name of the latest archive, a configuration dict, the
|
||||
local Borg version, global arguments as an argparse.Namespace instance, the local Borg path, the
|
||||
remote Borg path, and the borgmatic runtime directory, collect the paths from the given archive
|
||||
(but only include files and symlinks and exclude borgmatic runtime directories).
|
||||
|
||||
These paths do not have a leading slash, as that's how Borg stores them. As a result, we don't
|
||||
know whether they came from absolute or relative source directories.
|
||||
'''
|
||||
borgmatic_source_directory = borgmatic.config.paths.get_borgmatic_source_directory(config)
|
||||
|
||||
return tuple(
|
||||
path
|
||||
for line in borgmatic.borg.list.capture_archive_listing(
|
||||
repository['path'],
|
||||
archive,
|
||||
config,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
path_format='{type} {path}{NUL}', # noqa: FS003
|
||||
local_path=local_path,
|
||||
remote_path=remote_path,
|
||||
)
|
||||
for (file_type, path) in (line.split(' ', 1),)
|
||||
if file_type not in (BORG_DIRECTORY_FILE_TYPE, BORG_PIPE_FILE_TYPE)
|
||||
if pathlib.Path('borgmatic') not in pathlib.Path(path).parents
|
||||
if pathlib.Path(borgmatic_source_directory.lstrip(os.path.sep))
|
||||
not in pathlib.Path(path).parents
|
||||
if pathlib.Path(borgmatic_runtime_directory.lstrip(os.path.sep))
|
||||
not in pathlib.Path(path).parents
|
||||
)
|
||||
|
||||
|
||||
SAMPLE_PATHS_SUBSET_COUNT = 10000
|
||||
|
||||
|
||||
def compare_spot_check_hashes(
|
||||
repository,
|
||||
archive,
|
||||
config,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
source_paths,
|
||||
):
|
||||
'''
|
||||
Given a repository configuration dict, the name of the latest archive, a configuration dict, the
|
||||
local Borg version, global arguments as an argparse.Namespace instance, the local Borg path, the
|
||||
remote Borg path, and spot check source paths, compare the hashes for a sampling of the source
|
||||
paths with hashes from corresponding paths in the given archive. Return a sequence of the paths
|
||||
that fail that hash comparison.
|
||||
'''
|
||||
# Based on the configured sample percentage, come up with a list of random sample files from the
|
||||
# source directories.
|
||||
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
|
||||
)
|
||||
source_sample_paths = tuple(random.sample(source_paths, sample_count))
|
||||
working_directory = borgmatic.config.paths.get_working_directory(config)
|
||||
existing_source_sample_paths = {
|
||||
source_path
|
||||
for source_path in source_sample_paths
|
||||
if os.path.exists(os.path.join(working_directory or '', source_path))
|
||||
}
|
||||
logger.debug(
|
||||
f'Sampling {sample_count} source paths (~{spot_check_config["data_sample_percentage"]}%) for spot check'
|
||||
)
|
||||
|
||||
source_sample_paths_iterator = iter(source_sample_paths)
|
||||
source_hashes = {}
|
||||
archive_hashes = {}
|
||||
|
||||
# Only hash a few thousand files at a time (a subset of the total paths) to avoid an "Argument
|
||||
# list too long" shell error.
|
||||
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)
|
||||
)
|
||||
if not source_sample_paths_subset:
|
||||
break
|
||||
|
||||
hash_output = borgmatic.execute.execute_command_and_capture_output(
|
||||
(spot_check_config.get('xxh64sum_command', 'xxh64sum'),)
|
||||
+ tuple(
|
||||
path for path in source_sample_paths_subset if path in existing_source_sample_paths
|
||||
),
|
||||
working_directory=working_directory,
|
||||
)
|
||||
|
||||
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.
|
||||
**{
|
||||
path: ''
|
||||
for path in source_sample_paths_subset
|
||||
if path not in existing_source_sample_paths
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
# Get the hash for each file in the archive.
|
||||
archive_hashes.update(
|
||||
**dict(
|
||||
reversed(line.split(' ', 1))
|
||||
for line in borgmatic.borg.list.capture_archive_listing(
|
||||
repository['path'],
|
||||
archive,
|
||||
config,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
list_paths=source_sample_paths_subset,
|
||||
path_format='{xxh64} {path}{NUL}', # noqa: FS003
|
||||
local_path=local_path,
|
||||
remote_path=remote_path,
|
||||
)
|
||||
if line
|
||||
)
|
||||
)
|
||||
|
||||
# Compare the source hashes with the archive hashes to see how many match.
|
||||
failing_paths = []
|
||||
|
||||
for path, source_hash in source_hashes.items():
|
||||
archive_hash = archive_hashes.get(path.lstrip(os.path.sep))
|
||||
|
||||
if archive_hash is not None and archive_hash == source_hash:
|
||||
continue
|
||||
|
||||
failing_paths.append(path)
|
||||
|
||||
return tuple(failing_paths)
|
||||
|
||||
|
||||
def spot_check(
|
||||
repository,
|
||||
config,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
borgmatic_runtime_directory,
|
||||
):
|
||||
'''
|
||||
Given a repository dict, a loaded configuration dict, the local Borg version, global arguments
|
||||
as an argparse.Namespace instance, the local Borg path, the remote Borg path, and the borgmatic
|
||||
runtime directory, perform a spot check for the latest archive in the given repository.
|
||||
|
||||
A spot check compares file counts and also the hashes for a random sampling of source files on
|
||||
disk to those stored in the latest archive. If any differences are beyond configured tolerances,
|
||||
then the check fails.
|
||||
'''
|
||||
logger.debug('Running spot check')
|
||||
|
||||
try:
|
||||
spot_check_config = next(
|
||||
check for check in config.get('checks', ()) if check.get('name') == 'spot'
|
||||
)
|
||||
except StopIteration:
|
||||
raise ValueError('Cannot run spot check because it is unconfigured')
|
||||
|
||||
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'
|
||||
)
|
||||
|
||||
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',
|
||||
config,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
)
|
||||
logger.debug(f'Using archive {archive} for spot check')
|
||||
|
||||
archive_paths = collect_spot_check_archive_paths(
|
||||
repository,
|
||||
archive,
|
||||
config,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
borgmatic_runtime_directory,
|
||||
)
|
||||
logger.debug(f'{len(archive_paths)} total archive paths for spot check')
|
||||
|
||||
if len(source_paths) == 0:
|
||||
logger.debug(
|
||||
f'Paths in latest archive but not source paths: {", ".join(set(archive_paths)) or "none"}'
|
||||
)
|
||||
raise ValueError(
|
||||
'Spot check failed: There are no source paths to compare against the archive'
|
||||
)
|
||||
|
||||
# Calculate the percentage delta between the source paths count and the archive paths count, and
|
||||
# compare that delta to the configured count tolerance percentage.
|
||||
count_delta_percentage = abs(len(source_paths) - len(archive_paths)) / len(source_paths) * 100
|
||||
|
||||
if count_delta_percentage > spot_check_config['count_tolerance_percentage']:
|
||||
rootless_source_paths = set(path.lstrip(os.path.sep) for path in source_paths)
|
||||
logger.debug(
|
||||
f'Paths in source paths but not latest archive: {", ".join(rootless_source_paths - set(archive_paths)) or "none"}'
|
||||
)
|
||||
logger.debug(
|
||||
f'Paths in latest archive but not source paths: {", ".join(set(archive_paths) - rootless_source_paths) or "none"}'
|
||||
)
|
||||
raise ValueError(
|
||||
f'Spot check failed: {count_delta_percentage:.2f}% file count delta between source paths and latest archive (tolerance is {spot_check_config["count_tolerance_percentage"]}%)'
|
||||
)
|
||||
|
||||
failing_paths = compare_spot_check_hashes(
|
||||
repository,
|
||||
archive,
|
||||
config,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
source_paths,
|
||||
)
|
||||
|
||||
# Error if the percentage of failing hashes exceeds the configured tolerance percentage.
|
||||
logger.debug(f'{len(failing_paths)} non-matching spot check hashes')
|
||||
data_tolerance_percentage = spot_check_config['data_tolerance_percentage']
|
||||
failing_percentage = (len(failing_paths) / len(source_paths)) * 100
|
||||
|
||||
if failing_percentage > data_tolerance_percentage:
|
||||
logger.debug(
|
||||
f'Source paths with data not matching the latest archive: {", ".join(failing_paths)}'
|
||||
)
|
||||
raise ValueError(
|
||||
f'Spot check failed: {failing_percentage:.2f}% of source paths with data not matching the latest archive (tolerance is {data_tolerance_percentage}%)'
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f'Spot check passed with a {count_delta_percentage:.2f}% file count delta and a {failing_percentage:.2f}% file data delta'
|
||||
)
|
||||
|
||||
|
||||
def run_check(
|
||||
config_filename,
|
||||
repository,
|
||||
@@ -20,6 +691,8 @@ def run_check(
|
||||
):
|
||||
'''
|
||||
Run the "check" action for the given repository.
|
||||
|
||||
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
|
||||
@@ -34,16 +707,72 @@ def run_check(
|
||||
global_arguments.dry_run,
|
||||
**hook_context,
|
||||
)
|
||||
logger.info(f'{repository.get("label", repository["path"])}: Running consistency checks')
|
||||
borgmatic.borg.check.check_archives(
|
||||
|
||||
logger.info('Running consistency checks')
|
||||
|
||||
repository_id = borgmatic.borg.check.get_repository_id(
|
||||
repository['path'],
|
||||
config,
|
||||
local_borg_version,
|
||||
check_arguments,
|
||||
global_arguments,
|
||||
local_path=local_path,
|
||||
remote_path=remote_path,
|
||||
)
|
||||
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
|
||||
)
|
||||
archives_check_id = make_archives_check_id(archive_filter_flags)
|
||||
checks = filter_checks_on_frequency(
|
||||
config,
|
||||
repository_id,
|
||||
configured_checks,
|
||||
check_arguments.force,
|
||||
archives_check_id,
|
||||
)
|
||||
borg_specific_checks = set(checks).intersection({'repository', 'archives', 'data'})
|
||||
|
||||
if borg_specific_checks:
|
||||
borgmatic.borg.check.check_archives(
|
||||
repository['path'],
|
||||
config,
|
||||
local_borg_version,
|
||||
check_arguments,
|
||||
global_arguments,
|
||||
borg_specific_checks,
|
||||
archive_filter_flags,
|
||||
local_path=local_path,
|
||||
remote_path=remote_path,
|
||||
)
|
||||
for check in borg_specific_checks:
|
||||
write_check_time(make_check_time_path(config, repository_id, check, archives_check_id))
|
||||
|
||||
if 'extract' in checks:
|
||||
borgmatic.borg.extract.extract_last_archive_dry_run(
|
||||
config,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
repository['path'],
|
||||
config.get('lock_wait'),
|
||||
local_path,
|
||||
remote_path,
|
||||
)
|
||||
write_check_time(make_check_time_path(config, repository_id, 'extract'))
|
||||
|
||||
if 'spot' in checks:
|
||||
with borgmatic.config.paths.Runtime_directory(config) as borgmatic_runtime_directory:
|
||||
spot_check(
|
||||
repository,
|
||||
config,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
borgmatic_runtime_directory,
|
||||
)
|
||||
write_check_time(make_check_time_path(config, repository_id, 'spot'))
|
||||
|
||||
borgmatic.hooks.command.execute_hook(
|
||||
config.get('after_check'),
|
||||
config.get('umask'),
|
||||
|
||||
@@ -37,9 +37,7 @@ def run_compact(
|
||||
**hook_context,
|
||||
)
|
||||
if borgmatic.borg.feature.available(borgmatic.borg.feature.Feature.COMPACT, local_borg_version):
|
||||
logger.info(
|
||||
f'{repository.get("label", repository["path"])}: Compacting segments{dry_run_label}'
|
||||
)
|
||||
logger.info(f'Compacting segments{dry_run_label}')
|
||||
borgmatic.borg.compact.compact_segments(
|
||||
global_arguments.dry_run,
|
||||
repository['path'],
|
||||
@@ -53,9 +51,8 @@ def run_compact(
|
||||
threshold=compact_arguments.threshold,
|
||||
)
|
||||
else: # pragma: nocover
|
||||
logger.info(
|
||||
f'{repository.get("label", repository["path"])}: Skipping compact (only available/needed in Borg 1.2+)'
|
||||
)
|
||||
logger.info('Skipping compact (only available/needed in Borg 1.2+)')
|
||||
|
||||
borgmatic.hooks.command.execute_hook(
|
||||
config.get('after_compact'),
|
||||
config.get('umask'),
|
||||
|
||||
@@ -3,55 +3,78 @@ import logging
|
||||
import os
|
||||
|
||||
import borgmatic.borg.extract
|
||||
import borgmatic.borg.rlist
|
||||
import borgmatic.borg.repo_list
|
||||
import borgmatic.config.paths
|
||||
import borgmatic.config.validate
|
||||
import borgmatic.hooks.command
|
||||
from borgmatic.borg.state import DEFAULT_BORGMATIC_SOURCE_DIRECTORY
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_config_paths(bootstrap_arguments, global_arguments, local_borg_version):
|
||||
def make_bootstrap_config(bootstrap_arguments):
|
||||
'''
|
||||
Given the bootstrap arguments as an argparse.Namespace (containing the repository and archive
|
||||
name, 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
|
||||
after extracting it from the repository.
|
||||
Given the bootstrap arguments as an argparse.Namespace, return a corresponding config dict.
|
||||
'''
|
||||
return {
|
||||
'ssh_command': bootstrap_arguments.ssh_command,
|
||||
# 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,
|
||||
}
|
||||
|
||||
|
||||
def get_config_paths(archive_name, bootstrap_arguments, global_arguments, local_borg_version):
|
||||
'''
|
||||
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.
|
||||
|
||||
Raise ValueError if the manifest JSON is missing, can't be decoded, or doesn't contain the
|
||||
expected configuration path data.
|
||||
'''
|
||||
borgmatic_source_directory = (
|
||||
bootstrap_arguments.borgmatic_source_directory or DEFAULT_BORGMATIC_SOURCE_DIRECTORY
|
||||
borgmatic_source_directory = borgmatic.config.paths.get_borgmatic_source_directory(
|
||||
{'borgmatic_source_directory': bootstrap_arguments.borgmatic_source_directory}
|
||||
)
|
||||
borgmatic_manifest_path = os.path.expanduser(
|
||||
os.path.join(borgmatic_source_directory, 'bootstrap', 'manifest.json')
|
||||
)
|
||||
config = {'ssh_command': bootstrap_arguments.ssh_command}
|
||||
config = make_bootstrap_config(bootstrap_arguments)
|
||||
|
||||
extract_process = borgmatic.borg.extract.extract_archive(
|
||||
global_arguments.dry_run,
|
||||
bootstrap_arguments.repository,
|
||||
borgmatic.borg.rlist.resolve_archive_name(
|
||||
bootstrap_arguments.repository,
|
||||
bootstrap_arguments.archive,
|
||||
config,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
),
|
||||
[borgmatic_manifest_path],
|
||||
config,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
extract_to_stdout=True,
|
||||
)
|
||||
manifest_json = extract_process.stdout.read()
|
||||
# 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'
|
||||
)
|
||||
|
||||
if not manifest_json:
|
||||
raise ValueError(
|
||||
'Cannot read configuration paths from archive due to missing bootstrap manifest'
|
||||
)
|
||||
extract_process = borgmatic.borg.extract.extract_archive(
|
||||
global_arguments.dry_run,
|
||||
bootstrap_arguments.repository,
|
||||
archive_name,
|
||||
[borgmatic_manifest_path],
|
||||
config,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
local_path=bootstrap_arguments.local_path,
|
||||
remote_path=bootstrap_arguments.remote_path,
|
||||
extract_to_stdout=True,
|
||||
)
|
||||
manifest_json = extract_process.stdout.read()
|
||||
|
||||
if manifest_json:
|
||||
break
|
||||
else:
|
||||
raise ValueError(
|
||||
'Cannot read configuration paths from archive due to missing bootstrap manifest'
|
||||
)
|
||||
|
||||
try:
|
||||
manifest_data = json.loads(manifest_json)
|
||||
@@ -75,27 +98,32 @@ def run_bootstrap(bootstrap_arguments, global_arguments, local_borg_version):
|
||||
Raise ValueError if the bootstrap configuration could not be loaded.
|
||||
Raise CalledProcessError or OSError if Borg could not be run.
|
||||
'''
|
||||
config = make_bootstrap_config(bootstrap_arguments)
|
||||
archive_name = borgmatic.borg.repo_list.resolve_archive_name(
|
||||
bootstrap_arguments.repository,
|
||||
bootstrap_arguments.archive,
|
||||
config,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
local_path=bootstrap_arguments.local_path,
|
||||
remote_path=bootstrap_arguments.remote_path,
|
||||
)
|
||||
manifest_config_paths = get_config_paths(
|
||||
bootstrap_arguments, global_arguments, local_borg_version
|
||||
archive_name, bootstrap_arguments, global_arguments, local_borg_version
|
||||
)
|
||||
config = {'ssh_command': bootstrap_arguments.ssh_command}
|
||||
|
||||
logger.info(f"Bootstrapping config paths: {', '.join(manifest_config_paths)}")
|
||||
|
||||
borgmatic.borg.extract.extract_archive(
|
||||
global_arguments.dry_run,
|
||||
bootstrap_arguments.repository,
|
||||
borgmatic.borg.rlist.resolve_archive_name(
|
||||
bootstrap_arguments.repository,
|
||||
bootstrap_arguments.archive,
|
||||
config,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
),
|
||||
archive_name,
|
||||
[config_path.lstrip(os.path.sep) for config_path in manifest_config_paths],
|
||||
config,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
local_path=bootstrap_arguments.local_path,
|
||||
remote_path=bootstrap_arguments.remote_path,
|
||||
extract_to_stdout=False,
|
||||
destination_path=bootstrap_arguments.destination,
|
||||
strip_components=bootstrap_arguments.strip_components,
|
||||
|
||||
+287
-65
@@ -1,46 +1,259 @@
|
||||
import importlib.metadata
|
||||
import json
|
||||
import glob
|
||||
import itertools
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
|
||||
import borgmatic.actions.json
|
||||
import borgmatic.borg.create
|
||||
import borgmatic.borg.state
|
||||
import borgmatic.borg.pattern
|
||||
import borgmatic.config.paths
|
||||
import borgmatic.config.validate
|
||||
import borgmatic.hooks.command
|
||||
import borgmatic.hooks.dispatch
|
||||
import borgmatic.hooks.dump
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_borgmatic_manifest(config, config_paths, dry_run):
|
||||
def parse_pattern(pattern_line, default_style=borgmatic.borg.pattern.Pattern_style.NONE):
|
||||
'''
|
||||
Create a borgmatic manifest file to store the paths to the configuration files used to create
|
||||
the archive.
|
||||
Given a Borg pattern as a string, parse it into a borgmatic.borg.pattern.Pattern instance and
|
||||
return it.
|
||||
'''
|
||||
if dry_run:
|
||||
return
|
||||
try:
|
||||
(pattern_type, remainder) = pattern_line.split(' ', maxsplit=1)
|
||||
except ValueError:
|
||||
raise ValueError(f'Invalid pattern: {pattern_line}')
|
||||
|
||||
borgmatic_source_directory = config.get(
|
||||
'borgmatic_source_directory', borgmatic.borg.state.DEFAULT_BORGMATIC_SOURCE_DIRECTORY
|
||||
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,
|
||||
)
|
||||
|
||||
borgmatic_manifest_path = os.path.expanduser(
|
||||
os.path.join(borgmatic_source_directory, 'bootstrap', 'manifest.json')
|
||||
)
|
||||
|
||||
if not os.path.exists(borgmatic_manifest_path):
|
||||
os.makedirs(os.path.dirname(borgmatic_manifest_path), exist_ok=True)
|
||||
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.
|
||||
|
||||
with open(borgmatic_manifest_path, 'w') as config_list_file:
|
||||
json.dump(
|
||||
{
|
||||
'borgmatic_version': importlib.metadata.version('borgmatic'),
|
||||
'config_paths': config_paths,
|
||||
},
|
||||
config_list_file,
|
||||
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).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,
|
||||
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 (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
|
||||
),
|
||||
source=pattern.source,
|
||||
)
|
||||
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(
|
||||
@@ -74,54 +287,63 @@ def run_create(
|
||||
global_arguments.dry_run,
|
||||
**hook_context,
|
||||
)
|
||||
logger.info(f'{repository.get("label", repository["path"])}: Creating archive{dry_run_label}')
|
||||
borgmatic.hooks.dispatch.call_hooks_even_if_unconfigured(
|
||||
'remove_data_source_dumps',
|
||||
config,
|
||||
repository['path'],
|
||||
borgmatic.hooks.dump.DATA_SOURCE_HOOK_NAMES,
|
||||
global_arguments.dry_run,
|
||||
)
|
||||
active_dumps = borgmatic.hooks.dispatch.call_hooks(
|
||||
'dump_data_sources',
|
||||
config,
|
||||
repository['path'],
|
||||
borgmatic.hooks.dump.DATA_SOURCE_HOOK_NAMES,
|
||||
global_arguments.dry_run,
|
||||
)
|
||||
if config.get('store_config_files', True):
|
||||
create_borgmatic_manifest(
|
||||
|
||||
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:
|
||||
borgmatic.hooks.dispatch.call_hooks_even_if_unconfigured(
|
||||
'remove_data_source_dumps',
|
||||
config,
|
||||
borgmatic.hooks.dispatch.Hook_type.DATA_SOURCE,
|
||||
borgmatic_runtime_directory,
|
||||
global_arguments.dry_run,
|
||||
)
|
||||
patterns = process_patterns(collect_patterns(config), working_directory)
|
||||
active_dumps = borgmatic.hooks.dispatch.call_hooks(
|
||||
'dump_data_sources',
|
||||
config,
|
||||
borgmatic.hooks.dispatch.Hook_type.DATA_SOURCE,
|
||||
config_paths,
|
||||
borgmatic_runtime_directory,
|
||||
patterns,
|
||||
global_arguments.dry_run,
|
||||
)
|
||||
stream_processes = [process for processes in active_dumps.values() for process in processes]
|
||||
|
||||
json_output = borgmatic.borg.create.create_archive(
|
||||
global_arguments.dry_run,
|
||||
repository['path'],
|
||||
config,
|
||||
config_paths,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
local_path=local_path,
|
||||
remote_path=remote_path,
|
||||
progress=create_arguments.progress,
|
||||
stats=create_arguments.stats,
|
||||
json=create_arguments.json,
|
||||
list_files=create_arguments.list_files,
|
||||
stream_processes=stream_processes,
|
||||
)
|
||||
if json_output:
|
||||
yield borgmatic.actions.json.parse_json(json_output, repository.get('label'))
|
||||
# Process the patterns again in case any data source hooks updated them. Without this step,
|
||||
# we could end up with duplicate paths that cause Borg to hang when it tries to read from
|
||||
# the same named pipe twice.
|
||||
patterns = process_patterns(patterns, working_directory, skip_expand_paths=config_paths)
|
||||
stream_processes = [process for processes in active_dumps.values() for process in processes]
|
||||
|
||||
json_output = borgmatic.borg.create.create_archive(
|
||||
global_arguments.dry_run,
|
||||
repository['path'],
|
||||
config,
|
||||
patterns,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
borgmatic_runtime_directory,
|
||||
local_path=local_path,
|
||||
remote_path=remote_path,
|
||||
progress=create_arguments.progress,
|
||||
stats=create_arguments.stats,
|
||||
json=create_arguments.json,
|
||||
list_files=create_arguments.list_files,
|
||||
stream_processes=stream_processes,
|
||||
)
|
||||
|
||||
if json_output:
|
||||
yield borgmatic.actions.json.parse_json(json_output, repository.get('label'))
|
||||
|
||||
borgmatic.hooks.dispatch.call_hooks_even_if_unconfigured(
|
||||
'remove_data_source_dumps',
|
||||
config,
|
||||
borgmatic.hooks.dispatch.Hook_type.DATA_SOURCE,
|
||||
borgmatic_runtime_directory,
|
||||
global_arguments.dry_run,
|
||||
)
|
||||
|
||||
borgmatic.hooks.dispatch.call_hooks_even_if_unconfigured(
|
||||
'remove_data_source_dumps',
|
||||
config,
|
||||
config_filename,
|
||||
borgmatic.hooks.dump.DATA_SOURCE_HOOK_NAMES,
|
||||
global_arguments.dry_run,
|
||||
)
|
||||
borgmatic.hooks.command.execute_hook(
|
||||
config.get('after_backup'),
|
||||
config.get('umask'),
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import logging
|
||||
|
||||
import borgmatic.actions.arguments
|
||||
import borgmatic.borg.delete
|
||||
import borgmatic.borg.repo_delete
|
||||
import borgmatic.borg.repo_list
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def run_delete(
|
||||
repository,
|
||||
config,
|
||||
local_borg_version,
|
||||
delete_arguments,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
):
|
||||
'''
|
||||
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
|
||||
):
|
||||
logger.answer('Deleting archives')
|
||||
|
||||
archive_name = (
|
||||
borgmatic.borg.repo_list.resolve_archive_name(
|
||||
repository['path'],
|
||||
delete_arguments.archive,
|
||||
config,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
)
|
||||
if delete_arguments.archive
|
||||
else None
|
||||
)
|
||||
|
||||
borgmatic.borg.delete.delete_archives(
|
||||
repository,
|
||||
config,
|
||||
local_borg_version,
|
||||
borgmatic.actions.arguments.update_arguments(delete_arguments, archive=archive_name),
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
)
|
||||
@@ -21,7 +21,7 @@ def run_export_key(
|
||||
if export_arguments.repository is None or borgmatic.config.validate.repositories_match(
|
||||
repository, export_arguments.repository
|
||||
):
|
||||
logger.info(f'{repository.get("label", repository["path"])}: Exporting repository key')
|
||||
logger.info('Exporting repository key')
|
||||
borgmatic.borg.export_key.export_key(
|
||||
repository['path'],
|
||||
config,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import logging
|
||||
|
||||
import borgmatic.borg.export_tar
|
||||
import borgmatic.borg.rlist
|
||||
import borgmatic.borg.repo_list
|
||||
import borgmatic.config.validate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -22,13 +22,11 @@ def run_export_tar(
|
||||
if export_tar_arguments.repository is None or borgmatic.config.validate.repositories_match(
|
||||
repository, export_tar_arguments.repository
|
||||
):
|
||||
logger.info(
|
||||
f'{repository["path"]}: Exporting archive {export_tar_arguments.archive} as tar file'
|
||||
)
|
||||
logger.info(f'Exporting archive {export_tar_arguments.archive} as tar file')
|
||||
borgmatic.borg.export_tar.export_tar_archive(
|
||||
global_arguments.dry_run,
|
||||
repository['path'],
|
||||
borgmatic.borg.rlist.resolve_archive_name(
|
||||
borgmatic.borg.repo_list.resolve_archive_name(
|
||||
repository['path'],
|
||||
export_tar_arguments.archive,
|
||||
config,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import logging
|
||||
|
||||
import borgmatic.borg.extract
|
||||
import borgmatic.borg.rlist
|
||||
import borgmatic.borg.repo_list
|
||||
import borgmatic.config.validate
|
||||
import borgmatic.hooks.command
|
||||
|
||||
@@ -33,13 +33,11 @@ def run_extract(
|
||||
if extract_arguments.repository is None or borgmatic.config.validate.repositories_match(
|
||||
repository, extract_arguments.repository
|
||||
):
|
||||
logger.info(
|
||||
f'{repository.get("label", repository["path"])}: Extracting archive {extract_arguments.archive}'
|
||||
)
|
||||
logger.info(f'Extracting archive {extract_arguments.archive}')
|
||||
borgmatic.borg.extract.extract_archive(
|
||||
global_arguments.dry_run,
|
||||
repository['path'],
|
||||
borgmatic.borg.rlist.resolve_archive_name(
|
||||
borgmatic.borg.repo_list.resolve_archive_name(
|
||||
repository['path'],
|
||||
extract_arguments.archive,
|
||||
config,
|
||||
|
||||
@@ -3,7 +3,7 @@ import logging
|
||||
import borgmatic.actions.arguments
|
||||
import borgmatic.actions.json
|
||||
import borgmatic.borg.info
|
||||
import borgmatic.borg.rlist
|
||||
import borgmatic.borg.repo_list
|
||||
import borgmatic.config.validate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -27,10 +27,8 @@ def run_info(
|
||||
repository, info_arguments.repository
|
||||
):
|
||||
if not info_arguments.json:
|
||||
logger.answer(
|
||||
f'{repository.get("label", repository["path"])}: Displaying archive summary information'
|
||||
)
|
||||
archive_name = borgmatic.borg.rlist.resolve_archive_name(
|
||||
logger.answer('Displaying archive summary information')
|
||||
archive_name = borgmatic.borg.repo_list.resolve_archive_name(
|
||||
repository['path'],
|
||||
info_arguments.archive,
|
||||
config,
|
||||
|
||||
@@ -1,12 +1,26 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def parse_json(borg_json_output, label):
|
||||
'''
|
||||
Given a Borg JSON output string, parse it as JSON into a dict. Inject the given borgmatic
|
||||
repository label into it and return the dict.
|
||||
|
||||
Raise JSONDecodeError if the JSON output cannot be parsed.
|
||||
'''
|
||||
json_data = json.loads(borg_json_output)
|
||||
lines = borg_json_output.splitlines()
|
||||
start_line_index = 0
|
||||
|
||||
# Scan forward to find the first line starting with "{" and assume that's where the JSON starts.
|
||||
for line_index, line in enumerate(lines):
|
||||
if line.startswith('{'):
|
||||
start_line_index = line_index
|
||||
break
|
||||
|
||||
json_data = json.loads('\n'.join(lines[start_line_index:]))
|
||||
|
||||
if 'repository' not in json_data:
|
||||
return json_data
|
||||
|
||||
@@ -27,11 +27,11 @@ def run_list(
|
||||
):
|
||||
if not list_arguments.json:
|
||||
if list_arguments.find_paths: # pragma: no cover
|
||||
logger.answer(f'{repository.get("label", repository["path"])}: Searching archives')
|
||||
logger.answer('Searching archives')
|
||||
elif not list_arguments.archive: # pragma: no cover
|
||||
logger.answer(f'{repository.get("label", repository["path"])}: Listing archives')
|
||||
logger.answer('Listing archives')
|
||||
|
||||
archive_name = borgmatic.borg.rlist.resolve_archive_name(
|
||||
archive_name = borgmatic.borg.repo_list.resolve_archive_name(
|
||||
repository['path'],
|
||||
list_arguments.archive,
|
||||
config,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import logging
|
||||
|
||||
import borgmatic.borg.mount
|
||||
import borgmatic.borg.rlist
|
||||
import borgmatic.borg.repo_list
|
||||
import borgmatic.config.validate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -23,15 +23,13 @@ def run_mount(
|
||||
repository, mount_arguments.repository
|
||||
):
|
||||
if mount_arguments.archive:
|
||||
logger.info(
|
||||
f'{repository.get("label", repository["path"])}: Mounting archive {mount_arguments.archive}'
|
||||
)
|
||||
logger.info(f'Mounting archive {mount_arguments.archive}')
|
||||
else: # pragma: nocover
|
||||
logger.info(f'{repository.get("label", repository["path"])}: Mounting repository')
|
||||
logger.info('Mounting repository')
|
||||
|
||||
borgmatic.borg.mount.mount_archive(
|
||||
repository['path'],
|
||||
borgmatic.borg.rlist.resolve_archive_name(
|
||||
borgmatic.borg.repo_list.resolve_archive_name(
|
||||
repository['path'],
|
||||
mount_arguments.archive,
|
||||
config,
|
||||
|
||||
@@ -35,7 +35,7 @@ def run_prune(
|
||||
global_arguments.dry_run,
|
||||
**hook_context,
|
||||
)
|
||||
logger.info(f'{repository.get("label", repository["path"])}: Pruning archives{dry_run_label}')
|
||||
logger.info(f'Pruning archives{dry_run_label}')
|
||||
borgmatic.borg.prune.prune_archives(
|
||||
global_arguments.dry_run,
|
||||
repository['path'],
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import logging
|
||||
|
||||
import borgmatic.borg.rcreate
|
||||
import borgmatic.config.validate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def run_rcreate(
|
||||
repository,
|
||||
config,
|
||||
local_borg_version,
|
||||
rcreate_arguments,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
):
|
||||
'''
|
||||
Run the "rcreate" action for the given repository.
|
||||
'''
|
||||
if rcreate_arguments.repository and not borgmatic.config.validate.repositories_match(
|
||||
repository, rcreate_arguments.repository
|
||||
):
|
||||
return
|
||||
|
||||
logger.info(f'{repository.get("label", repository["path"])}: Creating repository')
|
||||
borgmatic.borg.rcreate.create_repository(
|
||||
global_arguments.dry_run,
|
||||
repository['path'],
|
||||
config,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
rcreate_arguments.encryption_mode,
|
||||
rcreate_arguments.source_repository,
|
||||
rcreate_arguments.copy_crypt_key,
|
||||
rcreate_arguments.append_only,
|
||||
rcreate_arguments.storage_quota,
|
||||
rcreate_arguments.make_parent_dirs,
|
||||
local_path=local_path,
|
||||
remote_path=remote_path,
|
||||
)
|
||||
@@ -0,0 +1,41 @@
|
||||
import logging
|
||||
|
||||
import borgmatic.borg.repo_create
|
||||
import borgmatic.config.validate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def run_repo_create(
|
||||
repository,
|
||||
config,
|
||||
local_borg_version,
|
||||
repo_create_arguments,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
):
|
||||
'''
|
||||
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
|
||||
):
|
||||
return
|
||||
|
||||
logger.info('Creating repository')
|
||||
borgmatic.borg.repo_create.create_repository(
|
||||
global_arguments.dry_run,
|
||||
repository['path'],
|
||||
config,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
repo_create_arguments.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,
|
||||
local_path=local_path,
|
||||
remote_path=remote_path,
|
||||
)
|
||||
@@ -0,0 +1,35 @@
|
||||
import logging
|
||||
|
||||
import borgmatic.borg.repo_delete
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def run_repo_delete(
|
||||
repository,
|
||||
config,
|
||||
local_borg_version,
|
||||
repo_delete_arguments,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
):
|
||||
'''
|
||||
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
|
||||
):
|
||||
logger.answer(
|
||||
'Deleting repository' + (' cache' if repo_delete_arguments.cache_only else '')
|
||||
)
|
||||
|
||||
borgmatic.borg.repo_delete.delete_repository(
|
||||
repository,
|
||||
config,
|
||||
local_borg_version,
|
||||
repo_delete_arguments,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
)
|
||||
@@ -0,0 +1,40 @@
|
||||
import logging
|
||||
|
||||
import borgmatic.actions.json
|
||||
import borgmatic.borg.repo_info
|
||||
import borgmatic.config.validate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def run_repo_info(
|
||||
repository,
|
||||
config,
|
||||
local_borg_version,
|
||||
repo_info_arguments,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
):
|
||||
'''
|
||||
Run the "repo-info" action for the given repository.
|
||||
|
||||
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
|
||||
):
|
||||
if not repo_info_arguments.json:
|
||||
logger.answer('Displaying repository summary information')
|
||||
|
||||
json_output = borgmatic.borg.repo_info.display_repository_info(
|
||||
repository['path'],
|
||||
config,
|
||||
local_borg_version,
|
||||
repo_info_arguments=repo_info_arguments,
|
||||
global_arguments=global_arguments,
|
||||
local_path=local_path,
|
||||
remote_path=remote_path,
|
||||
)
|
||||
if json_output:
|
||||
yield borgmatic.actions.json.parse_json(json_output, repository.get('label'))
|
||||
@@ -1,37 +1,37 @@
|
||||
import logging
|
||||
|
||||
import borgmatic.actions.json
|
||||
import borgmatic.borg.rlist
|
||||
import borgmatic.borg.repo_list
|
||||
import borgmatic.config.validate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def run_rlist(
|
||||
def run_repo_list(
|
||||
repository,
|
||||
config,
|
||||
local_borg_version,
|
||||
rlist_arguments,
|
||||
repo_list_arguments,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
):
|
||||
'''
|
||||
Run the "rlist" action for the given repository.
|
||||
Run the "repo-list" action for the given repository.
|
||||
|
||||
If rlist_arguments.json is True, yield the JSON output from listing the repository.
|
||||
If repo_list_arguments.json is True, yield the JSON output from listing the repository.
|
||||
'''
|
||||
if rlist_arguments.repository is None or borgmatic.config.validate.repositories_match(
|
||||
repository, rlist_arguments.repository
|
||||
if repo_list_arguments.repository is None or borgmatic.config.validate.repositories_match(
|
||||
repository, repo_list_arguments.repository
|
||||
):
|
||||
if not rlist_arguments.json:
|
||||
logger.answer(f'{repository.get("label", repository["path"])}: Listing repository')
|
||||
if not repo_list_arguments.json:
|
||||
logger.answer('Listing repository')
|
||||
|
||||
json_output = borgmatic.borg.rlist.list_repository(
|
||||
json_output = borgmatic.borg.repo_list.list_repository(
|
||||
repository['path'],
|
||||
config,
|
||||
local_borg_version,
|
||||
rlist_arguments=rlist_arguments,
|
||||
repo_list_arguments=repo_list_arguments,
|
||||
global_arguments=global_arguments,
|
||||
local_path=local_path,
|
||||
remote_path=remote_path,
|
||||
+387
-236
@@ -1,67 +1,156 @@
|
||||
import copy
|
||||
import collections
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
import borgmatic.borg.extract
|
||||
import borgmatic.borg.list
|
||||
import borgmatic.borg.mount
|
||||
import borgmatic.borg.rlist
|
||||
import borgmatic.borg.state
|
||||
import borgmatic.borg.repo_list
|
||||
import borgmatic.config.paths
|
||||
import borgmatic.config.validate
|
||||
import borgmatic.hooks.data_source.dump
|
||||
import borgmatic.hooks.dispatch
|
||||
import borgmatic.hooks.dump
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
UNSPECIFIED_HOOK = object()
|
||||
UNSPECIFIED = object()
|
||||
|
||||
|
||||
def get_configured_data_source(
|
||||
config,
|
||||
archive_data_source_names,
|
||||
hook_name,
|
||||
data_source_name,
|
||||
configuration_data_source_name=None,
|
||||
):
|
||||
Dump = collections.namedtuple(
|
||||
'Dump',
|
||||
('hook_name', 'data_source_name', 'hostname', 'port'),
|
||||
defaults=('localhost', None),
|
||||
)
|
||||
|
||||
|
||||
def dumps_match(first, second, default_port=None):
|
||||
'''
|
||||
Find the first data source with the given hook name and data source name in the configuration
|
||||
dict and the given archive data source names dict (from hook name to data source names contained
|
||||
in a particular backup archive). If UNSPECIFIED_HOOK is given as the hook name, search all data
|
||||
source hooks for the named data source. If a configuration data source name is given, use that
|
||||
instead of the data source name to lookup the data source in the given hooks configuration.
|
||||
|
||||
Return the found data source as a tuple of (found hook name, data source configuration dict) or
|
||||
(None, None) if not found.
|
||||
Compare two Dump instances for equality while supporting a field value of UNSPECIFIED, which
|
||||
indicates that the field should match any value. If a default port is given, then consider any
|
||||
dump having that port to match with a dump having a None port.
|
||||
'''
|
||||
if not configuration_data_source_name:
|
||||
configuration_data_source_name = data_source_name
|
||||
for field_name in first._fields:
|
||||
first_value = getattr(first, field_name)
|
||||
second_value = getattr(second, field_name)
|
||||
|
||||
if hook_name == UNSPECIFIED_HOOK:
|
||||
hooks_to_search = {
|
||||
hook_name: value
|
||||
for (hook_name, value) in config.items()
|
||||
if hook_name in borgmatic.hooks.dump.DATA_SOURCE_HOOK_NAMES
|
||||
}
|
||||
if default_port is not None and field_name == 'port':
|
||||
if first_value == default_port and second_value is None:
|
||||
continue
|
||||
if second_value == default_port and first_value is None:
|
||||
continue
|
||||
|
||||
if first_value == UNSPECIFIED or second_value == UNSPECIFIED:
|
||||
continue
|
||||
|
||||
if first_value != second_value:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def render_dump_metadata(dump):
|
||||
'''
|
||||
Given a Dump instance, make a display string describing it for use in log messages.
|
||||
'''
|
||||
name = 'unspecified' if dump.data_source_name is UNSPECIFIED else dump.data_source_name
|
||||
hostname = dump.hostname or 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}'
|
||||
else:
|
||||
try:
|
||||
hooks_to_search = {hook_name: config[hook_name]}
|
||||
except KeyError:
|
||||
return (None, None)
|
||||
metadata = f'{name}' if hostname is UNSPECIFIED else f'{name}@{hostname}'
|
||||
|
||||
return next(
|
||||
(
|
||||
(name, hook_data_source)
|
||||
for (name, hook) in hooks_to_search.items()
|
||||
for hook_data_source in hook
|
||||
if hook_data_source['name'] == configuration_data_source_name
|
||||
and data_source_name in archive_data_source_names.get(name, [])
|
||||
),
|
||||
(None, None),
|
||||
if dump.hook_name not in (None, UNSPECIFIED):
|
||||
return f'{metadata} ({dump.hook_name})'
|
||||
|
||||
return metadata
|
||||
|
||||
|
||||
def get_configured_data_source(config, restore_dump):
|
||||
'''
|
||||
Search in the given configuration dict for dumps corresponding to the given dump to restore. If
|
||||
there are multiple matches, error.
|
||||
|
||||
Return the found data source as a data source configuration dict or None if not found.
|
||||
'''
|
||||
try:
|
||||
hooks_to_search = {restore_dump.hook_name: config[restore_dump.hook_name]}
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
matching_dumps = tuple(
|
||||
hook_data_source
|
||||
for (hook_name, hook_config) in hooks_to_search.items()
|
||||
for hook_data_source in hook_config
|
||||
for default_port in (
|
||||
borgmatic.hooks.dispatch.call_hook(
|
||||
function_name='get_default_port',
|
||||
config=config,
|
||||
hook_name=hook_name,
|
||||
),
|
||||
)
|
||||
if dumps_match(
|
||||
Dump(
|
||||
hook_name,
|
||||
hook_data_source.get('name'),
|
||||
hook_data_source.get('hostname', 'localhost'),
|
||||
hook_data_source.get('port'),
|
||||
),
|
||||
restore_dump,
|
||||
default_port,
|
||||
)
|
||||
)
|
||||
|
||||
if not matching_dumps:
|
||||
return None
|
||||
|
||||
def restore_single_data_source(
|
||||
if len(matching_dumps) > 1:
|
||||
raise ValueError(
|
||||
f'Cannot restore data source {render_dump_metadata(restore_dump)} because there are multiple matching data sources configured'
|
||||
)
|
||||
|
||||
return matching_dumps[0]
|
||||
|
||||
|
||||
def strip_path_prefix_from_extracted_dump_destination(
|
||||
destination_path, borgmatic_runtime_directory
|
||||
):
|
||||
'''
|
||||
Directory-format dump files get extracted into a temporary directory containing a path prefix
|
||||
that depends how the files were stored in the archive. So, given the destination path where the
|
||||
dump was extracted and the borgmatic runtime directory, move the dump files such that the
|
||||
restore doesn't have to deal with that varying path prefix.
|
||||
|
||||
For instance, if the dump was extracted to:
|
||||
|
||||
/run/user/0/borgmatic/tmp1234/borgmatic/postgresql_databases/test/...
|
||||
|
||||
or:
|
||||
|
||||
/run/user/0/borgmatic/tmp1234/root/.borgmatic/postgresql_databases/test/...
|
||||
|
||||
then this function moves it to:
|
||||
|
||||
/run/user/0/borgmatic/postgresql_databases/test/...
|
||||
'''
|
||||
for subdirectory_path, _, _ in os.walk(destination_path):
|
||||
databases_directory = os.path.basename(subdirectory_path)
|
||||
|
||||
if not databases_directory.endswith('_databases'):
|
||||
continue
|
||||
|
||||
shutil.move(
|
||||
subdirectory_path, os.path.join(borgmatic_runtime_directory, databases_directory)
|
||||
)
|
||||
break
|
||||
|
||||
|
||||
def restore_single_dump(
|
||||
repository,
|
||||
config,
|
||||
local_borg_version,
|
||||
@@ -72,55 +161,78 @@ def restore_single_data_source(
|
||||
hook_name,
|
||||
data_source,
|
||||
connection_params,
|
||||
): # pragma: no cover
|
||||
borgmatic_runtime_directory,
|
||||
):
|
||||
'''
|
||||
Given (among other things) an archive name, a data source hook name, the hostname, port,
|
||||
username/password as connection params, and a configured data source configuration dict, restore
|
||||
that data source from the archive.
|
||||
'''
|
||||
logger.info(
|
||||
f'{repository.get("label", repository["path"])}: Restoring data source {data_source["name"]}'
|
||||
dump_metadata = render_dump_metadata(
|
||||
Dump(hook_name, data_source['name'], data_source.get('hostname'), data_source.get('port'))
|
||||
)
|
||||
|
||||
dump_pattern = borgmatic.hooks.dispatch.call_hooks(
|
||||
'make_data_source_dump_pattern',
|
||||
logger.info(f'Restoring data source {dump_metadata}')
|
||||
|
||||
dump_patterns = borgmatic.hooks.dispatch.call_hooks(
|
||||
'make_data_source_dump_patterns',
|
||||
config,
|
||||
repository['path'],
|
||||
borgmatic.hooks.dump.DATA_SOURCE_HOOK_NAMES,
|
||||
borgmatic.hooks.dispatch.Hook_type.DATA_SOURCE,
|
||||
borgmatic_runtime_directory,
|
||||
data_source['name'],
|
||||
)[hook_name]
|
||||
)[hook_name.split('_databases', 1)[0]]
|
||||
|
||||
# Kick off a single data source extract to stdout.
|
||||
extract_process = borgmatic.borg.extract.extract_archive(
|
||||
dry_run=global_arguments.dry_run,
|
||||
repository=repository['path'],
|
||||
archive=archive_name,
|
||||
paths=borgmatic.hooks.dump.convert_glob_patterns_to_borg_patterns([dump_pattern]),
|
||||
config=config,
|
||||
local_borg_version=local_borg_version,
|
||||
global_arguments=global_arguments,
|
||||
local_path=local_path,
|
||||
remote_path=remote_path,
|
||||
destination_path='/',
|
||||
# A directory format dump isn't a single file, and therefore can't extract
|
||||
# to stdout. In this case, the extract_process return value is None.
|
||||
extract_to_stdout=bool(data_source.get('format') != 'directory'),
|
||||
destination_path = (
|
||||
tempfile.mkdtemp(dir=borgmatic_runtime_directory)
|
||||
if data_source.get('format') == 'directory'
|
||||
else None
|
||||
)
|
||||
|
||||
try:
|
||||
# Kick off a single data source extract. If using a directory format, extract to a temporary
|
||||
# directory. Otherwise extract the single dump file to stdout.
|
||||
extract_process = borgmatic.borg.extract.extract_archive(
|
||||
dry_run=global_arguments.dry_run,
|
||||
repository=repository['path'],
|
||||
archive=archive_name,
|
||||
paths=[
|
||||
borgmatic.hooks.data_source.dump.convert_glob_patterns_to_borg_pattern(
|
||||
dump_patterns
|
||||
)
|
||||
],
|
||||
config=config,
|
||||
local_borg_version=local_borg_version,
|
||||
global_arguments=global_arguments,
|
||||
local_path=local_path,
|
||||
remote_path=remote_path,
|
||||
destination_path=destination_path,
|
||||
# A directory format dump isn't a single file, and therefore can't extract
|
||||
# to stdout. In this case, the extract_process return value is None.
|
||||
extract_to_stdout=bool(data_source.get('format') != 'directory'),
|
||||
)
|
||||
|
||||
if destination_path and not global_arguments.dry_run:
|
||||
strip_path_prefix_from_extracted_dump_destination(
|
||||
destination_path, borgmatic_runtime_directory
|
||||
)
|
||||
finally:
|
||||
if destination_path and not global_arguments.dry_run:
|
||||
shutil.rmtree(destination_path, ignore_errors=True)
|
||||
|
||||
# Run a single data source restore, consuming the extract stdout (if any).
|
||||
borgmatic.hooks.dispatch.call_hooks(
|
||||
borgmatic.hooks.dispatch.call_hook(
|
||||
function_name='restore_data_source_dump',
|
||||
config=config,
|
||||
log_prefix=repository['path'],
|
||||
hook_names=[hook_name],
|
||||
hook_name=hook_name,
|
||||
data_source=data_source,
|
||||
dry_run=global_arguments.dry_run,
|
||||
extract_process=extract_process,
|
||||
connection_params=connection_params,
|
||||
borgmatic_runtime_directory=borgmatic_runtime_directory,
|
||||
)
|
||||
|
||||
|
||||
def collect_archive_data_source_names(
|
||||
def collect_dumps_from_archive(
|
||||
repository,
|
||||
archive,
|
||||
config,
|
||||
@@ -128,18 +240,21 @@ def collect_archive_data_source_names(
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
borgmatic_runtime_directory,
|
||||
):
|
||||
'''
|
||||
Given a local or remote repository path, a resolved archive name, a configuration dict, the
|
||||
local Borg version, global_arguments an argparse.Namespace, and local and remote Borg paths,
|
||||
query the archive for the names of data sources it contains as dumps and return them as a dict
|
||||
from hook name to a sequence of data source names.
|
||||
local Borg version, global arguments an argparse.Namespace, local and remote Borg paths, and the
|
||||
borgmatic runtime directory, query the archive for the names of data sources dumps it contains
|
||||
and return them as a set of Dump instances.
|
||||
'''
|
||||
borgmatic_source_directory = os.path.expanduser(
|
||||
config.get(
|
||||
'borgmatic_source_directory', borgmatic.borg.state.DEFAULT_BORGMATIC_SOURCE_DIRECTORY
|
||||
)
|
||||
).lstrip('/')
|
||||
borgmatic_source_directory = str(
|
||||
pathlib.Path(borgmatic.config.paths.get_borgmatic_source_directory(config))
|
||||
)
|
||||
|
||||
# Probe for the data source dumps in multiple locations, as the default location has moved to
|
||||
# the borgmatic runtime directory (which gets stored as just "/borgmatic" with Borg 1.4+). But
|
||||
# we still want to support reading dumps from previously created archives as well.
|
||||
dump_paths = borgmatic.borg.list.capture_archive_listing(
|
||||
repository,
|
||||
archive,
|
||||
@@ -147,108 +262,162 @@ def collect_archive_data_source_names(
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
list_paths=[
|
||||
os.path.expanduser(
|
||||
borgmatic.hooks.dump.make_data_source_dump_path(borgmatic_source_directory, pattern)
|
||||
'sh:'
|
||||
+ borgmatic.hooks.data_source.dump.make_data_source_dump_path(
|
||||
base_directory, '*_databases/*/*'
|
||||
)
|
||||
for base_directory in (
|
||||
'borgmatic',
|
||||
borgmatic.config.paths.make_runtime_directory_glob(borgmatic_runtime_directory),
|
||||
borgmatic_source_directory.lstrip('/'),
|
||||
)
|
||||
for pattern in ('*_databases/*/*',)
|
||||
],
|
||||
local_path=local_path,
|
||||
remote_path=remote_path,
|
||||
)
|
||||
|
||||
# Determine the data source names corresponding to the dumps found in the archive and
|
||||
# add them to restore_names.
|
||||
archive_data_source_names = {}
|
||||
# Parse the paths of dumps found in the archive to get their respective dump metadata.
|
||||
dumps_from_archive = set()
|
||||
|
||||
for dump_path in dump_paths:
|
||||
try:
|
||||
(hook_name, _, data_source_name) = dump_path.split(
|
||||
borgmatic_source_directory + os.path.sep, 1
|
||||
)[1].split(os.path.sep)[0:3]
|
||||
except (ValueError, IndexError):
|
||||
logger.warning(
|
||||
f'{repository}: Ignoring invalid data source dump path "{dump_path}" in archive {archive}'
|
||||
)
|
||||
if not dump_path:
|
||||
continue
|
||||
|
||||
# Probe to find the base directory that's at the start of the dump path.
|
||||
for base_directory in (
|
||||
'borgmatic',
|
||||
borgmatic_runtime_directory,
|
||||
borgmatic_source_directory,
|
||||
):
|
||||
try:
|
||||
(hook_name, host_and_port, data_source_name) = dump_path.split(
|
||||
base_directory + os.path.sep, 1
|
||||
)[1].split(os.path.sep)[0:3]
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
|
||||
parts = host_and_port.split(':', 1)
|
||||
|
||||
if len(parts) == 1:
|
||||
parts += (None,)
|
||||
|
||||
(hostname, port) = parts
|
||||
|
||||
try:
|
||||
port = int(port)
|
||||
except (ValueError, TypeError):
|
||||
port = None
|
||||
|
||||
dumps_from_archive.add(Dump(hook_name, data_source_name, hostname, port))
|
||||
|
||||
# We've successfully parsed the dump path, so need to probe any further.
|
||||
break
|
||||
else:
|
||||
if data_source_name not in archive_data_source_names.get(hook_name, []):
|
||||
archive_data_source_names.setdefault(hook_name, []).extend([data_source_name])
|
||||
logger.warning(
|
||||
f'Ignoring invalid data source dump path "{dump_path}" in archive {archive}'
|
||||
)
|
||||
|
||||
return archive_data_source_names
|
||||
return dumps_from_archive
|
||||
|
||||
|
||||
def find_data_sources_to_restore(requested_data_source_names, archive_data_source_names):
|
||||
def get_dumps_to_restore(restore_arguments, dumps_from_archive):
|
||||
'''
|
||||
Given a sequence of requested data source names to restore and a dict of hook name to the names
|
||||
of data sources found in an archive, return an expanded sequence of data source names to
|
||||
restore, replacing "all" with actual data source names as appropriate.
|
||||
Given restore arguments as an argparse.Namespace instance indicating which dumps to restore and
|
||||
a set of Dump instances representing the dumps found in an archive, return a set of specific
|
||||
Dump instances from the archive to restore. As part of this, replace any Dump having a data
|
||||
source name of "all" with multiple named Dump instances as appropriate.
|
||||
|
||||
Raise ValueError if any of the requested data source names cannot be found in the archive.
|
||||
Raise ValueError if any of the requested data source names cannot be found in the archive or if
|
||||
there are multiple archive dump matches for a given requested dump.
|
||||
'''
|
||||
# A map from data source hook name to the data source names to restore for that hook.
|
||||
restore_names = (
|
||||
{UNSPECIFIED_HOOK: requested_data_source_names}
|
||||
if requested_data_source_names
|
||||
else {UNSPECIFIED_HOOK: ['all']}
|
||||
requested_dumps = (
|
||||
{
|
||||
Dump(
|
||||
hook_name=(
|
||||
(
|
||||
restore_arguments.hook
|
||||
if restore_arguments.hook.endswith('_databases')
|
||||
else f'{restore_arguments.hook}_databases'
|
||||
)
|
||||
if restore_arguments.hook
|
||||
else UNSPECIFIED
|
||||
),
|
||||
data_source_name=name,
|
||||
hostname=restore_arguments.original_hostname or UNSPECIFIED,
|
||||
port=restore_arguments.original_port,
|
||||
)
|
||||
for name in restore_arguments.data_sources or (UNSPECIFIED,)
|
||||
}
|
||||
if restore_arguments.hook
|
||||
or restore_arguments.data_sources
|
||||
or restore_arguments.original_hostname
|
||||
or restore_arguments.original_port
|
||||
else {
|
||||
Dump(
|
||||
hook_name=UNSPECIFIED,
|
||||
data_source_name='all',
|
||||
hostname=UNSPECIFIED,
|
||||
port=UNSPECIFIED,
|
||||
)
|
||||
}
|
||||
)
|
||||
missing_dumps = set()
|
||||
dumps_to_restore = set()
|
||||
|
||||
# If "all" is in restore_names, then replace it with the names of dumps found within the
|
||||
# archive.
|
||||
if 'all' in restore_names[UNSPECIFIED_HOOK]:
|
||||
restore_names[UNSPECIFIED_HOOK].remove('all')
|
||||
# If there's a requested "all" dump, add every dump from the archive to the dumps to restore.
|
||||
if any(dump for dump in requested_dumps if dump.data_source_name == 'all'):
|
||||
dumps_to_restore.update(dumps_from_archive)
|
||||
|
||||
for hook_name, data_source_names in archive_data_source_names.items():
|
||||
restore_names.setdefault(hook_name, []).extend(data_source_names)
|
||||
# If any archive dump matches a requested dump, add the archive dump to the dumps to restore.
|
||||
for requested_dump in requested_dumps:
|
||||
if requested_dump.data_source_name == 'all':
|
||||
continue
|
||||
|
||||
# If a data source is to be restored as part of "all", then remove it from restore names
|
||||
# so it doesn't get restored twice.
|
||||
for data_source_name in data_source_names:
|
||||
if data_source_name in restore_names[UNSPECIFIED_HOOK]:
|
||||
restore_names[UNSPECIFIED_HOOK].remove(data_source_name)
|
||||
|
||||
if not restore_names[UNSPECIFIED_HOOK]:
|
||||
restore_names.pop(UNSPECIFIED_HOOK)
|
||||
|
||||
combined_restore_names = set(
|
||||
name for data_source_names in restore_names.values() for name in data_source_names
|
||||
)
|
||||
combined_archive_data_source_names = set(
|
||||
name
|
||||
for data_source_names in archive_data_source_names.values()
|
||||
for name in data_source_names
|
||||
)
|
||||
|
||||
missing_names = sorted(set(combined_restore_names) - combined_archive_data_source_names)
|
||||
if missing_names:
|
||||
joined_names = ', '.join(f'"{name}"' for name in missing_names)
|
||||
raise ValueError(
|
||||
f"Cannot restore data source{'s' if len(missing_names) > 1 else ''} {joined_names} missing from archive"
|
||||
matching_dumps = tuple(
|
||||
archive_dump
|
||||
for archive_dump in dumps_from_archive
|
||||
if dumps_match(requested_dump, archive_dump)
|
||||
)
|
||||
|
||||
return restore_names
|
||||
if len(matching_dumps) == 0:
|
||||
missing_dumps.add(requested_dump)
|
||||
elif len(matching_dumps) == 1:
|
||||
dumps_to_restore.add(matching_dumps[0])
|
||||
else:
|
||||
raise ValueError(
|
||||
f'Cannot restore data source {render_dump_metadata(requested_dump)} because there are multiple matching dumps in the archive. Try adding flags to disambiguate.'
|
||||
)
|
||||
|
||||
if missing_dumps:
|
||||
rendered_dumps = ', '.join(
|
||||
f'{render_dump_metadata(dump)}' for dump in sorted(missing_dumps)
|
||||
)
|
||||
|
||||
raise ValueError(
|
||||
f"Cannot restore data source dump{'s' if len(missing_dumps) > 1 else ''} {rendered_dumps} missing from archive"
|
||||
)
|
||||
|
||||
return dumps_to_restore
|
||||
|
||||
|
||||
def ensure_data_sources_found(restore_names, remaining_restore_names, found_names):
|
||||
def ensure_requested_dumps_restored(dumps_to_restore, dumps_actually_restored):
|
||||
'''
|
||||
Given a dict from hook name to data source names to restore, a dict from hook name to remaining
|
||||
data source names to restore, and a sequence of found (actually restored) data source names,
|
||||
raise ValueError if requested data source to restore were missing from the archive and/or
|
||||
Given a set of requested dumps to restore and a set of dumps actually restored, raise ValueError
|
||||
if any requested dumps to restore weren't restored, indicating that they were missing from the
|
||||
configuration.
|
||||
'''
|
||||
combined_restore_names = set(
|
||||
name
|
||||
for data_source_names in tuple(restore_names.values())
|
||||
+ tuple(remaining_restore_names.values())
|
||||
for name in data_source_names
|
||||
if not 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
|
||||
)
|
||||
|
||||
if not combined_restore_names and not found_names:
|
||||
raise ValueError('No data sources were found to restore')
|
||||
if missing_dumps:
|
||||
rendered_dumps = ', '.join(f'{render_dump_metadata(dump)}' for dump in missing_dumps)
|
||||
|
||||
missing_names = sorted(set(combined_restore_names) - set(found_names))
|
||||
if missing_names:
|
||||
joined_names = ', '.join(f'"{name}"' for name in missing_names)
|
||||
raise ValueError(
|
||||
f"Cannot restore data source{'s' if len(missing_names) > 1 else ''} {joined_names} missing from borgmatic's configuration"
|
||||
f"Cannot restore data source{'s' if len(missing_dumps) > 1 else ''} {rendered_dumps} missing from borgmatic's configuration"
|
||||
)
|
||||
|
||||
|
||||
@@ -265,70 +434,79 @@ def run_restore(
|
||||
Run the "restore" action for the given repository, but only if the repository matches the
|
||||
requested repository in restore arguments.
|
||||
|
||||
Raise ValueError if a configured data source could not be found to restore.
|
||||
Raise ValueError if a configured data source could not be found to restore or there's no
|
||||
matching dump in the archive.
|
||||
'''
|
||||
if restore_arguments.repository and not borgmatic.config.validate.repositories_match(
|
||||
repository, restore_arguments.repository
|
||||
):
|
||||
return
|
||||
|
||||
logger.info(
|
||||
f'{repository.get("label", repository["path"])}: Restoring data sources from archive {restore_arguments.archive}'
|
||||
)
|
||||
logger.info(f'Restoring data sources from archive {restore_arguments.archive}')
|
||||
|
||||
borgmatic.hooks.dispatch.call_hooks_even_if_unconfigured(
|
||||
'remove_data_source_dumps',
|
||||
config,
|
||||
repository['path'],
|
||||
borgmatic.hooks.dump.DATA_SOURCE_HOOK_NAMES,
|
||||
global_arguments.dry_run,
|
||||
)
|
||||
with borgmatic.config.paths.Runtime_directory(config) as 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,
|
||||
global_arguments.dry_run,
|
||||
)
|
||||
|
||||
archive_name = borgmatic.borg.rlist.resolve_archive_name(
|
||||
repository['path'],
|
||||
restore_arguments.archive,
|
||||
config,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
)
|
||||
archive_data_source_names = collect_archive_data_source_names(
|
||||
repository['path'],
|
||||
archive_name,
|
||||
config,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
)
|
||||
restore_names = find_data_sources_to_restore(
|
||||
restore_arguments.data_sources, archive_data_source_names
|
||||
)
|
||||
found_names = set()
|
||||
remaining_restore_names = {}
|
||||
connection_params = {
|
||||
'hostname': restore_arguments.hostname,
|
||||
'port': restore_arguments.port,
|
||||
'username': restore_arguments.username,
|
||||
'password': restore_arguments.password,
|
||||
'restore_path': restore_arguments.restore_path,
|
||||
}
|
||||
archive_name = borgmatic.borg.repo_list.resolve_archive_name(
|
||||
repository['path'],
|
||||
restore_arguments.archive,
|
||||
config,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
)
|
||||
dumps_from_archive = collect_dumps_from_archive(
|
||||
repository['path'],
|
||||
archive_name,
|
||||
config,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
borgmatic_runtime_directory,
|
||||
)
|
||||
dumps_to_restore = get_dumps_to_restore(restore_arguments, dumps_from_archive)
|
||||
|
||||
for hook_name, data_source_names in restore_names.items():
|
||||
for data_source_name in data_source_names:
|
||||
found_hook_name, found_data_source = get_configured_data_source(
|
||||
config, archive_data_source_names, hook_name, data_source_name
|
||||
dumps_actually_restored = set()
|
||||
connection_params = {
|
||||
'hostname': restore_arguments.hostname,
|
||||
'port': restore_arguments.port,
|
||||
'username': restore_arguments.username,
|
||||
'password': restore_arguments.password,
|
||||
'restore_path': restore_arguments.restore_path,
|
||||
}
|
||||
|
||||
# Restore each dump.
|
||||
for restore_dump in dumps_to_restore:
|
||||
found_data_source = get_configured_data_source(
|
||||
config,
|
||||
restore_dump,
|
||||
)
|
||||
|
||||
# For a dump that wasn't found via an exact match in the configuration, try to fallback
|
||||
# to an "all" data source.
|
||||
if not found_data_source:
|
||||
remaining_restore_names.setdefault(found_hook_name or hook_name, []).append(
|
||||
data_source_name
|
||||
found_data_source = get_configured_data_source(
|
||||
config,
|
||||
Dump(restore_dump.hook_name, 'all', restore_dump.hostname, restore_dump.port),
|
||||
)
|
||||
continue
|
||||
|
||||
found_names.add(data_source_name)
|
||||
restore_single_data_source(
|
||||
if not found_data_source:
|
||||
continue
|
||||
|
||||
found_data_source = dict(found_data_source)
|
||||
found_data_source['name'] = restore_dump.data_source_name
|
||||
|
||||
dumps_actually_restored.add(restore_dump)
|
||||
|
||||
restore_single_dump(
|
||||
repository,
|
||||
config,
|
||||
local_borg_version,
|
||||
@@ -336,45 +514,18 @@ def run_restore(
|
||||
local_path,
|
||||
remote_path,
|
||||
archive_name,
|
||||
found_hook_name or hook_name,
|
||||
restore_dump.hook_name,
|
||||
dict(found_data_source, **{'schemas': restore_arguments.schemas}),
|
||||
connection_params,
|
||||
borgmatic_runtime_directory,
|
||||
)
|
||||
|
||||
# For any data sources that weren't found via exact matches in the configuration, try to
|
||||
# fallback to "all" entries.
|
||||
for hook_name, data_source_names in remaining_restore_names.items():
|
||||
for data_source_name in data_source_names:
|
||||
found_hook_name, found_data_source = get_configured_data_source(
|
||||
config, archive_data_source_names, hook_name, data_source_name, 'all'
|
||||
)
|
||||
borgmatic.hooks.dispatch.call_hooks_even_if_unconfigured(
|
||||
'remove_data_source_dumps',
|
||||
config,
|
||||
borgmatic.hooks.dispatch.Hook_type.DATA_SOURCE,
|
||||
borgmatic_runtime_directory,
|
||||
global_arguments.dry_run,
|
||||
)
|
||||
|
||||
if not found_data_source:
|
||||
continue
|
||||
|
||||
found_names.add(data_source_name)
|
||||
data_source = copy.copy(found_data_source)
|
||||
data_source['name'] = data_source_name
|
||||
|
||||
restore_single_data_source(
|
||||
repository,
|
||||
config,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
archive_name,
|
||||
found_hook_name or hook_name,
|
||||
dict(data_source, **{'schemas': restore_arguments.schemas}),
|
||||
connection_params,
|
||||
)
|
||||
|
||||
borgmatic.hooks.dispatch.call_hooks_even_if_unconfigured(
|
||||
'remove_data_source_dumps',
|
||||
config,
|
||||
repository['path'],
|
||||
borgmatic.hooks.dump.DATA_SOURCE_HOOK_NAMES,
|
||||
global_arguments.dry_run,
|
||||
)
|
||||
|
||||
ensure_data_sources_found(restore_names, remaining_restore_names, found_names)
|
||||
ensure_requested_dumps_restored(dumps_to_restore, dumps_actually_restored)
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import logging
|
||||
|
||||
import borgmatic.actions.json
|
||||
import borgmatic.borg.rinfo
|
||||
import borgmatic.config.validate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def run_rinfo(
|
||||
repository,
|
||||
config,
|
||||
local_borg_version,
|
||||
rinfo_arguments,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
):
|
||||
'''
|
||||
Run the "rinfo" action for the given repository.
|
||||
|
||||
If rinfo_arguments.json is True, yield the JSON output from the info for the repository.
|
||||
'''
|
||||
if rinfo_arguments.repository is None or borgmatic.config.validate.repositories_match(
|
||||
repository, rinfo_arguments.repository
|
||||
):
|
||||
if not rinfo_arguments.json:
|
||||
logger.answer(
|
||||
f'{repository.get("label", repository["path"])}: Displaying repository summary information'
|
||||
)
|
||||
|
||||
json_output = borgmatic.borg.rinfo.display_repository_info(
|
||||
repository['path'],
|
||||
config,
|
||||
local_borg_version,
|
||||
rinfo_arguments=rinfo_arguments,
|
||||
global_arguments=global_arguments,
|
||||
local_path=local_path,
|
||||
remote_path=remote_path,
|
||||
)
|
||||
if json_output:
|
||||
yield borgmatic.actions.json.parse_json(json_output, repository.get('label'))
|
||||
@@ -17,9 +17,7 @@ def run_transfer(
|
||||
'''
|
||||
Run the "transfer" action for the given repository.
|
||||
'''
|
||||
logger.info(
|
||||
f'{repository.get("label", repository["path"])}: Transferring archives to repository'
|
||||
)
|
||||
logger.info('Transferring archives to repository')
|
||||
borgmatic.borg.transfer.transfer_archives(
|
||||
global_arguments.dry_run,
|
||||
repository['path'],
|
||||
|
||||
@@ -2,6 +2,7 @@ import logging
|
||||
import shlex
|
||||
|
||||
import borgmatic.commands.arguments
|
||||
import borgmatic.config.paths
|
||||
import borgmatic.logger
|
||||
from borgmatic.borg import environment, flags
|
||||
from borgmatic.execute import DO_NOT_CAPTURE, execute_command
|
||||
@@ -60,13 +61,14 @@ def run_arbitrary_borg(
|
||||
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 '',
|
||||
},
|
||||
),
|
||||
working_directory=borgmatic.config.paths.get_working_directory(config),
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=config.get('borg_exit_codes'),
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
|
||||
import borgmatic.config.paths
|
||||
from borgmatic.borg import environment, flags
|
||||
from borgmatic.execute import execute_command
|
||||
|
||||
@@ -33,10 +34,10 @@ def break_lock(
|
||||
+ flags.make_repository_flags(repository_path, local_borg_version)
|
||||
)
|
||||
|
||||
borg_environment = environment.make_environment(config)
|
||||
execute_command(
|
||||
full_command,
|
||||
extra_environment=borg_environment,
|
||||
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,67 @@
|
||||
import logging
|
||||
|
||||
import borgmatic.config.paths
|
||||
import borgmatic.execute
|
||||
import borgmatic.logger
|
||||
from borgmatic.borg import environment, flags
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def change_passphrase(
|
||||
repository_path,
|
||||
config,
|
||||
local_borg_version,
|
||||
change_passphrase_arguments,
|
||||
global_arguments,
|
||||
local_path='borg',
|
||||
remote_path=None,
|
||||
):
|
||||
'''
|
||||
Given a local or remote repository path, a configuration dict, the local Borg version, change
|
||||
passphrase arguments, and optional local and remote Borg paths, change the repository passphrase
|
||||
based on an interactive prompt.
|
||||
'''
|
||||
borgmatic.logger.add_custom_log_levels()
|
||||
umask = config.get('umask', None)
|
||||
lock_wait = config.get('lock_wait', None)
|
||||
|
||||
full_command = (
|
||||
(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 ())
|
||||
+ (('--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_repository_flags(
|
||||
repository_path,
|
||||
local_borg_version,
|
||||
)
|
||||
)
|
||||
|
||||
if global_arguments.dry_run:
|
||||
logger.info('Skipping change password (dry run)')
|
||||
return
|
||||
|
||||
# If the original passphrase is set programmatically, then Borg won't prompt for a new one! So
|
||||
# don't give Borg any passphrase, and it'll ask the user for both old and new ones.
|
||||
config_without_passphrase = {
|
||||
option_name: value
|
||||
for (option_name, value) in config.items()
|
||||
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,
|
||||
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)"
|
||||
)
|
||||
+90
-373
@@ -1,172 +1,27 @@
|
||||
import argparse
|
||||
import datetime
|
||||
import hashlib
|
||||
import itertools
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
|
||||
from borgmatic.borg import environment, extract, feature, flags, rinfo, state
|
||||
import borgmatic.config.paths
|
||||
from borgmatic.borg import environment, feature, flags, repo_info
|
||||
from borgmatic.execute import DO_NOT_CAPTURE, execute_command
|
||||
|
||||
DEFAULT_CHECKS = (
|
||||
{'name': 'repository', 'frequency': '1 month'},
|
||||
{'name': 'archives', 'frequency': '1 month'},
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def parse_checks(config, only_checks=None):
|
||||
def make_archive_filter_flags(local_borg_version, config, checks, check_arguments):
|
||||
'''
|
||||
Given a configuration dict with a "checks" sequence of dicts and an optional list of override
|
||||
checks, return a tuple of named checks to run.
|
||||
Given the local Borg version, a configuration dict, a parsed sequence of checks, and check
|
||||
arguments as an argparse.Namespace instance, transform the checks into tuple of command-line
|
||||
flags for filtering archives in a check command.
|
||||
|
||||
For example, given a config of:
|
||||
|
||||
{'checks': ({'name': 'repository'}, {'name': 'archives'})}
|
||||
|
||||
This will be returned as:
|
||||
|
||||
('repository', 'archives')
|
||||
|
||||
If no "checks" option is present in the config, return the DEFAULT_CHECKS. If a checks value
|
||||
has a name of "disabled", return an empty tuple, meaning that no checks should be run.
|
||||
If "check_last" is set in the configuration and "archives" is in checks, then include a "--last"
|
||||
flag. And if "prefix" is set in configuration and "archives" is in checks, then include a
|
||||
"--match-archives" flag.
|
||||
'''
|
||||
checks = only_checks or tuple(
|
||||
check_config['name'] for check_config in (config.get('checks', None) or DEFAULT_CHECKS)
|
||||
)
|
||||
checks = tuple(check.lower() for check in checks)
|
||||
check_last = config.get('check_last', None)
|
||||
prefix = config.get('prefix')
|
||||
|
||||
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'
|
||||
)
|
||||
if len(checks) > 1:
|
||||
logger.warning(
|
||||
'Multiple checks are configured, but one of them is "disabled"; not running any checks'
|
||||
)
|
||||
return ()
|
||||
|
||||
return checks
|
||||
|
||||
|
||||
def parse_frequency(frequency):
|
||||
'''
|
||||
Given a frequency string with a number and a unit of time, return a corresponding
|
||||
datetime.timedelta instance or None if the frequency is None or "always".
|
||||
|
||||
For instance, given "3 weeks", return datetime.timedelta(weeks=3)
|
||||
|
||||
Raise ValueError if the given frequency cannot be parsed.
|
||||
'''
|
||||
if not frequency:
|
||||
return None
|
||||
|
||||
frequency = frequency.strip().lower()
|
||||
|
||||
if frequency == 'always':
|
||||
return None
|
||||
|
||||
try:
|
||||
number, time_unit = frequency.split(' ')
|
||||
number = int(number)
|
||||
except ValueError:
|
||||
raise ValueError(f"Could not parse consistency check frequency '{frequency}'")
|
||||
|
||||
if not time_unit.endswith('s'):
|
||||
time_unit += 's'
|
||||
|
||||
if time_unit == 'months':
|
||||
number *= 30
|
||||
time_unit = 'days'
|
||||
elif time_unit == 'years':
|
||||
number *= 365
|
||||
time_unit = 'days'
|
||||
|
||||
try:
|
||||
return datetime.timedelta(**{time_unit: number})
|
||||
except TypeError:
|
||||
raise ValueError(f"Could not parse consistency check frequency '{frequency}'")
|
||||
|
||||
|
||||
def filter_checks_on_frequency(
|
||||
config,
|
||||
borg_repository_id,
|
||||
checks,
|
||||
force,
|
||||
archives_check_id=None,
|
||||
):
|
||||
'''
|
||||
Given a configuration dict with a "checks" sequence of dicts, a Borg repository ID, a sequence
|
||||
of checks, whether to force checks to run, and an ID for the archives check potentially being
|
||||
run (if any), filter down those checks based on the configured "frequency" for each check as
|
||||
compared to its check time file.
|
||||
|
||||
In other words, a check whose check time file's timestamp is too new (based on the configured
|
||||
frequency) will get cut from the returned sequence of checks. Example:
|
||||
|
||||
config = {
|
||||
'checks': [
|
||||
{
|
||||
'name': 'archives',
|
||||
'frequency': '2 weeks',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
When this function is called with that config and "archives" in checks, "archives" will get
|
||||
filtered out of the returned result if its check time file is newer than 2 weeks old, indicating
|
||||
that it's not yet time to run that check again.
|
||||
|
||||
Raise ValueError if a frequency cannot be parsed.
|
||||
'''
|
||||
if not checks:
|
||||
return checks
|
||||
|
||||
filtered_checks = list(checks)
|
||||
|
||||
if force:
|
||||
return tuple(filtered_checks)
|
||||
|
||||
for check_config in config.get('checks', DEFAULT_CHECKS):
|
||||
check = check_config['name']
|
||||
if checks and check not in checks:
|
||||
continue
|
||||
|
||||
frequency_delta = parse_frequency(check_config.get('frequency'))
|
||||
if not frequency_delta:
|
||||
continue
|
||||
|
||||
check_time = probe_for_check_time(config, borg_repository_id, check, archives_check_id)
|
||||
if not check_time:
|
||||
continue
|
||||
|
||||
# If we've not yet reached the time when the frequency dictates we're ready for another
|
||||
# check, skip this check.
|
||||
if datetime.datetime.now() < check_time + frequency_delta:
|
||||
remaining = check_time + frequency_delta - datetime.datetime.now()
|
||||
logger.info(
|
||||
f'Skipping {check} check due to configured frequency; {remaining} until next check (use --force to check anyway)'
|
||||
)
|
||||
filtered_checks.remove(check)
|
||||
|
||||
return tuple(filtered_checks)
|
||||
|
||||
|
||||
def make_archive_filter_flags(
|
||||
local_borg_version, config, checks, check_arguments, check_last=None, prefix=None
|
||||
):
|
||||
'''
|
||||
Given the local Borg version, a configuration dict, a parsed sequence of checks, check arguments
|
||||
as an argparse.Namespace instance, the check last value, and a consistency check prefix,
|
||||
transform the checks into tuple of command-line flags for filtering archives in a check command.
|
||||
|
||||
If a check_last value is given and "archives" is in checks, then include a "--last" flag. And if
|
||||
a prefix value is given and "archives" is in checks, then include a "--match-archives" flag.
|
||||
'''
|
||||
if 'archives' in checks or 'data' in checks:
|
||||
return (('--last', str(check_last)) if check_last else ()) + (
|
||||
(
|
||||
@@ -196,21 +51,10 @@ def make_archive_filter_flags(
|
||||
return ()
|
||||
|
||||
|
||||
def make_archives_check_id(archive_filter_flags):
|
||||
def make_check_name_flags(checks, archive_filter_flags):
|
||||
'''
|
||||
Given a sequence of flags to filter archives, return a unique hash corresponding to those
|
||||
particular flags. If there are no flags, return None.
|
||||
'''
|
||||
if not archive_filter_flags:
|
||||
return None
|
||||
|
||||
return hashlib.sha256(' '.join(archive_filter_flags).encode()).hexdigest()
|
||||
|
||||
|
||||
def make_check_flags(checks, archive_filter_flags):
|
||||
'''
|
||||
Given a parsed sequence of checks and a sequence of flags to filter archives, transform the
|
||||
checks into tuple of command-line check flags.
|
||||
Given parsed checks set and a sequence of flags to filter archives, transform the checks into
|
||||
tuple of command-line check flags.
|
||||
|
||||
For example, given parsed checks of:
|
||||
|
||||
@@ -220,18 +64,14 @@ def make_check_flags(checks, archive_filter_flags):
|
||||
|
||||
('--repository-only',)
|
||||
|
||||
However, if both "repository" and "archives" are in checks, then omit them from the returned
|
||||
flags because Borg does both checks by default. If "data" is in checks, that implies "archives".
|
||||
However, if both "repository" and "archives" are in checks, then omit the "only" flags from the
|
||||
returned flags because Borg does both checks by default. Note that a "data" check only works
|
||||
along with an "archives" check.
|
||||
'''
|
||||
if 'data' in checks:
|
||||
data_flags = ('--verify-data',)
|
||||
checks += ('archives',)
|
||||
else:
|
||||
data_flags = ()
|
||||
|
||||
data_flags = ('--verify-data',) if 'data' in checks else ()
|
||||
common_flags = (archive_filter_flags if 'archives' in checks else ()) + data_flags
|
||||
|
||||
if {'repository', 'archives'}.issubset(set(checks)):
|
||||
if {'repository', 'archives'}.issubset(checks):
|
||||
return common_flags
|
||||
|
||||
return (
|
||||
@@ -240,145 +80,18 @@ def make_check_flags(checks, archive_filter_flags):
|
||||
)
|
||||
|
||||
|
||||
def make_check_time_path(config, borg_repository_id, check_type, archives_check_id=None):
|
||||
'''
|
||||
Given a configuration dict, a Borg repository ID, the name of a check type ("repository",
|
||||
"archives", etc.), and a unique hash of the archives filter flags, return a path for recording
|
||||
that check's time (the time of that check last occurring).
|
||||
'''
|
||||
borgmatic_source_directory = os.path.expanduser(
|
||||
config.get('borgmatic_source_directory', state.DEFAULT_BORGMATIC_SOURCE_DIRECTORY)
|
||||
)
|
||||
|
||||
if check_type in ('archives', 'data'):
|
||||
return os.path.join(
|
||||
borgmatic_source_directory,
|
||||
'checks',
|
||||
borg_repository_id,
|
||||
check_type,
|
||||
archives_check_id if archives_check_id else 'all',
|
||||
)
|
||||
|
||||
return os.path.join(
|
||||
borgmatic_source_directory,
|
||||
'checks',
|
||||
borg_repository_id,
|
||||
check_type,
|
||||
)
|
||||
|
||||
|
||||
def write_check_time(path): # pragma: no cover
|
||||
'''
|
||||
Record a check time of now as the modification time of the given path.
|
||||
'''
|
||||
logger.debug(f'Writing check time at {path}')
|
||||
|
||||
os.makedirs(os.path.dirname(path), mode=0o700, exist_ok=True)
|
||||
pathlib.Path(path, mode=0o600).touch()
|
||||
|
||||
|
||||
def read_check_time(path):
|
||||
'''
|
||||
Return the check time based on the modification time of the given path. Return None if the path
|
||||
doesn't exist.
|
||||
'''
|
||||
logger.debug(f'Reading check time from {path}')
|
||||
|
||||
try:
|
||||
return datetime.datetime.fromtimestamp(os.stat(path).st_mtime)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
|
||||
|
||||
def probe_for_check_time(config, borg_repository_id, check, archives_check_id):
|
||||
'''
|
||||
Given a configuration dict, a Borg repository ID, the name of a check type ("repository",
|
||||
"archives", etc.), and a unique hash of the archives filter flags, return a the corresponding
|
||||
check time or None if such a check time does not exist.
|
||||
|
||||
When the check type is "archives" or "data", this function probes two different paths to find
|
||||
the check time, e.g.:
|
||||
|
||||
~/.borgmatic/checks/1234567890/archives/9876543210
|
||||
~/.borgmatic/checks/1234567890/archives/all
|
||||
|
||||
... and returns the maximum modification time of the files found (if any). The first path
|
||||
represents a more specific archives check time (a check on a subset of archives), and the second
|
||||
is a fallback to the last "all" archives check.
|
||||
|
||||
For other check types, this function reads from a single check time path, e.g.:
|
||||
|
||||
~/.borgmatic/checks/1234567890/repository
|
||||
'''
|
||||
check_times = (
|
||||
read_check_time(group[0])
|
||||
for group in itertools.groupby(
|
||||
(
|
||||
make_check_time_path(config, borg_repository_id, check, archives_check_id),
|
||||
make_check_time_path(config, borg_repository_id, check),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
return max(check_time for check_time in check_times if check_time)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def upgrade_check_times(config, borg_repository_id):
|
||||
'''
|
||||
Given a configuration dict and a Borg repository ID, upgrade any corresponding check times on
|
||||
disk from old-style paths to new-style paths.
|
||||
|
||||
Currently, the only upgrade performed is renaming an archive or data check path that looks like:
|
||||
|
||||
~/.borgmatic/checks/1234567890/archives
|
||||
|
||||
to:
|
||||
|
||||
~/.borgmatic/checks/1234567890/archives/all
|
||||
'''
|
||||
for check_type in ('archives', 'data'):
|
||||
new_path = make_check_time_path(config, borg_repository_id, check_type, 'all')
|
||||
old_path = os.path.dirname(new_path)
|
||||
temporary_path = f'{old_path}.temp'
|
||||
|
||||
if not os.path.isfile(old_path) and not os.path.isfile(temporary_path):
|
||||
continue
|
||||
|
||||
logger.debug(f'Upgrading archives check time from {old_path} to {new_path}')
|
||||
|
||||
try:
|
||||
os.rename(old_path, temporary_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
os.mkdir(old_path)
|
||||
os.rename(temporary_path, new_path)
|
||||
|
||||
|
||||
def check_archives(
|
||||
repository_path,
|
||||
config,
|
||||
local_borg_version,
|
||||
check_arguments,
|
||||
global_arguments,
|
||||
local_path='borg',
|
||||
remote_path=None,
|
||||
def get_repository_id(
|
||||
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, check
|
||||
arguments as an argparse.Namespace instance, global arguments, and local/remote commands to run,
|
||||
check the contained Borg archives for consistency.
|
||||
Given a local or remote repository path, a configuration dict, the local Borg version, global
|
||||
arguments, and local/remote commands to run, return the corresponding Borg repository ID.
|
||||
|
||||
If there are no consistency checks to run, skip running them.
|
||||
|
||||
Raises ValueError if the Borg repository ID cannot be determined.
|
||||
Raise ValueError if the Borg repository ID cannot be determined.
|
||||
'''
|
||||
try:
|
||||
borg_repository_id = json.loads(
|
||||
rinfo.display_repository_info(
|
||||
return json.loads(
|
||||
repo_info.display_repository_info(
|
||||
repository_path,
|
||||
config,
|
||||
local_borg_version,
|
||||
@@ -391,40 +104,69 @@ def check_archives(
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
raise ValueError(f'Cannot determine Borg repository ID for {repository_path}')
|
||||
|
||||
upgrade_check_times(config, borg_repository_id)
|
||||
|
||||
check_last = config.get('check_last', None)
|
||||
prefix = config.get('prefix')
|
||||
configured_checks = parse_checks(config, check_arguments.only_checks)
|
||||
lock_wait = None
|
||||
def check_archives(
|
||||
repository_path,
|
||||
config,
|
||||
local_borg_version,
|
||||
check_arguments,
|
||||
global_arguments,
|
||||
checks,
|
||||
archive_filter_flags,
|
||||
local_path='borg',
|
||||
remote_path=None,
|
||||
):
|
||||
'''
|
||||
Given a local or remote repository path, a configuration dict, the local Borg version, check
|
||||
arguments as an argparse.Namespace instance, global arguments, a set of named Borg checks to run
|
||||
(some combination "repository", "archives", and/or "data"), archive filter flags, and
|
||||
local/remote commands to run, check the contained Borg archives for consistency.
|
||||
'''
|
||||
lock_wait = config.get('lock_wait')
|
||||
extra_borg_options = config.get('extra_borg_options', {}).get('check', '')
|
||||
archive_filter_flags = make_archive_filter_flags(
|
||||
local_borg_version, config, configured_checks, check_arguments, check_last, prefix
|
||||
)
|
||||
archives_check_id = make_archives_check_id(archive_filter_flags)
|
||||
|
||||
checks = filter_checks_on_frequency(
|
||||
config,
|
||||
borg_repository_id,
|
||||
configured_checks,
|
||||
check_arguments.force,
|
||||
archives_check_id,
|
||||
)
|
||||
verbosity_flags = ()
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
verbosity_flags = ('--info',)
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
verbosity_flags = ('--debug', '--show-rc')
|
||||
|
||||
if set(checks).intersection({'repository', 'archives', 'data'}):
|
||||
lock_wait = config.get('lock_wait')
|
||||
try:
|
||||
repository_check_config = next(
|
||||
check for check in config.get('checks', ()) if check.get('name') == 'repository'
|
||||
)
|
||||
except StopIteration:
|
||||
repository_check_config = {}
|
||||
|
||||
verbosity_flags = ()
|
||||
if logger.isEnabledFor(logging.INFO):
|
||||
verbosity_flags = ('--info',)
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
verbosity_flags = ('--debug', '--show-rc')
|
||||
max_duration = check_arguments.max_duration or repository_check_config.get('max_duration')
|
||||
|
||||
umask = config.get('umask')
|
||||
borg_exit_codes = config.get('borg_exit_codes')
|
||||
working_directory = borgmatic.config.paths.get_working_directory(config)
|
||||
|
||||
if 'data' in checks:
|
||||
checks.add('archives')
|
||||
|
||||
grouped_checks = (checks,)
|
||||
|
||||
# If max_duration is set, then archives and repository checks need to be run separately, as Borg
|
||||
# doesn't support --max-duration along with an archives checks.
|
||||
if max_duration and 'archives' in checks and 'repository' in checks:
|
||||
checks.remove('repository')
|
||||
grouped_checks = (checks, {'repository'})
|
||||
|
||||
for checks_subset in grouped_checks:
|
||||
full_command = (
|
||||
(local_path, 'check')
|
||||
+ (('--repair',) if check_arguments.repair else ())
|
||||
+ make_check_flags(checks, archive_filter_flags)
|
||||
+ (
|
||||
('--max-duration', str(max_duration))
|
||||
if max_duration and 'repository' in checks_subset
|
||||
else ()
|
||||
)
|
||||
+ 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 ())
|
||||
+ (('--lock-wait', str(lock_wait)) if lock_wait else ())
|
||||
+ verbosity_flags
|
||||
@@ -433,40 +175,15 @@ def check_archives(
|
||||
+ flags.make_repository_flags(repository_path, local_borg_version)
|
||||
)
|
||||
|
||||
borg_environment = environment.make_environment(config)
|
||||
borg_exit_codes = config.get('borg_exit_codes')
|
||||
|
||||
# The Borg repair option triggers an interactive prompt, which won't work when output is
|
||||
# captured. And progress messes with the terminal directly.
|
||||
if check_arguments.repair or check_arguments.progress:
|
||||
execute_command(
|
||||
full_command,
|
||||
output_file=DO_NOT_CAPTURE,
|
||||
extra_environment=borg_environment,
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=borg_exit_codes,
|
||||
)
|
||||
else:
|
||||
execute_command(
|
||||
full_command,
|
||||
extra_environment=borg_environment,
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=borg_exit_codes,
|
||||
)
|
||||
|
||||
for check in checks:
|
||||
write_check_time(
|
||||
make_check_time_path(config, borg_repository_id, check, archives_check_id)
|
||||
)
|
||||
|
||||
if 'extract' in checks:
|
||||
extract.extract_last_archive_dry_run(
|
||||
config,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
repository_path,
|
||||
lock_wait,
|
||||
local_path,
|
||||
remote_path,
|
||||
execute_command(
|
||||
full_command,
|
||||
# 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
|
||||
),
|
||||
environment=environment.make_environment(config),
|
||||
working_directory=working_directory,
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=borg_exit_codes,
|
||||
)
|
||||
write_check_time(make_check_time_path(config, borg_repository_id, 'extract'))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
|
||||
import borgmatic.config.paths
|
||||
from borgmatic.borg import environment, flags
|
||||
from borgmatic.execute import execute_command
|
||||
|
||||
@@ -42,13 +43,14 @@ def compact_segments(
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
logging.info(f'{repository_path}: Skipping compact (dry run)')
|
||||
logging.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'),
|
||||
)
|
||||
|
||||
+201
-284
@@ -1,13 +1,15 @@
|
||||
import glob
|
||||
import itertools
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
import stat
|
||||
import tempfile
|
||||
import textwrap
|
||||
|
||||
import borgmatic.borg.pattern
|
||||
import borgmatic.config.paths
|
||||
import borgmatic.logger
|
||||
from borgmatic.borg import environment, feature, flags, state
|
||||
from borgmatic.borg import environment, feature, flags
|
||||
from borgmatic.execute import (
|
||||
DO_NOT_CAPTURE,
|
||||
execute_command,
|
||||
@@ -18,163 +20,42 @@ from borgmatic.execute import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def expand_directory(directory):
|
||||
def write_patterns_file(patterns, borgmatic_runtime_directory, patterns_file=None):
|
||||
'''
|
||||
Given a directory path, expand any tilde (representing a user's home directory) and any globs
|
||||
therein. Return a list of one or more resulting paths.
|
||||
'''
|
||||
expanded_directory = os.path.expanduser(directory)
|
||||
Given a sequence of patterns as borgmatic.borg.pattern.Pattern instances, write them to a named
|
||||
temporary file in the given borgmatic runtime directory and return the file object so it can
|
||||
continue to exist on disk as long as the caller needs it.
|
||||
|
||||
return glob.glob(expanded_directory) or [expanded_directory]
|
||||
|
||||
|
||||
def expand_directories(directories):
|
||||
'''
|
||||
Given a sequence of directory paths, expand tildes and globs in each one. Return all the
|
||||
resulting directories as a single flattened tuple.
|
||||
'''
|
||||
if directories is None:
|
||||
return ()
|
||||
|
||||
return tuple(
|
||||
itertools.chain.from_iterable(expand_directory(directory) for directory in directories)
|
||||
)
|
||||
|
||||
|
||||
def expand_home_directories(directories):
|
||||
'''
|
||||
Given a sequence of directory paths, expand tildes in each one. Do not perform any globbing.
|
||||
Return the results as a tuple.
|
||||
'''
|
||||
if directories is None:
|
||||
return ()
|
||||
|
||||
return tuple(os.path.expanduser(directory) for directory in directories)
|
||||
|
||||
|
||||
def map_directories_to_devices(directories):
|
||||
'''
|
||||
Given a sequence of directories, return a map from directory to an identifier for the device on
|
||||
which that directory resides or None if the path doesn't exist.
|
||||
|
||||
This is handy for determining whether two different directories are on the same filesystem (have
|
||||
the same device identifier).
|
||||
'''
|
||||
return {
|
||||
directory: os.stat(directory).st_dev if os.path.exists(directory) else None
|
||||
for directory in directories
|
||||
}
|
||||
|
||||
|
||||
def deduplicate_directories(directory_devices, additional_directory_devices):
|
||||
'''
|
||||
Given a map from directory to the identifier for the device on which that directory resides,
|
||||
return the directories as a sorted tuple with all duplicate child directories removed. For
|
||||
instance, if paths is ('/foo', '/foo/bar'), return just: ('/foo',)
|
||||
|
||||
The one exception to this rule is if two paths are on different filesystems (devices). In that
|
||||
case, they won't get de-duplicated in case they both need to be passed to Borg (e.g. the
|
||||
location.one_file_system option is true).
|
||||
|
||||
The idea is that if Borg is given a parent directory, then it doesn't also need to be given
|
||||
child directories, because it will naturally spider the contents of the parent directory. And
|
||||
there are cases where Borg coming across the same file twice will result in duplicate reads and
|
||||
even hangs, e.g. when a database hook is using a named pipe for streaming database dumps to
|
||||
Borg.
|
||||
|
||||
If any additional directory devices are given, also deduplicate against them, but don't include
|
||||
them in the returned directories.
|
||||
'''
|
||||
deduplicated = set()
|
||||
directories = sorted(directory_devices.keys())
|
||||
additional_directories = sorted(additional_directory_devices.keys())
|
||||
all_devices = {**directory_devices, **additional_directory_devices}
|
||||
|
||||
for directory in directories:
|
||||
deduplicated.add(directory)
|
||||
parents = pathlib.PurePath(directory).parents
|
||||
|
||||
# If another directory in the given list (or the additional list) is a parent of current
|
||||
# directory (even n levels up) and both are on the same filesystem, then the current
|
||||
# directory is a duplicate.
|
||||
for other_directory in directories + additional_directories:
|
||||
for parent in parents:
|
||||
if (
|
||||
pathlib.PurePath(other_directory) == parent
|
||||
and all_devices[directory] is not None
|
||||
and all_devices[other_directory] == all_devices[directory]
|
||||
):
|
||||
if directory in deduplicated:
|
||||
deduplicated.remove(directory)
|
||||
break
|
||||
|
||||
return tuple(sorted(deduplicated))
|
||||
|
||||
|
||||
def write_pattern_file(patterns=None, sources=None, pattern_file=None):
|
||||
'''
|
||||
Given a sequence of patterns and an optional sequence of source directories, write them to a
|
||||
named temporary file (with the source directories as additional roots) and return the file.
|
||||
If an optional open pattern file is given, overwrite it instead of making a new temporary file.
|
||||
If an optional open pattern file is given, append to it instead of making a new temporary file.
|
||||
Return None if no patterns are provided.
|
||||
'''
|
||||
if not patterns and not sources:
|
||||
if not patterns:
|
||||
return None
|
||||
|
||||
if pattern_file is None:
|
||||
pattern_file = tempfile.NamedTemporaryFile('w')
|
||||
if patterns_file is None:
|
||||
patterns_file = tempfile.NamedTemporaryFile('w', dir=borgmatic_runtime_directory)
|
||||
operation_name = 'Writing'
|
||||
else:
|
||||
pattern_file.seek(0)
|
||||
patterns_file.write('\n')
|
||||
operation_name = 'Appending'
|
||||
|
||||
pattern_file.write(
|
||||
'\n'.join(tuple(patterns or ()) + tuple(f'R {source}' for source in (sources or [])))
|
||||
patterns_output = '\n'.join(
|
||||
f'{pattern.type.value} {pattern.style.value}{":" if pattern.style.value else ""}{pattern.path}'
|
||||
for pattern in patterns
|
||||
)
|
||||
pattern_file.flush()
|
||||
logger.debug(f'{operation_name} patterns to {patterns_file.name}:\n{patterns_output}')
|
||||
|
||||
return pattern_file
|
||||
patterns_file.write(patterns_output)
|
||||
patterns_file.flush()
|
||||
|
||||
return patterns_file
|
||||
|
||||
|
||||
def ensure_files_readable(*filename_lists):
|
||||
def make_exclude_flags(config):
|
||||
'''
|
||||
Given a sequence of filename sequences, ensure that each filename is openable. This prevents
|
||||
unreadable files from being passed to Borg, which in certain situations only warns instead of
|
||||
erroring.
|
||||
Given a configuration dict with various exclude options, return the corresponding Borg flags as
|
||||
a tuple.
|
||||
'''
|
||||
for file_object in itertools.chain.from_iterable(
|
||||
filename_list for filename_list in filename_lists if filename_list
|
||||
):
|
||||
open(file_object).close()
|
||||
|
||||
|
||||
def make_pattern_flags(config, pattern_filename=None):
|
||||
'''
|
||||
Given a configuration dict with a potential patterns_from option, and a filename containing any
|
||||
additional patterns, return the corresponding Borg flags for those files as a tuple.
|
||||
'''
|
||||
pattern_filenames = tuple(config.get('patterns_from') or ()) + (
|
||||
(pattern_filename,) if pattern_filename else ()
|
||||
)
|
||||
|
||||
return tuple(
|
||||
itertools.chain.from_iterable(
|
||||
('--patterns-from', pattern_filename) for pattern_filename in pattern_filenames
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def make_exclude_flags(config, exclude_filename=None):
|
||||
'''
|
||||
Given a configuration dict with various exclude options, and a filename containing any exclude
|
||||
patterns, return the corresponding Borg flags as a tuple.
|
||||
'''
|
||||
exclude_filenames = tuple(config.get('exclude_from') or ()) + (
|
||||
(exclude_filename,) if exclude_filename else ()
|
||||
)
|
||||
exclude_from_flags = tuple(
|
||||
itertools.chain.from_iterable(
|
||||
('--exclude-from', exclude_filename) for exclude_filename in exclude_filenames
|
||||
)
|
||||
)
|
||||
caches_flag = ('--exclude-caches',) if config.get('exclude_caches') else ()
|
||||
if_present_flags = tuple(
|
||||
itertools.chain.from_iterable(
|
||||
@@ -185,13 +66,7 @@ def make_exclude_flags(config, exclude_filename=None):
|
||||
keep_exclude_tags_flags = ('--keep-exclude-tags',) if config.get('keep_exclude_tags') else ()
|
||||
exclude_nodump_flags = ('--exclude-nodump',) if config.get('exclude_nodump') else ()
|
||||
|
||||
return (
|
||||
exclude_from_flags
|
||||
+ caches_flag
|
||||
+ if_present_flags
|
||||
+ keep_exclude_tags_flags
|
||||
+ exclude_nodump_flags
|
||||
)
|
||||
return caches_flag + if_present_flags + keep_exclude_tags_flags + exclude_nodump_flags
|
||||
|
||||
|
||||
def make_list_filter_flags(local_borg_version, dry_run):
|
||||
@@ -215,44 +90,14 @@ def make_list_filter_flags(local_borg_version, dry_run):
|
||||
return f'{base_flags}-'
|
||||
|
||||
|
||||
def collect_borgmatic_source_directories(borgmatic_source_directory):
|
||||
'''
|
||||
Return a list of borgmatic-specific source directories used for state like database backups.
|
||||
'''
|
||||
if not borgmatic_source_directory:
|
||||
borgmatic_source_directory = state.DEFAULT_BORGMATIC_SOURCE_DIRECTORY
|
||||
|
||||
return (
|
||||
[borgmatic_source_directory]
|
||||
if os.path.exists(os.path.expanduser(borgmatic_source_directory))
|
||||
else []
|
||||
)
|
||||
|
||||
|
||||
ROOT_PATTERN_PREFIX = 'R '
|
||||
|
||||
|
||||
def pattern_root_directories(patterns=None):
|
||||
'''
|
||||
Given a sequence of patterns, parse out and return just the root directories.
|
||||
'''
|
||||
if not patterns:
|
||||
return []
|
||||
|
||||
return [
|
||||
pattern.split(ROOT_PATTERN_PREFIX, maxsplit=1)[1]
|
||||
for pattern in patterns
|
||||
if pattern.startswith(ROOT_PATTERN_PREFIX)
|
||||
]
|
||||
|
||||
|
||||
def special_file(path):
|
||||
def special_file(path, working_directory=None):
|
||||
'''
|
||||
Return whether the given path is a special file (character device, block device, or named pipe
|
||||
/ FIFO).
|
||||
/ FIFO). If a working directory is given, take it into account when making the full path to
|
||||
check.
|
||||
'''
|
||||
try:
|
||||
mode = os.stat(path).st_mode
|
||||
mode = os.stat(os.path.join(working_directory or '', path)).st_mode
|
||||
except (FileNotFoundError, OSError):
|
||||
return False
|
||||
|
||||
@@ -272,119 +117,131 @@ def any_parent_directories(path, candidate_parents):
|
||||
|
||||
|
||||
def collect_special_file_paths(
|
||||
create_command, config, local_path, working_directory, borg_environment, skip_directories
|
||||
dry_run,
|
||||
create_command,
|
||||
config,
|
||||
local_path,
|
||||
working_directory,
|
||||
borgmatic_runtime_directory,
|
||||
):
|
||||
'''
|
||||
Given a Borg create command as a tuple, a local Borg path, a working directory, a dict of
|
||||
environment variables to pass to Borg, and a sequence of parent directories to skip, collect the
|
||||
paths for any special files (character devices, block devices, and named pipes / FIFOs) that
|
||||
Borg would encounter during a create. These are all paths that could cause Borg to hang if its
|
||||
--read-special flag is used.
|
||||
Given a dry-run flag, a Borg create command as a tuple, a configuration dict, a local Borg path,
|
||||
a working directory, and the borgmatic runtime directory, collect the paths for any special
|
||||
files (character devices, block devices, and named pipes / FIFOs) that Borg would encounter
|
||||
during a create. These are all paths that could cause Borg to hang if its --read-special flag is
|
||||
used.
|
||||
|
||||
Skip looking for special files in the given borgmatic runtime directory, as borgmatic creates
|
||||
its own special files there for database dumps and we don't want those omitted.
|
||||
|
||||
Additionally, if the borgmatic runtime directory is not contained somewhere in the files Borg
|
||||
plans to backup, that means the user must have excluded the runtime directory (e.g. via
|
||||
"exclude_patterns" or similar). Therefore, raise, because this means Borg won't be able to
|
||||
consume any database dumps and therefore borgmatic will hang when it tries to do so.
|
||||
'''
|
||||
# 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 th expected "- ".
|
||||
paths_output = execute_command_and_capture_output(
|
||||
tuple(argument for argument in create_command if argument != '--exclude-nodump')
|
||||
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=borg_environment,
|
||||
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('+ ')
|
||||
)
|
||||
|
||||
# These are the subset of those files that contain the borgmatic runtime directory.
|
||||
paths_containing_runtime_directory = {}
|
||||
|
||||
if os.path.exists(borgmatic_runtime_directory):
|
||||
paths_containing_runtime_directory = {
|
||||
path for path in paths if any_parent_directories(path, (borgmatic_runtime_directory,))
|
||||
}
|
||||
|
||||
# If no paths to backup contain the runtime directory, it must've been excluded.
|
||||
if not paths_containing_runtime_directory and not dry_run:
|
||||
raise ValueError(
|
||||
f'The runtime directory {os.path.normpath(borgmatic_runtime_directory)} overlaps with the configured excludes or patterns with excludes. Please ensure the runtime directory is not excluded.'
|
||||
)
|
||||
|
||||
return tuple(
|
||||
path
|
||||
for path in paths
|
||||
if special_file(path) and not any_parent_directories(path, skip_directories)
|
||||
if special_file(path, working_directory)
|
||||
if path not in paths_containing_runtime_directory
|
||||
)
|
||||
|
||||
|
||||
def check_all_source_directories_exist(source_directories):
|
||||
def check_all_root_patterns_exist(patterns):
|
||||
'''
|
||||
Given a sequence of source directories, check that they all exist. If any do not, raise an
|
||||
exception.
|
||||
Given a sequence of borgmatic.borg.pattern.Pattern instances, check that all root pattern
|
||||
paths exist. If any don't, raise an exception.
|
||||
'''
|
||||
missing_directories = [
|
||||
source_directory
|
||||
for source_directory in source_directories
|
||||
if not all([os.path.exists(directory) for directory in expand_directory(source_directory)])
|
||||
missing_paths = [
|
||||
pattern.path
|
||||
for pattern in patterns
|
||||
if pattern.type == borgmatic.borg.pattern.Pattern_type.ROOT
|
||||
if not os.path.exists(pattern.path)
|
||||
]
|
||||
if missing_directories:
|
||||
raise ValueError(f"Source directories do not exist: {', '.join(missing_directories)}")
|
||||
|
||||
if missing_paths:
|
||||
raise ValueError(
|
||||
f"Source directories / root pattern paths do not exist: {', '.join(missing_paths)}"
|
||||
)
|
||||
|
||||
|
||||
def create_archive(
|
||||
MAX_SPECIAL_FILE_PATHS_LENGTH = 1000
|
||||
|
||||
|
||||
def make_base_create_command(
|
||||
dry_run,
|
||||
repository_path,
|
||||
config,
|
||||
config_paths,
|
||||
patterns,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
borgmatic_runtime_directory,
|
||||
local_path='borg',
|
||||
remote_path=None,
|
||||
progress=False,
|
||||
stats=False,
|
||||
json=False,
|
||||
list_files=False,
|
||||
stream_processes=None,
|
||||
):
|
||||
'''
|
||||
Given vebosity/dry-run flags, a local or remote repository path, a configuration dict, a
|
||||
sequence of loaded configuration paths, the local Borg version, and global arguments as an
|
||||
argparse.Namespace instance, 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.
|
||||
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).
|
||||
'''
|
||||
borgmatic.logger.add_custom_log_levels()
|
||||
borgmatic_source_directories = expand_directories(
|
||||
collect_borgmatic_source_directories(config.get('borgmatic_source_directory'))
|
||||
)
|
||||
if config.get('source_directories_must_exist', False):
|
||||
check_all_source_directories_exist(config.get('source_directories'))
|
||||
sources = deduplicate_directories(
|
||||
map_directories_to_devices(
|
||||
expand_directories(
|
||||
tuple(config.get('source_directories', ()))
|
||||
+ borgmatic_source_directories
|
||||
+ tuple(config_paths if config.get('store_config_files', True) else ())
|
||||
)
|
||||
),
|
||||
additional_directory_devices=map_directories_to_devices(
|
||||
expand_directories(pattern_root_directories(config.get('patterns')))
|
||||
),
|
||||
)
|
||||
check_all_root_patterns_exist(patterns)
|
||||
|
||||
ensure_files_readable(config.get('patterns_from'), config.get('exclude_from'))
|
||||
|
||||
try:
|
||||
working_directory = os.path.expanduser(config.get('working_directory'))
|
||||
except TypeError:
|
||||
working_directory = None
|
||||
|
||||
pattern_file = (
|
||||
write_pattern_file(config.get('patterns'), sources)
|
||||
if config.get('patterns') or config.get('patterns_from')
|
||||
else None
|
||||
)
|
||||
exclude_file = write_pattern_file(expand_home_directories(config.get('exclude_patterns')))
|
||||
patterns_file = write_patterns_file(patterns, borgmatic_runtime_directory)
|
||||
checkpoint_interval = config.get('checkpoint_interval', None)
|
||||
checkpoint_volume = config.get('checkpoint_volume', None)
|
||||
chunker_params = config.get('chunker_params', None)
|
||||
compression = config.get('compression', None)
|
||||
upload_rate_limit = config.get('upload_rate_limit', None)
|
||||
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)
|
||||
files_cache = config.get('files_cache')
|
||||
archive_name_format = config.get('archive_name_format', flags.DEFAULT_ARCHIVE_NAME_FORMAT)
|
||||
archive_name_format = config.get(
|
||||
'archive_name_format', flags.get_default_archive_name_format(local_borg_version)
|
||||
)
|
||||
extra_borg_options = config.get('extra_borg_options', {}).get('create', '')
|
||||
|
||||
if feature.available(feature.Feature.ATIME, local_borg_version):
|
||||
@@ -411,22 +268,18 @@ def create_archive(
|
||||
('--remote-ratelimit', str(upload_rate_limit)) if upload_rate_limit else ()
|
||||
)
|
||||
|
||||
if stream_processes and config.get('read_special') is False:
|
||||
logger.warning(
|
||||
f'{repository_path}: Ignoring configured "read_special" value of false, as true is needed for database hooks.'
|
||||
)
|
||||
|
||||
create_flags = (
|
||||
tuple(local_path.split(' '))
|
||||
+ ('create',)
|
||||
+ make_pattern_flags(config, pattern_file.name if pattern_file else None)
|
||||
+ make_exclude_flags(config, exclude_file.name if exclude_file else None)
|
||||
+ (('--patterns-from', patterns_file.name) if patterns_file else ())
|
||||
+ make_exclude_flags(config)
|
||||
+ (('--checkpoint-interval', str(checkpoint_interval)) if checkpoint_interval else ())
|
||||
+ (('--checkpoint-volume', str(checkpoint_volume)) if checkpoint_volume else ())
|
||||
+ (('--chunker-params', chunker_params) if chunker_params else ())
|
||||
+ (('--compression', compression) if compression else ())
|
||||
+ upload_ratelimit_flags
|
||||
+ (('--one-file-system',) if config.get('one_file_system') or stream_processes else ())
|
||||
+ (('--upload-buffer', str(upload_buffer_size)) if upload_buffer_size else ())
|
||||
+ (('--one-file-system',) if config.get('one_file_system') else ())
|
||||
+ numeric_ids_flags
|
||||
+ atime_flags
|
||||
+ (('--noctime',) if config.get('ctime') is False else ())
|
||||
@@ -449,7 +302,98 @@ def create_archive(
|
||||
|
||||
create_positional_arguments = flags.make_repository_archive_flags(
|
||||
repository_path, archive_name_format, local_borg_version
|
||||
) + (sources if not pattern_file else ())
|
||||
)
|
||||
|
||||
# If database hooks are enabled (as indicated by streaming processes), exclude files that might
|
||||
# cause Borg to hang. But skip this if the user has explicitly set the "read_special" to True.
|
||||
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.'
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
if special_file_paths:
|
||||
truncated_special_file_paths = textwrap.shorten(
|
||||
', '.join(special_file_paths),
|
||||
width=MAX_SPECIAL_FILE_PATHS_LENGTH,
|
||||
placeholder=' ...',
|
||||
)
|
||||
logger.warning(
|
||||
f'Excluding special files to prevent Borg from hanging: {truncated_special_file_paths}'
|
||||
)
|
||||
patterns_file = write_patterns_file(
|
||||
tuple(
|
||||
borgmatic.borg.pattern.Pattern(
|
||||
special_file_path,
|
||||
borgmatic.borg.pattern.Pattern_type.NO_RECURSE,
|
||||
borgmatic.borg.pattern.Pattern_style.FNMATCH,
|
||||
source=borgmatic.borg.pattern.Pattern_source.INTERNAL,
|
||||
)
|
||||
for special_file_path in special_file_paths
|
||||
),
|
||||
borgmatic_runtime_directory,
|
||||
patterns_file=patterns_file,
|
||||
)
|
||||
|
||||
if '--patterns-from' not in create_flags:
|
||||
create_flags += ('--patterns-from', patterns_file.name)
|
||||
|
||||
return (create_flags, create_positional_arguments, patterns_file)
|
||||
|
||||
|
||||
def create_archive(
|
||||
dry_run,
|
||||
repository_path,
|
||||
config,
|
||||
patterns,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
borgmatic_runtime_directory,
|
||||
local_path='borg',
|
||||
remote_path=None,
|
||||
progress=False,
|
||||
stats=False,
|
||||
json=False,
|
||||
list_files=False,
|
||||
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).
|
||||
|
||||
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.
|
||||
'''
|
||||
borgmatic.logger.add_custom_log_levels()
|
||||
|
||||
working_directory = borgmatic.config.paths.get_working_directory(config)
|
||||
|
||||
(create_flags, create_positional_arguments, patterns_file) = make_base_create_command(
|
||||
dry_run,
|
||||
repository_path,
|
||||
config,
|
||||
patterns,
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
borgmatic_runtime_directory,
|
||||
local_path,
|
||||
remote_path,
|
||||
progress,
|
||||
json,
|
||||
list_files,
|
||||
stream_processes,
|
||||
)
|
||||
|
||||
if json:
|
||||
output_log_level = None
|
||||
@@ -462,33 +406,6 @@ def create_archive(
|
||||
# the terminal directly.
|
||||
output_file = DO_NOT_CAPTURE if progress else None
|
||||
|
||||
borg_environment = environment.make_environment(config)
|
||||
|
||||
# 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.debug(f'{repository_path}: Collecting special file paths')
|
||||
special_file_paths = collect_special_file_paths(
|
||||
create_flags + create_positional_arguments,
|
||||
config,
|
||||
local_path,
|
||||
working_directory,
|
||||
borg_environment,
|
||||
skip_directories=borgmatic_source_directories,
|
||||
)
|
||||
|
||||
if special_file_paths:
|
||||
logger.warning(
|
||||
f'{repository_path}: Excluding special files to prevent Borg from hanging: {", ".join(special_file_paths)}'
|
||||
)
|
||||
exclude_file = write_pattern_file(
|
||||
expand_home_directories(
|
||||
tuple(config.get('exclude_patterns') or ()) + special_file_paths
|
||||
),
|
||||
pattern_file=exclude_file,
|
||||
)
|
||||
create_flags += make_exclude_flags(config, exclude_file.name)
|
||||
|
||||
create_flags += (
|
||||
(('--info',) if logger.getEffectiveLevel() == logging.INFO and not json else ())
|
||||
+ (('--stats',) if stats and not json and not dry_run else ())
|
||||
@@ -505,7 +422,7 @@ def create_archive(
|
||||
output_log_level,
|
||||
output_file,
|
||||
working_directory=working_directory,
|
||||
extra_environment=borg_environment,
|
||||
environment=environment.make_environment(config),
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=borg_exit_codes,
|
||||
)
|
||||
@@ -513,7 +430,7 @@ def create_archive(
|
||||
return execute_command_and_capture_output(
|
||||
create_flags + create_positional_arguments,
|
||||
working_directory=working_directory,
|
||||
extra_environment=borg_environment,
|
||||
environment=environment.make_environment(config),
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=borg_exit_codes,
|
||||
)
|
||||
@@ -523,7 +440,7 @@ def create_archive(
|
||||
output_log_level,
|
||||
output_file,
|
||||
working_directory=working_directory,
|
||||
extra_environment=borg_environment,
|
||||
environment=environment.make_environment(config),
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=borg_exit_codes,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import argparse
|
||||
import logging
|
||||
|
||||
import borgmatic.borg.environment
|
||||
import borgmatic.borg.feature
|
||||
import borgmatic.borg.flags
|
||||
import borgmatic.borg.repo_delete
|
||||
import borgmatic.config.paths
|
||||
import borgmatic.execute
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def make_delete_command(
|
||||
repository,
|
||||
config,
|
||||
local_borg_version,
|
||||
delete_arguments,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
):
|
||||
'''
|
||||
Given a local or remote repository dict, a configuration dict, the local Borg version, the
|
||||
arguments to the delete action as an argparse.Namespace, and global arguments, return a command
|
||||
as a tuple to delete archives from the repository.
|
||||
'''
|
||||
return (
|
||||
(local_path, 'delete')
|
||||
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
|
||||
+ (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ())
|
||||
+ borgmatic.borg.flags.make_flags('dry-run', global_arguments.dry_run)
|
||||
+ borgmatic.borg.flags.make_flags('remote-path', remote_path)
|
||||
+ borgmatic.borg.flags.make_flags('umask', config.get('umask'))
|
||||
+ borgmatic.borg.flags.make_flags('log-json', global_arguments.log_json)
|
||||
+ borgmatic.borg.flags.make_flags('lock-wait', config.get('lock_wait'))
|
||||
+ borgmatic.borg.flags.make_flags('list', delete_arguments.list_archives)
|
||||
+ (
|
||||
(('--force',) + (('--force',) if delete_arguments.force >= 2 else ()))
|
||||
if delete_arguments.force
|
||||
else ()
|
||||
)
|
||||
# Ignore match_archives and archive_name_format options from configuration, so the user has
|
||||
# to be explicit on the command-line about the archives they want to delete.
|
||||
+ borgmatic.borg.flags.make_match_archives_flags(
|
||||
delete_arguments.match_archives or delete_arguments.archive,
|
||||
archive_name_format=None,
|
||||
local_borg_version=local_borg_version,
|
||||
default_archive_name_format='*',
|
||||
)
|
||||
+ borgmatic.borg.flags.make_flags_from_arguments(
|
||||
delete_arguments,
|
||||
excludes=('list_archives', 'force', 'match_archives', 'archive', 'repository'),
|
||||
)
|
||||
+ borgmatic.borg.flags.make_repository_flags(repository['path'], local_borg_version)
|
||||
)
|
||||
|
||||
|
||||
ARCHIVE_RELATED_ARGUMENT_NAMES = (
|
||||
'archive',
|
||||
'match_archives',
|
||||
'first',
|
||||
'last',
|
||||
'oldest',
|
||||
'newest',
|
||||
'older',
|
||||
'newer',
|
||||
)
|
||||
|
||||
|
||||
def delete_archives(
|
||||
repository,
|
||||
config,
|
||||
local_borg_version,
|
||||
delete_arguments,
|
||||
global_arguments,
|
||||
local_path='borg',
|
||||
remote_path=None,
|
||||
):
|
||||
'''
|
||||
Given a local or remote repository dict, a configuration dict, the local Borg version, the
|
||||
arguments to the delete action as an argparse.Namespace, global arguments as an
|
||||
argparse.Namespace, and local and remote Borg paths, delete the selected archives from the
|
||||
repository. If no archives are selected, then delete the entire repository.
|
||||
'''
|
||||
borgmatic.logger.add_custom_log_levels()
|
||||
|
||||
if not any(
|
||||
getattr(delete_arguments, argument_name, None)
|
||||
for argument_name in ARCHIVE_RELATED_ARGUMENT_NAMES
|
||||
):
|
||||
if borgmatic.borg.feature.available(
|
||||
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.'
|
||||
)
|
||||
|
||||
repo_delete_arguments = argparse.Namespace(
|
||||
repository=repository['path'],
|
||||
list_archives=delete_arguments.list_archives,
|
||||
force=delete_arguments.force,
|
||||
cache_only=delete_arguments.cache_only,
|
||||
keep_security_info=delete_arguments.keep_security_info,
|
||||
)
|
||||
borgmatic.borg.repo_delete.delete_repository(
|
||||
repository,
|
||||
config,
|
||||
local_borg_version,
|
||||
repo_delete_arguments,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
)
|
||||
|
||||
return
|
||||
|
||||
command = make_delete_command(
|
||||
repository,
|
||||
config,
|
||||
local_borg_version,
|
||||
delete_arguments,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
)
|
||||
|
||||
borgmatic.execute.execute_command(
|
||||
command,
|
||||
output_log_level=logging.ANSWER,
|
||||
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,3 +1,8 @@
|
||||
import os
|
||||
|
||||
import borgmatic.borg.passcommand
|
||||
import borgmatic.hooks.credential.parse
|
||||
|
||||
OPTION_TO_ENVIRONMENT_VARIABLE = {
|
||||
'borg_base_directory': 'BORG_BASE_DIR',
|
||||
'borg_config_directory': 'BORG_CONFIG_DIR',
|
||||
@@ -5,8 +10,6 @@ OPTION_TO_ENVIRONMENT_VARIABLE = {
|
||||
'borg_files_cache_ttl': 'BORG_FILES_CACHE_TTL',
|
||||
'borg_security_directory': 'BORG_SECURITY_DIR',
|
||||
'borg_keys_directory': 'BORG_KEYS_DIR',
|
||||
'encryption_passcommand': 'BORG_PASSCOMMAND',
|
||||
'encryption_passphrase': 'BORG_PASSPHRASE',
|
||||
'ssh_command': 'BORG_RSH',
|
||||
'temporary_directory': 'TMPDIR',
|
||||
}
|
||||
@@ -23,23 +26,65 @@ DEFAULT_BOOL_OPTION_TO_UPPERCASE_ENVIRONMENT_VARIABLE = {
|
||||
|
||||
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 value:
|
||||
if value is not None:
|
||||
environment[environment_variable_name] = str(value)
|
||||
|
||||
if 'encryption_passphrase' in config:
|
||||
environment.pop('BORG_PASSPHRASE', None)
|
||||
environment.pop('BORG_PASSCOMMAND', None)
|
||||
|
||||
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
|
||||
# child process to inherit the file descriptor.
|
||||
os.set_inheritable(read_file_descriptor, True)
|
||||
environment['BORG_PASSPHRASE_FD'] = str(read_file_descriptor)
|
||||
|
||||
for (
|
||||
option_name,
|
||||
environment_variable_name,
|
||||
) in DEFAULT_BOOL_OPTION_TO_DOWNCASE_ENVIRONMENT_VARIABLE.items():
|
||||
value = config.get(option_name)
|
||||
if value is not None:
|
||||
if os.environ.get(environment_variable_name) is None:
|
||||
value = config.get(option_name)
|
||||
environment[environment_variable_name] = 'yes' if value else 'no'
|
||||
|
||||
for (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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
|
||||
@@ -18,9 +19,9 @@ def export_key(
|
||||
remote_path=None,
|
||||
):
|
||||
'''
|
||||
Given a local or remote repository path, a configuration dict, the local Borg version, and
|
||||
optional local and remote Borg paths, export the repository key to the destination path
|
||||
indicated in the export arguments.
|
||||
Given a local or remote repository path, a configuration dict, the local Borg version, export
|
||||
arguments, and optional local and remote Borg paths, export the repository key to the
|
||||
destination path indicated in the export arguments.
|
||||
|
||||
If the destination path is empty or "-", then print the key to stdout instead of to a file.
|
||||
|
||||
@@ -29,9 +30,10 @@ def export_key(
|
||||
borgmatic.logger.add_custom_log_levels()
|
||||
umask = config.get('umask', None)
|
||||
lock_wait = config.get('lock_wait', None)
|
||||
working_directory = borgmatic.config.paths.get_working_directory(config)
|
||||
|
||||
if export_arguments.path and export_arguments.path != '-':
|
||||
if os.path.exists(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.'
|
||||
)
|
||||
@@ -58,14 +60,15 @@ def export_key(
|
||||
)
|
||||
|
||||
if global_arguments.dry_run:
|
||||
logging.info(f'{repository_path}: Skipping key export (dry run)')
|
||||
logger.info('Skipping key export (dry run)')
|
||||
return
|
||||
|
||||
execute_command(
|
||||
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'),
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
|
||||
import borgmatic.config.paths
|
||||
import borgmatic.logger
|
||||
from borgmatic.borg import environment, flags
|
||||
from borgmatic.execute import DO_NOT_CAPTURE, execute_command
|
||||
@@ -62,14 +63,15 @@ def export_tar_archive(
|
||||
output_log_level = logging.INFO
|
||||
|
||||
if dry_run:
|
||||
logging.info(f'{repository_path}: Skipping export to tar file (dry run)')
|
||||
logging.info('Skipping export to tar file (dry run)')
|
||||
return
|
||||
|
||||
execute_command(
|
||||
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'),
|
||||
)
|
||||
|
||||
+32
-17
@@ -2,8 +2,9 @@ import logging
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import borgmatic.config.paths
|
||||
import borgmatic.config.validate
|
||||
from borgmatic.borg import environment, feature, flags, rlist
|
||||
from borgmatic.borg import environment, feature, flags, repo_list
|
||||
from borgmatic.execute import DO_NOT_CAPTURE, execute_command
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -29,7 +30,7 @@ def extract_last_archive_dry_run(
|
||||
verbosity_flags = ('--info',)
|
||||
|
||||
try:
|
||||
last_archive_name = rlist.resolve_archive_name(
|
||||
last_archive_name = repo_list.resolve_archive_name(
|
||||
repository_path,
|
||||
'latest',
|
||||
config,
|
||||
@@ -43,7 +44,6 @@ def extract_last_archive_dry_run(
|
||||
return
|
||||
|
||||
list_flag = ('--list',) if logger.isEnabledFor(logging.DEBUG) else ()
|
||||
borg_environment = environment.make_environment(config)
|
||||
full_extract_command = (
|
||||
(local_path, 'extract', '--dry-run')
|
||||
+ (('--remote-path', remote_path) if remote_path else ())
|
||||
@@ -58,8 +58,8 @@ def extract_last_archive_dry_run(
|
||||
|
||||
execute_command(
|
||||
full_extract_command,
|
||||
working_directory=None,
|
||||
extra_environment=borg_environment,
|
||||
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'),
|
||||
)
|
||||
@@ -104,8 +104,18 @@ def extract_archive(
|
||||
if not paths:
|
||||
raise ValueError('The --strip-components flag with "all" requires at least one --path')
|
||||
|
||||
# Calculate the maximum number of leading path components of the given paths.
|
||||
strip_components = max(0, *(len(path.split(os.path.sep)) - 1 for path in paths))
|
||||
# Calculate the maximum number of leading path components of the given paths. "if piece"
|
||||
# ignores empty path components, e.g. those resulting from a leading slash. And the "- 1"
|
||||
# is so this doesn't count the final path component, e.g. the filename itself.
|
||||
strip_components = max(
|
||||
0,
|
||||
*(
|
||||
len(tuple(piece for piece in path.split(os.path.sep) if piece)) - 1
|
||||
for path in paths
|
||||
),
|
||||
)
|
||||
|
||||
working_directory = borgmatic.config.paths.get_working_directory(config)
|
||||
|
||||
full_command = (
|
||||
(local_path, 'extract')
|
||||
@@ -121,17 +131,22 @@ def extract_archive(
|
||||
+ (('--progress',) if progress else ())
|
||||
+ (('--stdout',) if extract_to_stdout else ())
|
||||
+ flags.make_repository_archive_flags(
|
||||
# Make the repository path absolute so the working directory changes below don't
|
||||
# prevent Borg from finding the repo.
|
||||
borgmatic.config.validate.normalize_repository_path(repository),
|
||||
# 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)
|
||||
),
|
||||
archive,
|
||||
local_borg_version,
|
||||
)
|
||||
+ (tuple(paths) if paths else ())
|
||||
)
|
||||
|
||||
borg_environment = environment.make_environment(config)
|
||||
borg_exit_codes = config.get('borg_exit_codes')
|
||||
full_destination_path = (
|
||||
os.path.join(working_directory or '', destination_path) if destination_path else None
|
||||
)
|
||||
|
||||
# The progress output isn't compatible with captured and logged output, as progress messes with
|
||||
# the terminal directly.
|
||||
@@ -139,8 +154,8 @@ def extract_archive(
|
||||
return execute_command(
|
||||
full_command,
|
||||
output_file=DO_NOT_CAPTURE,
|
||||
working_directory=destination_path,
|
||||
extra_environment=borg_environment,
|
||||
environment=environment.make_environment(config),
|
||||
working_directory=full_destination_path,
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=borg_exit_codes,
|
||||
)
|
||||
@@ -150,9 +165,9 @@ def extract_archive(
|
||||
return execute_command(
|
||||
full_command,
|
||||
output_file=subprocess.PIPE,
|
||||
working_directory=destination_path,
|
||||
run_to_completion=False,
|
||||
extra_environment=borg_environment,
|
||||
environment=environment.make_environment(config),
|
||||
working_directory=full_destination_path,
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=borg_exit_codes,
|
||||
)
|
||||
@@ -161,8 +176,8 @@ def extract_archive(
|
||||
# if the restore paths don't exist in the archive.
|
||||
execute_command(
|
||||
full_command,
|
||||
working_directory=destination_path,
|
||||
extra_environment=borg_environment,
|
||||
environment=environment.make_environment(config),
|
||||
working_directory=full_destination_path,
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=borg_exit_codes,
|
||||
)
|
||||
|
||||
@@ -10,11 +10,13 @@ class Feature(Enum):
|
||||
NUMERIC_IDS = 4
|
||||
UPLOAD_RATELIMIT = 5
|
||||
SEPARATE_REPOSITORY_ARCHIVE = 6
|
||||
RCREATE = 7
|
||||
RLIST = 8
|
||||
RINFO = 9
|
||||
MATCH_ARCHIVES = 10
|
||||
EXCLUDED_FILES_MINUS = 11
|
||||
REPO_CREATE = 7
|
||||
REPO_LIST = 8
|
||||
REPO_INFO = 9
|
||||
REPO_DELETE = 10
|
||||
MATCH_ARCHIVES = 11
|
||||
EXCLUDED_FILES_MINUS = 12
|
||||
ARCHIVE_SERIES = 13
|
||||
|
||||
|
||||
FEATURE_TO_MINIMUM_BORG_VERSION = {
|
||||
@@ -24,11 +26,13 @@ FEATURE_TO_MINIMUM_BORG_VERSION = {
|
||||
Feature.NUMERIC_IDS: parse('1.2.0b3'), # borg create/extract/mount --numeric-ids
|
||||
Feature.UPLOAD_RATELIMIT: parse('1.2.0b3'), # borg create --upload-ratelimit
|
||||
Feature.SEPARATE_REPOSITORY_ARCHIVE: parse('2.0.0a2'), # --repo with separate archive
|
||||
Feature.RCREATE: parse('2.0.0a2'), # borg rcreate
|
||||
Feature.RLIST: parse('2.0.0a2'), # borg rlist
|
||||
Feature.RINFO: parse('2.0.0a2'), # borg rinfo
|
||||
Feature.REPO_CREATE: parse('2.0.0a2'), # borg repo-create
|
||||
Feature.REPO_LIST: parse('2.0.0a2'), # borg repo-list
|
||||
Feature.REPO_INFO: parse('2.0.0a2'), # borg repo-info
|
||||
Feature.REPO_DELETE: parse('2.0.0a2'), # borg repo-delete
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
|
||||
+88
-4
@@ -50,6 +50,9 @@ def make_repository_flags(repository_path, local_borg_version):
|
||||
) + (repository_path,)
|
||||
|
||||
|
||||
ARCHIVE_HASH_PATTERN = re.compile('[0-9a-fA-F]{8,}$')
|
||||
|
||||
|
||||
def make_repository_archive_flags(repository_path, archive, local_borg_version):
|
||||
'''
|
||||
Given the path of a Borg repository, an archive name or pattern, and the local Borg version,
|
||||
@@ -57,16 +60,42 @@ def make_repository_archive_flags(repository_path, archive, local_borg_version):
|
||||
and archive.
|
||||
'''
|
||||
return (
|
||||
('--repo', repository_path, archive)
|
||||
(
|
||||
'--repo',
|
||||
repository_path,
|
||||
(
|
||||
f'aid:{archive}'
|
||||
if feature.available(feature.Feature.ARCHIVE_SERIES, local_borg_version)
|
||||
and ARCHIVE_HASH_PATTERN.match(archive)
|
||||
and not archive.startswith('aid:')
|
||||
else archive
|
||||
),
|
||||
)
|
||||
if feature.available(feature.Feature.SEPARATE_REPOSITORY_ARCHIVE, local_borg_version)
|
||||
else (f'{repository_path}::{archive}',)
|
||||
)
|
||||
|
||||
|
||||
DEFAULT_ARCHIVE_NAME_FORMAT = '{hostname}-{now:%Y-%m-%dT%H:%M:%S.%f}' # noqa: FS003
|
||||
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
|
||||
|
||||
|
||||
def make_match_archives_flags(match_archives, archive_name_format, local_borg_version):
|
||||
def get_default_archive_name_format(local_borg_version):
|
||||
'''
|
||||
Given the local Borg version, return the corresponding default archive name format.
|
||||
'''
|
||||
if feature.available(feature.Feature.ARCHIVE_SERIES, local_borg_version):
|
||||
return DEFAULT_ARCHIVE_NAME_FORMAT_WITH_SERIES
|
||||
|
||||
return DEFAULT_ARCHIVE_NAME_FORMAT_WITHOUT_SERIES
|
||||
|
||||
|
||||
def make_match_archives_flags(
|
||||
match_archives,
|
||||
archive_name_format,
|
||||
local_borg_version,
|
||||
default_archive_name_format=None,
|
||||
):
|
||||
'''
|
||||
Return match archives flags based on the given match archives value, if any. If it isn't set,
|
||||
return match archives flags to match archives created with the given (or default) archive name
|
||||
@@ -74,13 +103,27 @@ def make_match_archives_flags(match_archives, archive_name_format, local_borg_ve
|
||||
(like "{now}") with globs.
|
||||
'''
|
||||
if match_archives:
|
||||
if match_archives in {'*', 're:.*', 'sh:*'}:
|
||||
return ()
|
||||
|
||||
if feature.available(feature.Feature.MATCH_ARCHIVES, local_borg_version):
|
||||
if (
|
||||
feature.available(feature.Feature.ARCHIVE_SERIES, local_borg_version)
|
||||
and ARCHIVE_HASH_PATTERN.match(match_archives)
|
||||
and not match_archives.startswith('aid:')
|
||||
):
|
||||
return ('--match-archives', f'aid:{match_archives}')
|
||||
|
||||
return ('--match-archives', match_archives)
|
||||
else:
|
||||
return ('--glob-archives', re.sub(r'^sh:', '', match_archives))
|
||||
|
||||
derived_match_archives = re.sub(
|
||||
r'\{(now|utcnow|pid)([:%\w\.-]*)\}', '*', archive_name_format or DEFAULT_ARCHIVE_NAME_FORMAT
|
||||
r'\{(now|utcnow|pid)([:%\w\.-]*)\}',
|
||||
'*',
|
||||
archive_name_format
|
||||
or default_archive_name_format
|
||||
or get_default_archive_name_format(local_borg_version),
|
||||
)
|
||||
|
||||
if derived_match_archives == '*':
|
||||
@@ -113,3 +156,44 @@ def warn_for_aggressive_archive_flags(json_command, json_output):
|
||||
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}=')
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import argparse
|
||||
import logging
|
||||
|
||||
import borgmatic.config.paths
|
||||
import borgmatic.logger
|
||||
from borgmatic.borg import environment, feature, flags
|
||||
from borgmatic.execute import execute_command, execute_command_and_capture_output
|
||||
@@ -35,6 +36,7 @@ def make_info_command(
|
||||
else ()
|
||||
)
|
||||
+ flags.make_flags('remote-path', remote_path)
|
||||
+ flags.make_flags('umask', config.get('umask'))
|
||||
+ flags.make_flags('log-json', global_arguments.log_json)
|
||||
+ flags.make_flags('lock-wait', config.get('lock_wait'))
|
||||
+ (
|
||||
@@ -96,10 +98,12 @@ def display_archives_info(
|
||||
remote_path,
|
||||
)
|
||||
borg_exit_codes = config.get('borg_exit_codes')
|
||||
working_directory = borgmatic.config.paths.get_working_directory(config)
|
||||
|
||||
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,
|
||||
)
|
||||
@@ -112,7 +116,8 @@ 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,
|
||||
)
|
||||
|
||||
+45
-41
@@ -3,20 +3,21 @@ import copy
|
||||
import logging
|
||||
import re
|
||||
|
||||
import borgmatic.config.paths
|
||||
import borgmatic.logger
|
||||
from borgmatic.borg import environment, feature, flags, rlist
|
||||
from borgmatic.borg import environment, feature, flags, repo_list
|
||||
from borgmatic.execute import execute_command, execute_command_and_capture_output
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
ARCHIVE_FILTER_FLAGS_MOVED_TO_RLIST = ('prefix', 'match_archives', 'sort_by', 'first', 'last')
|
||||
ARCHIVE_FILTER_FLAGS_MOVED_TO_REPO_LIST = ('prefix', 'match_archives', 'sort_by', 'first', 'last')
|
||||
MAKE_FLAGS_EXCLUDES = (
|
||||
'repository',
|
||||
'archive',
|
||||
'paths',
|
||||
'find_paths',
|
||||
) + ARCHIVE_FILTER_FLAGS_MOVED_TO_RLIST
|
||||
) + ARCHIVE_FILTER_FLAGS_MOVED_TO_REPO_LIST
|
||||
|
||||
|
||||
def make_list_command(
|
||||
@@ -33,8 +34,6 @@ def make_list_command(
|
||||
and local and remote Borg paths, return a command as a tuple to list archives or paths within an
|
||||
archive.
|
||||
'''
|
||||
lock_wait = config.get('lock_wait', None)
|
||||
|
||||
return (
|
||||
(local_path, 'list')
|
||||
+ (
|
||||
@@ -48,8 +47,9 @@ def make_list_command(
|
||||
else ()
|
||||
)
|
||||
+ flags.make_flags('remote-path', remote_path)
|
||||
+ flags.make_flags('umask', config.get('umask'))
|
||||
+ flags.make_flags('log-json', global_arguments.log_json)
|
||||
+ flags.make_flags('lock-wait', lock_wait)
|
||||
+ flags.make_flags('lock-wait', config.get('lock_wait'))
|
||||
+ flags.make_flags_from_arguments(list_arguments, excludes=MAKE_FLAGS_EXCLUDES)
|
||||
+ (
|
||||
flags.make_repository_archive_flags(
|
||||
@@ -79,9 +79,11 @@ def make_find_paths(find_paths):
|
||||
return ()
|
||||
|
||||
return tuple(
|
||||
find_path
|
||||
if re.compile(r'([-!+RrPp] )|(\w\w:)').match(find_path)
|
||||
else f'sh:**/*{find_path}*/**'
|
||||
(
|
||||
find_path
|
||||
if re.compile(r'([-!+RrPp] )|(\w\w:)').match(find_path)
|
||||
else f'sh:**/*{find_path}*/**'
|
||||
)
|
||||
for find_path in find_paths
|
||||
)
|
||||
|
||||
@@ -93,17 +95,17 @@ def capture_archive_listing(
|
||||
local_borg_version,
|
||||
global_arguments,
|
||||
list_paths=None,
|
||||
path_format=None,
|
||||
local_path='borg',
|
||||
remote_path=None,
|
||||
):
|
||||
'''
|
||||
Given a local or remote repository path, an archive name, a configuration dict, the local Borg
|
||||
version, global arguments as an argparse.Namespace, the archive paths in which to list files, and
|
||||
local and remote Borg paths, capture the output of listing that archive and return it as a list
|
||||
of file paths.
|
||||
Given a local or remote repository path, an archive name, a configuration
|
||||
dict, the local Borg version, global arguments as an argparse.Namespace,
|
||||
the archive paths (or Borg patterns) in which to list files, the Borg path
|
||||
format to use for the output, and local and remote Borg paths, capture the
|
||||
output of listing that archive and return it as a list of file paths.
|
||||
'''
|
||||
borg_environment = environment.make_environment(config)
|
||||
|
||||
return tuple(
|
||||
execute_command_and_capture_output(
|
||||
make_list_command(
|
||||
@@ -113,21 +115,22 @@ def capture_archive_listing(
|
||||
argparse.Namespace(
|
||||
repository=repository_path,
|
||||
archive=archive,
|
||||
paths=[f'sh:{path}' for path in list_paths] if list_paths else None,
|
||||
paths=[path for path in list_paths] if list_paths else None,
|
||||
find_paths=None,
|
||||
json=None,
|
||||
format='{path}{NL}', # noqa: FS003
|
||||
format=path_format or '{path}{NUL}', # noqa: FS003
|
||||
),
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
),
|
||||
extra_environment=borg_environment,
|
||||
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('\n')
|
||||
.split('\n')
|
||||
.strip('\0')
|
||||
.split('\0')
|
||||
)
|
||||
|
||||
|
||||
@@ -141,22 +144,22 @@ def list_archive(
|
||||
remote_path=None,
|
||||
):
|
||||
'''
|
||||
Given a local or remote repository path, a configuration dict, the local Borg version, global
|
||||
arguments as an argparse.Namespace, the arguments to the list action as an argparse.Namespace,
|
||||
and local and remote Borg paths, display the output of listing the files of a Borg archive (or
|
||||
return JSON output). If list_arguments.find_paths are given, list the files by searching across
|
||||
multiple archives. If neither find_paths nor archive name are given, instead list the archives
|
||||
in the given repository.
|
||||
Given a local or remote repository path, a configuration dict, the local Borg version, the
|
||||
arguments to the list action as an argparse.Namespace, global arguments as an
|
||||
argparse.Namespace, and local and remote Borg paths, display the output of listing the files of
|
||||
a Borg archive (or return JSON output). If list_arguments.find_paths are given, list the files
|
||||
by searching across multiple archives. If neither find_paths nor archive name are given, instead
|
||||
list the archives in the given repository.
|
||||
'''
|
||||
borgmatic.logger.add_custom_log_levels()
|
||||
|
||||
if not list_arguments.archive and not list_arguments.find_paths:
|
||||
if feature.available(feature.Feature.RLIST, local_borg_version):
|
||||
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 rlist action instead.'
|
||||
'Omitting the --archive flag on the list action is deprecated when using Borg 2.x+. Use the repo-list action instead.'
|
||||
)
|
||||
|
||||
rlist_arguments = argparse.Namespace(
|
||||
repo_list_arguments = argparse.Namespace(
|
||||
repository=repository_path,
|
||||
short=list_arguments.short,
|
||||
format=list_arguments.format,
|
||||
@@ -167,18 +170,18 @@ def list_archive(
|
||||
first=list_arguments.first,
|
||||
last=list_arguments.last,
|
||||
)
|
||||
return rlist.list_repository(
|
||||
return repo_list.list_repository(
|
||||
repository_path,
|
||||
config,
|
||||
local_borg_version,
|
||||
rlist_arguments,
|
||||
repo_list_arguments,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
)
|
||||
|
||||
if list_arguments.archive:
|
||||
for name in ARCHIVE_FILTER_FLAGS_MOVED_TO_RLIST:
|
||||
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."
|
||||
@@ -189,13 +192,12 @@ def list_archive(
|
||||
'The --json flag on the list action is not supported when using the --archive/--find flags.'
|
||||
)
|
||||
|
||||
borg_environment = environment.make_environment(config)
|
||||
borg_exit_codes = config.get('borg_exit_codes')
|
||||
|
||||
# If there are any paths to find (and there's not a single archive already selected), start by
|
||||
# getting a list of archives to search.
|
||||
if list_arguments.find_paths and not list_arguments.archive:
|
||||
rlist_arguments = argparse.Namespace(
|
||||
repo_list_arguments = argparse.Namespace(
|
||||
repository=repository_path,
|
||||
short=True,
|
||||
format=None,
|
||||
@@ -210,35 +212,36 @@ def list_archive(
|
||||
# Ask Borg to list archives. Capture its output for use below.
|
||||
archive_lines = tuple(
|
||||
execute_command_and_capture_output(
|
||||
rlist.make_rlist_command(
|
||||
repo_list.make_repo_list_command(
|
||||
repository_path,
|
||||
config,
|
||||
local_borg_version,
|
||||
rlist_arguments,
|
||||
repo_list_arguments,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
),
|
||||
extra_environment=borg_environment,
|
||||
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')
|
||||
.split('\n')
|
||||
.splitlines()
|
||||
)
|
||||
else:
|
||||
archive_lines = (list_arguments.archive,)
|
||||
|
||||
# For each archive listed by Borg, run list on the contents of that archive.
|
||||
for archive in archive_lines:
|
||||
logger.answer(f'{repository_path}: Listing archive {archive}')
|
||||
logger.answer(f'Listing archive {archive}')
|
||||
|
||||
archive_arguments = copy.copy(list_arguments)
|
||||
archive_arguments.archive = archive
|
||||
|
||||
# This list call is to show the files in a single archive, not list multiple archives. So
|
||||
# blank out any archive filtering flags. They'll break anyway in Borg 2.
|
||||
for name in ARCHIVE_FILTER_FLAGS_MOVED_TO_RLIST:
|
||||
for name in ARCHIVE_FILTER_FLAGS_MOVED_TO_REPO_LIST:
|
||||
setattr(archive_arguments, name, None)
|
||||
|
||||
main_command = make_list_command(
|
||||
@@ -254,7 +257,8 @@ def list_archive(
|
||||
execute_command(
|
||||
main_command,
|
||||
output_log_level=logging.ANSWER,
|
||||
extra_environment=borg_environment,
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
|
||||
import borgmatic.config.paths
|
||||
from borgmatic.borg import environment, feature, flags
|
||||
from borgmatic.execute import DO_NOT_CAPTURE, execute_command
|
||||
|
||||
@@ -58,14 +59,15 @@ def mount_archive(
|
||||
+ (tuple(mount_arguments.paths) if mount_arguments.paths else ())
|
||||
)
|
||||
|
||||
borg_environment = environment.make_environment(config)
|
||||
working_directory = borgmatic.config.paths.get_working_directory(config)
|
||||
|
||||
# Don't capture the output when foreground mode is used so that ctrl-C can work properly.
|
||||
if mount_arguments.foreground:
|
||||
execute_command(
|
||||
full_command,
|
||||
output_file=DO_NOT_CAPTURE,
|
||||
extra_environment=borg_environment,
|
||||
environment=environment.make_environment(config),
|
||||
working_directory=working_directory,
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=config.get('borg_exit_codes'),
|
||||
)
|
||||
@@ -73,7 +75,8 @@ def mount_archive(
|
||||
|
||||
execute_command(
|
||||
full_command,
|
||||
extra_environment=borg_environment,
|
||||
environment=environment.make_environment(config),
|
||||
working_directory=working_directory,
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=config.get('borg_exit_codes'),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import functools
|
||||
import logging
|
||||
import shlex
|
||||
|
||||
import borgmatic.config.paths
|
||||
import borgmatic.execute
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@functools.cache
|
||||
def run_passcommand(passcommand, working_directory):
|
||||
'''
|
||||
Run the given passcommand using the given working directory and return the passphrase produced
|
||||
by the command.
|
||||
|
||||
Cache the results so that the passcommand only needs to run—and potentially prompt the user—once
|
||||
per borgmatic invocation.
|
||||
'''
|
||||
return borgmatic.execute.execute_command_and_capture_output(
|
||||
shlex.split(passcommand),
|
||||
working_directory=working_directory,
|
||||
)
|
||||
|
||||
|
||||
def get_passphrase_from_passcommand(config):
|
||||
'''
|
||||
Given the configuration dict, call the configured passcommand to produce and return an
|
||||
encryption passphrase. In effect, we're doing an end-run around Borg by invoking its passcommand
|
||||
ourselves. This allows us to pass the resulting passphrase to multiple different Borg
|
||||
invocations without the user having to be prompted multiple times.
|
||||
|
||||
If no passcommand is configured, then return None.
|
||||
'''
|
||||
passcommand = config.get('encryption_passcommand')
|
||||
|
||||
if not passcommand:
|
||||
return None
|
||||
|
||||
return run_passcommand(passcommand, borgmatic.config.paths.get_working_directory(config))
|
||||
@@ -0,0 +1,50 @@
|
||||
import collections
|
||||
import enum
|
||||
|
||||
|
||||
# See https://borgbackup.readthedocs.io/en/stable/usage/help.html#borg-help-patterns
|
||||
class Pattern_type(enum.Enum):
|
||||
ROOT = 'R' # A ROOT pattern always has a NONE pattern style.
|
||||
PATTERN_STYLE = 'P'
|
||||
EXCLUDE = '-'
|
||||
NO_RECURSE = '!'
|
||||
INCLUDE = '+'
|
||||
|
||||
|
||||
class Pattern_style(enum.Enum):
|
||||
NONE = ''
|
||||
FNMATCH = 'fm'
|
||||
SHELL = 'sh'
|
||||
REGULAR_EXPRESSION = 're'
|
||||
PATH_PREFIX = 'pp'
|
||||
PATH_FULL_MATCH = 'pf'
|
||||
|
||||
|
||||
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', 'source'),
|
||||
defaults=(
|
||||
Pattern_type.ROOT,
|
||||
Pattern_style.NONE,
|
||||
None,
|
||||
Pattern_source.HOOK,
|
||||
),
|
||||
)
|
||||
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
|
||||
import borgmatic.config.paths
|
||||
import borgmatic.logger
|
||||
from borgmatic.borg import environment, feature, flags
|
||||
from borgmatic.execute import execute_command
|
||||
@@ -7,9 +8,10 @@ from borgmatic.execute import execute_command
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def make_prune_flags(config, local_borg_version):
|
||||
def make_prune_flags(config, prune_arguments, local_borg_version):
|
||||
'''
|
||||
Given a configuration dict mapping from option name to value, transform it into an sequence of
|
||||
Given a configuration dict mapping from option name to value, prune arguments as an
|
||||
argparse.Namespace instance, and the local Borg version, produce a corresponding sequence of
|
||||
command-line flags.
|
||||
|
||||
For example, given a retention config of:
|
||||
@@ -39,7 +41,7 @@ def make_prune_flags(config, local_borg_version):
|
||||
if prefix
|
||||
else (
|
||||
flags.make_match_archives_flags(
|
||||
config.get('match_archives'),
|
||||
prune_arguments.match_archives or config.get('match_archives'),
|
||||
config.get('archive_name_format'),
|
||||
local_borg_version,
|
||||
)
|
||||
@@ -68,7 +70,7 @@ def prune_archives(
|
||||
|
||||
full_command = (
|
||||
(local_path, 'prune')
|
||||
+ make_prune_flags(config, local_borg_version)
|
||||
+ 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 ())
|
||||
@@ -77,7 +79,7 @@ def prune_archives(
|
||||
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
|
||||
+ flags.make_flags_from_arguments(
|
||||
prune_arguments,
|
||||
excludes=('repository', 'stats', 'list_archives'),
|
||||
excludes=('repository', 'match_archives', 'stats', 'list_archives'),
|
||||
)
|
||||
+ (('--list',) if prune_arguments.list_archives else ())
|
||||
+ (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ())
|
||||
@@ -94,7 +96,8 @@ 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'),
|
||||
)
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
|
||||
from borgmatic.borg import environment, feature, flags, rinfo
|
||||
import borgmatic.config.paths
|
||||
from borgmatic.borg import environment, feature, flags, repo_info
|
||||
from borgmatic.execute import DO_NOT_CAPTURE, execute_command
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
RINFO_REPOSITORY_NOT_FOUND_EXIT_CODES = {2, 13}
|
||||
REPO_INFO_REPOSITORY_NOT_FOUND_EXIT_CODES = {2, 13, 15}
|
||||
|
||||
|
||||
def create_repository(
|
||||
@@ -31,31 +33,45 @@ def create_repository(
|
||||
version, a Borg encryption mode, the path to another repo whose key material should be reused,
|
||||
whether the repository should be append-only, and the storage quota to use, create the
|
||||
repository. If the repository already exists, then log and skip creation.
|
||||
|
||||
Raise ValueError if the requested encryption mode does not match that of the repository.
|
||||
Raise json.decoder.JSONDecodeError if the "borg info" JSON outputcannot be decoded.
|
||||
Raise subprocess.CalledProcessError if "borg info" returns an error exit code.
|
||||
'''
|
||||
try:
|
||||
rinfo.display_repository_info(
|
||||
repository_path,
|
||||
config,
|
||||
local_borg_version,
|
||||
argparse.Namespace(json=True),
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
info_data = json.loads(
|
||||
repo_info.display_repository_info(
|
||||
repository_path,
|
||||
config,
|
||||
local_borg_version,
|
||||
argparse.Namespace(json=True),
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
)
|
||||
)
|
||||
logger.info(f'{repository_path}: Repository already exists. Skipping creation.')
|
||||
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}"'
|
||||
)
|
||||
|
||||
logger.info('Repository already exists. Skipping creation.')
|
||||
return
|
||||
except subprocess.CalledProcessError as error:
|
||||
if error.returncode not in RINFO_REPOSITORY_NOT_FOUND_EXIT_CODES:
|
||||
if error.returncode not in REPO_INFO_REPOSITORY_NOT_FOUND_EXIT_CODES:
|
||||
raise
|
||||
|
||||
lock_wait = config.get('lock_wait')
|
||||
extra_borg_options = config.get('extra_borg_options', {}).get('rcreate', '')
|
||||
umask = config.get('umask')
|
||||
extra_borg_options = config.get('extra_borg_options', {}).get('repo-create', '')
|
||||
|
||||
rcreate_command = (
|
||||
repo_create_command = (
|
||||
(local_path,)
|
||||
+ (
|
||||
('rcreate',)
|
||||
if feature.available(feature.Feature.RCREATE, local_borg_version)
|
||||
('repo-create',)
|
||||
if feature.available(feature.Feature.REPO_CREATE, local_borg_version)
|
||||
else ('init',)
|
||||
)
|
||||
+ (('--encryption', encryption_mode) if encryption_mode else ())
|
||||
@@ -69,19 +85,21 @@ def create_repository(
|
||||
+ (('--log-json',) if global_arguments.log_json else ())
|
||||
+ (('--lock-wait', str(lock_wait)) if lock_wait else ())
|
||||
+ (('--remote-path', remote_path) if remote_path else ())
|
||||
+ (('--umask', str(umask)) if umask else ())
|
||||
+ (tuple(extra_borg_options.split(' ')) if extra_borg_options else ())
|
||||
+ flags.make_repository_flags(repository_path, local_borg_version)
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
logging.info(f'{repository_path}: Skipping repository creation (dry run)')
|
||||
logging.info('Skipping repository creation (dry run)')
|
||||
return
|
||||
|
||||
# Do not capture output here, so as to support interactive prompts.
|
||||
execute_command(
|
||||
rcreate_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'),
|
||||
)
|
||||
@@ -0,0 +1,95 @@
|
||||
import logging
|
||||
|
||||
import borgmatic.borg.environment
|
||||
import borgmatic.borg.feature
|
||||
import borgmatic.borg.flags
|
||||
import borgmatic.config.paths
|
||||
import borgmatic.execute
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def make_repo_delete_command(
|
||||
repository,
|
||||
config,
|
||||
local_borg_version,
|
||||
repo_delete_arguments,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
):
|
||||
'''
|
||||
Given a local or remote repository dict, a configuration dict, the local Borg version, the
|
||||
arguments to the repo_delete action as an argparse.Namespace, and global arguments, return a command
|
||||
as a tuple to repo_delete the entire repository.
|
||||
'''
|
||||
return (
|
||||
(local_path,)
|
||||
+ (
|
||||
('repo-delete',)
|
||||
if borgmatic.borg.feature.available(
|
||||
borgmatic.borg.feature.Feature.REPO_DELETE, local_borg_version
|
||||
)
|
||||
else ('delete',)
|
||||
)
|
||||
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
|
||||
+ (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ())
|
||||
+ borgmatic.borg.flags.make_flags('dry-run', global_arguments.dry_run)
|
||||
+ borgmatic.borg.flags.make_flags('remote-path', remote_path)
|
||||
+ borgmatic.borg.flags.make_flags('umask', config.get('umask'))
|
||||
+ borgmatic.borg.flags.make_flags('log-json', global_arguments.log_json)
|
||||
+ borgmatic.borg.flags.make_flags('lock-wait', config.get('lock_wait'))
|
||||
+ borgmatic.borg.flags.make_flags('list', repo_delete_arguments.list_archives)
|
||||
+ (
|
||||
(('--force',) + (('--force',) if repo_delete_arguments.force >= 2 else ()))
|
||||
if repo_delete_arguments.force
|
||||
else ()
|
||||
)
|
||||
+ borgmatic.borg.flags.make_flags_from_arguments(
|
||||
repo_delete_arguments, excludes=('list_archives', 'force', 'repository')
|
||||
)
|
||||
+ borgmatic.borg.flags.make_repository_flags(repository['path'], local_borg_version)
|
||||
)
|
||||
|
||||
|
||||
def delete_repository(
|
||||
repository,
|
||||
config,
|
||||
local_borg_version,
|
||||
repo_delete_arguments,
|
||||
global_arguments,
|
||||
local_path='borg',
|
||||
remote_path=None,
|
||||
):
|
||||
'''
|
||||
Given a local or remote repository dict, a configuration dict, the local Borg version, the
|
||||
arguments to the repo_delete action as an argparse.Namespace, global arguments as an
|
||||
argparse.Namespace, and local and remote Borg paths, repo_delete the entire repository.
|
||||
'''
|
||||
borgmatic.logger.add_custom_log_levels()
|
||||
|
||||
command = make_repo_delete_command(
|
||||
repository,
|
||||
config,
|
||||
local_borg_version,
|
||||
repo_delete_arguments,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
)
|
||||
|
||||
borgmatic.execute.execute_command(
|
||||
command,
|
||||
output_log_level=logging.ANSWER,
|
||||
# Don't capture output when Borg is expected to prompt for interactive confirmation, or the
|
||||
# prompt won't work.
|
||||
output_file=(
|
||||
None
|
||||
if repo_delete_arguments.force or repo_delete_arguments.cache_only
|
||||
else borgmatic.execute.DO_NOT_CAPTURE
|
||||
),
|
||||
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,5 +1,6 @@
|
||||
import logging
|
||||
|
||||
import borgmatic.config.paths
|
||||
import borgmatic.logger
|
||||
from borgmatic.borg import environment, feature, flags
|
||||
from borgmatic.execute import execute_command, execute_command_and_capture_output
|
||||
@@ -11,14 +12,14 @@ def display_repository_info(
|
||||
repository_path,
|
||||
config,
|
||||
local_borg_version,
|
||||
rinfo_arguments,
|
||||
repo_info_arguments,
|
||||
global_arguments,
|
||||
local_path='borg',
|
||||
remote_path=None,
|
||||
):
|
||||
'''
|
||||
Given a local or remote repository path, a configuration dict, the local Borg version, the
|
||||
arguments to the rinfo action, and global arguments as an argparse.Namespace, display summary
|
||||
arguments to the repo_info action, and global arguments as an argparse.Namespace, display summary
|
||||
information for the Borg repository or return JSON summary information.
|
||||
'''
|
||||
borgmatic.logger.add_custom_log_levels()
|
||||
@@ -27,34 +28,36 @@ def display_repository_info(
|
||||
full_command = (
|
||||
(local_path,)
|
||||
+ (
|
||||
('rinfo',)
|
||||
if feature.available(feature.Feature.RINFO, local_borg_version)
|
||||
('repo-info',)
|
||||
if feature.available(feature.Feature.REPO_INFO, local_borg_version)
|
||||
else ('info',)
|
||||
)
|
||||
+ (
|
||||
('--info',)
|
||||
if logger.getEffectiveLevel() == logging.INFO and not rinfo_arguments.json
|
||||
if logger.getEffectiveLevel() == logging.INFO and not repo_info_arguments.json
|
||||
else ()
|
||||
)
|
||||
+ (
|
||||
('--debug', '--show-rc')
|
||||
if logger.isEnabledFor(logging.DEBUG) and not rinfo_arguments.json
|
||||
if logger.isEnabledFor(logging.DEBUG) and not repo_info_arguments.json
|
||||
else ()
|
||||
)
|
||||
+ flags.make_flags('remote-path', remote_path)
|
||||
+ flags.make_flags('umask', config.get('umask'))
|
||||
+ flags.make_flags('log-json', global_arguments.log_json)
|
||||
+ flags.make_flags('lock-wait', lock_wait)
|
||||
+ (('--json',) if rinfo_arguments.json else ())
|
||||
+ (('--json',) if repo_info_arguments.json else ())
|
||||
+ flags.make_repository_flags(repository_path, local_borg_version)
|
||||
)
|
||||
|
||||
extra_environment = environment.make_environment(config)
|
||||
working_directory = borgmatic.config.paths.get_working_directory(config)
|
||||
borg_exit_codes = config.get('borg_exit_codes')
|
||||
|
||||
if rinfo_arguments.json:
|
||||
if repo_info_arguments.json:
|
||||
return execute_command_and_capture_output(
|
||||
full_command,
|
||||
extra_environment=extra_environment,
|
||||
environment=environment.make_environment(config),
|
||||
working_directory=working_directory,
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=borg_exit_codes,
|
||||
)
|
||||
@@ -62,7 +65,8 @@ def display_repository_info(
|
||||
execute_command(
|
||||
full_command,
|
||||
output_log_level=logging.ANSWER,
|
||||
extra_environment=extra_environment,
|
||||
environment=environment.make_environment(config),
|
||||
working_directory=working_directory,
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=borg_exit_codes,
|
||||
)
|
||||
@@ -1,6 +1,7 @@
|
||||
import argparse
|
||||
import logging
|
||||
|
||||
import borgmatic.config.paths
|
||||
import borgmatic.logger
|
||||
from borgmatic.borg import environment, feature, flags
|
||||
from borgmatic.execute import execute_command, execute_command_and_capture_output
|
||||
@@ -31,9 +32,14 @@ def resolve_archive_name(
|
||||
full_command = (
|
||||
(
|
||||
local_path,
|
||||
'rlist' if feature.available(feature.Feature.RLIST, local_borg_version) else 'list',
|
||||
(
|
||||
'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)
|
||||
@@ -43,7 +49,8 @@ def resolve_archive_name(
|
||||
|
||||
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'),
|
||||
)
|
||||
@@ -52,7 +59,7 @@ def resolve_archive_name(
|
||||
except IndexError:
|
||||
raise ValueError('No archives found in the repository')
|
||||
|
||||
logger.debug(f'{repository_path}: Latest archive is {latest_archive}')
|
||||
logger.debug(f'Latest archive is {latest_archive}')
|
||||
|
||||
return latest_archive
|
||||
|
||||
@@ -60,54 +67,59 @@ def resolve_archive_name(
|
||||
MAKE_FLAGS_EXCLUDES = ('repository', 'prefix', 'match_archives')
|
||||
|
||||
|
||||
def make_rlist_command(
|
||||
def make_repo_list_command(
|
||||
repository_path,
|
||||
config,
|
||||
local_borg_version,
|
||||
rlist_arguments,
|
||||
repo_list_arguments,
|
||||
global_arguments,
|
||||
local_path='borg',
|
||||
remote_path=None,
|
||||
):
|
||||
'''
|
||||
Given a local or remote repository path, a configuration dict, the local Borg version, the
|
||||
arguments to the rlist action, global arguments as an argparse.Namespace instance, and local and
|
||||
arguments to the repo_list action, global arguments as an argparse.Namespace instance, and local and
|
||||
remote Borg paths, return a command as a tuple to list archives with a repository.
|
||||
'''
|
||||
return (
|
||||
(
|
||||
local_path,
|
||||
'rlist' if feature.available(feature.Feature.RLIST, local_borg_version) else 'list',
|
||||
(
|
||||
'repo-list'
|
||||
if feature.available(feature.Feature.REPO_LIST, local_borg_version)
|
||||
else 'list'
|
||||
),
|
||||
)
|
||||
+ (
|
||||
('--info',)
|
||||
if logger.getEffectiveLevel() == logging.INFO and not rlist_arguments.json
|
||||
if logger.getEffectiveLevel() == logging.INFO and not repo_list_arguments.json
|
||||
else ()
|
||||
)
|
||||
+ (
|
||||
('--debug', '--show-rc')
|
||||
if logger.isEnabledFor(logging.DEBUG) and not rlist_arguments.json
|
||||
if logger.isEnabledFor(logging.DEBUG) and not repo_list_arguments.json
|
||||
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'))
|
||||
+ (
|
||||
(
|
||||
flags.make_flags('match-archives', f'sh:{rlist_arguments.prefix}*')
|
||||
flags.make_flags('match-archives', f'sh:{repo_list_arguments.prefix}*')
|
||||
if feature.available(feature.Feature.MATCH_ARCHIVES, local_borg_version)
|
||||
else flags.make_flags('glob-archives', f'{rlist_arguments.prefix}*')
|
||||
else flags.make_flags('glob-archives', f'{repo_list_arguments.prefix}*')
|
||||
)
|
||||
if rlist_arguments.prefix
|
||||
if repo_list_arguments.prefix
|
||||
else (
|
||||
flags.make_match_archives_flags(
|
||||
rlist_arguments.match_archives or config.get('match_archives'),
|
||||
repo_list_arguments.match_archives or config.get('match_archives'),
|
||||
config.get('archive_name_format'),
|
||||
local_borg_version,
|
||||
)
|
||||
)
|
||||
)
|
||||
+ flags.make_flags_from_arguments(rlist_arguments, excludes=MAKE_FLAGS_EXCLUDES)
|
||||
+ flags.make_flags_from_arguments(repo_list_arguments, excludes=MAKE_FLAGS_EXCLUDES)
|
||||
+ flags.make_repository_flags(repository_path, local_borg_version)
|
||||
)
|
||||
|
||||
@@ -116,7 +128,7 @@ def list_repository(
|
||||
repository_path,
|
||||
config,
|
||||
local_borg_version,
|
||||
rlist_arguments,
|
||||
repo_list_arguments,
|
||||
global_arguments,
|
||||
local_path='borg',
|
||||
remote_path=None,
|
||||
@@ -128,36 +140,37 @@ def list_repository(
|
||||
return JSON output).
|
||||
'''
|
||||
borgmatic.logger.add_custom_log_levels()
|
||||
borg_environment = environment.make_environment(config)
|
||||
|
||||
main_command = make_rlist_command(
|
||||
main_command = make_repo_list_command(
|
||||
repository_path,
|
||||
config,
|
||||
local_borg_version,
|
||||
rlist_arguments,
|
||||
repo_list_arguments,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
)
|
||||
json_command = make_rlist_command(
|
||||
json_command = make_repo_list_command(
|
||||
repository_path,
|
||||
config,
|
||||
local_borg_version,
|
||||
argparse.Namespace(**dict(rlist_arguments.__dict__, json=True)),
|
||||
argparse.Namespace(**dict(repo_list_arguments.__dict__, json=True)),
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
)
|
||||
working_directory = borgmatic.config.paths.get_working_directory(config)
|
||||
borg_exit_codes = config.get('borg_exit_codes')
|
||||
|
||||
json_listing = execute_command_and_capture_output(
|
||||
json_command,
|
||||
extra_environment=borg_environment,
|
||||
environment=environment.make_environment(config),
|
||||
working_directory=working_directory,
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=borg_exit_codes,
|
||||
)
|
||||
|
||||
if rlist_arguments.json:
|
||||
if repo_list_arguments.json:
|
||||
return json_listing
|
||||
|
||||
flags.warn_for_aggressive_archive_flags(json_command, json_listing)
|
||||
@@ -165,7 +178,8 @@ def list_repository(
|
||||
execute_command(
|
||||
main_command,
|
||||
output_log_level=logging.ANSWER,
|
||||
extra_environment=borg_environment,
|
||||
environment=environment.make_environment(config),
|
||||
working_directory=working_directory,
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=borg_exit_codes,
|
||||
)
|
||||
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
|
||||
import borgmatic.config.paths
|
||||
import borgmatic.logger
|
||||
from borgmatic.borg import environment, flags
|
||||
from borgmatic.execute import DO_NOT_CAPTURE, execute_command
|
||||
@@ -29,6 +30,7 @@ def transfer_archives(
|
||||
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
|
||||
+ (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ())
|
||||
+ flags.make_flags('remote-path', remote_path)
|
||||
+ flags.make_flags('umask', config.get('umask'))
|
||||
+ flags.make_flags('log-json', global_arguments.log_json)
|
||||
+ flags.make_flags('lock-wait', config.get('lock_wait', None))
|
||||
+ (
|
||||
@@ -55,7 +57,8 @@ def transfer_archives(
|
||||
full_command,
|
||||
output_log_level=logging.ANSWER,
|
||||
output_file=DO_NOT_CAPTURE if transfer_arguments.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'),
|
||||
extra_environment=environment.make_environment(config),
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
|
||||
import borgmatic.config.paths
|
||||
from borgmatic.execute import execute_command
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -18,5 +19,8 @@ def unmount_archive(config, mount_point, local_path='borg'):
|
||||
)
|
||||
|
||||
execute_command(
|
||||
full_command, borg_local_path=local_path, borg_exit_codes=config.get('borg_exit_codes')
|
||||
full_command,
|
||||
working_directory=borgmatic.config.paths.get_working_directory(config),
|
||||
borg_local_path=local_path,
|
||||
borg_exit_codes=config.get('borg_exit_codes'),
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
|
||||
import borgmatic.config.paths
|
||||
from borgmatic.borg import environment
|
||||
from borgmatic.execute import execute_command_and_capture_output
|
||||
|
||||
@@ -8,7 +9,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
def local_borg_version(config, local_path='borg'):
|
||||
'''
|
||||
Given a configuration dict and a local Borg binary path, return a version string for it.
|
||||
Given a configuration dict and a local Borg executable path, return a version string for it.
|
||||
|
||||
Raise OSError or CalledProcessError if there is a problem running Borg.
|
||||
Raise ValueError if the version cannot be parsed.
|
||||
@@ -20,7 +21,8 @@ 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'),
|
||||
)
|
||||
|
||||
+355
-90
@@ -6,20 +6,22 @@ from argparse import ArgumentParser
|
||||
from borgmatic.config import collect
|
||||
|
||||
ACTION_ALIASES = {
|
||||
'rcreate': ['init', '-I'],
|
||||
'repo-create': ['rcreate', 'init', '-I'],
|
||||
'prune': ['-p'],
|
||||
'compact': [],
|
||||
'create': ['-C'],
|
||||
'check': ['-k'],
|
||||
'config': [],
|
||||
'delete': [],
|
||||
'extract': ['-x'],
|
||||
'export-tar': [],
|
||||
'mount': ['-m'],
|
||||
'umount': ['-u'],
|
||||
'restore': ['-r'],
|
||||
'rlist': [],
|
||||
'repo-delete': ['rdelete'],
|
||||
'repo-list': ['rlist'],
|
||||
'list': ['-l'],
|
||||
'rinfo': [],
|
||||
'repo-info': ['rinfo'],
|
||||
'info': ['-i'],
|
||||
'transfer': [],
|
||||
'break-lock': [],
|
||||
@@ -72,11 +74,11 @@ def omit_values_colliding_with_action_names(unparsed_arguments, parsed_arguments
|
||||
for action_name, parsed in parsed_arguments.items():
|
||||
for value in vars(parsed).values():
|
||||
if isinstance(value, str):
|
||||
if value in ACTION_ALIASES.keys():
|
||||
if value in ACTION_ALIASES.keys() and value in remaining_arguments:
|
||||
remaining_arguments.remove(value)
|
||||
elif isinstance(value, list):
|
||||
for item in value:
|
||||
if item in ACTION_ALIASES.keys():
|
||||
if item in ACTION_ALIASES.keys() and item in remaining_arguments:
|
||||
remaining_arguments.remove(item)
|
||||
|
||||
return tuple(remaining_arguments)
|
||||
@@ -113,6 +115,54 @@ def parse_and_record_action_arguments(
|
||||
return tuple(argument for argument in remaining if argument != action_name)
|
||||
|
||||
|
||||
def argument_is_flag(argument):
|
||||
'''
|
||||
Return True if the given argument looks like a flag, e.g. '--some-flag', as opposed to a
|
||||
non-flag value.
|
||||
'''
|
||||
return isinstance(argument, str) and argument.startswith('--')
|
||||
|
||||
|
||||
def group_arguments_with_values(arguments):
|
||||
'''
|
||||
Given a sequence of arguments, return a sequence of tuples where each one contains either a
|
||||
single argument (such as for a stand-alone flag) or a flag argument and its corresponding value.
|
||||
|
||||
For instance, given the following arguments sequence as input:
|
||||
|
||||
('--foo', '--bar', '33', '--baz')
|
||||
|
||||
... return the following output:
|
||||
|
||||
(('--foo',), ('--bar', '33'), ('--baz',))
|
||||
'''
|
||||
grouped_arguments = []
|
||||
index = 0
|
||||
|
||||
while index < len(arguments):
|
||||
this_argument = arguments[index]
|
||||
|
||||
try:
|
||||
next_argument = arguments[index + 1]
|
||||
except IndexError:
|
||||
grouped_arguments.append((this_argument,))
|
||||
break
|
||||
|
||||
if (
|
||||
argument_is_flag(this_argument)
|
||||
and not argument_is_flag(next_argument)
|
||||
and next_argument not in ACTION_ALIASES
|
||||
):
|
||||
grouped_arguments.append((this_argument, next_argument))
|
||||
index += 2
|
||||
continue
|
||||
|
||||
grouped_arguments.append((this_argument,))
|
||||
index += 1
|
||||
|
||||
return tuple(grouped_arguments)
|
||||
|
||||
|
||||
def get_unparsable_arguments(remaining_action_arguments):
|
||||
'''
|
||||
Given a sequence of argument tuples (one per action parser that parsed arguments), determine the
|
||||
@@ -121,12 +171,21 @@ def get_unparsable_arguments(remaining_action_arguments):
|
||||
if not remaining_action_arguments:
|
||||
return ()
|
||||
|
||||
grouped_action_arguments = tuple(
|
||||
group_arguments_with_values(action_arguments)
|
||||
for action_arguments in remaining_action_arguments
|
||||
)
|
||||
|
||||
return tuple(
|
||||
argument
|
||||
for argument in dict.fromkeys(
|
||||
itertools.chain.from_iterable(remaining_action_arguments)
|
||||
).keys()
|
||||
if all(argument in action_arguments for action_arguments in remaining_action_arguments)
|
||||
itertools.chain.from_iterable(
|
||||
argument_group
|
||||
for argument_group in dict.fromkeys(
|
||||
itertools.chain.from_iterable(grouped_action_arguments)
|
||||
).keys()
|
||||
if all(
|
||||
argument_group in action_arguments for action_arguments in grouped_action_arguments
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -241,7 +300,7 @@ def make_parsers():
|
||||
'--config',
|
||||
dest='config_paths',
|
||||
action='append',
|
||||
help=f"Configuration filename or directory, can specify flag multiple times, defaults to: {' '.join(unexpanded_config_paths)}",
|
||||
help=f"Configuration filename or directory, can specify flag multiple times, defaults to: -c {' -c '.join(unexpanded_config_paths)}",
|
||||
)
|
||||
global_group.add_argument(
|
||||
'-n',
|
||||
@@ -290,12 +349,12 @@ def make_parsers():
|
||||
global_group.add_argument(
|
||||
'--log-file-format',
|
||||
type=str,
|
||||
help='Log format string used for log messages written to the log file',
|
||||
help='Python format string used for log messages written to the log file',
|
||||
)
|
||||
global_group.add_argument(
|
||||
'--log-json',
|
||||
action='store_true',
|
||||
help='Write log messages and console output as one JSON object per log line instead of formatted text',
|
||||
help='Write Borg log messages and console output as one JSON object per log line instead of formatted text',
|
||||
)
|
||||
global_group.add_argument(
|
||||
'--override',
|
||||
@@ -343,51 +402,51 @@ def make_parsers():
|
||||
metavar='',
|
||||
help='Specify zero or more actions. Defaults to create, prune, compact, and check. Use --help with action for details:',
|
||||
)
|
||||
rcreate_parser = action_parsers.add_parser(
|
||||
'rcreate',
|
||||
aliases=ACTION_ALIASES['rcreate'],
|
||||
repo_create_parser = action_parsers.add_parser(
|
||||
'repo-create',
|
||||
aliases=ACTION_ALIASES['repo-create'],
|
||||
help='Create a new, empty Borg repository',
|
||||
description='Create a new, empty Borg repository',
|
||||
add_help=False,
|
||||
)
|
||||
rcreate_group = rcreate_parser.add_argument_group('rcreate arguments')
|
||||
rcreate_group.add_argument(
|
||||
repo_create_group = repo_create_parser.add_argument_group('repo-create arguments')
|
||||
repo_create_group.add_argument(
|
||||
'-e',
|
||||
'--encryption',
|
||||
dest='encryption_mode',
|
||||
help='Borg repository encryption mode',
|
||||
required=True,
|
||||
)
|
||||
rcreate_group.add_argument(
|
||||
repo_create_group.add_argument(
|
||||
'--source-repository',
|
||||
'--other-repo',
|
||||
metavar='KEY_REPOSITORY',
|
||||
help='Path to an existing Borg repository whose key material should be reused [Borg 2.x+ only]',
|
||||
)
|
||||
rcreate_group.add_argument(
|
||||
repo_create_group.add_argument(
|
||||
'--repository',
|
||||
help='Path of the new repository to create (must be already specified in a borgmatic configuration file), defaults to the configured repository if there is only one',
|
||||
help='Path of the new repository to create (must be already specified in a borgmatic configuration file), defaults to the configured repository if there is only one, quoted globs supported',
|
||||
)
|
||||
rcreate_group.add_argument(
|
||||
repo_create_group.add_argument(
|
||||
'--copy-crypt-key',
|
||||
action='store_true',
|
||||
help='Copy the crypt key used for authenticated encryption from the source repository, defaults to a new random key [Borg 2.x+ only]',
|
||||
)
|
||||
rcreate_group.add_argument(
|
||||
repo_create_group.add_argument(
|
||||
'--append-only',
|
||||
action='store_true',
|
||||
help='Create an append-only repository',
|
||||
)
|
||||
rcreate_group.add_argument(
|
||||
repo_create_group.add_argument(
|
||||
'--storage-quota',
|
||||
help='Create a repository with a fixed storage quota',
|
||||
)
|
||||
rcreate_group.add_argument(
|
||||
repo_create_group.add_argument(
|
||||
'--make-parent-dirs',
|
||||
action='store_true',
|
||||
help='Create any missing parent directories of the repository directory',
|
||||
)
|
||||
rcreate_group.add_argument(
|
||||
repo_create_group.add_argument(
|
||||
'-h', '--help', action='help', help='Show this help message and exit'
|
||||
)
|
||||
|
||||
@@ -401,7 +460,7 @@ def make_parsers():
|
||||
transfer_group = transfer_parser.add_argument_group('transfer arguments')
|
||||
transfer_group.add_argument(
|
||||
'--repository',
|
||||
help='Path of existing destination repository to transfer archives to, defaults to the configured repository if there is only one',
|
||||
help='Path of existing destination repository to transfer archives to, defaults to the configured repository if there is only one, quoted globs supported',
|
||||
)
|
||||
transfer_group.add_argument(
|
||||
'--source-repository',
|
||||
@@ -410,7 +469,7 @@ def make_parsers():
|
||||
)
|
||||
transfer_group.add_argument(
|
||||
'--archive',
|
||||
help='Name of single archive to transfer (or "latest"), defaults to transferring all archives',
|
||||
help='Name or hash of a single archive to transfer (or "latest"), defaults to transferring all archives',
|
||||
)
|
||||
transfer_group.add_argument(
|
||||
'--upgrader',
|
||||
@@ -427,7 +486,7 @@ def make_parsers():
|
||||
'--match-archives',
|
||||
'--glob-archives',
|
||||
metavar='PATTERN',
|
||||
help='Only transfer archives with names matching this pattern',
|
||||
help='Only transfer archives with names, hashes, or series matching this pattern',
|
||||
)
|
||||
transfer_group.add_argument(
|
||||
'--sort-by', metavar='KEYS', help='Comma-separated list of sorting keys'
|
||||
@@ -474,14 +533,21 @@ def make_parsers():
|
||||
prune_group = prune_parser.add_argument_group('prune arguments')
|
||||
prune_group.add_argument(
|
||||
'--repository',
|
||||
help='Path of specific existing repository to prune (must be already specified in a borgmatic configuration file)',
|
||||
help='Path of specific existing repository to prune (must be already specified in a borgmatic configuration file), quoted globs supported',
|
||||
)
|
||||
prune_group.add_argument(
|
||||
'-a',
|
||||
'--match-archives',
|
||||
'--glob-archives',
|
||||
metavar='PATTERN',
|
||||
help='When pruning, only consider archives with names, hashes, or series matching this pattern',
|
||||
)
|
||||
prune_group.add_argument(
|
||||
'--stats',
|
||||
dest='stats',
|
||||
default=False,
|
||||
action='store_true',
|
||||
help='Display statistics of archive',
|
||||
help='Display statistics of the pruned archive',
|
||||
)
|
||||
prune_group.add_argument(
|
||||
'--list', dest='list_archives', action='store_true', help='List archives kept/pruned'
|
||||
@@ -518,7 +584,7 @@ def make_parsers():
|
||||
compact_group = compact_parser.add_argument_group('compact arguments')
|
||||
compact_group.add_argument(
|
||||
'--repository',
|
||||
help='Path of specific existing repository to compact (must be already specified in a borgmatic configuration file)',
|
||||
help='Path of specific existing repository to compact (must be already specified in a borgmatic configuration file), quoted globs supported',
|
||||
)
|
||||
compact_group.add_argument(
|
||||
'--progress',
|
||||
@@ -554,7 +620,7 @@ def make_parsers():
|
||||
create_group = create_parser.add_argument_group('create arguments')
|
||||
create_group.add_argument(
|
||||
'--repository',
|
||||
help='Path of specific existing repository to backup to (must be already specified in a borgmatic configuration file)',
|
||||
help='Path of specific existing repository to backup to (must be already specified in a borgmatic configuration file), quoted globs supported',
|
||||
)
|
||||
create_group.add_argument(
|
||||
'--progress',
|
||||
@@ -588,7 +654,7 @@ def make_parsers():
|
||||
check_group = check_parser.add_argument_group('check arguments')
|
||||
check_group.add_argument(
|
||||
'--repository',
|
||||
help='Path of specific existing repository to check (must be already specified in a borgmatic configuration file)',
|
||||
help='Path of specific existing repository to check (must be already specified in a borgmatic configuration file), quoted globs supported',
|
||||
)
|
||||
check_group.add_argument(
|
||||
'--progress',
|
||||
@@ -604,20 +670,25 @@ def make_parsers():
|
||||
action='store_true',
|
||||
help='Attempt to repair any inconsistencies found (for interactive use)',
|
||||
)
|
||||
check_group.add_argument(
|
||||
'--max-duration',
|
||||
metavar='SECONDS',
|
||||
help='How long to check the repository before interrupting the check, defaults to no interruption',
|
||||
)
|
||||
check_group.add_argument(
|
||||
'-a',
|
||||
'--match-archives',
|
||||
'--glob-archives',
|
||||
metavar='PATTERN',
|
||||
help='Only check archives with names matching this pattern',
|
||||
help='Only check archives with names, hashes, or series matching this pattern',
|
||||
)
|
||||
check_group.add_argument(
|
||||
'--only',
|
||||
metavar='CHECK',
|
||||
choices=('repository', 'archives', 'data', 'extract'),
|
||||
choices=('repository', 'archives', 'data', 'extract', 'spot'),
|
||||
dest='only_checks',
|
||||
action='append',
|
||||
help='Run a particular consistency check (repository, archives, data, or extract) instead of configured checks (subject to configured frequency, can specify flag multiple times)',
|
||||
help='Run a particular consistency check (repository, archives, data, extract, or spot) instead of configured checks (subject to configured frequency, can specify flag multiple times)',
|
||||
)
|
||||
check_group.add_argument(
|
||||
'--force',
|
||||
@@ -627,6 +698,97 @@ def make_parsers():
|
||||
)
|
||||
check_group.add_argument('-h', '--help', action='help', help='Show this help message and exit')
|
||||
|
||||
delete_parser = action_parsers.add_parser(
|
||||
'delete',
|
||||
aliases=ACTION_ALIASES['delete'],
|
||||
help='Delete an archive from a repository or delete an entire repository (with Borg 1.2+, you must run compact afterwards to actually free space)',
|
||||
description='Delete an archive from a repository or delete an entire repository (with Borg 1.2+, you must run compact afterwards to actually free space)',
|
||||
add_help=False,
|
||||
)
|
||||
delete_group = delete_parser.add_argument_group('delete arguments')
|
||||
delete_group.add_argument(
|
||||
'--repository',
|
||||
help='Path of repository to delete or delete archives from, defaults to the configured repository if there is only one, quoted globs supported',
|
||||
)
|
||||
delete_group.add_argument(
|
||||
'--archive',
|
||||
help='Archive name, hash, or series to delete',
|
||||
)
|
||||
delete_group.add_argument(
|
||||
'--list',
|
||||
dest='list_archives',
|
||||
action='store_true',
|
||||
help='Show details for the deleted archives',
|
||||
)
|
||||
delete_group.add_argument(
|
||||
'--stats',
|
||||
action='store_true',
|
||||
help='Display statistics for the deleted archives',
|
||||
)
|
||||
delete_group.add_argument(
|
||||
'--cache-only',
|
||||
action='store_true',
|
||||
help='Delete only the local cache for the given repository',
|
||||
)
|
||||
delete_group.add_argument(
|
||||
'--force',
|
||||
action='count',
|
||||
help='Force deletion of corrupted archives, can be given twice if once does not work',
|
||||
)
|
||||
delete_group.add_argument(
|
||||
'--keep-security-info',
|
||||
action='store_true',
|
||||
help='Do not delete the local security info when deleting a repository',
|
||||
)
|
||||
delete_group.add_argument(
|
||||
'--save-space',
|
||||
action='store_true',
|
||||
help='Work slower, but using less space [Not supported in Borg 2.x+]',
|
||||
)
|
||||
delete_group.add_argument(
|
||||
'--checkpoint-interval',
|
||||
type=int,
|
||||
metavar='SECONDS',
|
||||
help='Write a checkpoint at the given interval, defaults to 1800 seconds (30 minutes)',
|
||||
)
|
||||
delete_group.add_argument(
|
||||
'-a',
|
||||
'--match-archives',
|
||||
'--glob-archives',
|
||||
metavar='PATTERN',
|
||||
help='Only delete archives with names, hashes, or series matching this pattern',
|
||||
)
|
||||
delete_group.add_argument(
|
||||
'--sort-by', metavar='KEYS', help='Comma-separated list of sorting keys'
|
||||
)
|
||||
delete_group.add_argument(
|
||||
'--first', metavar='N', help='Delete first N archives after other filters are applied'
|
||||
)
|
||||
delete_group.add_argument(
|
||||
'--last', metavar='N', help='Delete last N archives after other filters are applied'
|
||||
)
|
||||
delete_group.add_argument(
|
||||
'--oldest',
|
||||
metavar='TIMESPAN',
|
||||
help='Delete archives within a specified time range starting from the timestamp of the oldest archive (e.g. 7d or 12m) [Borg 2.x+ only]',
|
||||
)
|
||||
delete_group.add_argument(
|
||||
'--newest',
|
||||
metavar='TIMESPAN',
|
||||
help='Delete archives within a time range that ends at timestamp of the newest archive and starts a specified time range ago (e.g. 7d or 12m) [Borg 2.x+ only]',
|
||||
)
|
||||
delete_group.add_argument(
|
||||
'--older',
|
||||
metavar='TIMESPAN',
|
||||
help='Delete archives that are older than the specified time range (e.g. 7d or 12m) from the current time [Borg 2.x+ only]',
|
||||
)
|
||||
delete_group.add_argument(
|
||||
'--newer',
|
||||
metavar='TIMESPAN',
|
||||
help='Delete archives that are newer than the specified time range (e.g. 7d or 12m) from the current time [Borg 2.x+ only]',
|
||||
)
|
||||
delete_group.add_argument('-h', '--help', action='help', help='Show this help message and exit')
|
||||
|
||||
extract_parser = action_parsers.add_parser(
|
||||
'extract',
|
||||
aliases=ACTION_ALIASES['extract'],
|
||||
@@ -637,10 +799,10 @@ def make_parsers():
|
||||
extract_group = extract_parser.add_argument_group('extract arguments')
|
||||
extract_group.add_argument(
|
||||
'--repository',
|
||||
help='Path of repository to extract, defaults to the configured repository if there is only one',
|
||||
help='Path of repository to extract, defaults to the configured repository if there is only one, quoted globs supported',
|
||||
)
|
||||
extract_group.add_argument(
|
||||
'--archive', help='Name of archive to extract (or "latest")', required=True
|
||||
'--archive', help='Name or hash of a single archive to extract (or "latest")', required=True
|
||||
)
|
||||
extract_group.add_argument(
|
||||
'--path',
|
||||
@@ -699,16 +861,30 @@ def make_parsers():
|
||||
)
|
||||
config_bootstrap_group.add_argument(
|
||||
'--repository',
|
||||
help='Path of repository to extract config files from',
|
||||
help='Path of repository to extract config files from, quoted globs supported',
|
||||
required=True,
|
||||
)
|
||||
config_bootstrap_group.add_argument(
|
||||
'--local-path',
|
||||
help='Alternate Borg local executable. Defaults to "borg"',
|
||||
default='borg',
|
||||
)
|
||||
config_bootstrap_group.add_argument(
|
||||
'--remote-path',
|
||||
help='Alternate Borg remote executable. Defaults to "borg"',
|
||||
default='borg',
|
||||
)
|
||||
config_bootstrap_group.add_argument(
|
||||
'--user-runtime-directory',
|
||||
help='Path used for temporary runtime data like bootstrap metadata. Defaults to $XDG_RUNTIME_DIR or $TMPDIR or $TEMP or /var/run/$UID',
|
||||
)
|
||||
config_bootstrap_group.add_argument(
|
||||
'--borgmatic-source-directory',
|
||||
help='Path that stores the config files used to create an archive and additional source files used for temporary internal state like borgmatic database dumps. Defaults to ~/.borgmatic',
|
||||
help='Deprecated. Path formerly used for temporary runtime data like bootstrap metadata. Defaults to ~/.borgmatic',
|
||||
)
|
||||
config_bootstrap_group.add_argument(
|
||||
'--archive',
|
||||
help='Name of archive to extract config files from, defaults to "latest"',
|
||||
help='Name or hash of a single archive to extract config files from, defaults to "latest"',
|
||||
default='latest',
|
||||
)
|
||||
config_bootstrap_group.add_argument(
|
||||
@@ -797,10 +973,10 @@ def make_parsers():
|
||||
export_tar_group = export_tar_parser.add_argument_group('export-tar arguments')
|
||||
export_tar_group.add_argument(
|
||||
'--repository',
|
||||
help='Path of repository to export from, defaults to the configured repository if there is only one',
|
||||
help='Path of repository to export from, defaults to the configured repository if there is only one, quoted globs supported',
|
||||
)
|
||||
export_tar_group.add_argument(
|
||||
'--archive', help='Name of archive to export (or "latest")', required=True
|
||||
'--archive', help='Name or hash of a single archive to export (or "latest")', required=True
|
||||
)
|
||||
export_tar_group.add_argument(
|
||||
'--path',
|
||||
@@ -843,9 +1019,11 @@ def make_parsers():
|
||||
mount_group = mount_parser.add_argument_group('mount arguments')
|
||||
mount_group.add_argument(
|
||||
'--repository',
|
||||
help='Path of repository to use, defaults to the configured repository if there is only one',
|
||||
help='Path of repository to use, defaults to the configured repository if there is only one, quoted globs supported',
|
||||
)
|
||||
mount_group.add_argument(
|
||||
'--archive', help='Name or hash of a single archive to mount (or "latest")'
|
||||
)
|
||||
mount_group.add_argument('--archive', help='Name of archive to mount (or "latest")')
|
||||
mount_group.add_argument(
|
||||
'--mount-point',
|
||||
metavar='PATH',
|
||||
@@ -915,6 +1093,43 @@ def make_parsers():
|
||||
)
|
||||
umount_group.add_argument('-h', '--help', action='help', help='Show this help message and exit')
|
||||
|
||||
repo_delete_parser = action_parsers.add_parser(
|
||||
'repo-delete',
|
||||
aliases=ACTION_ALIASES['repo-delete'],
|
||||
help='Delete an entire repository (with Borg 1.2+, you must run compact afterwards to actually free space)',
|
||||
description='Delete an entire repository (with Borg 1.2+, you must run compact afterwards to actually free space)',
|
||||
add_help=False,
|
||||
)
|
||||
repo_delete_group = repo_delete_parser.add_argument_group('delete arguments')
|
||||
repo_delete_group.add_argument(
|
||||
'--repository',
|
||||
help='Path of repository to delete, defaults to the configured repository if there is only one, quoted globs supported',
|
||||
)
|
||||
repo_delete_group.add_argument(
|
||||
'--list',
|
||||
dest='list_archives',
|
||||
action='store_true',
|
||||
help='Show details for the archives in the given repository',
|
||||
)
|
||||
repo_delete_group.add_argument(
|
||||
'--force',
|
||||
action='count',
|
||||
help='Force deletion of corrupted archives, can be given twice if once does not work',
|
||||
)
|
||||
repo_delete_group.add_argument(
|
||||
'--cache-only',
|
||||
action='store_true',
|
||||
help='Delete only the local cache for the given repository',
|
||||
)
|
||||
repo_delete_group.add_argument(
|
||||
'--keep-security-info',
|
||||
action='store_true',
|
||||
help='Do not delete the local security info when deleting a repository',
|
||||
)
|
||||
repo_delete_group.add_argument(
|
||||
'-h', '--help', action='help', help='Show this help message and exit'
|
||||
)
|
||||
|
||||
restore_parser = action_parsers.add_parser(
|
||||
'restore',
|
||||
aliases=ACTION_ALIASES['restore'],
|
||||
@@ -925,10 +1140,12 @@ def make_parsers():
|
||||
restore_group = restore_parser.add_argument_group('restore arguments')
|
||||
restore_group.add_argument(
|
||||
'--repository',
|
||||
help='Path of repository to restore from, defaults to the configured repository if there is only one',
|
||||
help='Path of repository to restore from, defaults to the configured repository if there is only one, quoted globs supported',
|
||||
)
|
||||
restore_group.add_argument(
|
||||
'--archive', help='Name of archive to restore from (or "latest")', required=True
|
||||
'--archive',
|
||||
help='Name or hash of a single archive to restore from (or "latest")',
|
||||
required=True,
|
||||
)
|
||||
restore_group.add_argument(
|
||||
'--data-source',
|
||||
@@ -936,7 +1153,7 @@ def make_parsers():
|
||||
metavar='NAME',
|
||||
dest='data_sources',
|
||||
action='append',
|
||||
help="Name of data source (e.g. database) to restore from archive, must be defined in borgmatic's configuration, can specify flag multiple times, defaults to all data sources in the archive",
|
||||
help="Name of data source (e.g. database) to restore from the archive, must be defined in borgmatic's configuration, can specify the flag multiple times, defaults to all data sources in the archive",
|
||||
)
|
||||
restore_group.add_argument(
|
||||
'--schema',
|
||||
@@ -965,69 +1182,90 @@ def make_parsers():
|
||||
'--restore-path',
|
||||
help='Path to restore SQLite database dumps to. Defaults to the "restore_path" option in borgmatic\'s configuration',
|
||||
)
|
||||
restore_group.add_argument(
|
||||
'--original-hostname',
|
||||
help='The hostname where the dump to restore came from, only necessary if you need to disambiguate dumps',
|
||||
)
|
||||
restore_group.add_argument(
|
||||
'--original-port',
|
||||
type=int,
|
||||
help="The port where the dump to restore came from (if that port is in borgmatic's configuration), only necessary if you need to disambiguate dumps",
|
||||
)
|
||||
restore_group.add_argument(
|
||||
'--hook',
|
||||
help='The name of the data source hook for the dump to restore, only necessary if you need to disambiguate dumps',
|
||||
)
|
||||
restore_group.add_argument(
|
||||
'-h', '--help', action='help', help='Show this help message and exit'
|
||||
)
|
||||
|
||||
rlist_parser = action_parsers.add_parser(
|
||||
'rlist',
|
||||
aliases=ACTION_ALIASES['rlist'],
|
||||
repo_list_parser = action_parsers.add_parser(
|
||||
'repo-list',
|
||||
aliases=ACTION_ALIASES['repo-list'],
|
||||
help='List repository',
|
||||
description='List the archives in a repository',
|
||||
add_help=False,
|
||||
)
|
||||
rlist_group = rlist_parser.add_argument_group('rlist arguments')
|
||||
rlist_group.add_argument(
|
||||
repo_list_group = repo_list_parser.add_argument_group('repo-list arguments')
|
||||
repo_list_group.add_argument(
|
||||
'--repository',
|
||||
help='Path of repository to list, defaults to the configured repositories',
|
||||
help='Path of repository to list, defaults to the configured repositories, quoted globs supported',
|
||||
)
|
||||
rlist_group.add_argument(
|
||||
repo_list_group.add_argument(
|
||||
'--short', default=False, action='store_true', help='Output only archive names'
|
||||
)
|
||||
rlist_group.add_argument('--format', help='Format for archive listing')
|
||||
rlist_group.add_argument(
|
||||
repo_list_group.add_argument('--format', help='Format for archive listing')
|
||||
repo_list_group.add_argument(
|
||||
'--json', default=False, action='store_true', help='Output results as JSON'
|
||||
)
|
||||
rlist_group.add_argument(
|
||||
repo_list_group.add_argument(
|
||||
'-P', '--prefix', help='Deprecated. Only list archive names starting with this prefix'
|
||||
)
|
||||
rlist_group.add_argument(
|
||||
repo_list_group.add_argument(
|
||||
'-a',
|
||||
'--match-archives',
|
||||
'--glob-archives',
|
||||
metavar='PATTERN',
|
||||
help='Only list archive names matching this pattern',
|
||||
help='Only list archive names, hashes, or series matching this pattern',
|
||||
)
|
||||
rlist_group.add_argument(
|
||||
repo_list_group.add_argument(
|
||||
'--sort-by', metavar='KEYS', help='Comma-separated list of sorting keys'
|
||||
)
|
||||
rlist_group.add_argument(
|
||||
repo_list_group.add_argument(
|
||||
'--first', metavar='N', help='List first N archives after other filters are applied'
|
||||
)
|
||||
rlist_group.add_argument(
|
||||
repo_list_group.add_argument(
|
||||
'--last', metavar='N', help='List last N archives after other filters are applied'
|
||||
)
|
||||
rlist_group.add_argument(
|
||||
repo_list_group.add_argument(
|
||||
'--oldest',
|
||||
metavar='TIMESPAN',
|
||||
help='List archives within a specified time range starting from the timestamp of the oldest archive (e.g. 7d or 12m) [Borg 2.x+ only]',
|
||||
)
|
||||
rlist_group.add_argument(
|
||||
repo_list_group.add_argument(
|
||||
'--newest',
|
||||
metavar='TIMESPAN',
|
||||
help='List archives within a time range that ends at timestamp of the newest archive and starts a specified time range ago (e.g. 7d or 12m) [Borg 2.x+ only]',
|
||||
)
|
||||
rlist_group.add_argument(
|
||||
repo_list_group.add_argument(
|
||||
'--older',
|
||||
metavar='TIMESPAN',
|
||||
help='List archives that are older than the specified time range (e.g. 7d or 12m) from the current time [Borg 2.x+ only]',
|
||||
)
|
||||
rlist_group.add_argument(
|
||||
repo_list_group.add_argument(
|
||||
'--newer',
|
||||
metavar='TIMESPAN',
|
||||
help='List archives that are newer than the specified time range (e.g. 7d or 12m) from the current time [Borg 2.x+ only]',
|
||||
)
|
||||
rlist_group.add_argument('-h', '--help', action='help', help='Show this help message and exit')
|
||||
repo_list_group.add_argument(
|
||||
'--deleted',
|
||||
default=False,
|
||||
action='store_true',
|
||||
help="List only deleted archives that haven't yet been compacted [Borg 2.x+ only]",
|
||||
)
|
||||
repo_list_group.add_argument(
|
||||
'-h', '--help', action='help', help='Show this help message and exit'
|
||||
)
|
||||
|
||||
list_parser = action_parsers.add_parser(
|
||||
'list',
|
||||
@@ -1039,9 +1277,11 @@ def make_parsers():
|
||||
list_group = list_parser.add_argument_group('list arguments')
|
||||
list_group.add_argument(
|
||||
'--repository',
|
||||
help='Path of repository containing archive to list, defaults to the configured repositories',
|
||||
help='Path of repository containing archive to list, defaults to the configured repositories, quoted globs supported',
|
||||
)
|
||||
list_group.add_argument(
|
||||
'--archive', help='Name or hash of a single archive to list (or "latest")'
|
||||
)
|
||||
list_group.add_argument('--archive', help='Name of the archive to list (or "latest")')
|
||||
list_group.add_argument(
|
||||
'--path',
|
||||
metavar='PATH',
|
||||
@@ -1096,22 +1336,24 @@ def make_parsers():
|
||||
)
|
||||
list_group.add_argument('-h', '--help', action='help', help='Show this help message and exit')
|
||||
|
||||
rinfo_parser = action_parsers.add_parser(
|
||||
'rinfo',
|
||||
aliases=ACTION_ALIASES['rinfo'],
|
||||
repo_info_parser = action_parsers.add_parser(
|
||||
'repo-info',
|
||||
aliases=ACTION_ALIASES['repo-info'],
|
||||
help='Show repository summary information such as disk space used',
|
||||
description='Show repository summary information such as disk space used',
|
||||
add_help=False,
|
||||
)
|
||||
rinfo_group = rinfo_parser.add_argument_group('rinfo arguments')
|
||||
rinfo_group.add_argument(
|
||||
repo_info_group = repo_info_parser.add_argument_group('repo-info arguments')
|
||||
repo_info_group.add_argument(
|
||||
'--repository',
|
||||
help='Path of repository to show info for, defaults to the configured repository if there is only one',
|
||||
help='Path of repository to show info for, defaults to the configured repository if there is only one, quoted globs supported',
|
||||
)
|
||||
rinfo_group.add_argument(
|
||||
repo_info_group.add_argument(
|
||||
'--json', dest='json', default=False, action='store_true', help='Output results as JSON'
|
||||
)
|
||||
rinfo_group.add_argument('-h', '--help', action='help', help='Show this help message and exit')
|
||||
repo_info_group.add_argument(
|
||||
'-h', '--help', action='help', help='Show this help message and exit'
|
||||
)
|
||||
|
||||
info_parser = action_parsers.add_parser(
|
||||
'info',
|
||||
@@ -1123,9 +1365,11 @@ def make_parsers():
|
||||
info_group = info_parser.add_argument_group('info arguments')
|
||||
info_group.add_argument(
|
||||
'--repository',
|
||||
help='Path of repository containing archive to show info for, defaults to the configured repository if there is only one',
|
||||
help='Path of repository containing archive to show info for, defaults to the configured repository if there is only one, quoted globs supported',
|
||||
)
|
||||
info_group.add_argument(
|
||||
'--archive', help='Archive name, hash, or series to show info for (or "latest")'
|
||||
)
|
||||
info_group.add_argument('--archive', help='Name of archive to show info for (or "latest")')
|
||||
info_group.add_argument(
|
||||
'--json', dest='json', default=False, action='store_true', help='Output results as JSON'
|
||||
)
|
||||
@@ -1139,7 +1383,7 @@ def make_parsers():
|
||||
'--match-archives',
|
||||
'--glob-archives',
|
||||
metavar='PATTERN',
|
||||
help='Only show info for archive names matching this pattern',
|
||||
help='Only show info for archive names, hashes, or series matching this pattern',
|
||||
)
|
||||
info_group.add_argument(
|
||||
'--sort-by', metavar='KEYS', help='Comma-separated list of sorting keys'
|
||||
@@ -1184,7 +1428,7 @@ def make_parsers():
|
||||
break_lock_group = break_lock_parser.add_argument_group('break-lock arguments')
|
||||
break_lock_group.add_argument(
|
||||
'--repository',
|
||||
help='Path of repository to break the lock for, defaults to the configured repository if there is only one',
|
||||
help='Path of repository to break the lock for, defaults to the configured repository if there is only one, quoted globs supported',
|
||||
)
|
||||
break_lock_group.add_argument(
|
||||
'-h', '--help', action='help', help='Show this help message and exit'
|
||||
@@ -1224,7 +1468,7 @@ def make_parsers():
|
||||
)
|
||||
key_export_group.add_argument(
|
||||
'--repository',
|
||||
help='Path of repository to export the key for, defaults to the configured repository if there is only one',
|
||||
help='Path of repository to export the key for, defaults to the configured repository if there is only one, quoted globs supported',
|
||||
)
|
||||
key_export_group.add_argument(
|
||||
'--path',
|
||||
@@ -1235,6 +1479,23 @@ def make_parsers():
|
||||
'-h', '--help', action='help', help='Show this help message and exit'
|
||||
)
|
||||
|
||||
key_change_passphrase_parser = key_parsers.add_parser(
|
||||
'change-passphrase',
|
||||
help='Change the passphrase protecting the repository key',
|
||||
description='Change the passphrase protecting the repository key',
|
||||
add_help=False,
|
||||
)
|
||||
key_change_passphrase_group = key_change_passphrase_parser.add_argument_group(
|
||||
'key change-passphrase arguments'
|
||||
)
|
||||
key_change_passphrase_group.add_argument(
|
||||
'--repository',
|
||||
help='Path of repository to change the passphrase for, defaults to the configured repository if there is only one, quoted globs supported',
|
||||
)
|
||||
key_change_passphrase_group.add_argument(
|
||||
'-h', '--help', action='help', help='Show this help message and exit'
|
||||
)
|
||||
|
||||
borg_parser = action_parsers.add_parser(
|
||||
'borg',
|
||||
aliases=ACTION_ALIASES['borg'],
|
||||
@@ -1245,9 +1506,11 @@ def make_parsers():
|
||||
borg_group = borg_parser.add_argument_group('borg arguments')
|
||||
borg_group.add_argument(
|
||||
'--repository',
|
||||
help='Path of repository to pass to Borg, defaults to the configured repositories',
|
||||
help='Path of repository to pass to Borg, defaults to the configured repositories, quoted globs supported',
|
||||
)
|
||||
borg_group.add_argument(
|
||||
'--archive', help='Archive name, hash, or series to pass to Borg (or "latest")'
|
||||
)
|
||||
borg_group.add_argument('--archive', help='Name of archive to pass to Borg (or "latest")')
|
||||
borg_group.add_argument(
|
||||
'--',
|
||||
metavar='OPTION',
|
||||
@@ -1306,9 +1569,9 @@ def parse_arguments(*unparsed_arguments):
|
||||
)
|
||||
|
||||
if (
|
||||
('list' in arguments and 'rinfo' in arguments and arguments['list'].json)
|
||||
('list' in arguments and 'repo-info' in arguments and arguments['list'].json)
|
||||
or ('list' in arguments and 'info' in arguments and arguments['list'].json)
|
||||
or ('rinfo' in arguments and 'info' in arguments and arguments['rinfo'].json)
|
||||
or ('repo-info' in arguments and 'info' in arguments and arguments['repo-info'].json)
|
||||
):
|
||||
raise ValueError('With the --json flag, multiple actions cannot be used together.')
|
||||
|
||||
@@ -1326,9 +1589,11 @@ def parse_arguments(*unparsed_arguments):
|
||||
'With the list action, only one of --prefix or --match-archives flags can be used.'
|
||||
)
|
||||
|
||||
if 'rlist' in arguments and (arguments['rlist'].prefix and arguments['rlist'].match_archives):
|
||||
if 'repo-list' in arguments and (
|
||||
arguments['repo-list'].prefix and arguments['repo-list'].match_archives
|
||||
):
|
||||
raise ValueError(
|
||||
'With the rlist action, only one of --prefix or --match-archives flags can be used.'
|
||||
'With the repo-list action, only one of --prefix or --match-archives flags can be used.'
|
||||
)
|
||||
|
||||
if 'info' in arguments and (
|
||||
|
||||
+168
-114
@@ -8,16 +8,16 @@ import time
|
||||
from queue import Queue
|
||||
from subprocess import CalledProcessError
|
||||
|
||||
import colorama
|
||||
|
||||
import borgmatic.actions.borg
|
||||
import borgmatic.actions.break_lock
|
||||
import borgmatic.actions.change_passphrase
|
||||
import borgmatic.actions.check
|
||||
import borgmatic.actions.compact
|
||||
import borgmatic.actions.config.bootstrap
|
||||
import borgmatic.actions.config.generate
|
||||
import borgmatic.actions.config.validate
|
||||
import borgmatic.actions.create
|
||||
import borgmatic.actions.delete
|
||||
import borgmatic.actions.export_key
|
||||
import borgmatic.actions.export_tar
|
||||
import borgmatic.actions.extract
|
||||
@@ -25,10 +25,11 @@ import borgmatic.actions.info
|
||||
import borgmatic.actions.list
|
||||
import borgmatic.actions.mount
|
||||
import borgmatic.actions.prune
|
||||
import borgmatic.actions.rcreate
|
||||
import borgmatic.actions.repo_create
|
||||
import borgmatic.actions.repo_delete
|
||||
import borgmatic.actions.repo_info
|
||||
import borgmatic.actions.repo_list
|
||||
import borgmatic.actions.restore
|
||||
import borgmatic.actions.rinfo
|
||||
import borgmatic.actions.rlist
|
||||
import borgmatic.actions.transfer
|
||||
import borgmatic.commands.completion.bash
|
||||
import borgmatic.commands.completion.fish
|
||||
@@ -36,8 +37,16 @@ from borgmatic.borg import umount as borg_umount
|
||||
from borgmatic.borg import version as borg_version
|
||||
from borgmatic.commands.arguments import parse_arguments
|
||||
from borgmatic.config import checks, collect, validate
|
||||
from borgmatic.hooks import command, dispatch, monitor
|
||||
from borgmatic.logger import DISABLED, add_custom_log_levels, configure_logging, should_do_markup
|
||||
from borgmatic.hooks import command, dispatch
|
||||
from borgmatic.hooks.monitoring import monitor
|
||||
from borgmatic.logger import (
|
||||
DISABLED,
|
||||
Log_prefix,
|
||||
add_custom_log_levels,
|
||||
configure_delayed_logging,
|
||||
configure_logging,
|
||||
should_do_markup,
|
||||
)
|
||||
from borgmatic.signals import configure_signals
|
||||
from borgmatic.verbosity import verbosity_to_log_level
|
||||
|
||||
@@ -84,12 +93,12 @@ def run_configuration(config_filename, config, config_paths, arguments):
|
||||
|
||||
if skip_actions:
|
||||
logger.debug(
|
||||
f"{config_filename}: Skipping {'/'.join(skip_actions)} action{'s' if len(skip_actions) > 1 else ''} due to configured skip_actions"
|
||||
f"Skipping {'/'.join(skip_actions)} action{'s' if len(skip_actions) > 1 else ''} due to configured skip_actions"
|
||||
)
|
||||
|
||||
try:
|
||||
local_borg_version = borg_version.local_borg_version(config, local_path)
|
||||
logger.debug(f'{config_filename}: Borg {local_borg_version}')
|
||||
logger.debug(f'Borg {local_borg_version}')
|
||||
except (OSError, CalledProcessError, ValueError) as error:
|
||||
yield from log_error_records(f'{config_filename}: Error getting local Borg version', error)
|
||||
return
|
||||
@@ -99,8 +108,8 @@ def run_configuration(config_filename, config, config_paths, arguments):
|
||||
dispatch.call_hooks(
|
||||
'initialize_monitor',
|
||||
config,
|
||||
dispatch.Hook_type.MONITORING,
|
||||
config_filename,
|
||||
monitor.MONITOR_HOOK_NAMES,
|
||||
monitoring_log_level,
|
||||
global_arguments.dry_run,
|
||||
)
|
||||
@@ -108,14 +117,14 @@ def run_configuration(config_filename, config, config_paths, arguments):
|
||||
dispatch.call_hooks(
|
||||
'ping_monitor',
|
||||
config,
|
||||
dispatch.Hook_type.MONITORING,
|
||||
config_filename,
|
||||
monitor.MONITOR_HOOK_NAMES,
|
||||
monitor.State.START,
|
||||
monitoring_log_level,
|
||||
global_arguments.dry_run,
|
||||
)
|
||||
except (OSError, CalledProcessError) as error:
|
||||
if command.considered_soft_failure(config_filename, error):
|
||||
if command.considered_soft_failure(error):
|
||||
return
|
||||
|
||||
encountered_error = error
|
||||
@@ -130,72 +139,66 @@ def run_configuration(config_filename, config, config_paths, arguments):
|
||||
|
||||
while not repo_queue.empty():
|
||||
repository, retry_num = repo_queue.get()
|
||||
logger.debug(
|
||||
f'{repository.get("label", repository["path"])}: Running actions for repository'
|
||||
)
|
||||
timeout = retry_num * retry_wait
|
||||
if timeout:
|
||||
logger.warning(
|
||||
f'{repository.get("label", repository["path"])}: Sleeping {timeout}s before next retry'
|
||||
)
|
||||
time.sleep(timeout)
|
||||
try:
|
||||
yield from run_actions(
|
||||
arguments=arguments,
|
||||
config_filename=config_filename,
|
||||
config=config,
|
||||
config_paths=config_paths,
|
||||
local_path=local_path,
|
||||
remote_path=remote_path,
|
||||
local_borg_version=local_borg_version,
|
||||
repository=repository,
|
||||
)
|
||||
except (OSError, CalledProcessError, ValueError) as error:
|
||||
if retry_num < retries:
|
||||
repo_queue.put(
|
||||
(repository, retry_num + 1),
|
||||
|
||||
with Log_prefix(repository.get('label', repository['path'])):
|
||||
logger.debug('Running actions for repository')
|
||||
timeout = retry_num * retry_wait
|
||||
if timeout:
|
||||
logger.warning(f'Sleeping {timeout}s before next retry')
|
||||
time.sleep(timeout)
|
||||
try:
|
||||
yield from run_actions(
|
||||
arguments=arguments,
|
||||
config_filename=config_filename,
|
||||
config=config,
|
||||
config_paths=config_paths,
|
||||
local_path=local_path,
|
||||
remote_path=remote_path,
|
||||
local_borg_version=local_borg_version,
|
||||
repository=repository,
|
||||
)
|
||||
tuple( # Consume the generator so as to trigger logging.
|
||||
log_error_records(
|
||||
f'{repository.get("label", repository["path"])}: Error running actions for repository',
|
||||
error,
|
||||
levelno=logging.WARNING,
|
||||
log_command_error_output=True,
|
||||
except (OSError, CalledProcessError, ValueError) as error:
|
||||
if retry_num < retries:
|
||||
repo_queue.put(
|
||||
(repository, retry_num + 1),
|
||||
)
|
||||
)
|
||||
logger.warning(
|
||||
f'{repository.get("label", repository["path"])}: Retrying... attempt {retry_num + 1}/{retries}'
|
||||
)
|
||||
continue
|
||||
tuple( # Consume the generator so as to trigger logging.
|
||||
log_error_records(
|
||||
'Error running actions for repository',
|
||||
error,
|
||||
levelno=logging.WARNING,
|
||||
log_command_error_output=True,
|
||||
)
|
||||
)
|
||||
logger.warning(f'Retrying... attempt {retry_num + 1}/{retries}')
|
||||
continue
|
||||
|
||||
if command.considered_soft_failure(config_filename, error):
|
||||
return
|
||||
if command.considered_soft_failure(error):
|
||||
continue
|
||||
|
||||
yield from log_error_records(
|
||||
f'{repository.get("label", repository["path"])}: Error running actions for repository',
|
||||
error,
|
||||
)
|
||||
encountered_error = error
|
||||
error_repository = repository['path']
|
||||
yield from log_error_records(
|
||||
'Error running actions for repository',
|
||||
error,
|
||||
)
|
||||
encountered_error = error
|
||||
error_repository = repository['path']
|
||||
|
||||
try:
|
||||
if monitoring_hooks_are_activated:
|
||||
# send logs irrespective of error
|
||||
# Send logs irrespective of error.
|
||||
dispatch.call_hooks(
|
||||
'ping_monitor',
|
||||
config,
|
||||
dispatch.Hook_type.MONITORING,
|
||||
config_filename,
|
||||
monitor.MONITOR_HOOK_NAMES,
|
||||
monitor.State.LOG,
|
||||
monitoring_log_level,
|
||||
global_arguments.dry_run,
|
||||
)
|
||||
except (OSError, CalledProcessError) as error:
|
||||
if command.considered_soft_failure(config_filename, error):
|
||||
return
|
||||
|
||||
encountered_error = error
|
||||
yield from log_error_records(f'{repository["path"]}: Error pinging monitor', error)
|
||||
if not command.considered_soft_failure(error):
|
||||
encountered_error = error
|
||||
yield from log_error_records('Error pinging monitor', error)
|
||||
|
||||
if not encountered_error:
|
||||
try:
|
||||
@@ -203,8 +206,8 @@ def run_configuration(config_filename, config, config_paths, arguments):
|
||||
dispatch.call_hooks(
|
||||
'ping_monitor',
|
||||
config,
|
||||
dispatch.Hook_type.MONITORING,
|
||||
config_filename,
|
||||
monitor.MONITOR_HOOK_NAMES,
|
||||
monitor.State.FINISH,
|
||||
monitoring_log_level,
|
||||
global_arguments.dry_run,
|
||||
@@ -212,13 +215,12 @@ def run_configuration(config_filename, config, config_paths, arguments):
|
||||
dispatch.call_hooks(
|
||||
'destroy_monitor',
|
||||
config,
|
||||
config_filename,
|
||||
monitor.MONITOR_HOOK_NAMES,
|
||||
dispatch.Hook_type.MONITORING,
|
||||
monitoring_log_level,
|
||||
global_arguments.dry_run,
|
||||
)
|
||||
except (OSError, CalledProcessError) as error:
|
||||
if command.considered_soft_failure(config_filename, error):
|
||||
if command.considered_soft_failure(error):
|
||||
return
|
||||
|
||||
encountered_error = error
|
||||
@@ -239,8 +241,8 @@ def run_configuration(config_filename, config, config_paths, arguments):
|
||||
dispatch.call_hooks(
|
||||
'ping_monitor',
|
||||
config,
|
||||
dispatch.Hook_type.MONITORING,
|
||||
config_filename,
|
||||
monitor.MONITOR_HOOK_NAMES,
|
||||
monitor.State.FAIL,
|
||||
monitoring_log_level,
|
||||
global_arguments.dry_run,
|
||||
@@ -248,13 +250,12 @@ def run_configuration(config_filename, config, config_paths, arguments):
|
||||
dispatch.call_hooks(
|
||||
'destroy_monitor',
|
||||
config,
|
||||
config_filename,
|
||||
monitor.MONITOR_HOOK_NAMES,
|
||||
dispatch.Hook_type.MONITORING,
|
||||
monitoring_log_level,
|
||||
global_arguments.dry_run,
|
||||
)
|
||||
except (OSError, CalledProcessError) as error:
|
||||
if command.considered_soft_failure(config_filename, error):
|
||||
if command.considered_soft_failure(error):
|
||||
return
|
||||
|
||||
yield from log_error_records(f'{config_filename}: Error running on-error hook', error)
|
||||
@@ -288,10 +289,11 @@ def run_actions(
|
||||
global_arguments = arguments['global']
|
||||
dry_run_label = ' (dry run; not making any changes)' if global_arguments.dry_run else ''
|
||||
hook_context = {
|
||||
'repository': repository_path,
|
||||
'repository_label': repository.get('label', ''),
|
||||
'log_file': global_arguments.log_file if global_arguments.log_file else '',
|
||||
# Deprecated: For backwards compatibility with borgmatic < 1.6.0.
|
||||
'repositories': ','.join([repo['path'] for repo in config['repositories']]),
|
||||
'log_file': global_arguments.log_file if global_arguments.log_file else '',
|
||||
'repository': repository_path,
|
||||
}
|
||||
skip_actions = set(get_skip_actions(config, arguments))
|
||||
|
||||
@@ -305,8 +307,8 @@ def run_actions(
|
||||
)
|
||||
|
||||
for action_name, action_arguments in arguments.items():
|
||||
if action_name == 'rcreate' and action_name not in skip_actions:
|
||||
borgmatic.actions.rcreate.run_rcreate(
|
||||
if action_name == 'repo-create' and action_name not in skip_actions:
|
||||
borgmatic.actions.repo_create.run_repo_create(
|
||||
repository,
|
||||
config,
|
||||
local_borg_version,
|
||||
@@ -420,8 +422,8 @@ def run_actions(
|
||||
local_path,
|
||||
remote_path,
|
||||
)
|
||||
elif action_name == 'rlist' and action_name not in skip_actions:
|
||||
yield from borgmatic.actions.rlist.run_rlist(
|
||||
elif action_name == 'repo-list' and action_name not in skip_actions:
|
||||
yield from borgmatic.actions.repo_list.run_repo_list(
|
||||
repository,
|
||||
config,
|
||||
local_borg_version,
|
||||
@@ -440,8 +442,8 @@ def run_actions(
|
||||
local_path,
|
||||
remote_path,
|
||||
)
|
||||
elif action_name == 'rinfo' and action_name not in skip_actions:
|
||||
yield from borgmatic.actions.rinfo.run_rinfo(
|
||||
elif action_name == 'repo-info' and action_name not in skip_actions:
|
||||
yield from borgmatic.actions.repo_info.run_repo_info(
|
||||
repository,
|
||||
config,
|
||||
local_borg_version,
|
||||
@@ -480,6 +482,36 @@ def run_actions(
|
||||
local_path,
|
||||
remote_path,
|
||||
)
|
||||
elif action_name == 'change-passphrase' and action_name not in skip_actions:
|
||||
borgmatic.actions.change_passphrase.run_change_passphrase(
|
||||
repository,
|
||||
config,
|
||||
local_borg_version,
|
||||
action_arguments,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
)
|
||||
elif action_name == 'delete' and action_name not in skip_actions:
|
||||
borgmatic.actions.delete.run_delete(
|
||||
repository,
|
||||
config,
|
||||
local_borg_version,
|
||||
action_arguments,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
)
|
||||
elif action_name == 'repo-delete' and action_name not in skip_actions:
|
||||
borgmatic.actions.repo_delete.run_repo_delete(
|
||||
repository,
|
||||
config,
|
||||
local_borg_version,
|
||||
action_arguments,
|
||||
global_arguments,
|
||||
local_path,
|
||||
remote_path,
|
||||
)
|
||||
elif action_name == 'borg' and action_name not in skip_actions:
|
||||
borgmatic.actions.borg.run_borg(
|
||||
repository,
|
||||
@@ -503,13 +535,16 @@ def run_actions(
|
||||
|
||||
def load_configurations(config_filenames, overrides=None, resolve_env=True):
|
||||
'''
|
||||
Given a sequence of configuration filenames, load and validate each configuration file. Return
|
||||
the results as a tuple of: dict of configuration filename to corresponding parsed configuration,
|
||||
a sequence of paths for all loaded configuration files (including includes), and a sequence of
|
||||
logging.LogRecord instances containing any parse errors.
|
||||
Given a sequence of configuration filenames, a sequence of configuration file override strings
|
||||
in the form of "option.suboption=value", and whether to resolve environment variables, load and
|
||||
validate each configuration file. Return the results as a tuple of: dict of configuration
|
||||
filename to corresponding parsed configuration, a sequence of paths for all loaded configuration
|
||||
files (including includes), and a sequence of logging.LogRecord instances containing any parse
|
||||
errors.
|
||||
|
||||
Log records are returned here instead of being logged directly because logging isn't yet
|
||||
initialized at this point!
|
||||
initialized at this point! (Although with the Delayed_logging_handler now in place, maybe this
|
||||
approach could change.)
|
||||
'''
|
||||
# Dict mapping from config filename to corresponding parsed config dict.
|
||||
configs = collections.OrderedDict()
|
||||
@@ -531,7 +566,10 @@ def load_configurations(config_filenames, overrides=None, resolve_env=True):
|
||||
)
|
||||
try:
|
||||
configs[config_filename], paths, parse_logs = validate.parse_configuration(
|
||||
config_filename, validate.schema_filename(), overrides, resolve_env
|
||||
config_filename,
|
||||
validate.schema_filename(),
|
||||
overrides,
|
||||
resolve_env,
|
||||
)
|
||||
config_paths.update(paths)
|
||||
logs.extend(parse_logs)
|
||||
@@ -558,7 +596,7 @@ def load_configurations(config_filenames, overrides=None, resolve_env=True):
|
||||
)
|
||||
),
|
||||
logging.makeLogRecord(
|
||||
dict(levelno=logging.CRITICAL, levelname='CRITICAL', msg=error)
|
||||
dict(levelno=logging.CRITICAL, levelname='CRITICAL', msg=str(error))
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -579,6 +617,9 @@ def log_record(suppress_log=False, **kwargs):
|
||||
return record
|
||||
|
||||
|
||||
BORG_REPOSITORY_ACCESS_ABORTED_EXIT_CODE = 62
|
||||
|
||||
|
||||
def log_error_records(
|
||||
message, error=None, levelno=logging.CRITICAL, log_command_error_output=False
|
||||
):
|
||||
@@ -593,13 +634,13 @@ def log_error_records(
|
||||
level_name = logging._levelToName[levelno]
|
||||
|
||||
if not error:
|
||||
yield log_record(levelno=levelno, levelname=level_name, msg=message)
|
||||
yield log_record(levelno=levelno, levelname=level_name, msg=str(message))
|
||||
return
|
||||
|
||||
try:
|
||||
raise error
|
||||
except CalledProcessError as error:
|
||||
yield log_record(levelno=levelno, levelname=level_name, msg=message)
|
||||
yield log_record(levelno=levelno, levelname=level_name, msg=str(message))
|
||||
|
||||
if error.output:
|
||||
try:
|
||||
@@ -618,10 +659,17 @@ def log_error_records(
|
||||
suppress_log=True,
|
||||
)
|
||||
|
||||
yield log_record(levelno=levelno, levelname=level_name, msg=error)
|
||||
yield log_record(levelno=levelno, levelname=level_name, msg=str(error))
|
||||
|
||||
if error.returncode == BORG_REPOSITORY_ACCESS_ABORTED_EXIT_CODE:
|
||||
yield log_record(
|
||||
levelno=levelno,
|
||||
levelname=level_name,
|
||||
msg='\nTo work around this, set either the "relocated_repo_access_is_ok" or "unknown_unencrypted_repo_access_is_ok" option to "true", as appropriate.',
|
||||
)
|
||||
except (ValueError, OSError) as error:
|
||||
yield log_record(levelno=levelno, levelname=level_name, msg=message)
|
||||
yield log_record(levelno=levelno, levelname=level_name, msg=error)
|
||||
yield log_record(levelno=levelno, levelname=level_name, msg=str(message))
|
||||
yield log_record(levelno=levelno, levelname=level_name, msg=str(error))
|
||||
except: # noqa: E722
|
||||
# Raising above only as a means of determining the error type. Swallow the exception here
|
||||
# because we don't want the exception to propagate out of this function.
|
||||
@@ -652,7 +700,9 @@ def collect_highlander_action_summary_logs(configs, arguments, configuration_par
|
||||
if 'bootstrap' in arguments:
|
||||
try:
|
||||
# No configuration file is needed for bootstrap.
|
||||
local_borg_version = borg_version.local_borg_version({}, 'borg')
|
||||
local_borg_version = borg_version.local_borg_version(
|
||||
{}, arguments['bootstrap'].local_path
|
||||
)
|
||||
except (OSError, CalledProcessError, ValueError) as error:
|
||||
yield from log_error_records('Error getting local Borg version', error)
|
||||
return
|
||||
@@ -749,9 +799,6 @@ def collect_configuration_run_summary_logs(configs, config_paths, arguments):
|
||||
break
|
||||
|
||||
try:
|
||||
if 'extract' in arguments or 'mount' in arguments:
|
||||
validate.guard_single_repository_selected(repository, configs)
|
||||
|
||||
validate.guard_configuration_contains_repository(repository, configs)
|
||||
except ValueError as error:
|
||||
yield from log_error_records(str(error))
|
||||
@@ -779,23 +826,27 @@ def collect_configuration_run_summary_logs(configs, config_paths, arguments):
|
||||
|
||||
# Execute the actions corresponding to each configuration file.
|
||||
json_results = []
|
||||
for config_filename, config in configs.items():
|
||||
results = list(run_configuration(config_filename, config, config_paths, arguments))
|
||||
error_logs = tuple(result for result in results if isinstance(result, logging.LogRecord))
|
||||
|
||||
if error_logs:
|
||||
yield from log_error_records(f'{config_filename}: An error occurred')
|
||||
yield from error_logs
|
||||
else:
|
||||
yield logging.makeLogRecord(
|
||||
dict(
|
||||
levelno=logging.INFO,
|
||||
levelname='INFO',
|
||||
msg=f'{config_filename}: Successfully ran configuration file',
|
||||
)
|
||||
for config_filename, config in configs.items():
|
||||
with Log_prefix(config_filename):
|
||||
results = list(run_configuration(config_filename, config, config_paths, arguments))
|
||||
error_logs = tuple(
|
||||
result for result in results if isinstance(result, logging.LogRecord)
|
||||
)
|
||||
if results:
|
||||
json_results.extend(results)
|
||||
|
||||
if error_logs:
|
||||
yield from log_error_records('An error occurred')
|
||||
yield from error_logs
|
||||
else:
|
||||
yield logging.makeLogRecord(
|
||||
dict(
|
||||
levelno=logging.INFO,
|
||||
levelname='INFO',
|
||||
msg='Successfully ran configuration file',
|
||||
)
|
||||
)
|
||||
if results:
|
||||
json_results.extend(results)
|
||||
|
||||
if 'umount' in arguments:
|
||||
logger.info(f"Unmounting mount point {arguments['umount'].mount_point}")
|
||||
@@ -836,6 +887,7 @@ def exit_with_help_link(): # pragma: no cover
|
||||
|
||||
def main(extra_summary_logs=[]): # pragma: no cover
|
||||
configure_signals()
|
||||
configure_delayed_logging()
|
||||
|
||||
try:
|
||||
arguments = parse_arguments(*sys.argv[1:])
|
||||
@@ -861,9 +913,12 @@ def main(extra_summary_logs=[]): # pragma: no cover
|
||||
print(borgmatic.commands.completion.fish.fish_completion())
|
||||
sys.exit(0)
|
||||
|
||||
validate = bool('validate' in arguments)
|
||||
config_filenames = tuple(collect.collect_config_filenames(global_arguments.config_paths))
|
||||
configs, config_paths, parse_logs = load_configurations(
|
||||
config_filenames, global_arguments.overrides, global_arguments.resolve_env
|
||||
config_filenames,
|
||||
global_arguments.overrides,
|
||||
resolve_env=global_arguments.resolve_env and not validate,
|
||||
)
|
||||
configuration_parse_errors = (
|
||||
(max(log.levelno for log in parse_logs) >= logging.CRITICAL) if parse_logs else False
|
||||
@@ -872,10 +927,8 @@ def main(extra_summary_logs=[]): # pragma: no cover
|
||||
any_json_flags = any(
|
||||
getattr(sub_arguments, 'json', False) for sub_arguments in arguments.values()
|
||||
)
|
||||
colorama.init(
|
||||
autoreset=True,
|
||||
strip=not should_do_markup(global_arguments.no_color or any_json_flags, configs),
|
||||
)
|
||||
color_enabled = should_do_markup(global_arguments.no_color or any_json_flags, configs)
|
||||
|
||||
try:
|
||||
configure_logging(
|
||||
verbosity_to_log_level(global_arguments.verbosity),
|
||||
@@ -884,6 +937,7 @@ def main(extra_summary_logs=[]): # pragma: no cover
|
||||
verbosity_to_log_level(global_arguments.monitoring_verbosity),
|
||||
global_arguments.log_file,
|
||||
global_arguments.log_file_format,
|
||||
color_enabled=color_enabled,
|
||||
)
|
||||
except (FileNotFoundError, PermissionError) as error:
|
||||
configure_logging(logging.CRITICAL)
|
||||
|
||||
@@ -50,12 +50,15 @@ def apply_constants(value, constants, shell_escape=False):
|
||||
value[index] = apply_constants(list_value, constants, shell_escape)
|
||||
elif isinstance(value, dict):
|
||||
for option_name, option_value in value.items():
|
||||
shell_escape = (
|
||||
shell_escape
|
||||
or option_name.startswith('before_')
|
||||
or option_name.startswith('after_')
|
||||
or option_name == 'on_error'
|
||||
value[option_name] = apply_constants(
|
||||
option_value,
|
||||
constants,
|
||||
shell_escape=(
|
||||
shell_escape
|
||||
or option_name.startswith('before_')
|
||||
or option_name.startswith('after_')
|
||||
or option_name == 'on_error'
|
||||
),
|
||||
)
|
||||
value[option_name] = apply_constants(option_value, constants, shell_escape)
|
||||
|
||||
return value
|
||||
|
||||
@@ -21,6 +21,19 @@ def insert_newline_before_comment(config, field_name):
|
||||
)
|
||||
|
||||
|
||||
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']
|
||||
|
||||
|
||||
def schema_to_sample_configuration(schema, level=0, parent_is_sequence=False):
|
||||
'''
|
||||
Given a loaded configuration schema, generate and return sample config for it. Include comments
|
||||
@@ -31,16 +44,16 @@ def schema_to_sample_configuration(schema, level=0, parent_is_sequence=False):
|
||||
if example is not None:
|
||||
return example
|
||||
|
||||
if schema_type == 'array':
|
||||
if schema_type == 'array' or (isinstance(schema_type, list) and 'array' in schema_type):
|
||||
config = ruamel.yaml.comments.CommentedSeq(
|
||||
[schema_to_sample_configuration(schema['items'], level, parent_is_sequence=True)]
|
||||
)
|
||||
add_comments_to_configuration_sequence(config, schema, indent=(level * INDENT))
|
||||
elif schema_type == 'object':
|
||||
elif schema_type == 'object' or (isinstance(schema_type, list) and 'object' in schema_type):
|
||||
config = ruamel.yaml.comments.CommentedMap(
|
||||
[
|
||||
(field_name, schema_to_sample_configuration(sub_schema, level + 1))
|
||||
for field_name, sub_schema in schema['properties'].items()
|
||||
for field_name, sub_schema in get_properties(schema).items()
|
||||
]
|
||||
)
|
||||
indent = (level * INDENT) + (SEQUENCE_INDENT if parent_is_sequence else 0)
|
||||
@@ -151,7 +164,7 @@ def add_comments_to_configuration_sequence(config, schema, indent=0):
|
||||
return
|
||||
|
||||
for field_name in config[0].keys():
|
||||
field_schema = schema['items']['properties'].get(field_name, {})
|
||||
field_schema = get_properties(schema['items']).get(field_name, {})
|
||||
description = field_schema.get('description')
|
||||
|
||||
# No description to use? Skip it.
|
||||
@@ -178,7 +191,7 @@ def add_comments_to_configuration_object(config, schema, indent=0, skip_first=Fa
|
||||
if skip_first and index == 0:
|
||||
continue
|
||||
|
||||
field_schema = schema['properties'].get(field_name, {})
|
||||
field_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
|
||||
|
||||
@@ -69,7 +69,7 @@ def include_configuration(loader, filename_node, include_directory, config_paths
|
||||
]
|
||||
|
||||
raise ValueError(
|
||||
'!include value is not supported; use a single filename or a list of filenames'
|
||||
'The value given for the !include tag is invalid; use a single filename or a list of filenames instead'
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -68,6 +68,17 @@ def normalize(config_filename, config):
|
||||
'''
|
||||
logs = normalize_sections(config_filename, config)
|
||||
|
||||
if config.get('borgmatic_source_directory'):
|
||||
logs.append(
|
||||
logging.makeLogRecord(
|
||||
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.',
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# Upgrade exclude_if_present from a string to a list.
|
||||
exclude_if_present = config.get('exclude_if_present')
|
||||
if isinstance(exclude_if_present, str):
|
||||
@@ -82,6 +93,25 @@ def normalize(config_filename, config):
|
||||
)
|
||||
config['exclude_if_present'] = [exclude_if_present]
|
||||
|
||||
# Unconditionally set the bootstrap hook so that it's enabled by default and config files get
|
||||
# stored in each Borg archive.
|
||||
config.setdefault('bootstrap', {})
|
||||
|
||||
# Move store_config_files from the global scope to the bootstrap hook.
|
||||
store_config_files = config.get('store_config_files')
|
||||
if store_config_files is not None:
|
||||
logs.append(
|
||||
logging.makeLogRecord(
|
||||
dict(
|
||||
levelno=logging.WARNING,
|
||||
levelname='WARNING',
|
||||
msg=f'{config_filename}: The store_config_files option has moved under the bootstrap hook. Specifying store_config_files at the global scope is deprecated and support will be removed from a future release.',
|
||||
)
|
||||
)
|
||||
)
|
||||
del config['store_config_files']
|
||||
config['bootstrap']['store_config_files'] = store_config_files
|
||||
|
||||
# Upgrade various monitoring hooks from a string to a dict.
|
||||
healthchecks = config.get('healthchecks')
|
||||
if isinstance(healthchecks, str):
|
||||
@@ -233,7 +263,11 @@ def normalize(config_filename, config):
|
||||
path=updated_repository_path,
|
||||
)
|
||||
)
|
||||
elif repository_path.startswith('ssh://'):
|
||||
elif (
|
||||
repository_path.startswith('ssh://')
|
||||
or repository_path.startswith('sftp://')
|
||||
or repository_path.startswith('rclone:')
|
||||
):
|
||||
config['repositories'].append(repository_dict)
|
||||
else:
|
||||
rewritten_repository_path = f"ssh://{repository_path.replace(':~', '/~').replace(':/', '/').replace(':', '/./')}"
|
||||
@@ -242,7 +276,7 @@ def normalize(config_filename, config):
|
||||
dict(
|
||||
levelno=logging.WARNING,
|
||||
levelname='WARNING',
|
||||
msg=f'{config_filename}: Remote repository paths without ssh:// 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:// or rclone: syntax are deprecated and support will be removed from a future release. Interpreting "{repository_path}" as "{rewritten_repository_path}"',
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -103,7 +103,7 @@ def parse_overrides(raw_overrides, schema):
|
||||
for raw_override in raw_overrides:
|
||||
try:
|
||||
raw_keys, value = raw_override.split('=', 1)
|
||||
keys = strip_section_names(tuple(raw_keys.split('.')))
|
||||
keys = tuple(raw_keys.split('.'))
|
||||
option_type = type_for_option(schema, keys)
|
||||
|
||||
parsed_overrides.append(
|
||||
@@ -127,8 +127,13 @@ def apply_overrides(config, schema, raw_overrides):
|
||||
Given a configuration dict, a corresponding configuration schema dict, and a sequence of
|
||||
configuration file override strings in the form of "option.suboption=value", parse each override
|
||||
and set it into the configuration dict.
|
||||
|
||||
Set the overrides into the configuration both with and without deprecated section names (if
|
||||
used), so that the overrides work regardless of whether the configuration is also using
|
||||
deprecated section names.
|
||||
'''
|
||||
overrides = parse_overrides(raw_overrides, schema)
|
||||
|
||||
for keys, value in overrides:
|
||||
set_values(config, keys, value)
|
||||
set_values(config, strip_section_names(keys), value)
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def expand_user_in_path(path):
|
||||
'''
|
||||
Given a directory path, expand any tildes in it.
|
||||
'''
|
||||
try:
|
||||
return os.path.expanduser(path or '') or None
|
||||
except TypeError:
|
||||
return None
|
||||
|
||||
|
||||
def get_working_directory(config): # pragma: no cover
|
||||
'''
|
||||
Given a configuration dict, get the working directory from it, expanding any tildes.
|
||||
'''
|
||||
return expand_user_in_path(config.get('working_directory'))
|
||||
|
||||
|
||||
def get_borgmatic_source_directory(config):
|
||||
'''
|
||||
Given a configuration dict, get the (deprecated) borgmatic source directory, expanding any
|
||||
tildes. Defaults to ~/.borgmatic.
|
||||
'''
|
||||
return expand_user_in_path(config.get('borgmatic_source_directory') or '~/.borgmatic')
|
||||
|
||||
|
||||
TEMPORARY_DIRECTORY_PREFIX = 'borgmatic-'
|
||||
|
||||
|
||||
def replace_temporary_subdirectory_with_glob(
|
||||
path, temporary_directory_prefix=TEMPORARY_DIRECTORY_PREFIX
|
||||
):
|
||||
'''
|
||||
Given an absolute temporary directory path and an optional temporary directory prefix, look for
|
||||
a subdirectory within it starting with the temporary directory prefix (or a default) and replace
|
||||
it with an appropriate glob. For instance, given:
|
||||
|
||||
/tmp/borgmatic-aet8kn93/borgmatic
|
||||
|
||||
... replace it with:
|
||||
|
||||
/tmp/borgmatic-*/borgmatic
|
||||
|
||||
This is useful for finding previous temporary directories from prior borgmatic runs.
|
||||
'''
|
||||
return os.path.join(
|
||||
'/',
|
||||
*(
|
||||
(
|
||||
f'{temporary_directory_prefix}*'
|
||||
if subdirectory.startswith(temporary_directory_prefix)
|
||||
else subdirectory
|
||||
)
|
||||
for subdirectory in path.split(os.path.sep)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class Runtime_directory:
|
||||
'''
|
||||
A Python context manager for creating and cleaning up the borgmatic runtime directory used for
|
||||
storing temporary runtime data like streaming database dumps and bootstrap metadata.
|
||||
|
||||
Example use as a context manager:
|
||||
|
||||
with borgmatic.config.paths.Runtime_directory(config) as borgmatic_runtime_directory:
|
||||
do_something_with(borgmatic_runtime_directory)
|
||||
|
||||
For the scope of that "with" statement, the runtime directory is available. Afterwards, it
|
||||
automatically gets cleaned up as necessary.
|
||||
'''
|
||||
|
||||
def __init__(self, config):
|
||||
'''
|
||||
Given a configuration dict determine the borgmatic runtime directory, creating a secure,
|
||||
temporary directory within it if necessary. Defaults to $XDG_RUNTIME_DIR/./borgmatic or
|
||||
$RUNTIME_DIRECTORY/./borgmatic or $TMPDIR/borgmatic-[random]/./borgmatic or
|
||||
$TEMP/borgmatic-[random]/./borgmatic or /tmp/borgmatic-[random]/./borgmatic where "[random]"
|
||||
is a randomly generated string intended to avoid path collisions.
|
||||
|
||||
If XDG_RUNTIME_DIR or RUNTIME_DIRECTORY is set and already ends in "/borgmatic", then don't
|
||||
tack on a second "/borgmatic" path component.
|
||||
|
||||
The "/./" is taking advantage of a Borg feature such that the part of the path before the "/./"
|
||||
does not get stored in the file path within an archive. That way, the path of the runtime
|
||||
directory can change without leaving database dumps within an archive inaccessible.
|
||||
'''
|
||||
runtime_directory = (
|
||||
config.get('user_runtime_directory')
|
||||
or os.environ.get('XDG_RUNTIME_DIR') # Set by PAM on Linux.
|
||||
or os.environ.get('RUNTIME_DIRECTORY') # Set by systemd if configured.
|
||||
)
|
||||
|
||||
if runtime_directory:
|
||||
if not runtime_directory.startswith(os.path.sep):
|
||||
raise ValueError('The runtime directory must be an absolute path')
|
||||
|
||||
self.temporary_directory = None
|
||||
else:
|
||||
base_directory = os.environ.get('TMPDIR') or os.environ.get('TEMP') or '/tmp'
|
||||
|
||||
if not base_directory.startswith(os.path.sep):
|
||||
raise ValueError('The temporary directory must be an absolute path')
|
||||
|
||||
os.makedirs(base_directory, mode=0o700, exist_ok=True)
|
||||
self.temporary_directory = tempfile.TemporaryDirectory(
|
||||
prefix=TEMPORARY_DIRECTORY_PREFIX,
|
||||
dir=base_directory,
|
||||
)
|
||||
runtime_directory = self.temporary_directory.name
|
||||
|
||||
(base_path, final_directory) = os.path.split(runtime_directory.rstrip(os.path.sep))
|
||||
|
||||
self.runtime_path = expand_user_in_path(
|
||||
os.path.join(
|
||||
base_path if final_directory == 'borgmatic' else runtime_directory,
|
||||
'.', # Borg 1.4+ "slashdot" hack.
|
||||
'borgmatic',
|
||||
)
|
||||
)
|
||||
os.makedirs(self.runtime_path, mode=0o700, exist_ok=True)
|
||||
|
||||
logger.debug(f'Using runtime directory {os.path.normpath(self.runtime_path)}')
|
||||
|
||||
def __enter__(self):
|
||||
'''
|
||||
Return the borgmatic runtime path as a string.
|
||||
'''
|
||||
return self.runtime_path
|
||||
|
||||
def __exit__(self, exception, value, traceback):
|
||||
'''
|
||||
Delete any temporary directory that was created as part of initialization.
|
||||
'''
|
||||
if self.temporary_directory:
|
||||
try:
|
||||
self.temporary_directory.cleanup()
|
||||
# The cleanup() call errors if, for instance, there's still a
|
||||
# mounted filesystem within the temporary directory. There's
|
||||
# nothing we can do about that here, so swallow the error.
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def make_runtime_directory_glob(borgmatic_runtime_directory):
|
||||
'''
|
||||
Given a borgmatic runtime directory path, make a glob that would match that path, specifically
|
||||
replacing any randomly generated temporary subdirectory with "*" since such a directory's name
|
||||
changes on every borgmatic run.
|
||||
'''
|
||||
return os.path.join(
|
||||
*(
|
||||
'*' if subdirectory.startswith(TEMPORARY_DIRECTORY_PREFIX) else subdirectory
|
||||
for subdirectory in os.path.normpath(borgmatic_runtime_directory).split(os.path.sep)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def get_borgmatic_state_directory(config):
|
||||
'''
|
||||
Given a configuration dict, get the borgmatic state directory used for storing borgmatic state
|
||||
files like records of when checks last ran. Defaults to $XDG_STATE_HOME/borgmatic or
|
||||
~/.local/state/./borgmatic.
|
||||
'''
|
||||
return expand_user_in_path(
|
||||
os.path.join(
|
||||
config.get('user_state_directory')
|
||||
or os.environ.get('XDG_STATE_HOME')
|
||||
or os.environ.get('STATE_DIRECTORY') # Set by systemd if configured.
|
||||
or '~/.local/state',
|
||||
'borgmatic',
|
||||
)
|
||||
)
|
||||
+936
-107
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
||||
import fnmatch
|
||||
import os
|
||||
|
||||
import jsonschema
|
||||
@@ -87,8 +88,9 @@ def parse_configuration(config_filename, schema_filename, overrides=None, resolv
|
||||
'''
|
||||
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", return the parsed configuration as a data structure of nested dicts
|
||||
and lists corresponding to the schema. Example return value:
|
||||
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:
|
||||
|
||||
{
|
||||
'source_directories': ['/home', '/etc'],
|
||||
@@ -123,6 +125,7 @@ def parse_configuration(config_filename, schema_filename, overrides=None, resolv
|
||||
validator = jsonschema.Draft7Validator(schema)
|
||||
except AttributeError: # pragma: no cover
|
||||
validator = jsonschema.Draft4Validator(schema)
|
||||
|
||||
validation_errors = tuple(validator.iter_errors(config))
|
||||
|
||||
if validation_errors:
|
||||
@@ -149,18 +152,30 @@ def normalize_repository_path(repository):
|
||||
return repository
|
||||
|
||||
|
||||
def glob_match(first, second):
|
||||
'''
|
||||
Given two strings, return whether the first matches the second. Globs are
|
||||
supported.
|
||||
'''
|
||||
if first is None or second is None:
|
||||
return False
|
||||
|
||||
return fnmatch.fnmatch(first, second) or fnmatch.fnmatch(second, first)
|
||||
|
||||
|
||||
def repositories_match(first, second):
|
||||
'''
|
||||
Given two repository dicts with keys 'path' (relative and/or absolute),
|
||||
and 'label', or two repository paths, return whether they match.
|
||||
Given two repository dicts with keys "path" (relative and/or absolute),
|
||||
and "label", two repository paths as strings, or a mix of the two formats,
|
||||
return whether they match. Globs are supported.
|
||||
'''
|
||||
if isinstance(first, str):
|
||||
first = {'path': first, 'label': first}
|
||||
if isinstance(second, str):
|
||||
second = {'path': second, 'label': second}
|
||||
return (first.get('label') == second.get('label')) or (
|
||||
normalize_repository_path(first.get('path'))
|
||||
== normalize_repository_path(second.get('path'))
|
||||
|
||||
return glob_match(first.get('label'), second.get('label')) or glob_match(
|
||||
normalize_repository_path(first.get('path')), normalize_repository_path(second.get('path'))
|
||||
)
|
||||
|
||||
|
||||
@@ -186,26 +201,3 @@ def guard_configuration_contains_repository(repository, configurations):
|
||||
|
||||
if count == 0:
|
||||
raise ValueError(f'Repository "{repository}" not found in configuration files')
|
||||
|
||||
|
||||
def guard_single_repository_selected(repository, configurations):
|
||||
'''
|
||||
Given a repository path and a dict mapping from config filename to corresponding parsed config
|
||||
dict, ensure either a single repository exists across all configuration files or a repository
|
||||
path was given.
|
||||
'''
|
||||
if repository:
|
||||
return
|
||||
|
||||
count = len(
|
||||
tuple(
|
||||
config_repository
|
||||
for config in configurations.values()
|
||||
for config_repository in config['repositories']
|
||||
)
|
||||
)
|
||||
|
||||
if count != 1:
|
||||
raise ValueError(
|
||||
"Can't determine which repository to use. Use --repository to disambiguate"
|
||||
)
|
||||
|
||||
+92
-49
@@ -1,9 +1,11 @@
|
||||
import collections
|
||||
import enum
|
||||
import logging
|
||||
import os
|
||||
import select
|
||||
import subprocess
|
||||
import textwrap
|
||||
|
||||
import borgmatic.logger
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -219,13 +221,51 @@ def log_outputs(processes, exclude_stdouts, output_log_level, borg_local_path, b
|
||||
}
|
||||
|
||||
|
||||
SECRET_COMMAND_FLAG_NAMES = {'--password'}
|
||||
|
||||
|
||||
def mask_command_secrets(full_command):
|
||||
'''
|
||||
Given a command as a sequence, mask secret values for flags like "--password" in preparation for
|
||||
logging.
|
||||
'''
|
||||
masked_command = []
|
||||
previous_piece = None
|
||||
|
||||
for piece in full_command:
|
||||
masked_command.append('***' if previous_piece in SECRET_COMMAND_FLAG_NAMES else piece)
|
||||
previous_piece = piece
|
||||
|
||||
return tuple(masked_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
|
||||
paths and extra environment variables (with omitted values in case they contain passwords).
|
||||
'''
|
||||
logger.debug(
|
||||
' '.join(tuple(f'{key}=***' for key in (environment or {}).keys()) + tuple(full_command))
|
||||
textwrap.shorten(
|
||||
' '.join(
|
||||
tuple(
|
||||
f'{key}=***'
|
||||
for key in (environment or {}).keys()
|
||||
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 '')
|
||||
)
|
||||
@@ -243,7 +283,7 @@ 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,
|
||||
@@ -253,18 +293,17 @@ def execute_command(
|
||||
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
|
||||
|
||||
@@ -276,24 +315,27 @@ def execute_command(
|
||||
shell=shell,
|
||||
env=environment,
|
||||
cwd=working_directory,
|
||||
# Necessary for the passcommand credential hook to work.
|
||||
close_fds=not bool((environment or {}).get('BORG_PASSPHRASE_FD')),
|
||||
)
|
||||
if not run_to_completion:
|
||||
return process
|
||||
|
||||
log_outputs(
|
||||
(process,),
|
||||
(input_file, output_file),
|
||||
output_log_level,
|
||||
borg_local_path,
|
||||
borg_exit_codes,
|
||||
)
|
||||
with borgmatic.logger.Log_prefix(None): # Log command output without any prefix.
|
||||
log_outputs(
|
||||
(process,),
|
||||
(input_file, output_file),
|
||||
output_log_level,
|
||||
borg_local_path,
|
||||
borg_exit_codes,
|
||||
)
|
||||
|
||||
|
||||
def execute_command_and_capture_output(
|
||||
full_command,
|
||||
capture_stderr=False,
|
||||
shell=False,
|
||||
extra_environment=None,
|
||||
environment=None,
|
||||
working_directory=None,
|
||||
borg_local_path=None,
|
||||
borg_exit_codes=None,
|
||||
@@ -301,18 +343,16 @@ def execute_command_and_capture_output(
|
||||
'''
|
||||
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.
|
||||
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, environment=environment)
|
||||
command = ' '.join(full_command) if shell else full_command
|
||||
|
||||
try:
|
||||
@@ -322,6 +362,8 @@ def execute_command_and_capture_output(
|
||||
shell=shell,
|
||||
env=environment,
|
||||
cwd=working_directory,
|
||||
# Necessary for the passcommand credential hook to work.
|
||||
close_fds=not bool((environment or {}).get('BORG_PASSPHRASE_FD')),
|
||||
)
|
||||
except subprocess.CalledProcessError as error:
|
||||
if (
|
||||
@@ -341,7 +383,7 @@ 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,
|
||||
@@ -355,19 +397,17 @@ 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
|
||||
|
||||
@@ -376,12 +416,14 @@ def execute_command_with_processes(
|
||||
command,
|
||||
stdin=input_file,
|
||||
stdout=None if do_not_capture else (output_file or subprocess.PIPE),
|
||||
stderr=None
|
||||
if do_not_capture
|
||||
else (subprocess.PIPE if output_file else subprocess.STDOUT),
|
||||
stderr=(
|
||||
None if do_not_capture else (subprocess.PIPE if output_file else subprocess.STDOUT)
|
||||
),
|
||||
shell=shell,
|
||||
env=environment,
|
||||
cwd=working_directory,
|
||||
# Necessary for the passcommand credential hook to work.
|
||||
close_fds=not bool((environment or {}).get('BORG_PASSPHRASE_FD')),
|
||||
)
|
||||
except (subprocess.CalledProcessError, OSError):
|
||||
# Something has gone wrong. So vent each process' output buffer to prevent it from hanging.
|
||||
@@ -392,13 +434,14 @@ def execute_command_with_processes(
|
||||
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(
|
||||
tuple(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)
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import logging
|
||||
import operator
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def initialize_monitor(
|
||||
ping_url, config, config_filename, monitoring_log_level, dry_run
|
||||
): # pragma: no cover
|
||||
'''
|
||||
No initialization is necessary for this monitor.
|
||||
'''
|
||||
pass
|
||||
|
||||
|
||||
def ping_monitor(hook_config, config, config_filename, state, monitoring_log_level, dry_run):
|
||||
'''
|
||||
Ping the configured Apprise service URLs. Use the given configuration filename in any log
|
||||
entries. If this is a dry run, then don't actually ping anything.
|
||||
'''
|
||||
try:
|
||||
import apprise
|
||||
from apprise import NotifyFormat, NotifyType
|
||||
except ImportError: # pragma: no cover
|
||||
logger.warning('Unable to import Apprise in monitoring hook')
|
||||
return
|
||||
|
||||
state_to_notify_type = {
|
||||
'start': NotifyType.INFO,
|
||||
'finish': NotifyType.SUCCESS,
|
||||
'fail': NotifyType.FAILURE,
|
||||
'log': NotifyType.INFO,
|
||||
}
|
||||
|
||||
run_states = hook_config.get('states', ['fail'])
|
||||
|
||||
if state.name.lower() not in run_states:
|
||||
return
|
||||
|
||||
state_config = hook_config.get(
|
||||
state.name.lower(),
|
||||
{
|
||||
'title': f'A borgmatic {state.name} event happened',
|
||||
'body': f'A borgmatic {state.name} event happened',
|
||||
},
|
||||
)
|
||||
|
||||
if not hook_config.get('services'):
|
||||
logger.info(f'{config_filename}: No Apprise services to ping')
|
||||
return
|
||||
|
||||
dry_run_string = ' (dry run; not actually pinging)' if dry_run else ''
|
||||
labels_string = ', '.join(map(operator.itemgetter('label'), hook_config.get('services')))
|
||||
logger.info(f'{config_filename}: Pinging Apprise services: {labels_string}{dry_run_string}')
|
||||
|
||||
apprise_object = apprise.Apprise()
|
||||
apprise_object.add(list(map(operator.itemgetter('url'), hook_config.get('services'))))
|
||||
|
||||
if dry_run:
|
||||
return
|
||||
|
||||
result = apprise_object.notify(
|
||||
title=state_config.get('title', ''),
|
||||
body=state_config.get('body'),
|
||||
body_format=NotifyFormat.TEXT,
|
||||
notify_type=state_to_notify_type[state.name.lower()],
|
||||
)
|
||||
|
||||
if result is False:
|
||||
logger.warning(f'{config_filename}: Error sending some Apprise notifications')
|
||||
|
||||
|
||||
def destroy_monitor(
|
||||
ping_url_or_uuid, config, config_filename, monitoring_log_level, dry_run
|
||||
): # pragma: no cover
|
||||
'''
|
||||
No destruction is necessary for this monitor.
|
||||
'''
|
||||
pass
|
||||
+36
-20
@@ -2,8 +2,9 @@ import logging
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import sys
|
||||
|
||||
from borgmatic import execute
|
||||
import borgmatic.execute
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -11,7 +12,7 @@ logger = logging.getLogger(__name__)
|
||||
SOFT_FAIL_EXIT_CODE = 75
|
||||
|
||||
|
||||
def interpolate_context(config_filename, hook_description, command, context):
|
||||
def interpolate_context(hook_description, command, context):
|
||||
'''
|
||||
Given a config filename, a hook description, a single hook command, and a dict of context
|
||||
names/values, interpolate the values by "{name}" into the command and return the result.
|
||||
@@ -21,12 +22,28 @@ def interpolate_context(config_filename, hook_description, command, context):
|
||||
|
||||
for unsupported_variable in re.findall(r'{\w+}', command):
|
||||
logger.warning(
|
||||
f"{config_filename}: Variable '{unsupported_variable}' is not supported in {hook_description} hook"
|
||||
f"Variable '{unsupported_variable}' is not supported in {hook_description} hook"
|
||||
)
|
||||
|
||||
return command
|
||||
|
||||
|
||||
def make_environment(current_environment, sys_module=sys):
|
||||
'''
|
||||
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'):
|
||||
environment['LD_LIBRARY_PATH'] = environment.get('LD_LIBRARY_PATH_ORIG', '')
|
||||
|
||||
return environment
|
||||
|
||||
|
||||
def execute_hook(commands, umask, config_filename, description, dry_run, **context):
|
||||
'''
|
||||
Given a list of hook commands to execute, a umask to execute with (or None), a config filename,
|
||||
@@ -39,46 +56,45 @@ def execute_hook(commands, umask, config_filename, description, dry_run, **conte
|
||||
Raise subprocesses.CalledProcessError if an error occurs in a hook.
|
||||
'''
|
||||
if not commands:
|
||||
logger.debug(f'{config_filename}: No commands to run for {description} hook')
|
||||
logger.debug(f'No commands to run for {description} hook')
|
||||
return
|
||||
|
||||
dry_run_label = ' (dry run; not actually running hooks)' if dry_run else ''
|
||||
|
||||
context['configuration_filename'] = config_filename
|
||||
commands = [
|
||||
interpolate_context(config_filename, description, command, context) for command in commands
|
||||
]
|
||||
commands = [interpolate_context(description, command, context) for command in commands]
|
||||
|
||||
if len(commands) == 1:
|
||||
logger.info(f'{config_filename}: Running command for {description} hook{dry_run_label}')
|
||||
logger.info(f'Running command for {description} hook{dry_run_label}')
|
||||
else:
|
||||
logger.info(
|
||||
f'{config_filename}: Running {len(commands)} commands for {description} hook{dry_run_label}',
|
||||
f'Running {len(commands)} commands for {description} hook{dry_run_label}',
|
||||
)
|
||||
|
||||
if umask:
|
||||
parsed_umask = int(str(umask), 8)
|
||||
logger.debug(f'{config_filename}: Set hook umask to {oct(parsed_umask)}')
|
||||
logger.debug(f'Set hook umask to {oct(parsed_umask)}')
|
||||
original_umask = os.umask(parsed_umask)
|
||||
else:
|
||||
original_umask = None
|
||||
|
||||
try:
|
||||
for command in commands:
|
||||
if not dry_run:
|
||||
execute.execute_command(
|
||||
[command],
|
||||
output_log_level=logging.ERROR
|
||||
if description == 'on-error'
|
||||
else logging.WARNING,
|
||||
shell=True,
|
||||
)
|
||||
if dry_run:
|
||||
continue
|
||||
|
||||
borgmatic.execute.execute_command(
|
||||
[command],
|
||||
output_log_level=(logging.ERROR if description == 'on-error' else logging.WARNING),
|
||||
shell=True,
|
||||
environment=make_environment(os.environ),
|
||||
)
|
||||
finally:
|
||||
if original_umask:
|
||||
os.umask(original_umask)
|
||||
|
||||
|
||||
def considered_soft_failure(config_filename, error):
|
||||
def considered_soft_failure(error):
|
||||
'''
|
||||
Given a configuration filename and an exception object, return whether the exception object
|
||||
represents a subprocess.CalledProcessError with a return code of SOFT_FAIL_EXIT_CODE. If so,
|
||||
@@ -90,7 +106,7 @@ def considered_soft_failure(config_filename, error):
|
||||
|
||||
if exit_code == SOFT_FAIL_EXIT_CODE:
|
||||
logger.info(
|
||||
f'{config_filename}: Command hook exited with soft failure exit code ({SOFT_FAIL_EXIT_CODE}); skipping remaining actions',
|
||||
f'Command hook exited with soft failure exit code ({SOFT_FAIL_EXIT_CODE}); skipping remaining repository actions',
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
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:
|
||||
raise ValueError(f'Cannot load invalid secret name: "{' '.join(credential_parameters)}"')
|
||||
|
||||
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,
|
||||
)
|
||||
) 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,28 @@
|
||||
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:
|
||||
raise ValueError(f'Cannot load invalid credential: "{' '.join(credential_parameters)}"')
|
||||
|
||||
try:
|
||||
with open(
|
||||
os.path.join(config.get('working_directory', ''), credential_path)
|
||||
) 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,40 @@
|
||||
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 corresponidng 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'Cannot load credential with invalid KeePassXC database path and attribute name: "{' '.join(credential_parameters)}"'
|
||||
)
|
||||
|
||||
if not os.path.exists(database_path):
|
||||
raise ValueError(
|
||||
f'Cannot load credential because KeePassXC database path does not exist: {database_path}'
|
||||
)
|
||||
|
||||
return borgmatic.execute.execute_command_and_capture_output(
|
||||
tuple(shlex.split((hook_config or {}).get('keepassxc_cli_command', 'keepassxc-cli')))
|
||||
+ (
|
||||
'show',
|
||||
'--show-protected',
|
||||
'--attributes',
|
||||
'Password',
|
||||
database_path,
|
||||
attribute_name,
|
||||
)
|
||||
).rstrip(os.linesep)
|
||||
@@ -0,0 +1,124 @@
|
||||
import functools
|
||||
import re
|
||||
import shlex
|
||||
|
||||
import borgmatic.hooks.dispatch
|
||||
|
||||
IS_A_HOOK = False
|
||||
|
||||
|
||||
class Hash_adapter:
|
||||
'''
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
matcher = CREDENTIAL_PATTERN.match(value)
|
||||
|
||||
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}"')
|
||||
|
||||
(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)
|
||||
)
|
||||
@@ -0,0 +1,43 @@
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
CREDENTIAL_NAME_PATTERN = re.compile(r'^\w+$')
|
||||
|
||||
|
||||
def load_credential(hook_config, config, credential_parameters):
|
||||
'''
|
||||
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.
|
||||
'''
|
||||
try:
|
||||
(credential_name,) = credential_parameters
|
||||
except ValueError:
|
||||
raise ValueError(
|
||||
f'Cannot load invalid credential name: "{' '.join(credential_parameters)}"'
|
||||
)
|
||||
|
||||
credentials_directory = os.environ.get('CREDENTIALS_DIRECTORY')
|
||||
|
||||
if not credentials_directory:
|
||||
raise ValueError(
|
||||
f'Cannot load credential "{credential_name}" because the systemd CREDENTIALS_DIRECTORY environment variable is not set'
|
||||
)
|
||||
|
||||
if not CREDENTIAL_NAME_PATTERN.match(credential_name):
|
||||
raise ValueError(f'Cannot load invalid credential name "{credential_name}"')
|
||||
|
||||
try:
|
||||
with open(os.path.join(credentials_directory, credential_name)) as credential_file:
|
||||
return credential_file.read().rstrip(os.linesep)
|
||||
except (FileNotFoundError, OSError) as error:
|
||||
logger.warning(error)
|
||||
|
||||
raise ValueError(f'Cannot load credential "{credential_name}" from file: {error.filename}')
|
||||
@@ -0,0 +1,133 @@
|
||||
import glob
|
||||
import importlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
import borgmatic.borg.pattern
|
||||
import borgmatic.config.paths
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def use_streaming(hook_config, config): # pragma: no cover
|
||||
'''
|
||||
Return whether dump streaming is used for this hook. (Spoiler: It isn't.)
|
||||
'''
|
||||
return False
|
||||
|
||||
|
||||
def dump_data_sources(
|
||||
hook_config,
|
||||
config,
|
||||
config_paths,
|
||||
borgmatic_runtime_directory,
|
||||
patterns,
|
||||
dry_run,
|
||||
):
|
||||
'''
|
||||
Given a bootstrap configuration dict, a configuration dict, the borgmatic configuration file
|
||||
paths, the borgmatic runtime directory, the configured patterns, and whether this is a dry run,
|
||||
create a borgmatic manifest file to store the paths of the configuration files used to create
|
||||
the archive. But skip this if the bootstrap store_config_files option is False or if this is a
|
||||
dry run.
|
||||
|
||||
Return an empty sequence, since there are no ongoing dump processes from this hook.
|
||||
'''
|
||||
if hook_config and hook_config.get('store_config_files') is False:
|
||||
return []
|
||||
|
||||
borgmatic_manifest_path = os.path.join(
|
||||
borgmatic_runtime_directory, 'bootstrap', 'manifest.json'
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
return []
|
||||
|
||||
os.makedirs(os.path.dirname(borgmatic_manifest_path), exist_ok=True)
|
||||
|
||||
with open(borgmatic_manifest_path, 'w') as manifest_file:
|
||||
json.dump(
|
||||
{
|
||||
'borgmatic_version': importlib.metadata.version('borgmatic'),
|
||||
'config_paths': config_paths,
|
||||
},
|
||||
manifest_file,
|
||||
)
|
||||
|
||||
patterns.extend(
|
||||
borgmatic.borg.pattern.Pattern(
|
||||
config_path, 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'),
|
||||
source=borgmatic.borg.pattern.Pattern_source.HOOK,
|
||||
)
|
||||
)
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def remove_data_source_dumps(hook_config, config, borgmatic_runtime_directory, 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.
|
||||
'''
|
||||
dry_run_label = ' (dry run; not actually removing anything)' if dry_run else ''
|
||||
|
||||
manifest_glob = os.path.join(
|
||||
borgmatic.config.paths.replace_temporary_subdirectory_with_glob(
|
||||
os.path.normpath(borgmatic_runtime_directory),
|
||||
),
|
||||
'bootstrap',
|
||||
)
|
||||
logger.debug(
|
||||
f'Looking for bootstrap manifest files to remove in {manifest_glob}{dry_run_label}'
|
||||
)
|
||||
|
||||
for manifest_directory in glob.glob(manifest_glob):
|
||||
manifest_file_path = os.path.join(manifest_directory, 'manifest.json')
|
||||
logger.debug(f'Removing bootstrap manifest at {manifest_file_path}{dry_run_label}')
|
||||
|
||||
if dry_run:
|
||||
continue
|
||||
|
||||
try:
|
||||
os.remove(manifest_file_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
try:
|
||||
os.rmdir(manifest_directory)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def make_data_source_dump_patterns(
|
||||
hook_config, config, borgmatic_runtime_directory, name=None
|
||||
): # pragma: no cover
|
||||
'''
|
||||
Restores are implemented via the separate, purpose-specific "bootstrap" action rather than the
|
||||
generic "restore".
|
||||
'''
|
||||
return ()
|
||||
|
||||
|
||||
def restore_data_source_dump(
|
||||
hook_config,
|
||||
config,
|
||||
data_source,
|
||||
dry_run,
|
||||
extract_process,
|
||||
connection_params,
|
||||
borgmatic_runtime_directory,
|
||||
): # pragma: no cover
|
||||
'''
|
||||
Restores are implemented via the separate, purpose-specific "bootstrap" action rather than the
|
||||
generic "restore".
|
||||
'''
|
||||
raise NotImplementedError()
|
||||
@@ -0,0 +1,341 @@
|
||||
import collections
|
||||
import glob
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
import borgmatic.borg.pattern
|
||||
import borgmatic.config.paths
|
||||
import borgmatic.execute
|
||||
import borgmatic.hooks.data_source.snapshot
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def use_streaming(hook_config, config): # pragma: no cover
|
||||
'''
|
||||
Return whether dump streaming is used for this hook. (Spoiler: It isn't.)
|
||||
'''
|
||||
return False
|
||||
|
||||
|
||||
def get_subvolume_mount_points(findmnt_command):
|
||||
'''
|
||||
Given a findmnt command to run, get all sorted Btrfs subvolume mount points.
|
||||
'''
|
||||
findmnt_output = borgmatic.execute.execute_command_and_capture_output(
|
||||
tuple(findmnt_command.split(' '))
|
||||
+ (
|
||||
'-t', # Filesystem type.
|
||||
'btrfs',
|
||||
'--json',
|
||||
'--list', # Request a flat list instead of a nested subvolume hierarchy.
|
||||
)
|
||||
)
|
||||
|
||||
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}"')
|
||||
|
||||
|
||||
Subvolume = collections.namedtuple('Subvolume', ('path', 'contained_patterns'), defaults=((),))
|
||||
|
||||
|
||||
def get_subvolumes(btrfs_command, findmnt_command, patterns=None):
|
||||
'''
|
||||
Given a Btrfs command to run and a sequence of configured patterns, find the intersection
|
||||
between the current Btrfs filesystem and subvolume mount points and the paths of any patterns.
|
||||
The idea is that these pattern paths represent the requested subvolumes to snapshot.
|
||||
|
||||
Only include subvolumes that contain at least one root pattern sourced from borgmatic
|
||||
configuration (as opposed to generated elsewhere in borgmatic). But if patterns is None, then
|
||||
return all subvolumes instead, sorted by path.
|
||||
|
||||
Return the result as a sequence of matching subvolume mount points.
|
||||
'''
|
||||
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
|
||||
# a whack at the candidate pattern piñata before their parents do. (Patterns are consumed during
|
||||
# this process, so no two subvolumes end up with the same contained patterns.)
|
||||
for mount_point in reversed(get_subvolume_mount_points(findmnt_command)):
|
||||
subvolumes.extend(
|
||||
Subvolume(mount_point, contained_patterns)
|
||||
for contained_patterns in (
|
||||
borgmatic.hooks.data_source.snapshot.get_contained_patterns(
|
||||
mount_point, candidate_patterns
|
||||
),
|
||||
)
|
||||
if patterns is None
|
||||
or 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))
|
||||
|
||||
|
||||
BORGMATIC_SNAPSHOT_PREFIX = '.borgmatic-snapshot-'
|
||||
|
||||
|
||||
def make_snapshot_path(subvolume_path):
|
||||
'''
|
||||
Given the path to a subvolume, make a corresponding snapshot path for it.
|
||||
'''
|
||||
return os.path.join(
|
||||
subvolume_path,
|
||||
f'{BORGMATIC_SNAPSHOT_PREFIX}{os.getpid()}',
|
||||
# Included so that the snapshot ends up in the Borg archive at the "original" subvolume path.
|
||||
) + subvolume_path.rstrip(os.path.sep)
|
||||
|
||||
|
||||
def make_snapshot_exclude_pattern(subvolume_path): # pragma: no cover
|
||||
'''
|
||||
Given the path to a subvolume, make a corresponding exclude pattern for its embedded snapshot
|
||||
path. This is to work around a quirk of Btrfs: If you make a snapshot path as a child directory
|
||||
of a subvolume, then the snapshot's own initial directory component shows up as an empty
|
||||
directory within the snapshot itself. For instance, if you have a Btrfs subvolume at /mnt and
|
||||
make a snapshot of it at:
|
||||
|
||||
/mnt/.borgmatic-snapshot-1234/mnt
|
||||
|
||||
... then the snapshot itself will have an empty directory at:
|
||||
|
||||
/mnt/.borgmatic-snapshot-1234/mnt/.borgmatic-snapshot-1234
|
||||
|
||||
So to prevent that from ending up in the Borg archive, this function produces an exclude pattern
|
||||
to exclude that path.
|
||||
'''
|
||||
snapshot_directory = f'{BORGMATIC_SNAPSHOT_PREFIX}{os.getpid()}'
|
||||
|
||||
return borgmatic.borg.pattern.Pattern(
|
||||
os.path.join(
|
||||
subvolume_path,
|
||||
snapshot_directory,
|
||||
subvolume_path.lstrip(os.path.sep),
|
||||
snapshot_directory,
|
||||
),
|
||||
borgmatic.borg.pattern.Pattern_type.NO_RECURSE,
|
||||
borgmatic.borg.pattern.Pattern_style.FNMATCH,
|
||||
source=borgmatic.borg.pattern.Pattern_source.HOOK,
|
||||
)
|
||||
|
||||
|
||||
def make_borg_snapshot_pattern(subvolume_path, pattern):
|
||||
'''
|
||||
Given the path to a subvolume and a pattern as a borgmatic.borg.pattern.Pattern instance whose
|
||||
path is inside the subvolume, return a new Pattern with its path rewritten to be in a snapshot
|
||||
path intended for giving to Borg.
|
||||
|
||||
Move any initial caret in a regular expression pattern path to the beginning, so as not to break
|
||||
the regular expression.
|
||||
'''
|
||||
initial_caret = (
|
||||
'^'
|
||||
if pattern.style == borgmatic.borg.pattern.Pattern_style.REGULAR_EXPRESSION
|
||||
and pattern.path.startswith('^')
|
||||
else ''
|
||||
)
|
||||
|
||||
rewritten_path = initial_caret + os.path.join(
|
||||
subvolume_path,
|
||||
f'{BORGMATIC_SNAPSHOT_PREFIX}{os.getpid()}',
|
||||
'.', # Borg 1.4+ "slashdot" hack.
|
||||
# Included so that the source directory ends up in the Borg archive at its "original" path.
|
||||
pattern.path.lstrip('^').lstrip(os.path.sep),
|
||||
)
|
||||
|
||||
return borgmatic.borg.pattern.Pattern(
|
||||
rewritten_path,
|
||||
pattern.type,
|
||||
pattern.style,
|
||||
pattern.device,
|
||||
source=borgmatic.borg.pattern.Pattern_source.HOOK,
|
||||
)
|
||||
|
||||
|
||||
def snapshot_subvolume(btrfs_command, subvolume_path, snapshot_path): # pragma: no cover
|
||||
'''
|
||||
Given a Btrfs command to run, the path to a subvolume, and the path for a snapshot, create a new
|
||||
Btrfs snapshot of the subvolume.
|
||||
'''
|
||||
os.makedirs(os.path.dirname(snapshot_path), mode=0o700, exist_ok=True)
|
||||
|
||||
borgmatic.execute.execute_command(
|
||||
tuple(btrfs_command.split(' '))
|
||||
+ (
|
||||
'subvolume',
|
||||
'snapshot',
|
||||
'-r', # Read-only.
|
||||
subvolume_path,
|
||||
snapshot_path,
|
||||
),
|
||||
output_log_level=logging.DEBUG,
|
||||
)
|
||||
|
||||
|
||||
def dump_data_sources(
|
||||
hook_config,
|
||||
config,
|
||||
config_paths,
|
||||
borgmatic_runtime_directory,
|
||||
patterns,
|
||||
dry_run,
|
||||
):
|
||||
'''
|
||||
Given a Btrfs configuration dict, a configuration dict, the borgmatic configuration file paths,
|
||||
the borgmatic runtime directory, the configured patterns, and whether this is a dry run,
|
||||
auto-detect and snapshot any Btrfs subvolume mount points listed in the given patterns. Also
|
||||
update those patterns, replacing subvolume mount points with corresponding snapshot directories
|
||||
so they get stored in the Borg archive instead.
|
||||
|
||||
Return an empty sequence, since there are no ongoing dump processes from this hook.
|
||||
|
||||
If this is a dry run, then don't actually snapshot anything.
|
||||
'''
|
||||
dry_run_label = ' (dry run; not actually snapshotting anything)' if dry_run else ''
|
||||
logger.info(f'Snapshotting Btrfs subvolumes{dry_run_label}')
|
||||
|
||||
# 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)
|
||||
|
||||
if not subvolumes:
|
||||
logger.warning(f'No Btrfs subvolumes found to snapshot{dry_run_label}')
|
||||
|
||||
# Snapshot each subvolume, rewriting patterns to use their snapshot paths.
|
||||
for subvolume in subvolumes:
|
||||
logger.debug(f'Creating Btrfs snapshot for {subvolume.path} subvolume')
|
||||
|
||||
snapshot_path = make_snapshot_path(subvolume.path)
|
||||
|
||||
if dry_run:
|
||||
continue
|
||||
|
||||
snapshot_subvolume(btrfs_command, subvolume.path, snapshot_path)
|
||||
|
||||
for pattern in subvolume.contained_patterns:
|
||||
snapshot_pattern = make_borg_snapshot_pattern(subvolume.path, pattern)
|
||||
|
||||
# Attempt to update the pattern in place, since pattern order matters to Borg.
|
||||
try:
|
||||
patterns[patterns.index(pattern)] = snapshot_pattern
|
||||
except ValueError:
|
||||
patterns.append(snapshot_pattern)
|
||||
|
||||
patterns.append(make_snapshot_exclude_pattern(subvolume.path))
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def delete_snapshot(btrfs_command, snapshot_path): # pragma: no cover
|
||||
'''
|
||||
Given a Btrfs command to run and the name of a snapshot path, delete it.
|
||||
'''
|
||||
borgmatic.execute.execute_command(
|
||||
tuple(btrfs_command.split(' '))
|
||||
+ (
|
||||
'subvolume',
|
||||
'delete',
|
||||
snapshot_path,
|
||||
),
|
||||
output_log_level=logging.DEBUG,
|
||||
)
|
||||
|
||||
|
||||
def remove_data_source_dumps(hook_config, config, borgmatic_runtime_directory, 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.
|
||||
'''
|
||||
if hook_config is None:
|
||||
return
|
||||
|
||||
dry_run_label = ' (dry run; not actually removing anything)' if dry_run else ''
|
||||
|
||||
btrfs_command = hook_config.get('btrfs_command', 'btrfs')
|
||||
findmnt_command = hook_config.get('findmnt_command', 'findmnt')
|
||||
|
||||
try:
|
||||
all_subvolumes = get_subvolumes(btrfs_command, findmnt_command)
|
||||
except FileNotFoundError as error:
|
||||
logger.debug(f'Could not find "{error.filename}" command')
|
||||
return
|
||||
except subprocess.CalledProcessError as error:
|
||||
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.
|
||||
for subvolume in reversed(all_subvolumes):
|
||||
subvolume_snapshots_glob = borgmatic.config.paths.replace_temporary_subdirectory_with_glob(
|
||||
os.path.normpath(make_snapshot_path(subvolume.path)),
|
||||
temporary_directory_prefix=BORGMATIC_SNAPSHOT_PREFIX,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f'Looking for snapshots to remove in {subvolume_snapshots_glob}{dry_run_label}'
|
||||
)
|
||||
|
||||
for snapshot_path in glob.glob(subvolume_snapshots_glob):
|
||||
if not os.path.isdir(snapshot_path):
|
||||
continue
|
||||
|
||||
logger.debug(f'Deleting Btrfs snapshot {snapshot_path}{dry_run_label}')
|
||||
|
||||
if dry_run:
|
||||
continue
|
||||
|
||||
try:
|
||||
delete_snapshot(btrfs_command, snapshot_path)
|
||||
except FileNotFoundError:
|
||||
logger.debug(f'Could not find "{btrfs_command}" command')
|
||||
return
|
||||
except subprocess.CalledProcessError as error:
|
||||
logger.debug(error)
|
||||
return
|
||||
|
||||
# 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
|
||||
): # pragma: no cover
|
||||
'''
|
||||
Restores aren't implemented, because stored files can be extracted directly with "extract".
|
||||
'''
|
||||
return ()
|
||||
|
||||
|
||||
def restore_data_source_dump(
|
||||
hook_config,
|
||||
config,
|
||||
data_source,
|
||||
dry_run,
|
||||
extract_process,
|
||||
connection_params,
|
||||
borgmatic_runtime_directory,
|
||||
): # pragma: no cover
|
||||
'''
|
||||
Restores aren't implemented, because stored files can be extracted directly with "extract".
|
||||
'''
|
||||
raise NotImplementedError()
|
||||
@@ -0,0 +1,76 @@
|
||||
import fnmatch
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
IS_A_HOOK = False
|
||||
|
||||
|
||||
def make_data_source_dump_path(borgmatic_runtime_directory, data_source_hook_name):
|
||||
'''
|
||||
Given a borgmatic runtime directory and a data source hook name, construct a data source dump
|
||||
path.
|
||||
'''
|
||||
return os.path.join(borgmatic_runtime_directory, data_source_hook_name)
|
||||
|
||||
|
||||
def make_data_source_dump_filename(dump_path, name, hostname=None, port=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.
|
||||
|
||||
Raise ValueError if the data source name is invalid.
|
||||
'''
|
||||
if os.path.sep in name:
|
||||
raise ValueError(f'Invalid data source name {name}')
|
||||
|
||||
return os.path.join(
|
||||
dump_path, (hostname or 'localhost') + ('' if port is None else f':{port}'), name
|
||||
)
|
||||
|
||||
|
||||
def create_parent_directory_for_dump(dump_path):
|
||||
'''
|
||||
Create a directory to contain the given dump path.
|
||||
'''
|
||||
os.makedirs(os.path.dirname(dump_path), mode=0o700, exist_ok=True)
|
||||
|
||||
|
||||
def create_named_pipe_for_dump(dump_path):
|
||||
'''
|
||||
Create a named pipe at the given dump path.
|
||||
'''
|
||||
create_parent_directory_for_dump(dump_path)
|
||||
os.mkfifo(dump_path, mode=0o600)
|
||||
|
||||
|
||||
def remove_data_source_dumps(dump_path, data_source_type_name, dry_run):
|
||||
'''
|
||||
Remove all data source dumps in the given dump directory path (including the directory itself).
|
||||
If this is a dry run, then don't actually remove anything.
|
||||
'''
|
||||
dry_run_label = ' (dry run; not actually removing anything)' if dry_run else ''
|
||||
|
||||
logger.debug(f'Removing {data_source_type_name} data source dumps{dry_run_label}')
|
||||
|
||||
if dry_run:
|
||||
return
|
||||
|
||||
if os.path.exists(dump_path):
|
||||
shutil.rmtree(dump_path)
|
||||
|
||||
|
||||
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
|
||||
# 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
|
||||
)
|
||||
@@ -0,0 +1,456 @@
|
||||
import collections
|
||||
import glob
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
import borgmatic.borg.pattern
|
||||
import borgmatic.config.paths
|
||||
import borgmatic.execute
|
||||
import borgmatic.hooks.data_source.snapshot
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def use_streaming(hook_config, config): # pragma: no cover
|
||||
'''
|
||||
Return whether dump streaming is used for this hook. (Spoiler: It isn't.)
|
||||
'''
|
||||
return False
|
||||
|
||||
|
||||
BORGMATIC_SNAPSHOT_PREFIX = 'borgmatic-'
|
||||
Logical_volume = collections.namedtuple(
|
||||
'Logical_volume', ('name', 'device_path', 'mount_point', 'contained_patterns')
|
||||
)
|
||||
|
||||
|
||||
def get_logical_volumes(lsblk_command, patterns=None):
|
||||
'''
|
||||
Given an lsblk command to run and a sequence of configured patterns, find the intersection
|
||||
between the current LVM logical volume mount points and the paths of any patterns. The idea is
|
||||
that these pattern paths represent the requested logical volumes to snapshot.
|
||||
|
||||
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.
|
||||
'''
|
||||
try:
|
||||
devices_info = json.loads(
|
||||
borgmatic.execute.execute_command_and_capture_output(
|
||||
# Use lsblk instead of lvs here because lvs can't show active mounts.
|
||||
tuple(lsblk_command.split(' '))
|
||||
+ (
|
||||
'--output',
|
||||
'name,path,mountpoint,type',
|
||||
'--json',
|
||||
'--list',
|
||||
)
|
||||
)
|
||||
)
|
||||
except json.JSONDecodeError as error:
|
||||
raise ValueError(f'Invalid {lsblk_command} JSON output: {error}')
|
||||
|
||||
candidate_patterns = set(patterns or ())
|
||||
|
||||
try:
|
||||
# Sort from longest to shortest mount points, so longer mount points get a whack at the
|
||||
# candidate pattern piñata before their parents do. (Patterns are consumed below, so no two
|
||||
# logical volumes end up with the same contained patterns.)
|
||||
return tuple(
|
||||
Logical_volume(device['name'], device['path'], device['mountpoint'], contained_patterns)
|
||||
for device in sorted(
|
||||
devices_info['blockdevices'],
|
||||
key=lambda device: device['mountpoint'] or '',
|
||||
reverse=True,
|
||||
)
|
||||
if device['mountpoint'] and device['type'] == 'lvm'
|
||||
for contained_patterns in (
|
||||
borgmatic.hooks.data_source.snapshot.get_contained_patterns(
|
||||
device['mountpoint'], candidate_patterns
|
||||
),
|
||||
)
|
||||
if not patterns
|
||||
or 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}"')
|
||||
|
||||
|
||||
def snapshot_logical_volume(
|
||||
lvcreate_command,
|
||||
snapshot_name,
|
||||
logical_volume_device,
|
||||
snapshot_size,
|
||||
):
|
||||
'''
|
||||
Given an lvcreate command to run, a snapshot name, the path to the logical volume device to
|
||||
snapshot, and a snapshot size string, create a new LVM snapshot.
|
||||
'''
|
||||
borgmatic.execute.execute_command(
|
||||
tuple(lvcreate_command.split(' '))
|
||||
+ (
|
||||
'--snapshot',
|
||||
('--extents' if '%' in snapshot_size else '--size'),
|
||||
snapshot_size,
|
||||
'--permission',
|
||||
'r', # Read-only.
|
||||
'--name',
|
||||
snapshot_name,
|
||||
logical_volume_device,
|
||||
),
|
||||
output_log_level=logging.DEBUG,
|
||||
)
|
||||
|
||||
|
||||
def mount_snapshot(mount_command, snapshot_device, snapshot_mount_path): # pragma: no cover
|
||||
'''
|
||||
Given a mount command to run, the device path for an existing snapshot, and the path where the
|
||||
snapshot should be mounted, mount the snapshot as read-only (making any necessary directories
|
||||
first).
|
||||
'''
|
||||
os.makedirs(snapshot_mount_path, mode=0o700, exist_ok=True)
|
||||
|
||||
borgmatic.execute.execute_command(
|
||||
tuple(mount_command.split(' '))
|
||||
+ (
|
||||
'-o',
|
||||
'ro',
|
||||
snapshot_device,
|
||||
snapshot_mount_path,
|
||||
),
|
||||
output_log_level=logging.DEBUG,
|
||||
)
|
||||
|
||||
|
||||
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 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.
|
||||
'''
|
||||
initial_caret = (
|
||||
'^'
|
||||
if pattern.style == borgmatic.borg.pattern.Pattern_style.REGULAR_EXPRESSION
|
||||
and pattern.path.startswith('^')
|
||||
else ''
|
||||
)
|
||||
|
||||
rewritten_path = initial_caret + os.path.join(
|
||||
normalized_runtime_directory,
|
||||
'lvm_snapshots',
|
||||
# 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
|
||||
),
|
||||
'.', # Borg 1.4+ "slashdot" hack.
|
||||
# Included so that the source directory ends up in the Borg archive at its "original" path.
|
||||
pattern.path.lstrip('^').lstrip(os.path.sep),
|
||||
)
|
||||
|
||||
return borgmatic.borg.pattern.Pattern(
|
||||
rewritten_path,
|
||||
pattern.type,
|
||||
pattern.style,
|
||||
pattern.device,
|
||||
source=borgmatic.borg.pattern.Pattern_source.HOOK,
|
||||
)
|
||||
|
||||
|
||||
DEFAULT_SNAPSHOT_SIZE = '10%ORIGIN'
|
||||
|
||||
|
||||
def dump_data_sources(
|
||||
hook_config,
|
||||
config,
|
||||
config_paths,
|
||||
borgmatic_runtime_directory,
|
||||
patterns,
|
||||
dry_run,
|
||||
):
|
||||
'''
|
||||
Given an LVM configuration dict, a configuration dict, the borgmatic configuration file paths,
|
||||
the borgmatic runtime directory, the configured patterns, and whether this is a dry run,
|
||||
auto-detect and snapshot any LVM logical volume mount points listed in the given patterns. Also
|
||||
update those patterns, replacing logical volume mount points with corresponding snapshot
|
||||
directories so they get stored in the Borg archive instead.
|
||||
|
||||
Return an empty sequence, since there are no ongoing dump processes from this hook.
|
||||
|
||||
If this is a dry run, then don't actually snapshot anything.
|
||||
'''
|
||||
dry_run_label = ' (dry run; not actually snapshotting anything)' if dry_run else ''
|
||||
logger.info(f'Snapshotting LVM logical volumes{dry_run_label}')
|
||||
|
||||
# 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)
|
||||
|
||||
# Snapshot each logical volume, rewriting source directories to use the snapshot paths.
|
||||
snapshot_suffix = f'{BORGMATIC_SNAPSHOT_PREFIX}{os.getpid()}'
|
||||
normalized_runtime_directory = os.path.normpath(borgmatic_runtime_directory)
|
||||
|
||||
if not requested_logical_volumes:
|
||||
logger.warning(f'No LVM logical volumes found to snapshot{dry_run_label}')
|
||||
|
||||
for logical_volume in requested_logical_volumes:
|
||||
snapshot_name = f'{logical_volume.name}_{snapshot_suffix}'
|
||||
logger.debug(
|
||||
f'Creating LVM snapshot {snapshot_name} of {logical_volume.mount_point}{dry_run_label}'
|
||||
)
|
||||
|
||||
if not dry_run:
|
||||
snapshot_logical_volume(
|
||||
hook_config.get('lvcreate_command', 'lvcreate'),
|
||||
snapshot_name,
|
||||
logical_volume.device_path,
|
||||
hook_config.get('snapshot_size', DEFAULT_SNAPSHOT_SIZE),
|
||||
)
|
||||
|
||||
# Get the device path for the snapshot we just created.
|
||||
try:
|
||||
snapshot = get_snapshots(
|
||||
hook_config.get('lvs_command', 'lvs'), snapshot_name=snapshot_name
|
||||
)[0]
|
||||
except IndexError:
|
||||
raise ValueError(f'Cannot find LVM snapshot {snapshot_name}')
|
||||
|
||||
# Mount the snapshot into a particular named temporary directory so that the snapshot ends
|
||||
# up in the Borg archive at the "original" logical volume mount point path.
|
||||
snapshot_mount_path = os.path.join(
|
||||
normalized_runtime_directory,
|
||||
'lvm_snapshots',
|
||||
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}'
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
continue
|
||||
|
||||
mount_snapshot(
|
||||
hook_config.get('mount_command', 'mount'), snapshot.device_path, snapshot_mount_path
|
||||
)
|
||||
|
||||
for pattern in logical_volume.contained_patterns:
|
||||
snapshot_pattern = make_borg_snapshot_pattern(
|
||||
pattern, logical_volume, normalized_runtime_directory
|
||||
)
|
||||
|
||||
# Attempt to update the pattern in place, since pattern order matters to Borg.
|
||||
try:
|
||||
patterns[patterns.index(pattern)] = snapshot_pattern
|
||||
except ValueError:
|
||||
patterns.append(snapshot_pattern)
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def unmount_snapshot(umount_command, snapshot_mount_path): # pragma: no cover
|
||||
'''
|
||||
Given a umount command to run and the mount path of a snapshot, unmount it.
|
||||
'''
|
||||
borgmatic.execute.execute_command(
|
||||
tuple(umount_command.split(' ')) + (snapshot_mount_path,),
|
||||
output_log_level=logging.DEBUG,
|
||||
)
|
||||
|
||||
|
||||
def remove_snapshot(lvremove_command, snapshot_device_path): # pragma: no cover
|
||||
'''
|
||||
Given an lvremove command to run and the device path of a snapshot, remove it it.
|
||||
'''
|
||||
borgmatic.execute.execute_command(
|
||||
tuple(lvremove_command.split(' '))
|
||||
+ (
|
||||
'--force', # Suppress an interactive "are you sure?" type prompt.
|
||||
snapshot_device_path,
|
||||
),
|
||||
output_log_level=logging.DEBUG,
|
||||
)
|
||||
|
||||
|
||||
Snapshot = collections.namedtuple(
|
||||
'Snapshot',
|
||||
('name', 'device_path'),
|
||||
)
|
||||
|
||||
|
||||
def get_snapshots(lvs_command, snapshot_name=None):
|
||||
'''
|
||||
Given an lvs command to run, return all LVM snapshots as a sequence of Snapshot instances.
|
||||
|
||||
If a snapshot name is given, filter the results to that snapshot.
|
||||
'''
|
||||
try:
|
||||
snapshot_info = json.loads(
|
||||
borgmatic.execute.execute_command_and_capture_output(
|
||||
# Use lvs instead of lsblk here because lsblk can't filter to just snapshots.
|
||||
tuple(lvs_command.split(' '))
|
||||
+ (
|
||||
'--report-format',
|
||||
'json',
|
||||
'--options',
|
||||
'lv_name,lv_path',
|
||||
'--select',
|
||||
'lv_attr =~ ^s', # Filter to just snapshots.
|
||||
)
|
||||
)
|
||||
)
|
||||
except json.JSONDecodeError as error:
|
||||
raise ValueError(f'Invalid {lvs_command} JSON output: {error}')
|
||||
|
||||
try:
|
||||
return tuple(
|
||||
Snapshot(snapshot['lv_name'], snapshot['lv_path'])
|
||||
for snapshot in snapshot_info['report'][0]['lv']
|
||||
if snapshot_name is None or snapshot['lv_name'] == snapshot_name
|
||||
)
|
||||
except IndexError:
|
||||
raise ValueError(f'Invalid {lvs_command} output: Missing report data')
|
||||
except KeyError as error:
|
||||
raise ValueError(f'Invalid {lvs_command} output: Missing key "{error}"')
|
||||
|
||||
|
||||
def remove_data_source_dumps(hook_config, config, borgmatic_runtime_directory, dry_run):
|
||||
'''
|
||||
Given an LVM configuration dict, a configuration dict, the borgmatic runtime directory, and
|
||||
whether this is a dry run, unmount and delete any LVM snapshots created by borgmatic. If this is
|
||||
a dry run or LVM isn't configured in borgmatic's configuration, then don't actually remove
|
||||
anything.
|
||||
'''
|
||||
if hook_config is None:
|
||||
return
|
||||
|
||||
dry_run_label = ' (dry run; not actually removing anything)' if dry_run else ''
|
||||
|
||||
# Unmount snapshots.
|
||||
try:
|
||||
logical_volumes = get_logical_volumes(hook_config.get('lsblk_command', 'lsblk'))
|
||||
except FileNotFoundError as error:
|
||||
logger.debug(f'Could not find "{error.filename}" command')
|
||||
return
|
||||
except subprocess.CalledProcessError as error:
|
||||
logger.debug(error)
|
||||
return
|
||||
|
||||
snapshots_glob = os.path.join(
|
||||
borgmatic.config.paths.replace_temporary_subdirectory_with_glob(
|
||||
os.path.normpath(borgmatic_runtime_directory),
|
||||
),
|
||||
'lvm_snapshots',
|
||||
'*',
|
||||
)
|
||||
logger.debug(f'Looking for snapshots to remove in {snapshots_glob}{dry_run_label}')
|
||||
umount_command = hook_config.get('umount_command', 'umount')
|
||||
|
||||
for snapshots_directory in glob.glob(snapshots_glob):
|
||||
if not os.path.isdir(snapshots_directory):
|
||||
continue
|
||||
|
||||
for logical_volume in logical_volumes:
|
||||
snapshot_mount_path = os.path.join(
|
||||
snapshots_directory, logical_volume.mount_point.lstrip(os.path.sep)
|
||||
)
|
||||
|
||||
# If 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
|
||||
# we'll do another recursive delete below. The point of doing it here is that we don't
|
||||
# want to try to unmount a non-mounted directory (which *will* fail).
|
||||
if not dry_run:
|
||||
shutil.rmtree(snapshot_mount_path, ignore_errors=True)
|
||||
|
||||
# If the delete was successful, that means there's nothing to unmount.
|
||||
if not os.path.isdir(snapshot_mount_path):
|
||||
continue
|
||||
|
||||
logger.debug(f'Unmounting LVM snapshot at {snapshot_mount_path}{dry_run_label}')
|
||||
|
||||
if dry_run:
|
||||
continue
|
||||
|
||||
try:
|
||||
unmount_snapshot(umount_command, snapshot_mount_path)
|
||||
except FileNotFoundError:
|
||||
logger.debug(f'Could not find "{umount_command}" command')
|
||||
return
|
||||
except subprocess.CalledProcessError as error:
|
||||
logger.debug(error)
|
||||
continue
|
||||
|
||||
if not dry_run:
|
||||
shutil.rmtree(snapshots_directory)
|
||||
|
||||
# Delete snapshots.
|
||||
lvremove_command = hook_config.get('lvremove_command', 'lvremove')
|
||||
|
||||
try:
|
||||
snapshots = get_snapshots(hook_config.get('lvs_command', 'lvs'))
|
||||
except FileNotFoundError as error:
|
||||
logger.debug(f'Could not find "{error.filename}" command')
|
||||
return
|
||||
except subprocess.CalledProcessError as error:
|
||||
logger.debug(error)
|
||||
return
|
||||
|
||||
for snapshot in snapshots:
|
||||
# Only delete snapshots that borgmatic actually created!
|
||||
if not snapshot.name.split('_')[-1].startswith(BORGMATIC_SNAPSHOT_PREFIX):
|
||||
continue
|
||||
|
||||
logger.debug(f'Deleting LVM snapshot {snapshot.name}{dry_run_label}')
|
||||
|
||||
if not dry_run:
|
||||
remove_snapshot(lvremove_command, snapshot.device_path)
|
||||
|
||||
|
||||
def make_data_source_dump_patterns(
|
||||
hook_config, config, borgmatic_runtime_directory, name=None
|
||||
): # pragma: no cover
|
||||
'''
|
||||
Restores aren't implemented, because stored files can be extracted directly with "extract".
|
||||
'''
|
||||
return ()
|
||||
|
||||
|
||||
def restore_data_source_dump(
|
||||
hook_config,
|
||||
config,
|
||||
data_source,
|
||||
dry_run,
|
||||
extract_process,
|
||||
connection_params,
|
||||
borgmatic_runtime_directory,
|
||||
): # pragma: no cover
|
||||
'''
|
||||
Restores aren't implemented, because stored files can be extracted directly with "extract".
|
||||
'''
|
||||
raise NotImplementedError()
|
||||
@@ -0,0 +1,326 @@
|
||||
import copy
|
||||
import logging
|
||||
import os
|
||||
import shlex
|
||||
|
||||
import borgmatic.borg.pattern
|
||||
import borgmatic.config.paths
|
||||
import borgmatic.hooks.credential.parse
|
||||
from borgmatic.execute import (
|
||||
execute_command,
|
||||
execute_command_and_capture_output,
|
||||
execute_command_with_processes,
|
||||
)
|
||||
from borgmatic.hooks.data_source import dump
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def make_dump_path(base_directory): # pragma: no cover
|
||||
'''
|
||||
Given a base directory, make the corresponding dump path.
|
||||
'''
|
||||
return dump.make_data_source_dump_path(base_directory, 'mariadb_databases')
|
||||
|
||||
|
||||
SYSTEM_DATABASE_NAMES = ('information_schema', 'mysql', 'performance_schema', 'sys')
|
||||
|
||||
|
||||
def database_names_to_dump(database, config, environment, dry_run):
|
||||
'''
|
||||
Given a requested database config and a configuration dict, 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')
|
||||
)
|
||||
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'], config),
|
||||
)
|
||||
if 'username' in database
|
||||
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, environment=environment)
|
||||
|
||||
return tuple(
|
||||
show_name
|
||||
for show_name in show_output.strip().splitlines()
|
||||
if show_name not in SYSTEM_DATABASE_NAMES
|
||||
)
|
||||
|
||||
|
||||
def execute_dump_command(
|
||||
database, config, 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
|
||||
pipe constructed from the given dump path and database name.
|
||||
|
||||
Return a subprocess.Popen instance for the dump process ready to spew to a named pipe. But if
|
||||
this is a dry run, then don't actually dump anything and return None.
|
||||
'''
|
||||
database_name = database['name']
|
||||
dump_filename = dump.make_data_source_dump_filename(
|
||||
dump_path,
|
||||
database['name'],
|
||||
database.get('hostname'),
|
||||
database.get('port'),
|
||||
)
|
||||
|
||||
if os.path.exists(dump_filename):
|
||||
logger.warning(
|
||||
f'Skipping duplicate dump of MariaDB database "{database_name}" to {dump_filename}'
|
||||
)
|
||||
return None
|
||||
|
||||
mariadb_dump_command = tuple(
|
||||
shlex.quote(part)
|
||||
for part in shlex.split(database.get('mariadb_dump_command') or 'mariadb-dump')
|
||||
)
|
||||
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'], config),
|
||||
)
|
||||
if 'username' in database
|
||||
else ()
|
||||
)
|
||||
+ ('--databases',)
|
||||
+ database_names
|
||||
+ ('--result-file', dump_filename)
|
||||
)
|
||||
|
||||
logger.debug(f'Dumping MariaDB database "{database_name}" to {dump_filename}{dry_run_label}')
|
||||
if dry_run:
|
||||
return None
|
||||
|
||||
dump.create_named_pipe_for_dump(dump_filename)
|
||||
|
||||
return execute_command(
|
||||
dump_command,
|
||||
environment=environment,
|
||||
run_to_completion=False,
|
||||
)
|
||||
|
||||
|
||||
def get_default_port(databases, config): # pragma: no cover
|
||||
return 3306
|
||||
|
||||
|
||||
def use_streaming(databases, config):
|
||||
'''
|
||||
Given a sequence of MariaDB database configuration dicts, a configuration dict (ignored), return
|
||||
whether streaming will be using during dumps.
|
||||
'''
|
||||
return any(databases)
|
||||
|
||||
|
||||
def dump_data_sources(
|
||||
databases,
|
||||
config,
|
||||
config_paths,
|
||||
borgmatic_runtime_directory,
|
||||
patterns,
|
||||
dry_run,
|
||||
):
|
||||
'''
|
||||
Dump the given MariaDB databases to a named pipe. The databases are supplied as a sequence of
|
||||
dicts, one dict describing each database as per the configuration schema. Use the given
|
||||
borgmatic runtime directory to construct the destination path.
|
||||
|
||||
Return a sequence of subprocess.Popen instances for the dump processes ready to spew to a named
|
||||
pipe. But if this is a dry run, then don't actually dump anything and return an empty sequence.
|
||||
Also append the the parent directory of the database dumps to the given patterns list, so the
|
||||
dumps actually get backed up.
|
||||
'''
|
||||
dry_run_label = ' (dry run; not actually dumping anything)' if dry_run else ''
|
||||
processes = []
|
||||
|
||||
logger.info(f'Dumping MariaDB databases{dry_run_label}')
|
||||
|
||||
for database in databases:
|
||||
dump_path = make_dump_path(borgmatic_runtime_directory)
|
||||
environment = dict(
|
||||
os.environ,
|
||||
**(
|
||||
{
|
||||
'MYSQL_PWD': borgmatic.hooks.credential.parse.resolve_credential(
|
||||
database['password'], config
|
||||
)
|
||||
}
|
||||
if 'password' in database
|
||||
else {}
|
||||
),
|
||||
)
|
||||
dump_database_names = database_names_to_dump(database, config, environment, dry_run)
|
||||
|
||||
if not dump_database_names:
|
||||
if dry_run:
|
||||
continue
|
||||
|
||||
raise ValueError('Cannot find any MariaDB databases to dump.')
|
||||
|
||||
if database['name'] == 'all' and database.get('format'):
|
||||
for dump_name in dump_database_names:
|
||||
renamed_database = copy.copy(database)
|
||||
renamed_database['name'] = dump_name
|
||||
processes.append(
|
||||
execute_dump_command(
|
||||
renamed_database,
|
||||
config,
|
||||
dump_path,
|
||||
(dump_name,),
|
||||
environment,
|
||||
dry_run,
|
||||
dry_run_label,
|
||||
)
|
||||
)
|
||||
else:
|
||||
processes.append(
|
||||
execute_dump_command(
|
||||
database,
|
||||
config,
|
||||
dump_path,
|
||||
dump_database_names,
|
||||
environment,
|
||||
dry_run,
|
||||
dry_run_label,
|
||||
)
|
||||
)
|
||||
|
||||
if not dry_run:
|
||||
patterns.append(
|
||||
borgmatic.borg.pattern.Pattern(
|
||||
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
|
||||
): # pragma: no cover
|
||||
'''
|
||||
Remove all database dump files for this hook regardless of the given databases. Use the
|
||||
borgmatic_runtime_directory to construct the destination path. If this is a dry run, then don't
|
||||
actually remove anything.
|
||||
'''
|
||||
dump.remove_data_source_dumps(make_dump_path(borgmatic_runtime_directory), 'MariaDB', dry_run)
|
||||
|
||||
|
||||
def make_data_source_dump_patterns(
|
||||
databases, config, borgmatic_runtime_directory, name=None
|
||||
): # pragma: no cover
|
||||
'''
|
||||
Given a sequence of configurations dicts, a configuration dict, the borgmatic runtime directory,
|
||||
and a database name to match, return the corresponding glob patterns to match the database dump
|
||||
in an archive.
|
||||
'''
|
||||
borgmatic_source_directory = borgmatic.config.paths.get_borgmatic_source_directory(config)
|
||||
|
||||
return (
|
||||
dump.make_data_source_dump_filename(make_dump_path('borgmatic'), name, hostname='*'),
|
||||
dump.make_data_source_dump_filename(
|
||||
make_dump_path(borgmatic_runtime_directory), name, hostname='*'
|
||||
),
|
||||
dump.make_data_source_dump_filename(
|
||||
make_dump_path(borgmatic_source_directory), name, hostname='*'
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def restore_data_source_dump(
|
||||
hook_config,
|
||||
config,
|
||||
data_source,
|
||||
dry_run,
|
||||
extract_process,
|
||||
connection_params,
|
||||
borgmatic_runtime_directory,
|
||||
):
|
||||
'''
|
||||
Restore a database from the given extract stream. The database is supplied as a data source
|
||||
configuration dict, but the given hook configuration is ignored. If this is a dry run, then
|
||||
don't actually restore anything. Trigger the given active extract process (an instance of
|
||||
subprocess.Popen) to produce output to consume.
|
||||
'''
|
||||
dry_run_label = ' (dry run; not actually restoring anything)' if dry_run else ''
|
||||
hostname = connection_params['hostname'] or data_source.get(
|
||||
'restore_hostname', data_source.get('hostname')
|
||||
)
|
||||
port = str(
|
||||
connection_params['port'] or data_source.get('restore_port', data_source.get('port', ''))
|
||||
)
|
||||
username = borgmatic.hooks.credential.parse.resolve_credential(
|
||||
(
|
||||
connection_params['username']
|
||||
or data_source.get('restore_username', data_source.get('username'))
|
||||
),
|
||||
config,
|
||||
)
|
||||
password = borgmatic.hooks.credential.parse.resolve_credential(
|
||||
(
|
||||
connection_params['password']
|
||||
or data_source.get('restore_password', data_source.get('password'))
|
||||
),
|
||||
config,
|
||||
)
|
||||
|
||||
mariadb_restore_command = tuple(
|
||||
shlex.quote(part) for part in shlex.split(data_source.get('mariadb_command') or 'mariadb')
|
||||
)
|
||||
restore_command = (
|
||||
mariadb_restore_command
|
||||
+ ('--batch',)
|
||||
+ (
|
||||
tuple(data_source['restore_options'].split(' '))
|
||||
if 'restore_options' in data_source
|
||||
else ()
|
||||
)
|
||||
+ (('--host', hostname) if hostname else ())
|
||||
+ (('--port', str(port)) if port else ())
|
||||
+ (('--protocol', 'tcp') if hostname or port else ())
|
||||
+ (('--user', username) if username else ())
|
||||
)
|
||||
environment = dict(os.environ, **({'MYSQL_PWD': password} if password else {}))
|
||||
|
||||
logger.debug(f"Restoring MariaDB database {data_source['name']}{dry_run_label}")
|
||||
if dry_run:
|
||||
return
|
||||
|
||||
# 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(
|
||||
restore_command,
|
||||
[extract_process],
|
||||
output_log_level=logging.DEBUG,
|
||||
input_file=extract_process.stdout,
|
||||
environment=environment,
|
||||
)
|
||||
@@ -0,0 +1,263 @@
|
||||
import logging
|
||||
import os
|
||||
import shlex
|
||||
|
||||
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 dump
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def make_dump_path(base_directory): # pragma: no cover
|
||||
'''
|
||||
Given a base directory, make the corresponding dump path.
|
||||
'''
|
||||
return dump.make_data_source_dump_path(base_directory, 'mongodb_databases')
|
||||
|
||||
|
||||
def get_default_port(databases, config): # pragma: no cover
|
||||
return 27017
|
||||
|
||||
|
||||
def use_streaming(databases, config):
|
||||
'''
|
||||
Given a sequence of MongoDB database configuration dicts, a configuration dict (ignored), return
|
||||
whether streaming will be using during dumps.
|
||||
'''
|
||||
return any(database.get('format') != 'directory' for database in databases)
|
||||
|
||||
|
||||
def dump_data_sources(
|
||||
databases,
|
||||
config,
|
||||
config_paths,
|
||||
borgmatic_runtime_directory,
|
||||
patterns,
|
||||
dry_run,
|
||||
):
|
||||
'''
|
||||
Dump the given MongoDB databases to a named pipe. The databases are supplied as a sequence of
|
||||
dicts, one dict describing each database as per the configuration schema. Use the borgmatic
|
||||
runtime directory to construct the destination path (used for the directory format.
|
||||
|
||||
Return a sequence of subprocess.Popen instances for the dump processes ready to spew to a named
|
||||
pipe. But if this is a dry run, then don't actually dump anything and return an empty sequence.
|
||||
Also append the the parent directory of the database dumps to the given patterns list, so the
|
||||
dumps actually get backed up.
|
||||
'''
|
||||
dry_run_label = ' (dry run; not actually dumping anything)' if dry_run else ''
|
||||
|
||||
logger.info(f'Dumping MongoDB databases{dry_run_label}')
|
||||
|
||||
processes = []
|
||||
for database in databases:
|
||||
name = database['name']
|
||||
dump_filename = dump.make_data_source_dump_filename(
|
||||
make_dump_path(borgmatic_runtime_directory),
|
||||
name,
|
||||
database.get('hostname'),
|
||||
database.get('port'),
|
||||
)
|
||||
dump_format = database.get('format', 'archive')
|
||||
|
||||
logger.debug(
|
||||
f'Dumping MongoDB database {name} to {dump_filename}{dry_run_label}',
|
||||
)
|
||||
if dry_run:
|
||||
continue
|
||||
|
||||
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)
|
||||
else:
|
||||
dump.create_named_pipe_for_dump(dump_filename)
|
||||
processes.append(execute_command(command, shell=True, run_to_completion=False))
|
||||
|
||||
if not dry_run:
|
||||
patterns.append(
|
||||
borgmatic.borg.pattern.Pattern(
|
||||
os.path.join(borgmatic_runtime_directory, 'mongodb_databases'),
|
||||
source=borgmatic.borg.pattern.Pattern_source.HOOK,
|
||||
)
|
||||
)
|
||||
|
||||
return processes
|
||||
|
||||
|
||||
def build_dump_command(database, config, dump_filename, dump_format):
|
||||
'''
|
||||
Return the mongodump command from a single database configuration.
|
||||
'''
|
||||
all_databases = database['name'] == 'all'
|
||||
|
||||
return (
|
||||
('mongodump',)
|
||||
+ (('--out', shlex.quote(dump_filename)) if dump_format == 'directory' else ())
|
||||
+ (('--host', shlex.quote(database['hostname'])) if 'hostname' in database else ())
|
||||
+ (('--port', shlex.quote(str(database['port']))) if 'port' in database else ())
|
||||
+ (
|
||||
(
|
||||
'--username',
|
||||
shlex.quote(
|
||||
borgmatic.hooks.credential.parse.resolve_credential(
|
||||
database['username'], config
|
||||
)
|
||||
),
|
||||
)
|
||||
if 'username' in database
|
||||
else ()
|
||||
)
|
||||
+ (
|
||||
(
|
||||
'--password',
|
||||
shlex.quote(
|
||||
borgmatic.hooks.credential.parse.resolve_credential(
|
||||
database['password'], config
|
||||
)
|
||||
),
|
||||
)
|
||||
if 'password' in database
|
||||
else ()
|
||||
)
|
||||
+ (
|
||||
('--authenticationDatabase', shlex.quote(database['authentication_database']))
|
||||
if 'authentication_database' in database
|
||||
else ()
|
||||
)
|
||||
+ (('--db', shlex.quote(database['name'])) if not all_databases else ())
|
||||
+ (
|
||||
tuple(shlex.quote(option) for option in database['options'].split(' '))
|
||||
if 'options' in database
|
||||
else ()
|
||||
)
|
||||
+ (('--archive', '>', shlex.quote(dump_filename)) if dump_format != 'directory' else ())
|
||||
)
|
||||
|
||||
|
||||
def remove_data_source_dumps(
|
||||
databases, config, borgmatic_runtime_directory, dry_run
|
||||
): # pragma: no cover
|
||||
'''
|
||||
Remove all database dump files for this hook regardless of the given databases. Use the
|
||||
borgmatic_runtime_directory to construct the destination path. If this is a dry run, then don't
|
||||
actually remove anything.
|
||||
'''
|
||||
dump.remove_data_source_dumps(make_dump_path(borgmatic_runtime_directory), 'MongoDB', dry_run)
|
||||
|
||||
|
||||
def make_data_source_dump_patterns(
|
||||
databases, config, borgmatic_runtime_directory, name=None
|
||||
): # pragma: no cover
|
||||
'''
|
||||
Given a sequence of configurations dicts, a configuration dict, the borgmatic runtime directory,
|
||||
and a database name to match, return the corresponding glob patterns to match the database dump
|
||||
in an archive.
|
||||
'''
|
||||
borgmatic_source_directory = borgmatic.config.paths.get_borgmatic_source_directory(config)
|
||||
|
||||
return (
|
||||
dump.make_data_source_dump_filename(make_dump_path('borgmatic'), name, hostname='*'),
|
||||
dump.make_data_source_dump_filename(
|
||||
make_dump_path(borgmatic_runtime_directory), name, hostname='*'
|
||||
),
|
||||
dump.make_data_source_dump_filename(
|
||||
make_dump_path(borgmatic_source_directory), name, hostname='*'
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def restore_data_source_dump(
|
||||
hook_config,
|
||||
config,
|
||||
data_source,
|
||||
dry_run,
|
||||
extract_process,
|
||||
connection_params,
|
||||
borgmatic_runtime_directory,
|
||||
):
|
||||
'''
|
||||
Restore a database from the given extract stream. The database is supplied as a data source
|
||||
configuration dict, but the given hook configuration is ignored. The given configuration dict is
|
||||
used to construct the destination path. If this is a dry run, then don't actually restore
|
||||
anything. Trigger the given active extract process (an instance of subprocess.Popen) to produce
|
||||
output to consume.
|
||||
|
||||
If the extract process is None, then restore the dump from the filesystem rather than from an
|
||||
extract stream.
|
||||
'''
|
||||
dry_run_label = ' (dry run; not actually restoring anything)' if dry_run else ''
|
||||
dump_filename = dump.make_data_source_dump_filename(
|
||||
make_dump_path(borgmatic_runtime_directory),
|
||||
data_source['name'],
|
||||
data_source.get('hostname'),
|
||||
)
|
||||
restore_command = build_restore_command(
|
||||
extract_process, data_source, config, dump_filename, connection_params
|
||||
)
|
||||
|
||||
logger.debug(f"Restoring MongoDB database {data_source['name']}{dry_run_label}")
|
||||
if dry_run:
|
||||
return
|
||||
|
||||
# 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(
|
||||
restore_command,
|
||||
[extract_process] if extract_process else [],
|
||||
output_log_level=logging.DEBUG,
|
||||
input_file=extract_process.stdout if extract_process else None,
|
||||
)
|
||||
|
||||
|
||||
def build_restore_command(extract_process, database, config, dump_filename, connection_params):
|
||||
'''
|
||||
Return the mongorestore command from a single database configuration.
|
||||
'''
|
||||
hostname = connection_params['hostname'] or database.get(
|
||||
'restore_hostname', database.get('hostname')
|
||||
)
|
||||
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'))
|
||||
),
|
||||
config,
|
||||
)
|
||||
password = borgmatic.hooks.credential.parse.resolve_credential(
|
||||
(
|
||||
connection_params['password']
|
||||
or database.get('restore_password', database.get('password'))
|
||||
),
|
||||
config,
|
||||
)
|
||||
|
||||
command = ['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))
|
||||
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))
|
||||
|
||||
return command
|
||||
@@ -0,0 +1,325 @@
|
||||
import copy
|
||||
import logging
|
||||
import os
|
||||
import shlex
|
||||
|
||||
import borgmatic.borg.pattern
|
||||
import borgmatic.config.paths
|
||||
import borgmatic.hooks.credential.parse
|
||||
from borgmatic.execute import (
|
||||
execute_command,
|
||||
execute_command_and_capture_output,
|
||||
execute_command_with_processes,
|
||||
)
|
||||
from borgmatic.hooks.data_source import dump
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def make_dump_path(base_directory): # pragma: no cover
|
||||
'''
|
||||
Given a base directory, make the corresponding dump path.
|
||||
'''
|
||||
return dump.make_data_source_dump_path(base_directory, 'mysql_databases')
|
||||
|
||||
|
||||
SYSTEM_DATABASE_NAMES = ('information_schema', 'mysql', 'performance_schema', 'sys')
|
||||
|
||||
|
||||
def database_names_to_dump(database, config, environment, dry_run):
|
||||
'''
|
||||
Given a requested database config and a configuration dict, 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')
|
||||
)
|
||||
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'], config),
|
||||
)
|
||||
if 'username' in database
|
||||
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, environment=environment)
|
||||
|
||||
return tuple(
|
||||
show_name
|
||||
for show_name in show_output.strip().splitlines()
|
||||
if show_name not in SYSTEM_DATABASE_NAMES
|
||||
)
|
||||
|
||||
|
||||
def execute_dump_command(
|
||||
database, config, 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
|
||||
named pipe constructed from the given dump path and database name.
|
||||
|
||||
Return a subprocess.Popen instance for the dump process ready to spew to a named pipe. But if
|
||||
this is a dry run, then don't actually dump anything and return None.
|
||||
'''
|
||||
database_name = database['name']
|
||||
dump_filename = dump.make_data_source_dump_filename(
|
||||
dump_path,
|
||||
database['name'],
|
||||
database.get('hostname'),
|
||||
database.get('port'),
|
||||
)
|
||||
|
||||
if os.path.exists(dump_filename):
|
||||
logger.warning(
|
||||
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')
|
||||
)
|
||||
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'], config),
|
||||
)
|
||||
if 'username' in database
|
||||
else ()
|
||||
)
|
||||
+ ('--databases',)
|
||||
+ database_names
|
||||
+ ('--result-file', dump_filename)
|
||||
)
|
||||
|
||||
logger.debug(f'Dumping MySQL database "{database_name}" to {dump_filename}{dry_run_label}')
|
||||
if dry_run:
|
||||
return None
|
||||
|
||||
dump.create_named_pipe_for_dump(dump_filename)
|
||||
|
||||
return execute_command(
|
||||
dump_command,
|
||||
environment=environment,
|
||||
run_to_completion=False,
|
||||
)
|
||||
|
||||
|
||||
def get_default_port(databases, config): # pragma: no cover
|
||||
return 3306
|
||||
|
||||
|
||||
def use_streaming(databases, config):
|
||||
'''
|
||||
Given a sequence of MySQL database configuration dicts, a configuration dict (ignored), return
|
||||
whether streaming will be using during dumps.
|
||||
'''
|
||||
return any(databases)
|
||||
|
||||
|
||||
def dump_data_sources(
|
||||
databases,
|
||||
config,
|
||||
config_paths,
|
||||
borgmatic_runtime_directory,
|
||||
patterns,
|
||||
dry_run,
|
||||
):
|
||||
'''
|
||||
Dump the given MySQL/MariaDB databases to a named pipe. The databases are supplied as a sequence
|
||||
of dicts, one dict describing each database as per the configuration schema. Use the given
|
||||
borgmatic runtime directory to construct the destination path.
|
||||
|
||||
Return a sequence of subprocess.Popen instances for the dump processes ready to spew to a named
|
||||
pipe. But if this is a dry run, then don't actually dump anything and return an empty sequence.
|
||||
Also append the the parent directory of the database dumps to the given patterns list, so the
|
||||
dumps actually get backed up.
|
||||
'''
|
||||
dry_run_label = ' (dry run; not actually dumping anything)' if dry_run else ''
|
||||
processes = []
|
||||
|
||||
logger.info(f'Dumping MySQL databases{dry_run_label}')
|
||||
|
||||
for database in databases:
|
||||
dump_path = make_dump_path(borgmatic_runtime_directory)
|
||||
environment = dict(
|
||||
os.environ,
|
||||
**(
|
||||
{
|
||||
'MYSQL_PWD': borgmatic.hooks.credential.parse.resolve_credential(
|
||||
database['password'], config
|
||||
)
|
||||
}
|
||||
if 'password' in database
|
||||
else {}
|
||||
),
|
||||
)
|
||||
dump_database_names = database_names_to_dump(database, config, environment, dry_run)
|
||||
|
||||
if not dump_database_names:
|
||||
if dry_run:
|
||||
continue
|
||||
|
||||
raise ValueError('Cannot find any MySQL databases to dump.')
|
||||
|
||||
if database['name'] == 'all' and database.get('format'):
|
||||
for dump_name in dump_database_names:
|
||||
renamed_database = copy.copy(database)
|
||||
renamed_database['name'] = dump_name
|
||||
processes.append(
|
||||
execute_dump_command(
|
||||
renamed_database,
|
||||
config,
|
||||
dump_path,
|
||||
(dump_name,),
|
||||
environment,
|
||||
dry_run,
|
||||
dry_run_label,
|
||||
)
|
||||
)
|
||||
else:
|
||||
processes.append(
|
||||
execute_dump_command(
|
||||
database,
|
||||
config,
|
||||
dump_path,
|
||||
dump_database_names,
|
||||
environment,
|
||||
dry_run,
|
||||
dry_run_label,
|
||||
)
|
||||
)
|
||||
|
||||
if not dry_run:
|
||||
patterns.append(
|
||||
borgmatic.borg.pattern.Pattern(
|
||||
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
|
||||
): # pragma: no cover
|
||||
'''
|
||||
Remove all database dump files for this hook regardless of the given databases. Use the
|
||||
borgmatic runtime directory to construct the destination path. If this is a dry run, then don't
|
||||
actually remove anything.
|
||||
'''
|
||||
dump.remove_data_source_dumps(make_dump_path(borgmatic_runtime_directory), 'MySQL', dry_run)
|
||||
|
||||
|
||||
def make_data_source_dump_patterns(
|
||||
databases, config, borgmatic_runtime_directory, name=None
|
||||
): # pragma: no cover
|
||||
'''
|
||||
Given a sequence of configurations dicts, a configuration dict, the borgmatic runtime directory,
|
||||
and a database name to match, return the corresponding glob patterns to match the database dump
|
||||
in an archive.
|
||||
'''
|
||||
borgmatic_source_directory = borgmatic.config.paths.get_borgmatic_source_directory(config)
|
||||
|
||||
return (
|
||||
dump.make_data_source_dump_filename(make_dump_path('borgmatic'), name, hostname='*'),
|
||||
dump.make_data_source_dump_filename(
|
||||
make_dump_path(borgmatic_runtime_directory), name, hostname='*'
|
||||
),
|
||||
dump.make_data_source_dump_filename(
|
||||
make_dump_path(borgmatic_source_directory), name, hostname='*'
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def restore_data_source_dump(
|
||||
hook_config,
|
||||
config,
|
||||
data_source,
|
||||
dry_run,
|
||||
extract_process,
|
||||
connection_params,
|
||||
borgmatic_runtime_directory,
|
||||
):
|
||||
'''
|
||||
Restore a database from the given extract stream. The database is supplied as a data source
|
||||
configuration dict, but the given hook configuration is ignored. If this is a dry run, then
|
||||
don't actually restore anything. Trigger the given active extract process (an instance of
|
||||
subprocess.Popen) to produce output to consume.
|
||||
'''
|
||||
dry_run_label = ' (dry run; not actually restoring anything)' if dry_run else ''
|
||||
hostname = connection_params['hostname'] or data_source.get(
|
||||
'restore_hostname', data_source.get('hostname')
|
||||
)
|
||||
port = str(
|
||||
connection_params['port'] or data_source.get('restore_port', data_source.get('port', ''))
|
||||
)
|
||||
username = borgmatic.hooks.credential.parse.resolve_credential(
|
||||
(
|
||||
connection_params['username']
|
||||
or data_source.get('restore_username', data_source.get('username'))
|
||||
),
|
||||
config,
|
||||
)
|
||||
password = borgmatic.hooks.credential.parse.resolve_credential(
|
||||
(
|
||||
connection_params['password']
|
||||
or data_source.get('restore_password', data_source.get('password'))
|
||||
),
|
||||
config,
|
||||
)
|
||||
|
||||
mysql_restore_command = tuple(
|
||||
shlex.quote(part) for part in shlex.split(data_source.get('mysql_command') or 'mysql')
|
||||
)
|
||||
restore_command = (
|
||||
mysql_restore_command
|
||||
+ ('--batch',)
|
||||
+ (
|
||||
tuple(data_source['restore_options'].split(' '))
|
||||
if 'restore_options' in data_source
|
||||
else ()
|
||||
)
|
||||
+ (('--host', hostname) if hostname else ())
|
||||
+ (('--port', str(port)) if port else ())
|
||||
+ (('--protocol', 'tcp') if hostname or port else ())
|
||||
+ (('--user', username) if username else ())
|
||||
)
|
||||
environment = dict(os.environ, **({'MYSQL_PWD': password} if password else {}))
|
||||
|
||||
logger.debug(f"Restoring MySQL database {data_source['name']}{dry_run_label}")
|
||||
if dry_run:
|
||||
return
|
||||
|
||||
# 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(
|
||||
restore_command,
|
||||
[extract_process],
|
||||
output_log_level=logging.DEBUG,
|
||||
input_file=extract_process.stdout,
|
||||
environment=environment,
|
||||
)
|
||||
@@ -2,67 +2,75 @@ import csv
|
||||
import itertools
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
import shlex
|
||||
|
||||
import borgmatic.borg.pattern
|
||||
import borgmatic.config.paths
|
||||
import borgmatic.hooks.credential.parse
|
||||
from borgmatic.execute import (
|
||||
execute_command,
|
||||
execute_command_and_capture_output,
|
||||
execute_command_with_processes,
|
||||
)
|
||||
from borgmatic.hooks import dump
|
||||
from borgmatic.hooks.data_source import dump
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def make_dump_path(config): # pragma: no cover
|
||||
def make_dump_path(base_directory): # pragma: no cover
|
||||
'''
|
||||
Make the dump path from the given configuration dict and the name of this hook.
|
||||
Given a base directory, make the corresponding dump path.
|
||||
'''
|
||||
return dump.make_data_source_dump_path(
|
||||
config.get('borgmatic_source_directory'), 'postgresql_databases'
|
||||
)
|
||||
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'] = restore_connection_params.get('password') or database.get(
|
||||
'restore_password', database['password']
|
||||
environment['PGPASSWORD'] = borgmatic.hooks.credential.parse.resolve_credential(
|
||||
(
|
||||
restore_connection_params.get('password')
|
||||
or database.get('restore_password', database['password'])
|
||||
),
|
||||
config,
|
||||
)
|
||||
else:
|
||||
extra['PGPASSWORD'] = database['password']
|
||||
environment['PGPASSWORD'] = borgmatic.hooks.credential.parse.resolve_credential(
|
||||
database['password'], config
|
||||
)
|
||||
except (AttributeError, KeyError):
|
||||
pass
|
||||
|
||||
if 'ssl_mode' in database:
|
||||
extra['PGSSLMODE'] = database['ssl_mode']
|
||||
environment['PGSSLMODE'] = database['ssl_mode']
|
||||
if 'ssl_cert' in database:
|
||||
extra['PGSSLCERT'] = database['ssl_cert']
|
||||
environment['PGSSLCERT'] = database['ssl_cert']
|
||||
if 'ssl_key' in database:
|
||||
extra['PGSSLKEY'] = database['ssl_key']
|
||||
environment['PGSSLKEY'] = database['ssl_key']
|
||||
if 'ssl_root_cert' in database:
|
||||
extra['PGSSLROOTCERT'] = database['ssl_root_cert']
|
||||
environment['PGSSLROOTCERT'] = database['ssl_root_cert']
|
||||
if 'ssl_crl' in database:
|
||||
extra['PGSSLCRL'] = database['ssl_crl']
|
||||
environment['PGSSLCRL'] = database['ssl_crl']
|
||||
|
||||
return extra
|
||||
return environment
|
||||
|
||||
|
||||
EXCLUDED_DATABASE_NAMES = ('template0', 'template1')
|
||||
|
||||
|
||||
def database_names_to_dump(database, extra_environment, log_prefix, 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']
|
||||
|
||||
@@ -81,13 +89,18 @@ def database_names_to_dump(database, extra_environment, log_prefix, dry_run):
|
||||
+ ('--list', '--no-password', '--no-psqlrc', '--csv', '--tuples-only')
|
||||
+ (('--host', database['hostname']) if 'hostname' in database else ())
|
||||
+ (('--port', str(database['port'])) if 'port' in database else ())
|
||||
+ (('--username', database['username']) if 'username' in database else ())
|
||||
+ (
|
||||
(
|
||||
'--username',
|
||||
borgmatic.hooks.credential.parse.resolve_credential(database['username'], config),
|
||||
)
|
||||
if 'username' in database
|
||||
else ()
|
||||
)
|
||||
+ (tuple(database['list_options'].split(' ')) if 'list_options' in database else ())
|
||||
)
|
||||
logger.debug(f'{log_prefix}: Querying for "all" PostgreSQL databases to dump')
|
||||
list_output = execute_command_and_capture_output(
|
||||
list_command, extra_environment=extra_environment
|
||||
)
|
||||
logger.debug('Querying for "all" PostgreSQL databases to dump')
|
||||
list_output = execute_command_and_capture_output(list_command, environment=environment)
|
||||
|
||||
return tuple(
|
||||
row[0]
|
||||
@@ -96,29 +109,47 @@ def database_names_to_dump(database, extra_environment, log_prefix, dry_run):
|
||||
)
|
||||
|
||||
|
||||
def dump_data_sources(databases, config, log_prefix, dry_run):
|
||||
def get_default_port(databases, config): # pragma: no cover
|
||||
return 5432
|
||||
|
||||
|
||||
def use_streaming(databases, config):
|
||||
'''
|
||||
Given a sequence of PostgreSQL database configuration dicts, a configuration dict (ignored),
|
||||
return whether streaming will be using during dumps.
|
||||
'''
|
||||
return any(database.get('format') != 'directory' for database in databases)
|
||||
|
||||
|
||||
def dump_data_sources(
|
||||
databases,
|
||||
config,
|
||||
config_paths,
|
||||
borgmatic_runtime_directory,
|
||||
patterns,
|
||||
dry_run,
|
||||
):
|
||||
'''
|
||||
Dump the given PostgreSQL databases to a named pipe. The databases are supplied as a sequence of
|
||||
dicts, one dict describing each database as per the configuration schema. Use the given
|
||||
configuration dict to construct the destination path and the given log prefix in any log
|
||||
entries.
|
||||
borgmatic runtime directory to construct the destination path.
|
||||
|
||||
Return a sequence of subprocess.Popen instances for the dump processes ready to spew to a named
|
||||
pipe. But if this is a dry run, then don't actually dump anything and return an empty sequence.
|
||||
Also append the the parent directory of the database dumps to the given patterns list, so the
|
||||
dumps actually get backed up.
|
||||
|
||||
Raise ValueError if the databases to dump cannot be determined.
|
||||
'''
|
||||
dry_run_label = ' (dry run; not actually dumping anything)' if dry_run else ''
|
||||
processes = []
|
||||
|
||||
logger.info(f'{log_prefix}: Dumping PostgreSQL databases{dry_run_label}')
|
||||
logger.info(f'Dumping PostgreSQL databases{dry_run_label}')
|
||||
|
||||
for database in databases:
|
||||
extra_environment = make_extra_environment(database)
|
||||
dump_path = make_dump_path(config)
|
||||
dump_database_names = database_names_to_dump(
|
||||
database, extra_environment, log_prefix, dry_run
|
||||
)
|
||||
environment = make_environment(database, config)
|
||||
dump_path = make_dump_path(borgmatic_runtime_directory)
|
||||
dump_database_names = database_names_to_dump(database, config, environment, dry_run)
|
||||
|
||||
if not dump_database_names:
|
||||
if dry_run:
|
||||
@@ -134,11 +165,14 @@ def dump_data_sources(databases, config, log_prefix, dry_run):
|
||||
for part in shlex.split(database.get('pg_dump_command') or default_dump_command)
|
||||
)
|
||||
dump_filename = dump.make_data_source_dump_filename(
|
||||
dump_path, database_name, database.get('hostname')
|
||||
dump_path,
|
||||
database_name,
|
||||
database.get('hostname'),
|
||||
database.get('port'),
|
||||
)
|
||||
if os.path.exists(dump_filename):
|
||||
logger.warning(
|
||||
f'{log_prefix}: Skipping duplicate dump of PostgreSQL database "{database_name}" to {dump_filename}'
|
||||
f'Skipping duplicate dump of PostgreSQL database "{database_name}" to {dump_filename}'
|
||||
)
|
||||
continue
|
||||
|
||||
@@ -152,7 +186,14 @@ def dump_data_sources(databases, config, log_prefix, dry_run):
|
||||
+ (('--host', shlex.quote(database['hostname'])) if 'hostname' in database else ())
|
||||
+ (('--port', shlex.quote(str(database['port']))) if 'port' in database else ())
|
||||
+ (
|
||||
('--username', shlex.quote(database['username']))
|
||||
(
|
||||
'--username',
|
||||
shlex.quote(
|
||||
borgmatic.hooks.credential.parse.resolve_credential(
|
||||
database['username'], config
|
||||
)
|
||||
),
|
||||
)
|
||||
if 'username' in database
|
||||
else ()
|
||||
)
|
||||
@@ -172,7 +213,7 @@ def dump_data_sources(databases, config, log_prefix, dry_run):
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f'{log_prefix}: Dumping PostgreSQL database "{database_name}" to {dump_filename}{dry_run_label}'
|
||||
f'Dumping PostgreSQL database "{database_name}" to {dump_filename}{dry_run_label}'
|
||||
)
|
||||
if dry_run:
|
||||
continue
|
||||
@@ -182,7 +223,7 @@ def dump_data_sources(databases, config, log_prefix, dry_run):
|
||||
execute_command(
|
||||
command,
|
||||
shell=True,
|
||||
extra_environment=extra_environment,
|
||||
environment=environment,
|
||||
)
|
||||
else:
|
||||
dump.create_named_pipe_for_dump(dump_filename)
|
||||
@@ -190,41 +231,71 @@ def dump_data_sources(databases, config, log_prefix, dry_run):
|
||||
execute_command(
|
||||
command,
|
||||
shell=True,
|
||||
extra_environment=extra_environment,
|
||||
environment=environment,
|
||||
run_to_completion=False,
|
||||
)
|
||||
)
|
||||
|
||||
if not dry_run:
|
||||
patterns.append(
|
||||
borgmatic.borg.pattern.Pattern(
|
||||
os.path.join(borgmatic_runtime_directory, 'postgresql_databases'),
|
||||
source=borgmatic.borg.pattern.Pattern_source.HOOK,
|
||||
)
|
||||
)
|
||||
|
||||
return processes
|
||||
|
||||
|
||||
def remove_data_source_dumps(databases, config, log_prefix, dry_run): # pragma: no cover
|
||||
def remove_data_source_dumps(
|
||||
databases, config, borgmatic_runtime_directory, dry_run
|
||||
): # pragma: no cover
|
||||
'''
|
||||
Remove all database dump files for this hook regardless of the given databases. Use the given
|
||||
configuration dict to construct the destination path and the log prefix in any log entries. If
|
||||
this is a dry run, then don't actually remove anything.
|
||||
Remove all database dump files for this hook regardless of the given databases. Use the
|
||||
borgmatic runtime directory to construct the destination path. If this is a dry run, then don't
|
||||
actually remove anything.
|
||||
'''
|
||||
dump.remove_data_source_dumps(make_dump_path(config), 'PostgreSQL', log_prefix, dry_run)
|
||||
dump.remove_data_source_dumps(
|
||||
make_dump_path(borgmatic_runtime_directory), 'PostgreSQL', dry_run
|
||||
)
|
||||
|
||||
|
||||
def make_data_source_dump_pattern(databases, config, log_prefix, name=None): # pragma: no cover
|
||||
def make_data_source_dump_patterns(
|
||||
databases, config, borgmatic_runtime_directory, name=None
|
||||
): # pragma: no cover
|
||||
'''
|
||||
Given a sequence of configurations dicts, a configuration dict, a prefix to log with, and a
|
||||
database name to match, return the corresponding glob patterns to match the database dump in an
|
||||
archive.
|
||||
Given a sequence of configurations dicts, a configuration dict, the borgmatic runtime directory,
|
||||
and a database name to match, return the corresponding glob patterns to match the database dump
|
||||
in an archive.
|
||||
'''
|
||||
return dump.make_data_source_dump_filename(make_dump_path(config), name, hostname='*')
|
||||
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_runtime_directory), name, hostname='*'
|
||||
),
|
||||
dump.make_data_source_dump_filename(
|
||||
make_dump_path(borgmatic_source_directory), name, hostname='*'
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def restore_data_source_dump(
|
||||
hook_config, config, log_prefix, data_source, dry_run, extract_process, connection_params
|
||||
hook_config,
|
||||
config,
|
||||
data_source,
|
||||
dry_run,
|
||||
extract_process,
|
||||
connection_params,
|
||||
borgmatic_runtime_directory,
|
||||
):
|
||||
'''
|
||||
Restore a database from the given extract stream. The database is supplied as a data source
|
||||
configuration dict, but the given hook configuration is ignored. The given configuration dict is
|
||||
used to construct the destination path, and the given log prefix is used for any log entries. If
|
||||
this is a dry run, then don't actually restore anything. Trigger the given active extract
|
||||
process (an instance of subprocess.Popen) to produce output to consume.
|
||||
configuration dict, but the given hook configuration is ignored. The given borgmatic runtime
|
||||
directory is used to construct the destination path (used for the directory format). If this is
|
||||
a dry run, then don't actually restore anything. Trigger the given active extract process (an
|
||||
instance of subprocess.Popen) to produce output to consume.
|
||||
|
||||
If the extract process is None, then restore the dump from the filesystem rather than from an
|
||||
extract stream.
|
||||
@@ -239,13 +310,19 @@ def restore_data_source_dump(
|
||||
port = str(
|
||||
connection_params['port'] or data_source.get('restore_port', data_source.get('port', ''))
|
||||
)
|
||||
username = connection_params['username'] or data_source.get(
|
||||
'restore_username', data_source.get('username')
|
||||
username = borgmatic.hooks.credential.parse.resolve_credential(
|
||||
(
|
||||
connection_params['username']
|
||||
or data_source.get('restore_username', data_source.get('username'))
|
||||
),
|
||||
config,
|
||||
)
|
||||
|
||||
all_databases = bool(data_source['name'] == 'all')
|
||||
dump_filename = dump.make_data_source_dump_filename(
|
||||
make_dump_path(config), data_source['name'], data_source.get('hostname')
|
||||
make_dump_path(borgmatic_runtime_directory),
|
||||
data_source['name'],
|
||||
data_source.get('hostname'),
|
||||
)
|
||||
psql_command = tuple(
|
||||
shlex.quote(part) for part in shlex.split(data_source.get('psql_command') or 'psql')
|
||||
@@ -283,7 +360,7 @@ def restore_data_source_dump(
|
||||
if 'restore_options' in data_source
|
||||
else ()
|
||||
)
|
||||
+ (() if extract_process else (dump_filename,))
|
||||
+ (() if extract_process else (str(pathlib.Path(dump_filename)),))
|
||||
+ tuple(
|
||||
itertools.chain.from_iterable(('--schema', schema) for schema in data_source['schemas'])
|
||||
if data_source.get('schemas')
|
||||
@@ -291,13 +368,9 @@ def restore_data_source_dump(
|
||||
)
|
||||
)
|
||||
|
||||
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"{log_prefix}: Restoring PostgreSQL database {data_source['name']}{dry_run_label}"
|
||||
)
|
||||
logger.debug(f"Restoring PostgreSQL database {data_source['name']}{dry_run_label}")
|
||||
if dry_run:
|
||||
return
|
||||
|
||||
@@ -308,6 +381,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)
|
||||
@@ -0,0 +1,41 @@
|
||||
import pathlib
|
||||
|
||||
IS_A_HOOK = False
|
||||
|
||||
|
||||
def get_contained_patterns(parent_directory, candidate_patterns):
|
||||
'''
|
||||
Given a parent directory and a set of candidate patterns potentially inside it, get the subset
|
||||
of contained patterns for which the parent directory is actually the parent, a grandparent, the
|
||||
very same directory, etc. The idea is if, say, /var/log and /var/lib are candidate pattern
|
||||
paths, but there's a parent directory (logical volume, dataset, subvolume, etc.) at /var, then
|
||||
/var is what we want to snapshot.
|
||||
|
||||
For this 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.
|
||||
|
||||
As part of this, also mutate the given set of candidate patterns to remove any actually
|
||||
contained patterns from it. That way, this function can be called multiple times, successively
|
||||
processing candidate patterns until none are left—and avoiding assigning any candidate pattern
|
||||
to more than one parent directory.
|
||||
'''
|
||||
if not candidate_patterns:
|
||||
return ()
|
||||
|
||||
contained_patterns = tuple(
|
||||
candidate
|
||||
for candidate in candidate_patterns
|
||||
for candidate_path in (pathlib.PurePath(candidate.path.lstrip('^')),)
|
||||
if (
|
||||
pathlib.PurePath(parent_directory) == candidate_path
|
||||
or pathlib.PurePath(parent_directory) in candidate_path.parents
|
||||
)
|
||||
)
|
||||
candidate_patterns -= set(contained_patterns)
|
||||
|
||||
return contained_patterns
|
||||
@@ -0,0 +1,175 @@
|
||||
import logging
|
||||
import os
|
||||
import shlex
|
||||
|
||||
import borgmatic.borg.pattern
|
||||
import borgmatic.config.paths
|
||||
from borgmatic.execute import execute_command, execute_command_with_processes
|
||||
from borgmatic.hooks.data_source import dump
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def make_dump_path(base_directory): # pragma: no cover
|
||||
'''
|
||||
Given a base directory, make the corresponding dump path.
|
||||
'''
|
||||
return dump.make_data_source_dump_path(base_directory, 'sqlite_databases')
|
||||
|
||||
|
||||
def get_default_port(databases, config): # pragma: no cover
|
||||
return None # SQLite doesn't use a port.
|
||||
|
||||
|
||||
def use_streaming(databases, config):
|
||||
'''
|
||||
Given a sequence of SQLite database configuration dicts, a configuration dict (ignored), return
|
||||
whether streaming will be using during dumps.
|
||||
'''
|
||||
return any(databases)
|
||||
|
||||
|
||||
def dump_data_sources(
|
||||
databases,
|
||||
config,
|
||||
config_paths,
|
||||
borgmatic_runtime_directory,
|
||||
patterns,
|
||||
dry_run,
|
||||
):
|
||||
'''
|
||||
Dump the given SQLite databases to a named pipe. The databases are supplied as a sequence of
|
||||
configuration dicts, as per the configuration schema. Use the given borgmatic runtime directory
|
||||
to construct the destination path.
|
||||
|
||||
Return a sequence of subprocess.Popen instances for the dump processes ready to spew to a named
|
||||
pipe. But if this is a dry run, then don't actually dump anything and return an empty sequence.
|
||||
Also append the the parent directory of the database dumps to the given patterns list, so the
|
||||
dumps actually get backed up.
|
||||
'''
|
||||
dry_run_label = ' (dry run; not actually dumping anything)' if dry_run else ''
|
||||
processes = []
|
||||
|
||||
logger.info(f'Dumping SQLite databases{dry_run_label}')
|
||||
|
||||
for database in databases:
|
||||
database_path = database['path']
|
||||
|
||||
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'
|
||||
)
|
||||
|
||||
dump_path = make_dump_path(borgmatic_runtime_directory)
|
||||
dump_filename = dump.make_data_source_dump_filename(dump_path, database['name'])
|
||||
|
||||
if os.path.exists(dump_filename):
|
||||
logger.warning(
|
||||
f'Skipping duplicate dump of SQLite database at {database_path} to {dump_filename}'
|
||||
)
|
||||
continue
|
||||
|
||||
command = (
|
||||
'sqlite3',
|
||||
shlex.quote(database_path),
|
||||
'.dump',
|
||||
'>',
|
||||
shlex.quote(dump_filename),
|
||||
)
|
||||
logger.debug(
|
||||
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))
|
||||
|
||||
if not dry_run:
|
||||
patterns.append(
|
||||
borgmatic.borg.pattern.Pattern(
|
||||
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
|
||||
): # pragma: no cover
|
||||
'''
|
||||
Remove all database dump files for this hook regardless of the given databases. Use the
|
||||
borgmatic runtime directory to construct the destination path. If this is a dry run, then don't
|
||||
actually remove anything.
|
||||
'''
|
||||
dump.remove_data_source_dumps(make_dump_path(borgmatic_runtime_directory), 'SQLite', dry_run)
|
||||
|
||||
|
||||
def make_data_source_dump_patterns(
|
||||
databases, config, borgmatic_runtime_directory, name=None
|
||||
): # pragma: no cover
|
||||
'''
|
||||
Given a sequence of configurations dicts, a configuration dict, the borgmatic runtime directory,
|
||||
and a database name to match, return the corresponding glob patterns to match the database dump
|
||||
in an archive.
|
||||
'''
|
||||
borgmatic_source_directory = borgmatic.config.paths.get_borgmatic_source_directory(config)
|
||||
|
||||
return (
|
||||
dump.make_data_source_dump_filename(make_dump_path('borgmatic'), name, hostname='*'),
|
||||
dump.make_data_source_dump_filename(
|
||||
make_dump_path(borgmatic_runtime_directory), name, hostname='*'
|
||||
),
|
||||
dump.make_data_source_dump_filename(
|
||||
make_dump_path(borgmatic_source_directory), name, hostname='*'
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def restore_data_source_dump(
|
||||
hook_config,
|
||||
config,
|
||||
data_source,
|
||||
dry_run,
|
||||
extract_process,
|
||||
connection_params,
|
||||
borgmatic_runtime_directory,
|
||||
):
|
||||
'''
|
||||
Restore a database from the given extract stream. The database is supplied as a data source
|
||||
configuration dict, but the given hook configuration is ignored. If this is a dry run, then
|
||||
don't actually restore anything. Trigger the given active extract process (an instance of
|
||||
subprocess.Popen) to produce output to consume.
|
||||
'''
|
||||
dry_run_label = ' (dry run; not actually restoring anything)' if dry_run else ''
|
||||
database_path = connection_params['restore_path'] or data_source.get(
|
||||
'restore_path', data_source.get('path')
|
||||
)
|
||||
|
||||
logger.debug(f'Restoring SQLite database at {database_path}{dry_run_label}')
|
||||
if dry_run:
|
||||
return
|
||||
|
||||
try:
|
||||
os.remove(database_path)
|
||||
logger.warning(f'Removed existing SQLite database at {database_path}')
|
||||
except FileNotFoundError: # pragma: no cover
|
||||
pass
|
||||
|
||||
restore_command = (
|
||||
'sqlite3',
|
||||
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(
|
||||
restore_command,
|
||||
[extract_process],
|
||||
output_log_level=logging.DEBUG,
|
||||
input_file=extract_process.stdout,
|
||||
)
|
||||
@@ -0,0 +1,451 @@
|
||||
import collections
|
||||
import glob
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
import borgmatic.borg.pattern
|
||||
import borgmatic.config.paths
|
||||
import borgmatic.execute
|
||||
import borgmatic.hooks.data_source.snapshot
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def use_streaming(hook_config, config): # pragma: no cover
|
||||
'''
|
||||
Return whether dump streaming is used for this hook. (Spoiler: It isn't.)
|
||||
'''
|
||||
return False
|
||||
|
||||
|
||||
BORGMATIC_SNAPSHOT_PREFIX = 'borgmatic-'
|
||||
BORGMATIC_USER_PROPERTY = 'org.torsion.borgmatic:backup'
|
||||
|
||||
|
||||
Dataset = collections.namedtuple(
|
||||
'Dataset',
|
||||
('name', 'mount_point', 'auto_backup', 'contained_patterns'),
|
||||
defaults=(False, ()),
|
||||
)
|
||||
|
||||
|
||||
def get_datasets_to_backup(zfs_command, patterns):
|
||||
'''
|
||||
Given a ZFS command to run and a sequence of configured patterns, find the intersection between
|
||||
the current ZFS dataset mount points and the paths of any patterns. The idea is that these
|
||||
pattern paths represent the requested datasets to snapshot. But also include any datasets tagged
|
||||
with a borgmatic-specific user property, whether or not they appear in the patterns.
|
||||
|
||||
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(' '))
|
||||
+ (
|
||||
'list',
|
||||
'-H',
|
||||
'-t',
|
||||
'filesystem',
|
||||
'-o',
|
||||
f'name,mountpoint,canmount,{BORGMATIC_USER_PROPERTY}',
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
# Sort from longest to shortest mount points, so longer mount points get a whack at the
|
||||
# candidate pattern piñata before their parents do. (Patterns are consumed during the second
|
||||
# loop below, so no two datasets end up with the same contained patterns.)
|
||||
datasets = sorted(
|
||||
(
|
||||
Dataset(dataset_name, mount_point, (user_property_value == 'auto'), ())
|
||||
for line in list_output.splitlines()
|
||||
for (dataset_name, mount_point, 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,
|
||||
)
|
||||
except ValueError:
|
||||
raise ValueError(f'Invalid {zfs_command} list output')
|
||||
|
||||
candidate_patterns = set(patterns)
|
||||
|
||||
return tuple(
|
||||
sorted(
|
||||
(
|
||||
Dataset(
|
||||
dataset.name,
|
||||
dataset.mount_point,
|
||||
dataset.auto_backup,
|
||||
contained_patterns,
|
||||
)
|
||||
for dataset in datasets
|
||||
for contained_patterns in (
|
||||
(
|
||||
(
|
||||
(
|
||||
borgmatic.borg.pattern.Pattern(
|
||||
dataset.mount_point,
|
||||
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
|
||||
)
|
||||
),
|
||||
)
|
||||
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,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def get_all_dataset_mount_points(zfs_command):
|
||||
'''
|
||||
Given a ZFS command to run, return all ZFS datasets as a sequence of sorted mount points.
|
||||
'''
|
||||
list_output = borgmatic.execute.execute_command_and_capture_output(
|
||||
tuple(zfs_command.split(' '))
|
||||
+ (
|
||||
'list',
|
||||
'-H',
|
||||
'-t',
|
||||
'filesystem',
|
||||
'-o',
|
||||
'mountpoint',
|
||||
)
|
||||
)
|
||||
|
||||
return tuple(sorted(line.rstrip() for line in list_output.splitlines()))
|
||||
|
||||
|
||||
def snapshot_dataset(zfs_command, full_snapshot_name): # pragma: no cover
|
||||
'''
|
||||
Given a ZFS command to run and a snapshot name of the form "dataset@snapshot", create a new ZFS
|
||||
snapshot.
|
||||
'''
|
||||
borgmatic.execute.execute_command(
|
||||
tuple(zfs_command.split(' '))
|
||||
+ (
|
||||
'snapshot',
|
||||
full_snapshot_name,
|
||||
),
|
||||
output_log_level=logging.DEBUG,
|
||||
)
|
||||
|
||||
|
||||
def mount_snapshot(mount_command, full_snapshot_name, snapshot_mount_path): # pragma: no cover
|
||||
'''
|
||||
Given a mount command to run, an existing snapshot name of the form "dataset@snapshot", and the
|
||||
path where the snapshot should be mounted, mount the snapshot (making any necessary directories
|
||||
first).
|
||||
'''
|
||||
os.makedirs(snapshot_mount_path, mode=0o700, exist_ok=True)
|
||||
|
||||
borgmatic.execute.execute_command(
|
||||
tuple(mount_command.split(' '))
|
||||
+ (
|
||||
'-t',
|
||||
'zfs',
|
||||
'-o',
|
||||
'ro',
|
||||
full_snapshot_name,
|
||||
snapshot_mount_path,
|
||||
),
|
||||
output_log_level=logging.DEBUG,
|
||||
)
|
||||
|
||||
|
||||
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 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.
|
||||
'''
|
||||
initial_caret = (
|
||||
'^'
|
||||
if pattern.style == borgmatic.borg.pattern.Pattern_style.REGULAR_EXPRESSION
|
||||
and pattern.path.startswith('^')
|
||||
else ''
|
||||
)
|
||||
|
||||
rewritten_path = initial_caret + os.path.join(
|
||||
normalized_runtime_directory,
|
||||
'zfs_snapshots',
|
||||
# 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),
|
||||
'.', # Borg 1.4+ "slashdot" hack.
|
||||
# Included so that the source directory ends up in the Borg archive at its "original" path.
|
||||
pattern.path.lstrip('^').lstrip(os.path.sep),
|
||||
)
|
||||
|
||||
return borgmatic.borg.pattern.Pattern(
|
||||
rewritten_path,
|
||||
pattern.type,
|
||||
pattern.style,
|
||||
pattern.device,
|
||||
source=borgmatic.borg.pattern.Pattern_source.HOOK,
|
||||
)
|
||||
|
||||
|
||||
def dump_data_sources(
|
||||
hook_config,
|
||||
config,
|
||||
config_paths,
|
||||
borgmatic_runtime_directory,
|
||||
patterns,
|
||||
dry_run,
|
||||
):
|
||||
'''
|
||||
Given a ZFS configuration dict, a configuration dict, the borgmatic configuration file paths,
|
||||
the borgmatic runtime directory, the configured patterns, and whether this is a dry run,
|
||||
auto-detect and snapshot any ZFS dataset mount points listed in the given patterns and any
|
||||
dataset with a borgmatic-specific user property. Also update those patterns, replacing dataset
|
||||
mount points with corresponding snapshot directories so they get stored in the Borg archive
|
||||
instead.
|
||||
|
||||
Return an empty sequence, since there are no ongoing dump processes from this hook.
|
||||
|
||||
If this is a dry run, then don't actually snapshot anything.
|
||||
'''
|
||||
dry_run_label = ' (dry run; not actually snapshotting anything)' if dry_run else ''
|
||||
logger.info(f'Snapshotting ZFS datasets{dry_run_label}')
|
||||
|
||||
# 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)
|
||||
|
||||
# Snapshot each dataset, rewriting patterns to use the snapshot paths.
|
||||
snapshot_name = f'{BORGMATIC_SNAPSHOT_PREFIX}{os.getpid()}'
|
||||
normalized_runtime_directory = os.path.normpath(borgmatic_runtime_directory)
|
||||
|
||||
if not requested_datasets:
|
||||
logger.warning(f'No ZFS datasets found to snapshot{dry_run_label}')
|
||||
|
||||
for dataset in requested_datasets:
|
||||
full_snapshot_name = f'{dataset.name}@{snapshot_name}'
|
||||
logger.debug(
|
||||
f'Creating ZFS snapshot {full_snapshot_name} of {dataset.mount_point}{dry_run_label}'
|
||||
)
|
||||
|
||||
if not dry_run:
|
||||
snapshot_dataset(zfs_command, full_snapshot_name)
|
||||
|
||||
# Mount the snapshot into a particular named temporary directory so that the snapshot ends
|
||||
# up in the Borg archive at the "original" dataset mount point path.
|
||||
snapshot_mount_path = os.path.join(
|
||||
normalized_runtime_directory,
|
||||
'zfs_snapshots',
|
||||
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}'
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
continue
|
||||
|
||||
mount_snapshot(
|
||||
hook_config.get('mount_command', 'mount'), full_snapshot_name, snapshot_mount_path
|
||||
)
|
||||
|
||||
for pattern in dataset.contained_patterns:
|
||||
snapshot_pattern = make_borg_snapshot_pattern(
|
||||
pattern, dataset, normalized_runtime_directory
|
||||
)
|
||||
|
||||
# Attempt to update the pattern in place, since pattern order matters to Borg.
|
||||
try:
|
||||
patterns[patterns.index(pattern)] = snapshot_pattern
|
||||
except ValueError:
|
||||
patterns.append(snapshot_pattern)
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def unmount_snapshot(umount_command, snapshot_mount_path): # pragma: no cover
|
||||
'''
|
||||
Given a umount command to run and the mount path of a snapshot, unmount it.
|
||||
'''
|
||||
borgmatic.execute.execute_command(
|
||||
tuple(umount_command.split(' ')) + (snapshot_mount_path,),
|
||||
output_log_level=logging.DEBUG,
|
||||
)
|
||||
|
||||
|
||||
def destroy_snapshot(zfs_command, full_snapshot_name): # pragma: no cover
|
||||
'''
|
||||
Given a ZFS command to run and the name of a snapshot in the form "dataset@snapshot", destroy
|
||||
it.
|
||||
'''
|
||||
borgmatic.execute.execute_command(
|
||||
tuple(zfs_command.split(' '))
|
||||
+ (
|
||||
'destroy',
|
||||
full_snapshot_name,
|
||||
),
|
||||
output_log_level=logging.DEBUG,
|
||||
)
|
||||
|
||||
|
||||
def get_all_snapshots(zfs_command):
|
||||
'''
|
||||
Given a ZFS command to run, return all ZFS snapshots as a sequence of full snapshot names of the
|
||||
form "dataset@snapshot".
|
||||
'''
|
||||
list_output = borgmatic.execute.execute_command_and_capture_output(
|
||||
tuple(zfs_command.split(' '))
|
||||
+ (
|
||||
'list',
|
||||
'-H',
|
||||
'-t',
|
||||
'snapshot',
|
||||
'-o',
|
||||
'name',
|
||||
)
|
||||
)
|
||||
|
||||
return tuple(line.rstrip() for line in list_output.splitlines())
|
||||
|
||||
|
||||
def remove_data_source_dumps(hook_config, config, borgmatic_runtime_directory, dry_run):
|
||||
'''
|
||||
Given a ZFS configuration dict, a configuration dict, the borgmatic runtime directory, and
|
||||
whether this is a dry run, unmount and destroy any ZFS snapshots created by borgmatic. If this
|
||||
is a dry run or ZFS isn't configured in borgmatic's configuration, then don't actually remove
|
||||
anything.
|
||||
'''
|
||||
if hook_config is None:
|
||||
return
|
||||
|
||||
dry_run_label = ' (dry run; not actually removing anything)' if dry_run else ''
|
||||
|
||||
# Unmount snapshots.
|
||||
zfs_command = hook_config.get('zfs_command', 'zfs')
|
||||
|
||||
try:
|
||||
dataset_mount_points = get_all_dataset_mount_points(zfs_command)
|
||||
except FileNotFoundError:
|
||||
logger.debug(f'Could not find "{zfs_command}" command')
|
||||
return
|
||||
except subprocess.CalledProcessError as error:
|
||||
logger.debug(error)
|
||||
return
|
||||
|
||||
snapshots_glob = os.path.join(
|
||||
borgmatic.config.paths.replace_temporary_subdirectory_with_glob(
|
||||
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')
|
||||
|
||||
for snapshots_directory in glob.glob(snapshots_glob):
|
||||
if not os.path.isdir(snapshots_directory):
|
||||
continue
|
||||
|
||||
# Reversing the sorted datasets ensures that we unmount the longer mount point paths of
|
||||
# child datasets before the shorter mount point paths of parent datasets.
|
||||
for mount_point in reversed(dataset_mount_points):
|
||||
snapshot_mount_path = os.path.join(snapshots_directory, mount_point.lstrip(os.path.sep))
|
||||
|
||||
# If 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
|
||||
# do another recursive delete below. The point of doing it here is that we don't want to
|
||||
# try to unmount a non-mounted directory (which *will* fail), and probing for whether a
|
||||
# directory is mounted is tough to do in a cross-platform way.
|
||||
if not dry_run:
|
||||
shutil.rmtree(snapshot_mount_path, ignore_errors=True)
|
||||
|
||||
# If the delete was successful, that means there's nothing to unmount.
|
||||
if not os.path.isdir(snapshot_mount_path):
|
||||
continue
|
||||
|
||||
logger.debug(f'Unmounting ZFS snapshot at {snapshot_mount_path}{dry_run_label}')
|
||||
|
||||
if not dry_run:
|
||||
try:
|
||||
unmount_snapshot(umount_command, snapshot_mount_path)
|
||||
except FileNotFoundError:
|
||||
logger.debug(f'Could not find "{umount_command}" command')
|
||||
return
|
||||
except subprocess.CalledProcessError as error:
|
||||
logger.debug(error)
|
||||
continue
|
||||
|
||||
if not dry_run:
|
||||
shutil.rmtree(snapshots_directory)
|
||||
|
||||
# Destroy snapshots.
|
||||
full_snapshot_names = get_all_snapshots(zfs_command)
|
||||
|
||||
for full_snapshot_name in full_snapshot_names:
|
||||
# Only destroy snapshots that borgmatic actually created!
|
||||
if not full_snapshot_name.split('@')[-1].startswith(BORGMATIC_SNAPSHOT_PREFIX):
|
||||
continue
|
||||
|
||||
logger.debug(f'Destroying ZFS snapshot {full_snapshot_name}{dry_run_label}')
|
||||
|
||||
if not dry_run:
|
||||
destroy_snapshot(zfs_command, full_snapshot_name)
|
||||
|
||||
|
||||
def make_data_source_dump_patterns(
|
||||
hook_config, config, borgmatic_runtime_directory, name=None
|
||||
): # pragma: no cover
|
||||
'''
|
||||
Restores aren't implemented, because stored files can be extracted directly with "extract".
|
||||
'''
|
||||
return ()
|
||||
|
||||
|
||||
def restore_data_source_dump(
|
||||
hook_config,
|
||||
config,
|
||||
data_source,
|
||||
dry_run,
|
||||
extract_process,
|
||||
connection_params,
|
||||
borgmatic_runtime_directory,
|
||||
): # pragma: no cover
|
||||
'''
|
||||
Restores aren't implemented, because stored files can be extracted directly with "extract".
|
||||
'''
|
||||
raise NotImplementedError()
|
||||
+71
-53
@@ -1,65 +1,79 @@
|
||||
import enum
|
||||
import importlib
|
||||
import logging
|
||||
import pkgutil
|
||||
|
||||
from borgmatic.hooks import (
|
||||
apprise,
|
||||
cronhub,
|
||||
cronitor,
|
||||
healthchecks,
|
||||
loki,
|
||||
mariadb,
|
||||
mongodb,
|
||||
mysql,
|
||||
ntfy,
|
||||
pagerduty,
|
||||
postgresql,
|
||||
sqlite,
|
||||
)
|
||||
import borgmatic.hooks.credential
|
||||
import borgmatic.hooks.data_source
|
||||
import borgmatic.hooks.monitoring
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
HOOK_NAME_TO_MODULE = {
|
||||
'apprise': apprise,
|
||||
'cronhub': cronhub,
|
||||
'cronitor': cronitor,
|
||||
'healthchecks': healthchecks,
|
||||
'mariadb_databases': mariadb,
|
||||
'mongodb_databases': mongodb,
|
||||
'mysql_databases': mysql,
|
||||
'ntfy': ntfy,
|
||||
'pagerduty': pagerduty,
|
||||
'postgresql_databases': postgresql,
|
||||
'sqlite_databases': sqlite,
|
||||
'loki': loki,
|
||||
}
|
||||
|
||||
class Hook_type(enum.Enum):
|
||||
CREDENTIAL = 'credential'
|
||||
DATA_SOURCE = 'data_source'
|
||||
MONITORING = 'monitoring'
|
||||
|
||||
|
||||
def call_hook(function_name, config, log_prefix, hook_name, *args, **kwargs):
|
||||
def get_submodule_names(parent_module): # pragma: no cover
|
||||
'''
|
||||
Given a configuration dict and a prefix to use in log entries, call the requested function of
|
||||
the Python module corresponding to the given hook name. Supply that call with the configuration
|
||||
for this hook (if any), the log prefix, and any given args and kwargs. Return any return value.
|
||||
Given a parent module, return the names of its direct submodules as a tuple of strings.
|
||||
'''
|
||||
return tuple(module_info.name for module_info in pkgutil.iter_modules(parent_module.__path__))
|
||||
|
||||
|
||||
def call_hook(function_name, config, hook_name, *args, **kwargs):
|
||||
'''
|
||||
Given a configuration dict, call the requested function of the Python module corresponding to
|
||||
the given hook name. Supply that call with the configuration for this hook (if any) and any
|
||||
given args and kwargs. Return the return value of that call or None if the module in question is
|
||||
not a hook.
|
||||
|
||||
Raise ValueError if the hook name is unknown.
|
||||
Raise AttributeError if the function name is not found in the module.
|
||||
Raise anything else that the called function raises.
|
||||
'''
|
||||
hook_config = config.get(hook_name, {})
|
||||
if hook_name in config or f'{hook_name}_databases' in config:
|
||||
hook_config = config.get(hook_name) or config.get(f'{hook_name}_databases') or {}
|
||||
else:
|
||||
hook_config = None
|
||||
|
||||
try:
|
||||
module = HOOK_NAME_TO_MODULE[hook_name]
|
||||
except KeyError:
|
||||
module_name = hook_name.split('_databases')[0]
|
||||
|
||||
# Probe for a data source or monitoring hook module corresponding to the hook name.
|
||||
for parent_module in (
|
||||
borgmatic.hooks.credential,
|
||||
borgmatic.hooks.data_source,
|
||||
borgmatic.hooks.monitoring,
|
||||
):
|
||||
if module_name not in get_submodule_names(parent_module):
|
||||
continue
|
||||
|
||||
module = importlib.import_module(f'{parent_module.__name__}.{module_name}')
|
||||
|
||||
# If this module is explicitly flagged as not a hook, bail.
|
||||
if not getattr(module, 'IS_A_HOOK', True):
|
||||
return None
|
||||
|
||||
break
|
||||
else:
|
||||
raise ValueError(f'Unknown hook name: {hook_name}')
|
||||
|
||||
logger.debug(f'{log_prefix}: Calling {hook_name} hook function {function_name}')
|
||||
return getattr(module, function_name)(hook_config, config, log_prefix, *args, **kwargs)
|
||||
logger.debug(f'Calling {hook_name} hook function {function_name}')
|
||||
|
||||
return getattr(module, function_name)(hook_config, config, *args, **kwargs)
|
||||
|
||||
|
||||
def call_hooks(function_name, config, log_prefix, hook_names, *args, **kwargs):
|
||||
def call_hooks(function_name, config, hook_type, *args, **kwargs):
|
||||
'''
|
||||
Given a configuration dict and a prefix to use in log entries, call the requested function of
|
||||
the Python module corresponding to each given hook name. Supply each call with the configuration
|
||||
for that hook, the log prefix, and any given args and kwargs. Collect any return values into a
|
||||
dict from hook name to return value.
|
||||
Given a configuration dict, call the requested function of the Python module corresponding to
|
||||
each hook of the given hook type ("credential", "data_source", or "monitoring"). Supply each
|
||||
call with the configuration for that hook, and any given args and kwargs.
|
||||
|
||||
Collect any return values into a dict from module name to return value. Note that the module
|
||||
name is the name of the hook module itself, which might be different from the hook configuration
|
||||
option (e.g. "postgresql" for the former vs. "postgresql_databases" for the latter).
|
||||
|
||||
If the hook name is not present in the hooks configuration, then don't call the function for it
|
||||
and omit it from the return values.
|
||||
@@ -69,23 +83,27 @@ def call_hooks(function_name, config, log_prefix, hook_names, *args, **kwargs):
|
||||
Raise anything else that a called function raises. An error stops calls to subsequent functions.
|
||||
'''
|
||||
return {
|
||||
hook_name: call_hook(function_name, config, log_prefix, hook_name, *args, **kwargs)
|
||||
for hook_name in hook_names
|
||||
if config.get(hook_name)
|
||||
hook_name: call_hook(function_name, config, hook_name, *args, **kwargs)
|
||||
for hook_name in get_submodule_names(
|
||||
importlib.import_module(f'borgmatic.hooks.{hook_type.value}')
|
||||
)
|
||||
if hook_name in config or f'{hook_name}_databases' in config
|
||||
}
|
||||
|
||||
|
||||
def call_hooks_even_if_unconfigured(function_name, config, log_prefix, hook_names, *args, **kwargs):
|
||||
def call_hooks_even_if_unconfigured(function_name, config, hook_type, *args, **kwargs):
|
||||
'''
|
||||
Given a configuration dict and a prefix to use in log entries, call the requested function of
|
||||
the Python module corresponding to each given hook name. Supply each call with the configuration
|
||||
for that hook, the log prefix, and any given args and kwargs. Collect any return values into a
|
||||
dict from hook name to return value.
|
||||
Given a configuration dict, call the requested function of the Python module corresponding to
|
||||
each hook of the given hook type ("credential", "data_source", or "monitoring"). Supply each
|
||||
call with the configuration for that hook and any given args and kwargs. Collect any return
|
||||
values into a dict from hook name to return value.
|
||||
|
||||
Raise AttributeError if the function name is not found in the module.
|
||||
Raise anything else that a called function raises. An error stops calls to subsequent functions.
|
||||
'''
|
||||
return {
|
||||
hook_name: call_hook(function_name, config, log_prefix, hook_name, *args, **kwargs)
|
||||
for hook_name in hook_names
|
||||
hook_name: call_hook(function_name, config, hook_name, *args, **kwargs)
|
||||
for hook_name in get_submodule_names(
|
||||
importlib.import_module(f'borgmatic.hooks.{hook_type.value}')
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from borgmatic.borg.state import DEFAULT_BORGMATIC_SOURCE_DIRECTORY
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DATA_SOURCE_HOOK_NAMES = (
|
||||
'mariadb_databases',
|
||||
'mysql_databases',
|
||||
'mongodb_databases',
|
||||
'postgresql_databases',
|
||||
'sqlite_databases',
|
||||
)
|
||||
|
||||
|
||||
def make_data_source_dump_path(borgmatic_source_directory, data_source_hook_name):
|
||||
'''
|
||||
Given a borgmatic source directory (or None) and a data source hook name, construct a data
|
||||
source dump path.
|
||||
'''
|
||||
if not borgmatic_source_directory:
|
||||
borgmatic_source_directory = DEFAULT_BORGMATIC_SOURCE_DIRECTORY
|
||||
|
||||
return os.path.join(borgmatic_source_directory, data_source_hook_name)
|
||||
|
||||
|
||||
def make_data_source_dump_filename(dump_path, name, hostname=None):
|
||||
'''
|
||||
Based on the given dump directory path, data source name, and hostname, return a filename to use
|
||||
for the data source dump. The hostname defaults to localhost.
|
||||
|
||||
Raise ValueError if the data source name is invalid.
|
||||
'''
|
||||
if os.path.sep in name:
|
||||
raise ValueError(f'Invalid data source name {name}')
|
||||
|
||||
return os.path.join(os.path.expanduser(dump_path), hostname or 'localhost', name)
|
||||
|
||||
|
||||
def create_parent_directory_for_dump(dump_path):
|
||||
'''
|
||||
Create a directory to contain the given dump path.
|
||||
'''
|
||||
os.makedirs(os.path.dirname(dump_path), mode=0o700, exist_ok=True)
|
||||
|
||||
|
||||
def create_named_pipe_for_dump(dump_path):
|
||||
'''
|
||||
Create a named pipe at the given dump path.
|
||||
'''
|
||||
create_parent_directory_for_dump(dump_path)
|
||||
os.mkfifo(dump_path, mode=0o600)
|
||||
|
||||
|
||||
def remove_data_source_dumps(dump_path, data_source_type_name, log_prefix, dry_run):
|
||||
'''
|
||||
Remove all data source dumps in the given dump directory path (including the directory itself).
|
||||
If this is a dry run, then don't actually remove anything.
|
||||
'''
|
||||
dry_run_label = ' (dry run; not actually removing anything)' if dry_run else ''
|
||||
|
||||
logger.debug(f'{log_prefix}: Removing {data_source_type_name} data source dumps{dry_run_label}')
|
||||
|
||||
expanded_path = os.path.expanduser(dump_path)
|
||||
|
||||
if dry_run:
|
||||
return
|
||||
|
||||
if os.path.exists(expanded_path):
|
||||
shutil.rmtree(expanded_path)
|
||||
|
||||
|
||||
def convert_glob_patterns_to_borg_patterns(patterns):
|
||||
'''
|
||||
Convert a sequence of shell glob patterns like "/etc/*" to the corresponding Borg archive
|
||||
patterns like "sh:etc/*".
|
||||
'''
|
||||
return [f'sh:{pattern.lstrip(os.path.sep)}' for pattern in patterns]
|
||||
@@ -1,145 +0,0 @@
|
||||
import logging
|
||||
|
||||
import requests
|
||||
|
||||
from borgmatic.hooks import monitor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MONITOR_STATE_TO_HEALTHCHECKS = {
|
||||
monitor.State.START: 'start',
|
||||
monitor.State.FINISH: None, # Healthchecks doesn't append to the URL for the finished state.
|
||||
monitor.State.FAIL: 'fail',
|
||||
monitor.State.LOG: 'log',
|
||||
}
|
||||
|
||||
PAYLOAD_TRUNCATION_INDICATOR = '...\n'
|
||||
DEFAULT_PING_BODY_LIMIT_BYTES = 100000
|
||||
|
||||
|
||||
class Forgetful_buffering_handler(logging.Handler):
|
||||
'''
|
||||
A buffering log handler that stores log messages in memory, and throws away messages (oldest
|
||||
first) once a particular capacity in bytes is reached. But if the given byte capacity is zero,
|
||||
don't throw away any messages.
|
||||
'''
|
||||
|
||||
def __init__(self, byte_capacity, log_level):
|
||||
super().__init__()
|
||||
|
||||
self.byte_capacity = byte_capacity
|
||||
self.byte_count = 0
|
||||
self.buffer = []
|
||||
self.forgot = False
|
||||
self.setLevel(log_level)
|
||||
|
||||
def emit(self, record):
|
||||
message = record.getMessage() + '\n'
|
||||
self.byte_count += len(message)
|
||||
self.buffer.append(message)
|
||||
|
||||
if not self.byte_capacity:
|
||||
return
|
||||
|
||||
while self.byte_count > self.byte_capacity and self.buffer:
|
||||
self.byte_count -= len(self.buffer[0])
|
||||
self.buffer.pop(0)
|
||||
self.forgot = True
|
||||
|
||||
|
||||
def format_buffered_logs_for_payload():
|
||||
'''
|
||||
Get the handler previously added to the root logger, and slurp buffered logs out of it to
|
||||
send to Healthchecks.
|
||||
'''
|
||||
try:
|
||||
buffering_handler = next(
|
||||
handler
|
||||
for handler in logging.getLogger().handlers
|
||||
if isinstance(handler, Forgetful_buffering_handler)
|
||||
)
|
||||
except StopIteration:
|
||||
# No handler means no payload.
|
||||
return ''
|
||||
|
||||
payload = ''.join(message for message in buffering_handler.buffer)
|
||||
|
||||
if buffering_handler.forgot:
|
||||
return PAYLOAD_TRUNCATION_INDICATOR + payload
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
def initialize_monitor(hook_config, config, config_filename, monitoring_log_level, dry_run):
|
||||
'''
|
||||
Add a handler to the root logger that stores in memory the most recent logs emitted. That way,
|
||||
we can send them all to Healthchecks upon a finish or failure state. But skip this if the
|
||||
"send_logs" option is false.
|
||||
'''
|
||||
if hook_config.get('send_logs') is False:
|
||||
return
|
||||
|
||||
ping_body_limit = max(
|
||||
hook_config.get('ping_body_limit', DEFAULT_PING_BODY_LIMIT_BYTES)
|
||||
- len(PAYLOAD_TRUNCATION_INDICATOR),
|
||||
0,
|
||||
)
|
||||
|
||||
logging.getLogger().addHandler(
|
||||
Forgetful_buffering_handler(ping_body_limit, monitoring_log_level)
|
||||
)
|
||||
|
||||
|
||||
def ping_monitor(hook_config, config, config_filename, state, monitoring_log_level, dry_run):
|
||||
'''
|
||||
Ping the configured Healthchecks URL or UUID, modified with the monitor.State. Use the given
|
||||
configuration filename in any log entries, and log to Healthchecks with the giving log level.
|
||||
If this is a dry run, then don't actually ping anything.
|
||||
'''
|
||||
ping_url = (
|
||||
hook_config['ping_url']
|
||||
if hook_config['ping_url'].startswith('http')
|
||||
else f"https://hc-ping.com/{hook_config['ping_url']}"
|
||||
)
|
||||
dry_run_label = ' (dry run; not actually pinging)' if dry_run else ''
|
||||
|
||||
if 'states' in hook_config and state.name.lower() not in hook_config['states']:
|
||||
logger.info(
|
||||
f'{config_filename}: Skipping Healthchecks {state.name.lower()} ping due to configured states'
|
||||
)
|
||||
return
|
||||
|
||||
healthchecks_state = MONITOR_STATE_TO_HEALTHCHECKS.get(state)
|
||||
if healthchecks_state:
|
||||
ping_url = f'{ping_url}/{healthchecks_state}'
|
||||
|
||||
logger.info(f'{config_filename}: Pinging Healthchecks {state.name.lower()}{dry_run_label}')
|
||||
logger.debug(f'{config_filename}: Using Healthchecks ping URL {ping_url}')
|
||||
|
||||
if state in (monitor.State.FINISH, monitor.State.FAIL, monitor.State.LOG):
|
||||
payload = format_buffered_logs_for_payload()
|
||||
else:
|
||||
payload = ''
|
||||
|
||||
if not dry_run:
|
||||
logging.getLogger('urllib3').setLevel(logging.ERROR)
|
||||
try:
|
||||
response = requests.post(
|
||||
ping_url, data=payload.encode('utf-8'), verify=hook_config.get('verify_tls', True)
|
||||
)
|
||||
if not response.ok:
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.RequestException as error:
|
||||
logger.warning(f'{config_filename}: Healthchecks error: {error}')
|
||||
|
||||
|
||||
def destroy_monitor(hook_config, config, config_filename, monitoring_log_level, dry_run):
|
||||
'''
|
||||
Remove the monitor handler that was added to the root logger. This prevents the handler from
|
||||
getting reused by other instances of this monitor.
|
||||
'''
|
||||
logger = logging.getLogger()
|
||||
|
||||
for handler in tuple(logger.handlers):
|
||||
if isinstance(handler, Forgetful_buffering_handler):
|
||||
logger.removeHandler(handler)
|
||||
@@ -1,237 +0,0 @@
|
||||
import copy
|
||||
import logging
|
||||
import os
|
||||
|
||||
from borgmatic.execute import (
|
||||
execute_command,
|
||||
execute_command_and_capture_output,
|
||||
execute_command_with_processes,
|
||||
)
|
||||
from borgmatic.hooks import dump
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def make_dump_path(config): # pragma: no cover
|
||||
'''
|
||||
Make the dump path from the given configuration dict and the name of this hook.
|
||||
'''
|
||||
return dump.make_data_source_dump_path(
|
||||
config.get('borgmatic_source_directory'), 'mariadb_databases'
|
||||
)
|
||||
|
||||
|
||||
SYSTEM_DATABASE_NAMES = ('information_schema', 'mysql', 'performance_schema', 'sys')
|
||||
|
||||
|
||||
def database_names_to_dump(database, extra_environment, log_prefix, 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.
|
||||
'''
|
||||
if database['name'] != 'all':
|
||||
return (database['name'],)
|
||||
if dry_run:
|
||||
return ()
|
||||
|
||||
show_command = (
|
||||
('mariadb',)
|
||||
+ (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', database['username']) if 'username' in database else ())
|
||||
+ ('--skip-column-names', '--batch')
|
||||
+ ('--execute', 'show schemas')
|
||||
)
|
||||
logger.debug(f'{log_prefix}: Querying for "all" MariaDB databases to dump')
|
||||
show_output = execute_command_and_capture_output(
|
||||
show_command, extra_environment=extra_environment
|
||||
)
|
||||
|
||||
return tuple(
|
||||
show_name
|
||||
for show_name in show_output.strip().splitlines()
|
||||
if show_name not in SYSTEM_DATABASE_NAMES
|
||||
)
|
||||
|
||||
|
||||
def execute_dump_command(
|
||||
database, log_prefix, dump_path, database_names, extra_environment, dry_run, dry_run_label
|
||||
):
|
||||
'''
|
||||
Kick off a dump for the given MariaDB database (provided as a configuration dict) to a named
|
||||
pipe constructed from the given dump path and database name. Use the given log prefix in any
|
||||
log entries.
|
||||
|
||||
Return a subprocess.Popen instance for the dump process ready to spew to a named pipe. But if
|
||||
this is a dry run, then don't actually dump anything and return None.
|
||||
'''
|
||||
database_name = database['name']
|
||||
dump_filename = dump.make_data_source_dump_filename(
|
||||
dump_path, database['name'], database.get('hostname')
|
||||
)
|
||||
|
||||
if os.path.exists(dump_filename):
|
||||
logger.warning(
|
||||
f'{log_prefix}: Skipping duplicate dump of MariaDB database "{database_name}" to {dump_filename}'
|
||||
)
|
||||
return None
|
||||
|
||||
dump_command = (
|
||||
('mariadb-dump',)
|
||||
+ (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', database['username']) if 'username' in database else ())
|
||||
+ ('--databases',)
|
||||
+ database_names
|
||||
+ ('--result-file', dump_filename)
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f'{log_prefix}: Dumping MariaDB database "{database_name}" to {dump_filename}{dry_run_label}'
|
||||
)
|
||||
if dry_run:
|
||||
return None
|
||||
|
||||
dump.create_named_pipe_for_dump(dump_filename)
|
||||
|
||||
return execute_command(
|
||||
dump_command,
|
||||
extra_environment=extra_environment,
|
||||
run_to_completion=False,
|
||||
)
|
||||
|
||||
|
||||
def dump_data_sources(databases, config, log_prefix, dry_run):
|
||||
'''
|
||||
Dump the given MariaDB databases to a named pipe. The databases are supplied as a sequence of
|
||||
dicts, one dict describing each database as per the configuration schema. Use the given
|
||||
configuration dict to construct the destination path and the given log prefix in any log
|
||||
entries.
|
||||
|
||||
Return a sequence of subprocess.Popen instances for the dump processes ready to spew to a named
|
||||
pipe. But if this is a dry run, then don't actually dump anything and return an empty sequence.
|
||||
'''
|
||||
dry_run_label = ' (dry run; not actually dumping anything)' if dry_run else ''
|
||||
processes = []
|
||||
|
||||
logger.info(f'{log_prefix}: Dumping MariaDB databases{dry_run_label}')
|
||||
|
||||
for database in databases:
|
||||
dump_path = make_dump_path(config)
|
||||
extra_environment = {'MYSQL_PWD': database['password']} if 'password' in database else None
|
||||
dump_database_names = database_names_to_dump(
|
||||
database, extra_environment, log_prefix, dry_run
|
||||
)
|
||||
|
||||
if not dump_database_names:
|
||||
if dry_run:
|
||||
continue
|
||||
|
||||
raise ValueError('Cannot find any MariaDB databases to dump.')
|
||||
|
||||
if database['name'] == 'all' and database.get('format'):
|
||||
for dump_name in dump_database_names:
|
||||
renamed_database = copy.copy(database)
|
||||
renamed_database['name'] = dump_name
|
||||
processes.append(
|
||||
execute_dump_command(
|
||||
renamed_database,
|
||||
log_prefix,
|
||||
dump_path,
|
||||
(dump_name,),
|
||||
extra_environment,
|
||||
dry_run,
|
||||
dry_run_label,
|
||||
)
|
||||
)
|
||||
else:
|
||||
processes.append(
|
||||
execute_dump_command(
|
||||
database,
|
||||
log_prefix,
|
||||
dump_path,
|
||||
dump_database_names,
|
||||
extra_environment,
|
||||
dry_run,
|
||||
dry_run_label,
|
||||
)
|
||||
)
|
||||
|
||||
return [process for process in processes if process]
|
||||
|
||||
|
||||
def remove_data_source_dumps(databases, config, log_prefix, dry_run): # pragma: no cover
|
||||
'''
|
||||
Remove all database dump files for this hook regardless of the given databases. Use the given
|
||||
configuration dict to construct the destination path and the log prefix in any log entries. If
|
||||
this is a dry run, then don't actually remove anything.
|
||||
'''
|
||||
dump.remove_data_source_dumps(make_dump_path(config), 'MariaDB', log_prefix, dry_run)
|
||||
|
||||
|
||||
def make_data_source_dump_pattern(databases, config, log_prefix, name=None): # pragma: no cover
|
||||
'''
|
||||
Given a sequence of configurations dicts, a configuration dict, a prefix to log with, and a
|
||||
database name to match, return the corresponding glob patterns to match the database dump in an
|
||||
archive.
|
||||
'''
|
||||
return dump.make_data_source_dump_filename(make_dump_path(config), name, hostname='*')
|
||||
|
||||
|
||||
def restore_data_source_dump(
|
||||
hook_config, config, log_prefix, data_source, dry_run, extract_process, connection_params
|
||||
):
|
||||
'''
|
||||
Restore a database from the given extract stream. The database is supplied as a data source
|
||||
configuration dict, but the given hook configuration is ignored. The given configuration dict is
|
||||
used to construct the destination path, and the given log prefix is used for any log entries. If
|
||||
this is a dry run, then don't actually restore anything. Trigger the given active extract
|
||||
process (an instance of subprocess.Popen) to produce output to consume.
|
||||
'''
|
||||
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')
|
||||
)
|
||||
port = str(
|
||||
connection_params['port'] or data_source.get('restore_port', data_source.get('port', ''))
|
||||
)
|
||||
username = connection_params['username'] or data_source.get(
|
||||
'restore_username', data_source.get('username')
|
||||
)
|
||||
password = connection_params['password'] or data_source.get(
|
||||
'restore_password', data_source.get('password')
|
||||
)
|
||||
|
||||
restore_command = (
|
||||
('mariadb', '--batch')
|
||||
+ (
|
||||
tuple(data_source['restore_options'].split(' '))
|
||||
if 'restore_options' in data_source
|
||||
else ()
|
||||
)
|
||||
+ (('--host', hostname) if hostname else ())
|
||||
+ (('--port', str(port)) if port else ())
|
||||
+ (('--protocol', 'tcp') if hostname or port else ())
|
||||
+ (('--user', username) if username else ())
|
||||
)
|
||||
extra_environment = {'MYSQL_PWD': password} if password else None
|
||||
|
||||
logger.debug(f"{log_prefix}: Restoring MariaDB database {data_source['name']}{dry_run_label}")
|
||||
if dry_run:
|
||||
return
|
||||
|
||||
# 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(
|
||||
restore_command,
|
||||
[extract_process],
|
||||
output_log_level=logging.DEBUG,
|
||||
input_file=extract_process.stdout,
|
||||
extra_environment=extra_environment,
|
||||
)
|
||||
@@ -1,177 +0,0 @@
|
||||
import logging
|
||||
import shlex
|
||||
|
||||
from borgmatic.execute import execute_command, execute_command_with_processes
|
||||
from borgmatic.hooks import dump
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def make_dump_path(config): # pragma: no cover
|
||||
'''
|
||||
Make the dump path from the given configuration dict and the name of this hook.
|
||||
'''
|
||||
return dump.make_data_source_dump_path(
|
||||
config.get('borgmatic_source_directory'), 'mongodb_databases'
|
||||
)
|
||||
|
||||
|
||||
def dump_data_sources(databases, config, log_prefix, dry_run):
|
||||
'''
|
||||
Dump the given MongoDB databases to a named pipe. The databases are supplied as a sequence of
|
||||
dicts, one dict describing each database as per the configuration schema. Use the configuration
|
||||
dict to construct the destination path and the given log prefix in any log entries.
|
||||
|
||||
Return a sequence of subprocess.Popen instances for the dump processes ready to spew to a named
|
||||
pipe. But if this is a dry run, then don't actually dump anything and return an empty sequence.
|
||||
'''
|
||||
dry_run_label = ' (dry run; not actually dumping anything)' if dry_run else ''
|
||||
|
||||
logger.info(f'{log_prefix}: Dumping MongoDB databases{dry_run_label}')
|
||||
|
||||
processes = []
|
||||
for database in databases:
|
||||
name = database['name']
|
||||
dump_filename = dump.make_data_source_dump_filename(
|
||||
make_dump_path(config), name, database.get('hostname')
|
||||
)
|
||||
dump_format = database.get('format', 'archive')
|
||||
|
||||
logger.debug(
|
||||
f'{log_prefix}: Dumping MongoDB database {name} to {dump_filename}{dry_run_label}',
|
||||
)
|
||||
if dry_run:
|
||||
continue
|
||||
|
||||
command = build_dump_command(database, dump_filename, dump_format)
|
||||
|
||||
if dump_format == 'directory':
|
||||
dump.create_parent_directory_for_dump(dump_filename)
|
||||
execute_command(command, shell=True)
|
||||
else:
|
||||
dump.create_named_pipe_for_dump(dump_filename)
|
||||
processes.append(execute_command(command, shell=True, run_to_completion=False))
|
||||
|
||||
return processes
|
||||
|
||||
|
||||
def build_dump_command(database, dump_filename, dump_format):
|
||||
'''
|
||||
Return the mongodump command from a single database configuration.
|
||||
'''
|
||||
all_databases = database['name'] == 'all'
|
||||
|
||||
return (
|
||||
('mongodump',)
|
||||
+ (('--out', shlex.quote(dump_filename)) if dump_format == 'directory' else ())
|
||||
+ (('--host', shlex.quote(database['hostname'])) if 'hostname' in database else ())
|
||||
+ (('--port', shlex.quote(str(database['port']))) if 'port' in database else ())
|
||||
+ (('--username', shlex.quote(database['username'])) if 'username' in database else ())
|
||||
+ (('--password', shlex.quote(database['password'])) if 'password' in database else ())
|
||||
+ (
|
||||
('--authenticationDatabase', shlex.quote(database['authentication_database']))
|
||||
if 'authentication_database' in database
|
||||
else ()
|
||||
)
|
||||
+ (('--db', shlex.quote(database['name'])) if not all_databases else ())
|
||||
+ (
|
||||
tuple(shlex.quote(option) for option in database['options'].split(' '))
|
||||
if 'options' in database
|
||||
else ()
|
||||
)
|
||||
+ (('--archive', '>', shlex.quote(dump_filename)) if dump_format != 'directory' else ())
|
||||
)
|
||||
|
||||
|
||||
def remove_data_source_dumps(databases, config, log_prefix, dry_run): # pragma: no cover
|
||||
'''
|
||||
Remove all database dump files for this hook regardless of the given databases. Use the log
|
||||
prefix in any log entries. Use the given configuration dict to construct the destination path.
|
||||
If this is a dry run, then don't actually remove anything.
|
||||
'''
|
||||
dump.remove_data_source_dumps(make_dump_path(config), 'MongoDB', log_prefix, dry_run)
|
||||
|
||||
|
||||
def make_data_source_dump_pattern(databases, config, log_prefix, name=None): # pragma: no cover
|
||||
'''
|
||||
Given a sequence of database configurations dicts, a configuration dict, a prefix to log with,
|
||||
and a database name to match, return the corresponding glob patterns to match the database dump
|
||||
in an archive.
|
||||
'''
|
||||
return dump.make_data_source_dump_filename(make_dump_path(config), name, hostname='*')
|
||||
|
||||
|
||||
def restore_data_source_dump(
|
||||
hook_config, config, log_prefix, data_source, dry_run, extract_process, connection_params
|
||||
):
|
||||
'''
|
||||
Restore a database from the given extract stream. The database is supplied as a data source
|
||||
configuration dict, but the given hook configuration is ignored. The given configuration dict is
|
||||
used to construct the destination path, and the given log prefix is used for any log entries. If
|
||||
this is a dry run, then don't actually restore anything. Trigger the given active extract
|
||||
process (an instance of subprocess.Popen) to produce output to consume.
|
||||
|
||||
If the extract process is None, then restore the dump from the filesystem rather than from an
|
||||
extract stream.
|
||||
'''
|
||||
dry_run_label = ' (dry run; not actually restoring anything)' if dry_run else ''
|
||||
dump_filename = dump.make_data_source_dump_filename(
|
||||
make_dump_path(config), data_source['name'], data_source.get('hostname')
|
||||
)
|
||||
restore_command = build_restore_command(
|
||||
extract_process, data_source, dump_filename, connection_params
|
||||
)
|
||||
|
||||
logger.debug(f"{log_prefix}: Restoring MongoDB database {data_source['name']}{dry_run_label}")
|
||||
if dry_run:
|
||||
return
|
||||
|
||||
# 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(
|
||||
restore_command,
|
||||
[extract_process] if extract_process else [],
|
||||
output_log_level=logging.DEBUG,
|
||||
input_file=extract_process.stdout if extract_process else None,
|
||||
)
|
||||
|
||||
|
||||
def build_restore_command(extract_process, database, dump_filename, connection_params):
|
||||
'''
|
||||
Return the mongorestore command from a single database configuration.
|
||||
'''
|
||||
hostname = connection_params['hostname'] or database.get(
|
||||
'restore_hostname', database.get('hostname')
|
||||
)
|
||||
port = str(connection_params['port'] or database.get('restore_port', database.get('port', '')))
|
||||
username = connection_params['username'] or database.get(
|
||||
'restore_username', database.get('username')
|
||||
)
|
||||
password = connection_params['password'] or database.get(
|
||||
'restore_password', database.get('password')
|
||||
)
|
||||
|
||||
command = ['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))
|
||||
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))
|
||||
|
||||
return command
|
||||
@@ -1,10 +0,0 @@
|
||||
from enum import Enum
|
||||
|
||||
MONITOR_HOOK_NAMES = ('apprise', 'healthchecks', 'cronitor', 'cronhub', 'pagerduty', 'ntfy', 'loki')
|
||||
|
||||
|
||||
class State(Enum):
|
||||
START = 1
|
||||
FINISH = 2
|
||||
FAIL = 3
|
||||
LOG = 4
|
||||
@@ -0,0 +1,111 @@
|
||||
import logging
|
||||
import operator
|
||||
|
||||
import borgmatic.hooks.monitoring.logs
|
||||
import borgmatic.hooks.monitoring.monitor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
DEFAULT_LOGS_SIZE_LIMIT_BYTES = 100000
|
||||
HANDLER_IDENTIFIER = 'apprise'
|
||||
|
||||
|
||||
def initialize_monitor(hook_config, config, config_filename, monitoring_log_level, dry_run):
|
||||
'''
|
||||
Add a handler to the root logger that stores in memory the most recent logs emitted. That way,
|
||||
we can send them all to an Apprise notification service upon a finish or failure state. But skip
|
||||
this if the "send_logs" option is false.
|
||||
'''
|
||||
if hook_config.get('send_logs') is False:
|
||||
return
|
||||
|
||||
logs_size_limit = max(
|
||||
hook_config.get('logs_size_limit', DEFAULT_LOGS_SIZE_LIMIT_BYTES)
|
||||
- len(borgmatic.hooks.monitoring.logs.PAYLOAD_TRUNCATION_INDICATOR),
|
||||
0,
|
||||
)
|
||||
|
||||
borgmatic.hooks.monitoring.logs.add_handler(
|
||||
borgmatic.hooks.monitoring.logs.Forgetful_buffering_handler(
|
||||
HANDLER_IDENTIFIER, logs_size_limit, monitoring_log_level
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def ping_monitor(hook_config, config, config_filename, state, monitoring_log_level, dry_run):
|
||||
'''
|
||||
Ping the configured Apprise service URLs. Use the given configuration filename in any log
|
||||
entries. If this is a dry run, then don't actually ping anything.
|
||||
'''
|
||||
try:
|
||||
import apprise
|
||||
from apprise import NotifyFormat, NotifyType
|
||||
except ImportError: # pragma: no cover
|
||||
logger.warning('Unable to import Apprise in monitoring hook')
|
||||
return
|
||||
|
||||
state_to_notify_type = {
|
||||
'start': NotifyType.INFO,
|
||||
'finish': NotifyType.SUCCESS,
|
||||
'fail': NotifyType.FAILURE,
|
||||
'log': NotifyType.INFO,
|
||||
}
|
||||
|
||||
run_states = hook_config.get('states', ['fail'])
|
||||
|
||||
if state.name.lower() not in run_states:
|
||||
return
|
||||
|
||||
state_config = hook_config.get(
|
||||
state.name.lower(),
|
||||
{
|
||||
'title': f'A borgmatic {state.name} event happened',
|
||||
'body': f'A borgmatic {state.name} event happened',
|
||||
},
|
||||
)
|
||||
|
||||
if not hook_config.get('services'):
|
||||
logger.info('No Apprise services to ping')
|
||||
return
|
||||
|
||||
dry_run_string = ' (dry run; not actually pinging)' if dry_run else ''
|
||||
labels_string = ', '.join(map(operator.itemgetter('label'), hook_config.get('services')))
|
||||
logger.info(f'Pinging Apprise services: {labels_string}{dry_run_string}')
|
||||
|
||||
apprise_object = apprise.Apprise()
|
||||
apprise_object.add(list(map(operator.itemgetter('url'), hook_config.get('services'))))
|
||||
|
||||
if dry_run:
|
||||
return
|
||||
|
||||
body = state_config.get('body')
|
||||
|
||||
if state in (
|
||||
borgmatic.hooks.monitoring.monitor.State.FINISH,
|
||||
borgmatic.hooks.monitoring.monitor.State.FAIL,
|
||||
borgmatic.hooks.monitoring.monitor.State.LOG,
|
||||
):
|
||||
formatted_logs = borgmatic.hooks.monitoring.logs.format_buffered_logs_for_payload(
|
||||
HANDLER_IDENTIFIER
|
||||
)
|
||||
if formatted_logs:
|
||||
body += f'\n\n{formatted_logs}'
|
||||
|
||||
result = apprise_object.notify(
|
||||
title=state_config.get('title', ''),
|
||||
body=body,
|
||||
body_format=NotifyFormat.TEXT,
|
||||
notify_type=state_to_notify_type[state.name.lower()],
|
||||
)
|
||||
|
||||
if result is False:
|
||||
logger.warning('Error sending some Apprise notifications')
|
||||
|
||||
|
||||
def destroy_monitor(hook_config, config, monitoring_log_level, dry_run):
|
||||
'''
|
||||
Remove the monitor handler that was added to the root logger. This prevents the handler from
|
||||
getting reused by other instances of this monitor.
|
||||
'''
|
||||
borgmatic.hooks.monitoring.logs.remove_handler(HANDLER_IDENTIFIER)
|
||||
@@ -2,7 +2,7 @@ import logging
|
||||
|
||||
import requests
|
||||
|
||||
from borgmatic.hooks import monitor
|
||||
from borgmatic.hooks.monitoring import monitor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -28,9 +28,7 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
|
||||
filename in any log entries. If this is a dry run, then don't actually ping anything.
|
||||
'''
|
||||
if state not in MONITOR_STATE_TO_CRONHUB:
|
||||
logger.debug(
|
||||
f'{config_filename}: Ignoring unsupported monitoring {state.name.lower()} in Cronhub hook'
|
||||
)
|
||||
logger.debug(f'Ignoring unsupported monitoring {state.name.lower()} in Cronhub hook')
|
||||
return
|
||||
|
||||
dry_run_label = ' (dry run; not actually pinging)' if dry_run else ''
|
||||
@@ -41,8 +39,8 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
|
||||
.replace('/ping/', formatted_state)
|
||||
)
|
||||
|
||||
logger.info(f'{config_filename}: Pinging Cronhub {state.name.lower()}{dry_run_label}')
|
||||
logger.debug(f'{config_filename}: Using Cronhub ping URL {ping_url}')
|
||||
logger.info(f'Pinging Cronhub {state.name.lower()}{dry_run_label}')
|
||||
logger.debug(f'Using Cronhub ping URL {ping_url}')
|
||||
|
||||
if not dry_run:
|
||||
logging.getLogger('urllib3').setLevel(logging.ERROR)
|
||||
@@ -51,12 +49,10 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
|
||||
if not response.ok:
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.RequestException as error:
|
||||
logger.warning(f'{config_filename}: Cronhub error: {error}')
|
||||
logger.warning(f'Cronhub error: {error}')
|
||||
|
||||
|
||||
def destroy_monitor(
|
||||
ping_url_or_uuid, config, config_filename, monitoring_log_level, dry_run
|
||||
): # pragma: no cover
|
||||
def destroy_monitor(ping_url_or_uuid, config, monitoring_log_level, dry_run): # pragma: no cover
|
||||
'''
|
||||
No destruction is necessary for this monitor.
|
||||
'''
|
||||
@@ -2,7 +2,7 @@ import logging
|
||||
|
||||
import requests
|
||||
|
||||
from borgmatic.hooks import monitor
|
||||
from borgmatic.hooks.monitoring import monitor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -28,16 +28,14 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
|
||||
filename in any log entries. If this is a dry run, then don't actually ping anything.
|
||||
'''
|
||||
if state not in MONITOR_STATE_TO_CRONITOR:
|
||||
logger.debug(
|
||||
f'{config_filename}: Ignoring unsupported monitoring {state.name.lower()} in Cronitor hook'
|
||||
)
|
||||
logger.debug(f'Ignoring unsupported monitoring {state.name.lower()} in Cronitor hook')
|
||||
return
|
||||
|
||||
dry_run_label = ' (dry run; not actually pinging)' if dry_run else ''
|
||||
ping_url = f"{hook_config['ping_url']}/{MONITOR_STATE_TO_CRONITOR[state]}"
|
||||
|
||||
logger.info(f'{config_filename}: Pinging Cronitor {state.name.lower()}{dry_run_label}')
|
||||
logger.debug(f'{config_filename}: Using Cronitor ping URL {ping_url}')
|
||||
logger.info(f'Pinging Cronitor {state.name.lower()}{dry_run_label}')
|
||||
logger.debug(f'Using Cronitor ping URL {ping_url}')
|
||||
|
||||
if not dry_run:
|
||||
logging.getLogger('urllib3').setLevel(logging.ERROR)
|
||||
@@ -46,12 +44,10 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
|
||||
if not response.ok:
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.RequestException as error:
|
||||
logger.warning(f'{config_filename}: Cronitor error: {error}')
|
||||
logger.warning(f'Cronitor error: {error}')
|
||||
|
||||
|
||||
def destroy_monitor(
|
||||
ping_url_or_uuid, config, config_filename, monitoring_log_level, dry_run
|
||||
): # pragma: no cover
|
||||
def destroy_monitor(ping_url_or_uuid, config, monitoring_log_level, dry_run): # pragma: no cover
|
||||
'''
|
||||
No destruction is necessary for this monitor.
|
||||
'''
|
||||
@@ -0,0 +1,100 @@
|
||||
import logging
|
||||
import re
|
||||
|
||||
import requests
|
||||
|
||||
import borgmatic.hooks.monitoring.logs
|
||||
from borgmatic.hooks.monitoring import monitor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MONITOR_STATE_TO_HEALTHCHECKS = {
|
||||
monitor.State.START: 'start',
|
||||
monitor.State.FINISH: None, # Healthchecks doesn't append to the URL for the finished state.
|
||||
monitor.State.FAIL: 'fail',
|
||||
monitor.State.LOG: 'log',
|
||||
}
|
||||
|
||||
DEFAULT_PING_BODY_LIMIT_BYTES = 100000
|
||||
HANDLER_IDENTIFIER = 'healthchecks'
|
||||
|
||||
|
||||
def initialize_monitor(hook_config, config, config_filename, monitoring_log_level, dry_run):
|
||||
'''
|
||||
Add a handler to the root logger that stores in memory the most recent logs emitted. That way,
|
||||
we can send them all to Healthchecks upon a finish or failure state. But skip this if the
|
||||
"send_logs" option is false.
|
||||
'''
|
||||
if hook_config.get('send_logs') is False:
|
||||
return
|
||||
|
||||
ping_body_limit = max(
|
||||
hook_config.get('ping_body_limit', DEFAULT_PING_BODY_LIMIT_BYTES)
|
||||
- len(borgmatic.hooks.monitoring.logs.PAYLOAD_TRUNCATION_INDICATOR),
|
||||
0,
|
||||
)
|
||||
|
||||
borgmatic.hooks.monitoring.logs.add_handler(
|
||||
borgmatic.hooks.monitoring.logs.Forgetful_buffering_handler(
|
||||
HANDLER_IDENTIFIER, ping_body_limit, monitoring_log_level
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def ping_monitor(hook_config, config, config_filename, state, monitoring_log_level, dry_run):
|
||||
'''
|
||||
Ping the configured Healthchecks URL or UUID, modified with the monitor.State. Use the given
|
||||
configuration filename in any log entries, and log to Healthchecks with the giving log level.
|
||||
If this is a dry run, then don't actually ping anything.
|
||||
'''
|
||||
ping_url = (
|
||||
hook_config['ping_url']
|
||||
if hook_config['ping_url'].startswith('http')
|
||||
else f"https://hc-ping.com/{hook_config['ping_url']}"
|
||||
)
|
||||
dry_run_label = ' (dry run; not actually pinging)' if dry_run else ''
|
||||
|
||||
if 'states' in hook_config and state.name.lower() not in hook_config['states']:
|
||||
logger.info(f'Skipping Healthchecks {state.name.lower()} ping due to configured states')
|
||||
return
|
||||
|
||||
ping_url_is_uuid = re.search(r'\w{8}-\w{4}-\w{4}-\w{4}-\w{12}$', ping_url)
|
||||
|
||||
healthchecks_state = MONITOR_STATE_TO_HEALTHCHECKS.get(state)
|
||||
if healthchecks_state:
|
||||
ping_url = f'{ping_url}/{healthchecks_state}'
|
||||
|
||||
if hook_config.get('create_slug'):
|
||||
if ping_url_is_uuid:
|
||||
logger.warning('Healthchecks UUIDs do not support auto provisionning; ignoring')
|
||||
else:
|
||||
ping_url = f'{ping_url}?create=1'
|
||||
|
||||
logger.info(f'Pinging Healthchecks {state.name.lower()}{dry_run_label}')
|
||||
logger.debug(f'Using Healthchecks ping URL {ping_url}')
|
||||
|
||||
if state in (monitor.State.FINISH, monitor.State.FAIL, monitor.State.LOG):
|
||||
payload = borgmatic.hooks.monitoring.logs.format_buffered_logs_for_payload(
|
||||
HANDLER_IDENTIFIER
|
||||
)
|
||||
else:
|
||||
payload = ''
|
||||
|
||||
if not dry_run:
|
||||
logging.getLogger('urllib3').setLevel(logging.ERROR)
|
||||
try:
|
||||
response = requests.post(
|
||||
ping_url, data=payload.encode('utf-8'), verify=hook_config.get('verify_tls', True)
|
||||
)
|
||||
if not response.ok:
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.RequestException as error:
|
||||
logger.warning(f'Healthchecks error: {error}')
|
||||
|
||||
|
||||
def destroy_monitor(hook_config, config, monitoring_log_level, dry_run):
|
||||
'''
|
||||
Remove the monitor handler that was added to the root logger. This prevents the handler from
|
||||
getting reused by other instances of this monitor.
|
||||
'''
|
||||
borgmatic.hooks.monitoring.logs.remove_handler(HANDLER_IDENTIFIER)
|
||||
@@ -0,0 +1,92 @@
|
||||
import logging
|
||||
|
||||
IS_A_HOOK = False
|
||||
PAYLOAD_TRUNCATION_INDICATOR = '...\n'
|
||||
|
||||
|
||||
class Forgetful_buffering_handler(logging.Handler):
|
||||
'''
|
||||
A buffering log handler that stores log messages in memory, and throws away messages (oldest
|
||||
first) once a particular capacity in bytes is reached. But if the given byte capacity is zero,
|
||||
don't throw away any messages.
|
||||
|
||||
The given identifier is used to distinguish the instance of this handler used for one monitoring
|
||||
hook from those instances used for other monitoring hooks.
|
||||
'''
|
||||
|
||||
def __init__(self, identifier, byte_capacity, log_level):
|
||||
super().__init__()
|
||||
|
||||
self.identifier = identifier
|
||||
self.byte_capacity = byte_capacity
|
||||
self.byte_count = 0
|
||||
self.buffer = []
|
||||
self.forgot = False
|
||||
self.setLevel(log_level)
|
||||
|
||||
def emit(self, record):
|
||||
message = record.getMessage() + '\n'
|
||||
self.byte_count += len(message)
|
||||
self.buffer.append(message)
|
||||
|
||||
if not self.byte_capacity:
|
||||
return
|
||||
|
||||
while self.byte_count > self.byte_capacity and self.buffer:
|
||||
self.byte_count -= len(self.buffer[0])
|
||||
self.buffer.pop(0)
|
||||
self.forgot = True
|
||||
|
||||
|
||||
def add_handler(handler): # pragma: no cover
|
||||
'''
|
||||
Add the given handler to the global logger.
|
||||
'''
|
||||
logging.getLogger().addHandler(handler)
|
||||
|
||||
|
||||
def get_handler(identifier):
|
||||
'''
|
||||
Given the identifier for an existing Forgetful_buffering_handler instance, return the handler.
|
||||
|
||||
Raise ValueError if the handler isn't found.
|
||||
'''
|
||||
try:
|
||||
return next(
|
||||
handler
|
||||
for handler in logging.getLogger().handlers
|
||||
if isinstance(handler, Forgetful_buffering_handler) and handler.identifier == identifier
|
||||
)
|
||||
except StopIteration:
|
||||
raise ValueError(f'A buffering handler for {identifier} was not found')
|
||||
|
||||
|
||||
def format_buffered_logs_for_payload(identifier):
|
||||
'''
|
||||
Get the handler previously added to the root logger, and slurp buffered logs out of it to
|
||||
send to Healthchecks.
|
||||
'''
|
||||
try:
|
||||
buffering_handler = get_handler(identifier)
|
||||
except ValueError:
|
||||
# No handler means no payload.
|
||||
return ''
|
||||
|
||||
payload = ''.join(message for message in buffering_handler.buffer)
|
||||
|
||||
if buffering_handler.forgot:
|
||||
return PAYLOAD_TRUNCATION_INDICATOR + payload
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
def remove_handler(identifier):
|
||||
'''
|
||||
Given the identifier for an existing Forgetful_buffering_handler instance, remove it.
|
||||
'''
|
||||
logger = logging.getLogger()
|
||||
|
||||
try:
|
||||
logger.removeHandler(get_handler(identifier))
|
||||
except ValueError:
|
||||
pass
|
||||
@@ -6,7 +6,7 @@ import time
|
||||
|
||||
import requests
|
||||
|
||||
from borgmatic.hooks import monitor
|
||||
from borgmatic.hooks.monitoring import monitor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -139,10 +139,10 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
|
||||
for handler in tuple(logging.getLogger().handlers):
|
||||
if isinstance(handler, Loki_log_handler):
|
||||
if state in MONITOR_STATE_TO_LOKI.keys():
|
||||
handler.raw(f'{config_filename}: {MONITOR_STATE_TO_LOKI[state]} backup')
|
||||
handler.raw(f'{MONITOR_STATE_TO_LOKI[state]} backup')
|
||||
|
||||
|
||||
def destroy_monitor(hook_config, config, config_filename, monitoring_log_level, dry_run):
|
||||
def destroy_monitor(hook_config, config, monitoring_log_level, dry_run):
|
||||
'''
|
||||
Remove the monitor handler that was added to the root logger.
|
||||
'''
|
||||
@@ -0,0 +1,10 @@
|
||||
import enum
|
||||
|
||||
IS_A_HOOK = False
|
||||
|
||||
|
||||
class State(enum.Enum):
|
||||
START = 1
|
||||
FINISH = 2
|
||||
FAIL = 3
|
||||
LOG = 4
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user